Compare commits
14 Commits
9e9486b7c2
...
codex/clie
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a574b2c661 | ||
|
|
5d87c9eee7 | ||
|
|
44fe160880 | ||
|
|
f9e090125c | ||
|
|
a01fc93594 | ||
|
|
f69fb46c59 | ||
|
|
873cd6ef53 | ||
|
|
a280774f50 | ||
|
|
142b8b78fa | ||
|
|
6ff9bfc6b5 | ||
|
|
4b1a580132 | ||
|
|
8092c4b1f8 | ||
|
|
24045274ad | ||
|
|
afb3d9f4e6 |
10
.gitignore
vendored
10
.gitignore
vendored
@@ -8,6 +8,12 @@
|
||||
!/qiming-mobile/
|
||||
!/qiming-claude-code-acp-ts/
|
||||
!/qiming-dev-inject/
|
||||
!/qiming-mcp-proxy/
|
||||
!/qiming-noVNC/
|
||||
!/qiming-rcoder/
|
||||
!/qiming-run_code_rmcp/
|
||||
!/qiming-vite-plugin-design-mode/
|
||||
!/qiming-xagi-frontend-templates/
|
||||
!/qimingclaw/
|
||||
!/qimingcode/
|
||||
|
||||
@@ -19,6 +25,8 @@ node_modules/
|
||||
# Build outputs and generated artifacts
|
||||
dist/
|
||||
build/
|
||||
!qiming-file-server/src/utils/build/
|
||||
!qiming-file-server/src/utils/build/**
|
||||
out/
|
||||
target/
|
||||
.umi/
|
||||
@@ -31,6 +39,8 @@ coverage/
|
||||
*.log
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
**/.idea/
|
||||
**/logs/
|
||||
|
||||
# Local environment files
|
||||
.env
|
||||
|
||||
2088
development-environment-startup-manual.md
Normal file
2088
development-environment-startup-manual.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -216,13 +216,6 @@ public class AuthInterceptor implements HandlerInterceptor {
|
||||
requestContext.setLangMap(userDto.getLangMap());
|
||||
requestContext.setLang(userDto.getLang());
|
||||
log.debug("JWT token OK, userId: {} ", userDto.getId());
|
||||
if (originalRequestUri.startsWith("/api/system/")) {
|
||||
//判断是不是管理员
|
||||
if (userDto.getRole() != User.Role.Admin) {
|
||||
throw BizException.of(ErrorCodeEnum.PERMISSION_DENIED, BizExceptionCodeEnum.permissionDenied);
|
||||
}
|
||||
}
|
||||
|
||||
String header = request.getHeader("X-Client-Type");
|
||||
if (header != null || token.startsWith("ticket")) {
|
||||
authService.renewToken(token);
|
||||
@@ -358,4 +351,4 @@ public class AuthInterceptor implements HandlerInterceptor {
|
||||
throw BizException.of(HttpStatusEnum.UNAUTHORIZED, ErrorCodeEnum.UNAUTHORIZED,
|
||||
BizExceptionCodeEnum.systemUnauthorizedOrSessionExpired);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
spring:
|
||||
mvc:
|
||||
pathmatch:
|
||||
matching-strategy: ANT_PATH_MATCHER
|
||||
|
||||
servlet:
|
||||
multipart:
|
||||
max-file-size: 100MB
|
||||
max-request-size: 100MB
|
||||
|
||||
# 使用 dynamic-datasource-spring-boot-starter 配置
|
||||
datasource:
|
||||
# 动态SQL生成器配置
|
||||
sql-generator:
|
||||
# 指定使用的数据库类型:mysql 或 doris
|
||||
type: mysql
|
||||
dynamic:
|
||||
# 设置默认的数据源或者数据源组, 默认值即为 master
|
||||
primary: master
|
||||
# 设置严格模式,默认 false. 严格模式下未匹配到数据源直接报错, 非严格模式下则使用默认数据源 primary 配置的数据源
|
||||
strict: false
|
||||
# p6spy 开关, 默认 false
|
||||
p6spy: false
|
||||
# seata 开关, 默认 false
|
||||
seata: false
|
||||
# 数据源配置信息
|
||||
datasource:
|
||||
# 主数据源 (MySQL)
|
||||
master:
|
||||
url: jdbc:mysql://${DB_HOST:localhost}:3306/${DB_NAME:agent_platform}?serverTimezone=Asia/Shanghai&characterEncoding=utf-8&allowMultiQueries=true&socketTimeout=300000&useSSL=false
|
||||
driver-class-name: com.mysql.cj.jdbc.Driver
|
||||
username: ${DB_USERNAME:root}
|
||||
password: ${DB_PASSWORD:Byyagz@121}
|
||||
# 以下为 Druid 连接池配置 (如果使用)
|
||||
druid:
|
||||
initial-size: 5
|
||||
min-idle: 5
|
||||
max-active: 20
|
||||
max-wait: 60000
|
||||
time-between-eviction-runs-millis: 60000
|
||||
min-evictable-idle-time-millis: 300000
|
||||
validation-query: SELECT 1 FROM DUAL
|
||||
test-while-idle: true
|
||||
test-on-borrow: false
|
||||
test-on-return: false
|
||||
pool-prepared-statements: true
|
||||
max-pool-prepared-statement-per-connection-size: 20
|
||||
filters: stat # 启用 stat filter 用于监控
|
||||
# Doris 数据源
|
||||
doris:
|
||||
url: jdbc:mysql://${DORIS_HOST:localhost}:3306/${DORIS_DB_NAME:agent_platform}?serverTimezone=Asia/Shanghai&characterEncoding=utf-8&allowMultiQueries=true&socketTimeout=300000&useSSL=false
|
||||
driver-class-name: com.mysql.cj.jdbc.Driver
|
||||
username: ${DORIS_USERNAME:root}
|
||||
password: ${DORIS_PASSWORD:Byyagz@121}
|
||||
# Doris 表配置
|
||||
table:
|
||||
replication-num: 1 # 副本数,生产环境通常为3,开发环境可以为1
|
||||
bucket-num: 10 # 分桶数
|
||||
duplicate-key: id # 重复键字段名,默认使用第一个字段
|
||||
distributed-key: id # 分布键字段名,默认使用第一个字段
|
||||
# 可选: 如果 Doris 也用 Druid,可以添加 druid 配置
|
||||
druid:
|
||||
initial-size: 2
|
||||
min-idle: 2
|
||||
max-active: 10
|
||||
max-wait: 60000
|
||||
|
||||
data:
|
||||
redis:
|
||||
host: ${REDIS_HOST:localhost}
|
||||
port: ${REDIS_PORT:6379}
|
||||
password: ${REDIS_PASSWORD:Byyagz@121}
|
||||
database: ${REDIS_DB:1}
|
||||
lettuce:
|
||||
pool:
|
||||
max-idle: 16
|
||||
max-active: 32
|
||||
min-idle: 8
|
||||
|
||||
main:
|
||||
allow-circular-references: true
|
||||
log:
|
||||
level: DEBUG
|
||||
|
||||
server:
|
||||
port: 18081
|
||||
|
||||
jwt:
|
||||
secretKey: ${JWT_SECRET_KEY:qiming-local-dev-jwt-secret-key-32-chars}
|
||||
|
||||
file:
|
||||
uploadFolder: ${FILE_UPLOAD_FOLDER:D:/work/workspace/feitian-base/nvw/.dev/uploads}
|
||||
baseUrl: ${FILE_BASE_URL:http://localhost:18081/files/}
|
||||
|
||||
# Storage configuration
|
||||
storage:
|
||||
type: ${STORAGE_TYPE:local} # Storage type: local, cos(Tencent Cloud), oss(Alibaba Cloud), s3(S3 protocol/MinIO)
|
||||
|
||||
local:
|
||||
upload:
|
||||
folder: ${LOCAL_UPLOAD_FOLDER:D:/work/workspace/feitian-base/nvw/.dev/uploads}
|
||||
|
||||
# Tencent Cloud COS configuration (takes effect when storage.type=cos)
|
||||
cos:
|
||||
secret-id: ${COS_SECRET_ID:}
|
||||
secret-key: ${COS_SECRET_KEY:}
|
||||
region: ${COS_REGION:ap-chengdu}
|
||||
bucket-name: ${COS_BUCKET_NAME:}
|
||||
endpoint: ${COS_ENDPOINT:}
|
||||
# Legacy configuration below
|
||||
secretId: ${COS_SECRET_ID:}
|
||||
secretKey: ${COS_SECRET_KEY:}
|
||||
baseUrl: ${COS_BASE_URL:}
|
||||
|
||||
# Alibaba Cloud OSS configuration (takes effect when storage.type=oss)
|
||||
oss:
|
||||
endpoint: ${OSS_ENDPOINT:}
|
||||
access-key-id: ${OSS_ACCESS_KEY_ID:}
|
||||
access-key-secret: ${OSS_ACCESS_KEY_SECRET:}
|
||||
region-name: ${OSS_REGION_NAME:cn-chengdu}
|
||||
bucket-name: ${OSS_BUCKET_NAME:}
|
||||
|
||||
# S3 protocol configuration (takes effect when storage.type=s3, supports MinIO etc.)
|
||||
s3:
|
||||
endpoint: ${S3_ENDPOINT:}
|
||||
access-key: ${S3_ACCESS_KEY:}
|
||||
secret-key: ${S3_SECRET_KEY:}
|
||||
bucket-name: ${S3_BUCKET_NAME:}
|
||||
region: ${S3_REGION:us-east-1}
|
||||
|
||||
vector-store:
|
||||
engine: milvus
|
||||
|
||||
milvus:
|
||||
uri: ${MILVUS_URI:http://43.153.147.203:19530/}
|
||||
user: ${MILVUS_USER:}
|
||||
password: ${MILVUS_PASSWORD:}
|
||||
|
||||
code:
|
||||
execute:
|
||||
url: ${CODE_EXECUTE_URL:http://localhost:18020/api/run_code_with_log}
|
||||
|
||||
# 日志模块配置
|
||||
log-module:
|
||||
log:
|
||||
rust-service:
|
||||
# Rust 日志服务的基础 URL
|
||||
base-url: ${LOG_SERVICE_URL:http://localhost:8097}
|
||||
# 连接超时时间(秒)
|
||||
connect-timeout-seconds: 10
|
||||
# 写入超时时间(秒)
|
||||
write-timeout-seconds: 30
|
||||
# 读取超时时间(秒)
|
||||
read-timeout-seconds: 30
|
||||
# 是否启用连接池
|
||||
enable-connection-pool: true
|
||||
# 最大空闲连接数
|
||||
max-idle-connections: 5
|
||||
# 连接保持存活时间(分钟)
|
||||
keep-alive-duration-minutes: 5
|
||||
|
||||
search:
|
||||
elasticsearch:
|
||||
url: ${ES_URL:http://43.153.147.203:19200}
|
||||
username: ${ES_USERNAME:}
|
||||
password: ${ES_PASSWORD:}
|
||||
api_key: ${ES_API_KEY:}
|
||||
# 生态市场模块配置
|
||||
eco-market:
|
||||
# 服务端配置
|
||||
server:
|
||||
# 远程server端的 ip端口配置
|
||||
base-url: ${ECO_MARKET_SERVER_URL:https://test-agent-market-api.xspaceagi.com}
|
||||
client:
|
||||
# 客户端配置
|
||||
retry:
|
||||
#自动注册客户端到 server端, 重试间隔时间,单位:秒;
|
||||
interval: 300
|
||||
|
||||
mcp:
|
||||
proxy-base-url: ${MCP_PROXY_URL:http://localhost:18020}
|
||||
|
||||
knife4j:
|
||||
# 开启增强配置
|
||||
enable: true
|
||||
# 标识是否生产环境:true-生产环境关闭文档,false-显示文档
|
||||
production: false
|
||||
|
||||
custom-page:
|
||||
http-proxy:
|
||||
host: ${CUSTOM_PAGE_PROXY_HOST:0.0.0.0}
|
||||
port: ${CUSTOM_PAGE_PROXY_PORT:18082}
|
||||
dev-server-host: ${DEV_SERVER_HOST:http://localhost}
|
||||
prod-server-host: ${PROD_SERVER_HOST:http://localhost:18099}
|
||||
build-server:
|
||||
base-url: ${BUILD_SERVER_URL:http://localhost:16000/api}
|
||||
ai-agent:
|
||||
base-url: ${AI_AGENT_URL:http://43.153.147.203:18087}
|
||||
docker-proxy:
|
||||
enable: ${DOCKER_PROXY_ENABLE:true}
|
||||
base-url: ${DOCKER_PROXY_URL:http://43.153.147.203:18088}
|
||||
|
||||
computer:
|
||||
proxy:
|
||||
host: ${COMPUTER_PROXY_HOST:0.0.0.0}
|
||||
port: ${COMPUTER_PROXY_PORT:18085}
|
||||
|
||||
supportCustomDomain: true
|
||||
|
||||
# 内外穿透相关服务配置
|
||||
reverse:
|
||||
server:
|
||||
# 内部服务器配置
|
||||
inner:
|
||||
# 内部服务可访问地址,如果分布式部署,可以在每台主机上通过环境变量制定 SERVICE_HOST
|
||||
service-host: ${SERVICE_HOST:127.0.0.1}
|
||||
# 绑定主机地址,如果分布式部署,可以在每台主机上通过环境变量制定 BIND_HOST
|
||||
bind-host: ${BIND_HOST:0.0.0.0}
|
||||
# 内部服务器端口范围
|
||||
ports: ${REVERSE_PORTS:30000-40000}
|
||||
# 外部服务器配置
|
||||
outer:
|
||||
# 外部用于与客户端保持连接的服务器主机地址,多个用英文逗号隔开,例如 192.168.1.12,192.168.1.13,留空时使用平台服务器地址
|
||||
host: ${OUTER_HOST:}
|
||||
# 外部服务器端口
|
||||
port: ${OUTER_PORT:11076}
|
||||
|
||||
model-api-proxy:
|
||||
base-api-url: ${MODEL_API_BASE_URL:http://localhost:18086}
|
||||
enable-model-proxy: ${MODEL_PROXY_ENABLE:true}
|
||||
port: ${MODEL_PROXY_PORT:18086}
|
||||
#记录日志
|
||||
save-log: ${MODEL_PROXY_SAVE_LOG:true}
|
||||
@@ -48,7 +48,6 @@
|
||||
<logger name="org.redisson" level="ERROR"/>
|
||||
<logger name="com.alibaba" level="ERROR"/>
|
||||
<logger name="com.xspaceagi.system.domain.service.impl.ScheduleTaskDomainServiceImpl" level="ERROR"/>
|
||||
|
||||
<!-- 缺省应用日志输出 -->
|
||||
<root level="${LOG_LEVEL}">
|
||||
<if condition='"${DEV_MODE}".equals("dev")'>
|
||||
@@ -68,4 +67,4 @@
|
||||
</then>
|
||||
</if>
|
||||
</root>
|
||||
</configuration>
|
||||
</configuration>
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
package com.xspaceagi.sandbox.application.dto;
|
||||
|
||||
import com.xspaceagi.system.infra.dao.entity.User;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
|
||||
@Schema(description = "在线监控聚合信息")
|
||||
@Data
|
||||
public class OnlineMonitorDto {
|
||||
|
||||
@Schema(description = "客户端总数")
|
||||
private long totalClientCount;
|
||||
|
||||
@Schema(description = "在线客户端数")
|
||||
private long onlineClientCount;
|
||||
|
||||
@Schema(description = "离线客户端数")
|
||||
private long offlineClientCount;
|
||||
|
||||
@Schema(description = "已停用客户端数")
|
||||
private long inactiveClientCount;
|
||||
|
||||
@Schema(description = "用户总数")
|
||||
private long totalUserCount;
|
||||
|
||||
@Schema(description = "启用用户数")
|
||||
private long enabledUserCount;
|
||||
|
||||
@Schema(description = "禁用用户数")
|
||||
private long disabledUserCount;
|
||||
|
||||
@Schema(description = "用户组监控信息")
|
||||
private List<GroupMonitorDto> groups;
|
||||
|
||||
@Schema(description = "用户监控信息")
|
||||
private List<UserMonitorDto> users;
|
||||
|
||||
@Schema(description = "客户端监控信息")
|
||||
private List<ClientMonitorDto> clients;
|
||||
|
||||
@Schema(description = "用户组监控信息")
|
||||
@Data
|
||||
public static class GroupMonitorDto {
|
||||
private Long groupId;
|
||||
private String groupName;
|
||||
private Integer authEnabled;
|
||||
private Integer status;
|
||||
private Date expireTime;
|
||||
private Integer maxUserCount;
|
||||
private long currentUserCount;
|
||||
private Integer maxClientCount;
|
||||
private long currentClientCount;
|
||||
private long onlineClientCount;
|
||||
private long offlineClientCount;
|
||||
private long inactiveClientCount;
|
||||
}
|
||||
|
||||
@Schema(description = "用户监控信息")
|
||||
@Data
|
||||
public static class UserMonitorDto {
|
||||
private Long userId;
|
||||
private String userName;
|
||||
private String nickName;
|
||||
private User.Role role;
|
||||
private User.Status status;
|
||||
private List<String> groupNames;
|
||||
private long clientCount;
|
||||
private long onlineClientCount;
|
||||
private long inactiveClientCount;
|
||||
private Date lastLoginTime;
|
||||
}
|
||||
|
||||
@Schema(description = "客户端监控信息")
|
||||
@Data
|
||||
public static class ClientMonitorDto {
|
||||
private Long id;
|
||||
private String name;
|
||||
private Long userId;
|
||||
private String userName;
|
||||
private String nickName;
|
||||
private Long groupId;
|
||||
private String groupName;
|
||||
private Boolean isActive;
|
||||
private boolean online;
|
||||
private Integer maxAgentCount;
|
||||
private Date created;
|
||||
private Date modified;
|
||||
}
|
||||
}
|
||||
@@ -1,18 +1,23 @@
|
||||
package com.xspaceagi.sandbox.ui.web.controller;
|
||||
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.xspaceagi.sandbox.application.dto.OnlineMonitorDto;
|
||||
import com.xspaceagi.sandbox.application.dto.SandboxConfigDto;
|
||||
import com.xspaceagi.sandbox.application.dto.SandboxConfigQueryDto;
|
||||
import com.xspaceagi.sandbox.application.dto.SandboxGlobalConfigDto;
|
||||
import com.xspaceagi.sandbox.application.service.SandboxConfigApplicationService;
|
||||
import com.xspaceagi.system.application.dto.UserDto;
|
||||
import com.xspaceagi.system.application.service.SysGroupApplicationService;
|
||||
import com.xspaceagi.system.application.service.SysRoleApplicationService;
|
||||
import com.xspaceagi.system.infra.dao.entity.SysGroup;
|
||||
import com.xspaceagi.system.infra.dao.entity.User;
|
||||
import com.xspaceagi.system.infra.dao.service.UserService;
|
||||
import com.xspaceagi.system.spec.annotation.RequireResource;
|
||||
import com.xspaceagi.system.spec.common.RequestContext;
|
||||
import com.xspaceagi.system.spec.dto.ReqResult;
|
||||
import com.xspaceagi.system.spec.enums.ErrorCodeEnum;
|
||||
import com.xspaceagi.system.spec.enums.GroupEnum;
|
||||
import com.xspaceagi.system.spec.enums.RoleEnum;
|
||||
import com.xspaceagi.system.spec.exception.BizException;
|
||||
import com.xspaceagi.system.spec.exception.BizExceptionCodeEnum;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
@@ -21,7 +26,12 @@ import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@@ -41,6 +51,12 @@ public class SandboxConfigManageController {
|
||||
@Resource
|
||||
private SysGroupApplicationService sysGroupApplicationService;
|
||||
|
||||
@Resource
|
||||
private SysRoleApplicationService sysRoleApplicationService;
|
||||
|
||||
@Resource
|
||||
private UserService userService;
|
||||
|
||||
@RequireResource(SANDBOX_CONFIG_QUERY)
|
||||
@Operation(summary = "分页查询配置列表")
|
||||
@PostMapping("/page")
|
||||
@@ -148,6 +164,154 @@ public class SandboxConfigManageController {
|
||||
return ReqResult.success(globalConfig);
|
||||
}
|
||||
|
||||
@RequireResource(ONLINE_MONITOR_QUERY)
|
||||
@Operation(summary = "查询在线监控信息")
|
||||
@GetMapping("/online-monitor")
|
||||
public ReqResult<OnlineMonitorDto> onlineMonitor() {
|
||||
boolean platformAdmin = isPlatformAdmin();
|
||||
List<SysGroup> groups = visibleGroups();
|
||||
Set<Long> groupIds = groups.stream().map(SysGroup::getId).collect(Collectors.toSet());
|
||||
|
||||
List<User> users = visibleUsers(platformAdmin, groupIds);
|
||||
Set<Long> userIds = users.stream().map(User::getId).collect(Collectors.toSet());
|
||||
|
||||
SandboxConfigQueryDto queryDto = new SandboxConfigQueryDto();
|
||||
queryDto.setPageNum(1);
|
||||
queryDto.setPageSize(100000);
|
||||
if (!platformAdmin && !groupIds.isEmpty()) {
|
||||
queryDto.setGroupIds(new ArrayList<>(groupIds));
|
||||
} else if (!platformAdmin) {
|
||||
queryDto.setGroupIds(List.of(-1L));
|
||||
}
|
||||
List<SandboxConfigDto> configs = sandboxConfigApplicationService.pageQuery(queryDto).getRecords().stream()
|
||||
.filter(config -> config.getUserId() != null)
|
||||
.filter(config -> platformAdmin || userIds.contains(config.getUserId()))
|
||||
.toList();
|
||||
|
||||
return ReqResult.success(buildOnlineMonitor(groups, users, configs));
|
||||
}
|
||||
|
||||
private OnlineMonitorDto buildOnlineMonitor(List<SysGroup> groups, List<User> users, List<SandboxConfigDto> configs) {
|
||||
Map<Long, SysGroup> groupMap = groups.stream()
|
||||
.filter(group -> group.getId() != null)
|
||||
.collect(Collectors.toMap(SysGroup::getId, group -> group, (a, b) -> a));
|
||||
Map<Long, User> userMap = users.stream()
|
||||
.filter(user -> user.getId() != null)
|
||||
.collect(Collectors.toMap(User::getId, user -> user, (a, b) -> a));
|
||||
Map<Long, List<SysGroup>> userGroupMap = new HashMap<>();
|
||||
groups.forEach(group -> sysGroupApplicationService.getUserListByGroupId(group.getId())
|
||||
.forEach(user -> userGroupMap.computeIfAbsent(user.getId(), key -> new ArrayList<>()).add(group)));
|
||||
|
||||
OnlineMonitorDto dto = new OnlineMonitorDto();
|
||||
dto.setTotalClientCount(configs.size());
|
||||
dto.setOnlineClientCount(configs.stream().filter(config -> Boolean.TRUE.equals(config.getIsActive()) && config.isOnline()).count());
|
||||
dto.setInactiveClientCount(configs.stream().filter(config -> !Boolean.TRUE.equals(config.getIsActive())).count());
|
||||
dto.setOfflineClientCount(configs.stream().filter(config -> Boolean.TRUE.equals(config.getIsActive()) && !config.isOnline()).count());
|
||||
dto.setTotalUserCount(users.size());
|
||||
dto.setEnabledUserCount(users.stream().filter(user -> user.getStatus() == User.Status.Enabled).count());
|
||||
dto.setDisabledUserCount(users.stream().filter(user -> user.getStatus() == User.Status.Disabled).count());
|
||||
|
||||
dto.setGroups(groups.stream()
|
||||
.map(group -> buildGroupMonitor(group, users, configs))
|
||||
.sorted(Comparator.comparing(OnlineMonitorDto.GroupMonitorDto::getGroupId, Comparator.nullsLast(Long::compareTo)))
|
||||
.toList());
|
||||
dto.setUsers(users.stream()
|
||||
.map(user -> buildUserMonitor(user, userGroupMap, configs))
|
||||
.sorted(Comparator.comparing(OnlineMonitorDto.UserMonitorDto::getUserId, Comparator.nullsLast(Long::compareTo)))
|
||||
.toList());
|
||||
dto.setClients(configs.stream()
|
||||
.map(config -> buildClientMonitor(config, userMap, groupMap))
|
||||
.sorted(Comparator.comparing(OnlineMonitorDto.ClientMonitorDto::getId, Comparator.nullsLast(Long::compareTo)))
|
||||
.toList());
|
||||
return dto;
|
||||
}
|
||||
|
||||
private OnlineMonitorDto.GroupMonitorDto buildGroupMonitor(SysGroup group, List<User> users, List<SandboxConfigDto> configs) {
|
||||
Set<Long> groupUserIds = sysGroupApplicationService.getUserListByGroupId(group.getId()).stream()
|
||||
.map(User::getId)
|
||||
.collect(Collectors.toSet());
|
||||
List<SandboxConfigDto> groupConfigs = configs.stream()
|
||||
.filter(config -> Objects.equals(config.getGroupId(), group.getId()))
|
||||
.toList();
|
||||
OnlineMonitorDto.GroupMonitorDto dto = new OnlineMonitorDto.GroupMonitorDto();
|
||||
dto.setGroupId(group.getId());
|
||||
dto.setGroupName(group.getName());
|
||||
dto.setAuthEnabled(group.getAuthEnabled());
|
||||
dto.setStatus(group.getStatus());
|
||||
dto.setExpireTime(group.getExpireTime());
|
||||
dto.setMaxUserCount(group.getMaxUserCount());
|
||||
dto.setCurrentUserCount(users.stream().filter(user -> groupUserIds.contains(user.getId())).count());
|
||||
dto.setMaxClientCount(group.getMaxClientCount());
|
||||
dto.setCurrentClientCount(groupConfigs.size());
|
||||
dto.setOnlineClientCount(groupConfigs.stream().filter(config -> Boolean.TRUE.equals(config.getIsActive()) && config.isOnline()).count());
|
||||
dto.setInactiveClientCount(groupConfigs.stream().filter(config -> !Boolean.TRUE.equals(config.getIsActive())).count());
|
||||
dto.setOfflineClientCount(groupConfigs.stream().filter(config -> Boolean.TRUE.equals(config.getIsActive()) && !config.isOnline()).count());
|
||||
return dto;
|
||||
}
|
||||
|
||||
private OnlineMonitorDto.UserMonitorDto buildUserMonitor(User user, Map<Long, List<SysGroup>> userGroupMap, List<SandboxConfigDto> configs) {
|
||||
List<SandboxConfigDto> userConfigs = configs.stream()
|
||||
.filter(config -> Objects.equals(config.getUserId(), user.getId()))
|
||||
.toList();
|
||||
OnlineMonitorDto.UserMonitorDto dto = new OnlineMonitorDto.UserMonitorDto();
|
||||
dto.setUserId(user.getId());
|
||||
dto.setUserName(user.getUserName());
|
||||
dto.setNickName(user.getNickName());
|
||||
dto.setRole(user.getRole());
|
||||
dto.setStatus(user.getStatus());
|
||||
dto.setGroupNames(userGroupMap.getOrDefault(user.getId(), List.of()).stream().map(SysGroup::getName).toList());
|
||||
dto.setClientCount(userConfigs.size());
|
||||
dto.setOnlineClientCount(userConfigs.stream().filter(config -> Boolean.TRUE.equals(config.getIsActive()) && config.isOnline()).count());
|
||||
dto.setInactiveClientCount(userConfigs.stream().filter(config -> !Boolean.TRUE.equals(config.getIsActive())).count());
|
||||
dto.setLastLoginTime(user.getLastLoginTime());
|
||||
return dto;
|
||||
}
|
||||
|
||||
private OnlineMonitorDto.ClientMonitorDto buildClientMonitor(SandboxConfigDto config, Map<Long, User> userMap, Map<Long, SysGroup> groupMap) {
|
||||
User user = userMap.get(config.getUserId());
|
||||
SysGroup group = groupMap.get(config.getGroupId());
|
||||
OnlineMonitorDto.ClientMonitorDto dto = new OnlineMonitorDto.ClientMonitorDto();
|
||||
dto.setId(config.getId());
|
||||
dto.setName(config.getName());
|
||||
dto.setUserId(config.getUserId());
|
||||
dto.setUserName(user == null ? null : user.getUserName());
|
||||
dto.setNickName(user == null ? null : user.getNickName());
|
||||
dto.setGroupId(config.getGroupId());
|
||||
dto.setGroupName(group == null ? null : group.getName());
|
||||
dto.setIsActive(config.getIsActive());
|
||||
dto.setOnline(config.isOnline());
|
||||
dto.setMaxAgentCount(config.getMaxAgentCount());
|
||||
dto.setCreated(config.getCreated());
|
||||
dto.setModified(config.getModified());
|
||||
return dto;
|
||||
}
|
||||
|
||||
private List<SysGroup> visibleGroups() {
|
||||
if (isPlatformAdmin()) {
|
||||
return sysGroupApplicationService.getGroupList(new SysGroup()).stream()
|
||||
.filter(group -> !GroupEnum.DEFAULT_GROUP.getCode().equals(group.getCode()))
|
||||
.toList();
|
||||
}
|
||||
Set<Long> groupIds = currentUserGroupIds();
|
||||
if (groupIds.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
return sysGroupApplicationService.listGroupsByIds(new ArrayList<>(groupIds));
|
||||
}
|
||||
|
||||
private List<User> visibleUsers(boolean platformAdmin, Set<Long> groupIds) {
|
||||
if (platformAdmin) {
|
||||
return userService.list().stream()
|
||||
.filter(user -> user.getStatus() != User.Status.Deleted)
|
||||
.toList();
|
||||
}
|
||||
return groupIds.stream()
|
||||
.flatMap(groupId -> sysGroupApplicationService.getUserListByGroupId(groupId).stream())
|
||||
.filter(user -> user.getStatus() != User.Status.Deleted)
|
||||
.collect(Collectors.toMap(User::getId, user -> user, (a, b) -> a))
|
||||
.values().stream().toList();
|
||||
}
|
||||
|
||||
private void applySandboxConfigManageScope(SandboxConfigQueryDto queryDto) {
|
||||
if (queryDto == null || isPlatformAdmin()) {
|
||||
return;
|
||||
@@ -172,13 +336,18 @@ public class SandboxConfigManageController {
|
||||
}
|
||||
|
||||
private Set<Long> currentUserGroupIds() {
|
||||
return sysGroupApplicationService.getEffectiveGroupListByUserId(getCurrentUserDto().getId()).stream()
|
||||
return sysGroupApplicationService.getGroupListByUserId(getCurrentUserDto().getId()).stream()
|
||||
.filter(group -> !GroupEnum.DEFAULT_GROUP.getCode().equals(group.getCode()))
|
||||
.map(SysGroup::getId)
|
||||
.collect(Collectors.toSet());
|
||||
}
|
||||
|
||||
private boolean isPlatformAdmin() {
|
||||
return getCurrentUserDto().getRole() == User.Role.Admin;
|
||||
UserDto currentUser = getCurrentUserDto();
|
||||
return currentUser.getRole() == User.Role.Admin
|
||||
&& (currentUserGroupIds().isEmpty()
|
||||
|| sysRoleApplicationService.getRoleListByUserId(currentUser.getId()).stream()
|
||||
.anyMatch(role -> RoleEnum.SUPER_ADMIN.getCode().equals(role.getCode())));
|
||||
}
|
||||
|
||||
private UserDto getCurrentUserDto() {
|
||||
|
||||
@@ -5,6 +5,7 @@ import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.List;
|
||||
|
||||
@Data
|
||||
public class UserQueryDto implements Serializable {
|
||||
@@ -29,4 +30,7 @@ public class UserQueryDto implements Serializable {
|
||||
|
||||
@Schema(description = "角色")
|
||||
private User.Role role;
|
||||
|
||||
@Schema(description = "用户ID范围,后端数据权限过滤使用")
|
||||
private List<Long> userIds;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.xspaceagi.system.application.dto.permission;
|
||||
import java.io.Serializable;
|
||||
import java.util.Date;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
@@ -28,6 +29,7 @@ public class SysGroupAddDto implements Serializable {
|
||||
private Integer authEnabled;
|
||||
|
||||
@Schema(description = "授权到期时间,空表示不过期")
|
||||
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
|
||||
private Date expireTime;
|
||||
|
||||
@Schema(description = "来源,1:系统内置 2:用户自定义", hidden = true)
|
||||
@@ -38,4 +40,4 @@ public class SysGroupAddDto implements Serializable {
|
||||
|
||||
@Schema(description = "排序")
|
||||
private Integer sortIndex;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.xspaceagi.system.application.dto.permission;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
@@ -31,6 +32,7 @@ public class SysGroupUpdateDto implements Serializable {
|
||||
private Integer authEnabled;
|
||||
|
||||
@Schema(description = "授权到期时间,空表示不过期")
|
||||
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
|
||||
private Date expireTime;
|
||||
|
||||
@Schema(description = "来源,1:系统内置 2:用户自定义", hidden = true)
|
||||
@@ -41,4 +43,4 @@ public class SysGroupUpdateDto implements Serializable {
|
||||
|
||||
@Schema(description = "排序")
|
||||
private Integer sortIndex;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -247,6 +247,12 @@ public class UserApplicationServiceImpl implements UserApplicationService {
|
||||
UserQueryDto userQueryDto = pageQueryVo.getQueryFilter();
|
||||
if (userQueryDto != null) {
|
||||
BeanUtils.copyProperties(userQueryDto, user);
|
||||
if (userQueryDto.getUserIds() != null) {
|
||||
if (userQueryDto.getUserIds().isEmpty()) {
|
||||
return new Page<>(pageQueryVo.getPageNo(), pageQueryVo.getPageSize(), 0);
|
||||
}
|
||||
queryWrapper.in(User::getId, userQueryDto.getUserIds());
|
||||
}
|
||||
}
|
||||
if ("".equals(user.getUserName())) {
|
||||
user.setUserName(null);
|
||||
|
||||
@@ -145,13 +145,22 @@
|
||||
"sortIndex": 16,
|
||||
"status": 1
|
||||
},
|
||||
{
|
||||
"code": "online_monitor",
|
||||
"name": "在线监控模块",
|
||||
"description": "在线监控模块",
|
||||
"source": 1,
|
||||
"type": 1,
|
||||
"sortIndex": 17,
|
||||
"status": 1
|
||||
},
|
||||
{
|
||||
"code": "category_config",
|
||||
"name": "分类管理模块",
|
||||
"description": "分类管理模块",
|
||||
"source": 1,
|
||||
"type": 1,
|
||||
"sortIndex": 17,
|
||||
"sortIndex": 18,
|
||||
"status": 1
|
||||
},
|
||||
{
|
||||
@@ -924,6 +933,16 @@
|
||||
"sortIndex": 1,
|
||||
"status": 1
|
||||
},
|
||||
{
|
||||
"code": "online_monitor_query",
|
||||
"name": "查询",
|
||||
"description": "查询在线监控",
|
||||
"source": 1,
|
||||
"type": 2,
|
||||
"parentCode": "online_monitor",
|
||||
"sortIndex": 1,
|
||||
"status": 1
|
||||
},
|
||||
{
|
||||
"code": "subscription_points_query",
|
||||
"name": "查询",
|
||||
@@ -2645,6 +2664,18 @@
|
||||
"sortIndex": 10,
|
||||
"status": 1
|
||||
},
|
||||
{
|
||||
"parentCode": "system_manage",
|
||||
"code": "online_monitor",
|
||||
"name": "在线监控",
|
||||
"description": "在线监控",
|
||||
"source": 1,
|
||||
"path": "/system/online-monitor",
|
||||
"openType": 1,
|
||||
"icon": "",
|
||||
"sortIndex": 11,
|
||||
"status": 1
|
||||
},
|
||||
{
|
||||
"parentCode": "workspace",
|
||||
"code": "member_setting",
|
||||
@@ -2664,7 +2695,7 @@
|
||||
"source": 1,
|
||||
"openType": 1,
|
||||
"icon": "",
|
||||
"sortIndex": 11,
|
||||
"sortIndex": 12,
|
||||
"status": 1
|
||||
},
|
||||
{
|
||||
@@ -2675,7 +2706,7 @@
|
||||
"source": 1,
|
||||
"openType": 1,
|
||||
"icon": "",
|
||||
"sortIndex": 12,
|
||||
"sortIndex": 13,
|
||||
"status": 1
|
||||
},
|
||||
{
|
||||
@@ -3203,6 +3234,11 @@
|
||||
"resourceCode": "sandbox_config",
|
||||
"resourceBindType": 1
|
||||
},
|
||||
{
|
||||
"menuCode": "online_monitor",
|
||||
"resourceCode": "online_monitor",
|
||||
"resourceBindType": 1
|
||||
},
|
||||
{
|
||||
"menuCode": "category_config",
|
||||
"resourceCode": "category_config",
|
||||
@@ -4016,6 +4052,17 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"roleCode": "super_admin",
|
||||
"menuCode": "online_monitor",
|
||||
"menuBindType": 1,
|
||||
"resourceTree": [
|
||||
{
|
||||
"code": "online_monitor",
|
||||
"resourceBindType": 1
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"roleCode": "super_admin",
|
||||
"menuCode": "category_config",
|
||||
@@ -4354,4 +4401,4 @@
|
||||
"pageDailyPromptLimit": -1
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -267,6 +267,11 @@ public enum ResourceEnum {
|
||||
SANDBOX_CONFIG_DELETE(ResourceTypeEnum.OPERATION, "sandbox_config_delete", "删除", "sandbox_config"),
|
||||
// SANDBOX_CONFIG_ENABLE(ResourceTypeEnum.OPERATION, "sandbox_config_enable", "启用/禁用", "sandbox_config"),
|
||||
|
||||
// ================== 在线监控模块 ==================
|
||||
ONLINE_MONITOR(ResourceTypeEnum.MODULE, "online_monitor", "在线监控模块", "root"),
|
||||
|
||||
ONLINE_MONITOR_QUERY(ResourceTypeEnum.OPERATION, "online_monitor_query", "查询", "online_monitor"),
|
||||
|
||||
// ================== 分类管理模块 ==================
|
||||
CATEGORY_CONFIG(ResourceTypeEnum.MODULE, "category_config", "分类管理模块", "root"),
|
||||
|
||||
|
||||
@@ -5,15 +5,22 @@ import com.xspaceagi.system.application.dto.SendNotifyMessageDto;
|
||||
import com.xspaceagi.system.application.dto.UserDto;
|
||||
import com.xspaceagi.system.application.dto.UserQueryDto;
|
||||
import com.xspaceagi.system.application.service.NotifyMessageApplicationService;
|
||||
import com.xspaceagi.system.application.service.SysGroupApplicationService;
|
||||
import com.xspaceagi.system.application.service.SysRoleApplicationService;
|
||||
import com.xspaceagi.system.application.service.UserApplicationService;
|
||||
import com.xspaceagi.system.infra.dao.entity.SysGroup;
|
||||
import com.xspaceagi.system.infra.dao.entity.NotifyMessage;
|
||||
import com.xspaceagi.system.infra.dao.entity.User;
|
||||
import com.xspaceagi.system.infra.dao.mapper.UserMapper;
|
||||
import com.xspaceagi.system.infra.dao.service.UserService;
|
||||
import com.xspaceagi.system.spec.annotation.RequireResource;
|
||||
import com.xspaceagi.system.spec.common.RequestContext;
|
||||
import com.xspaceagi.system.spec.common.UserContext;
|
||||
import com.xspaceagi.system.spec.dto.PageQueryVo;
|
||||
import com.xspaceagi.system.spec.dto.ReqResult;
|
||||
import com.xspaceagi.system.spec.enums.ErrorCodeEnum;
|
||||
import com.xspaceagi.system.spec.enums.GroupEnum;
|
||||
import com.xspaceagi.system.spec.enums.RoleEnum;
|
||||
import com.xspaceagi.system.spec.exception.BizException;
|
||||
import com.xspaceagi.system.spec.exception.BizExceptionCodeEnum;
|
||||
import com.xspaceagi.system.web.dto.UserAddDto;
|
||||
@@ -28,6 +35,7 @@ import org.springframework.beans.BeanUtils;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import static com.xspaceagi.system.spec.enums.ResourceEnum.*;
|
||||
@@ -40,17 +48,35 @@ public class UserManageController {
|
||||
@Resource
|
||||
private UserApplicationService userApplicationService;
|
||||
|
||||
@Resource
|
||||
private SysGroupApplicationService sysGroupApplicationService;
|
||||
|
||||
@Resource
|
||||
private SysRoleApplicationService sysRoleApplicationService;
|
||||
|
||||
@Resource
|
||||
private NotifyMessageApplicationService notifyMessageApplicationService;
|
||||
|
||||
@Resource
|
||||
private UserMapper userMapper;
|
||||
|
||||
@Resource
|
||||
private UserService userService;
|
||||
|
||||
@RequireResource(USER_MANAGE_QUERY)
|
||||
@Operation(summary = "查询用户列表")
|
||||
@RequestMapping(path = "/list", method = RequestMethod.POST)
|
||||
public ReqResult<IPage<UserDto>> listQuery(@RequestBody PageQueryVo<UserQueryDto> pageQueryVo) {
|
||||
checkAdmin();
|
||||
if (pageQueryVo == null) {
|
||||
pageQueryVo = new PageQueryVo<>();
|
||||
}
|
||||
if (pageQueryVo.getPageNo() == null) {
|
||||
pageQueryVo.setPageNo(1L);
|
||||
}
|
||||
if (pageQueryVo.getPageSize() == null) {
|
||||
pageQueryVo.setPageSize(10L);
|
||||
}
|
||||
applyUserManageScope(pageQueryVo);
|
||||
return ReqResult.success(userApplicationService.listQuery(pageQueryVo));
|
||||
}
|
||||
|
||||
@@ -58,8 +84,6 @@ public class UserManageController {
|
||||
@Operation(summary = "获取用户统计")
|
||||
@RequestMapping(path = "/stats", method = RequestMethod.GET)
|
||||
public ReqResult<UserStatsDto> getUserStats() {
|
||||
checkAdmin();
|
||||
|
||||
Long totalUserCount = userMapper.countTotalUsers();
|
||||
Long todayNewUserCount = userMapper.countTodayNewUsers();
|
||||
|
||||
@@ -90,13 +114,17 @@ public class UserManageController {
|
||||
@Operation(summary = "添加用户")
|
||||
@RequestMapping(path = "/add", method = RequestMethod.POST)
|
||||
public ReqResult<Void> addUser(@RequestBody UserAddDto userAddDto) {
|
||||
checkAdmin();
|
||||
UserDto userDto = new UserDto();
|
||||
BeanUtils.copyProperties(userAddDto, userDto);
|
||||
if (StringUtils.isNotBlank(userAddDto.getPassword())) {
|
||||
userDto.setResetPass(1);
|
||||
}
|
||||
userDto.setRole(User.Role.User);
|
||||
userApplicationService.add(userDto);
|
||||
List<Long> groupIds = currentUserGroupIds().stream().toList();
|
||||
if (CollectionUtils.isNotEmpty(groupIds)) {
|
||||
groupIds.forEach(groupId -> sysGroupApplicationService.groupAddUser(groupId, userDto.getId(), getCurrentUserContext()));
|
||||
}
|
||||
return ReqResult.success();
|
||||
}
|
||||
|
||||
@@ -104,7 +132,7 @@ public class UserManageController {
|
||||
@Operation(summary = "更新用户信息")
|
||||
@RequestMapping(path = "/updateById/{id}", method = RequestMethod.POST)
|
||||
public ReqResult<Void> updateUserById(@PathVariable Long id, @RequestBody UserUpdateDto userUpdateDto) {
|
||||
checkAdmin();
|
||||
checkCurrentTenantUser(id);
|
||||
UserDto userUpdate = new UserDto();
|
||||
userUpdate.setId(id);
|
||||
userUpdate.setAvatar(userUpdateDto.getAvatar());
|
||||
@@ -122,7 +150,7 @@ public class UserManageController {
|
||||
@Operation(summary = "禁用用户")
|
||||
@RequestMapping(path = "/disable/{id}", method = RequestMethod.POST)
|
||||
public ReqResult<Void> disableUserById(@PathVariable Long id) {
|
||||
checkAdmin();
|
||||
checkCurrentTenantUser(id);
|
||||
UserDto userUpdate = new UserDto();
|
||||
userUpdate.setId(id);
|
||||
userUpdate.setStatus(User.Status.Disabled);
|
||||
@@ -134,7 +162,7 @@ public class UserManageController {
|
||||
@Operation(summary = "启用用户")
|
||||
@RequestMapping(path = "/enable/{id}", method = RequestMethod.POST)
|
||||
public ReqResult<Void> enableUserById(@PathVariable Long id) {
|
||||
checkAdmin();
|
||||
checkCurrentTenantUser(id);
|
||||
UserDto userUpdate = new UserDto();
|
||||
userUpdate.setId(id);
|
||||
userUpdate.setStatus(User.Status.Enabled);
|
||||
@@ -146,7 +174,7 @@ public class UserManageController {
|
||||
@Operation(summary = "删除用户")
|
||||
@RequestMapping(path = "/delete/{id}", method = RequestMethod.POST)
|
||||
public ReqResult<Void> deleteUserById(@PathVariable Long id) {
|
||||
checkAdmin();
|
||||
checkCurrentTenantUser(id);
|
||||
userApplicationService.logicDelete(id);
|
||||
return ReqResult.success();
|
||||
}
|
||||
@@ -165,12 +193,69 @@ public class UserManageController {
|
||||
return ReqResult.success();
|
||||
}
|
||||
|
||||
/**
|
||||
* 当前简单的在商家范围支持普通用户和管理员两种角色;后续再迭代完整的权限角色
|
||||
*/
|
||||
private void checkAdmin() {
|
||||
if (((UserDto) RequestContext.get().getUser()).getRole() != User.Role.Admin) {
|
||||
private void checkCurrentTenantUser(Long userId) {
|
||||
User user = userId == null ? null : userService.getById(userId);
|
||||
if (user == null || !isCurrentTenantUser(user) || (!isPlatformAdmin() && !visibleUserIds().contains(userId))) {
|
||||
throw BizException.of(ErrorCodeEnum.PERMISSION_DENIED, BizExceptionCodeEnum.permissionDenied);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void applyUserManageScope(PageQueryVo<UserQueryDto> pageQueryVo) {
|
||||
if (isPlatformAdmin()) {
|
||||
return;
|
||||
}
|
||||
if (pageQueryVo.getQueryFilter() == null) {
|
||||
pageQueryVo.setQueryFilter(new UserQueryDto());
|
||||
}
|
||||
pageQueryVo.getQueryFilter().setUserIds(visibleUserIds().stream().toList());
|
||||
}
|
||||
|
||||
private Set<Long> visibleUserIds() {
|
||||
return currentUserGroupIds().stream()
|
||||
.flatMap(groupId -> sysGroupApplicationService.getUserListByGroupId(groupId).stream())
|
||||
.map(User::getId)
|
||||
.collect(Collectors.toSet());
|
||||
}
|
||||
|
||||
private Set<Long> currentUserGroupIds() {
|
||||
return sysGroupApplicationService.getGroupListByUserId(getCurrentUserDto().getId()).stream()
|
||||
.filter(group -> !GroupEnum.DEFAULT_GROUP.getCode().equals(group.getCode()))
|
||||
.map(SysGroup::getId)
|
||||
.collect(Collectors.toSet());
|
||||
}
|
||||
|
||||
private boolean isPlatformAdmin() {
|
||||
UserDto currentUser = getCurrentUserDto();
|
||||
return currentUser.getRole() == User.Role.Admin
|
||||
&& (currentUserGroupIds().isEmpty()
|
||||
|| sysRoleApplicationService.getRoleListByUserId(currentUser.getId()).stream()
|
||||
.anyMatch(role -> RoleEnum.SUPER_ADMIN.getCode().equals(role.getCode())));
|
||||
}
|
||||
|
||||
private boolean isCurrentTenantUser(User user) {
|
||||
return user != null && (getCurrentUserDto().getTenantId() == null || getCurrentUserDto().getTenantId().equals(user.getTenantId()));
|
||||
}
|
||||
|
||||
private UserDto getCurrentUserDto() {
|
||||
UserDto userDto = (UserDto) RequestContext.get().getUser();
|
||||
if (userDto == null) {
|
||||
throw BizException.of(ErrorCodeEnum.UNAUTHORIZED, BizExceptionCodeEnum.systemUserNotLoggedInWeb);
|
||||
}
|
||||
return userDto;
|
||||
}
|
||||
|
||||
private UserContext getCurrentUserContext() {
|
||||
UserDto userDto = getCurrentUserDto();
|
||||
return UserContext.builder()
|
||||
.userId(userDto.getId())
|
||||
.userName(userDto.getUserName())
|
||||
.nickName(userDto.getNickName())
|
||||
.avatar(userDto.getAvatar())
|
||||
.email(userDto.getEmail())
|
||||
.phone(userDto.getPhone())
|
||||
.status(userDto.getStatus() == User.Status.Enabled ? 1 : -1)
|
||||
.tenantId(userDto.getTenantId())
|
||||
.roleType(userDto.getRole() == User.Role.Admin ? 1 : 2)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import com.xspaceagi.system.application.converter.SysDataPermissionConverter;
|
||||
import com.xspaceagi.system.application.dto.permission.*;
|
||||
import com.xspaceagi.system.application.service.SysDataPermissionApplicationService;
|
||||
import com.xspaceagi.system.application.service.SysGroupApplicationService;
|
||||
import com.xspaceagi.system.application.service.SysRoleApplicationService;
|
||||
import com.xspaceagi.system.application.service.SysSubjectPermissionApplicationService;
|
||||
import com.xspaceagi.system.application.dto.UserDto;
|
||||
import com.xspaceagi.system.domain.model.GroupBindMenuModel;
|
||||
@@ -52,6 +53,8 @@ public class SysGroupController extends BaseController {
|
||||
@Resource
|
||||
private SysGroupApplicationService sysGroupApplicationService;
|
||||
@Resource
|
||||
private SysRoleApplicationService sysRoleApplicationService;
|
||||
@Resource
|
||||
private SysDataPermissionApplicationService sysDataPermissionApplicationService;
|
||||
@Resource
|
||||
private SysSubjectPermissionApplicationService sysSubjectPermissionApplicationService;
|
||||
@@ -344,8 +347,8 @@ public class SysGroupController extends BaseController {
|
||||
return ReqResult.error("参数不能为空");
|
||||
}
|
||||
checkGroupManageScope(dto.getGroupId());
|
||||
// 用户组不允许绑定 生态市场/系统管理
|
||||
checkForbiddenMenuCodes(dto.getMenuTree());
|
||||
// 用户组不允许绑定生态市场和系统管理整棵子树。
|
||||
checkForbiddenMenuCodes(dto.getMenuTree(), false);
|
||||
|
||||
GroupBindMenuModel model = GroupBindMenuModelConverter.convertToModel(dto);
|
||||
sysGroupApplicationService.bindMenu(model, getUser());
|
||||
@@ -370,29 +373,34 @@ public class SysGroupController extends BaseController {
|
||||
return ReqResult.success(menuDtoList);
|
||||
}
|
||||
|
||||
private void checkForbiddenMenuCodes(List<MenuNodeDto> nodes) {
|
||||
private void checkForbiddenMenuCodes(List<MenuNodeDto> nodes, boolean underForbiddenMenu) {
|
||||
if (CollectionUtils.isEmpty(nodes)) {
|
||||
return;
|
||||
}
|
||||
for (MenuNodeDto node : nodes) {
|
||||
boolean currentForbidden = underForbiddenMenu;
|
||||
if (StringUtils.isNotBlank(node.getCode())) {
|
||||
if (node.getCode().equals(MenuEnum.ECO_MARKET.getCode()) && node.getMenuBindType() > 0) {
|
||||
throw BizException.of(ErrorCodeEnum.INVALID_PARAM, BizExceptionCodeEnum.systemGroupCannotBindForbiddenMenu,
|
||||
MenuEnum.ECO_MARKET.getName());
|
||||
}
|
||||
if (node.getCode().equals(MenuEnum.SYSTEM_MANAGE.getCode()) && node.getMenuBindType() > 0) {
|
||||
currentForbidden = currentForbidden || node.getCode().equals(MenuEnum.SYSTEM_MANAGE.getCode());
|
||||
if (currentForbidden && node.getMenuBindType() > 0) {
|
||||
throw BizException.of(ErrorCodeEnum.INVALID_PARAM, BizExceptionCodeEnum.systemGroupCannotBindForbiddenMenu,
|
||||
MenuEnum.SYSTEM_MANAGE.getName());
|
||||
}
|
||||
}
|
||||
if (CollectionUtils.isNotEmpty(node.getChildren())) {
|
||||
checkForbiddenMenuCodes(node.getChildren());
|
||||
checkForbiddenMenuCodes(node.getChildren(), currentForbidden);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private List<SysGroup> filterGroupManageScope(List<SysGroup> groupList) {
|
||||
if (isPlatformAdmin() || CollectionUtils.isEmpty(groupList)) {
|
||||
if (CollectionUtils.isEmpty(groupList)) {
|
||||
return groupList;
|
||||
}
|
||||
if (isPlatformAdmin()) {
|
||||
return groupList;
|
||||
}
|
||||
Set<Long> allowedGroupIds = currentUserGroupIds();
|
||||
@@ -411,13 +419,18 @@ public class SysGroupController extends BaseController {
|
||||
}
|
||||
|
||||
private Set<Long> currentUserGroupIds() {
|
||||
return sysGroupApplicationService.getEffectiveGroupListByUserId(getCurrentUserDto().getId()).stream()
|
||||
return sysGroupApplicationService.getGroupListByUserId(getCurrentUserDto().getId()).stream()
|
||||
.filter(group -> !GroupEnum.DEFAULT_GROUP.getCode().equals(group.getCode()))
|
||||
.map(SysGroup::getId)
|
||||
.collect(Collectors.toSet());
|
||||
}
|
||||
|
||||
private boolean isPlatformAdmin() {
|
||||
return getCurrentUserDto().getRole() == User.Role.Admin;
|
||||
UserDto currentUser = getCurrentUserDto();
|
||||
return currentUser.getRole() == User.Role.Admin
|
||||
&& (currentUserGroupIds().isEmpty()
|
||||
|| sysRoleApplicationService.getRoleListByUserId(currentUser.getId()).stream()
|
||||
.anyMatch(role -> RoleEnum.SUPER_ADMIN.getCode().equals(role.getCode())));
|
||||
}
|
||||
|
||||
private UserDto getCurrentUserDto() {
|
||||
|
||||
@@ -5,6 +5,7 @@ import com.xspaceagi.system.application.dto.permission.*;
|
||||
import com.xspaceagi.system.application.service.SysDataPermissionApplicationService;
|
||||
import com.xspaceagi.system.application.service.SysGroupApplicationService;
|
||||
import com.xspaceagi.system.application.service.SysRoleApplicationService;
|
||||
import com.xspaceagi.system.application.service.UserApplicationService;
|
||||
import com.xspaceagi.system.application.service.impl.SysUserPermissionCacheServiceImpl;
|
||||
import com.xspaceagi.system.infra.dao.entity.SysGroup;
|
||||
import com.xspaceagi.system.infra.dao.entity.SysRole;
|
||||
@@ -15,6 +16,8 @@ import com.xspaceagi.system.spec.annotation.SaasAdmin;
|
||||
import com.xspaceagi.system.spec.common.RequestContext;
|
||||
import com.xspaceagi.system.spec.dto.ReqResult;
|
||||
import com.xspaceagi.system.spec.enums.ErrorCodeEnum;
|
||||
import com.xspaceagi.system.spec.enums.GroupEnum;
|
||||
import com.xspaceagi.system.spec.enums.RoleEnum;
|
||||
import com.xspaceagi.system.spec.exception.BizException;
|
||||
import com.xspaceagi.system.spec.exception.BizExceptionCodeEnum;
|
||||
import com.xspaceagi.system.spec.utils.I18nUtil;
|
||||
@@ -28,6 +31,7 @@ import org.springframework.beans.BeanUtils;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@@ -47,6 +51,8 @@ public class SysUserController extends BaseController {
|
||||
private SysDataPermissionApplicationService sysDataPermissionApplicationService;
|
||||
@Resource
|
||||
private SysUserPermissionCacheServiceImpl sysUserPermissionCacheService;
|
||||
@Resource
|
||||
private UserApplicationService userApplicationService;
|
||||
|
||||
@RequireResource(USER_MANAGE_BIND_ROLE)
|
||||
@Operation(summary = "查询用户绑定的角色列表")
|
||||
@@ -56,6 +62,7 @@ public class SysUserController extends BaseController {
|
||||
return ReqResult.error("参数不能为空");
|
||||
}
|
||||
checkUserManageScope(userId);
|
||||
checkRoleAuthorizationTarget(userId);
|
||||
|
||||
List<SysRole> roleList = sysRoleApplicationService.getRoleListByUserId(userId);
|
||||
if (CollectionUtils.isEmpty(roleList)) {
|
||||
@@ -79,6 +86,7 @@ public class SysUserController extends BaseController {
|
||||
return ReqResult.error("参数不能为空");
|
||||
}
|
||||
checkUserManageScope(dto.getUserId());
|
||||
checkRoleAuthorizationTarget(dto.getUserId());
|
||||
checkRoleBindScope(dto.getRoleIds());
|
||||
sysRoleApplicationService.userBindRole(dto.getUserId(), dto.getRoleIds(), getUser());
|
||||
return ReqResult.success();
|
||||
@@ -146,11 +154,16 @@ public class SysUserController extends BaseController {
|
||||
}
|
||||
|
||||
private void checkUserManageScope(Long userId) {
|
||||
UserDto targetUser = userApplicationService.queryById(userId);
|
||||
if (targetUser == null || !Objects.equals(targetUser.getTenantId(), RequestContext.get().getTenantId())) {
|
||||
throw BizException.of(ErrorCodeEnum.PERMISSION_DENIED, BizExceptionCodeEnum.permissionDenied);
|
||||
}
|
||||
if (isPlatformAdmin()) {
|
||||
return;
|
||||
}
|
||||
|
||||
Set<Long> allowedGroupIds = currentUserGroupIds();
|
||||
boolean inScope = sysGroupApplicationService.getEffectiveGroupListByUserId(userId).stream()
|
||||
boolean inScope = sysGroupApplicationService.getGroupListByUserId(userId).stream()
|
||||
.map(SysGroup::getId)
|
||||
.anyMatch(allowedGroupIds::contains);
|
||||
if (!inScope) {
|
||||
@@ -159,7 +172,10 @@ public class SysUserController extends BaseController {
|
||||
}
|
||||
|
||||
private void checkGroupBindScope(List<Long> groupIds) {
|
||||
if (isPlatformAdmin() || CollectionUtils.isEmpty(groupIds)) {
|
||||
if (CollectionUtils.isEmpty(groupIds)) {
|
||||
return;
|
||||
}
|
||||
if (isPlatformAdmin()) {
|
||||
return;
|
||||
}
|
||||
Set<Long> allowedGroupIds = currentUserGroupIds();
|
||||
@@ -170,7 +186,10 @@ public class SysUserController extends BaseController {
|
||||
}
|
||||
|
||||
private void checkRoleBindScope(List<Long> roleIds) {
|
||||
if (isPlatformAdmin() || CollectionUtils.isEmpty(roleIds)) {
|
||||
if (CollectionUtils.isEmpty(roleIds)) {
|
||||
return;
|
||||
}
|
||||
if (isPlatformAdmin()) {
|
||||
return;
|
||||
}
|
||||
Set<Long> allowedRoleIds = sysRoleApplicationService.getRoleListByUserId(getCurrentUserDto().getId()).stream()
|
||||
@@ -182,14 +201,26 @@ public class SysUserController extends BaseController {
|
||||
}
|
||||
}
|
||||
|
||||
private void checkRoleAuthorizationTarget(Long userId) {
|
||||
UserDto targetUser = userApplicationService.queryById(userId);
|
||||
if (targetUser == null || targetUser.getRole() != User.Role.Admin) {
|
||||
throw BizException.of(ErrorCodeEnum.PERMISSION_DENIED, BizExceptionCodeEnum.permissionDenied);
|
||||
}
|
||||
}
|
||||
|
||||
private Set<Long> currentUserGroupIds() {
|
||||
return sysGroupApplicationService.getEffectiveGroupListByUserId(getCurrentUserDto().getId()).stream()
|
||||
return sysGroupApplicationService.getGroupListByUserId(getCurrentUserDto().getId()).stream()
|
||||
.filter(group -> !GroupEnum.DEFAULT_GROUP.getCode().equals(group.getCode()))
|
||||
.map(SysGroup::getId)
|
||||
.collect(Collectors.toSet());
|
||||
}
|
||||
|
||||
private boolean isPlatformAdmin() {
|
||||
return getCurrentUserDto().getRole() == User.Role.Admin;
|
||||
UserDto currentUser = getCurrentUserDto();
|
||||
return currentUser.getRole() == User.Role.Admin
|
||||
&& (currentUserGroupIds().isEmpty()
|
||||
|| sysRoleApplicationService.getRoleListByUserId(currentUser.getId()).stream()
|
||||
.anyMatch(role -> RoleEnum.SUPER_ADMIN.getCode().equals(role.getCode())));
|
||||
}
|
||||
|
||||
private UserDto getCurrentUserDto() {
|
||||
|
||||
74
qiming-backend/sql/update-20260602.sql
Normal file
74
qiming-backend/sql/update-20260602.sql
Normal file
@@ -0,0 +1,74 @@
|
||||
-- Add system online monitor menu and resource.
|
||||
-- Idempotent for existing tenants: creates menu/resource rows, binds menu to resource,
|
||||
-- and grants super_admin default access. User-group admins can be granted this menu
|
||||
-- from the database menu permission management UI.
|
||||
|
||||
INSERT INTO sys_resource (`parent_id`, `code`, `name`, `description`, `source`, `type`, `sort_index`, `status`, `_tenant_id`, `creator_id`, `creator`, `yn`)
|
||||
SELECT 0, 'online_monitor', '在线监控模块', '在线监控模块', 1, 1, 17, 1, t._tenant_id, 0, 'system', 1
|
||||
FROM (SELECT DISTINCT `_tenant_id` FROM sys_resource) t
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM sys_resource r
|
||||
WHERE r.`_tenant_id` = t.`_tenant_id` AND r.`code` = 'online_monitor' AND r.`yn` = 1
|
||||
);
|
||||
|
||||
INSERT INTO sys_resource (`parent_id`, `code`, `name`, `description`, `source`, `type`, `sort_index`, `status`, `_tenant_id`, `creator_id`, `creator`, `yn`)
|
||||
SELECT parent.id, 'online_monitor_query', '查询', '查询在线监控', 1, 2, 1, 1, parent.`_tenant_id`, 0, 'system', 1
|
||||
FROM sys_resource parent
|
||||
WHERE parent.`code` = 'online_monitor' AND parent.`yn` = 1
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM sys_resource r
|
||||
WHERE r.`_tenant_id` = parent.`_tenant_id` AND r.`code` = 'online_monitor_query' AND r.`yn` = 1
|
||||
);
|
||||
|
||||
INSERT INTO sys_menu (`parent_id`, `code`, `name`, `description`, `source`, `path`, `open_type`, `icon`, `sort_index`, `status`, `_tenant_id`, `creator_id`, `creator`, `yn`)
|
||||
SELECT parent.id, 'online_monitor', '在线监控', '在线监控', 1, '/system/online-monitor', 1, '', 11, 1, parent.`_tenant_id`, 0, 'system', 1
|
||||
FROM sys_menu parent
|
||||
WHERE parent.`code` = 'system_manage' AND parent.`yn` = 1
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM sys_menu m
|
||||
WHERE m.`_tenant_id` = parent.`_tenant_id` AND m.`code` = 'online_monitor' AND m.`yn` = 1
|
||||
);
|
||||
|
||||
INSERT INTO sys_menu_resource (`menu_id`, `resource_id`, `resource_bind_type`, `_tenant_id`, `creator_id`, `creator`, `yn`)
|
||||
SELECT m.id, r.id, 1, m.`_tenant_id`, 0, 'system', 1
|
||||
FROM sys_menu m
|
||||
JOIN sys_resource r ON r.`_tenant_id` = m.`_tenant_id` AND r.`code` = 'online_monitor' AND r.`yn` = 1
|
||||
WHERE m.`code` = 'online_monitor' AND m.`yn` = 1
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM sys_menu_resource mr
|
||||
WHERE mr.`_tenant_id` = m.`_tenant_id` AND mr.`menu_id` = m.id AND mr.`resource_id` = r.id AND mr.`yn` = 1
|
||||
);
|
||||
|
||||
UPDATE sys_menu_resource mr
|
||||
JOIN sys_menu m ON m.id = mr.menu_id AND m.`_tenant_id` = mr.`_tenant_id` AND m.`code` = 'online_monitor' AND m.`yn` = 1
|
||||
JOIN sys_resource r ON r.id = mr.resource_id AND r.`_tenant_id` = mr.`_tenant_id` AND r.`code` = 'online_monitor' AND r.`yn` = 1
|
||||
SET mr.`resource_bind_type` = 1,
|
||||
mr.`modified` = CURRENT_TIMESTAMP
|
||||
WHERE mr.`yn` = 1;
|
||||
|
||||
INSERT INTO sys_role_menu (`role_id`, `menu_id`, `menu_bind_type`, `resource_tree_json`, `_tenant_id`, `creator_id`, `creator`, `yn`)
|
||||
SELECT role.id,
|
||||
menu.id,
|
||||
1,
|
||||
JSON_ARRAY(JSON_OBJECT('id', resource.id, 'code', resource.`code`, 'resourceBindType', 1)),
|
||||
menu.`_tenant_id`,
|
||||
0,
|
||||
'system',
|
||||
1
|
||||
FROM sys_role role
|
||||
JOIN sys_menu menu ON menu.`_tenant_id` = role.`_tenant_id` AND menu.`code` = 'online_monitor' AND menu.`yn` = 1
|
||||
JOIN sys_resource resource ON resource.`_tenant_id` = role.`_tenant_id` AND resource.`code` = 'online_monitor' AND resource.`yn` = 1
|
||||
WHERE role.`code` = 'super_admin' AND role.`yn` = 1
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM sys_role_menu rm
|
||||
WHERE rm.`_tenant_id` = role.`_tenant_id` AND rm.`role_id` = role.id AND rm.`menu_id` = menu.id AND rm.`yn` = 1
|
||||
);
|
||||
|
||||
UPDATE sys_role_menu rm
|
||||
JOIN sys_role role ON role.id = rm.role_id AND role.`_tenant_id` = rm.`_tenant_id` AND role.`code` = 'super_admin' AND role.`yn` = 1
|
||||
JOIN sys_menu menu ON menu.id = rm.menu_id AND menu.`_tenant_id` = rm.`_tenant_id` AND menu.`code` = 'online_monitor' AND menu.`yn` = 1
|
||||
JOIN sys_resource resource ON resource.`_tenant_id` = rm.`_tenant_id` AND resource.`code` = 'online_monitor' AND resource.`yn` = 1
|
||||
SET rm.`menu_bind_type` = 1,
|
||||
rm.`resource_tree_json` = JSON_ARRAY(JSON_OBJECT('id', resource.id, 'code', resource.`code`, 'resourceBindType', 1)),
|
||||
rm.`modified` = CURRENT_TIMESTAMP
|
||||
WHERE rm.`yn` = 1;
|
||||
276
qiming-file-server/src/utils/build/buildProjectUtils.js
Normal file
276
qiming-file-server/src/utils/build/buildProjectUtils.js
Normal file
@@ -0,0 +1,276 @@
|
||||
import { exec } from "child_process";
|
||||
import path from "path";
|
||||
import fs from "fs";
|
||||
import { log, logBuild } from "../log/logUtils.js";
|
||||
import BuildErrorParser from "../error/buildErrorParser.js";
|
||||
import config from "../../appConfig/index.js";
|
||||
import {
|
||||
BusinessError,
|
||||
SystemError,
|
||||
FileError,
|
||||
ResourceError,
|
||||
} from "../error/errorHandler.js";
|
||||
import { installDependencies } from "../buildDependency/dependencyManager.js";
|
||||
import {
|
||||
extractIsolationContext,
|
||||
resolveProjectPath,
|
||||
} from "../common/projectPathUtils.js";
|
||||
|
||||
const distTargetDir = config.DIST_TARGET_DIR;
|
||||
|
||||
// 构建并发控制(无队列)
|
||||
const buildingProjects = new Set(); // 正在构建中的项目
|
||||
let currentBuilds = 0; // 当前并行构建数
|
||||
|
||||
// 将dist目录拷贝到指定目录
|
||||
async function copyBuildOutputToTarget({
|
||||
req,
|
||||
projectPath,
|
||||
projectId,
|
||||
outStream,
|
||||
}) {
|
||||
try {
|
||||
const sourceDir = path.join(projectPath, "dist");
|
||||
const targetBase = distTargetDir;
|
||||
const targetDir = path.join(targetBase, projectId, "dist");
|
||||
|
||||
if (!fs.existsSync(sourceDir)) {
|
||||
const msg = `dist directory not found: ${sourceDir}`;
|
||||
log(projectId, "WARN", msg, { projectId });
|
||||
outStream && outStream.write(`${msg}\n`);
|
||||
return;
|
||||
}
|
||||
|
||||
// 确保目标父目录存在
|
||||
if (!fs.existsSync(targetBase)) {
|
||||
fs.mkdirSync(targetBase, { recursive: true });
|
||||
}
|
||||
|
||||
// 先清空旧目录
|
||||
if (fs.existsSync(targetDir)) {
|
||||
await fs.promises.rm(targetDir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
// 复制 dist -> 目标目录
|
||||
if (fs.promises.cp) {
|
||||
await fs.promises.cp(sourceDir, targetDir, { recursive: true });
|
||||
} else {
|
||||
// Node 版本不支持 fs.promises.cp 时的降级方案
|
||||
const copyRecursiveSync = (src, dest) => {
|
||||
const stat = fs.statSync(src);
|
||||
if (stat.isDirectory()) {
|
||||
if (!fs.existsSync(dest)) {
|
||||
fs.mkdirSync(dest, { recursive: true });
|
||||
}
|
||||
for (const entry of fs.readdirSync(src)) {
|
||||
copyRecursiveSync(path.join(src, entry), path.join(dest, entry));
|
||||
}
|
||||
} else {
|
||||
fs.copyFileSync(src, dest);
|
||||
}
|
||||
};
|
||||
copyRecursiveSync(sourceDir, targetDir);
|
||||
}
|
||||
|
||||
const okMsg = `dist directory copied to: ${targetDir}`;
|
||||
log(projectId, "INFO", okMsg, { projectId });
|
||||
outStream && outStream.write(`${okMsg}\n`);
|
||||
} catch (err) {
|
||||
const errMsg = `Failed to copy dist directory: ${err.message}`;
|
||||
log(projectId, "ERROR", errMsg, { projectId });
|
||||
outStream && outStream.write(`${errMsg}\n`);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
//执行build脚本
|
||||
function runBuildScript(projectId, projectPath, scriptName, extraArgs = []) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const argsPart =
|
||||
Array.isArray(extraArgs) && extraArgs.length > 0
|
||||
? " -- " + extraArgs.map((s) => String(s)).join(" ")
|
||||
: "";
|
||||
const command = `cd ${projectPath} && pnpm run ${scriptName}${argsPart}`;
|
||||
logBuild(projectId, "INFO", "Execute command", { command });
|
||||
// 同步输出到普通日志也打印一次,便于从统一日志流检索
|
||||
try {
|
||||
log(projectId, "INFO", "Execute build script", { command, cwd: projectPath });
|
||||
} catch (_) {}
|
||||
|
||||
exec(
|
||||
command,
|
||||
{
|
||||
env: process.env, // 继承父进程的环境变量,包括 pnpm 配置
|
||||
maxBuffer: 10 * 1024 * 1024, // 10MB 缓冲区
|
||||
},
|
||||
(error, stdout, stderr) => {
|
||||
if (error) {
|
||||
logBuild(projectId, "ERROR", "Execution error", {
|
||||
error: error.message,
|
||||
stderr,
|
||||
});
|
||||
|
||||
// 使用错误解析器提供用户友好的错误信息
|
||||
const errorParser = new BuildErrorParser();
|
||||
const errorMessage = stderr || error.message;
|
||||
const userFriendlyMessage = errorParser.parseBuildError(
|
||||
errorMessage,
|
||||
projectId
|
||||
);
|
||||
|
||||
// 创建包含用户友好信息的构建错误
|
||||
const buildError = new SystemError(userFriendlyMessage, {
|
||||
originalError: error.message,
|
||||
command: command,
|
||||
});
|
||||
|
||||
return reject(buildError);
|
||||
}
|
||||
logBuild(projectId, "INFO", "Script execution completed", { stdout });
|
||||
resolve(stdout);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建项目
|
||||
* @param {Object} req 请求对象
|
||||
* @param {string} projectId 项目ID
|
||||
* @returns {Promise<Object>} 构建结果
|
||||
*/
|
||||
async function buildProject(req, projectId) {
|
||||
const isolationContext = extractIsolationContext(req?.query || {});
|
||||
const projectPath = resolveProjectPath(projectId, isolationContext);
|
||||
const jsonFilePath = path.join(projectPath, "package.json");
|
||||
|
||||
const exists = fs.existsSync(jsonFilePath);
|
||||
if (!exists) {
|
||||
throw new ResourceError("Project missing package.json file", {
|
||||
projectId,
|
||||
projectPath,
|
||||
});
|
||||
}
|
||||
|
||||
let jsonContent;
|
||||
try {
|
||||
jsonContent = JSON.parse(fs.readFileSync(jsonFilePath, "utf8"));
|
||||
} catch (error) {
|
||||
throw new FileError("package.json file format error", {
|
||||
projectId,
|
||||
jsonFilePath,
|
||||
originalError: error.message,
|
||||
});
|
||||
}
|
||||
|
||||
const jsonScripts = jsonContent.scripts;
|
||||
const buildScript = jsonScripts.build;
|
||||
if (!buildScript) {
|
||||
log(projectId, "WARN", "Project missing build script", {
|
||||
projectId,
|
||||
requestId: req.requestId,
|
||||
});
|
||||
throw new BusinessError("Project missing build script", { projectId });
|
||||
}
|
||||
|
||||
// 项目级互斥:同一项目仅允许一个构建
|
||||
if (buildingProjects.has(projectId)) {
|
||||
throw new BusinessError("This project is being built", { projectId });
|
||||
}
|
||||
|
||||
// 全局并发限制
|
||||
const max = Number.isFinite(config.MAX_BUILD_CONCURRENCY)
|
||||
? config.MAX_BUILD_CONCURRENCY
|
||||
: 20;
|
||||
if (currentBuilds >= max) {
|
||||
throw new BusinessError("Concurrency is full, please try again later", {
|
||||
currentBuilds,
|
||||
maxConcurrency: max,
|
||||
});
|
||||
}
|
||||
|
||||
// 直接执行同步构建(空闲)
|
||||
buildingProjects.add(projectId);
|
||||
currentBuilds += 1;
|
||||
try {
|
||||
// 读取并规范化 basePath(仅对 Vite 有效)
|
||||
let basePath = "";
|
||||
if (req && req.query && typeof req.query.basePath === "string") {
|
||||
basePath = req.query.basePath;
|
||||
}
|
||||
if (basePath) {
|
||||
if (!basePath.startsWith("/")) basePath = "/" + basePath;
|
||||
if (!basePath.endsWith("/")) basePath = basePath + "/";
|
||||
}
|
||||
|
||||
const buildExtraArgs = [];
|
||||
// 若脚本包含 vite,则传入 --base
|
||||
if (
|
||||
typeof buildScript === "string" &&
|
||||
buildScript.includes("vite") &&
|
||||
basePath
|
||||
) {
|
||||
buildExtraArgs.push("--base", basePath);
|
||||
}
|
||||
|
||||
// 若使用 Vite 构建,追加 --debug 以输出调试信息
|
||||
if (typeof buildScript === "string" && buildScript.includes("vite")) {
|
||||
buildExtraArgs.push("--debug");
|
||||
}
|
||||
|
||||
// 安装依赖
|
||||
log(projectId, "INFO", "Start installing dependencies", { projectId });
|
||||
await installDependencies(req, projectId, projectPath);
|
||||
|
||||
// 执行构建:Vite 直接使用 pnpm exec,避免 npm-run 参数分隔影响
|
||||
if (typeof buildScript === "string" && buildScript.includes("vite")) {
|
||||
const viteArgs = ["exec", "vite", "build", ...buildExtraArgs, "--debug"];
|
||||
const command = `cd ${projectPath} && pnpm ${viteArgs.join(" ")}`;
|
||||
logBuild(projectId, "INFO", "Execute command(direct vite)", { command });
|
||||
try {
|
||||
log(projectId, "INFO", "Execute build script(direct vite)", {
|
||||
command,
|
||||
cwd: projectPath,
|
||||
});
|
||||
} catch (_) {}
|
||||
await new Promise((resolve, reject) => {
|
||||
exec(command, (error, stdout, stderr) => {
|
||||
if (error) {
|
||||
logBuild(projectId, "ERROR", "Execution error", {
|
||||
error: error.message,
|
||||
stderr,
|
||||
});
|
||||
const errorParser = new BuildErrorParser();
|
||||
const errorMessage = stderr || error.message;
|
||||
const userFriendlyMessage = errorParser.parseBuildError(
|
||||
errorMessage,
|
||||
projectId
|
||||
);
|
||||
const buildError = new SystemError(userFriendlyMessage, {
|
||||
originalError: error.message,
|
||||
command,
|
||||
});
|
||||
return reject(buildError);
|
||||
}
|
||||
logBuild(projectId, "INFO", "Script execution completed", { stdout });
|
||||
resolve(stdout);
|
||||
});
|
||||
});
|
||||
} else {
|
||||
log(projectId, "INFO", "Start synchronously executing build script", { projectId });
|
||||
await runBuildScript(projectId, projectPath, "build", buildExtraArgs);
|
||||
}
|
||||
|
||||
// 拷贝 dist
|
||||
await copyBuildOutputToTarget({ req, projectPath, projectId });
|
||||
return {
|
||||
success: true,
|
||||
message: "Build completed",
|
||||
projectId,
|
||||
};
|
||||
} finally {
|
||||
buildingProjects.delete(projectId);
|
||||
currentBuilds -= 1;
|
||||
}
|
||||
}
|
||||
|
||||
export { buildProject, copyBuildOutputToTarget, runBuildScript };
|
||||
71
qiming-file-server/src/utils/build/keepAliveDevUtils.js
Normal file
71
qiming-file-server/src/utils/build/keepAliveDevUtils.js
Normal file
@@ -0,0 +1,71 @@
|
||||
import { isPortListening } from "../buildArg/portUtils.js";
|
||||
import { log } from "../log/logUtils.js";
|
||||
import { deleteRunningProcess, isProcessRunning } from "./processManager.js";
|
||||
import { isProjectAlive } from "../buildJudge/aliveJudgeUtils.js";
|
||||
import { restartDevServer } from "./restartDevUtils.js";
|
||||
import { startDevServer } from "./startDevUtils.js";
|
||||
import { stopDevServer } from "./stopDevUtils.js";
|
||||
|
||||
/**
|
||||
* 保持开发服务器活跃
|
||||
* @param {Object} req 请求对象
|
||||
* @param {string} projectId 项目ID
|
||||
* @param {number|string} pid 进程ID
|
||||
* @param {number|string} port 端口号
|
||||
* @returns {Promise<Object>} 检查结果
|
||||
*/
|
||||
async function keepAliveDevServer(req, projectId, pid, port, basePath) {
|
||||
const pidNum = Number(pid);
|
||||
const portNum = Number(port);
|
||||
|
||||
log(projectId, "INFO", "Start checking development server status", {
|
||||
projectId,
|
||||
pid: pidNum,
|
||||
port: portNum,
|
||||
requestId: req.requestId,
|
||||
});
|
||||
|
||||
// 检查项目是否存活
|
||||
const projectAlive = await isProjectAlive(projectId, portNum, basePath);
|
||||
if (projectAlive) {
|
||||
log(projectId, "INFO", "Development server is alive, returning success", {
|
||||
projectId,
|
||||
pid: pidNum,
|
||||
port: portNum,
|
||||
requestId: req.requestId,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "Development server is alive",
|
||||
projectId,
|
||||
pid: pidNum,
|
||||
port: portNum,
|
||||
};
|
||||
}
|
||||
|
||||
log(projectId, "INFO", "Development server is not alive, restarting", {
|
||||
projectId,
|
||||
pid: pidNum,
|
||||
port: portNum,
|
||||
requestId: req.requestId,
|
||||
});
|
||||
|
||||
deleteRunningProcess(projectId);
|
||||
|
||||
if(pidNum > 0) {
|
||||
await stopDevServer(req, projectId, pidNum, {
|
||||
strict: false,
|
||||
waitForStop: true,
|
||||
});
|
||||
}
|
||||
|
||||
const result = await startDevServer(req, projectId);
|
||||
return {
|
||||
...result,
|
||||
action: "start",
|
||||
};
|
||||
|
||||
}
|
||||
|
||||
export { keepAliveDevServer };
|
||||
920
qiming-file-server/src/utils/build/processManager.js
Normal file
920
qiming-file-server/src/utils/build/processManager.js
Normal file
@@ -0,0 +1,920 @@
|
||||
import { spawn } from "child_process";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { log, getLogDir, getCSTDateString, getCSTTimestampString } from "../log/logUtils.js";
|
||||
import logCacheManager from "../log/logCacheManager.js";
|
||||
import { ProcessError, BusinessError, ValidationError } from "../error/errorHandler.js";
|
||||
import ERROR_CODES from "../error/errorCodes.js";
|
||||
import { sanitizeSensitivePaths } from "../common/sensitiveUtils.js";
|
||||
import config from "../../appConfig/index.js";
|
||||
import {
|
||||
waitPortListening,
|
||||
waitPortFromPid,
|
||||
waitPortFromLog,
|
||||
getPidsByPort,
|
||||
} from "../buildArg/portUtils.js";
|
||||
import ExtraArgsUtils from "../buildArg/extraArgsUtils.js";
|
||||
import { ensureDevBinariesExecutable } from "../buildPermission/permissionManager.js";
|
||||
import { installDependencies } from "../buildDependency/dependencyManager.js";
|
||||
import portPool from "../buildArg/portPool.js";
|
||||
import { isProjectAlive } from "../buildJudge/aliveJudgeUtils.js";
|
||||
|
||||
/**
|
||||
* 从日志文件中提取执行命令后的所有行
|
||||
* 支持多种构建工具和框架:
|
||||
* - 构建工具: vite, webpack, rollup, parcel, esbuild, tsc, ts-node
|
||||
* - 框架: next, nuxt, astro, svelte, remix
|
||||
* - 包管理器: pnpm, npm, yarn, bun
|
||||
* - 脚本执行: node, 自定义脚本
|
||||
*
|
||||
* @param {string} logPath 日志文件路径
|
||||
* @param {string} projectId 项目ID
|
||||
* @param {number} pid 进程ID
|
||||
* @returns {string} 执行命令后的所有输出(已脱敏)
|
||||
*/
|
||||
function extractCommandOutputFromLog(logPath, projectId, pid) {
|
||||
let commandOutput = "开发服务器启动失败";
|
||||
try {
|
||||
if (fs.existsSync(logPath)) {
|
||||
const logContent = fs.readFileSync(logPath, "utf8");
|
||||
const lines = logContent.split("\n");
|
||||
|
||||
// 查找执行命令的行,支持多种构建工具和框架
|
||||
let commandStartIndex = -1;
|
||||
|
||||
// 第一轮:优先查找明确的命令执行行
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i].trim();
|
||||
if (
|
||||
// 直接命令格式: > vite, > webpack, > next, > nuxt 等
|
||||
line.match(
|
||||
/^>\s*(vite|webpack|next|nuxt|rollup|parcel|esbuild|tsc|ts-node|astro|svelte|remix)/
|
||||
) ||
|
||||
// 包管理器运行命令: > pnpm run dev, > npm run start 等
|
||||
line.match(
|
||||
/^>\s*(pnpm|npm|yarn|bun)\s+(run\s+)?(dev|start|serve|build|watch|start:dev)/
|
||||
) ||
|
||||
// 直接执行脚本: > node server.js, > node index.js 等
|
||||
line.match(/^>\s*node\s+\S+\.(js|ts|mjs)$/) ||
|
||||
// 自定义脚本执行标记
|
||||
line.includes("开始执行脚本: dev") ||
|
||||
line.includes("开始执行脚本: start") ||
|
||||
line.includes("开始执行脚本: serve") ||
|
||||
line.includes("开始执行脚本: build")
|
||||
) {
|
||||
commandStartIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 第二轮:如果没有找到命令行,查找构建工具输出标记
|
||||
if (commandStartIndex === -1) {
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i].trim();
|
||||
if (
|
||||
// 构建工具特定输出标记
|
||||
line.includes("VITE") ||
|
||||
line.includes("Webpack") ||
|
||||
line.includes("Next.js") ||
|
||||
line.includes("Nuxt") ||
|
||||
line.includes("Rollup") ||
|
||||
line.includes("Parcel") ||
|
||||
line.includes("Astro") ||
|
||||
line.includes("Svelte") ||
|
||||
line.includes("Remix") ||
|
||||
// 开发服务器启动标记
|
||||
line.includes("Local:") ||
|
||||
line.includes("Network:") ||
|
||||
line.includes("ready in") ||
|
||||
line.includes("compiled successfully") ||
|
||||
line.includes("dev server running") ||
|
||||
line.includes("server started")
|
||||
) {
|
||||
commandStartIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 第三轮:如果仍然没有找到,查找错误输出标记
|
||||
if (commandStartIndex === -1) {
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i].trim();
|
||||
if (
|
||||
line.includes("Error") ||
|
||||
line.includes("error") ||
|
||||
line.includes("failed") ||
|
||||
line.includes("Cannot find") ||
|
||||
line.includes("ERR_") ||
|
||||
line.includes("Command failed") ||
|
||||
line.includes("ELIFECYCLE") ||
|
||||
line.includes("npm ERR!") ||
|
||||
line.includes("pnpm ERR!")
|
||||
) {
|
||||
commandStartIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (commandStartIndex >= 0) {
|
||||
// 提取命令执行后的所有行(包含错误行本身)
|
||||
const outputLines = lines.slice(commandStartIndex);
|
||||
commandOutput = outputLines.join("\n").trim();
|
||||
|
||||
// 如果输出为空,返回默认信息
|
||||
if (!commandOutput) {
|
||||
commandOutput = "命令已执行,但无输出信息";
|
||||
}
|
||||
} else {
|
||||
// 如果没有找到命令开始标记,返回所有日志内容
|
||||
commandOutput = logContent.trim();
|
||||
}
|
||||
|
||||
// 脱敏处理:移除敏感路径信息
|
||||
commandOutput = sanitizeSensitivePaths(commandOutput);
|
||||
|
||||
// 如果输出太长,截取最后的部分(保留更多信息)
|
||||
if (commandOutput.length > 1000) {
|
||||
commandOutput = commandOutput.substring(commandOutput.length - 1000);
|
||||
}
|
||||
}
|
||||
} catch (readError) {
|
||||
log(projectId, "WARN", "读取日志文件失败", {
|
||||
projectId,
|
||||
pid: pid,
|
||||
error: readError.message,
|
||||
});
|
||||
}
|
||||
|
||||
return commandOutput;
|
||||
}
|
||||
|
||||
// 进程注册表:维护运行dev的项目
|
||||
// key: projectId
|
||||
// value: { pid, logPath, startedAt, port}
|
||||
const runningDevProcesses = new Map();
|
||||
|
||||
// 项目级启动锁,避免并发重复启动同一项目
|
||||
const startingProjects = new Set();
|
||||
|
||||
/**
|
||||
* 获取运行中的进程信息
|
||||
* @param {string} projectId 项目ID
|
||||
* @returns {Object|null} 进程信息
|
||||
*/
|
||||
function getRunningProcess(projectId) {
|
||||
return runningDevProcesses.get(projectId) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置运行中的进程信息
|
||||
* @param {string} projectId 项目ID
|
||||
* @param {Object} processInfo 进程信息
|
||||
*/
|
||||
function setRunningProcess(projectId, processInfo) {
|
||||
runningDevProcesses.set(projectId, processInfo);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除运行中的进程信息
|
||||
* @param {string} projectId 项目ID
|
||||
*/
|
||||
function deleteRunningProcess(projectId) {
|
||||
runningDevProcesses.delete(projectId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查项目是否正在启动中
|
||||
* @param {string} projectId 项目ID
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isProjectStarting(projectId) {
|
||||
return startingProjects.has(projectId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加项目到启动锁
|
||||
* @param {string} projectId 项目ID
|
||||
*/
|
||||
function addStartingProject(projectId) {
|
||||
startingProjects.add(projectId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 从启动锁中移除项目
|
||||
* @param {string} projectId 项目ID
|
||||
*/
|
||||
function removeStartingProject(projectId) {
|
||||
startingProjects.delete(projectId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用spawn非阻塞启动开发服务器,并把日志写入文件
|
||||
* @param {Object} options 启动选项
|
||||
* @param {Object} options.req 请求对象
|
||||
* @param {string} options.projectId 项目ID
|
||||
* @param {string} options.projectPath 项目路径
|
||||
* @param {string} options.devScript dev脚本内容
|
||||
* @param {Object} options.envExtra 额外的环境变量
|
||||
* @param {Array} options.extraArgs 额外的参数
|
||||
* @returns {Object} { pid, logPath, port }
|
||||
*/
|
||||
async function startDev_NonBlocking({
|
||||
req,
|
||||
projectId,
|
||||
projectPath,
|
||||
devScript,
|
||||
}) {
|
||||
const lowerScript = (devScript || "").toLowerCase();
|
||||
const isVite = lowerScript.includes("vite");
|
||||
const isNext = lowerScript.includes("next");
|
||||
|
||||
if (!isVite && !isNext) {
|
||||
throw new BusinessError("不支持的脚本类型" + devScript + ",请使用vite或next脚本", {
|
||||
projectId,
|
||||
code: ERROR_CODES.INVALID_SCRIPT_TYPE,
|
||||
});
|
||||
}
|
||||
|
||||
// 创建日志目录
|
||||
const logDir = getLogDir(projectId);
|
||||
if (!fs.existsSync(logDir)) {
|
||||
fs.mkdirSync(logDir, { recursive: true });
|
||||
}
|
||||
// 主日志文件
|
||||
const today = getCSTDateString(); // 格式: YYYY-MM-DD (东八区)
|
||||
const logPath = path.join(logDir, `dev-${today}.log`);
|
||||
|
||||
// 临时日志用于端口解析
|
||||
const tempLogPath = path.join(
|
||||
logDir,
|
||||
`dev-temp-${Date.now().toString()}.log`
|
||||
);
|
||||
|
||||
let outStream, tempOutStream, child;
|
||||
let streamsClosed = false;
|
||||
let childExited = false;
|
||||
let allocatedPort = null; // 跟踪分配的端口,用于失败时释放
|
||||
|
||||
// 标记缓存是否已失效(避免频繁删除缓存)
|
||||
let cacheInvalidated = false;
|
||||
|
||||
// 创建安全的写入函数
|
||||
const safeWrite = (stream, data, streamName, flush = false) => {
|
||||
// 分别检查流的状态,而不是使用共享的 streamsClosed
|
||||
if (!stream || stream.destroyed) {
|
||||
// 只有在需要时才输出调试信息,避免日志过多
|
||||
if (stream && stream.destroyed) {
|
||||
log(projectId, "DEBUG", `${streamName}流已销毁,跳过写入`, {
|
||||
destroyed: true,
|
||||
});
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// 如果全局标记已关闭,也不写入
|
||||
if (streamsClosed) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
// 在data前拼接时间戳 [2025/10/12 11:51:09] (东八区)
|
||||
const timestamp = getCSTTimestampString();
|
||||
const dataWithTimestamp = `[${timestamp}] ` + data;
|
||||
const result = stream.write(dataWithTimestamp);
|
||||
|
||||
// 如果需要立即刷新(关键日志),使用 cork/uncork 机制强制刷新缓冲区
|
||||
if (flush && typeof stream.cork === 'function' && typeof stream.uncork === 'function') {
|
||||
// cork() 暂停写入,uncork() 立即刷新所有缓冲数据
|
||||
// 这里我们先 cork 再 uncork,强制刷新当前缓冲区
|
||||
stream.cork();
|
||||
// 使用 setImmediate 确保在下一个事件循环刷新
|
||||
setImmediate(() => {
|
||||
try {
|
||||
if (!stream.destroyed) {
|
||||
stream.uncork();
|
||||
}
|
||||
} catch (e) {
|
||||
// 忽略 uncork 错误
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 如果缓存已启用,标记缓存失效(只标记一次)
|
||||
// 避免频繁删除缓存,让下次读取时自动重新加载
|
||||
if (logCacheManager.isEnabled() && !cacheInvalidated) {
|
||||
logCacheManager.delete(String(projectId));
|
||||
cacheInvalidated = true;
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (err) {
|
||||
log(projectId, "WARN", `${streamName}写入错误`, {
|
||||
error: err.message,
|
||||
code: err.code,
|
||||
streamName: streamName,
|
||||
});
|
||||
// 只有在严重错误时才关闭所有流
|
||||
// 临时日志的错误不应该影响主日志
|
||||
if (err.code === "ERR_STREAM_WRITE_AFTER_END" || err.code === "EPIPE") {
|
||||
// 只关闭出错的流,不影响其他流
|
||||
try {
|
||||
if (stream && !stream.destroyed) {
|
||||
stream.end();
|
||||
}
|
||||
} catch (closeErr) {
|
||||
log(projectId, "WARN", `关闭${streamName}流时出错`, { error: closeErr.message });
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// 安全关闭流的函数
|
||||
const safeCloseStreams = () => {
|
||||
if (streamsClosed) return;
|
||||
streamsClosed = true;
|
||||
|
||||
try {
|
||||
if (outStream && !outStream.destroyed) {
|
||||
outStream.end();
|
||||
}
|
||||
} catch (err) {
|
||||
log(projectId, "WARN", "关闭主日志流时出错", { error: err.message });
|
||||
}
|
||||
|
||||
try {
|
||||
if (tempOutStream && !tempOutStream.destroyed) {
|
||||
tempOutStream.end();
|
||||
}
|
||||
} catch (err) {
|
||||
log(projectId, "WARN", "关闭临时日志流时出错", { error: err.message });
|
||||
}
|
||||
};
|
||||
|
||||
// 添加流错误处理
|
||||
const handleStreamError = (streamName, err) => {
|
||||
log(projectId, "WARN", `${streamName}流错误`, { error: err.message });
|
||||
// 如果是写入错误且子进程已退出,则关闭流
|
||||
if (
|
||||
(err.code === "ERR_STREAM_WRITE_AFTER_END" || err.code === "EPIPE") &&
|
||||
childExited
|
||||
) {
|
||||
safeCloseStreams();
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
// 在启动前确保可执行权限
|
||||
try {
|
||||
await ensureDevBinariesExecutable(projectPath);
|
||||
} catch (_) {}
|
||||
|
||||
// 创建日志文件写入流
|
||||
outStream = fs.createWriteStream(logPath, { flags: "a" });
|
||||
tempOutStream = fs.createWriteStream(tempLogPath, { flags: "a" });
|
||||
|
||||
// 组合为单个子进程内串行执行:先 dev-inject,再 vite-plugin-design-mode
|
||||
// 使用 set +e 忽略错误,确保两个命令都会执行,无论第一个是否成功
|
||||
// 注意:即使预处理命令失败,也不会阻塞后续依赖安装和 dev 启动,仅做“尽力而为”的处理
|
||||
const preCmd = "set +e ; pnpm dlx @xagi/dev-inject@latest install --framework ; pnpm dlx @xagi/vite-plugin-design-mode@latest install ; set -e";
|
||||
|
||||
safeWrite(outStream, preCmd, "Main log");
|
||||
safeWrite(tempOutStream, preCmd, "Temp log");
|
||||
|
||||
// 在安装依赖之前先执行预处理命令(失败不影响后续流程)
|
||||
await new Promise((resolve) => {
|
||||
try {
|
||||
const preProcess = spawn("bash", ["-lc", preCmd], {
|
||||
cwd: projectPath,
|
||||
env: { ...process.env },
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
preProcess.stdout.on("data", (data) => {
|
||||
const msg = data.toString();
|
||||
safeWrite(outStream, msg, "Main log");
|
||||
safeWrite(tempOutStream, msg, "Temp log");
|
||||
});
|
||||
|
||||
preProcess.stderr.on("data", (data) => {
|
||||
const msg = data.toString();
|
||||
safeWrite(outStream, msg, "Main log");
|
||||
safeWrite(tempOutStream, msg, "Temp log");
|
||||
});
|
||||
|
||||
preProcess.on("error", (err) => {
|
||||
const errorMessage = `预处理命令执行出错(忽略并继续后续流程): ${err.message}\n`;
|
||||
safeWrite(outStream, errorMessage, "Main log", true);
|
||||
safeWrite(tempOutStream, errorMessage, "Temp log", true);
|
||||
resolve();
|
||||
});
|
||||
|
||||
preProcess.on("close", (code) => {
|
||||
if (code !== 0) {
|
||||
const errorMessage = `Preprocessing command exit code is ${code} (ignore and continue subsequent process)\n`;
|
||||
safeWrite(outStream, errorMessage, "主日志", true);
|
||||
safeWrite(tempOutStream, errorMessage, "临时日志", true);
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
} catch (error) {
|
||||
const errorMessage = `预处理命令启动失败(忽略并继续后续流程): ${error.message}\n`;
|
||||
safeWrite(outStream, errorMessage, "Main log", true);
|
||||
safeWrite(tempOutStream, errorMessage, "Temp log", true);
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
|
||||
// 安装依赖(日志会同时写入主日志和临时日志)
|
||||
try {
|
||||
await installDependencies(req, projectId, projectPath, {
|
||||
outStream,
|
||||
tempOutStream,
|
||||
safeWrite,
|
||||
});
|
||||
} catch (error) {
|
||||
// 安装失败时记录错误并抛出(safeWrite 会自动添加时间戳)- 立即刷新到磁盘
|
||||
const errorMessage = `Dependency installation failed: ${error.message}\n`;
|
||||
safeWrite(outStream, errorMessage, "Main log", true); // flush = true
|
||||
safeWrite(tempOutStream, errorMessage, "Temp log", true); // flush = true
|
||||
throw error;
|
||||
}
|
||||
|
||||
// 在日志文件开头写入开始执行
|
||||
const startMessage = `Start executing script: dev\n`;
|
||||
safeWrite(outStream, startMessage, "Main log");
|
||||
safeWrite(tempOutStream, startMessage, "Temp log");
|
||||
|
||||
// 构建 dev 命令参数
|
||||
const npmArgs = [];
|
||||
|
||||
// 额外参数和环境变量(端口将在内部从端口池获取)
|
||||
const { extraArgs, envExtra, port } = await ExtraArgsUtils.processExtraArgs({
|
||||
devScript,
|
||||
projectId,
|
||||
req,
|
||||
});
|
||||
|
||||
// 记录分配的端口,用于失败时释放
|
||||
allocatedPort = port;
|
||||
|
||||
// 透传额外参数到脚本
|
||||
if (extraArgs && extraArgs.length > 0) {
|
||||
npmArgs.push(...extraArgs);
|
||||
}
|
||||
|
||||
const escapeArg = (s) => `'${String(s).replace(/'/g, `'\\''`)}'`;
|
||||
const extraArgsEscaped = (npmArgs && npmArgs.length > 0)
|
||||
? npmArgs.map(escapeArg).join(" ")
|
||||
: "";
|
||||
|
||||
// 记录参数信息以便调试
|
||||
if (extraArgsEscaped) {
|
||||
log(projectId, "INFO", "Generated extra arguments", {
|
||||
extraArgs: extraArgs,
|
||||
npmArgs: npmArgs,
|
||||
extraArgsEscaped: extraArgsEscaped,
|
||||
});
|
||||
}
|
||||
|
||||
// 检测命令是否需要通过 npx 执行
|
||||
// 如果命令是纯命令名(如 vite, next)而不是路径,应该使用 npx
|
||||
const needsNpx = (script) => {
|
||||
if (!script || typeof script !== "string") return false;
|
||||
const trimmed = script.trim();
|
||||
// 如果包含路径分隔符(/ 或 \),说明是路径,不需要 npx
|
||||
if (trimmed.includes("/") || trimmed.includes("\\")) return false;
|
||||
// 提取第一个词(命令名)
|
||||
const firstWord = trimmed.split(/\s+/)[0];
|
||||
// 如果是常见的构建工具命令名,且不是路径,需要 npx
|
||||
const commandsNeedingNpx = ["vite", "next", "webpack", "rollup", "parcel", "esbuild", "tsc", "ts-node", "astro", "svelte", "remix", "nuxt"];
|
||||
return commandsNeedingNpx.includes(firstWord);
|
||||
};
|
||||
|
||||
let fullCommand;
|
||||
let execCommand = ""; // 用于记录实际执行的命令
|
||||
if (isVite) {
|
||||
// 移除脚本中已存在的 --host / --base(包含等号或跟随值),避免重复冲突
|
||||
const sanitizeCliFlags = (script, flagsToRemove) => {
|
||||
if (!script || typeof script !== "string") return script;
|
||||
let result = script;
|
||||
for (const flag of flagsToRemove) {
|
||||
// 1) 移除 --flag=value 形式
|
||||
const eqPattern = new RegExp(`${flag.replace(/[-/\\^$*+?.()|[\]{}]/g, "\\$&")}=\\S+`, "g");
|
||||
result = result.replace(eqPattern, "");
|
||||
// 2) 移除 --flag <value> 形式,<value> 为非以 - 开头的单词(含路径)
|
||||
const spacedPattern = new RegExp(`${flag.replace(/[-/\\^$*+?.()|[\]{}]/g, "\\$&")}\s+([^\s-][^\s]*)`, "g");
|
||||
result = result.replace(spacedPattern, "");
|
||||
// 3) 移除仅有 --flag(无值)
|
||||
const barePattern = new RegExp(`${flag.replace(/[-/\\^$*+?.()|[\]{}]/g, "\\$&")}\b`, "g");
|
||||
result = result.replace(barePattern, "");
|
||||
}
|
||||
// 归一多余空白
|
||||
return result.replace(/\s{2,}/g, " ").trim();
|
||||
};
|
||||
|
||||
const cleanedScript = sanitizeCliFlags(devScript, ["--host", "--base"]);
|
||||
const appended = extraArgsEscaped ? ` ${extraArgsEscaped}` : "";
|
||||
// 如果需要 npx,使用 npx 执行命令
|
||||
const commandPrefix = needsNpx(cleanedScript) ? "npx" : "";
|
||||
execCommand = commandPrefix ? `${commandPrefix} ${cleanedScript}${appended}` : `${cleanedScript}${appended}`;
|
||||
// 使用 exec 替换 shell 进程,确保 child.pid 直接对应 vite 进程
|
||||
fullCommand = `exec ${execCommand}`;
|
||||
} else if (isNext) {
|
||||
const appended = extraArgsEscaped ? ` ${extraArgsEscaped}` : "";
|
||||
// 如果需要 npx,使用 npx 执行命令
|
||||
const commandPrefix = needsNpx(devScript) ? "npx" : "";
|
||||
execCommand = commandPrefix ? `${commandPrefix} ${devScript}${appended}` : `${devScript}${appended}`;
|
||||
fullCommand = `exec ${execCommand}`;
|
||||
} else {
|
||||
const extraForNpm = extraArgsEscaped ? ` -- ${extraArgsEscaped}` : "";
|
||||
execCommand = `pnpm run dev${extraForNpm}`;
|
||||
fullCommand = `exec ${execCommand}`;
|
||||
}
|
||||
|
||||
// 将实际执行的命令写入日志文件
|
||||
if (execCommand) {
|
||||
const commandMessage = `> ${execCommand}\n`;
|
||||
safeWrite(outStream, commandMessage, "Main log");
|
||||
safeWrite(tempOutStream, commandMessage, "Temp log");
|
||||
}
|
||||
|
||||
// 打印将要执行的完整命令与上下文,便于排查
|
||||
try {
|
||||
log(projectId, "INFO", "Start child process, sequentially execute preprocessing and start dev:", {
|
||||
command: fullCommand,
|
||||
cwd: projectPath,
|
||||
devScript,
|
||||
extraArgs,
|
||||
envExtraKeys: Object.keys(envExtra || {}),
|
||||
});
|
||||
} catch (_) {}
|
||||
|
||||
// 使用 shell 执行,以便在同一子进程中串行执行两步
|
||||
child = spawn("sh", ["-c", fullCommand], {
|
||||
cwd: projectPath,
|
||||
env: {
|
||||
PATH: process.env.PATH, // 必需:找到命令
|
||||
//HOME: process.env.HOME, // 用户配置目录
|
||||
NODE_ENV: 'development', //不能指定production,否则hmr会失效
|
||||
...envExtra, // 项目特定变量
|
||||
},
|
||||
detached: true,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
// 添加日志流的错误处理
|
||||
outStream.on("error", (err) => {
|
||||
log(projectId, "ERROR", "Main log stream error", {
|
||||
error: err.message,
|
||||
code: err.code,
|
||||
path: logPath,
|
||||
destroyed: outStream.destroyed,
|
||||
});
|
||||
handleStreamError("Main log", err);
|
||||
});
|
||||
tempOutStream.on("error", (err) => {
|
||||
log(projectId, "ERROR", "Temp log stream error", {
|
||||
error: err.message,
|
||||
code: err.code,
|
||||
path: tempLogPath,
|
||||
destroyed: tempOutStream.destroyed,
|
||||
});
|
||||
handleStreamError("Temp log", err);
|
||||
});
|
||||
|
||||
// 安全地重定向子进程输出到双日志文件
|
||||
if (child.stdout) {
|
||||
child.stdout.on("data", (data) => {
|
||||
// 使用安全的写入函数,避免向已关闭的流写入
|
||||
const mainWriteOk = safeWrite(outStream, data, "Main log");
|
||||
const tempWriteOk = safeWrite(tempOutStream, data, "Temp log");
|
||||
|
||||
// 如果任一写入失败,记录详细信息(但不影响另一个流)
|
||||
if (!mainWriteOk || !tempWriteOk) {
|
||||
log(projectId, "DEBUG", "Log writing status", {
|
||||
mainWriteOk,
|
||||
tempWriteOk,
|
||||
mainDestroyed: outStream ? outStream.destroyed : null,
|
||||
tempDestroyed: tempOutStream ? tempOutStream.destroyed : null,
|
||||
streamsClosed,
|
||||
});
|
||||
}
|
||||
});
|
||||
child.stdout.on("error", (err) => {
|
||||
log(projectId, "WARN", "Child process stdout error", { error: err.message });
|
||||
});
|
||||
child.stdout.on("end", () => {
|
||||
// stdout 流结束时,不立即关闭日志流,等待子进程完全退出
|
||||
log(projectId, "INFO", "子进程stdout流已结束", { pid: child.pid });
|
||||
});
|
||||
}
|
||||
|
||||
if (child.stderr) {
|
||||
child.stderr.on("data", (data) => {
|
||||
// 使用安全的写入函数,避免向已关闭的流写入
|
||||
const mainWriteOk = safeWrite(outStream, data, "Main log");
|
||||
const tempWriteOk = safeWrite(tempOutStream, data, "Temp log");
|
||||
|
||||
// 如果任一写入失败,记录详细信息(但不影响另一个流)
|
||||
if (!mainWriteOk || !tempWriteOk) {
|
||||
log(projectId, "DEBUG", "Log writing status(stderr)", {
|
||||
mainWriteOk,
|
||||
tempWriteOk,
|
||||
mainDestroyed: outStream ? outStream.destroyed : null,
|
||||
tempDestroyed: tempOutStream ? tempOutStream.destroyed : null,
|
||||
streamsClosed,
|
||||
});
|
||||
}
|
||||
});
|
||||
child.stderr.on("error", (err) => {
|
||||
log(projectId, "WARN", "Child process stderr error", { error: err.message });
|
||||
});
|
||||
child.stderr.on("end", () => {
|
||||
// stderr 流结束时,不立即关闭日志流,等待子进程完全退出
|
||||
log(projectId, "INFO", "Child process stderr stream ended", { pid: child.pid });
|
||||
});
|
||||
}
|
||||
|
||||
// 监听子进程退出事件,安全关闭日志流
|
||||
child.on("exit", (code, signal) => {
|
||||
childExited = true;
|
||||
log(projectId, "INFO", "Child process exited", {
|
||||
pid: child.pid,
|
||||
code,
|
||||
signal,
|
||||
});
|
||||
// 延迟关闭流,确保所有数据都已写入
|
||||
setTimeout(() => {
|
||||
safeCloseStreams();
|
||||
}, 200);
|
||||
});
|
||||
|
||||
child.on("error", (err) => {
|
||||
log(projectId, "WARN", "Child process error", { error: err.message });
|
||||
childExited = true;
|
||||
log(projectId, "ERROR", "Child process startup failed", {
|
||||
pid: child.pid,
|
||||
error: err.message,
|
||||
});
|
||||
safeCloseStreams();
|
||||
});
|
||||
|
||||
runningDevProcesses.set(projectId, {
|
||||
pid: child.pid,
|
||||
logPath,
|
||||
startedAt: Date.now(),
|
||||
});
|
||||
|
||||
child.unref();// 解除子进程与父进程的关联,子进程可以独立运行
|
||||
|
||||
// 如果使用了 exec 命令(替换进程),需要等待 exec 完成进程替换
|
||||
// exec 会在 preCmd 成功后替换 shell 进程为实际服务进程(如 vite)
|
||||
// 这种情况下 child.pid 会直接对应服务进程,但仍需稍等确保替换完成
|
||||
const usesExec = fullCommand && fullCommand.includes("exec ");
|
||||
if (usesExec) {
|
||||
// 等待 200ms 确保 exec 完成进程替换
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
}
|
||||
|
||||
log(projectId, "INFO", "Using port pool allocated port and current process ID", {
|
||||
projectId,
|
||||
pid: child.pid,
|
||||
port: port
|
||||
});
|
||||
|
||||
// 轮询等待项目对外可访问,最多 30s
|
||||
const basePathFromReq =
|
||||
(req && req.query && req.query.basePath) ||
|
||||
(req && req.body && req.body.basePath) ||
|
||||
undefined;
|
||||
const resolvedBasePath = basePathFromReq || "/";
|
||||
const maxAliveWaitMs = 30000;
|
||||
const alivePollIntervalMs = 1000;
|
||||
const aliveCheckTimeoutPerRequest = 1500;
|
||||
|
||||
let projectAlive = false;
|
||||
const aliveStartedAt = Date.now();
|
||||
// 启动后先等待 1s 再开始轮询,给框架预留初始化时间
|
||||
await new Promise((resolve) => setTimeout(resolve, alivePollIntervalMs));
|
||||
while (Date.now() - aliveStartedAt < maxAliveWaitMs) {
|
||||
projectAlive = await isProjectAlive(projectId, port, basePathFromReq, {
|
||||
timeoutMs: aliveCheckTimeoutPerRequest,
|
||||
});
|
||||
if (projectAlive) {
|
||||
break;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, alivePollIntervalMs));
|
||||
}
|
||||
|
||||
if (!projectAlive) {
|
||||
const waitSeconds = Math.round(maxAliveWaitMs / 1000);
|
||||
log(projectId, "WARN", "Development server is not accessible within the limited time", {
|
||||
port,
|
||||
pid: child.pid,
|
||||
basePath: resolvedBasePath,
|
||||
waitSeconds,
|
||||
});
|
||||
} else {
|
||||
log(projectId, "INFO", "Project accessibility verification passed", {
|
||||
port,
|
||||
basePath: resolvedBasePath,
|
||||
elapsedMs: Date.now() - aliveStartedAt,
|
||||
});
|
||||
}
|
||||
|
||||
// 回填端口和实际进程ID
|
||||
const info = runningDevProcesses.get(projectId);
|
||||
if (info) {
|
||||
info.port = port;
|
||||
info.pid = child.pid; // 进程组pid
|
||||
runningDevProcesses.set(projectId, info);
|
||||
}
|
||||
|
||||
log(projectId, "INFO", "Development server startup successfully", {
|
||||
projectId,
|
||||
pid: child.pid,
|
||||
port: port,
|
||||
});
|
||||
|
||||
//要返回进程组pid和端口,因为后续需要通过进程组pid来停止进程
|
||||
return { pid: child.pid, port: port };
|
||||
} catch (error) {
|
||||
// 启动失败,释放已分配的端口
|
||||
if (allocatedPort) {
|
||||
portPool.release(String(projectId));
|
||||
log(projectId, "INFO", "Startup failed, port released", {
|
||||
port: allocatedPort,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
// 重新抛出错误
|
||||
throw error;
|
||||
} finally {
|
||||
// 只有在发生异常或子进程启动失败时才立即清理资源
|
||||
// 正常情况下,流会在子进程退出时自动关闭
|
||||
if (childExited || !child || !child.pid) {
|
||||
safeCloseStreams();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止指定的进程
|
||||
* @param {string} projectId 项目ID
|
||||
* @param {number} pid 进程ID
|
||||
* @returns {Promise<boolean>} 是否成功停止
|
||||
*/
|
||||
async function killProcess(projectId, pid) {
|
||||
const pidNum = Number(pid);
|
||||
if (!Number.isFinite(pidNum)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 首先检查进程是否存在
|
||||
if (!isProcessRunning(pidNum)) {
|
||||
log(projectId, "INFO", "Process does not exist", { pid: pidNum });
|
||||
runningDevProcesses.delete(projectId);
|
||||
return true;
|
||||
}
|
||||
|
||||
let killed = false;
|
||||
let killMethod = "";
|
||||
|
||||
try {
|
||||
// 优先杀进程组(detached)
|
||||
process.kill(-pidNum);
|
||||
killed = true;
|
||||
killMethod = "Process group";
|
||||
log(projectId, "INFO", "通过进程组杀死进程", { pid: pidNum });
|
||||
} catch (e) {
|
||||
if (e && (e.code === "ESRCH" || e.errno === "ESRCH")) {
|
||||
// 进程组不存在,不能认为已杀死;继续尝试对单个进程发送信号
|
||||
killed = false;
|
||||
killMethod = "Process group(not exist)";
|
||||
log(projectId, "INFO", "Process group does not exist, back to try single process kill", { pid: pidNum });
|
||||
} else {
|
||||
log(projectId, "WARN", "Failed to kill process group", {
|
||||
pid: pidNum,
|
||||
error: e.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (!killed) {
|
||||
try {
|
||||
process.kill(pidNum);
|
||||
killed = true;
|
||||
killMethod = "单个进程";
|
||||
log(projectId, "INFO", "Kill process through single process", { pid: pidNum });
|
||||
} catch (e) {
|
||||
if (e && (e.code === "ESRCH" || e.errno === "ESRCH")) {
|
||||
killed = true;
|
||||
killMethod = "Single process(not exist)";
|
||||
log(projectId, "INFO", "Process does not exist,视为已停止", { pid: pidNum });
|
||||
} else {
|
||||
log(projectId, "ERROR", "Failed to kill process", {
|
||||
pid: pidNum,
|
||||
error: e.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 验证进程是否真的被杀死了
|
||||
if (killed) {
|
||||
// 等待一小段时间让进程完全退出
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
if (!isProcessRunning(pidNum)) {
|
||||
log(projectId, "INFO", "Process stopped successfully", {
|
||||
pid: pidNum,
|
||||
method: killMethod,
|
||||
});
|
||||
runningDevProcesses.delete(projectId);
|
||||
return true;
|
||||
} else {
|
||||
log(projectId, "WARN", "Process still exists, kill may have failed", {
|
||||
pid: pidNum,
|
||||
method: killMethod,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return killed;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查进程是否正在运行
|
||||
* @param {number} pid 进程ID
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isProcessRunning(pid) {
|
||||
try {
|
||||
process.kill(pid, 0); // 发送信号0检查进程是否存在
|
||||
return true;
|
||||
} catch (err) {
|
||||
if (err && (err.code === 'EPERM' || err.code === 'EACCES')) {
|
||||
// 进程存在,但当前用户无权限发送信号
|
||||
return true;
|
||||
}
|
||||
if (err && err.code === 'ESRCH') {
|
||||
// 进程不存在
|
||||
return false;
|
||||
}
|
||||
// 其他错误,保守返回 false,或根据需要上报日志
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 等待进程停止
|
||||
* @param {string} projectId 项目ID
|
||||
* @param {number} pid 进程ID
|
||||
* @returns {Promise<Object>} { stopped: boolean, attempts: number }
|
||||
*/
|
||||
async function waitForProcessStop(projectId, pid) {
|
||||
let attempts = 0;
|
||||
const maxAttempts = config.DEV_SERVER_STOP_MAX_ATTEMPTS;
|
||||
while (isProcessRunning(pid) && attempts < maxAttempts) {
|
||||
await new Promise((resolve) =>
|
||||
setTimeout(resolve, config.DEV_SERVER_STOP_CHECK_INTERVAL)
|
||||
);
|
||||
attempts++;
|
||||
}
|
||||
|
||||
const stopped = !isProcessRunning(pid);
|
||||
return { stopped, attempts };
|
||||
}
|
||||
|
||||
/**
|
||||
* 列出所有运行中的进程
|
||||
* @returns {Array} 进程列表
|
||||
*/
|
||||
function listRunningProcesses() {
|
||||
const list = Array.from(runningDevProcesses.entries()).map(([id, info]) => ({
|
||||
projectId: id,
|
||||
pid: info.pid,
|
||||
type: info.type,
|
||||
startedAt: info.startedAt,
|
||||
port: info.port,
|
||||
}));
|
||||
return list;
|
||||
}
|
||||
|
||||
export {
|
||||
getRunningProcess,
|
||||
setRunningProcess,
|
||||
deleteRunningProcess,
|
||||
isProjectStarting,
|
||||
addStartingProject,
|
||||
removeStartingProject,
|
||||
startDev_NonBlocking,
|
||||
killProcess,
|
||||
isProcessRunning,
|
||||
waitForProcessStop,
|
||||
listRunningProcesses,
|
||||
};
|
||||
129
qiming-file-server/src/utils/build/restartDevUtils.js
Normal file
129
qiming-file-server/src/utils/build/restartDevUtils.js
Normal file
@@ -0,0 +1,129 @@
|
||||
import path from "path";
|
||||
import fs from "fs";
|
||||
import { log } from "../log/logUtils.js";
|
||||
import { BusinessError, FileError, ResourceError } from "../error/errorHandler.js";
|
||||
import {
|
||||
isProjectStarting,
|
||||
addStartingProject,
|
||||
removeStartingProject,
|
||||
startDev_NonBlocking,
|
||||
} from "./processManager.js";
|
||||
import ERROR_CODES from "../error/errorCodes.js";
|
||||
import { stopDevServer } from "./stopDevUtils.js";
|
||||
import { removeNodeModules } from "../buildDependency/dependencyManager.js";
|
||||
import { createPnpmNpmrc } from "../common/npmrcUtils.js";
|
||||
import {
|
||||
extractIsolationContext,
|
||||
resolveProjectPath,
|
||||
} from "../common/projectPathUtils.js";
|
||||
|
||||
// 重启开发服务器
|
||||
async function restartDevServer(req, projectId) {
|
||||
log(projectId, "INFO", "Start restarting development server", {
|
||||
projectId,
|
||||
requestId: req.requestId,
|
||||
});
|
||||
|
||||
// 检查项目是否存在
|
||||
const isolationContext = extractIsolationContext(req?.query || {});
|
||||
const projectPath = resolveProjectPath(projectId, isolationContext);
|
||||
const jsonFilePath = path.join(projectPath, "package.json");
|
||||
const exists = fs.existsSync(jsonFilePath);
|
||||
if (!exists) {
|
||||
log(projectId, "WARN", "Project missing package.json file", {
|
||||
projectId,
|
||||
requestId: req.requestId,
|
||||
});
|
||||
throw new ResourceError("Project missing package.json file", {
|
||||
projectId,
|
||||
projectPath,
|
||||
});
|
||||
}
|
||||
|
||||
let jsonContent;
|
||||
try {
|
||||
jsonContent = JSON.parse(fs.readFileSync(jsonFilePath, "utf8"));
|
||||
} catch (error) {
|
||||
throw new FileError("package.json file format error", {
|
||||
projectId,
|
||||
jsonFilePath,
|
||||
originalError: error.message,
|
||||
});
|
||||
}
|
||||
|
||||
const jsonScripts = jsonContent.scripts;
|
||||
const devScript = jsonScripts.dev;
|
||||
if (!devScript) {
|
||||
log(projectId, "WARN", "Project missing dev script", {
|
||||
projectId,
|
||||
requestId: req.requestId,
|
||||
});
|
||||
throw new BusinessError("Project missing dev script", { projectId });
|
||||
}
|
||||
|
||||
// 如果项目正在启动中,等待完成
|
||||
// if (isProjectStarting(projectId)) {
|
||||
// throw new BusinessError("Project is starting, please try again later", {
|
||||
// projectId,
|
||||
// code: ERROR_CODES.PROJECT_STARTING,
|
||||
// });
|
||||
// }
|
||||
|
||||
addStartingProject(projectId);
|
||||
|
||||
try {
|
||||
// 1. 停止现有的开发服务器
|
||||
const pidFromQuery = req.query.pid;
|
||||
|
||||
await stopDevServer(req, projectId, pidFromQuery, {
|
||||
strict: false, // 非严格模式,停止失败不抛出异常
|
||||
waitForStop: true, // 等待进程完全停止
|
||||
});
|
||||
|
||||
// 2. 删除node_modules
|
||||
log(projectId, "INFO", "Start deleting node_modules and lock file", {
|
||||
projectId,
|
||||
requestId: req.requestId,
|
||||
});
|
||||
await removeNodeModules(projectPath, projectId);
|
||||
|
||||
// 3. 创建.npmrc文件
|
||||
log(projectId, "INFO", "Create .npmrc file", {
|
||||
projectId,
|
||||
requestId: req.requestId,
|
||||
});
|
||||
await createPnpmNpmrc(projectPath, projectId);
|
||||
|
||||
// 4. 启动dev服务器(依赖安装会在 startDev_NonBlocking 中执行)
|
||||
log(projectId, "INFO", "Start starting dev server", {
|
||||
projectId,
|
||||
requestId: req.requestId,
|
||||
});
|
||||
|
||||
const { pid, port: actualPort } = await startDev_NonBlocking({
|
||||
req,
|
||||
projectId,
|
||||
projectPath,
|
||||
devScript,
|
||||
});
|
||||
|
||||
log(projectId, "INFO", "Dev server restart completed", {
|
||||
projectId,
|
||||
pid,
|
||||
port: actualPort,
|
||||
requestId: req.requestId,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "Development server restart successfully",
|
||||
projectId,
|
||||
pid,
|
||||
port: actualPort,
|
||||
};
|
||||
} finally {
|
||||
removeStartingProject(projectId);
|
||||
}
|
||||
}
|
||||
|
||||
export { restartDevServer };
|
||||
150
qiming-file-server/src/utils/build/startDevUtils.js
Normal file
150
qiming-file-server/src/utils/build/startDevUtils.js
Normal file
@@ -0,0 +1,150 @@
|
||||
import path from "path";
|
||||
import fs from "fs";
|
||||
import { log } from "../log/logUtils.js";
|
||||
import { BusinessError, FileError, ResourceError } from "../error/errorHandler.js";
|
||||
import ERROR_CODES from "../error/errorCodes.js";
|
||||
import {
|
||||
getRunningProcess,
|
||||
isProjectStarting,
|
||||
addStartingProject,
|
||||
removeStartingProject,
|
||||
startDev_NonBlocking,
|
||||
} from "./processManager.js";
|
||||
import { removeNodeModules } from "../buildDependency/dependencyManager.js";
|
||||
import {
|
||||
extractIsolationContext,
|
||||
resolveProjectPath,
|
||||
} from "../common/projectPathUtils.js";
|
||||
|
||||
// 启动开发服务器
|
||||
async function startDevServer(req, projectId) {
|
||||
|
||||
// if (isProjectStarting(projectId)) {
|
||||
// throw new BusinessError("该项目正在启动中,请稍后重试", {
|
||||
// projectId,
|
||||
// code: ERROR_CODES.PROJECT_STARTING,
|
||||
// });
|
||||
// }
|
||||
|
||||
addStartingProject(projectId);
|
||||
try {
|
||||
log(projectId, "INFO", "Start starting development server", {
|
||||
projectId,
|
||||
requestId: req.requestId,
|
||||
});
|
||||
const isolationContext = extractIsolationContext(req?.query || {});
|
||||
const projectPath = resolveProjectPath(projectId, isolationContext);
|
||||
const jsonFilePath = path.join(projectPath, "package.json");
|
||||
|
||||
const exists = fs.existsSync(jsonFilePath);
|
||||
if (!exists) {
|
||||
log(projectId, "WARN", "Project missing package.json file", {
|
||||
projectId,
|
||||
requestId: req.requestId,
|
||||
});
|
||||
throw new ResourceError("Project missing package.json file", {
|
||||
projectId,
|
||||
projectPath,
|
||||
});
|
||||
}
|
||||
|
||||
let jsonContent;
|
||||
try {
|
||||
jsonContent = JSON.parse(fs.readFileSync(jsonFilePath, "utf8"));
|
||||
} catch (error) {
|
||||
throw new FileError("package.json file format error", {
|
||||
projectId,
|
||||
jsonFilePath,
|
||||
originalError: error.message,
|
||||
});
|
||||
}
|
||||
|
||||
const jsonScripts = jsonContent.scripts;
|
||||
const devScript = jsonScripts.dev;
|
||||
if (!devScript) {
|
||||
log(projectId, "WARN", "Project missing dev script", {
|
||||
projectId,
|
||||
requestId: req.requestId,
|
||||
});
|
||||
throw new BusinessError("Project missing dev script, please add dev script in package.json", { projectId });
|
||||
}
|
||||
|
||||
// Linux 环境下:检测 libc 类型与已安装 Rollup 原生包是否匹配,若不匹配则清理依赖
|
||||
try {
|
||||
const isLinux = process.platform === "linux";
|
||||
if (isLinux) {
|
||||
const report = typeof process.report?.getReport === "function" ? process.report.getReport() : null;
|
||||
const glibcVersion = report && report.header && report.header.glibcVersionRuntime;
|
||||
const isMusl = !glibcVersion; // 没有 glibc 版本通常意味着 musl(如 Alpine)
|
||||
|
||||
const pnpmDir = path.join(projectPath, "node_modules", ".pnpm");
|
||||
if (fs.existsSync(pnpmDir)) {
|
||||
const entries = await fs.promises.readdir(pnpmDir, { withFileTypes: true });
|
||||
const hasRollupGnu = entries.some((ent) => ent.isDirectory() && (ent.name || "").includes("@rollup+rollup-linux-x64-gnu"));
|
||||
const hasRollupMusl = entries.some((ent) => ent.isDirectory() && (ent.name || "").includes("@rollup+rollup-linux-x64-musl"));
|
||||
|
||||
// 在 musl 系统上若装了 gnu 变体,或在 glibc 系统上若装了 musl 变体,则清理
|
||||
const mismatch = (isMusl && hasRollupGnu) || (!isMusl && hasRollupMusl);
|
||||
if (mismatch) {
|
||||
log(projectId, "WARN", "Detected Rollup native package does not match libc, clean dependencies and reinstall", {
|
||||
projectId,
|
||||
isMusl,
|
||||
glibcVersion: glibcVersion || null,
|
||||
});
|
||||
await removeNodeModules(projectPath, projectId);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
log(projectId, "WARN", "Linux native package matching detection failed (ignore continue)", {
|
||||
error: e && e.message,
|
||||
});
|
||||
}
|
||||
|
||||
// 尝试为后续 dev 进程注入回退环境,优先使用 WASM/JS,避免 .node 装载
|
||||
try {
|
||||
process.env.ROLLUP_WASM = process.env.ROLLUP_WASM || "1";
|
||||
process.env.ROLLUP_DISABLE_NATIVE = process.env.ROLLUP_DISABLE_NATIVE || "1";
|
||||
} catch (_) {}
|
||||
|
||||
// 如果已在运行,则直接返回信息
|
||||
// if (getRunningProcess(projectId)) {
|
||||
// const p = getRunningProcess(projectId);
|
||||
// log(projectId, "INFO", "项目已在运行,直接返回信息", {
|
||||
// projectId,
|
||||
// requestId: req.requestId,
|
||||
// });
|
||||
// return {
|
||||
// success: true,
|
||||
// message: "已在运行",
|
||||
// projectId,
|
||||
// pid: p.pid,
|
||||
// port: p.port,
|
||||
// };
|
||||
// }
|
||||
|
||||
log(projectId, "INFO", "Start executing dev script in non-blocking mode", {
|
||||
projectId,
|
||||
requestId: req.requestId,
|
||||
});
|
||||
|
||||
const { pid, port: actualPort } = await startDev_NonBlocking({
|
||||
req,
|
||||
projectId,
|
||||
projectPath,
|
||||
devScript,
|
||||
});
|
||||
return {
|
||||
success: true,
|
||||
message: "Development server started",
|
||||
projectId,
|
||||
pid,
|
||||
port: actualPort,
|
||||
};
|
||||
} finally {
|
||||
// 无论成功、失败都清理锁
|
||||
removeStartingProject(projectId);
|
||||
}
|
||||
}
|
||||
|
||||
export { startDevServer };
|
||||
428
qiming-file-server/src/utils/build/stopDevUtils.js
Normal file
428
qiming-file-server/src/utils/build/stopDevUtils.js
Normal file
@@ -0,0 +1,428 @@
|
||||
import { ValidationError, ProcessError } from "../error/errorHandler.js";
|
||||
import {
|
||||
killProcess,
|
||||
getRunningProcess,
|
||||
waitForProcessStop,
|
||||
} from "./processManager.js";
|
||||
import { log, getLogDir } from "../log/logUtils.js";
|
||||
import logCacheManager from "../log/logCacheManager.js";
|
||||
import portPool from "../buildArg/portPool.js";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { execSync } from "child_process";
|
||||
|
||||
|
||||
|
||||
function findPidsByProjectId(projectId) {
|
||||
try {
|
||||
const cmd = "ps -Ao pid,command -ww";
|
||||
const output = execSync(cmd, { encoding: "utf8" });
|
||||
const lines = output.split("\n");
|
||||
const pids = [];
|
||||
const preciseNeedle = "/" + String(projectId);
|
||||
|
||||
// 匹配包含 /{projectId} 的路径片段,避免误匹配
|
||||
for (const line of lines) {
|
||||
if (!line) continue;
|
||||
if (line.includes(preciseNeedle)) {
|
||||
const trimmed = line.trim();
|
||||
const firstSpace = trimmed.indexOf(" ");
|
||||
const pidStr = firstSpace > 0 ? trimmed.slice(0, firstSpace) : trimmed;
|
||||
const pidNum = Number(pidStr);
|
||||
if (Number.isFinite(pidNum)) {
|
||||
pids.push(pidNum);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 回退:使用宽松的包含 projectId 匹配(可能带来噪声)
|
||||
if (pids.length === 0) {
|
||||
for (const line of lines) {
|
||||
if (!line) continue;
|
||||
if (line.includes(String(projectId))) {
|
||||
const trimmed = line.trim();
|
||||
const firstSpace = trimmed.indexOf(" ");
|
||||
const pidStr = firstSpace > 0 ? trimmed.slice(0, firstSpace) : trimmed;
|
||||
const pidNum = Number(pidStr);
|
||||
if (Number.isFinite(pidNum)) {
|
||||
pids.push(pidNum);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(new Set(pids));
|
||||
} catch (_) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止开发服务器
|
||||
* @param {Object} req 请求对象
|
||||
* @param {string} projectId 项目ID
|
||||
* @param {number|string} pid 进程ID(可选,如果不提供会从运行表中获取)
|
||||
* @param {Object} options 选项
|
||||
* @param {boolean} options.strict 是否严格模式,严格模式下停止失败会抛出异常
|
||||
* @param {boolean} options.waitForStop 是否等待进程完全停止
|
||||
* @returns {Promise<Object>} 停止结果
|
||||
*
|
||||
* 功能说明:
|
||||
* 1. 确定要停止的进程ID
|
||||
* 2. 停止进程
|
||||
* 3. 等待进程完全停止(可选)
|
||||
* 4. 清理项目下的所有临时日志文件(dev-temp-*.log)
|
||||
*/
|
||||
async function stopDevServerByPid(req, projectId, pid, options = {}) {
|
||||
const { strict = true, waitForStop = false } = options;
|
||||
|
||||
log(projectId, "INFO", "Start stopping development server", {
|
||||
projectId,
|
||||
pid,
|
||||
strict,
|
||||
waitForStop,
|
||||
requestId: req.requestId,
|
||||
});
|
||||
|
||||
let pidToKill = null;
|
||||
let existingProcess = null;
|
||||
|
||||
// 1. 确定要停止的进程ID
|
||||
if (pid !== undefined && pid !== null) {
|
||||
const pidNum = Number(pid);
|
||||
if (Number.isFinite(pidNum)) {
|
||||
pidToKill = pidNum;
|
||||
log(projectId, "INFO", "Stop development server using incoming pid", {
|
||||
projectId,
|
||||
pid: pidToKill,
|
||||
requestId: req.requestId,
|
||||
});
|
||||
} else {
|
||||
log(projectId, "WARN", "Incoming pid is invalid", {
|
||||
projectId,
|
||||
invalidPid: pid,
|
||||
requestId: req.requestId,
|
||||
});
|
||||
|
||||
if (strict) {
|
||||
throw new ValidationError("Process ID is invalid", { field: "pid", value: pid });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 如果传入的pid无效或未提供,从运行表中获取
|
||||
if (!pidToKill) {
|
||||
existingProcess = getRunningProcess(projectId);
|
||||
if (existingProcess) {
|
||||
pidToKill = existingProcess.pid;
|
||||
log(projectId, "INFO", "Get pid to stop development server from running table", {
|
||||
projectId,
|
||||
pid: pidToKill,
|
||||
requestId: req.requestId,
|
||||
});
|
||||
} else {
|
||||
log(projectId, "INFO", "No development server process found to stop", {
|
||||
projectId,
|
||||
requestId: req.requestId,
|
||||
});
|
||||
|
||||
if (strict) {
|
||||
throw new ProcessError("No running development server process found", { projectId });
|
||||
}
|
||||
|
||||
// 即使未找到进程,也尝试释放端口(可能进程已意外退出)
|
||||
portPool.release(String(projectId));
|
||||
log(projectId, "INFO", "Port released (process not running)", {
|
||||
projectId,
|
||||
requestId: req.requestId,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "No running process found",
|
||||
projectId,
|
||||
pid: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 停止进程
|
||||
const killed = await killProcess(projectId, pidToKill);
|
||||
|
||||
if (killed) {
|
||||
log(projectId, "INFO", "Development server stopped", {
|
||||
projectId,
|
||||
pid: pidToKill,
|
||||
requestId: req.requestId,
|
||||
});
|
||||
} else {
|
||||
log(projectId, "WARN", "Failed to stop development server", {
|
||||
projectId,
|
||||
pid: pidToKill,
|
||||
requestId: req.requestId,
|
||||
});
|
||||
|
||||
if (strict) {
|
||||
throw new ProcessError("Failed to stop process", { projectId, pid: pidToKill });
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 等待进程完全停止(可选)
|
||||
if (waitForStop && killed) {
|
||||
const { stopped, attempts } = await waitForProcessStop(
|
||||
projectId,
|
||||
pidToKill
|
||||
);
|
||||
|
||||
if (!stopped) {
|
||||
log(projectId, "WARN", "Process stop timeout", {
|
||||
projectId,
|
||||
pid: pidToKill,
|
||||
attempts,
|
||||
requestId: req.requestId,
|
||||
});
|
||||
|
||||
if (strict) {
|
||||
throw new ProcessError("Process stop timeout", {
|
||||
projectId,
|
||||
pid: pidToKill,
|
||||
attempts,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
log(projectId, "INFO", "Process confirmed stopped", {
|
||||
projectId,
|
||||
pid: pidToKill,
|
||||
attempts,
|
||||
requestId: req.requestId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 清理项目下的所有临时日志文件
|
||||
try {
|
||||
const logDir = getLogDir(projectId);
|
||||
if (fs.existsSync(logDir)) {
|
||||
const files = fs.readdirSync(logDir);
|
||||
const tempFiles = files.filter(
|
||||
(file) => file.startsWith("dev-temp-") && file.endsWith(".log")
|
||||
);
|
||||
|
||||
if (tempFiles.length > 0) {
|
||||
let deletedCount = 0;
|
||||
for (const tempFile of tempFiles) {
|
||||
try {
|
||||
const tempFilePath = path.join(logDir, tempFile);
|
||||
fs.unlinkSync(tempFilePath);
|
||||
deletedCount++;
|
||||
} catch (err) {
|
||||
log(projectId, "WARN", "Failed to delete temporary log file", {
|
||||
projectId,
|
||||
tempFile,
|
||||
error: err.message,
|
||||
requestId: req.requestId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
log(projectId, "INFO", "Temporary log file cleanup completed", {
|
||||
projectId,
|
||||
deletedCount,
|
||||
totalTempFiles: tempFiles.length,
|
||||
requestId: req.requestId,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
log(projectId, "WARN", "Error cleaning temporary log files", {
|
||||
projectId,
|
||||
error: err.message,
|
||||
requestId: req.requestId,
|
||||
});
|
||||
}
|
||||
|
||||
// 5. 删除该项目的日志缓存
|
||||
try {
|
||||
if (logCacheManager.isEnabled()) {
|
||||
logCacheManager.delete(projectId);
|
||||
log(projectId, "INFO", "Log cache cleaned", {
|
||||
projectId,
|
||||
requestId: req.requestId,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
log(projectId, "WARN", "Error cleaning log cache", {
|
||||
projectId,
|
||||
error: err.message,
|
||||
requestId: req.requestId,
|
||||
});
|
||||
}
|
||||
|
||||
// 6. 停止成功后释放端口
|
||||
if (killed) {
|
||||
portPool.release(String(projectId));
|
||||
log(projectId, "INFO", "Port released", {
|
||||
projectId,
|
||||
requestId: req.requestId,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: killed ? "Stopped" : "Failed to stop but continue execution",
|
||||
projectId,
|
||||
pid: pidToKill,
|
||||
};
|
||||
}
|
||||
|
||||
async function stopDevServerByProjectId(req, projectId, options = {}) {
|
||||
const { strict = true, waitForStop = false } = options;
|
||||
|
||||
log(projectId, "INFO", "Start stopping development server(stop all by projectId)", {
|
||||
projectId,
|
||||
strict,
|
||||
waitForStop,
|
||||
requestId: req.requestId,
|
||||
});
|
||||
|
||||
// 忽略传入的 pid,按 projectId 通过系统进程检索
|
||||
const uniquePids = findPidsByProjectId(projectId);
|
||||
const candidates = uniquePids.map((pid) => ({ pid }));
|
||||
|
||||
if (!candidates || candidates.length === 0) {
|
||||
log(projectId, "INFO", "No development server process found to stop", {
|
||||
projectId,
|
||||
requestId: req.requestId,
|
||||
});
|
||||
|
||||
// if (strict) {
|
||||
// throw new ProcessError("未找到运行中的开发服务器进程", { projectId });
|
||||
// }
|
||||
|
||||
// 即使未找到进程,也尝试释放端口(可能进程已意外退出)
|
||||
portPool.release(String(projectId));
|
||||
log(projectId, "INFO", "Port released (process not running)", {
|
||||
projectId,
|
||||
requestId: req.requestId,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "No running process found",
|
||||
projectId,
|
||||
pid: null,
|
||||
};
|
||||
}
|
||||
|
||||
const results = [];
|
||||
|
||||
for (const thePid of uniquePids) {
|
||||
const killed = await killProcess(projectId, thePid);
|
||||
results.push({ pid: thePid, killed });
|
||||
|
||||
if (waitForStop && killed) {
|
||||
const { stopped, attempts } = await waitForProcessStop(projectId, thePid);
|
||||
log(projectId, stopped ? "INFO" : "WARN", stopped ? "Process confirmed stopped" : "Process stop timeout", {
|
||||
projectId,
|
||||
pid: thePid,
|
||||
attempts,
|
||||
requestId: req.requestId,
|
||||
});
|
||||
|
||||
if (!stopped && strict) {
|
||||
throw new ProcessError("Process stop timeout", {
|
||||
projectId,
|
||||
pid: thePid,
|
||||
attempts,
|
||||
});
|
||||
}
|
||||
} else if (!killed && strict) {
|
||||
throw new ProcessError("Failed to stop process", { projectId, pid: thePid });
|
||||
}
|
||||
}
|
||||
|
||||
// 清理项目下的所有临时日志文件
|
||||
try {
|
||||
const logDir = getLogDir(projectId);
|
||||
if (fs.existsSync(logDir)) {
|
||||
const files = fs.readdirSync(logDir);
|
||||
const tempFiles = files.filter(
|
||||
(file) => file.startsWith("dev-temp-") && file.endsWith(".log")
|
||||
);
|
||||
|
||||
if (tempFiles.length > 0) {
|
||||
let deletedCount = 0;
|
||||
for (const tempFile of tempFiles) {
|
||||
try {
|
||||
const tempFilePath = path.join(logDir, tempFile);
|
||||
fs.unlinkSync(tempFilePath);
|
||||
deletedCount++;
|
||||
} catch (err) {
|
||||
log(projectId, "WARN", "Failed to delete temporary log file", {
|
||||
projectId,
|
||||
tempFile,
|
||||
error: err.message,
|
||||
requestId: req.requestId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
log(projectId, "INFO", "Temporary log file cleanup completed", {
|
||||
projectId,
|
||||
deletedCount,
|
||||
totalTempFiles: tempFiles.length,
|
||||
requestId: req.requestId,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
log(projectId, "WARN", "Error cleaning temporary log files", {
|
||||
projectId,
|
||||
error: err.message,
|
||||
requestId: req.requestId,
|
||||
});
|
||||
}
|
||||
|
||||
// 删除该项目的日志缓存
|
||||
try {
|
||||
if (logCacheManager.isEnabled()) {
|
||||
logCacheManager.delete(projectId);
|
||||
log(projectId, "INFO", "Log cache cleaned", {
|
||||
projectId,
|
||||
requestId: req.requestId,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
log(projectId, "WARN", "Error cleaning log cache", {
|
||||
projectId,
|
||||
error: err.message,
|
||||
requestId: req.requestId,
|
||||
});
|
||||
}
|
||||
|
||||
const allKilled = results.every((r) => r.killed === true);
|
||||
|
||||
// 停止成功后释放端口
|
||||
if (allKilled && results.length > 0) {
|
||||
portPool.release(String(projectId));
|
||||
log(projectId, "INFO", "Port released", {
|
||||
projectId,
|
||||
requestId: req.requestId,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: allKilled ? "Stopped" : "Partially stopped but continue execution",
|
||||
projectId,
|
||||
pid: null,
|
||||
killedPids: results,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
async function stopDevServer(req, projectId, pid, options = {}) {
|
||||
return await stopDevServerByProjectId(req, projectId, options);
|
||||
}
|
||||
|
||||
export { stopDevServer };
|
||||
847
qiming-file-server/src/utils/build/syntaxCheckUtils.js
Normal file
847
qiming-file-server/src/utils/build/syntaxCheckUtils.js
Normal file
@@ -0,0 +1,847 @@
|
||||
import { spawn } from "child_process";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { log } from "../log/logUtils.js";
|
||||
|
||||
/**
|
||||
* 在启动开发服务器前进行语法检查
|
||||
* 根据项目配置自动选择检查方式:
|
||||
* - TypeScript 项目:运行 tsc --noEmit
|
||||
* - 纯 JavaScript 项目:使用 esbuild 进行快速语法检查
|
||||
* - HTML 文件:使用 html-validate 进行 HTML 语法检查
|
||||
*
|
||||
* @param {string} projectId 项目ID
|
||||
* @param {string} projectPath 项目路径
|
||||
* @param {number} timeoutMs 超时时间(毫秒)
|
||||
* @returns {Promise<Object>} { passed: boolean, error?: string, method?: string }
|
||||
*/
|
||||
async function runSyntaxCheck(projectId, projectPath, timeoutMs = 15000) {
|
||||
try {
|
||||
// 检查项目类型
|
||||
const projectType = detectProjectType(projectPath);
|
||||
|
||||
log(projectId, "INFO", "Start syntax check", {
|
||||
projectId,
|
||||
projectType,
|
||||
timeoutMs,
|
||||
});
|
||||
|
||||
const results = [];
|
||||
|
||||
// 1. 代码检查(TypeScript/JavaScript)
|
||||
if (projectType === "typescript") {
|
||||
const tsResult = await runTypeScriptCheck(projectId, projectPath, timeoutMs);
|
||||
results.push(tsResult);
|
||||
|
||||
// 如果 TS 检查失败,立即返回
|
||||
if (!tsResult.passed) {
|
||||
return tsResult;
|
||||
}
|
||||
} else if (projectType === "javascript") {
|
||||
const jsResult = await runJavaScriptCheck(projectId, projectPath, timeoutMs);
|
||||
results.push(jsResult);
|
||||
|
||||
// 如果 JS 检查失败,立即返回
|
||||
if (!jsResult.passed) {
|
||||
return jsResult;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. HTML 文件检查
|
||||
const htmlResult = await runHtmlCheck(projectId, projectPath, timeoutMs);
|
||||
results.push(htmlResult);
|
||||
|
||||
// 如果 HTML 检查失败,返回失败
|
||||
if (!htmlResult.passed) {
|
||||
return htmlResult;
|
||||
}
|
||||
|
||||
// 所有检查都通过
|
||||
if (results.length === 0) {
|
||||
log(projectId, "INFO", "Skip syntax check: no source code files detected", {
|
||||
projectId,
|
||||
});
|
||||
return { passed: true, method: "skipped" };
|
||||
}
|
||||
|
||||
// 返回综合结果
|
||||
const methods = results.map(r => r.method).filter(Boolean).join(", ");
|
||||
const totalDuration = results.reduce((sum, r) => sum + (r.duration || 0), 0);
|
||||
|
||||
log(projectId, "INFO", "All syntax checks passed", {
|
||||
projectId,
|
||||
methods,
|
||||
totalDuration,
|
||||
});
|
||||
|
||||
return {
|
||||
passed: true,
|
||||
method: methods,
|
||||
duration: totalDuration,
|
||||
};
|
||||
} catch (error) {
|
||||
log(projectId, "WARN", "Syntax check execution failed", {
|
||||
projectId,
|
||||
error: error.message,
|
||||
});
|
||||
// 检查失败不阻止启动
|
||||
return { passed: true, method: "error", error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检测项目类型
|
||||
* @param {string} projectPath 项目路径
|
||||
* @returns {string} "typescript" | "javascript" | "unknown"
|
||||
*/
|
||||
function detectProjectType(projectPath) {
|
||||
// 检查是否有 tsconfig.json
|
||||
const tsconfigPath = path.join(projectPath, "tsconfig.json");
|
||||
if (fs.existsSync(tsconfigPath)) {
|
||||
// 检查是否有 .ts 或 .tsx 文件
|
||||
const hasTsFiles = hasFilesWithExtension(projectPath, [".ts", ".tsx"]);
|
||||
if (hasTsFiles) {
|
||||
return "typescript";
|
||||
}
|
||||
}
|
||||
|
||||
// 检查是否有 .js 或 .jsx 文件
|
||||
const hasJsFiles = hasFilesWithExtension(projectPath, [".js", ".jsx"]);
|
||||
if (hasJsFiles) {
|
||||
return "javascript";
|
||||
}
|
||||
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查目录下是否存在指定扩展名的文件
|
||||
* @param {string} dir 目录路径
|
||||
* @param {Array<string>} extensions 扩展名列表
|
||||
* @param {number} maxDepth 最大搜索深度
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function hasFilesWithExtension(dir, extensions, maxDepth = 3) {
|
||||
try {
|
||||
// 排除的目录
|
||||
const excludeDirs = ["node_modules", ".git", "dist", "build", ".next", ".nuxt"];
|
||||
|
||||
function searchDir(currentDir, depth) {
|
||||
if (depth > maxDepth) return false;
|
||||
|
||||
const entries = fs.readdirSync(currentDir, { withFileTypes: true });
|
||||
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(currentDir, entry.name);
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
if (!excludeDirs.includes(entry.name)) {
|
||||
if (searchDir(fullPath, depth + 1)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
} else if (entry.isFile()) {
|
||||
const ext = path.extname(entry.name);
|
||||
if (extensions.includes(ext)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return searchDir(dir, 0);
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 运行 TypeScript 类型检查
|
||||
* @param {string} projectId 项目ID
|
||||
* @param {string} projectPath 项目路径
|
||||
* @param {number} timeoutMs 超时时间
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
async function runTypeScriptCheck(projectId, projectPath, timeoutMs) {
|
||||
return new Promise((resolve) => {
|
||||
const startTime = Date.now();
|
||||
|
||||
const tsconfigPath = path.join(projectPath, "tsconfig.json");
|
||||
const checkConfigPath = path.join(projectPath, "tsconfig.check.json");
|
||||
let createdCheckConfig = false;
|
||||
let createdTempTsconfig = false;
|
||||
|
||||
// 创建专门用于语法检查的配置
|
||||
const createCheckConfig = (hasTsconfig) => {
|
||||
const config = {
|
||||
// 明确指定要检查的所有文件模式
|
||||
"include": [
|
||||
"src/**/*.ts",
|
||||
"src/**/*.tsx",
|
||||
"src/**/*.js",
|
||||
"src/**/*.jsx",
|
||||
"*.ts",
|
||||
"*.tsx"
|
||||
],
|
||||
"exclude": ["node_modules", "dist", "build", ".next", ".nuxt", "**/*.spec.ts", "**/*.test.ts"]
|
||||
};
|
||||
|
||||
// 如果用户有 tsconfig.json,继承它的 compilerOptions
|
||||
if (hasTsconfig) {
|
||||
config.extends = "./tsconfig.json";
|
||||
log(projectId, "INFO", "Check configuration will inherit user's tsconfig.json", {
|
||||
projectId,
|
||||
});
|
||||
} else {
|
||||
// 如果没有 tsconfig.json,需要提供完整的 compilerOptions
|
||||
config.compilerOptions = {
|
||||
"target": "ESNext",
|
||||
"lib": ["ESNext", "DOM"],
|
||||
"jsx": "react-jsx",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": false,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true,
|
||||
"allowJs": true,
|
||||
"checkJs": false
|
||||
};
|
||||
}
|
||||
|
||||
return config;
|
||||
};
|
||||
|
||||
const hasTsconfig = fs.existsSync(tsconfigPath);
|
||||
|
||||
// 始终创建 tsconfig.check.json 用于检查
|
||||
try {
|
||||
const checkConfig = createCheckConfig(hasTsconfig);
|
||||
fs.writeFileSync(checkConfigPath, JSON.stringify(checkConfig, null, 2), "utf8");
|
||||
createdCheckConfig = true;
|
||||
|
||||
log(projectId, "INFO", "Create temporary tsconfig.check.json for syntax check", {
|
||||
projectId,
|
||||
hasTsconfig,
|
||||
extends: checkConfig.extends || "无",
|
||||
include: checkConfig.include,
|
||||
});
|
||||
} catch (error) {
|
||||
log(projectId, "WARN", "Failed to create tsconfig.check.json", {
|
||||
projectId,
|
||||
error: error.message,
|
||||
});
|
||||
|
||||
// 降级方案:如果无法创建检查配置且没有 tsconfig.json,创建临时的
|
||||
if (!hasTsconfig) {
|
||||
try {
|
||||
fs.writeFileSync(tsconfigPath, JSON.stringify(createCheckConfig(false), null, 2), "utf8");
|
||||
createdTempTsconfig = true;
|
||||
log(projectId, "INFO", "Create temporary tsconfig.json as fallback", {
|
||||
projectId,
|
||||
});
|
||||
} catch (e) {
|
||||
log(projectId, "ERROR", "Failed to create any TypeScript configuration file", {
|
||||
projectId,
|
||||
error: e.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 优先使用项目本地的 tsc,如果不存在则使用 npx
|
||||
const localTscPath = path.join(projectPath, "node_modules", ".bin", "tsc");
|
||||
const usesLocalTsc = fs.existsSync(localTscPath);
|
||||
|
||||
const command = usesLocalTsc ? localTscPath : "npx";
|
||||
// 使用 --project 参数指定检查配置文件
|
||||
const configToUse = createdCheckConfig ? "tsconfig.check.json" : "tsconfig.json";
|
||||
const args = usesLocalTsc
|
||||
? ["--project", configToUse, "--noEmit", "--skipLibCheck"]
|
||||
: ["--yes", "tsc", "--project", configToUse, "--noEmit", "--skipLibCheck"];
|
||||
|
||||
log(projectId, "INFO", "Run TypeScript type check", {
|
||||
projectId,
|
||||
command,
|
||||
args: args.join(" "),
|
||||
usesLocalTsc,
|
||||
configFile: configToUse,
|
||||
});
|
||||
|
||||
const child = spawn(command, args, {
|
||||
cwd: projectPath,
|
||||
shell: true,
|
||||
timeout: timeoutMs,
|
||||
});
|
||||
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
|
||||
child.stdout?.on("data", (data) => {
|
||||
stdout += data.toString();
|
||||
});
|
||||
|
||||
child.stderr?.on("data", (data) => {
|
||||
stderr += data.toString();
|
||||
});
|
||||
|
||||
// 清理临时文件的辅助函数
|
||||
const cleanupTempConfig = () => {
|
||||
// 清理检查配置文件
|
||||
if (createdCheckConfig) {
|
||||
try {
|
||||
if (fs.existsSync(checkConfigPath)) {
|
||||
fs.unlinkSync(checkConfigPath);
|
||||
log(projectId, "INFO", "Cleaned temporary tsconfig.check.json", {
|
||||
projectId,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
log(projectId, "WARN", "Failed to clean tsconfig.check.json", {
|
||||
projectId,
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 清理临时创建的 tsconfig.json(降级方案)
|
||||
if (createdTempTsconfig) {
|
||||
try {
|
||||
if (fs.existsSync(tsconfigPath)) {
|
||||
fs.unlinkSync(tsconfigPath);
|
||||
log(projectId, "INFO", "Cleaned temporary tsconfig.json", {
|
||||
projectId,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
log(projectId, "WARN", "Failed to clean tsconfig.json", {
|
||||
projectId,
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
child.on("close", (code) => {
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
// 清理临时配置
|
||||
cleanupTempConfig();
|
||||
|
||||
if (code === 0) {
|
||||
log(projectId, "INFO", "TypeScript type check passed", {
|
||||
projectId,
|
||||
duration,
|
||||
});
|
||||
resolve({ passed: true, method: "typescript", duration });
|
||||
} else {
|
||||
// 提取错误信息
|
||||
const errorOutput = stderr || stdout;
|
||||
const errorSummary = extractTypeScriptErrors(errorOutput);
|
||||
|
||||
log(projectId, "ERROR", "TypeScript type check failed", {
|
||||
projectId,
|
||||
code,
|
||||
duration,
|
||||
errorSummary,
|
||||
});
|
||||
|
||||
resolve({
|
||||
passed: false,
|
||||
method: "typescript",
|
||||
error: errorSummary,
|
||||
fullOutput: errorOutput.substring(0, 2000), // 限制长度
|
||||
duration,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
child.on("error", (error) => {
|
||||
// 清理临时配置
|
||||
cleanupTempConfig();
|
||||
|
||||
log(projectId, "WARN", "TypeScript check execution failed", {
|
||||
projectId,
|
||||
error: error.message,
|
||||
});
|
||||
// 执行失败不阻止启动
|
||||
resolve({ passed: true, method: "typescript-error", error: error.message });
|
||||
});
|
||||
|
||||
// 超时处理
|
||||
setTimeout(() => {
|
||||
try {
|
||||
child.kill();
|
||||
// 清理临时配置
|
||||
cleanupTempConfig();
|
||||
|
||||
log(projectId, "WARN", "TypeScript check timeout", {
|
||||
projectId,
|
||||
timeoutMs,
|
||||
});
|
||||
resolve({ passed: true, method: "typescript-timeout" });
|
||||
} catch (e) {
|
||||
cleanupTempConfig();
|
||||
resolve({ passed: true, method: "typescript-timeout-error" });
|
||||
}
|
||||
}, timeoutMs);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 运行 JavaScript 语法检查(使用 esbuild)
|
||||
* @param {string} projectId 项目ID
|
||||
* @param {string} projectPath 项目路径
|
||||
* @param {number} timeoutMs 超时时间
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
async function runJavaScriptCheck(projectId, projectPath, timeoutMs) {
|
||||
return new Promise((resolve) => {
|
||||
const startTime = Date.now();
|
||||
|
||||
// 查找入口文件
|
||||
const entryFiles = findEntryFiles(projectPath);
|
||||
|
||||
if (entryFiles.length === 0) {
|
||||
log(projectId, "INFO", "Skip JavaScript syntax check: no entry files found", {
|
||||
projectId,
|
||||
});
|
||||
resolve({ passed: true, method: "javascript-skipped" });
|
||||
return;
|
||||
}
|
||||
|
||||
// 使用 esbuild 进行快速语法检查(不输出文件)
|
||||
const args = [
|
||||
"--yes",
|
||||
"esbuild",
|
||||
...entryFiles,
|
||||
"--bundle",
|
||||
"--write=false",
|
||||
"--outdir=/tmp",
|
||||
"--format=esm",
|
||||
];
|
||||
|
||||
log(projectId, "INFO", "Run JavaScript syntax check", {
|
||||
projectId,
|
||||
entryFiles,
|
||||
});
|
||||
|
||||
const child = spawn("npx", args, {
|
||||
cwd: projectPath,
|
||||
shell: true,
|
||||
timeout: timeoutMs,
|
||||
});
|
||||
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
|
||||
child.stdout?.on("data", (data) => {
|
||||
stdout += data.toString();
|
||||
});
|
||||
|
||||
child.stderr?.on("data", (data) => {
|
||||
stderr += data.toString();
|
||||
});
|
||||
|
||||
child.on("close", (code) => {
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
if (code === 0) {
|
||||
log(projectId, "INFO", "JavaScript syntax check passed", {
|
||||
projectId,
|
||||
duration,
|
||||
});
|
||||
resolve({ passed: true, method: "javascript", duration });
|
||||
} else {
|
||||
const errorOutput = stderr || stdout;
|
||||
const errorSummary = errorOutput.substring(0, 1000);
|
||||
|
||||
log(projectId, "ERROR", "JavaScript syntax check failed", {
|
||||
projectId,
|
||||
code,
|
||||
duration,
|
||||
errorSummary,
|
||||
});
|
||||
|
||||
resolve({
|
||||
passed: false,
|
||||
method: "javascript",
|
||||
error: errorSummary,
|
||||
fullOutput: errorOutput.substring(0, 2000),
|
||||
duration,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
child.on("error", (error) => {
|
||||
log(projectId, "WARN", "JavaScript check execution failed", {
|
||||
projectId,
|
||||
error: error.message,
|
||||
});
|
||||
resolve({ passed: true, method: "javascript-error", error: error.message });
|
||||
});
|
||||
|
||||
// 超时处理
|
||||
setTimeout(() => {
|
||||
try {
|
||||
child.kill();
|
||||
log(projectId, "WARN", "JavaScript check timeout", {
|
||||
projectId,
|
||||
timeoutMs,
|
||||
});
|
||||
resolve({ passed: true, method: "javascript-timeout" });
|
||||
} catch (e) {
|
||||
resolve({ passed: true, method: "javascript-timeout-error" });
|
||||
}
|
||||
}, timeoutMs);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 查找入口文件
|
||||
* @param {string} projectPath 项目路径
|
||||
* @returns {Array<string>} 入口文件路径列表
|
||||
*/
|
||||
function findEntryFiles(projectPath) {
|
||||
const possibleEntries = [
|
||||
"src/main.js",
|
||||
"src/main.jsx",
|
||||
"src/main.ts",
|
||||
"src/main.tsx",
|
||||
"src/index.js",
|
||||
"src/index.jsx",
|
||||
"src/index.ts",
|
||||
"src/index.tsx",
|
||||
"src/app.js",
|
||||
"src/app.jsx",
|
||||
"src/App.js",
|
||||
"src/App.jsx",
|
||||
"index.js",
|
||||
"index.jsx",
|
||||
"index.ts",
|
||||
"index.tsx",
|
||||
];
|
||||
|
||||
const entries = [];
|
||||
for (const entry of possibleEntries) {
|
||||
const fullPath = path.join(projectPath, entry);
|
||||
if (fs.existsSync(fullPath)) {
|
||||
entries.push(entry);
|
||||
}
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
/**
|
||||
* 确保项目有 HTML 验证配置文件(如果不存在则创建宽松配置)
|
||||
* @param {string} projectPath 项目路径
|
||||
* @returns {boolean} 是否创建了新配置
|
||||
*/
|
||||
function ensureHtmlValidateConfig(projectPath) {
|
||||
const configPath = path.join(projectPath, ".htmlvalidate.json");
|
||||
|
||||
// 如果已经存在配置文件,不覆盖
|
||||
if (fs.existsSync(configPath)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 创建宽松的默认配置
|
||||
const defaultConfig = {
|
||||
"extends": ["html-validate:recommended"],
|
||||
"rules": {
|
||||
// 允许 void elements 不自闭合(HTML5 标准)
|
||||
"void-style": "off",
|
||||
// 允许不需要 SRI
|
||||
"require-sri": "off",
|
||||
// 允许尾部空白
|
||||
"no-trailing-whitespace": "off",
|
||||
// 允许内联样式(开发时常用)
|
||||
"no-inline-style": "off",
|
||||
// 允许重复的 ID(某些框架会动态处理)
|
||||
"no-dup-id": "warn",
|
||||
// 允许未使用的 disable 指令
|
||||
"no-unused-disable": "off"
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
fs.writeFileSync(configPath, JSON.stringify(defaultConfig, null, 2), "utf8");
|
||||
return true;
|
||||
} catch (error) {
|
||||
// 创建失败不影响检查,使用默认规则
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 运行 HTML 语法检查
|
||||
* @param {string} projectId 项目ID
|
||||
* @param {string} projectPath 项目路径
|
||||
* @param {number} timeoutMs 超时时间
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
async function runHtmlCheck(projectId, projectPath, timeoutMs) {
|
||||
return new Promise((resolve) => {
|
||||
const startTime = Date.now();
|
||||
|
||||
// 查找 HTML 文件
|
||||
const htmlFiles = findHtmlFiles(projectPath);
|
||||
|
||||
if (htmlFiles.length === 0) {
|
||||
log(projectId, "INFO", "Skip HTML syntax check: no HTML files found", {
|
||||
projectId,
|
||||
});
|
||||
resolve({ passed: true, method: "html-skipped" });
|
||||
return;
|
||||
}
|
||||
|
||||
// 确保有宽松的验证配置
|
||||
const configCreated = ensureHtmlValidateConfig(projectPath);
|
||||
if (configCreated) {
|
||||
log(projectId, "INFO", "Automatically create宽松的 HTML 验证配置", {
|
||||
projectId,
|
||||
configPath: ".htmlvalidate.json",
|
||||
});
|
||||
}
|
||||
|
||||
// 使用 html-validate 进行 HTML 语法检查
|
||||
const args = [
|
||||
"--yes",
|
||||
"html-validate",
|
||||
...htmlFiles,
|
||||
"--formatter=text",
|
||||
];
|
||||
|
||||
log(projectId, "INFO", "Run HTML syntax check", {
|
||||
projectId,
|
||||
htmlFiles,
|
||||
fileCount: htmlFiles.length,
|
||||
});
|
||||
|
||||
const child = spawn("npx", args, {
|
||||
cwd: projectPath,
|
||||
shell: true,
|
||||
timeout: timeoutMs,
|
||||
});
|
||||
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
|
||||
child.stdout?.on("data", (data) => {
|
||||
stdout += data.toString();
|
||||
});
|
||||
|
||||
child.stderr?.on("data", (data) => {
|
||||
stderr += data.toString();
|
||||
});
|
||||
|
||||
child.on("close", (code) => {
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
if (code === 0) {
|
||||
log(projectId, "INFO", "HTML syntax check passed", {
|
||||
projectId,
|
||||
duration,
|
||||
fileCount: htmlFiles.length,
|
||||
});
|
||||
resolve({ passed: true, method: "html", duration });
|
||||
} else {
|
||||
const errorOutput = stdout || stderr;
|
||||
const errorSummary = extractHtmlErrors(errorOutput);
|
||||
|
||||
log(projectId, "ERROR", "HTML syntax check failed", {
|
||||
projectId,
|
||||
code,
|
||||
duration,
|
||||
errorSummary,
|
||||
});
|
||||
|
||||
resolve({
|
||||
passed: false,
|
||||
method: "html",
|
||||
error: errorSummary,
|
||||
fullOutput: errorOutput.substring(0, 2000),
|
||||
duration,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
child.on("error", (error) => {
|
||||
log(projectId, "WARN", "HTML check execution failed", {
|
||||
projectId,
|
||||
error: error.message,
|
||||
});
|
||||
// 执行失败不阻止启动
|
||||
resolve({ passed: true, method: "html-error", error: error.message });
|
||||
});
|
||||
|
||||
// 超时处理
|
||||
setTimeout(() => {
|
||||
try {
|
||||
child.kill();
|
||||
log(projectId, "WARN", "HTML check timeout", {
|
||||
projectId,
|
||||
timeoutMs,
|
||||
});
|
||||
resolve({ passed: true, method: "html-timeout" });
|
||||
} catch (e) {
|
||||
resolve({ passed: true, method: "html-timeout-error" });
|
||||
}
|
||||
}, timeoutMs);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 查找 HTML 文件
|
||||
* @param {string} projectPath 项目路径
|
||||
* @returns {Array<string>} HTML 文件相对路径列表
|
||||
*/
|
||||
function findHtmlFiles(projectPath) {
|
||||
const htmlFiles = [];
|
||||
|
||||
// 常见的 HTML 文件位置
|
||||
const possibleLocations = [
|
||||
"index.html",
|
||||
"public/index.html",
|
||||
"src/index.html",
|
||||
"dist/index.html",
|
||||
];
|
||||
|
||||
// 检查常见位置
|
||||
for (const location of possibleLocations) {
|
||||
const fullPath = path.join(projectPath, location);
|
||||
if (fs.existsSync(fullPath)) {
|
||||
htmlFiles.push(location);
|
||||
}
|
||||
}
|
||||
|
||||
// 如果没找到,递归搜索 public 和 src 目录
|
||||
if (htmlFiles.length === 0) {
|
||||
const dirsToSearch = ["public", "src", "."];
|
||||
|
||||
for (const dir of dirsToSearch) {
|
||||
const dirPath = path.join(projectPath, dir);
|
||||
if (fs.existsSync(dirPath)) {
|
||||
const found = searchHtmlFilesInDir(dirPath, projectPath, 2);
|
||||
htmlFiles.push(...found);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 去重
|
||||
return [...new Set(htmlFiles)];
|
||||
}
|
||||
|
||||
/**
|
||||
* 递归搜索目录中的 HTML 文件
|
||||
* @param {string} dir 要搜索的目录
|
||||
* @param {string} basePath 基础路径(用于生成相对路径)
|
||||
* @param {number} maxDepth 最大搜索深度
|
||||
* @returns {Array<string>} HTML 文件相对路径列表
|
||||
*/
|
||||
function searchHtmlFilesInDir(dir, basePath, maxDepth = 2) {
|
||||
const htmlFiles = [];
|
||||
const excludeDirs = ["node_modules", ".git", "dist", "build", ".next", ".nuxt"];
|
||||
|
||||
function search(currentDir, depth) {
|
||||
if (depth > maxDepth) return;
|
||||
|
||||
try {
|
||||
const entries = fs.readdirSync(currentDir, { withFileTypes: true });
|
||||
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(currentDir, entry.name);
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
if (!excludeDirs.includes(entry.name)) {
|
||||
search(fullPath, depth + 1);
|
||||
}
|
||||
} else if (entry.isFile() && entry.name.endsWith(".html")) {
|
||||
// 生成相对路径
|
||||
const relativePath = path.relative(basePath, fullPath);
|
||||
htmlFiles.push(relativePath);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// 忽略读取错误
|
||||
}
|
||||
}
|
||||
|
||||
search(dir, 0);
|
||||
return htmlFiles;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 HTML 错误输出中提取关键错误信息
|
||||
* @param {string} output 错误输出
|
||||
* @returns {string} 错误摘要
|
||||
*/
|
||||
function extractHtmlErrors(output) {
|
||||
if (!output) return "Unknown error";
|
||||
|
||||
const lines = output.split("\n");
|
||||
const errorLines = [];
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
|
||||
// 提取 html-validate 的错误行
|
||||
// 格式: "error: ..." 或 "✖ ..." 或包含文件路径和行号
|
||||
if (
|
||||
trimmed.includes("error:") ||
|
||||
trimmed.includes("✖") ||
|
||||
/\.html:\d+:\d+/.test(trimmed) ||
|
||||
trimmed.includes("Element") ||
|
||||
trimmed.includes("Attribute")
|
||||
) {
|
||||
errorLines.push(trimmed);
|
||||
if (errorLines.length >= 10) break; // 保留前10个错误
|
||||
}
|
||||
}
|
||||
|
||||
if (errorLines.length > 0) {
|
||||
return errorLines.join("\n");
|
||||
}
|
||||
|
||||
// 如果没有找到特定格式的错误,返回前几行
|
||||
return lines.slice(0, 15).join("\n").trim().substring(0, 800);
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 TypeScript 错误输出中提取关键错误信息
|
||||
* @param {string} output 错误输出
|
||||
* @returns {string} 错误摘要
|
||||
*/
|
||||
function extractTypeScriptErrors(output) {
|
||||
if (!output) return "Unknown error";
|
||||
|
||||
const lines = output.split("\n");
|
||||
const errorLines = [];
|
||||
|
||||
for (const line of lines) {
|
||||
// 提取错误行(包含文件路径和错误信息)
|
||||
if (line.includes("error TS") || line.includes("): error")) {
|
||||
errorLines.push(line.trim());
|
||||
if (errorLines.length >= 5) break; // 只保留前5个错误
|
||||
}
|
||||
}
|
||||
|
||||
if (errorLines.length > 0) {
|
||||
return errorLines.join("\n");
|
||||
}
|
||||
|
||||
// 如果没有找到特定格式的错误,返回前几行
|
||||
return lines.slice(0, 10).join("\n").trim().substring(0, 500);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
runSyntaxCheck,
|
||||
detectProjectType,
|
||||
findHtmlFiles,
|
||||
ensureHtmlValidateConfig,
|
||||
};
|
||||
|
||||
2
qiming-mcp-proxy/.cargo/config.toml
Normal file
2
qiming-mcp-proxy/.cargo/config.toml
Normal file
@@ -0,0 +1,2 @@
|
||||
[env]
|
||||
CMAKE_CUDA_ARCHITECTURES = "60-real;61-real;62-real;70-real;75-real;80-real;86-real"
|
||||
38
qiming-mcp-proxy/.devcontainer/devcontainer.json
Normal file
38
qiming-mcp-proxy/.devcontainer/devcontainer.json
Normal file
@@ -0,0 +1,38 @@
|
||||
// For format details, see https://aka.ms/devcontainer.json. For config options, see the
|
||||
// README at: https://github.com/devcontainers/templates/tree/main/src/rust
|
||||
{
|
||||
"name": "Rust",
|
||||
// Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile
|
||||
"image": "mcr.microsoft.com/devcontainers/rust:1-1-bullseye",
|
||||
"features": {
|
||||
"ghcr.io/devcontainers/features/go:1": {},
|
||||
"ghcr.io/devcontainers-community/features/deno:1": {},
|
||||
"ghcr.io/va-h/devcontainers-features/uv:1": {},
|
||||
"ghcr.io/devcontainers-extra/features/curl-apt-get:1": {},
|
||||
"ghcr.io/devcontainers-extra/features/wget-apt-get:1": {}
|
||||
}
|
||||
|
||||
// Use 'mounts' to make the cargo cache persistent in a Docker Volume.
|
||||
// "mounts": [
|
||||
// {
|
||||
// "source": "devcontainer-cargo-cache-${devcontainerId}",
|
||||
// "target": "/usr/local/cargo",
|
||||
// "type": "volume"
|
||||
// }
|
||||
// ]
|
||||
|
||||
// Features to add to the dev container. More info: https://containers.dev/features.
|
||||
// "features": {},
|
||||
|
||||
// Use 'forwardPorts' to make a list of ports inside the container available locally.
|
||||
// "forwardPorts": [],
|
||||
|
||||
// Use 'postCreateCommand' to run commands after the container is created.
|
||||
// "postCreateCommand": "rustc --version",
|
||||
|
||||
// Configure tool-specific properties.
|
||||
// "customizations": {},
|
||||
|
||||
// Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root.
|
||||
// "remoteUser": "root"
|
||||
}
|
||||
74
qiming-mcp-proxy/.dockerignore
Normal file
74
qiming-mcp-proxy/.dockerignore
Normal file
@@ -0,0 +1,74 @@
|
||||
# Git
|
||||
.git
|
||||
.gitignore
|
||||
.github
|
||||
|
||||
# IDE
|
||||
.vscode
|
||||
.idea
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# Build artifacts
|
||||
target/
|
||||
dist/
|
||||
*.exe
|
||||
*.dll
|
||||
*.so
|
||||
*.dylib
|
||||
|
||||
# Temp files
|
||||
temp/
|
||||
tmp/
|
||||
*.tmp
|
||||
*.log
|
||||
logs/
|
||||
|
||||
# Cargo
|
||||
.cargo/
|
||||
|
||||
# Docs
|
||||
*.md
|
||||
README*
|
||||
CHANGELOG*
|
||||
LICENSE*
|
||||
LICENSE-*
|
||||
|
||||
# Config
|
||||
.env*
|
||||
!.env.example
|
||||
|
||||
# Tests
|
||||
**/tests/
|
||||
benches/
|
||||
examples/
|
||||
fixtures/
|
||||
|
||||
# Misc
|
||||
.DS_Store
|
||||
Thumb.db
|
||||
|
||||
# Project specific
|
||||
data/
|
||||
assets/
|
||||
scripts/
|
||||
spec/
|
||||
rmcp_code/
|
||||
.cursor/
|
||||
.devcontainer/
|
||||
.kiro/
|
||||
.trae/
|
||||
|
||||
# CI/CD
|
||||
.github/workflows/
|
||||
|
||||
# Config files
|
||||
.pre-commit-config.yaml
|
||||
_typos.toml
|
||||
cliff.toml
|
||||
deny.toml
|
||||
|
||||
# Keep essential files
|
||||
!Cargo.toml
|
||||
!Cargo.lock
|
||||
479
qiming-mcp-proxy/.github/workflows/release.yml
vendored
Normal file
479
qiming-mcp-proxy/.github/workflows/release.yml
vendored
Normal file
@@ -0,0 +1,479 @@
|
||||
# This file was autogenerated by dist: https://axodotdev.github.io/cargo-dist
|
||||
#
|
||||
# Copyright 2022-2024, axodotdev
|
||||
# SPDX-License-Identifier: MIT or Apache-2.0
|
||||
#
|
||||
# CI that:
|
||||
#
|
||||
# * checks for a Git Tag that looks like a release
|
||||
# * builds artifacts with dist (archives, installers, hashes)
|
||||
# * uploads those artifacts to temporary workflow zip
|
||||
# * on success, uploads the artifacts to a GitHub Release
|
||||
#
|
||||
# Note that the GitHub Release will be created with a generated
|
||||
# title/body based on your changelogs.
|
||||
|
||||
name: Release
|
||||
permissions:
|
||||
"contents": "write"
|
||||
|
||||
# This task will run whenever you push a git tag that looks like a version
|
||||
# like "1.0.0", "v0.1.0-prerelease.1", "my-app/0.1.0", "releases/v1.0.0", etc.
|
||||
# Various formats will be parsed into a VERSION and an optional PACKAGE_NAME, where
|
||||
# PACKAGE_NAME must be the name of a Cargo package in your workspace, and VERSION
|
||||
# must be a Cargo-style SemVer Version (must have at least major.minor.patch).
|
||||
#
|
||||
# If PACKAGE_NAME is specified, then the announcement will be for that
|
||||
# package (erroring out if it doesn't have the given version or isn't dist-able).
|
||||
#
|
||||
# If PACKAGE_NAME isn't specified, then the announcement will be for all
|
||||
# (dist-able) packages in the workspace with that version (this mode is
|
||||
# intended for workspaces with only one dist-able package, or with all dist-able
|
||||
# packages versioned/released in lockstep).
|
||||
#
|
||||
# If you push multiple tags at once, separate instances of this workflow will
|
||||
# spin up, creating an independent announcement for each one. However, GitHub
|
||||
# will hard limit this to 3 tags per commit, as it will assume more tags is a
|
||||
# mistake.
|
||||
#
|
||||
# If there's a prerelease-style suffix to the version, then the release(s)
|
||||
# will be marked as a prerelease.
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
tags:
|
||||
- '**[0-9]+.[0-9]+.[0-9]+*'
|
||||
|
||||
jobs:
|
||||
# Run 'dist plan' (or host) to determine what tasks we need to do
|
||||
plan:
|
||||
runs-on: "ubuntu-22.04"
|
||||
outputs:
|
||||
val: ${{ steps.plan.outputs.manifest }}
|
||||
tag: ${{ !github.event.pull_request && github.ref_name || '' }}
|
||||
tag-flag: ${{ !github.event.pull_request && format('--tag={0}', github.ref_name) || '' }}
|
||||
publishing: ${{ !github.event.pull_request }}
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
submodules: recursive
|
||||
- name: Install dist
|
||||
# we specify bash to get pipefail; it guards against the `curl` command
|
||||
# failing. otherwise `sh` won't catch that `curl` returned non-0
|
||||
shell: bash
|
||||
run: "curl --proto '=https' --tlsv1.2 -LsSf https://github.com/axodotdev/cargo-dist/releases/download/v0.30.3/cargo-dist-installer.sh | sh"
|
||||
- name: Cache dist
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: cargo-dist-cache
|
||||
path: ~/.cargo/bin/dist
|
||||
# sure would be cool if github gave us proper conditionals...
|
||||
# so here's a doubly-nested ternary-via-truthiness to try to provide the best possible
|
||||
# functionality based on whether this is a pull_request, and whether it's from a fork.
|
||||
# (PRs run on the *source* but secrets are usually on the *target* -- that's *good*
|
||||
# but also really annoying to build CI around when it needs secrets to work right.)
|
||||
- id: plan
|
||||
run: |
|
||||
dist ${{ (!github.event.pull_request && format('host --steps=create --tag={0}', github.ref_name)) || 'plan' }} --output-format=json > plan-dist-manifest.json
|
||||
echo "dist ran successfully"
|
||||
cat plan-dist-manifest.json
|
||||
echo "manifest=$(jq -c "." plan-dist-manifest.json)" >> "$GITHUB_OUTPUT"
|
||||
- name: "Upload dist-manifest.json"
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: artifacts-plan-dist-manifest
|
||||
path: plan-dist-manifest.json
|
||||
|
||||
# Build and packages all the platform-specific things
|
||||
build-local-artifacts:
|
||||
name: build-local-artifacts (${{ join(matrix.targets, ', ') }})
|
||||
# Let the initial task tell us to not run (currently very blunt)
|
||||
needs:
|
||||
- plan
|
||||
if: ${{ fromJson(needs.plan.outputs.val).ci.github.artifacts_matrix.include != null && (needs.plan.outputs.publishing == 'true' || fromJson(needs.plan.outputs.val).ci.github.pr_run_mode == 'upload') }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
# Target platforms/runners are computed by dist in create-release.
|
||||
# Each member of the matrix has the following arguments:
|
||||
#
|
||||
# - runner: the github runner
|
||||
# - dist-args: cli flags to pass to dist
|
||||
# - install-dist: expression to run to install dist on the runner
|
||||
#
|
||||
# Typically there will be:
|
||||
# - 1 "global" task that builds universal installers
|
||||
# - N "local" tasks that build each platform's binaries and platform-specific installers
|
||||
matrix: ${{ fromJson(needs.plan.outputs.val).ci.github.artifacts_matrix }}
|
||||
runs-on: ${{ matrix.runner }}
|
||||
container: ${{ matrix.container && matrix.container.image || null }}
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
BUILD_MANIFEST_NAME: target/distrib/${{ join(matrix.targets, '-') }}-dist-manifest.json
|
||||
steps:
|
||||
- name: enable windows longpaths
|
||||
run: |
|
||||
git config --global core.longpaths true
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
submodules: recursive
|
||||
- name: Install Rust non-interactively if not already installed
|
||||
if: ${{ matrix.container }}
|
||||
run: |
|
||||
if ! command -v cargo > /dev/null 2>&1; then
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
|
||||
echo "$HOME/.cargo/bin" >> $GITHUB_PATH
|
||||
fi
|
||||
- name: Install dist
|
||||
run: ${{ matrix.install_dist.run }}
|
||||
# Get the dist-manifest
|
||||
- name: Fetch local artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
pattern: artifacts-*
|
||||
path: target/distrib/
|
||||
merge-multiple: true
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
${{ matrix.packages_install }}
|
||||
- name: Build artifacts
|
||||
run: |
|
||||
# Actually do builds and make zips and whatnot
|
||||
dist build ${{ needs.plan.outputs.tag-flag }} --print=linkage --output-format=json ${{ matrix.dist_args }} > dist-manifest.json
|
||||
echo "dist ran successfully"
|
||||
- id: cargo-dist
|
||||
name: Post-build
|
||||
# We force bash here just because github makes it really hard to get values up
|
||||
# to "real" actions without writing to env-vars, and writing to env-vars has
|
||||
# inconsistent syntax between shell and powershell.
|
||||
shell: bash
|
||||
run: |
|
||||
# Parse out what we just built and upload it to scratch storage
|
||||
echo "paths<<EOF" >> "$GITHUB_OUTPUT"
|
||||
dist print-upload-files-from-manifest --manifest dist-manifest.json >> "$GITHUB_OUTPUT"
|
||||
echo "EOF" >> "$GITHUB_OUTPUT"
|
||||
|
||||
cp dist-manifest.json "$BUILD_MANIFEST_NAME"
|
||||
- name: "Upload artifacts"
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: artifacts-build-local-${{ join(matrix.targets, '_') }}
|
||||
path: |
|
||||
${{ steps.cargo-dist.outputs.paths }}
|
||||
${{ env.BUILD_MANIFEST_NAME }}
|
||||
|
||||
# Build and package all the platform-agnostic(ish) things
|
||||
build-global-artifacts:
|
||||
needs:
|
||||
- plan
|
||||
- build-local-artifacts
|
||||
runs-on: "ubuntu-22.04"
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
BUILD_MANIFEST_NAME: target/distrib/global-dist-manifest.json
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
submodules: recursive
|
||||
- name: Install cached dist
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: cargo-dist-cache
|
||||
path: ~/.cargo/bin/
|
||||
- run: chmod +x ~/.cargo/bin/dist
|
||||
# Get all the local artifacts for the global tasks to use (for e.g. checksums)
|
||||
- name: Fetch local artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
pattern: artifacts-*
|
||||
path: target/distrib/
|
||||
merge-multiple: true
|
||||
- id: cargo-dist
|
||||
shell: bash
|
||||
run: |
|
||||
dist build ${{ needs.plan.outputs.tag-flag }} --output-format=json "--artifacts=global" > dist-manifest.json
|
||||
echo "dist ran successfully"
|
||||
|
||||
# Parse out what we just built and upload it to scratch storage
|
||||
echo "paths<<EOF" >> "$GITHUB_OUTPUT"
|
||||
jq --raw-output ".upload_files[]" dist-manifest.json >> "$GITHUB_OUTPUT"
|
||||
echo "EOF" >> "$GITHUB_OUTPUT"
|
||||
|
||||
cp dist-manifest.json "$BUILD_MANIFEST_NAME"
|
||||
- name: "Upload artifacts"
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: artifacts-build-global
|
||||
path: |
|
||||
${{ steps.cargo-dist.outputs.paths }}
|
||||
${{ env.BUILD_MANIFEST_NAME }}
|
||||
# Determines if we should publish/announce
|
||||
host:
|
||||
needs:
|
||||
- plan
|
||||
- build-local-artifacts
|
||||
- build-global-artifacts
|
||||
# Only run if we're "publishing", and only if plan, local and global didn't fail (skipped is fine)
|
||||
if: ${{ always() && needs.plan.result == 'success' && needs.plan.outputs.publishing == 'true' && (needs.build-global-artifacts.result == 'skipped' || needs.build-global-artifacts.result == 'success') && (needs.build-local-artifacts.result == 'skipped' || needs.build-local-artifacts.result == 'success') }}
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
runs-on: "ubuntu-22.04"
|
||||
outputs:
|
||||
val: ${{ steps.host.outputs.manifest }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
submodules: recursive
|
||||
- name: Install cached dist
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: cargo-dist-cache
|
||||
path: ~/.cargo/bin/
|
||||
- run: chmod +x ~/.cargo/bin/dist
|
||||
# Fetch artifacts from scratch-storage
|
||||
- name: Fetch artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
pattern: artifacts-*
|
||||
path: target/distrib/
|
||||
merge-multiple: true
|
||||
- id: host
|
||||
shell: bash
|
||||
run: |
|
||||
dist host ${{ needs.plan.outputs.tag-flag }} --steps=upload --steps=release --output-format=json > dist-manifest.json
|
||||
echo "artifacts uploaded and released successfully"
|
||||
cat dist-manifest.json
|
||||
echo "manifest=$(jq -c "." dist-manifest.json)" >> "$GITHUB_OUTPUT"
|
||||
- name: "Upload dist-manifest.json"
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
# Overwrite the previous copy
|
||||
name: artifacts-dist-manifest
|
||||
path: dist-manifest.json
|
||||
# Create a GitHub Release while uploading all files to it
|
||||
- name: "Download GitHub Artifacts"
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
pattern: artifacts-*
|
||||
path: artifacts
|
||||
merge-multiple: true
|
||||
- name: Cleanup
|
||||
run: |
|
||||
# Remove the granular manifests
|
||||
rm -f artifacts/*-dist-manifest.json
|
||||
- name: Create GitHub Release
|
||||
env:
|
||||
PRERELEASE_FLAG: "${{ fromJson(steps.host.outputs.manifest).announcement_is_prerelease && '--prerelease' || '' }}"
|
||||
ANNOUNCEMENT_TITLE: "${{ fromJson(steps.host.outputs.manifest).announcement_title }}"
|
||||
ANNOUNCEMENT_BODY: "${{ fromJson(steps.host.outputs.manifest).announcement_github_body }}"
|
||||
RELEASE_COMMIT: "${{ github.sha }}"
|
||||
run: |
|
||||
# Write and read notes from a file to avoid quoting breaking things
|
||||
echo "$ANNOUNCEMENT_BODY" > $RUNNER_TEMP/notes.txt
|
||||
|
||||
gh release create "${{ needs.plan.outputs.tag }}" --target "$RELEASE_COMMIT" $PRERELEASE_FLAG --title "$ANNOUNCEMENT_TITLE" --notes-file "$RUNNER_TEMP/notes.txt" artifacts/*
|
||||
|
||||
sync-to-oss:
|
||||
name: Sync release assets to Alibaba Cloud OSS
|
||||
needs:
|
||||
- plan
|
||||
- host
|
||||
runs-on: "ubuntu-22.04"
|
||||
# Only run during actual release, not in PR mode
|
||||
if: ${{ needs.plan.outputs.publishing == 'true' }}
|
||||
continue-on-error: true
|
||||
outputs:
|
||||
oss_upload_success: ${{ steps.verify.outputs.oss_upload_success }}
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TAG: ${{ needs.plan.outputs.tag }}
|
||||
steps:
|
||||
- name: Install ossutil v2
|
||||
run: |
|
||||
curl -fSL -o /tmp/ossutil.zip \
|
||||
"https://gosspublic.alicdn.com/ossutil/v2/2.2.0/ossutil-2.2.0-linux-amd64.zip"
|
||||
unzip -q /tmp/ossutil.zip -d /tmp
|
||||
sudo mv /tmp/ossutil-2.2.0-linux-amd64/ossutil /usr/local/bin/
|
||||
ossutil version
|
||||
|
||||
- name: Download release assets
|
||||
run: |
|
||||
mkdir -p /tmp/release-assets
|
||||
gh release download "$TAG" --repo "$GITHUB_REPOSITORY" --dir /tmp/release-assets
|
||||
echo "==> Downloaded assets:"
|
||||
ls -lh /tmp/release-assets/
|
||||
|
||||
- name: Upload assets to OSS
|
||||
env:
|
||||
OSS_ACCESS_KEY_ID: ${{ secrets.OSS_ACCESS_KEY_ID }}
|
||||
OSS_ACCESS_KEY_SECRET: ${{ secrets.OSS_ACCESS_KEY_SECRET }}
|
||||
run: |
|
||||
OSS_ENDPOINT="https://oss-rg-china-mainland.aliyuncs.com"
|
||||
OSS_REGION="rg-china-mainland"
|
||||
OSS_BUCKET="oss://nuwa-packages"
|
||||
OSS_PREFIX="mcp-stdio-proxy/${TAG}"
|
||||
|
||||
for file in /tmp/release-assets/*.tar.xz /tmp/release-assets/*.zip; do
|
||||
if [ -f "$file" ]; then
|
||||
filename=$(basename "$file")
|
||||
echo "Uploading: ${filename}"
|
||||
ossutil cp --force "$file" "${OSS_BUCKET}/${OSS_PREFIX}/${filename}" \
|
||||
--endpoint "$OSS_ENDPOINT" \
|
||||
--region "$OSS_REGION" \
|
||||
--access-key-id "$OSS_ACCESS_KEY_ID" \
|
||||
--access-key-secret "$OSS_ACCESS_KEY_SECRET"
|
||||
fi
|
||||
done
|
||||
|
||||
- name: Verify OSS upload
|
||||
id: verify
|
||||
run: |
|
||||
VERIFY_URL="https://nuwa-packages.oss-rg-china-mainland.aliyuncs.com/mcp-stdio-proxy/${TAG}/mcp-stdio-proxy-x86_64-apple-darwin.tar.xz"
|
||||
HTTP_STATUS=$(curl -sS -o /tmp/verify.txt -w "%{http_code}" "$VERIFY_URL")
|
||||
if [ "$HTTP_STATUS" = "200" ]; then
|
||||
echo "==> OSS verification passed (HTTP ${HTTP_STATUS})"
|
||||
echo "oss_upload_success=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "::warning::OSS verification failed: ${VERIFY_URL} returned HTTP ${HTTP_STATUS}"
|
||||
echo "oss_upload_success=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
modify-npm-packages:
|
||||
name: Modify npm package download URLs
|
||||
needs:
|
||||
- plan
|
||||
- sync-to-oss
|
||||
runs-on: "ubuntu-22.04"
|
||||
# Only run during publishing
|
||||
if: ${{ needs.plan.outputs.publishing == 'true' }}
|
||||
outputs:
|
||||
modified: ${{ steps.modify.outputs.modified }}
|
||||
env:
|
||||
PLAN: ${{ needs.plan.outputs.val }}
|
||||
TAG: ${{ needs.plan.outputs.tag }}
|
||||
OSS_SYNC_SUCCESS: ${{ needs.sync-to-oss.result == 'success' && needs.sync-to-oss.outputs.oss_upload_success == 'true' }}
|
||||
steps:
|
||||
- name: Fetch npm packages
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
pattern: artifacts-*
|
||||
path: npm/
|
||||
merge-multiple: true
|
||||
|
||||
- id: modify
|
||||
name: Modify npm package URLs
|
||||
run: |
|
||||
GITHUB_URL="https://github.com/${GITHUB_REPOSITORY}/releases/download/${TAG}"
|
||||
OSS_URL="https://nuwa-packages.oss-rg-china-mainland.aliyuncs.com/mcp-stdio-proxy/${TAG}"
|
||||
|
||||
# 检查 OSS 同步是否成功
|
||||
if [ "$OSS_SYNC_SUCCESS" != "true" ]; then
|
||||
echo "==> OSS sync failed or was skipped, using original packages"
|
||||
echo "modified=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "==> OSS sync successful, modifying npm packages to use OSS URLs"
|
||||
echo "modified=true" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# 查找 npm 包并处理
|
||||
for release in $(echo "$PLAN" | jq --compact-output '.releases[]'); do
|
||||
# 从 release 对象中提取 artifacts 数组,并查找 npm 包
|
||||
pkg=$(echo "$release" | jq -r '.artifacts[] | select(. | test("-npm-package\\.tar\\.gz$"))')
|
||||
|
||||
if [ -n "$pkg" ]; then
|
||||
echo "==> Processing: $pkg"
|
||||
|
||||
# 创建临时目录
|
||||
mkdir -p temp-workspace
|
||||
|
||||
# 解压 npm 包
|
||||
tar -xzf "npm/${pkg}" -C temp-workspace
|
||||
|
||||
# 修改 package.json 中的 artifactDownloadUrl
|
||||
sed -i "s|\"artifactDownloadUrl\": \"${GITHUB_URL}\"|\"artifactDownloadUrl\": \"${OSS_URL}\"|g" temp-workspace/package/package.json
|
||||
|
||||
# 验证修改
|
||||
echo "==> Modified artifactDownloadUrl:"
|
||||
grep -A1 "artifactDownloadUrl" temp-workspace/package/package.json || true
|
||||
|
||||
# 重新打包
|
||||
tar -czf "npm/${pkg}" -C temp-workspace package
|
||||
rm -rf temp-workspace
|
||||
fi
|
||||
done
|
||||
|
||||
- name: Upload modified npm packages
|
||||
if: ${{ steps.modify.outputs.modified == 'true' }}
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: artifacts-npm-packages
|
||||
path: npm/*-npm-package.tar.gz
|
||||
if-no-files-found: error
|
||||
|
||||
|
||||
publish-npm:
|
||||
needs:
|
||||
- plan
|
||||
- host
|
||||
- modify-npm-packages
|
||||
runs-on: "ubuntu-22.04"
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
PLAN: ${{ needs.plan.outputs.val }}
|
||||
if: ${{ !fromJson(needs.plan.outputs.val).announcement_is_prerelease || fromJson(needs.plan.outputs.val).publish_prereleases }}
|
||||
steps:
|
||||
# Try to fetch modified npm packages (with OSS URLs)
|
||||
- name: Fetch modified npm packages
|
||||
id: fetch-modified
|
||||
uses: actions/download-artifact@v4
|
||||
continue-on-error: true
|
||||
with:
|
||||
name: artifacts-npm-packages
|
||||
path: npm/
|
||||
|
||||
# If modified packages don't exist, fetch original packages
|
||||
- name: Fetch original npm packages
|
||||
if: ${{ steps.fetch-modified.outcome == 'failure' || steps.fetch-modified.outcome == 'skipped' || needs.modify-npm-packages.outputs.modified != 'true' }}
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
pattern: artifacts-*
|
||||
path: npm/
|
||||
merge-multiple: true
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20.x'
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
|
||||
- run: |
|
||||
for release in $(echo "$PLAN" | jq --compact-output '.releases[] | select([.artifacts[] | endswith("-npm-package.tar.gz")] | any)'); do
|
||||
pkg=$(echo "$release" | jq '.artifacts[] | select(endswith("-npm-package.tar.gz"))' --raw-output)
|
||||
npm publish --access public "./npm/${pkg}"
|
||||
done
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
|
||||
announce:
|
||||
needs:
|
||||
- plan
|
||||
- host
|
||||
- publish-npm
|
||||
# sync-to-oss 和 modify-npm-packages 不需要等待(允许失败)
|
||||
# use "always() && ..." to allow us to wait for all publish jobs while
|
||||
# still allowing individual publish jobs to skip themselves (for prereleases).
|
||||
# "host" however must run to completion, no skipping allowed!
|
||||
if: ${{ always() && needs.host.result == 'success' && (needs.publish-npm.result == 'skipped' || needs.publish-npm.result == 'success') }}
|
||||
runs-on: "ubuntu-22.04"
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
submodules: recursive
|
||||
53
qiming-mcp-proxy/.gitignore
vendored
Normal file
53
qiming-mcp-proxy/.gitignore
vendored
Normal file
@@ -0,0 +1,53 @@
|
||||
/target
|
||||
.idea
|
||||
.vscode
|
||||
target
|
||||
.DS_Store
|
||||
**/target
|
||||
mcp-proxy/logs
|
||||
logs
|
||||
.venv
|
||||
.claude
|
||||
/tmp
|
||||
packages
|
||||
|
||||
|
||||
document-parser/data/*
|
||||
scripts/temp/*
|
||||
data/*
|
||||
temp/*
|
||||
server.log
|
||||
:memory:/*
|
||||
document-parser/:memory:/*
|
||||
|
||||
document-parser/temp/*
|
||||
models/*
|
||||
dist/
|
||||
voice-cli/temp/*
|
||||
voice-cli/cluster_metadata/*
|
||||
voice-cli/shared-voice-cli/*
|
||||
voice-cli/:memory:/*
|
||||
|
||||
cluster_metadata
|
||||
*.pid
|
||||
|
||||
voice-cli/build/*
|
||||
voice-cli/data/*
|
||||
voice-cli/server-config.yml
|
||||
voice-cli/server_debug.log
|
||||
voice-cli/test_audio.txt
|
||||
voice-cli/checkpoints/*
|
||||
mcp-proxy/tmp/*
|
||||
fastembed/.fastembed_cache
|
||||
.fastembed_cache/
|
||||
voice-cli/models/
|
||||
|
||||
# Environment files
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# Internal development tools and docs
|
||||
.qoder/
|
||||
spec/
|
||||
mcp-proxy/docs/
|
||||
61
qiming-mcp-proxy/.pre-commit-config.yaml
Normal file
61
qiming-mcp-proxy/.pre-commit-config.yaml
Normal file
@@ -0,0 +1,61 @@
|
||||
fail_fast: false
|
||||
repos:
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
rev: v4.3.0
|
||||
hooks:
|
||||
- id: check-byte-order-marker
|
||||
- id: check-case-conflict
|
||||
- id: check-merge-conflict
|
||||
- id: check-symlinks
|
||||
- id: check-yaml
|
||||
- id: end-of-file-fixer
|
||||
- id: mixed-line-ending
|
||||
- id: trailing-whitespace
|
||||
- repo: https://github.com/psf/black
|
||||
rev: 22.10.0
|
||||
hooks:
|
||||
- id: black
|
||||
- repo: local
|
||||
hooks:
|
||||
- id: cargo-fmt
|
||||
name: cargo fmt
|
||||
description: Format files with rustfmt.
|
||||
entry: bash -c 'cargo fmt -- --check'
|
||||
language: rust
|
||||
files: \.rs$
|
||||
args: []
|
||||
- id: cargo-deny
|
||||
name: cargo deny check
|
||||
description: Check cargo dependencies
|
||||
entry: bash -c 'cargo deny check -d'
|
||||
language: rust
|
||||
files: \.rs$
|
||||
args: []
|
||||
- id: typos
|
||||
name: typos
|
||||
description: check typo
|
||||
entry: bash -c 'typos'
|
||||
language: rust
|
||||
files: \.*$
|
||||
pass_filenames: false
|
||||
- id: cargo-check
|
||||
name: cargo check
|
||||
description: Check the package for errors.
|
||||
entry: bash -c 'cargo check --all'
|
||||
language: rust
|
||||
files: \.rs$
|
||||
pass_filenames: false
|
||||
- id: cargo-clippy
|
||||
name: cargo clippy
|
||||
description: Lint rust sources
|
||||
entry: bash -c 'cargo clippy --all-targets --all-features --tests --benches -- -D warnings'
|
||||
language: rust
|
||||
files: \.rs$
|
||||
pass_filenames: false
|
||||
- id: cargo-test
|
||||
name: cargo test
|
||||
description: unit test for the project
|
||||
entry: bash -c 'cargo nextest run --all-features'
|
||||
language: rust
|
||||
files: \.rs$
|
||||
pass_filenames: false
|
||||
175
qiming-mcp-proxy/CARGO_DIST_SETUP.md
Normal file
175
qiming-mcp-proxy/CARGO_DIST_SETUP.md
Normal file
@@ -0,0 +1,175 @@
|
||||
# cargo-dist 配置完成总结
|
||||
|
||||
## ✅ 已完成的配置
|
||||
|
||||
### 1. 初始化 cargo-dist
|
||||
|
||||
使用官方命令初始化:
|
||||
```bash
|
||||
dist init --yes
|
||||
```
|
||||
|
||||
这会自动创建:
|
||||
- `[profile.dist]` 到 `Cargo.toml`(工作区级别)
|
||||
- `dist-workspace.toml` 配置文件
|
||||
- `.github/workflows/release.yml` GitHub Actions 工作流
|
||||
|
||||
### 2. 配置文件
|
||||
|
||||
#### `dist-workspace.toml`
|
||||
```toml
|
||||
[workspace]
|
||||
members = ["cargo:."]
|
||||
|
||||
[dist]
|
||||
cargo-dist-version = "0.30.3"
|
||||
ci = "github"
|
||||
installers = ["shell", "powershell"]
|
||||
targets = [
|
||||
"aarch64-apple-darwin",
|
||||
"aarch64-unknown-linux-gnu",
|
||||
"x86_64-apple-darwin",
|
||||
"x86_64-unknown-linux-gnu",
|
||||
"x86_64-pc-windows-msvc"
|
||||
]
|
||||
```
|
||||
|
||||
#### 各子项目的 `Cargo.toml` 添加
|
||||
```toml
|
||||
[package]
|
||||
repository = "https://github.com/nuwax-ai/mcp-proxy"
|
||||
```
|
||||
|
||||
已更新:
|
||||
- ✅ `mcp-proxy/Cargo.toml` (已有 repository)
|
||||
- ✅ `document-parser/Cargo.toml`
|
||||
- ✅ `voice-cli/Cargo.toml`
|
||||
|
||||
### 3. GitHub Actions 工作流
|
||||
|
||||
文件:`.github/workflows/release.yml`
|
||||
|
||||
自动执行:
|
||||
1. **Plan** - 检测到 git tag 时,计算需要构建的内容
|
||||
2. **Build** - 为每个目标平台构建二进制文件
|
||||
3. **Host** - 创建 GitHub Release 并上传所有产物
|
||||
|
||||
### 4. 产物格式
|
||||
|
||||
每次发布会生成:
|
||||
|
||||
#### 每个 二进制文件
|
||||
- `{name}-{target}.tar.xz` (Linux/macOS)
|
||||
- `{name}-{target}.zip` (Windows)
|
||||
- 对应的 SHA256 校验和
|
||||
|
||||
#### 全局产物
|
||||
- `{name}-installer.sh` - Shell 安装脚本 (Linux/macOS)
|
||||
- `{name}-installer.ps1` - PowerShell 安装脚本 (Windows)
|
||||
- `sha256.sum` - 所有文件的校验和
|
||||
- `source.tar.gz` - 源码压缩包
|
||||
|
||||
## 🚀 发布流程
|
||||
|
||||
### 1. 更新版本号
|
||||
```bash
|
||||
# 更新各子项目的 version
|
||||
vim mcp-proxy/Cargo.toml
|
||||
vim document-parser/Cargo.toml
|
||||
vim voice-cli/Cargo.toml
|
||||
```
|
||||
|
||||
### 2. 提交并打标签
|
||||
```bash
|
||||
git add .
|
||||
git commit -m "release: v0.2.0"
|
||||
git tag v0.2.0
|
||||
git push
|
||||
git push --tags
|
||||
```
|
||||
|
||||
### 3. 自动触发 GitHub Actions
|
||||
推送 tag 后,GitHub Actions 自动:
|
||||
- ✅ 构建所有平台
|
||||
- ✅ 生成安装脚本
|
||||
- ✅ 创建 Release
|
||||
- ✅ 上传产物
|
||||
|
||||
## 📥 用户安装方式
|
||||
|
||||
### 最简单(推荐)
|
||||
```bash
|
||||
# Linux/macOS
|
||||
curl --proto '=https' --tlsv1.2 -sSf \
|
||||
https://github.com/nuwax-ai/mcp-proxy/releases/latest/download/mcp-stdio-proxy-installer.sh | sh
|
||||
|
||||
# Windows PowerShell
|
||||
irm https://github.com/nuwax-ai/mcp-proxy/releases/latest/download/mcp-stdio-proxy-installer.ps1 | iex
|
||||
```
|
||||
|
||||
### 从 Releases 页面下载
|
||||
访问:https://github.com/nuwax-ai/mcp-proxy/releases
|
||||
|
||||
### cargo install
|
||||
```bash
|
||||
cargo install mcp-stdio-proxy
|
||||
```
|
||||
|
||||
### cargo-binstall
|
||||
```bash
|
||||
cargo binstall mcp-stdio-proxy
|
||||
```
|
||||
|
||||
## 🔧 本地测试发布
|
||||
|
||||
```bash
|
||||
# 查看发布计划
|
||||
dist plan --tag=v0.2.0
|
||||
|
||||
# 构建全局产物(安装脚本)
|
||||
dist build --tag=v0.2.0 --artifacts=global
|
||||
|
||||
# 查看生成的文件
|
||||
ls -la target/distrib/
|
||||
```
|
||||
|
||||
## 📝 文档
|
||||
|
||||
- `RELEASE.md` - 完整的发布指南
|
||||
- `README.md` - 已更新安装方式章节
|
||||
- `dist-workspace.toml` - cargo-dist 配置
|
||||
- `.github/workflows/release.yml` - 自动化工作流
|
||||
|
||||
## 🎯 支持的平台
|
||||
|
||||
| 平台 | 目标三元组 |
|
||||
|------|-----------|
|
||||
| Linux x86_64 | x86_64-unknown-linux-gnu |
|
||||
| Linux ARM64 | aarch64-unknown-linux-gnu |
|
||||
| macOS Intel | x86_64-apple-darwin |
|
||||
| macOS Apple Silicon | aarch64-apple-darwin |
|
||||
| Windows x86_64 | x86_64-pc-windows-msvc |
|
||||
|
||||
## 📦 发布的二进制
|
||||
|
||||
1. **mcp-stdio-proxy** - 主 MCP 代理服务
|
||||
2. **document-parser** - 文档解析服务
|
||||
3. **voice-cli** - 语音转文字服务
|
||||
|
||||
## 🔐 校验和验证
|
||||
|
||||
每个 release 都包含 `sha256.sum` 文件:
|
||||
|
||||
```bash
|
||||
# 下载校验和
|
||||
curl -L -O https://github.com/nuwax-ai/mcp-proxy/releases/latest/download/sha256.sum
|
||||
|
||||
# 验证
|
||||
sha256sum -c sha256.sum
|
||||
```
|
||||
|
||||
## 📚 参考资源
|
||||
|
||||
- [cargo-dist 官方文档](https://axodotdev.github.io/cargo-dist/)
|
||||
- [cargo-dist 配置参考](https://axodotdev.github.io/cargo-dist/book/reference/config.html)
|
||||
- [项目 GitHub Releases](https://github.com/nuwax-ai/mcp-proxy/releases)
|
||||
62
qiming-mcp-proxy/CHANGELOG.md
Normal file
62
qiming-mcp-proxy/CHANGELOG.md
Normal file
@@ -0,0 +1,62 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/).
|
||||
|
||||
## [0.1.52] - 2026-02-15
|
||||
|
||||
### Fixed
|
||||
|
||||
- **PATH 按段去重**: `ensure_runtime_path` 从整段 `starts_with` 改为按分隔符拆段去重,
|
||||
解决上层多次前置导致的 `node/bin` 重复条目问题
|
||||
- **config PATH 覆盖**: 用户在 MCP config env 中指定自定义 PATH 时,
|
||||
仍然确保应用内置运行时路径(`NUWAX_APP_RUNTIME_PATH`)在最前面
|
||||
- **env value 泄露**: `log_command_details` 不再打印 env 变量的 value,
|
||||
仅输出 key 列表,避免泄露敏感信息(如 token、secret)
|
||||
|
||||
### Added
|
||||
|
||||
- **`mcp-common::diagnostic` 模块**: 提取子进程启动诊断日志为独立模块,
|
||||
提供 `log_stdio_spawn_context`、`format_spawn_error`、`format_path_summary` 等公共函数,
|
||||
减少业务代码中的日志侵入
|
||||
- **启动阶段环境诊断**: `env_init` 结束后输出 PATH 摘要和镜像环境变量最终值
|
||||
- **spawn 失败上下文**: SSE/Stream 子进程 spawn 失败时输出完整错误上下文
|
||||
(command、args、PATH),便于快速定位可执行文件找不到的问题
|
||||
- **build 失败上下文**: `mcp_start_task` 中 SSE/Stream server build 失败时
|
||||
通过 `anyhow::Context` 附加 MCP ID 和服务类型
|
||||
- **`ensure_runtime_path` 单元测试**: 新增 5 个测试覆盖前置、部分去重、
|
||||
全部已存在、双重重复等场景
|
||||
|
||||
### Changed
|
||||
|
||||
- **server_builder PATH 逻辑简化**: 两侧 `connect_stdio` 从三分支
|
||||
(继承/config/缺失)统一为:取基础 PATH → `ensure_runtime_path` → 传递给子进程
|
||||
- **无镜像提示**: 未配置镜像源时输出提示行,而非静默跳过
|
||||
|
||||
## [0.1.51] - 2026-02-14
|
||||
|
||||
### Changed
|
||||
|
||||
- 版本号更新
|
||||
|
||||
## [0.1.49] - 2026-02-13
|
||||
|
||||
### Added
|
||||
|
||||
- **镜像源配置**: 支持通过 `config.yml` 配置 npm/PyPI 镜像源,
|
||||
环境变量优先级高于配置文件
|
||||
- **环境变量初始化**: `env_init` 模块统一管理子进程环境(镜像源 + 内置运行时 PATH)
|
||||
- **`UV_INSECURE_HOST` 支持**: HTTP 类型的 PyPI 镜像自动提取 host 并设置
|
||||
|
||||
## [0.1.48] - 2026-02-12
|
||||
|
||||
### Added
|
||||
|
||||
- **跨平台进程管理**: `process_compat` 模块提供 `wrap_process_v8` / `wrap_process_v9` 宏,
|
||||
统一 Unix(ProcessGroup)和 Windows(JobObject + CREATE_NO_WINDOW)的进程包装
|
||||
|
||||
### Fixed
|
||||
|
||||
- Windows 平台隐藏控制台窗口配置
|
||||
365
qiming-mcp-proxy/CLAUDE.md
Normal file
365
qiming-mcp-proxy/CLAUDE.md
Normal file
@@ -0,0 +1,365 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Development Commands
|
||||
|
||||
### Building and Testing
|
||||
```bash
|
||||
# Build all workspace crates
|
||||
cargo build
|
||||
|
||||
# Build specific crate
|
||||
cargo build -p mcp-proxy
|
||||
cargo build -p voice-cli
|
||||
cargo build -p document-parser
|
||||
cargo build -p oss-client
|
||||
|
||||
# Build in release mode
|
||||
cargo build --release
|
||||
|
||||
# Run tests for all crates
|
||||
cargo test
|
||||
|
||||
# Run tests for specific crate
|
||||
cargo test -p mcp-proxy
|
||||
cargo test -p voice-cli
|
||||
|
||||
# Run clippy for linting
|
||||
cargo clippy --all-targets --all-features
|
||||
|
||||
# Format code
|
||||
cargo fmt
|
||||
|
||||
# Check formatting
|
||||
cargo fmt --check
|
||||
```
|
||||
|
||||
### Cross-Platform Building (using Docker)
|
||||
```bash
|
||||
# Build document-parser for Linux x86_64
|
||||
make build-document-parser-x86_64
|
||||
|
||||
# Build document-parser for Linux ARM64
|
||||
make build-document-parser-arm64
|
||||
|
||||
# Build voice-cli for Linux x86_64
|
||||
make build-voice-cli-x86_64
|
||||
|
||||
# Build all components for x86_64
|
||||
make build-all-x86_64
|
||||
|
||||
# Build Docker runtime image
|
||||
make build-image
|
||||
|
||||
# Run Docker container
|
||||
make run
|
||||
```
|
||||
|
||||
### Service-Specific Commands
|
||||
|
||||
**Document Parser:**
|
||||
```bash
|
||||
# Initialize Python environment
|
||||
cd document-parser && cargo run --bin document-parser -- uv-init
|
||||
|
||||
# Check environment status
|
||||
cd document-parser && cargo run --bin document-parser -- check
|
||||
|
||||
# Start server
|
||||
cd document-parser && cargo run --bin document-parser -- server
|
||||
|
||||
# Troubleshoot issues
|
||||
cd document-parser && cargo run --bin document-parser -- troubleshoot
|
||||
```
|
||||
|
||||
**Voice CLI:**
|
||||
```bash
|
||||
# Initialize server configuration
|
||||
cd voice-cli && cargo run --bin voice-cli -- server init
|
||||
|
||||
# Run voice server
|
||||
cd voice-cli && cargo run --bin voice-cli -- server run
|
||||
|
||||
# List Whisper models
|
||||
cd voice-cli && cargo run --bin voice-cli -- model list
|
||||
|
||||
# Download model
|
||||
cd voice-cli && cargo run --bin voice-cli -- model download tiny
|
||||
```
|
||||
|
||||
**MCP Proxy:**
|
||||
```bash
|
||||
# Start MCP proxy server
|
||||
cd mcp-proxy && cargo run --bin mcp-proxy
|
||||
```
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
This is a Rust workspace implementing an MCP (Model Context Protocol) proxy system with multiple services:
|
||||
|
||||
### Core Services
|
||||
|
||||
**mcp-proxy**: Main MCP proxy service implementing SSE protocol
|
||||
- Provides HTTP API for MCP service management
|
||||
- Handles dynamic plugin loading and configuration
|
||||
- Implements Server-Sent Events for real-time communication
|
||||
- Uses `rmcp` crate for MCP protocol implementation
|
||||
|
||||
**document-parser**: High-performance document parsing service
|
||||
- Multi-format support: PDF, Word, Excel, PowerPoint via MinerU/MarkItDown
|
||||
- Python-based with uv dependency management
|
||||
- GPU acceleration support with CUDA
|
||||
- Automatic virtual environment management
|
||||
|
||||
**voice-cli**: Speech-to-text service with TTS capabilities
|
||||
- Whisper model integration for transcription
|
||||
- Python-based TTS service with uv management
|
||||
- Apalis-based async task queue
|
||||
- FFmpeg integration for metadata extraction
|
||||
|
||||
**oss-client**: Lightweight Alibaba Cloud OSS client
|
||||
- Simple interface for object storage operations
|
||||
- Workspace dependency for other services
|
||||
|
||||
### Key Integrations
|
||||
|
||||
**Workspace Management**: All dependencies managed through workspace Cargo.toml with centralized versioning. Sub-crates use `{ workspace = true }` for dependency references.
|
||||
|
||||
**Async Processing**: Uses `tokio` runtime throughout. Task processing via `apalis` with SQLite persistence for voice transcription and TTS tasks.
|
||||
|
||||
**HTTP Framework**: `axum` with `tower` middleware for all web services. OpenAPI documentation via `utoipa`.
|
||||
|
||||
**Error Handling**: Consistent error handling with `anyhow` for application code and `thiserror` for library code.
|
||||
|
||||
**Logging**: Structured logging with `tracing` and `tracing-subscriber`. Daily log rotation with `tracing-appender`.
|
||||
|
||||
**FFmpeg Integration**: Lightweight FFmpeg command execution via `ffmpeg-sidecar` for media metadata extraction. System FFmpeg installation required but provides graceful fallback.
|
||||
|
||||
**Python Integration**: Both `document-parser` and `voice-cli` use Python services with `uv` for dependency management and virtual environment handling:
|
||||
- Automatic virtual environment creation in `./venv/`
|
||||
- uv package manager for fast Python dependency installation
|
||||
- CUDA GPU acceleration support (optional)
|
||||
- Graceful degradation if Python/uv unavailable
|
||||
|
||||
**Task Queue & Persistence**: Voice services use `apalis` for background task processing:
|
||||
- SQLite-based persistence for task state tracking
|
||||
- Task retry mechanisms with exponential backoff
|
||||
- Support for task prioritization and status monitoring
|
||||
- Worker management with resource limits
|
||||
|
||||
### Configuration System
|
||||
|
||||
All services use hierarchical configuration:
|
||||
1. Default values in code
|
||||
2. Configuration files (YAML/JSON/TOML)
|
||||
3. Environment variables with service prefixes
|
||||
4. Command-line arguments
|
||||
|
||||
### Development Standards
|
||||
|
||||
**Code Organization**: Strict workspace structure - no code in root directory. All implementation in sub-crates with clear module boundaries.
|
||||
|
||||
**Formatting & Linting**:
|
||||
- Line length: 100 characters
|
||||
- 4-space indentation (no tabs)
|
||||
- Always run `cargo fmt` and `cargo clippy` before commits
|
||||
- Use `cargo audit` to check for security vulnerabilities
|
||||
- Use `typos-cli` to check spelling
|
||||
|
||||
**Error Handling**:
|
||||
- Prefer `anyhow` for application code, `thiserror` for libraries
|
||||
- Avoid `unwrap()` except in tests
|
||||
- Use `?` operator for error propagation
|
||||
- Add contextual error messages with `anyhow::Context`
|
||||
- Never include sensitive data in error messages
|
||||
|
||||
**Concurrency**:
|
||||
- Use `tokio` for async, `Arc<Mutex<T>>` or `Arc<RwLock<T>>` for shared state
|
||||
- Avoid blocking operations in async contexts
|
||||
- Use `tokio::spawn` for creating concurrent tasks
|
||||
|
||||
**Memory Management**:
|
||||
- Prefer borrowing over ownership
|
||||
- **Use `dashmap` for concurrent hashmaps** instead of `Arc<RwLock<HashMap<_, _>>>` (dashmap provides atomic operations and is more efficient)
|
||||
- Avoid unnecessary `clone()`, consider `Cow<T>` or reference counting
|
||||
|
||||
**Testing**:
|
||||
- Unit tests alongside implementation code
|
||||
- Integration tests where appropriate
|
||||
- Use `assert_eq!`, `assert_ne!` for assertions
|
||||
- Run specific tests: `cargo test <test_name> -p <crate>`
|
||||
|
||||
**Documentation**:
|
||||
- All public APIs must have documentation comments (`///`)
|
||||
- Include usage examples in complex API documentation
|
||||
- Keep README.md and other docs updated
|
||||
|
||||
## Cursor Rules Summary
|
||||
|
||||
**Development Standards**:
|
||||
- Line length: 100 characters
|
||||
- 4-space indentation (no tabs)
|
||||
- Documentation comments for all public APIs
|
||||
- Use `cargo fmt` and `cargo clippy` before commits
|
||||
|
||||
**Error Handling**:
|
||||
- `anyhow` for application code, `thiserror` for libraries
|
||||
- Contextual error messages with `anyhow::Context`
|
||||
- No sensitive data in error messages
|
||||
|
||||
**Module Organization**:
|
||||
- Clear module responsibilities
|
||||
- `pub(crate)` for internal visibility
|
||||
- Re-export public APIs in `lib.rs`
|
||||
|
||||
**Dependencies**:
|
||||
- Centralized workspace dependency management
|
||||
- Specific versions (no `*`)
|
||||
- Regular security audits with `cargo audit`
|
||||
|
||||
## Build System
|
||||
|
||||
The project uses a sophisticated Makefile with Docker buildx for cross-platform compilation:
|
||||
|
||||
### Docker Build Commands
|
||||
```bash
|
||||
# Check Docker buildx availability
|
||||
make check-buildx
|
||||
|
||||
# Setup buildx builder (if needed)
|
||||
make setup-buildx
|
||||
|
||||
# Build document-parser for specific platforms
|
||||
make build-document-parser-x86_64
|
||||
make build-document-parser-arm64
|
||||
make build-document-parser-multi
|
||||
|
||||
# Build voice-cli for specific platforms
|
||||
make build-voice-cli-x86_64
|
||||
make build-voice-cli-arm64
|
||||
make build-voice-cli-multi
|
||||
|
||||
# Build all components
|
||||
make build-all-x86_64
|
||||
make build-all-arm64
|
||||
make build-all-multi
|
||||
|
||||
# Build and run Docker runtime image
|
||||
make build-image
|
||||
make run
|
||||
```
|
||||
|
||||
**Build System Features**:
|
||||
- **Docker-based builds**: All compilation happens in containers for consistency
|
||||
- **Multi-platform support**: Linux x86_64 and ARM64 targets
|
||||
- **Export targets**: Separate build and runtime stages
|
||||
- **Automated dependency installation**: Python and Rust dependencies managed in containers
|
||||
- **Output directory**: `./dist/` contains all built binaries organized by platform
|
||||
|
||||
## Service-Specific Architecture Details
|
||||
|
||||
### Document Parser (`document-parser/`)
|
||||
- **Core Structure**: `app_state.rs`, `config.rs`, `main.rs`, `lib.rs`
|
||||
- **Submodules**: `handlers/`, `middleware/`, `models/`, `parsers/`, `processors/`, `services/`, `tests/`, `utils/`
|
||||
- **Python Integration**: MinerU for PDF parsing, MarkItDown for other formats
|
||||
- **Virtual Environment**: Auto-managed in `./venv/`, activated via `source ./venv/bin/activate`
|
||||
- **Server**: Axum-based HTTP server with multipart file upload support
|
||||
- **Configuration**: YAML/JSON/TOML support with environment variable overrides
|
||||
|
||||
### Voice CLI (`voice-cli/`)
|
||||
- **Core Components**:
|
||||
- `services/`: Model management, transcription engine, TTS service, task queue
|
||||
- `server/`: HTTP handlers, routes, middleware configuration
|
||||
- `models/`: Request/response data structures
|
||||
- **Whisper Integration**: Model download and management via `voice-toolkit`
|
||||
- **TTS Service**: Python-based with `uv` dependency management
|
||||
- **FFmpeg**: Metadata extraction via `ffmpeg-sidecar`
|
||||
- **Apalis**: Async task processing with SQLite persistence
|
||||
|
||||
### MCP Proxy (`mcp-proxy/`)
|
||||
- **Core Structure**: `config.rs`, `lib.rs`, `main.rs`, `mcp_error.rs`
|
||||
- **Submodules**: `client/`, `model/`, `proxy/`, `server/`, `tests/`
|
||||
- **SSE Protocol**: Real-time communication via Server-Sent Events
|
||||
- **Plugin System**: Dynamic MCP service loading and management
|
||||
- **HTTP API**: REST endpoints for service management and status checks
|
||||
|
||||
## Common Patterns
|
||||
|
||||
**Service Initialization**: All services follow similar patterns for configuration loading, logging setup, and graceful shutdown.
|
||||
|
||||
**HTTP API Design**: Consistent use of axum extractors, middleware configuration, and OpenAPI documentation.
|
||||
|
||||
**Async Task Processing**: Voice services use apalis for background task processing with retry mechanisms and SQLite persistence.
|
||||
|
||||
**Python Integration**: Both document-parser and voice-cli use Python services with uv for dependency management and virtual environment handling.
|
||||
|
||||
**Configuration Management**: Hierarchical configuration with environment variable overrides and command-line argument integration.
|
||||
|
||||
## Single Test Execution Examples
|
||||
```bash
|
||||
# Run tests for specific crate
|
||||
cargo test -p mcp-proxy
|
||||
cargo test -p voice-cli
|
||||
cargo test -p document-parser
|
||||
|
||||
# Run specific test
|
||||
cargo test test_extract_basic_metadata -p voice-cli
|
||||
cargo test <test_name> -p mcp-proxy
|
||||
|
||||
# Run tests in release mode
|
||||
cargo test --release -p mcp-proxy
|
||||
|
||||
# Run library tests only (excluding integration tests)
|
||||
cargo test --lib -p voice-cli
|
||||
|
||||
# Run tests with output
|
||||
cargo test -p mcp-proxy -- --nocapture
|
||||
```
|
||||
|
||||
## Python/uv Environment Management
|
||||
|
||||
### For Document Parser:
|
||||
```bash
|
||||
cd document-parser
|
||||
# Initialize Python environment (creates ./venv/)
|
||||
cargo run --bin document-parser -- uv-init
|
||||
|
||||
# Check environment status
|
||||
cargo run --bin document-parser -- check
|
||||
|
||||
# Start server
|
||||
cargo run --bin document-parser -- server
|
||||
|
||||
# Troubleshoot issues
|
||||
cargo run --bin document-parser -- troubleshoot
|
||||
```
|
||||
|
||||
### For Voice CLI TTS:
|
||||
```bash
|
||||
cd voice-cli
|
||||
# Install uv package manager
|
||||
curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||
|
||||
# Install Python dependencies
|
||||
uv sync
|
||||
|
||||
# Run TTS service directly
|
||||
python3 tts_service.py --help
|
||||
```
|
||||
|
||||
## Dependencies Management
|
||||
|
||||
All dependencies are managed centrally in the workspace `Cargo.toml`:
|
||||
- Sub-crates use `{ workspace = true }` for dependency references
|
||||
- Specific versions (no `*` wildcards)
|
||||
- Centralized feature flags
|
||||
- Regular security audits with `cargo audit`
|
||||
|
||||
Key workspace dependencies:
|
||||
- `rmcp`: MCP protocol implementation with SSE support
|
||||
- `tokio`: Async runtime
|
||||
- `axum`: Web framework with tower middleware
|
||||
- `tracing`: Structured logging
|
||||
- `apalis`: Async task queue
|
||||
- `dashmap`: Concurrent hashmap (preferred over `Arc<RwLock<HashMap>>`)
|
||||
7822
qiming-mcp-proxy/Cargo.lock
generated
Normal file
7822
qiming-mcp-proxy/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
108
qiming-mcp-proxy/Cargo.toml
Normal file
108
qiming-mcp-proxy/Cargo.toml
Normal file
@@ -0,0 +1,108 @@
|
||||
[workspace]
|
||||
# 排除 fastembed(ort-sys 不支持部分平台)
|
||||
members = ["document-parser", "mcp-common", "mcp-proxy", "mcp-sse-proxy", "mcp-streamable-proxy", "oss-client", "voice-cli"]
|
||||
# 默认只构建 mcp-proxy(排除 document-parser 等需要特殊环境的包)
|
||||
default-members = ["mcp-proxy", "mcp-common", "mcp-sse-proxy", "mcp-streamable-proxy"]
|
||||
resolver = "2"
|
||||
exclude = ["fastembed"]
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[workspace.dependencies]
|
||||
mcp-proxy = { path = "mcp-proxy" }
|
||||
oss-client = { path = "oss-client" }
|
||||
|
||||
# Note: rmcp 依赖由各子项目独立管理(mcp-sse-proxy 用 0.10,mcp-streamable-proxy 用 0.12)
|
||||
tokio = { version = "1.48", features = ["macros", "net", "rt", "rt-multi-thread"] }
|
||||
tokio-util = "0.7"
|
||||
|
||||
# Logging and tracing
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
tracing-appender = "0.2"
|
||||
tracing-opentelemetry = "0.32"
|
||||
opentelemetry = { version = "0.31", features = ["trace"] }
|
||||
opentelemetry-jaeger = { version = "0.22", features = ["rt-tokio"] }
|
||||
opentelemetry-semantic-conventions = { version = "0.31", features = ["semconv_experimental"] }
|
||||
opentelemetry_sdk = "0.31"
|
||||
opentelemetry-otlp = { version = "0.31", features = ["grpc-tonic"] }
|
||||
tonic = "0.13"
|
||||
hostname = "0.4"
|
||||
uuid = { version = "1.19", features = ["v4", "v7"] }
|
||||
rand = "0.9"
|
||||
log = "0.4"
|
||||
anyhow = "1.0"
|
||||
chrono = { version = "0.4", features = ["serde", "now"] }
|
||||
thiserror = "2.0"
|
||||
axum = { version = "0.8", features = [
|
||||
"http2",
|
||||
"query",
|
||||
"tracing",
|
||||
"ws",
|
||||
"multipart",
|
||||
"macros",
|
||||
] }
|
||||
tower = { version = "0.5" }
|
||||
tower-http = { version = "0.6", features = [
|
||||
"compression-full",
|
||||
"cors",
|
||||
"fs",
|
||||
"trace",
|
||||
"limit",
|
||||
] }
|
||||
axum-extra = { version = "0.12", features = ["typed-header"] }
|
||||
utoipa = { version = "5.4", features = ["axum_extras", "chrono", "uuid"] }
|
||||
utoipa-rapidoc = { version = "6", features = ["axum"] }
|
||||
utoipa-redoc = { version = "6", features = ["axum"] }
|
||||
utoipa-swagger-ui = { version = "9", features = ["axum"] }
|
||||
dashmap = "6.1"
|
||||
arc-swap = "1.7"
|
||||
moka = { version = "0.12", features = ["future"] }
|
||||
criterion = "0.7"
|
||||
once_cell = "1.21"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
serde_yaml = "=0.9.33"
|
||||
serde_with = "3.12"
|
||||
reqwest = { version = "0.13", features = ["json"] }
|
||||
http = "1.4"
|
||||
aliyun-oss-rust-sdk = { version = "0.2", default-features = false, features = ["async"] }
|
||||
clap = { version = "4.5", features = ["derive", "env"] }
|
||||
futures = "0.3"
|
||||
run_code_rmcp = "0.0.35"
|
||||
derive_builder = "0.20"
|
||||
bytes = "1.0"
|
||||
base64 = "0.22"
|
||||
tempfile = "3.23"
|
||||
dirs = "6.0"
|
||||
scopeguard = "1.2"
|
||||
|
||||
async-trait = "0.1"
|
||||
|
||||
# 自己开发的语音相关工具
|
||||
voice-toolkit = { version = "0.16", features = ["stt", "audio"] }
|
||||
|
||||
# Task queue and persistence for async processing
|
||||
apalis = { version = "0.7", features = ["tracing", "limit"] }
|
||||
apalis-sql = { version = "0.7", features = ["sqlite"] }
|
||||
sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "sqlite", "chrono", "uuid"] }
|
||||
sled = "0.34"
|
||||
|
||||
# 可执行文件查找
|
||||
which = "7.0"
|
||||
|
||||
# 国际化支持
|
||||
rust-i18n = "3"
|
||||
|
||||
# Audio format detection and processing
|
||||
symphonia = { version = "0.5", features = ["all"] }
|
||||
bincode = "2.0"
|
||||
tokio-stream = "0.1.18"
|
||||
backtrace = "0.3"
|
||||
tracing-futures = "0.2.5"
|
||||
urlencoding = "2.1.3"
|
||||
|
||||
# The profile that 'dist' will build with
|
||||
[profile.dist]
|
||||
inherits = "release"
|
||||
lto = "thin"
|
||||
42
qiming-mcp-proxy/LICENSE
Normal file
42
qiming-mcp-proxy/LICENSE
Normal file
@@ -0,0 +1,42 @@
|
||||
# Dual Licensing
|
||||
|
||||
This project is dual-licensed under either:
|
||||
|
||||
* **MIT License** - See [LICENSE-MIT](LICENSE-MIT) for details
|
||||
* **Apache License, Version 2.0** - See [LICENSE-APACHE](LICENSE-APACHE) for details
|
||||
|
||||
You may choose either license for your use of this project.
|
||||
|
||||
## MIT License Summary
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
|
||||
|
||||
## Apache 2.0 License Summary
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
|
||||
---
|
||||
|
||||
Copyright (c) 2025 nuwax-ai
|
||||
190
qiming-mcp-proxy/LICENSE-APACHE
Normal file
190
qiming-mcp-proxy/LICENSE-APACHE
Normal file
@@ -0,0 +1,190 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to the Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law ( such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
Copyright 2025 nuwax-ai
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
21
qiming-mcp-proxy/LICENSE-MIT
Normal file
21
qiming-mcp-proxy/LICENSE-MIT
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025 nuwax-ai
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
357
qiming-mcp-proxy/Makefile
Normal file
357
qiming-mcp-proxy/Makefile
Normal file
@@ -0,0 +1,357 @@
|
||||
# Makefile for cross-platform compilation of document-parser, voice-cli and mcp-proxy
|
||||
|
||||
# 默认目标平台
|
||||
TARGET_PLATFORM ?= linux/amd64
|
||||
|
||||
# Docker 镜像名称
|
||||
IMAGE_NAME = mcp-proxy-builder
|
||||
|
||||
# 输出目录
|
||||
OUTPUT_DIR = ./dist
|
||||
|
||||
# 通用构建函数
|
||||
define build_target
|
||||
@echo "🚀 构建 $(1) $(2) 版本..."
|
||||
@git pull
|
||||
@mkdir -p $(3)
|
||||
docker buildx build --platform $(4) --target export --output type=local,dest=$(3) -f docker/Dockerfile.document-parser ..
|
||||
@echo "✅ $(1) $(2) 版本构建完成"
|
||||
endef
|
||||
|
||||
# 默认目标
|
||||
.PHONY: all
|
||||
all: build-document-parser-x86_64
|
||||
|
||||
# 创建输出目录
|
||||
$(OUTPUT_DIR):
|
||||
@mkdir -p $(OUTPUT_DIR)
|
||||
|
||||
# ============================================================================
|
||||
# Document Parser 构建目标
|
||||
# ============================================================================
|
||||
|
||||
# 构建 document-parser Linux x86_64 版本
|
||||
.PHONY: build-document-parser-x86_64
|
||||
build-document-parser-x86_64:
|
||||
$(call build_target,document-parser,Linux x86_64,./dist/document-parser-x86_64,linux/amd64)
|
||||
|
||||
# 构建 document-parser Linux ARM64 版本
|
||||
.PHONY: build-document-parser-arm64
|
||||
build-document-parser-arm64:
|
||||
$(call build_target,document-parser,Linux ARM64,./dist/document-parser-arm64,linux/arm64)
|
||||
|
||||
# 构建 document-parser 多平台版本
|
||||
.PHONY: build-document-parser-multi
|
||||
build-document-parser-multi: build-document-parser-x86_64 build-document-parser-arm64
|
||||
|
||||
# ============================================================================
|
||||
# Voice CLI 构建目标
|
||||
# ============================================================================
|
||||
|
||||
# 构建 voice-cli Linux x86_64 版本
|
||||
.PHONY: build-voice-cli-x86_64
|
||||
build-voice-cli-x86_64:
|
||||
$(call build_target,voice-cli,Linux x86_64,./dist/voice-cli-x86_64,linux/amd64)
|
||||
|
||||
# 构建 voice-cli Linux ARM64 版本
|
||||
.PHONY: build-voice-cli-arm64
|
||||
build-voice-cli-arm64:
|
||||
$(call build_target,voice-cli,Linux ARM64,./dist/voice-cli-arm64,linux/arm64)
|
||||
|
||||
# 构建 voice-cli 多平台版本
|
||||
.PHONY: build-voice-cli-multi
|
||||
build-voice-cli-multi: build-voice-cli-x86_64 build-voice-cli-arm64
|
||||
|
||||
# ============================================================================
|
||||
# MCP Proxy 构建目标
|
||||
# ============================================================================
|
||||
|
||||
# 构建 mcp-proxy(按照当前系统架构)
|
||||
.PHONY: build-mcp-proxy
|
||||
build-mcp-proxy:
|
||||
@echo "🚀 构建 mcp-proxy(当前系统架构)..."
|
||||
@git pull
|
||||
@mkdir -p ./dist/mcp-proxy
|
||||
docker buildx build \
|
||||
--target export \
|
||||
--output type=local,dest=./dist/mcp-proxy \
|
||||
-f docker/Dockerfile.mcp-proxy \
|
||||
..
|
||||
@echo "✅ mcp-proxy 构建完成"
|
||||
|
||||
# ============================================================================
|
||||
# 所有组件构建目标
|
||||
# ============================================================================
|
||||
|
||||
# 构建所有组件(当前系统架构)
|
||||
.PHONY: build-all
|
||||
build-all: build-document-parser-x86_64 build-voice-cli-x86_64 build-mcp-proxy
|
||||
|
||||
# 构建 Docker 镜像(用于运行)
|
||||
.PHONY: build-image
|
||||
build-image:
|
||||
@echo "🚀 构建 mcp-proxy Docker 运行镜像..."
|
||||
docker buildx build \
|
||||
--platform $(TARGET_PLATFORM) \
|
||||
--target runtime \
|
||||
-t mcp-proxy:latest \
|
||||
-f docker/Dockerfile.mcp-proxy \
|
||||
$(shell pwd)
|
||||
@echo "✅ Docker 镜像构建完成: mcp-proxy:latest"
|
||||
|
||||
# 构建 Docker 镜像(document-parser)
|
||||
.PHONY: build-image-document-parser
|
||||
build-image-document-parser:
|
||||
@echo "🚀 构建 document-parser Docker 运行镜像..."
|
||||
docker buildx build \
|
||||
--platform $(TARGET_PLATFORM) \
|
||||
--target runtime \
|
||||
-t document-parser:latest \
|
||||
-f docker/Dockerfile.document-parser \
|
||||
..
|
||||
@echo "✅ Docker 镜像构建完成: document-parser:latest"
|
||||
|
||||
# 运行 Docker 镜像(mcp-proxy)
|
||||
.PHONY: run
|
||||
run: build-image
|
||||
@echo "🚀 使用 docker-compose 后台启动 mcp-proxy..."
|
||||
cd docker && docker-compose up -d
|
||||
@echo "✅ mcp-proxy 已在后台启动"
|
||||
@echo "📋 查看日志: cd docker && docker-compose logs -f"
|
||||
@echo "🛑 停止服务: cd docker && docker-compose down"
|
||||
@echo "📊 查看状态: cd docker && docker-compose ps"
|
||||
|
||||
# 运行 Docker 镜像(mcp-proxy,前台模式)
|
||||
.PHONY: run-fg
|
||||
run-fg: build-image
|
||||
@echo "🚀 使用 docker-compose 前台启动 mcp-proxy..."
|
||||
cd docker && docker-compose up
|
||||
|
||||
# 运行 Docker 镜像(document-parser)
|
||||
.PHONY: run-document-parser
|
||||
run-document-parser:
|
||||
@echo "🚀 运行 document-parser..."
|
||||
docker run --rm -p 8080:8080 document-parser:latest
|
||||
|
||||
# 检查 Docker buildx 是否可用
|
||||
.PHONY: check-buildx
|
||||
check-buildx:
|
||||
@echo "🔍 检查 Docker buildx 状态..."
|
||||
@docker buildx version || (echo "❌ Docker buildx 不可用,请确保 Docker 版本支持 buildx" && exit 1)
|
||||
@docker buildx ls
|
||||
@echo "✅ Docker buildx 可用"
|
||||
|
||||
# 创建 buildx builder(如果需要)
|
||||
.PHONY: setup-buildx
|
||||
setup-buildx:
|
||||
@echo "🔧 设置 Docker buildx builder..."
|
||||
docker buildx create --name cross-builder --use --bootstrap || true
|
||||
@echo "✅ Docker buildx builder 设置完成"
|
||||
|
||||
# ============================================================================
|
||||
# MCP 包发布目标
|
||||
# ============================================================================
|
||||
|
||||
# 自动更新所有 MCP 包的版本号(小版本号加一)
|
||||
.PHONY: mcp-version-update
|
||||
mcp-version-update:
|
||||
@echo "🔄 开始更新 MCP 包版本号..."
|
||||
@echo ""
|
||||
@# 读取 mcp-common 的当前版本
|
||||
@COMMON_VERSION=$$(grep '^version = ' mcp-common/Cargo.toml | head -1 | sed 's/version = "\(.*\)"/\1/'); \
|
||||
COMMON_MAJOR=$$(echo $$COMMON_VERSION | cut -d. -f1); \
|
||||
COMMON_MINOR=$$(echo $$COMMON_VERSION | cut -d. -f2); \
|
||||
COMMON_PATCH=$$(echo $$COMMON_VERSION | cut -d. -f3); \
|
||||
COMMON_NEW_PATCH=$$((COMMON_PATCH + 1)); \
|
||||
COMMON_NEW_VERSION="$$COMMON_MAJOR.$$COMMON_MINOR.$$COMMON_NEW_PATCH"; \
|
||||
echo "mcp-common: $$COMMON_VERSION -> $$COMMON_NEW_VERSION"; \
|
||||
PROXY_VERSION=$$(grep '^version = ' mcp-proxy/Cargo.toml | head -1 | sed 's/version = "\(.*\)"/\1/'); \
|
||||
PROXY_MAJOR=$$(echo $$PROXY_VERSION | cut -d. -f1); \
|
||||
PROXY_MINOR=$$(echo $$PROXY_VERSION | cut -d. -f2); \
|
||||
PROXY_PATCH=$$(echo $$PROXY_VERSION | cut -d. -f3); \
|
||||
PROXY_NEW_PATCH=$$((PROXY_PATCH + 1)); \
|
||||
PROXY_NEW_VERSION="$$PROXY_MAJOR.$$PROXY_MINOR.$$PROXY_NEW_PATCH"; \
|
||||
echo "mcp-stdio-proxy: $$PROXY_VERSION -> $$PROXY_NEW_VERSION"; \
|
||||
echo ""; \
|
||||
echo "1️⃣ 更新 mcp-common 版本..."; \
|
||||
sed -i.bak "s/^version = \"$$COMMON_VERSION\"/version = \"$$COMMON_NEW_VERSION\"/" mcp-common/Cargo.toml && rm mcp-common/Cargo.toml.bak; \
|
||||
echo "2️⃣ 更新 mcp-sse-proxy 版本和依赖..."; \
|
||||
sed -i.bak "s/^version = \"$$COMMON_VERSION\"/version = \"$$COMMON_NEW_VERSION\"/" mcp-sse-proxy/Cargo.toml && rm mcp-sse-proxy/Cargo.toml.bak; \
|
||||
sed -i.bak "s/mcp-common = { version = \"$$COMMON_VERSION\"/mcp-common = { version = \"$$COMMON_NEW_VERSION\"/" mcp-sse-proxy/Cargo.toml && rm mcp-sse-proxy/Cargo.toml.bak; \
|
||||
echo "3️⃣ 更新 mcp-streamable-proxy 版本和依赖..."; \
|
||||
sed -i.bak "s/^version = \"$$COMMON_VERSION\"/version = \"$$COMMON_NEW_VERSION\"/" mcp-streamable-proxy/Cargo.toml && rm mcp-streamable-proxy/Cargo.toml.bak; \
|
||||
sed -i.bak "s/mcp-common = { version = \"$$COMMON_VERSION\"/mcp-common = { version = \"$$COMMON_NEW_VERSION\"/" mcp-streamable-proxy/Cargo.toml && rm mcp-streamable-proxy/Cargo.toml.bak; \
|
||||
echo "4️⃣ 更新 mcp-stdio-proxy 版本和依赖..."; \
|
||||
sed -i.bak "s/^version = \"$$PROXY_VERSION\"/version = \"$$PROXY_NEW_VERSION\"/" mcp-proxy/Cargo.toml && rm mcp-proxy/Cargo.toml.bak; \
|
||||
sed -i.bak "s/mcp-common = { version = \"$$COMMON_VERSION\"/mcp-common = { version = \"$$COMMON_NEW_VERSION\"/" mcp-proxy/Cargo.toml && rm mcp-proxy/Cargo.toml.bak; \
|
||||
sed -i.bak "s/mcp-streamable-proxy = { version = \"$$COMMON_VERSION\"/mcp-streamable-proxy = { version = \"$$COMMON_NEW_VERSION\"/" mcp-proxy/Cargo.toml && rm mcp-proxy/Cargo.toml.bak; \
|
||||
sed -i.bak "s/mcp-sse-proxy = { version = \"$$COMMON_VERSION\"/mcp-sse-proxy = { version = \"$$COMMON_NEW_VERSION\"/" mcp-proxy/Cargo.toml && rm mcp-proxy/Cargo.toml.bak; \
|
||||
echo ""; \
|
||||
echo "✅ 版本号更新完成!"
|
||||
|
||||
# 显示当前 MCP 包的版本号
|
||||
.PHONY: mcp-version-show
|
||||
mcp-version-show:
|
||||
@echo "📋 当前 MCP 包版本号:"
|
||||
@echo ""
|
||||
@echo " mcp-common: $$(grep '^version = ' mcp-common/Cargo.toml | head -1 | sed 's/version = "\(.*\)"/\1/')"
|
||||
@echo " mcp-sse-proxy: $$(grep '^version = ' mcp-sse-proxy/Cargo.toml | head -1 | sed 's/version = "\(.*\)"/\1/')"
|
||||
@echo " mcp-streamable-proxy: $$(grep '^version = ' mcp-streamable-proxy/Cargo.toml | head -1 | sed 's/version = "\(.*\)"/\1/')"
|
||||
@echo " mcp-stdio-proxy: $$(grep '^version = ' mcp-proxy/Cargo.toml | head -1 | sed 's/version = "\(.*\)"/\1/')"
|
||||
@echo ""
|
||||
@echo "📦 依赖版本号检查:"
|
||||
@echo ""
|
||||
@echo " mcp-sse-proxy 依赖的 mcp-common: $$(grep 'mcp-common = { version' mcp-sse-proxy/Cargo.toml | sed 's/.*version = "\([^"]*\)".*/\1/')"
|
||||
@echo " mcp-streamable-proxy 依赖的 mcp-common: $$(grep 'mcp-common = { version' mcp-streamable-proxy/Cargo.toml | sed 's/.*version = "\([^"]*\)".*/\1/')"
|
||||
@echo " mcp-stdio-proxy 依赖的 mcp-common: $$(grep 'mcp-common = { version' mcp-proxy/Cargo.toml | sed 's/.*version = "\([^"]*\)".*/\1/')"
|
||||
@echo " mcp-stdio-proxy 依赖的 mcp-sse-proxy: $$(grep 'mcp-sse-proxy = { version' mcp-proxy/Cargo.toml | sed 's/.*version = "\([^"]*\)".*/\1/')"
|
||||
@echo " mcp-stdio-proxy 依赖的 mcp-streamable-proxy: $$(grep 'mcp-streamable-proxy = { version' mcp-proxy/Cargo.toml | sed 's/.*version = "\([^"]*\)".*/\1/')"
|
||||
|
||||
# 发布所有 MCP 相关包(按依赖顺序)
|
||||
.PHONY: mcp-publish
|
||||
mcp-publish:
|
||||
@echo "📦 开始发布 MCP 相关包到 crates.io..."
|
||||
@echo ""
|
||||
@echo "1️⃣ 发布 mcp-common..."
|
||||
cd mcp-common && cargo publish
|
||||
@echo "⏳ 等待 10 秒让 crates.io 索引更新..."
|
||||
@sleep 10
|
||||
@echo ""
|
||||
@echo "2️⃣ 发布 mcp-sse-proxy..."
|
||||
cd mcp-sse-proxy && cargo publish
|
||||
@echo "⏳ 等待 10 秒让 crates.io 索引更新..."
|
||||
@sleep 10
|
||||
@echo ""
|
||||
@echo "3️⃣ 发布 mcp-streamable-proxy..."
|
||||
cd mcp-streamable-proxy && cargo publish
|
||||
@echo "⏳ 等待 10 秒让 crates.io 索引更新..."
|
||||
@sleep 10
|
||||
@echo ""
|
||||
@echo "4️⃣ 发布 mcp-stdio-proxy..."
|
||||
cd mcp-proxy && cargo publish
|
||||
@echo ""
|
||||
@echo "✅ 所有 MCP 包发布成功!"
|
||||
|
||||
# 预览将要发布的 MCP 包(dry-run)
|
||||
.PHONY: mcp-publish-dry-run
|
||||
mcp-publish-dry-run:
|
||||
@echo "🔍 预览将要发布的 MCP 包..."
|
||||
@echo ""
|
||||
@echo "1️⃣ mcp-common:"
|
||||
cd mcp-common && cargo publish --dry-run
|
||||
@echo ""
|
||||
@echo "2️⃣ mcp-sse-proxy:"
|
||||
cd mcp-sse-proxy && cargo publish --dry-run
|
||||
@echo ""
|
||||
@echo "3️⃣ mcp-streamable-proxy:"
|
||||
cd mcp-streamable-proxy && cargo publish --dry-run
|
||||
@echo ""
|
||||
@echo "4️⃣ mcp-stdio-proxy:"
|
||||
cd mcp-proxy && cargo publish --dry-run
|
||||
@echo ""
|
||||
@echo "✅ 预览完成(未实际发布)"
|
||||
|
||||
# 查看将要发布的文件列表
|
||||
.PHONY: mcp-package-list
|
||||
mcp-package-list:
|
||||
@echo "📋 查看各包将包含的文件..."
|
||||
@echo ""
|
||||
@echo "1️⃣ mcp-common:"
|
||||
cd mcp-common && cargo package --list
|
||||
@echo ""
|
||||
@echo "2️⃣ mcp-sse-proxy:"
|
||||
cd mcp-sse-proxy && cargo package --list
|
||||
@echo ""
|
||||
@echo "3️⃣ mcp-streamable-proxy:"
|
||||
cd mcp-streamable-proxy && cargo package --list
|
||||
@echo ""
|
||||
@echo "4️⃣ mcp-stdio-proxy:"
|
||||
cd mcp-proxy && cargo package --list
|
||||
|
||||
# 清理构建文件
|
||||
.PHONY: clean
|
||||
clean:
|
||||
@echo "🧹 清理构建文件..."
|
||||
rm -rf $(OUTPUT_DIR)
|
||||
@echo "✅ 清理完成"
|
||||
|
||||
|
||||
|
||||
# 清理 Docker 镜像
|
||||
.PHONY: clean-images
|
||||
clean-images:
|
||||
@echo "🧹 清理 Docker 镜像..."
|
||||
docker rmi $(IMAGE_NAME):latest 2>/dev/null || true
|
||||
docker builder prune -f
|
||||
@echo "✅ Docker 镜像清理完成"
|
||||
|
||||
# 显示帮助信息
|
||||
.PHONY: help
|
||||
help:
|
||||
@echo "📖 可用的 Make 命令:"
|
||||
@echo ""
|
||||
@echo " 📄 Document Parser 构建:"
|
||||
@echo " make build-document-parser-x86_64 - 构建 document-parser Linux x86_64 版本(默认)"
|
||||
@echo " make build-document-parser-arm64 - 构建 document-parser Linux ARM64 版本"
|
||||
@echo " make build-document-parser-multi - 构建 document-parser 多平台版本"
|
||||
@echo ""
|
||||
@echo " 🎤 Voice CLI 构建:"
|
||||
@echo " make build-voice-cli-x86_64 - 构建 voice-cli Linux x86_64 版本"
|
||||
@echo " make build-voice-cli-arm64 - 构建 voice-cli Linux ARM64 版本"
|
||||
@echo " make build-voice-cli-multi - 构建 voice-cli 多平台版本"
|
||||
@echo ""
|
||||
@echo " 🔌 MCP Proxy 构建:"
|
||||
@echo " make build-mcp-proxy - 构建 mcp-proxy(当前系统架构)"
|
||||
@echo ""
|
||||
@echo " 🔧 所有组件构建:"
|
||||
@echo " make build-all - 构建所有组件(当前系统架构)"
|
||||
@echo ""
|
||||
@echo " 🐳 Docker 镜像:"
|
||||
@echo " make build-image - 构建 mcp-proxy Docker 运行镜像"
|
||||
@echo " make build-image-document-parser - 构建 document-parser Docker 运行镜像"
|
||||
@echo ""
|
||||
@echo " 🚀 运行命令:"
|
||||
@echo " make run - 构建 + 后台启动 mcp-proxy(docker-compose -d)"
|
||||
@echo " make run-fg - 构建 + 前台启动 mcp-proxy(docker-compose)"
|
||||
@echo " make run-document-parser - 运行 document-parser Docker 镜像"
|
||||
@echo ""
|
||||
@echo " 🛠️ 工具命令:"
|
||||
@echo " make check-buildx - 检查 Docker buildx 状态"
|
||||
@echo " make setup-buildx - 设置 Docker buildx builder"
|
||||
@echo ""
|
||||
@echo " 📦 MCP 发布命令:"
|
||||
@echo " make mcp-version-show - 显示当前所有 MCP 包的版本号"
|
||||
@echo " make mcp-version-update - 自动更新版本号(小版本号加一)"
|
||||
@echo " make mcp-publish - 发布所有 MCP 包到 crates.io(按依赖顺序)"
|
||||
@echo " make mcp-publish-dry-run - 预览将要发布的内容(不实际发布)"
|
||||
@echo " make mcp-package-list - 查看各包将包含的文件列表"
|
||||
@echo ""
|
||||
@echo " 🧹 清理命令:"
|
||||
@echo " make clean - 清理所有构建文件"
|
||||
@echo " make clean-images - 清理 Docker 镜像"
|
||||
@echo ""
|
||||
@echo " ❓ 其他:"
|
||||
@echo " make help - 显示此帮助信息"
|
||||
@echo ""
|
||||
@echo "📝 示例用法:"
|
||||
@echo " make # 构建 document-parser Linux x86_64 版本"
|
||||
@echo " make build-mcp-proxy # 构建 mcp-proxy(当前架构)"
|
||||
@echo " make build-all # 构建所有组件(当前架构)"
|
||||
@echo " make build-image # 构建 mcp-proxy Docker 镜像"
|
||||
@echo " make run # 构建 + 后台启动 mcp-proxy 服务"
|
||||
@echo " make run-fg # 构建 + 前台启动 mcp-proxy 服务"
|
||||
@echo " make mcp-version-show # 查看当前版本号"
|
||||
@echo " make mcp-publish-dry-run # 预览 MCP 发布(建议先运行此命令)"
|
||||
@echo ""
|
||||
@echo "📊 输出目录: ./dist/"
|
||||
@echo " mcp-proxy/ # MCP Proxy 二进制文件(当前架构)"
|
||||
@echo " document-parser-x86_64/ # Document Parser x86_64 二进制文件"
|
||||
@echo " document-parser-arm64/ # Document Parser ARM64 二进制文件"
|
||||
@echo " voice-cli-x86_64/ # Voice CLI x86_64 二进制文件"
|
||||
@echo " voice-cli-arm64/ # Voice CLI ARM64 二进制文件"
|
||||
@echo ""
|
||||
@echo "📁 Docker 目录: ./docker/"
|
||||
@echo " Dockerfile.mcp-proxy # mcp-proxy Docker 构建文件"
|
||||
@echo " Dockerfile.document-parser # document-parser/voice-cli Docker 构建文件"
|
||||
@echo " config.yml # mcp-proxy 默认配置文件"
|
||||
@echo " docker-compose.yml # Docker Compose 配置文件"
|
||||
281
qiming-mcp-proxy/README.md
Normal file
281
qiming-mcp-proxy/README.md
Normal file
@@ -0,0 +1,281 @@
|
||||
# MCP-Proxy Workspace
|
||||
|
||||
**[English](README.md)** | **[简体中文](README_zh-CN.md)**
|
||||
|
||||
---
|
||||
|
||||
# MCP-Proxy Workspace
|
||||
|
||||
A comprehensive Rust workspace implementing MCP (Model Context Protocol) proxy system with multiple services including document parsing, voice transcription, and protocol conversion.
|
||||
|
||||
[](LICENSE)
|
||||
[](https://www.rust-lang.org/)
|
||||
|
||||
## Workspace Members
|
||||
|
||||
| Crate | Version | Description |
|
||||
|-------|---------|-------------|
|
||||
| **mcp-common** | 0.1.5 | Shared types and utilities for MCP proxy components |
|
||||
| **mcp-sse-proxy** | 0.1.5 | SSE (Server-Sent Events) proxy implementation using rmcp 0.10 |
|
||||
| **mcp-streamable-proxy** | 0.1.5 | Streamable HTTP proxy implementation using rmcp 0.12 |
|
||||
| **mcp-stdio-proxy** | 0.1.18 | Main MCP proxy server with CLI tool for protocol conversion |
|
||||
| **document-parser** | 0.1.0 | High-performance multi-format document parsing service |
|
||||
| **voice-cli** | 0.1.0 | Speech-to-text HTTP service with Whisper model support |
|
||||
| **oss-client** | 0.1.0 | Lightweight Alibaba Cloud OSS client library |
|
||||
| **fastembed** | 0.1.0 | Text embedding HTTP service using FastEmbed |
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- **Rust**: 1.70 or later (recommended 1.75+)
|
||||
- **Python**: 3.8+ (for document-parser and voice-cli TTS)
|
||||
- **uv**: Python package manager (install via `curl -LsSf https://astral.sh/uv/install.sh | sh`)
|
||||
|
||||
### Installation
|
||||
|
||||
#### Method 1: Pre-built Binaries (Recommended)
|
||||
|
||||
**Using installation script (Linux/macOS):**
|
||||
```bash
|
||||
# Install mcp-proxy
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://github.com/nuwax-ai/mcp-proxy/releases/latest/download/mcp-stdio-proxy-installer.sh | sh
|
||||
|
||||
# Install document-parser
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://github.com/nuwax-ai/mcp-proxy/releases/latest/download/document-parser-installer.sh | sh
|
||||
|
||||
# Install voice-cli
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://github.com/nuwax-ai/mcp-proxy/releases/latest/download/voice-cli-installer.sh | sh
|
||||
```
|
||||
|
||||
**Using installation script (Windows PowerShell):**
|
||||
```powershell
|
||||
# Install mcp-proxy
|
||||
irm https://github.com/nuwax-ai/mcp-proxy/releases/latest/download/mcp-stdio-proxy-installer.ps1 | iex
|
||||
|
||||
# Install document-parser
|
||||
irm https://github.com/nuwax-ai/mcp-proxy/releases/latest/download/document-parser-installer.ps1 | iex
|
||||
|
||||
# Install voice-cli
|
||||
irm https://github.com/nuwax-ai/mcp-proxy/releases/latest/download/voice-cli-installer.ps1 | iex
|
||||
```
|
||||
|
||||
**Download from GitHub Releases:**
|
||||
Visit [GitHub Releases](https://github.com/nuwax-ai/mcp-proxy/releases) to download binaries for your platform.
|
||||
|
||||
Supported platforms:
|
||||
- Linux x86_64
|
||||
- Linux ARM64
|
||||
- macOS Intel (x86_64)
|
||||
- macOS Apple Silicon (ARM64)
|
||||
- Windows x86_64
|
||||
|
||||
#### Method 2: cargo install
|
||||
|
||||
```bash
|
||||
cargo install mcp-stdio-proxy
|
||||
```
|
||||
|
||||
#### Method 3: Build from Source
|
||||
|
||||
```bash
|
||||
# Clone repository
|
||||
git clone https://github.com/nuwax-ai/mcp-proxy.git
|
||||
cd mcp-proxy
|
||||
|
||||
# Build all workspace members
|
||||
cargo build --release
|
||||
|
||||
# Or build specific crates
|
||||
cargo build -p mcp-proxy
|
||||
cargo build -p document-parser
|
||||
cargo build -p voice-cli
|
||||
```
|
||||
|
||||
### MCP Proxy (mcp-stdio-proxy)
|
||||
|
||||
The main proxy service that converts SSE/Streamable HTTP to stdio protocol.
|
||||
|
||||
```bash
|
||||
# Install from source
|
||||
cargo install --path ./mcp-proxy
|
||||
|
||||
# Start the proxy server
|
||||
mcp-proxy
|
||||
|
||||
# Convert remote MCP service to stdio
|
||||
mcp-proxy convert https://example.com/mcp/sse
|
||||
|
||||
# Check service status
|
||||
mcp-proxy check https://example.com/mcp/sse
|
||||
|
||||
# Detect protocol type
|
||||
mcp-proxy detect https://example.com/mcp
|
||||
```
|
||||
|
||||
**See:** [mcp-proxy/README.md](./mcp-proxy/README.md) for detailed documentation.
|
||||
|
||||
### Document Parser
|
||||
|
||||
High-performance document parsing service supporting PDF, Word, Excel, and PowerPoint.
|
||||
|
||||
```bash
|
||||
cd document-parser
|
||||
|
||||
# Initialize Python environment (first time)
|
||||
document-parser uv-init
|
||||
|
||||
# Check environment status
|
||||
document-parser check
|
||||
|
||||
# Start HTTP server
|
||||
document-parser server
|
||||
```
|
||||
|
||||
**See:** [document-parser/README.md](./document-parser/README.md) for detailed documentation.
|
||||
|
||||
### Voice CLI
|
||||
|
||||
Speech-to-text HTTP service with Whisper model support.
|
||||
|
||||
```bash
|
||||
cd voice-cli
|
||||
|
||||
# Initialize server configuration
|
||||
voice-cli server init
|
||||
|
||||
# Run voice server
|
||||
voice-cli server run
|
||||
|
||||
# List Whisper models
|
||||
voice-cli model list
|
||||
|
||||
# Download model
|
||||
voice-cli model download tiny
|
||||
```
|
||||
|
||||
**See:** [voice-cli/README.md](./voice-cli/README.md) for detailed documentation.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Core Services
|
||||
|
||||
#### 1. MCP Proxy System
|
||||
|
||||
- **mcp-common**: Shared configuration types and utilities
|
||||
- **mcp-sse-proxy**: SSE protocol support (rmcp 0.10)
|
||||
- **mcp-streamable-proxy**: Streamable HTTP protocol support (rmcp 0.12)
|
||||
- **mcp-stdio-proxy**: Main CLI tool for protocol conversion
|
||||
|
||||
**Features:**
|
||||
- Multi-protocol support: SSE, Streamable HTTP, stdio
|
||||
- Dynamic plugin loading
|
||||
- Protocol auto-detection and conversion
|
||||
- OpenTelemetry integration with OTLP
|
||||
- Background health checks
|
||||
|
||||
#### 2. Document Parser
|
||||
|
||||
**Features:**
|
||||
- Multi-format support: PDF (MinerU), Word/Excel/PowerPoint (MarkItDown)
|
||||
- GPU acceleration via CUDA/sglang (optional)
|
||||
- Python environment management with uv
|
||||
- HTTP API with OpenAPI documentation
|
||||
- OSS integration for cloud storage
|
||||
|
||||
#### 3. Voice CLI
|
||||
|
||||
**Features:**
|
||||
- Whisper model integration (tiny/base/small/medium/large)
|
||||
- Multi-format audio support (MP3, WAV, FLAC, M4A, etc.)
|
||||
- Apalis-based async task queue with SQLite persistence
|
||||
- FFmpeg integration for metadata extraction
|
||||
- **TTS service (TODO - currently has issues)**
|
||||
|
||||
#### 4. Utility Libraries
|
||||
|
||||
- **oss-client**: Alibaba Cloud OSS client with unified interface
|
||||
- **fastembed**: Text embedding HTTP service using FastEmbed
|
||||
|
||||
## Development
|
||||
|
||||
### Build Commands
|
||||
|
||||
```bash
|
||||
# Build all workspace crates
|
||||
cargo build
|
||||
|
||||
# Build specific crate
|
||||
cargo build -p mcp-proxy
|
||||
|
||||
# Build in release mode
|
||||
cargo build --release
|
||||
|
||||
# Run tests for all crates
|
||||
cargo test
|
||||
|
||||
# Run tests for specific crate
|
||||
cargo test -p mcp-proxy
|
||||
|
||||
# Run clippy for linting
|
||||
cargo clippy --all-targets --all-features
|
||||
|
||||
# Format code
|
||||
cargo fmt
|
||||
```
|
||||
|
||||
### Cross-Platform Building (Docker)
|
||||
|
||||
```bash
|
||||
# Build document-parser for Linux x86_64
|
||||
make build-document-parser-x86_64
|
||||
|
||||
# Build document-parser for Linux ARM64
|
||||
make build-document-parser-arm64
|
||||
|
||||
# Build voice-cli for Linux x86_64
|
||||
make build-voice-cli-x86_64
|
||||
|
||||
# Build all components for x86_64
|
||||
make build-all-x86_64
|
||||
|
||||
# Build Docker runtime image
|
||||
make build-image
|
||||
|
||||
# Run Docker container
|
||||
make run
|
||||
```
|
||||
|
||||
### Code Style
|
||||
|
||||
- Line length: 100 characters
|
||||
- 4-space indentation (no tabs)
|
||||
- Use `dashmap` for concurrent hashmaps instead of `Arc<RwLock<HashMap>>`
|
||||
- Follow KISS and SOLID principles
|
||||
- "Fail fast" error handling with `anyhow::Context`
|
||||
|
||||
## Documentation
|
||||
|
||||
- [CLAUDE.md](./CLAUDE.md) - Development guide for contributors
|
||||
- [mcp-proxy/README.md](./mcp-proxy/README.md) - MCP Proxy documentation
|
||||
- [document-parser/README.md](./document-parser/README.md) - Document Parser documentation
|
||||
- [voice-cli/README.md](./voice-cli/README.md) - Voice CLI documentation
|
||||
- [oss-client/README.md](./oss-client/README.md) - OSS Client documentation
|
||||
|
||||
## License
|
||||
|
||||
This project is dual-licensed under MIT OR Apache-2.0.
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributions are welcome! Please feel free to submit issues and pull requests.
|
||||
|
||||
- **GitHub Repository**: https://github.com/nuwax-ai/mcp-proxy
|
||||
- **Issue Tracker**: https://github.com/nuwax-ai/mcp-proxy/issues
|
||||
- **Discussions**: https://github.com/nuwax-ai/mcp-proxy/discussions
|
||||
|
||||
## Related Resources
|
||||
|
||||
- [MCP Official Documentation](https://modelcontextprotocol.io/)
|
||||
- [rmcp - Rust MCP Implementation](https://crates.io/crates/rmcp)
|
||||
- [MCP Servers List](https://github.com/modelcontextprotocol/servers)
|
||||
237
qiming-mcp-proxy/README_zh-CN.md
Normal file
237
qiming-mcp-proxy/README_zh-CN.md
Normal file
@@ -0,0 +1,237 @@
|
||||
# MCP-Proxy Workspace
|
||||
|
||||
**[English](README.md)** | **[简体中文](README_zh-CN.md)**
|
||||
|
||||
---
|
||||
|
||||
# MCP-Proxy 工作空间
|
||||
|
||||
一个基于 Rust 的综合工作空间,实现了 MCP (Model Context Protocol) 代理系统,包含文档解析、语音转录和协议转换等多个服务。
|
||||
|
||||
[](LICENSE)
|
||||
[](https://www.rust-lang.org/)
|
||||
|
||||
## 工作空间成员
|
||||
|
||||
| Crates | 版本 | 描述 |
|
||||
|--------|------|------|
|
||||
| **mcp-common** | 0.1.5 | MCP 代理组件的共享类型和工具 |
|
||||
| **mcp-sse-proxy** | 0.1.5 | 基于 rmcp 0.10 的 SSE (Server-Sent Events) 代理实现 |
|
||||
| **mcp-streamable-proxy** | 0.1.5 | 基于 rmcp 0.12 的 Streamable HTTP 代理实现 |
|
||||
| **mcp-stdio-proxy** | 0.1.18 | 主 MCP 代理服务器,带 CLI 工具用于协议转换 |
|
||||
| **document-parser** | 0.1.0 | 高性能多格式文档解析服务 |
|
||||
| **voice-cli** | 0.1.0 | 基于 Whisper 模型的语音转文字 HTTP 服务 |
|
||||
| **oss-client** | 0.1.0 | 轻量级阿里云 OSS 客户端库 |
|
||||
| **fastembed** | 0.1.0 | 使用 FastEmbed 的文本嵌入 HTTP 服务 |
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 环境要求
|
||||
|
||||
- **Rust**: 1.70 或更高版本(推荐 1.75+)
|
||||
- **Python**: 3.8+(用于 document-parser 和 voice-cli TTS)
|
||||
- **uv**: Python 包管理器(通过 `curl -LsSf https://astral.sh/uv/install.sh | sh` 安装)
|
||||
|
||||
### 安装
|
||||
|
||||
```bash
|
||||
# 克隆仓库
|
||||
git clone https://github.com/nuwax-ai/mcp-proxy.git
|
||||
cd mcp-proxy
|
||||
|
||||
# 构建所有工作空间成员
|
||||
cargo build --release
|
||||
|
||||
# 或构建特定 crate
|
||||
cargo build -p mcp-proxy
|
||||
cargo build -p document-parser
|
||||
cargo build -p voice-cli
|
||||
```
|
||||
|
||||
### MCP 代理 (mcp-stdio-proxy)
|
||||
|
||||
主代理服务,将 SSE/Streamable HTTP 转换为 stdio 协议。
|
||||
|
||||
```bash
|
||||
# 从源码安装
|
||||
cargo install --path ./mcp-proxy
|
||||
|
||||
# 启动代理服务器
|
||||
mcp-proxy
|
||||
|
||||
# 将远程 MCP 服务转换为 stdio
|
||||
mcp-proxy convert https://example.com/mcp/sse
|
||||
|
||||
# 检查服务状态
|
||||
mcp-proxy check https://example.com/mcp/sse
|
||||
|
||||
# 检测协议类型
|
||||
mcp-proxy detect https://example.com/mcp
|
||||
```
|
||||
|
||||
**详细文档:** [mcp-proxy/README_zh-CN.md](./mcp-proxy/README_zh-CN.md)
|
||||
|
||||
### 文档解析器
|
||||
|
||||
支持 PDF、Word、Excel 和 PowerPoint 的高性能文档解析服务。
|
||||
|
||||
```bash
|
||||
cd document-parser
|
||||
|
||||
# 初始化 Python 环境(首次使用)
|
||||
document-parser uv-init
|
||||
|
||||
# 检查环境状态
|
||||
document-parser check
|
||||
|
||||
# 启动 HTTP 服务器
|
||||
document-parser server
|
||||
```
|
||||
|
||||
**详细文档:** [document-parser/README_zh-CN.md](./document-parser/README_zh-CN.md)
|
||||
|
||||
### 语音 CLI
|
||||
|
||||
基于 Whisper 模型的语音转文字 HTTP 服务。
|
||||
|
||||
```bash
|
||||
cd voice-cli
|
||||
|
||||
# 初始化服务器配置
|
||||
voice-cli server init
|
||||
|
||||
# 运行语音服务器
|
||||
voice-cli server run
|
||||
|
||||
# 列出 Whisper 模型
|
||||
voice-cli model list
|
||||
|
||||
# 下载模型
|
||||
voice-cli model download tiny
|
||||
```
|
||||
|
||||
**详细文档:** [voice-cli/README_zh-CN.md](./voice-cli/README_zh-CN.md)
|
||||
|
||||
## 架构
|
||||
|
||||
### 核心服务
|
||||
|
||||
#### 1. MCP 代理系统
|
||||
|
||||
- **mcp-common**: 共享配置类型和工具
|
||||
- **mcp-sse-proxy**: SSE 协议支持 (rmcp 0.10)
|
||||
- **mcp-streamable-proxy**: Streamable HTTP 协议支持 (rmcp 0.12)
|
||||
- **mcp-stdio-proxy**: 用于协议转换的主 CLI 工具
|
||||
|
||||
**特性:**
|
||||
- 多协议支持:SSE、Streamable HTTP、stdio
|
||||
- 动态插件加载
|
||||
- 协议自动检测和转换
|
||||
- OpenTelemetry 集成,支持 OTLP
|
||||
- 后台健康检查
|
||||
|
||||
#### 2. 文档解析器
|
||||
|
||||
**特性:**
|
||||
- 多格式支持:PDF (MinerU)、Word/Excel/PowerPoint (MarkItDown)
|
||||
- GPU 加速,通过 CUDA/sglang(可选)
|
||||
- 使用 uv 进行 Python 环境管理
|
||||
- HTTP API,带 OpenAPI 文档
|
||||
- OSS 云存储集成
|
||||
|
||||
#### 3. 语音 CLI
|
||||
|
||||
**特性:**
|
||||
- Whisper 模型集成(tiny/base/small/medium/large)
|
||||
- 多格式音频支持(MP3、WAV、FLAC、M4A 等)
|
||||
- 基于 Apalis 的异步任务队列,带 SQLite 持久化
|
||||
- FFmpeg 集成,用于元数据提取
|
||||
- **TTS 服务(TODO - 当前存在问题)**
|
||||
|
||||
#### 4. 工具库
|
||||
|
||||
- **oss-client**: 阿里云 OSS 客户端,统一接口
|
||||
- **fastembed**: 使用 FastEmbed 的文本嵌入 HTTP 服务
|
||||
|
||||
## 开发
|
||||
|
||||
### 构建命令
|
||||
|
||||
```bash
|
||||
# 构建所有工作空间 crates
|
||||
cargo build
|
||||
|
||||
# 构建特定 crate
|
||||
cargo build -p mcp-proxy
|
||||
|
||||
# 以 release 模式构建
|
||||
cargo build --release
|
||||
|
||||
# 运行所有 crates 的测试
|
||||
cargo test
|
||||
|
||||
# 运行特定 crate 的测试
|
||||
cargo test -p mcp-proxy
|
||||
|
||||
# 运行 clippy 进行代码检查
|
||||
cargo clippy --all-targets --all-features
|
||||
|
||||
# 格式化代码
|
||||
cargo fmt
|
||||
```
|
||||
|
||||
### 跨平台构建 (Docker)
|
||||
|
||||
```bash
|
||||
# 为 Linux x86_64 构建 document-parser
|
||||
make build-document-parser-x86_64
|
||||
|
||||
# 为 Linux ARM64 构建 document-parser
|
||||
make build-document-parser-arm64
|
||||
|
||||
# 为 Linux x86_64 构建 voice-cli
|
||||
make build-voice-cli-x86_64
|
||||
|
||||
# 为 x86_64 构建所有组件
|
||||
make build-all-x86_64
|
||||
|
||||
# 构建 Docker 运行时镜像
|
||||
make build-image
|
||||
|
||||
# 运行 Docker 容器
|
||||
make run
|
||||
```
|
||||
|
||||
### 代码风格
|
||||
|
||||
- 行长度:100 字符
|
||||
- 4 空格缩进(不使用制表符)
|
||||
- 使用 `dashmap` 替代 `Arc<RwLock<HashMap>>` 进行并发哈希映射
|
||||
- 遵循 KISS 和 SOLID 原则
|
||||
- 使用 `anyhow::Context` 实现"尽快失败"错误处理
|
||||
|
||||
## 文档
|
||||
|
||||
- [CLAUDE.md](./CLAUDE.md) - 贡献者开发指南
|
||||
- [mcp-proxy/README_zh-CN.md](./mcp-proxy/README_zh-CN.md) - MCP 代理文档
|
||||
- [document-parser/README_zh-CN.md](./document-parser/README_zh-CN.md) - 文档解析器文档
|
||||
- [voice-cli/README_zh-CN.md](./voice-cli/README_zh-CN.md) - 语音 CLI 文档
|
||||
- [oss-client/README_zh-CN.md](./oss-client/README_zh-CN.md) - OSS 客户端文档
|
||||
|
||||
## 许可证
|
||||
|
||||
本项目采用 MIT OR Apache-2.0 双许可证。
|
||||
|
||||
## 贡献
|
||||
|
||||
欢迎贡献!请随时提交问题和拉取请求。
|
||||
|
||||
- **GitHub 仓库**: https://github.com/nuwax-ai/mcp-proxy
|
||||
- **问题跟踪**: https://github.com/nuwax-ai/mcp-proxy/issues
|
||||
- **讨论区**: https://github.com/nuwax-ai/mcp-proxy/discussions
|
||||
|
||||
## 相关资源
|
||||
|
||||
- [MCP 官方文档](https://modelcontextprotocol.io/)
|
||||
- [rmcp - Rust MCP 实现](https://crates.io/crates/rmcp)
|
||||
- [MCP 服务器列表](https://github.com/modelcontextprotocol/servers)
|
||||
220
qiming-mcp-proxy/RELEASE.md
Normal file
220
qiming-mcp-proxy/RELEASE.md
Normal file
@@ -0,0 +1,220 @@
|
||||
# mcp-proxy 发布指南
|
||||
|
||||
本文档说明如何使用 cargo-dist 进行多平台发布。
|
||||
|
||||
## 📦 支持的项目
|
||||
|
||||
当前使用 cargo-dist 发布以下二进制文件:
|
||||
- `mcp-stdio-proxy` (mcp-proxy)
|
||||
- `document-parser`
|
||||
- `voice-cli`
|
||||
|
||||
## 🚀 发布流程
|
||||
|
||||
### 1. 更新版本号
|
||||
|
||||
更新对应子项目的 `Cargo.toml` 中的版本号:
|
||||
|
||||
```bash
|
||||
# 更新 mcp-proxy
|
||||
cd mcp-proxy
|
||||
# 编辑 Cargo.toml 中的 version 字段
|
||||
|
||||
# 更新 document-parser
|
||||
cd document-parser
|
||||
# 编辑 Cargo.toml 中的 version 字段
|
||||
|
||||
# 更新 voice-cli
|
||||
cd voice-cli
|
||||
# 编辑 Cargo.toml 中的 version 字段
|
||||
```
|
||||
|
||||
### 2. 测试本地构建
|
||||
|
||||
在发布前,可以测试本地构建是否正常:
|
||||
|
||||
```bash
|
||||
# 生成发布计划
|
||||
~/.cargo/bin/dist plan --tag=v0.2.0
|
||||
|
||||
# 构建(测试)
|
||||
~/.cargo/bin/dist build --tag=v0.2.0
|
||||
```
|
||||
|
||||
### 3. 提交并打标签
|
||||
|
||||
```bash
|
||||
# 提交更改
|
||||
git add .
|
||||
git commit -m "release: v0.2.0"
|
||||
|
||||
# 创建标签
|
||||
git tag v0.2.0
|
||||
|
||||
# 推送到远程仓库
|
||||
git push
|
||||
git push --tags
|
||||
```
|
||||
|
||||
### 4. GitHub Actions 自动发布
|
||||
|
||||
推送标签后,GitHub Actions 会自动:
|
||||
- ✅ 构建所有目标平台的二进制文件
|
||||
- ✅ 生成安装脚本(shell 和 powershell)
|
||||
- ✅ 生成校验和(SHA256)
|
||||
- ✅ 创建 GitHub Release
|
||||
- ✅ 上传所有构建产物
|
||||
|
||||
## 📦 支持的平台
|
||||
|
||||
- Linux x86_64 (`x86_64-unknown-linux-gnu`)
|
||||
- Linux ARM64 (`aarch64-unknown-linux-gnu`)
|
||||
- macOS Intel (`x86_64-apple-darwin`)
|
||||
- macOS Apple Silicon (`aarch64-apple-darwin`)
|
||||
- Windows x86_64 (`x86_64-pc-windows-msvc`)
|
||||
|
||||
## 📥 用户安装方式
|
||||
|
||||
### 方式 1: 使用安装脚本(推荐)
|
||||
|
||||
**Linux/macOS:**
|
||||
```bash
|
||||
# 安装 mcp-proxy
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://github.com/nuwax-ai/mcp-proxy/releases/latest/download/mcp-stdio-proxy-installer.sh | sh
|
||||
|
||||
# 安装 document-parser
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://github.com/nuwax-ai/mcp-proxy/releases/latest/download/document-parser-installer.sh | sh
|
||||
|
||||
# 安装 voice-cli
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://github.com/nuwax-ai/mcp-proxy/releases/latest/download/voice-cli-installer.sh | sh
|
||||
```
|
||||
|
||||
**Windows (PowerShell):**
|
||||
```powershell
|
||||
# 安装 mcp-proxy
|
||||
irm https://github.com/nuwax-ai/mcp-proxy/releases/latest/download/mcp-stdio-proxy-installer.ps1 | iex
|
||||
|
||||
# 安装 document-parser
|
||||
irm https://github.com/nuwax-ai/mcp-proxy/releases/latest/download/document-parser-installer.ps1 | iex
|
||||
|
||||
# 安装 voice-cli
|
||||
irm https://github.com/nuwax-ai/mcp-proxy/releases/latest/download/voice-cli-installer.ps1 | iex
|
||||
```
|
||||
|
||||
### 方式 2: 直接下载二进制
|
||||
|
||||
从 [GitHub Releases](https://github.com/nuwax-ai/mcp-proxy/releases) 下载对应平台的压缩包。
|
||||
|
||||
**Linux/macOS:**
|
||||
```bash
|
||||
# 下载并解压
|
||||
curl -L https://github.com/nuwax-ai/mcp-proxy/releases/latest/download/mcp-stdio-proxy-x86_64-unknown-linux-gnu.tar.xz | tar xJ
|
||||
|
||||
# 安装
|
||||
sudo mv mcp-stdio-proxy /usr/local/bin/
|
||||
```
|
||||
|
||||
**Windows:**
|
||||
下载 `.zip` 文件并解压,将 `.exe` 文件放到 PATH 目录中。
|
||||
|
||||
### 方式 3: 使用 cargo install
|
||||
|
||||
```bash
|
||||
cargo install mcp-stdio-proxy
|
||||
```
|
||||
|
||||
### 方式 4: 使用 cargo-binstall
|
||||
|
||||
```bash
|
||||
# 如果已安装 cargo-binstall
|
||||
cargo binstall mcp-stdio-proxy
|
||||
|
||||
# 或从 crates.io 直接安装
|
||||
cargo install cargo-binstall
|
||||
cargo binstall mcp-stdio-proxy
|
||||
```
|
||||
|
||||
## 🔐 校验和验证
|
||||
|
||||
每个发布都会包含 `sha256.sum` 文件,用于验证下载的文件完整性:
|
||||
|
||||
```bash
|
||||
# 下载校验和文件
|
||||
curl -L -O https://github.com/nuwax-ai/mcp-proxy/releases/latest/download/sha256.sum
|
||||
|
||||
# 验证下载的文件
|
||||
sha256sum -c sha256.sum
|
||||
```
|
||||
|
||||
## 📋 发布检查清单
|
||||
|
||||
在发布前,请确认:
|
||||
|
||||
- [ ] 所有子项目的版本号已更新
|
||||
- [ ] `Cargo.toml` 中的 `repository` 字段正确
|
||||
- [ ] CHANGELOG.md 已更新(如有)
|
||||
- [ ] 本地测试构建成功
|
||||
- [ ] Git tag 格式正确(如 `v0.2.0`)
|
||||
- [ ] 推送 tag 到远程仓库
|
||||
|
||||
## 🛠️ 配置文件
|
||||
|
||||
### `dist-workspace.toml`
|
||||
|
||||
配置 cargo-dist 的行为:
|
||||
|
||||
```toml
|
||||
[dist]
|
||||
# CI 后端
|
||||
ci = "github"
|
||||
|
||||
# 安装脚本类型
|
||||
installers = ["shell", "powershell"]
|
||||
|
||||
# 构建目标平台
|
||||
targets = [
|
||||
"aarch64-apple-darwin",
|
||||
"aarch64-unknown-linux-gnu",
|
||||
"x86_64-apple-darwin",
|
||||
"x86_64-unknown-linux-gnu",
|
||||
"x86_64-pc-windows-msvc"
|
||||
]
|
||||
```
|
||||
|
||||
### `.github/workflows/release.yml`
|
||||
|
||||
自动生成的 GitHub Actions 工作流,处理:
|
||||
- 构建计划
|
||||
- 多平台编译
|
||||
- 生成安装脚本和校验和
|
||||
- 创建 GitHub Release
|
||||
|
||||
## 📞 故障排查
|
||||
|
||||
### 构建失败
|
||||
|
||||
如果 GitHub Actions 构建失败,检查:
|
||||
1. 所有子项目的 `Cargo.toml` 都有 `repository` 字段
|
||||
2. 版本号格式正确(遵循 SemVer)
|
||||
3. 所有依赖项兼容
|
||||
|
||||
### 本地测试
|
||||
|
||||
在本地测试发布流程:
|
||||
|
||||
```bash
|
||||
# 查看发布计划
|
||||
dist plan --tag=v0.2.0
|
||||
|
||||
# 构建产物
|
||||
dist build --tag=v0.2.0
|
||||
|
||||
# 查看构建结果
|
||||
ls -la target/distrib/
|
||||
```
|
||||
|
||||
## 📚 相关资源
|
||||
|
||||
- [cargo-dist 官方文档](https://axodotdev.github.io/cargo-dist/)
|
||||
- [GitHub Releases](https://github.com/nuwax-ai/mcp-proxy/releases)
|
||||
- [项目仓库](https://github.com/nuwax-ai/mcp-proxy)
|
||||
4
qiming-mcp-proxy/_typos.toml
Normal file
4
qiming-mcp-proxy/_typos.toml
Normal file
@@ -0,0 +1,4 @@
|
||||
[default.extend-words]
|
||||
|
||||
[files]
|
||||
extend-exclude = ["CHANGELOG.md", "notebooks/*"]
|
||||
3
qiming-mcp-proxy/assets/README.md
Normal file
3
qiming-mcp-proxy/assets/README.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# Assets
|
||||
|
||||
- [juventus.csv](./juventus.csv): dataset from [The-Football-Data](https://github.com/buckthorndev/The-Football-Data).
|
||||
28
qiming-mcp-proxy/assets/juventus.csv
Normal file
28
qiming-mcp-proxy/assets/juventus.csv
Normal file
@@ -0,0 +1,28 @@
|
||||
Name,Position,DOB,Nationality,Kit Number
|
||||
Wojciech Szczesny,Goalkeeper,"Apr 18, 1990 (29)",Poland,1
|
||||
Mattia Perin,Goalkeeper,"Nov 10, 1992 (26)",Italy,37
|
||||
Gianluigi Buffon,Goalkeeper,"Jan 28, 1978 (41)",Italy,77
|
||||
Carlo Pinsoglio,Goalkeeper,"Mar 16, 1990 (29)",Italy,31
|
||||
Matthijs de Ligt,Centre-Back,"Aug 12, 1999 (20)",Netherlands,4
|
||||
Leonardo Bonucci,Centre-Back,"May 1, 1987 (32)",Italy,19
|
||||
Daniele Rugani,Centre-Back,"Jul 29, 1994 (25)",Italy,24
|
||||
Merih Demiral,Centre-Back,"Mar 5, 1998 (21)",Turkey,28
|
||||
Giorgio Chiellini,Centre-Back,"Aug 14, 1984 (35)",Italy,3
|
||||
Alex Sandro,Left-Back,"Jan 26, 1991 (28)",Brazil,12
|
||||
Danilo,Right-Back,"Jul 15, 1991 (28)",Brazil,13
|
||||
Mattia De Sciglio,Right-Back,"Oct 20, 1992 (27)",Italy,2
|
||||
Emre Can,Defensive Midfield,"Jan 12, 1994 (25)",Germany,23
|
||||
Miralem Pjanic,Central Midfield,"Apr 2, 1990 (29)",Bosnia-Herzegovina,5
|
||||
Aaron Ramsey,Central Midfield,"Dec 26, 1990 (28)",Wales,8
|
||||
Adrien Rabiot,Central Midfield,"Apr 3, 1995 (24)",France,25
|
||||
Rodrigo Bentancur,Central Midfield,"Jun 25, 1997 (22)",Uruguay,30
|
||||
Blaise Matuidi,Central Midfield,"Apr 9, 1987 (32)",France,14
|
||||
Sami Khedira,Central Midfield,"Apr 4, 1987 (32)",Germany,6
|
||||
Cristiano Ronaldo,Left Winger,"Feb 5, 1985 (34)",Portugal,7
|
||||
Marko Pjaca,Left Winger,"May 6, 1995 (24)",Croatia,15
|
||||
Federico Bernardeschi,Right Winger,"Feb 16, 1994 (25)",Italy,33
|
||||
Douglas Costa,Right Winger,"Sep 14, 1990 (29)",Brazil,11
|
||||
Juan Cuadrado,Right Winger,"May 26, 1988 (31)",Colombia,16
|
||||
Paulo Dybala,Second Striker,"Nov 15, 1993 (25)",Argentina,10
|
||||
Gonzalo Higuaín,Centre-Forward,"Dec 10, 1987 (31)",Argentina,21
|
||||
Mario Mandzukic,Centre-Forward,"May 21, 1986 (33)",Croatia,17
|
||||
|
95
qiming-mcp-proxy/cliff.toml
Normal file
95
qiming-mcp-proxy/cliff.toml
Normal file
@@ -0,0 +1,95 @@
|
||||
# git-cliff ~ configuration file
|
||||
# https://git-cliff.org/docs/configuration
|
||||
|
||||
[changelog]
|
||||
# changelog header
|
||||
header = """
|
||||
# Changelog\n
|
||||
All notable changes to this project will be documented in this file. See [conventional commits](https://www.conventionalcommits.org/) for commit guidelines.\n
|
||||
"""
|
||||
# template for the changelog body
|
||||
# https://keats.github.io/tera/docs/#introduction
|
||||
body = """
|
||||
---
|
||||
{% if version %}\
|
||||
{% if previous.version %}\
|
||||
## [{{ version | trim_start_matches(pat="v") }}]($REPO/compare/{{ previous.version }}..{{ version }}) - {{ timestamp | date(format="%Y-%m-%d") }}
|
||||
{% else %}\
|
||||
## [{{ version | trim_start_matches(pat="v") }}] - {{ timestamp | date(format="%Y-%m-%d") }}
|
||||
{% endif %}\
|
||||
{% else %}\
|
||||
## [unreleased]
|
||||
{% endif %}\
|
||||
{% for group, commits in commits | group_by(attribute="group") %}
|
||||
### {{ group | striptags | trim | upper_first }}
|
||||
{% for commit in commits
|
||||
| filter(attribute="scope")
|
||||
| sort(attribute="scope") %}
|
||||
- **({{commit.scope}})**{% if commit.breaking %} [**breaking**]{% endif %} \
|
||||
{{ commit.message|trim }} - ([{{ commit.id | truncate(length=7, end="") }}]($REPO/commit/{{ commit.id }})) - {{ commit.author.name }}
|
||||
{%- endfor -%}
|
||||
{% raw %}\n{% endraw %}\
|
||||
{%- for commit in commits %}
|
||||
{%- if commit.scope -%}
|
||||
{% else -%}
|
||||
- {% if commit.breaking %} [**breaking**]{% endif %}\
|
||||
{{ commit.message|trim }} - ([{{ commit.id | truncate(length=7, end="") }}]($REPO/commit/{{ commit.id }})) - {{ commit.author.name }}
|
||||
{% endif -%}
|
||||
{% endfor -%}
|
||||
{% endfor %}\n
|
||||
"""
|
||||
# template for the changelog footer
|
||||
footer = """
|
||||
<!-- generated by git-cliff -->
|
||||
"""
|
||||
# remove the leading and trailing whitespace from the templates
|
||||
trim = true
|
||||
# postprocessors
|
||||
postprocessors = [
|
||||
{ pattern = '\$REPO', replace = "https://github.com/tyrchen/geektime-rust-live-coding" }, # replace repository URL
|
||||
]
|
||||
|
||||
[git]
|
||||
# parse the commits based on https://www.conventionalcommits.org
|
||||
conventional_commits = true
|
||||
# filter out the commits that are not conventional
|
||||
filter_unconventional = false
|
||||
# process each line of a commit as an individual commit
|
||||
split_commits = false
|
||||
# regex for preprocessing the commit messages
|
||||
commit_preprocessors = [
|
||||
# { pattern = '\((\w+\s)?#([0-9]+)\)', replace = "([#${2}](https://github.com/orhun/git-cliff/issues/${2}))"}, # replace issue numbers
|
||||
]
|
||||
# regex for parsing and grouping commits
|
||||
commit_parsers = [
|
||||
{ message = "\\[skip", skip = true },
|
||||
{ message = "\\p{Han}", skip = true },
|
||||
{ message = "^feat", group = "Features" },
|
||||
{ message = "^fix", group = "Bug Fixes" },
|
||||
{ message = "^doc", group = "Documentation" },
|
||||
{ message = "^perf", group = "Performance" },
|
||||
{ message = "^refactor", group = "Refactoring" },
|
||||
{ message = "^style", group = "Style" },
|
||||
{ message = "^revert", group = "Revert" },
|
||||
{ message = "^test", group = "Tests" },
|
||||
{ message = "^chore\\(version\\):", skip = true },
|
||||
{ message = "^chore", group = "Miscellaneous Chores" },
|
||||
{ message = ".*", group = "Other" },
|
||||
{ body = ".*security", group = "Security" },
|
||||
]
|
||||
# protect breaking changes from being skipped due to matching a skipping commit_parser
|
||||
protect_breaking_commits = false
|
||||
# filter out the commits that are not matched by commit parsers
|
||||
filter_commits = false
|
||||
# regex for matching git tags
|
||||
tag_pattern = "v[0-9].*"
|
||||
# regex for skipping tags
|
||||
skip_tags = "v0.1.0-beta.1"
|
||||
# regex for ignoring tags
|
||||
ignore_tags = ""
|
||||
# sort the tags topologically
|
||||
topo_order = false
|
||||
# sort the commits inside sections by oldest/newest order
|
||||
sort_commits = "oldest"
|
||||
# limit the number of commits included in the changelog.
|
||||
# limit_commits = 42
|
||||
16
qiming-mcp-proxy/config.yml
Normal file
16
qiming-mcp-proxy/config.yml
Normal file
@@ -0,0 +1,16 @@
|
||||
server:
|
||||
host: 0.0.0.0
|
||||
port: 8080
|
||||
log:
|
||||
level: info
|
||||
path: logs
|
||||
retain_days: 5
|
||||
mirror:
|
||||
npm_registry: ""
|
||||
pypi_index_url: ""
|
||||
fastembed:
|
||||
cache_dir: .fastembed_cache
|
||||
default_model: BGELargeZHV15
|
||||
max_length: 512
|
||||
batch_size: 256
|
||||
normalize: true
|
||||
239
qiming-mcp-proxy/deny.toml
Normal file
239
qiming-mcp-proxy/deny.toml
Normal file
@@ -0,0 +1,239 @@
|
||||
# This template contains all of the possible sections and their default values
|
||||
|
||||
# Note that all fields that take a lint level have these possible values:
|
||||
# * deny - An error will be produced and the check will fail
|
||||
# * warn - A warning will be produced, but the check will not fail
|
||||
# * allow - No warning or error will be produced, though in some cases a note
|
||||
# will be
|
||||
|
||||
# The values provided in this template are the default values that will be used
|
||||
# when any section or field is not specified in your own configuration
|
||||
|
||||
# Root options
|
||||
|
||||
# The graph table configures how the dependency graph is constructed and thus
|
||||
# which crates the checks are performed against
|
||||
[graph]
|
||||
# If 1 or more target triples (and optionally, target_features) are specified,
|
||||
# only the specified targets will be checked when running `cargo deny check`.
|
||||
# This means, if a particular package is only ever used as a target specific
|
||||
# dependency, such as, for example, the `nix` crate only being used via the
|
||||
# `target_family = "unix"` configuration, that only having windows targets in
|
||||
# this list would mean the nix crate, as well as any of its exclusive
|
||||
# dependencies not shared by any other crates, would be ignored, as the target
|
||||
# list here is effectively saying which targets you are building for.
|
||||
targets = [
|
||||
# The triple can be any string, but only the target triples built in to
|
||||
# rustc (as of 1.40) can be checked against actual config expressions
|
||||
#"x86_64-unknown-linux-musl",
|
||||
# You can also specify which target_features you promise are enabled for a
|
||||
# particular target. target_features are currently not validated against
|
||||
# the actual valid features supported by the target architecture.
|
||||
#{ triple = "wasm32-unknown-unknown", features = ["atomics"] },
|
||||
]
|
||||
# When creating the dependency graph used as the source of truth when checks are
|
||||
# executed, this field can be used to prune crates from the graph, removing them
|
||||
# from the view of cargo-deny. This is an extremely heavy hammer, as if a crate
|
||||
# is pruned from the graph, all of its dependencies will also be pruned unless
|
||||
# they are connected to another crate in the graph that hasn't been pruned,
|
||||
# so it should be used with care. The identifiers are [Package ID Specifications]
|
||||
# (https://doc.rust-lang.org/cargo/reference/pkgid-spec.html)
|
||||
#exclude = []
|
||||
# If true, metadata will be collected with `--all-features`. Note that this can't
|
||||
# be toggled off if true, if you want to conditionally enable `--all-features` it
|
||||
# is recommended to pass `--all-features` on the cmd line instead
|
||||
all-features = false
|
||||
# If true, metadata will be collected with `--no-default-features`. The same
|
||||
# caveat with `all-features` applies
|
||||
no-default-features = false
|
||||
# If set, these feature will be enabled when collecting metadata. If `--features`
|
||||
# is specified on the cmd line they will take precedence over this option.
|
||||
#features = []
|
||||
|
||||
# The output table provides options for how/if diagnostics are outputted
|
||||
[output]
|
||||
# When outputting inclusion graphs in diagnostics that include features, this
|
||||
# option can be used to specify the depth at which feature edges will be added.
|
||||
# This option is included since the graphs can be quite large and the addition
|
||||
# of features from the crate(s) to all of the graph roots can be far too verbose.
|
||||
# This option can be overridden via `--feature-depth` on the cmd line
|
||||
feature-depth = 1
|
||||
|
||||
# This section is considered when running `cargo deny check advisories`
|
||||
# More documentation for the advisories section can be found here:
|
||||
# https://embarkstudios.github.io/cargo-deny/checks/advisories/cfg.html
|
||||
[advisories]
|
||||
# The path where the advisory databases are cloned/fetched into
|
||||
#db-path = "$CARGO_HOME/advisory-dbs"
|
||||
# The url(s) of the advisory databases to use
|
||||
#db-urls = ["https://github.com/rustsec/advisory-db"]
|
||||
# A list of advisory IDs to ignore. Note that ignored advisories will still
|
||||
# output a note when they are encountered.
|
||||
ignore = [
|
||||
#"RUSTSEC-0000-0000",
|
||||
#{ id = "RUSTSEC-0000-0000", reason = "you can specify a reason the advisory is ignored" },
|
||||
#"a-crate-that-is-yanked@0.1.1", # you can also ignore yanked crate versions if you wish
|
||||
#{ crate = "a-crate-that-is-yanked@0.1.1", reason = "you can specify why you are ignoring the yanked crate" },
|
||||
]
|
||||
# If this is true, then cargo deny will use the git executable to fetch advisory database.
|
||||
# If this is false, then it uses a built-in git library.
|
||||
# Setting this to true can be helpful if you have special authentication requirements that cargo-deny does not support.
|
||||
# See Git Authentication for more information about setting up git authentication.
|
||||
#git-fetch-with-cli = true
|
||||
|
||||
# This section is considered when running `cargo deny check licenses`
|
||||
# More documentation for the licenses section can be found here:
|
||||
# https://embarkstudios.github.io/cargo-deny/checks/licenses/cfg.html
|
||||
[licenses]
|
||||
# List of explicitly allowed licenses
|
||||
# See https://spdx.org/licenses/ for list of possible licenses
|
||||
# [possible values: any SPDX 3.11 short identifier (+ optional exception)].
|
||||
allow = [
|
||||
#"MIT",
|
||||
#"Apache-2.0",
|
||||
#"Apache-2.0 WITH LLVM-exception",
|
||||
]
|
||||
# The confidence threshold for detecting a license from license text.
|
||||
# The higher the value, the more closely the license text must be to the
|
||||
# canonical license text of a valid SPDX license file.
|
||||
# [possible values: any between 0.0 and 1.0].
|
||||
confidence-threshold = 0.8
|
||||
# Allow 1 or more licenses on a per-crate basis, so that particular licenses
|
||||
# aren't accepted for every possible crate as with the normal allow list
|
||||
exceptions = [
|
||||
# Each entry is the crate and version constraint, and its specific allow
|
||||
# list
|
||||
#{ allow = ["Zlib"], crate = "adler32" },
|
||||
]
|
||||
|
||||
# Some crates don't have (easily) machine readable licensing information,
|
||||
# adding a clarification entry for it allows you to manually specify the
|
||||
# licensing information
|
||||
#[[licenses.clarify]]
|
||||
# The package spec the clarification applies to
|
||||
#crate = "ring"
|
||||
# The SPDX expression for the license requirements of the crate
|
||||
#expression = "MIT AND ISC AND OpenSSL"
|
||||
# One or more files in the crate's source used as the "source of truth" for
|
||||
# the license expression. If the contents match, the clarification will be used
|
||||
# when running the license check, otherwise the clarification will be ignored
|
||||
# and the crate will be checked normally, which may produce warnings or errors
|
||||
# depending on the rest of your configuration
|
||||
#license-files = [
|
||||
# Each entry is a crate relative path, and the (opaque) hash of its contents
|
||||
#{ path = "LICENSE", hash = 0xbd0eed23 }
|
||||
#]
|
||||
|
||||
[licenses.private]
|
||||
# If true, ignores workspace crates that aren't published, or are only
|
||||
# published to private registries.
|
||||
# To see how to mark a crate as unpublished (to the official registry),
|
||||
# visit https://doc.rust-lang.org/cargo/reference/manifest.html#the-publish-field.
|
||||
ignore = false
|
||||
# One or more private registries that you might publish crates to, if a crate
|
||||
# is only published to private registries, and ignore is true, the crate will
|
||||
# not have its license(s) checked
|
||||
registries = [
|
||||
#"https://sekretz.com/registry
|
||||
]
|
||||
|
||||
# This section is considered when running `cargo deny check bans`.
|
||||
# More documentation about the 'bans' section can be found here:
|
||||
# https://embarkstudios.github.io/cargo-deny/checks/bans/cfg.html
|
||||
[bans]
|
||||
# Lint level for when multiple versions of the same crate are detected
|
||||
multiple-versions = "warn"
|
||||
# Lint level for when a crate version requirement is `*`
|
||||
wildcards = "allow"
|
||||
# The graph highlighting used when creating dotgraphs for crates
|
||||
# with multiple versions
|
||||
# * lowest-version - The path to the lowest versioned duplicate is highlighted
|
||||
# * simplest-path - The path to the version with the fewest edges is highlighted
|
||||
# * all - Both lowest-version and simplest-path are used
|
||||
highlight = "all"
|
||||
# The default lint level for `default` features for crates that are members of
|
||||
# the workspace that is being checked. This can be overridden by allowing/denying
|
||||
# `default` on a crate-by-crate basis if desired.
|
||||
workspace-default-features = "allow"
|
||||
# The default lint level for `default` features for external crates that are not
|
||||
# members of the workspace. This can be overridden by allowing/denying `default`
|
||||
# on a crate-by-crate basis if desired.
|
||||
external-default-features = "allow"
|
||||
# List of crates that are allowed. Use with care!
|
||||
allow = [
|
||||
#"ansi_term@0.11.0",
|
||||
#{ crate = "ansi_term@0.11.0", reason = "you can specify a reason it is allowed" },
|
||||
]
|
||||
# List of crates to deny
|
||||
deny = [
|
||||
#"ansi_term@0.11.0",
|
||||
#{ crate = "ansi_term@0.11.0", reason = "you can specify a reason it is banned" },
|
||||
# Wrapper crates can optionally be specified to allow the crate when it
|
||||
# is a direct dependency of the otherwise banned crate
|
||||
#{ crate = "ansi_term@0.11.0", wrappers = ["this-crate-directly-depends-on-ansi_term"] },
|
||||
]
|
||||
|
||||
# List of features to allow/deny
|
||||
# Each entry the name of a crate and a version range. If version is
|
||||
# not specified, all versions will be matched.
|
||||
#[[bans.features]]
|
||||
#crate = "reqwest"
|
||||
# Features to not allow
|
||||
#deny = ["json"]
|
||||
# Features to allow
|
||||
#allow = [
|
||||
# "rustls",
|
||||
# "__rustls",
|
||||
# "__tls",
|
||||
# "hyper-rustls",
|
||||
# "rustls",
|
||||
# "rustls-pemfile",
|
||||
# "rustls-tls-webpki-roots",
|
||||
# "tokio-rustls",
|
||||
# "webpki-roots",
|
||||
#]
|
||||
# If true, the allowed features must exactly match the enabled feature set. If
|
||||
# this is set there is no point setting `deny`
|
||||
#exact = true
|
||||
|
||||
# Certain crates/versions that will be skipped when doing duplicate detection.
|
||||
skip = [
|
||||
{ crate = "windows_aarch64_msvc@0.52.6", reason = "Duplicate version, unavoidable due to dependency constraints" },
|
||||
{ crate = "windows_aarch64_msvc@0.53.0", reason = "Duplicate version, unavoidable due to dependency constraints" },
|
||||
{ crate = "windows_i686_gnu@0.52.6", reason = "Duplicate version, unavoidable due to dependency constraints" },
|
||||
{ crate = "windows_i686_gnu@0.53.0", reason = "Duplicate version, unavoidable due to dependency constraints" },
|
||||
{ crate = "windows_i686_gnullvm@0.52.6", reason = "Duplicate version, unavoidable due to dependency constraints" },
|
||||
{ crate = "windows_i686_gnullvm@0.53.0", reason = "Duplicate version, unavoidable due to dependency constraints" },
|
||||
]
|
||||
# Similarly to `skip` allows you to skip certain crates during duplicate
|
||||
# detection. Unlike skip, it also includes the entire tree of transitive
|
||||
# dependencies starting at the specified crate, up to a certain depth, which is
|
||||
# by default infinite.
|
||||
skip-tree = [
|
||||
#"ansi_term@0.11.0", # will be skipped along with _all_ of its direct and transitive dependencies
|
||||
#{ crate = "ansi_term@0.11.0", depth = 20 },
|
||||
]
|
||||
|
||||
# This section is considered when running `cargo deny check sources`.
|
||||
# More documentation about the 'sources' section can be found here:
|
||||
# https://embarkstudios.github.io/cargo-deny/checks/sources/cfg.html
|
||||
[sources]
|
||||
# Lint level for what to happen when a crate from a crate registry that is not
|
||||
# in the allow list is encountered
|
||||
unknown-registry = "warn"
|
||||
# Lint level for what to happen when a crate from a git repository that is not
|
||||
# in the allow list is encountered
|
||||
unknown-git = "warn"
|
||||
# List of URLs for allowed crate registries. Defaults to the crates.io index
|
||||
# if not specified. If it is specified but empty, no registries are allowed.
|
||||
allow-registry = ["https://github.com/rust-lang/crates.io-index"]
|
||||
# List of URLs for allowed Git repositories
|
||||
allow-git = []
|
||||
|
||||
[sources.allow-org]
|
||||
# github.com organizations to allow git sources for
|
||||
github = []
|
||||
# gitlab.com organizations to allow git sources for
|
||||
gitlab = []
|
||||
# bitbucket.org organizations to allow git sources for
|
||||
bitbucket = []
|
||||
28
qiming-mcp-proxy/dist-workspace.toml
Normal file
28
qiming-mcp-proxy/dist-workspace.toml
Normal file
@@ -0,0 +1,28 @@
|
||||
[workspace]
|
||||
# 只包含需要发布的包,排除 fastembed 等不支持所有平台的包
|
||||
members = ["cargo:mcp-proxy"]
|
||||
|
||||
# Config for 'dist'
|
||||
[dist]
|
||||
# The preferred dist version to use in CI (Cargo.toml SemVer syntax)
|
||||
cargo-dist-version = "0.30.3"
|
||||
# CI backends to support
|
||||
ci = "github"
|
||||
# The installers to generate for each app
|
||||
installers = ["shell", "powershell", "npm"]
|
||||
# Target platforms to build apps for (Rust target-triple syntax)
|
||||
targets = [
|
||||
"aarch64-apple-darwin",
|
||||
"x86_64-apple-darwin",
|
||||
"aarch64-unknown-linux-gnu",
|
||||
"x86_64-unknown-linux-gnu",
|
||||
"x86_64-pc-windows-msvc",
|
||||
]
|
||||
# Publish jobs to run in CI
|
||||
publish-jobs = ["npm"]
|
||||
# Path that installers should place binaries in
|
||||
install-path = "CARGO_HOME"
|
||||
# Whether to install an updater program
|
||||
install-updater = false
|
||||
# Allow manual modifications to the CI workflow file
|
||||
allow-dirty = ["ci"]
|
||||
1
qiming-mcp-proxy/docker/.npmrc
Normal file
1
qiming-mcp-proxy/docker/.npmrc
Normal file
@@ -0,0 +1 @@
|
||||
registry=https://registry.npmmirror.com/
|
||||
79
qiming-mcp-proxy/docker/Dockerfile.document-parser
Normal file
79
qiming-mcp-proxy/docker/Dockerfile.document-parser
Normal file
@@ -0,0 +1,79 @@
|
||||
# 多阶段构建 Dockerfile,用于跨平台编译 document-parser 和 voice-cli
|
||||
FROM rust:1.90 AS builder
|
||||
|
||||
# 安装必要的构建依赖
|
||||
RUN apt-get update && apt-get install -y \
|
||||
pkg-config \
|
||||
libssl-dev \
|
||||
openssl \
|
||||
ca-certificates \
|
||||
# C/C++ 开发环境
|
||||
build-essential \
|
||||
libc6-dev \
|
||||
gcc \
|
||||
g++ \
|
||||
# LLVM/Clang (bindgen 需要)
|
||||
libclang-dev \
|
||||
clang \
|
||||
# CMake (whisper-rs-sys 需要)
|
||||
cmake \
|
||||
make \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# 验证基础环境
|
||||
RUN echo "=== Verifying build environment ===" && \
|
||||
gcc --version && \
|
||||
cmake --version && \
|
||||
echo "=== Build environment verified ==="
|
||||
|
||||
# 设置工作目录
|
||||
WORKDIR /app
|
||||
|
||||
# 添加 glibc 目标和 rustfmt 组件
|
||||
RUN rustup target add x86_64-unknown-linux-gnu
|
||||
RUN rustup target add aarch64-unknown-linux-gnu
|
||||
RUN rustup component add rustfmt
|
||||
|
||||
# 复制整个项目
|
||||
COPY . .
|
||||
|
||||
# 设置 libclang 路径 (bindgen 需要)
|
||||
ENV LIBCLANG_PATH=/usr/lib/llvm-14/lib
|
||||
|
||||
# 根据目标架构编译所有包
|
||||
ARG TARGETARCH
|
||||
RUN echo "=== Starting build process ==="
|
||||
RUN echo "Target architecture: $TARGETARCH"
|
||||
RUN if [ "$TARGETARCH" = "arm64" ]; then \
|
||||
echo "Building for ARM64 architecture..." && \
|
||||
apt-get update && apt-get install -y gcc-aarch64-linux-gnu g++-aarch64-linux-gnu && \
|
||||
else \
|
||||
echo "Building for x86_64 architecture..." && \
|
||||
fi
|
||||
|
||||
RUN cargo build --release
|
||||
|
||||
# 复制编译好的二进制文件到指定位置
|
||||
RUN mkdir -p /output && \
|
||||
if [ "$TARGETARCH" = "arm64" ]; then \
|
||||
cp target/aarch64-unknown-linux-gnu/release/document-parser /output/ && \
|
||||
cp target/aarch64-unknown-linux-gnu/release/voice-cli /output/; \
|
||||
else \
|
||||
cp target/x86_64-unknown-linux-gnu/release/document-parser /output/ && \
|
||||
cp target/x86_64-unknown-linux-gnu/release/voice-cli /output/; \
|
||||
fi
|
||||
|
||||
# 最终阶段 - 创建最小运行时镜像(document-parser)
|
||||
FROM scratch AS runtime
|
||||
COPY --from=builder /output/document-parser /document-parser
|
||||
ENTRYPOINT ["/document-parser"]
|
||||
|
||||
# 最终阶段 - 创建最小运行时镜像(voice-cli)
|
||||
FROM scratch AS runtime-voice-cli
|
||||
COPY --from=builder /output/voice-cli /voice-cli
|
||||
ENTRYPOINT ["/voice-cli"]
|
||||
|
||||
# 导出阶段 - 用于提取所有二进制文件
|
||||
FROM scratch AS export
|
||||
COPY --from=builder /output/document-parser /document-parser
|
||||
COPY --from=builder /output/voice-cli /voice-cli
|
||||
139
qiming-mcp-proxy/docker/Dockerfile.mcp-proxy
Normal file
139
qiming-mcp-proxy/docker/Dockerfile.mcp-proxy
Normal file
@@ -0,0 +1,139 @@
|
||||
# Dockerfile for mcp-proxy
|
||||
# 多阶段构建,用于构建 mcp-proxy
|
||||
# 与线上环境保持依赖一致
|
||||
|
||||
# ==============================================================================
|
||||
# 第一阶段:构建阶段
|
||||
# ==============================================================================
|
||||
FROM rust:1.92 AS builder
|
||||
|
||||
# 设置环境变量避免交互式提示
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
ENV TZ=Asia/Shanghai
|
||||
|
||||
# 设置 ARG TARGETARCH
|
||||
ARG TARGETARCH
|
||||
|
||||
# 设置工作目录
|
||||
WORKDIR /build
|
||||
|
||||
# 安装构建依赖
|
||||
RUN apt-get update && apt-get install -y \
|
||||
pkg-config \
|
||||
libssl-dev \
|
||||
openssl \
|
||||
ca-certificates \
|
||||
build-essential \
|
||||
libc6-dev \
|
||||
gcc \
|
||||
g++ \
|
||||
libclang-dev \
|
||||
clang \
|
||||
cmake \
|
||||
make \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# 设置 libclang 路径 (bindgen 需要)
|
||||
ENV LIBCLANG_PATH=/usr/lib/llvm-14/lib
|
||||
|
||||
# 复制源代码(复制所有 workspace 成员)
|
||||
COPY Cargo.toml Cargo.lock ./
|
||||
COPY mcp-common/ ./mcp-common/
|
||||
COPY mcp-sse-proxy/ ./mcp-sse-proxy/
|
||||
COPY mcp-streamable-proxy/ ./mcp-streamable-proxy/
|
||||
COPY mcp-proxy/ ./mcp-proxy/
|
||||
COPY oss-client/ ./oss-client/
|
||||
COPY document-parser/ ./document-parser/
|
||||
COPY voice-cli/ ./voice-cli/
|
||||
COPY fastembed/ ./fastembed/
|
||||
|
||||
# 根据 TARGETARCH 设置交叉编译环境
|
||||
RUN echo "=== Starting build process ==="
|
||||
RUN echo "Target architecture: $TARGETARCH"
|
||||
RUN if [ "$TARGETARCH" = "arm64" ]; then echo "Building for ARM64 architecture..." && apt-get update && apt-get install -y gcc-aarch64-linux-gnu g++-aarch64-linux-gnu; else echo "Building for x86_64 architecture..."; fi
|
||||
|
||||
# 构建 mcp-stdio-proxy
|
||||
RUN cargo build --release --bin mcp-proxy
|
||||
|
||||
# ==============================================================================
|
||||
# 第二阶段:运行阶段
|
||||
# ==============================================================================
|
||||
FROM rust:1.92 AS runtime
|
||||
|
||||
# 设置环境变量
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
ENV TZ=Asia/Shanghai
|
||||
ENV PATH="/root/.deno/bin:/root/.cargo/bin:/root/.local/bin:${PATH}"
|
||||
ENV UV_INDEX_URL=https://mirrors.aliyun.com/pypi/simple
|
||||
|
||||
# 安装基础依赖
|
||||
RUN apt-get update && apt-get install -y \
|
||||
python3 \
|
||||
python3-venv \
|
||||
python3-dev \
|
||||
python3-pip \
|
||||
curl \
|
||||
vim \
|
||||
net-tools \
|
||||
gettext-base \
|
||||
telnet \
|
||||
wget \
|
||||
ffmpeg \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& ln -snf /usr/share/zoneinfo/$TZ /etc/localtime \
|
||||
&& echo $TZ > /etc/timezone
|
||||
|
||||
# 安装 Node.js
|
||||
RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \
|
||||
&& apt-get install -y nodejs
|
||||
|
||||
|
||||
# 安装 Deno
|
||||
RUN curl -fsSL https://deno.land/install.sh | sh
|
||||
|
||||
|
||||
# 添加uv到PATH
|
||||
ENV PATH="/root/.local/bin:${PATH}"
|
||||
|
||||
# 安装 uv
|
||||
RUN curl -LsSf https://astral.sh/uv/install.sh | sh && \
|
||||
/root/.local/bin/uv --version
|
||||
|
||||
# 创建虚拟环境
|
||||
RUN uv venv
|
||||
# 设置 uv 镜像加速地址
|
||||
ENV UV_INDEX_URL=https://mirrors.aliyun.com/pypi/simple
|
||||
|
||||
# ==============================================================================
|
||||
# 应用配置
|
||||
# ==============================================================================
|
||||
# 设置工作目录
|
||||
WORKDIR /app
|
||||
|
||||
# 复制构建产物
|
||||
COPY --from=builder /build/target/release/mcp-proxy ./mcp-proxy
|
||||
|
||||
# 复制默认配置文件
|
||||
COPY docker/config.yml /app/config.yml
|
||||
|
||||
# 复制 npm 配置文件(配置国内镜像源)
|
||||
COPY docker/.npmrc /root/.npmrc
|
||||
|
||||
# 复制 pip 配置文件(配置国内镜像源)
|
||||
COPY docker/pip.conf /etc/pip.conf
|
||||
|
||||
# 创建日志目录
|
||||
RUN mkdir -p /app/logs
|
||||
|
||||
# ==============================================================================
|
||||
# 暴露端口和健康检查
|
||||
# ==============================================================================
|
||||
# 暴露端口
|
||||
EXPOSE 8080
|
||||
|
||||
# 健康检查
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||
CMD curl -f http://localhost:8080/health || exit 1
|
||||
|
||||
# 入口点 - 服务器模式会自动加载 /app/config.yml
|
||||
ENTRYPOINT ["/app/mcp-proxy"]
|
||||
198
qiming-mcp-proxy/docker/README.md
Normal file
198
qiming-mcp-proxy/docker/README.md
Normal file
@@ -0,0 +1,198 @@
|
||||
# Docker 配置说明
|
||||
|
||||
本目录包含 mcp-proxy 项目的所有 Docker 相关配置文件,与线上环境保持依赖一致。
|
||||
|
||||
## 文件说明
|
||||
|
||||
### Dockerfile.mcp-proxy
|
||||
mcp-proxy 的 Docker 构建文件,采用多阶段构建:
|
||||
|
||||
**构建阶段:**
|
||||
- 基础镜像:`rust:1.90`
|
||||
- 设置时区:`Asia/Shanghai`
|
||||
- 构建命令:`cargo build --release --bin mcp-proxy`
|
||||
|
||||
**运行阶段:**
|
||||
- 基础镜像:`rust:1.90`
|
||||
- 包含完整的运行时环境(与线上环境一致)
|
||||
- 支持 Node.js 22.x、Python 3、Deno、Go 1.24.3
|
||||
|
||||
### Dockerfile.document-parser
|
||||
document-parser 和 voice-cli 的 Docker 构建文件,采用多阶段构建:
|
||||
|
||||
**构建阶段:**
|
||||
- 基础镜像:`rust:1.90`
|
||||
- 构建命令:`cargo build --release`
|
||||
|
||||
**运行阶段:**
|
||||
- 使用 `scratch` 基础镜像(最小化镜像)
|
||||
- 支持两个目标:
|
||||
- `runtime` - document-parser 运行时
|
||||
- `runtime-voice-cli` - voice-cli 运行时
|
||||
- `export` - 导出所有二进制文件
|
||||
|
||||
### config.yml
|
||||
mcp-proxy 的默认配置文件。
|
||||
|
||||
### docker-compose.yml
|
||||
Docker Compose 配置文件,用于快速启动 mcp-proxy 服务。
|
||||
|
||||
### .npmrc
|
||||
npm 国内镜像源配置(淘宝镜像),用于 Node.js 包管理:
|
||||
```ini
|
||||
registry=https://registry.npmmirror.com/
|
||||
```
|
||||
|
||||
### pip.conf
|
||||
pip 国内镜像源配置(清华大学镜像),用于 Python 包管理:
|
||||
```ini
|
||||
[global]
|
||||
index-url = https://pypi.tuna.tsinghua.edu.cn/simple
|
||||
trusted-host = pypi.tuna.tsinghua.edu.cn
|
||||
```
|
||||
|
||||
## 运行时环境
|
||||
|
||||
### mcp-proxy 容器
|
||||
|
||||
| 环境 | 版本 | 用途 |
|
||||
|------|------|------|
|
||||
| Rust | 1.90 | 基础运行时 |
|
||||
| Node.js | 22.x | run_code 功能执行 Node.js 代码 |
|
||||
| Python | 3.x + uv | run_code 功能执行 Python 代码 |
|
||||
| Deno | 最新版 | run_code 功能执行 TypeScript/JavaScript 代码 |
|
||||
| Go | 1.24.3 | run_code 功能执行 Go 代码 |
|
||||
|
||||
### 额外工具
|
||||
|
||||
| 工具 | 用途 |
|
||||
|------|------|
|
||||
| ffmpeg | 音视频处理 |
|
||||
| vim | 文本编辑 |
|
||||
| net-tools | 网络工具 |
|
||||
| telnet | 网络调试 |
|
||||
| wget | 文件下载 |
|
||||
|
||||
### document-parser/voice-cli 容器
|
||||
|
||||
使用 `scratch` 基础镜像,仅包含二进制文件,无额外依赖。
|
||||
|
||||
## 国内镜像源配置
|
||||
|
||||
容器内已配置以下国内镜像源:
|
||||
|
||||
| 工具 | 镜像源 | 配置位置 |
|
||||
|------|--------|----------|
|
||||
| npm | 淘宝镜像 | `/root/.npmrc` |
|
||||
| pip | 清华大学镜像 | `/etc/pip.conf` |
|
||||
| uv | 阿里云镜像 | `UV_INDEX_URL` 环境变量 |
|
||||
|
||||
## Go MCP 工具
|
||||
|
||||
mcp-proxy 容器内预装了 `go-mcp-mysql` 工具用于测试:
|
||||
```bash
|
||||
go install -v github.com/Zhwt/go-mcp-mysql@latest
|
||||
```
|
||||
|
||||
## 使用方法
|
||||
|
||||
### 构建 mcp-proxy 镜像
|
||||
|
||||
```bash
|
||||
# 使用 docker build
|
||||
docker build -f docker/Dockerfile.mcp-proxy -t mcp-proxy:latest ..
|
||||
|
||||
# 或使用 Make 命令
|
||||
make build-image
|
||||
|
||||
# 构建 ARM64 镜像
|
||||
docker build -f docker/Dockerfile.mcp-proxy --platform linux/arm64 -t mcp-proxy:latest ..
|
||||
```
|
||||
|
||||
### 构建 document-parser 镜像
|
||||
|
||||
```bash
|
||||
# 使用 docker build
|
||||
docker build -f docker/Dockerfile.document-parser --target runtime -t document-parser:latest ..
|
||||
|
||||
# 或使用 Make 命令
|
||||
make build-image-document-parser
|
||||
|
||||
# 构建 voice-cli 镜像
|
||||
docker build -f docker/Dockerfile.document-parser --target runtime-voice-cli -t voice-cli:latest ..
|
||||
```
|
||||
|
||||
### 使用 docker-compose(推荐)
|
||||
|
||||
```bash
|
||||
# 启动服务
|
||||
cd docker && docker-compose up
|
||||
|
||||
# 后台启动
|
||||
cd docker && docker-compose up -d
|
||||
|
||||
# 查看日志
|
||||
cd docker && docker-compose logs -f
|
||||
|
||||
# 停止服务
|
||||
cd docker && docker-compose down
|
||||
```
|
||||
|
||||
### 使用 Make 命令
|
||||
|
||||
```bash
|
||||
# mcp-proxy 相关
|
||||
make build-mcp-proxy-x86_64 # 构建 mcp-proxy x86_64 版本
|
||||
make build-image # 构建 mcp-proxy Docker 镜像
|
||||
make run-compose # 使用 docker-compose 启动
|
||||
|
||||
# document-parser 相关
|
||||
make build-document-parser-x86_64 # 构建 document-parser x86_64 版本
|
||||
make build-image-document-parser # 构建 document-parser Docker 镜像
|
||||
|
||||
# voice-cli 相关
|
||||
make build-voice-cli-x86_64 # 构建 voice-cli x86_64 版本
|
||||
```
|
||||
|
||||
## 环境变量
|
||||
|
||||
### mcp-proxy 容器
|
||||
|
||||
| 环境变量 | 说明 | 默认值 |
|
||||
|----------|------|--------|
|
||||
| RUST_LOG | 日志级别 | info |
|
||||
| TZ | 时区 | Asia/Shanghai |
|
||||
| UV_INDEX_URL | uv 镜像源 | https://mirrors.aliyun.com/pypi/simple |
|
||||
|
||||
## 挂载目录
|
||||
|
||||
### mcp-proxy 容器
|
||||
|
||||
- `./config.yml` - 配置文件(只读)
|
||||
- `./logs` - 日志目录(持久化)
|
||||
|
||||
## 健康检查
|
||||
|
||||
mcp-proxy 容器包含健康检查,定期检查 `/health` 端点:
|
||||
- 检查间隔:30 秒
|
||||
- 超时时间:10 秒
|
||||
- 重试次数:3 次
|
||||
- 启动等待:5 秒
|
||||
|
||||
## 线上环境一致性
|
||||
|
||||
本目录的 Dockerfile 与线上环境保持依赖一致:
|
||||
- `Dockerfile.mcp-proxy` 对应 `/Volumes/soddygo/git_work/build-agent-docker/build_config/mcp_proxy/Dockerfile`
|
||||
|
||||
## 目录结构
|
||||
|
||||
```
|
||||
docker/
|
||||
├── Dockerfile.mcp-proxy # mcp-proxy Docker 构建文件
|
||||
├── Dockerfile.document-parser # document-parser/voice-cli Docker 构建文件
|
||||
├── config.yml # mcp-proxy 默认配置
|
||||
├── docker-compose.yml # Docker Compose 配置
|
||||
├── .npmrc # npm 国内镜像源配置
|
||||
├── pip.conf # pip 国内镜像源配置
|
||||
└── README.md # 本文档
|
||||
```
|
||||
15
qiming-mcp-proxy/docker/config.yml
Normal file
15
qiming-mcp-proxy/docker/config.yml
Normal file
@@ -0,0 +1,15 @@
|
||||
# mcp-proxy 默认配置文件(用于 Docker 环境)
|
||||
|
||||
server:
|
||||
# 监听端口
|
||||
port: 8080
|
||||
# 监听地址
|
||||
host: 0.0.0.0
|
||||
|
||||
log:
|
||||
# 日志级别: trace, debug, info, warn, error
|
||||
level: info
|
||||
# 日志文件路径(Docker 环境下输出到 stdout,此配置仅用于本地文件日志)
|
||||
path: /app/logs
|
||||
# 保留最近 N 个日志文件
|
||||
retain_days: 3
|
||||
37
qiming-mcp-proxy/docker/docker-compose.yml
Normal file
37
qiming-mcp-proxy/docker/docker-compose.yml
Normal file
@@ -0,0 +1,37 @@
|
||||
# Docker Compose 配置文件 for mcp-proxy
|
||||
# 用于快速启动和测试 mcp-proxy 服务
|
||||
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
mcp-proxy:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: docker/Dockerfile.mcp-proxy
|
||||
image: mcp-proxy:latest
|
||||
container_name: mcp-proxy
|
||||
ports:
|
||||
- "8080:8080"
|
||||
volumes:
|
||||
# 挂载配置文件(可选,用于覆盖默认配置)
|
||||
- ./config.yml:/app/config.yml:ro
|
||||
# 挂载日志目录(可选,用于持久化日志)
|
||||
- ./logs:/app/logs
|
||||
environment:
|
||||
# 日志级别(可通过环境变量覆盖配置文件)
|
||||
- RUST_LOG=info
|
||||
# 服务端口(可通过环境变量覆盖配置文件)
|
||||
- MCP_PROXY_PORT=8080
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 5s
|
||||
networks:
|
||||
- mcp-network
|
||||
|
||||
networks:
|
||||
mcp-network:
|
||||
driver: bridge
|
||||
3
qiming-mcp-proxy/docker/pip.conf
Normal file
3
qiming-mcp-proxy/docker/pip.conf
Normal file
@@ -0,0 +1,3 @@
|
||||
[global]
|
||||
index-url = https://pypi.tuna.tsinghua.edu.cn/simple
|
||||
trusted-host = pypi.tuna.tsinghua.edu.cn
|
||||
256
qiming-mcp-proxy/docker/test/mcp-proxy.rest
Normal file
256
qiming-mcp-proxy/docker/test/mcp-proxy.rest
Normal file
@@ -0,0 +1,256 @@
|
||||
###############################################################################
|
||||
# MCP Proxy API 测试文件
|
||||
# 使用 VS Code REST Client 扩展或 IntelliJ HTTP Client 执行
|
||||
###############################################################################
|
||||
#
|
||||
# 测试流程:
|
||||
# 1. 先调用 /check_status 接口创建 MCP 服务(服务会异步启动)
|
||||
# 2. 再次调用 /check_status 确认服务状态变为 Ready
|
||||
# 3. 使用返回的 SSE/Stream URL 在 MCP 客户端中连接
|
||||
#
|
||||
# SSE 协议 URL 格式:
|
||||
# - 连接: {{baseUrl}}/mcp/sse/proxy/{mcp_id}/sse
|
||||
# - 消息: {{baseUrl}}/mcp/sse/proxy/{mcp_id}/message
|
||||
#
|
||||
# Streamable HTTP 协议 URL 格式:
|
||||
# - 请求: {{baseUrl}}/mcp/stream/proxy/{mcp_id}
|
||||
#
|
||||
###############################################################################
|
||||
|
||||
@baseUrl = http://localhost:8085
|
||||
@ssePrefix = /mcp/sse
|
||||
@streamPrefix = /mcp/stream
|
||||
|
||||
###############################################################################
|
||||
# 环境变量(根据实际情况修改)
|
||||
###############################################################################
|
||||
# 如果需要认证,取消下行注释并设置 token
|
||||
# @token = your-auth-token-here
|
||||
|
||||
# Coze API Token(用于测试 Coze MCP 服务)
|
||||
# 从环境变量 COZE_API_TOKEN 读取,使用前请设置: export COZE_API_TOKEN=your_token_here
|
||||
@cozeApiToken = {{$dotenv COZE_API_TOKEN}}
|
||||
|
||||
###############################################################################
|
||||
# 健康检查接口
|
||||
###############################################################################
|
||||
|
||||
### 健康检查
|
||||
GET {{baseUrl}}/health
|
||||
|
||||
### 就绪检查
|
||||
GET {{baseUrl}}/ready
|
||||
|
||||
###############################################################################
|
||||
# SSE 协议 - MCP 服务状态检查与创建
|
||||
###############################################################################
|
||||
|
||||
### 检查/创建 SSE 协议的 MCP 服务 - Time MCP (stdio)
|
||||
# @name checkTimeMcpSse
|
||||
POST {{baseUrl}}{{ssePrefix}}/check_status
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"mcpId": "time-mcp-sse",
|
||||
"mcpJsonConfig": "{\"mcpServers\":{\"time\":{\"command\":\"uvx\",\"args\":[\"mcp-server-time\"]}}}",
|
||||
"mcpType": "OneShot"
|
||||
}
|
||||
|
||||
### 检查/创建 SSE 协议的 MCP 服务 - Coze Plugin (remote URL)
|
||||
# @name checkCozeMcpSse
|
||||
POST {{baseUrl}}{{ssePrefix}}/check_status
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"mcpId": "coze_plugin_tianyancha",
|
||||
"mcpJsonConfig": "{\"mcpServers\":{\"coze_plugin_tianyancha\":{\"url\":\"https://mcp.coze.cn/v1/plugins/7407724292865130515\",\"headers\":{\"Authorization\":\"Bearer {{cozeApiToken}}\"}}}}",
|
||||
"mcpType": "OneShot"
|
||||
}
|
||||
|
||||
### SSE 连接端点 - 连接 Time MCP
|
||||
# 在 MCP 客户端中使用此 URL
|
||||
# SSE URL: {{baseUrl}}{{ssePrefix}}/proxy/time-mcp-sse/sse
|
||||
# Message URL: {{baseUrl}}{{ssePrefix}}/proxy/time-mcp-sse/message
|
||||
GET {{baseUrl}}{{ssePrefix}}/proxy/time-mcp-sse/sse
|
||||
Accept: text/event-stream
|
||||
|
||||
### SSE 连接端点 - 连接 Coze Plugin
|
||||
# SSE URL: {{baseUrl}}{{ssePrefix}}/proxy/coze_plugin_tianyancha/sse
|
||||
# Message URL: {{baseUrl}}{{ssePrefix}}/proxy/coze_plugin_tianyancha/message
|
||||
GET {{baseUrl}}{{ssePrefix}}/proxy/coze_plugin_tianyancha/sse
|
||||
Accept: text/event-stream
|
||||
|
||||
### SSE 发送消息示例
|
||||
POST {{baseUrl}}{{ssePrefix}}/proxy/time-mcp-sse/message
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": "tools/list"
|
||||
}
|
||||
|
||||
###############################################################################
|
||||
# Streamable HTTP 协议 - MCP 服务状态检查与创建
|
||||
###############################################################################
|
||||
|
||||
### 检查/创建 Stream 协议的 MCP 服务 - Time MCP (stdio)
|
||||
# @name checkTimeMcpStream
|
||||
POST {{baseUrl}}{{streamPrefix}}/check_status
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"mcpId": "time-mcp-stream",
|
||||
"mcpJsonConfig": "{\"mcpServers\":{\"time\":{\"command\":\"uvx\",\"args\":[\"mcp-server-time\"]}}}",
|
||||
"mcpType": "OneShot"
|
||||
}
|
||||
|
||||
### 检查/创建 Stream 协议的 MCP 服务 - Coze Plugin (remote URL)
|
||||
# @name checkCozeMcpStream
|
||||
POST {{baseUrl}}{{streamPrefix}}/check_status
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"mcpId": "coze_plugin_tianyancha_stream",
|
||||
"mcpJsonConfig": "{\"mcpServers\":{\"coze_plugin_tianyancha\":{\"url\":\"https://mcp.coze.cn/v1/plugins/7407724292865130515\",\"headers\":{\"Authorization\":\"Bearer {{cozeApiToken}}\"}}}}",
|
||||
"mcpType": "OneShot"
|
||||
}
|
||||
|
||||
### Streamable HTTP 请求 - Time MCP
|
||||
# 在 MCP 客户端中使用此 URL
|
||||
# Stream URL: {{baseUrl}}{{streamPrefix}}/proxy/time-mcp-stream
|
||||
POST {{baseUrl}}{{streamPrefix}}/proxy/time-mcp-stream
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": "tools/list"
|
||||
}
|
||||
|
||||
### Streamable HTTP 请求 - Coze Plugin
|
||||
POST {{baseUrl}}{{streamPrefix}}/proxy/coze_plugin_tianyancha_stream
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": "tools/list"
|
||||
}
|
||||
|
||||
###############################################################################
|
||||
# 查询服务状态(GET 方式)
|
||||
###############################################################################
|
||||
|
||||
### 查询 Time MCP SSE 服务状态
|
||||
GET {{baseUrl}}/mcp/check/status/time-mcp-sse
|
||||
|
||||
### 查询 Coze Plugin SSE 服务状态
|
||||
GET {{baseUrl}}/mcp/check/status/coze_plugin_tianyancha
|
||||
|
||||
### 查询 Time MCP Stream 服务状态
|
||||
GET {{baseUrl}}/mcp/check/status/time-mcp-stream
|
||||
|
||||
###############################################################################
|
||||
# 添加路由(/mcp/sse/add)- 同步方式创建服务
|
||||
# 注意:此接口使用 snake_case 参数名
|
||||
###############################################################################
|
||||
|
||||
### 添加 Time MCP 服务(同步创建)
|
||||
POST {{baseUrl}}{{ssePrefix}}/add
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"mcp_json_config": "{\"mcpServers\":{\"time\":{\"command\":\"uvx\",\"args\":[\"mcp-server-time\"]}}}",
|
||||
"mcp_type": "OneShot"
|
||||
}
|
||||
|
||||
### 添加 Coze Plugin 服务(同步创建)
|
||||
POST {{baseUrl}}{{ssePrefix}}/add
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"mcp_json_config": "{\"mcpServers\":{\"coze_plugin_tianyancha\":{\"url\":\"https://mcp.coze.cn/v1/plugins/7407724292865130515\",\"headers\":{\"Authorization\":\"Bearer {{cozeApiToken}}\"}}}}",
|
||||
"mcp_type": "OneShot"
|
||||
}
|
||||
|
||||
###############################################################################
|
||||
# MCP 客户端配置示例
|
||||
###############################################################################
|
||||
|
||||
### 在 Claude Desktop 或其他 MCP 客户端中使用以下配置:
|
||||
|
||||
# SSE 协议配置(Time MCP):
|
||||
# {
|
||||
# "mcpServers": {
|
||||
# "time-proxy": {
|
||||
# "url": "http://localhost:3000/mcp/sse/proxy/time-mcp-sse/sse"
|
||||
# }
|
||||
# }
|
||||
# }
|
||||
|
||||
# SSE 协议配置(Coze Plugin):
|
||||
# {
|
||||
# "mcpServers": {
|
||||
# "coze-proxy": {
|
||||
# "url": "http://localhost:3000/mcp/sse/proxy/coze_plugin_tianyancha/sse"
|
||||
# }
|
||||
# }
|
||||
# }
|
||||
|
||||
# Streamable HTTP 协议配置(Time MCP):
|
||||
# {
|
||||
# "mcpServers": {
|
||||
# "time-proxy-stream": {
|
||||
# "url": "http://localhost:3000/mcp/stream/proxy/time-mcp-stream",
|
||||
# "type": "streamable-http"
|
||||
# }
|
||||
# }
|
||||
# }
|
||||
|
||||
###############################################################################
|
||||
# 常用 MCP 服务配置示例
|
||||
###############################################################################
|
||||
|
||||
### 文件系统 MCP
|
||||
POST {{baseUrl}}{{ssePrefix}}/check_status
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"mcpId": "filesystem-mcp",
|
||||
"mcpJsonConfig": "{\"mcpServers\":{\"filesystem\":{\"command\":\"npx\",\"args\":[\"-y\",\"@modelcontextprotocol/server-filesystem\",\"/tmp\"]}}}",
|
||||
"mcpType": "OneShot"
|
||||
}
|
||||
|
||||
### GitHub MCP
|
||||
POST {{baseUrl}}{{ssePrefix}}/check_status
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"mcpId": "github-mcp",
|
||||
"mcpJsonConfig": "{\"mcpServers\":{\"github\":{\"command\":\"npx\",\"args\":[\"-y\",\"@modelcontextprotocol/server-github\"],\"env\":{\"GITHUB_PERSONAL_ACCESS_TOKEN\":\"your-github-token\"}}}}",
|
||||
"mcpType": "OneShot"
|
||||
}
|
||||
|
||||
### SQLite MCP
|
||||
POST {{baseUrl}}{{ssePrefix}}/check_status
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"mcpId": "sqlite-mcp",
|
||||
"mcpJsonConfig": "{\"mcpServers\":{\"sqlite\":{\"command\":\"uvx\",\"args\":[\"mcp-server-sqlite\",\"--db-path\",\"/tmp/test.db\"]}}}",
|
||||
"mcpType": "OneShot"
|
||||
}
|
||||
|
||||
###############################################################################
|
||||
# 删除服务路由
|
||||
###############################################################################
|
||||
|
||||
### 删除 Time MCP SSE 服务
|
||||
DELETE {{baseUrl}}/mcp/config/delete/time-mcp-sse
|
||||
|
||||
### 删除 Coze Plugin SSE 服务
|
||||
DELETE {{baseUrl}}/mcp/config/delete/coze_plugin_tianyancha
|
||||
|
||||
### 删除 Time MCP Stream 服务
|
||||
DELETE {{baseUrl}}/mcp/config/delete/time-mcp-stream
|
||||
451
qiming-mcp-proxy/docs/LOG_CONFIGURATION.md
Normal file
451
qiming-mcp-proxy/docs/LOG_CONFIGURATION.md
Normal file
@@ -0,0 +1,451 @@
|
||||
# mcp-proxy 日志配置指南
|
||||
|
||||
## 概述
|
||||
|
||||
mcp-proxy 支持灵活的日志配置,可以通过配置文件或环境变量进行配置,非常适合与 Tauri 等客户端集成。
|
||||
|
||||
## 日志功能特性
|
||||
|
||||
- ✅ **按日期滚动**:每天自动创建新的日志文件
|
||||
- ✅ **自动清理**:保留最近 N 天的日志文件
|
||||
- ✅ **多级别过滤**:支持 trace/debug/info/warn/error
|
||||
- ✅ **双输出**:同时输出到控制台和文件
|
||||
- ✅ **结构化日志**:使用 tracing 框架
|
||||
- ✅ **环境变量覆盖**:支持运行时动态配置
|
||||
|
||||
## 配置方式
|
||||
|
||||
### 方式 1: 环境变量(推荐用于 Tauri 集成)
|
||||
|
||||
环境变量具有最高优先级,会覆盖配置文件中的设置。
|
||||
|
||||
```bash
|
||||
# 设置日志目录(Tauri 可以传递应用数据目录)
|
||||
export MCP_PROXY_LOG_DIR="/path/to/logs"
|
||||
|
||||
# 设置日志级别
|
||||
export MCP_PROXY_LOG_LEVEL="info"
|
||||
|
||||
# 设置服务器端口
|
||||
export MCP_PROXY_PORT="18099"
|
||||
```
|
||||
|
||||
**Windows (PowerShell)**:
|
||||
```powershell
|
||||
$env:MCP_PROXY_LOG_DIR = "C:\Users\YourName\AppData\Roaming\YourApp\logs"
|
||||
$env:MCP_PROXY_LOG_LEVEL = "info"
|
||||
$env:MCP_PROXY_PORT = "18099"
|
||||
```
|
||||
|
||||
**Windows (CMD)**:
|
||||
```cmd
|
||||
set MCP_PROXY_LOG_DIR=C:\Users\YourName\AppData\Roaming\YourApp\logs
|
||||
set MCP_PROXY_LOG_LEVEL=info
|
||||
set MCP_PROXY_PORT=18099
|
||||
```
|
||||
|
||||
### 方式 2: 配置文件
|
||||
|
||||
创建 `config.yml` 文件:
|
||||
|
||||
```yaml
|
||||
server:
|
||||
port: 18099
|
||||
|
||||
log:
|
||||
level: "info"
|
||||
path: "./logs"
|
||||
retain_days: 7
|
||||
```
|
||||
|
||||
配置文件查找顺序:
|
||||
1. `/app/config.yml` (Docker 容器内)
|
||||
2. `./config.yml` (当前工作目录)
|
||||
3. 环境变量 `BOT_SERVER_CONFIG` 指定的路径
|
||||
|
||||
## Tauri 集成示例
|
||||
|
||||
### Rust 端 (nuwax-agent)
|
||||
|
||||
```rust
|
||||
use std::process::Command;
|
||||
use std::path::PathBuf;
|
||||
use tauri::api::path::app_data_dir;
|
||||
|
||||
pub async fn start_mcp_proxy(
|
||||
tauri_config: &tauri::Config,
|
||||
port: u16,
|
||||
) -> Result<Child> {
|
||||
// 获取 Tauri 应用数据目录
|
||||
let app_data = app_data_dir(tauri_config)
|
||||
.ok_or_else(|| anyhow::anyhow!("无法获取应用数据目录"))?;
|
||||
|
||||
// 创建日志目录
|
||||
let log_dir = app_data.join("logs").join("mcp-proxy");
|
||||
std::fs::create_dir_all(&log_dir)?;
|
||||
|
||||
let log_dir_str = log_dir.to_string_lossy().to_string();
|
||||
|
||||
tracing::info!("MCP Proxy 日志目录: {}", log_dir_str);
|
||||
|
||||
// 查找 mcp-proxy 可执行文件
|
||||
let mcp_proxy_path = resolve_npm_bin("mcp-proxy").await?;
|
||||
|
||||
// 启动进程,传递环境变量
|
||||
let mut cmd = tokio::process::Command::new(&mcp_proxy_path);
|
||||
|
||||
cmd.env("MCP_PROXY_LOG_DIR", &log_dir_str);
|
||||
cmd.env("MCP_PROXY_LOG_LEVEL", "info");
|
||||
cmd.env("MCP_PROXY_PORT", port.to_string());
|
||||
|
||||
// Windows: 隐藏 CMD 窗口
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::process::CommandExt;
|
||||
const CREATE_NO_WINDOW: u32 = 0x08000000;
|
||||
cmd.creation_flags(CREATE_NO_WINDOW);
|
||||
}
|
||||
|
||||
// 捕获 stdout/stderr 用于调试
|
||||
cmd.stdout(std::process::Stdio::piped());
|
||||
cmd.stderr(std::process::Stdio::piped());
|
||||
|
||||
let mut child = cmd.spawn()?;
|
||||
|
||||
// 读取并记录输出
|
||||
if let Some(stdout) = child.stdout.take() {
|
||||
tokio::spawn(async move {
|
||||
let reader = tokio::io::BufReader::new(stdout);
|
||||
let mut lines = reader.lines();
|
||||
while let Ok(Some(line)) = lines.next_line().await {
|
||||
tracing::info!("[mcp-proxy stdout] {}", line);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if let Some(stderr) = child.stderr.take() {
|
||||
tokio::spawn(async move {
|
||||
let reader = tokio::io::BufReader::new(stderr);
|
||||
let mut lines = reader.lines();
|
||||
while let Ok(Some(line)) = lines.next_line().await {
|
||||
tracing::error!("[mcp-proxy stderr] {}", line);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Ok(child)
|
||||
}
|
||||
```
|
||||
|
||||
### TypeScript/JavaScript 端
|
||||
|
||||
```typescript
|
||||
import { Command } from '@tauri-apps/api/shell';
|
||||
import { appDataDir, join } from '@tauri-apps/api/path';
|
||||
|
||||
async function startMcpProxy(port: number = 18099) {
|
||||
// 获取应用数据目录
|
||||
const appData = await appDataDir();
|
||||
const logDir = await join(appData, 'logs', 'mcp-proxy');
|
||||
|
||||
console.log(`MCP Proxy 日志目录: ${logDir}`);
|
||||
|
||||
// 创建命令
|
||||
const command = new Command('mcp-proxy', [], {
|
||||
env: {
|
||||
MCP_PROXY_LOG_DIR: logDir,
|
||||
MCP_PROXY_LOG_LEVEL: 'info',
|
||||
MCP_PROXY_PORT: port.toString(),
|
||||
}
|
||||
});
|
||||
|
||||
// 监听输出
|
||||
command.on('output', (data) => {
|
||||
console.log('[mcp-proxy]', data);
|
||||
});
|
||||
|
||||
command.on('error', (error) => {
|
||||
console.error('[mcp-proxy error]', error);
|
||||
});
|
||||
|
||||
// 执行命令
|
||||
const child = await command.spawn();
|
||||
|
||||
console.log(`MCP Proxy 已启动,PID: ${child.pid}`);
|
||||
|
||||
return child;
|
||||
}
|
||||
```
|
||||
|
||||
## 日志文件格式
|
||||
|
||||
### 文件命名规则
|
||||
|
||||
```
|
||||
日志目录/
|
||||
├── log.2026-02-12 # 2026年2月12日的日志
|
||||
├── log.2026-02-13 # 2026年2月13日的日志
|
||||
└── log.2026-02-14 # 2026年2月14日的日志(当前)
|
||||
```
|
||||
|
||||
**自动清理**:默认保留最近 5 天的日志,可通过 `retain_days` 配置。
|
||||
|
||||
### 日志内容示例
|
||||
|
||||
```
|
||||
2026-02-12T14:30:15.123456Z INFO mcp_proxy: ========================================
|
||||
2026-02-12T14:30:15.123789Z INFO mcp_proxy: MCP-Proxy 服务启动
|
||||
2026-02-12T14:30:15.124012Z INFO mcp_proxy: 命令: proxy (HTTP 服务器模式)
|
||||
2026-02-12T14:30:15.124234Z INFO mcp_proxy: 版本: 0.1.39
|
||||
2026-02-12T14:30:15.124456Z INFO mcp_proxy: 配置信息:
|
||||
2026-02-12T14:30:15.124678Z INFO mcp_proxy: - 监听端口: 18099
|
||||
2026-02-12T14:30:15.124890Z INFO mcp_proxy: - 日志目录: /path/to/logs
|
||||
2026-02-12T14:30:15.125012Z INFO mcp_proxy: - 日志级别: info
|
||||
2026-02-12T14:30:15.125234Z INFO mcp_proxy: - 日志保留: 7 天
|
||||
2026-02-12T14:30:15.125456Z INFO mcp_proxy: 环境变量覆盖:
|
||||
2026-02-12T14:30:15.125678Z INFO mcp_proxy: - MCP_PROXY_LOG_DIR: /custom/log/path
|
||||
2026-02-12T14:30:15.125890Z INFO mcp_proxy: ========================================
|
||||
2026-02-12T14:30:15.234567Z INFO mcp_proxy: 尝试绑定到地址: 0.0.0.0:18099
|
||||
2026-02-12T14:30:15.345678Z INFO mcp_proxy: 成功绑定到地址: 0.0.0.0:18099
|
||||
2026-02-12T14:30:15.456789Z INFO mcp_proxy: 初始化应用状态...
|
||||
2026-02-12T14:30:15.567890Z INFO mcp_proxy: 应用状态初始化完成
|
||||
2026-02-12T14:30:15.678901Z INFO mcp_proxy: 初始化路由...
|
||||
2026-02-12T14:30:15.789012Z INFO mcp_proxy: 路由初始化完成
|
||||
2026-02-12T14:30:15.890123Z INFO mcp_proxy: ✅ 服务启动成功,监听地址: 0.0.0.0:18099
|
||||
2026-02-12T14:30:15.901234Z INFO mcp_proxy: ✅ 健康检查端点: http://0.0.0.0:18099/health
|
||||
2026-02-12T14:30:15.912345Z INFO mcp_proxy: ✅ MCP 服务列表: http://0.0.0.0:18099/mcp
|
||||
2026-02-12T14:30:16.023456Z INFO mcp_proxy: ✅ MCP服务状态检查定时任务已启动
|
||||
2026-02-12T14:30:16.134567Z INFO mcp_proxy: 系统信息:
|
||||
2026-02-12T14:30:16.145678Z INFO mcp_proxy: - 操作系统: windows
|
||||
2026-02-12T14:30:16.156789Z INFO mcp_proxy: - 架构: x86_64
|
||||
2026-02-12T14:30:16.167890Z INFO mcp_proxy: - 工作目录: "C:\\Users\\YourName\\AppData\\Local"
|
||||
2026-02-12T14:30:16.178901Z INFO mcp_proxy: 🚀 HTTP 服务器启动,等待连接...
|
||||
```
|
||||
|
||||
## 日志级别说明
|
||||
|
||||
| 级别 | 用途 | 建议场景 |
|
||||
|------|------|---------|
|
||||
| `trace` | 最详细的调试信息 | 开发调试 |
|
||||
| `debug` | 调试信息 | 开发和测试 |
|
||||
| `info` | 一般信息 | **生产环境推荐** |
|
||||
| `warn` | 警告信息 | 最小日志 |
|
||||
| `error` | 错误信息 | 仅记录错误 |
|
||||
|
||||
## 启动时的 stderr 输出
|
||||
|
||||
即使日志写入文件,mcp-proxy 也会在启动时向 **stderr** 输出关键信息:
|
||||
|
||||
```
|
||||
========================================
|
||||
MCP-Proxy 启动中...
|
||||
版本: 0.1.39
|
||||
配置加载完成:
|
||||
- 端口: 18099
|
||||
- 日志目录: /path/to/logs
|
||||
- 日志级别: info
|
||||
- 日志保留天数: 7
|
||||
========================================
|
||||
```
|
||||
|
||||
这确保即使日志系统初始化失败,也能看到基本的启动信息。
|
||||
|
||||
## 故障排查
|
||||
|
||||
### 问题 1: 日志文件未创建
|
||||
|
||||
**可能原因**:
|
||||
- 日志目录路径不存在或无权限
|
||||
- 环境变量设置错误
|
||||
|
||||
**解决方案**:
|
||||
```bash
|
||||
# 检查日志目录是否存在
|
||||
ls -la /path/to/logs
|
||||
|
||||
# 检查环境变量
|
||||
echo $MCP_PROXY_LOG_DIR
|
||||
|
||||
# 手动创建目录
|
||||
mkdir -p /path/to/logs
|
||||
chmod 755 /path/to/logs
|
||||
```
|
||||
|
||||
### 问题 2: 看不到日志输出
|
||||
|
||||
**可能原因**:
|
||||
- 日志级别设置过高(如 `error`)
|
||||
- Windows 下 CMD 窗口被隐藏
|
||||
|
||||
**解决方案**:
|
||||
```bash
|
||||
# 降低日志级别
|
||||
export MCP_PROXY_LOG_LEVEL="debug"
|
||||
|
||||
# 或使用 RUST_LOG 环境变量
|
||||
export RUST_LOG="mcp_proxy=debug"
|
||||
```
|
||||
|
||||
### 问题 3: 日志文件过多
|
||||
|
||||
**可能原因**:
|
||||
- `retain_days` 设置过大
|
||||
|
||||
**解决方案**:
|
||||
```bash
|
||||
# 设置保留天数(通过配置文件)
|
||||
# config.yml
|
||||
log:
|
||||
retain_days: 3 # 只保留 3 天
|
||||
|
||||
# 或手动清理旧日志
|
||||
find /path/to/logs -name "log.*" -mtime +7 -delete
|
||||
```
|
||||
|
||||
## 完整示例:nuwax-agent 集成
|
||||
|
||||
```rust
|
||||
// nuwax-agent-core/src/service/mcp_proxy.rs
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use tokio::process::Child;
|
||||
use std::path::PathBuf;
|
||||
|
||||
pub struct McpProxyConfig {
|
||||
pub port: u16,
|
||||
pub log_dir: PathBuf,
|
||||
pub log_level: String,
|
||||
}
|
||||
|
||||
pub async fn start_mcp_proxy(config: McpProxyConfig) -> Result<Child> {
|
||||
// 确保日志目录存在
|
||||
std::fs::create_dir_all(&config.log_dir)
|
||||
.context("创建 MCP Proxy 日志目录失败")?;
|
||||
|
||||
tracing::info!("启动 MCP Proxy:");
|
||||
tracing::info!(" - 端口: {}", config.port);
|
||||
tracing::info!(" - 日志目录: {}", config.log_dir.display());
|
||||
tracing::info!(" - 日志级别: {}", config.log_level);
|
||||
|
||||
// 查找可执行文件
|
||||
let mcp_proxy_path = which::which("mcp-proxy")
|
||||
.or_else(|_| {
|
||||
// Fallback: 尝试 npm 全局路径
|
||||
let home = std::env::var("HOME")
|
||||
.or_else(|_| std::env::var("USERPROFILE"))?;
|
||||
|
||||
#[cfg(windows)]
|
||||
let npm_bin = PathBuf::from(&home).join(".local/bin/mcp-proxy.cmd");
|
||||
|
||||
#[cfg(not(windows))]
|
||||
let npm_bin = PathBuf::from(&home).join(".local/bin/mcp-proxy");
|
||||
|
||||
if npm_bin.exists() {
|
||||
Ok(npm_bin)
|
||||
} else {
|
||||
Err(which::Error::CannotFindBinaryPath)
|
||||
}
|
||||
})
|
||||
.context("找不到 mcp-proxy 可执行文件")?;
|
||||
|
||||
tracing::info!("mcp-proxy 路径: {}", mcp_proxy_path.display());
|
||||
|
||||
// 构建命令
|
||||
let mut cmd = tokio::process::Command::new(&mcp_proxy_path);
|
||||
|
||||
// 设置环境变量
|
||||
cmd.env("MCP_PROXY_LOG_DIR", config.log_dir.to_string_lossy().as_ref());
|
||||
cmd.env("MCP_PROXY_LOG_LEVEL", &config.log_level);
|
||||
cmd.env("MCP_PROXY_PORT", config.port.to_string());
|
||||
|
||||
// 捕获输出
|
||||
cmd.stdout(std::process::Stdio::piped());
|
||||
cmd.stderr(std::process::Stdio::piped());
|
||||
|
||||
// Windows: 隐藏 CMD 窗口
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::process::CommandExt;
|
||||
const CREATE_NO_WINDOW: u32 = 0x08000000;
|
||||
cmd.creation_flags(CREATE_NO_WINDOW);
|
||||
}
|
||||
|
||||
// 启动进程
|
||||
let mut child = cmd.spawn()
|
||||
.context("启动 mcp-proxy 进程失败")?;
|
||||
|
||||
// 异步读取 stdout
|
||||
if let Some(stdout) = child.stdout.take() {
|
||||
use tokio::io::{AsyncBufReadExt, BufReader};
|
||||
|
||||
tokio::spawn(async move {
|
||||
let reader = BufReader::new(stdout);
|
||||
let mut lines = reader.lines();
|
||||
|
||||
while let Ok(Some(line)) = lines.next_line().await {
|
||||
tracing::info!("[mcp-proxy] {}", line);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 异步读取 stderr
|
||||
if let Some(stderr) = child.stderr.take() {
|
||||
use tokio::io::{AsyncBufReadExt, BufReader};
|
||||
|
||||
tokio::spawn(async move {
|
||||
let reader = BufReader::new(stderr);
|
||||
let mut lines = reader.lines();
|
||||
|
||||
while let Ok(Some(line)) = lines.next_line().await {
|
||||
tracing::error!("[mcp-proxy stderr] {}", line);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
tracing::info!("MCP Proxy 进程已启动,PID: {:?}", child.id());
|
||||
|
||||
Ok(child)
|
||||
}
|
||||
```
|
||||
|
||||
## 环境变量参考
|
||||
|
||||
| 环境变量 | 类型 | 默认值 | 说明 |
|
||||
|---------|------|--------|------|
|
||||
| `MCP_PROXY_PORT` | u16 | `3000` | HTTP 服务监听端口 |
|
||||
| `MCP_PROXY_LOG_DIR` | String | `./logs` | 日志文件目录 |
|
||||
| `MCP_PROXY_LOG_LEVEL` | String | `info` | 日志级别 (trace/debug/info/warn/error) |
|
||||
| `RUST_LOG` | String | - | Rust 标准日志环境变量(更细粒度控制) |
|
||||
| `BOT_SERVER_CONFIG` | String | - | 自定义配置文件路径 |
|
||||
|
||||
## 高级用法:RUST_LOG
|
||||
|
||||
`RUST_LOG` 环境变量提供更细粒度的日志控制:
|
||||
|
||||
```bash
|
||||
# 只显示 mcp_proxy 模块的 debug 级别日志
|
||||
export RUST_LOG="mcp_proxy=debug"
|
||||
|
||||
# 显示多个模块的日志
|
||||
export RUST_LOG="mcp_proxy=debug,tokio=info,hyper=warn"
|
||||
|
||||
# 显示所有模块的 trace 级别日志(非常详细)
|
||||
export RUST_LOG="trace"
|
||||
|
||||
# 组合使用
|
||||
export RUST_LOG="debug,mcp_proxy=trace,hyper::proto=error"
|
||||
```
|
||||
|
||||
**注意**:`RUST_LOG` 优先级高于 `MCP_PROXY_LOG_LEVEL`。
|
||||
|
||||
## 总结
|
||||
|
||||
- ✅ 使用 `MCP_PROXY_LOG_DIR` 环境变量指定日志目录(Tauri 集成推荐)
|
||||
- ✅ 日志文件按日期自动滚动,默认保留 5 天
|
||||
- ✅ 关键启动信息会输出到 stderr,即使日志系统失败也能看到
|
||||
- ✅ 支持通过环境变量动态配置,无需修改配置文件
|
||||
- ✅ Windows 下可以隐藏 CMD 窗口,但仍然捕获日志输出
|
||||
|
||||
更多信息请参考:
|
||||
- [Tracing 文档](https://docs.rs/tracing)
|
||||
- [Tracing Subscriber 文档](https://docs.rs/tracing-subscriber)
|
||||
@@ -0,0 +1,511 @@
|
||||
# mcp-proxy 启动失败分析
|
||||
|
||||
## 问题描述
|
||||
|
||||
从 nuwax-agent 日志中发现,mcp-proxy 启动后健康检查持续失败:
|
||||
|
||||
```
|
||||
2026-02-12T06:19:01.052725Z ERROR [McpProxy] 健康检查失败:
|
||||
MCP Proxy 健康检查超时: 等待 15s 后 http://127.0.0.1:18099/mcp 仍未就绪
|
||||
```
|
||||
|
||||
**启动日志**:
|
||||
```
|
||||
2026-02-12T06:18:41.088275Z INFO [McpProxy] 可执行文件路径: C:\Users\MECHREVO\.local\bin\mcp-proxy.cmd
|
||||
2026-02-12T06:18:41.088293Z INFO [McpProxy] 监听地址: 127.0.0.1:18099
|
||||
2026-02-12T06:18:41.088337Z INFO [McpProxy] Windows: 添加 npm 全局 bin 到 PATH: C:\Users\MECHREVO\AppData\Roaming\npm
|
||||
... (15 秒后)
|
||||
2026-02-12T06:19:01.052725Z ERROR [McpProxy] 健康检查失败
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 关键问题
|
||||
|
||||
### 1. 缺少子进程输出
|
||||
|
||||
由于 CMD 窗口可能被隐藏或者 nuwax-agent 没有捕获 stdout/stderr,**看不到 mcp-proxy 的实际错误信息**。
|
||||
|
||||
这就像在黑暗中调试:
|
||||
```
|
||||
nuwax-agent: "mcp-proxy,你启动了吗?"
|
||||
[15 秒沉默]
|
||||
nuwax-agent: "超时了,你失败了!"
|
||||
mcp-proxy: (可能早就崩溃了,但没人知道为什么)
|
||||
```
|
||||
|
||||
### 2. 健康检查端点可能不对
|
||||
|
||||
日志显示健康检查 URL:
|
||||
```
|
||||
http://127.0.0.1:18099/mcp
|
||||
```
|
||||
|
||||
**需要验证**:mcp-proxy 的健康检查端点是否真的是 `/mcp`?
|
||||
|
||||
根据 mcp-proxy 代码,让我检查实际的路由:
|
||||
|
||||
---
|
||||
|
||||
## 诊断步骤
|
||||
|
||||
### 步骤 1: 检查 mcp-proxy 实际的健康检查路由
|
||||
|
||||
从 mcp-proxy 的代码分析(假设基于之前的代码结构):
|
||||
|
||||
**可能的健康检查端点**:
|
||||
- `/health` - 标准健康检查
|
||||
- `/` - 根路径
|
||||
- `/mcp` - MCP 服务列表
|
||||
- `/api/health` - API 健康检查
|
||||
|
||||
**需要确认**: nuwax-agent 使用的 `/mcp` 端点是否正确。
|
||||
|
||||
### 步骤 2: 手动测试 mcp-proxy
|
||||
|
||||
在 Windows 测试机上手动运行:
|
||||
|
||||
```powershell
|
||||
# 1. 手动启动 mcp-proxy
|
||||
cd C:\Users\MECHREVO\.local\bin
|
||||
.\mcp-proxy.cmd --help
|
||||
|
||||
# 2. 查看可用命令
|
||||
.\mcp-proxy.cmd server --help
|
||||
|
||||
# 3. 手动启动服务器(查看完整输出)
|
||||
.\mcp-proxy.cmd server --port 18099
|
||||
|
||||
# 4. 在另一个终端测试健康检查
|
||||
curl http://127.0.0.1:18099/health
|
||||
curl http://127.0.0.1:18099/mcp
|
||||
curl http://127.0.0.1:18099/
|
||||
|
||||
# 5. 检查进程
|
||||
tasklist | findstr node
|
||||
netstat -ano | findstr "18099"
|
||||
```
|
||||
|
||||
### 步骤 3: 检查依赖问题
|
||||
|
||||
mcp-proxy 可能缺少 Node.js 依赖:
|
||||
|
||||
```powershell
|
||||
# 检查 mcp-proxy 的依赖
|
||||
cd C:\Users\MECHREVO\.local\bin\node_modules\mcp-stdio-proxy
|
||||
npm list
|
||||
|
||||
# 重新安装(如果有问题)
|
||||
npm install -g mcp-stdio-proxy --force
|
||||
```
|
||||
|
||||
### 步骤 4: 检查端口占用
|
||||
|
||||
```powershell
|
||||
# 检查 18099 端口是否被占用
|
||||
netstat -ano | findstr "18099"
|
||||
|
||||
# 如果被占用,查看占用进程
|
||||
tasklist /FI "PID eq <PID>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 可能的失败原因
|
||||
|
||||
### 原因 1: Node.js 模块缺失 ⭐ 最可能
|
||||
|
||||
**症状**: npm 包安装成功,但 node_modules 不完整
|
||||
|
||||
**可能性**:
|
||||
- npm 安装过程中网络问题
|
||||
- 包的 postinstall 脚本失败
|
||||
- Windows 长路径问题(路径超过 260 字符)
|
||||
|
||||
**验证**:
|
||||
```powershell
|
||||
# 检查 mcp-stdio-proxy 的 node_modules
|
||||
dir C:\Users\MECHREVO\.local\bin\node_modules\mcp-stdio-proxy\node_modules
|
||||
|
||||
# 检查 package.json 中的依赖
|
||||
type C:\Users\MECHREVO\.local\bin\node_modules\mcp-stdio-proxy\package.json
|
||||
```
|
||||
|
||||
**解决**:
|
||||
```powershell
|
||||
# 清理并重新安装
|
||||
npm uninstall -g mcp-stdio-proxy
|
||||
npm cache clean --force
|
||||
npm install -g mcp-stdio-proxy --verbose
|
||||
```
|
||||
|
||||
### 原因 2: 健康检查 URL 不匹配
|
||||
|
||||
**症状**: mcp-proxy 启动成功,但 nuwax-agent 访问了错误的端点
|
||||
|
||||
**验证**: 需要查看 mcp-proxy 的实际路由
|
||||
|
||||
从 mcp-proxy 源码看,可能的路由:
|
||||
```rust
|
||||
// mcp-proxy/src/main.rs 或 server 模块
|
||||
.route("/", get(root_handler))
|
||||
.route("/health", get(health_check))
|
||||
.route("/mcp", get(list_mcp_services)) // 这个可能不是健康检查!
|
||||
```
|
||||
|
||||
**/mcp 可能是服务列表端点,不是健康检查!**
|
||||
|
||||
**正确的健康检查应该是**:
|
||||
```rust
|
||||
// 应该访问
|
||||
http://127.0.0.1:18099/health
|
||||
// 而不是
|
||||
http://127.0.0.1:18099/mcp
|
||||
```
|
||||
|
||||
### 原因 3: 环境变量问题
|
||||
|
||||
**症状**: mcp-proxy 需要特定的环境变量
|
||||
|
||||
日志显示添加了 PATH:
|
||||
```
|
||||
Windows: 添加 npm 全局 bin 到 PATH: C:\Users\MECHREVO\AppData\Roaming\npm
|
||||
```
|
||||
|
||||
但可能还需要其他环境变量(如果 mcp-proxy 有子进程)。
|
||||
|
||||
### 原因 4: 配置文件缺失
|
||||
|
||||
**症状**: mcp-proxy 启动时需要配置文件,但找不到
|
||||
|
||||
从日志中看到:
|
||||
```
|
||||
WARN [McpProxy] mcpServers 配置为空,跳过启动
|
||||
```
|
||||
|
||||
**这可能是关键**!如果 `mcpServers` 配置为空,mcp-proxy 可能:
|
||||
- 不启动 HTTP 服务器
|
||||
- 或者启动了但没有实际的服务端点
|
||||
|
||||
**需要确认**:
|
||||
1. mcp-proxy 是否需要配置文件?
|
||||
2. 配置文件应该在哪里?
|
||||
3. 空配置时 mcp-proxy 的行为是什么?
|
||||
|
||||
### 原因 5: Windows 防火墙
|
||||
|
||||
**症状**: 本地回环连接被防火墙阻止
|
||||
|
||||
**验证**:
|
||||
```powershell
|
||||
# 检查防火墙规则
|
||||
netsh advfirewall firewall show rule name=all | findstr "18099"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 深入分析:mcpServers 配置为空
|
||||
|
||||
从日志:
|
||||
```
|
||||
2026-02-12T06:18:32.797802Z WARN [McpProxy] mcpServers 配置为空,跳过启动
|
||||
```
|
||||
|
||||
**这说明**:
|
||||
1. nuwax-agent 读取配置发现 `mcpServers` 为空
|
||||
2. nuwax-agent 可能**根本没有启动 mcp-proxy 进程**
|
||||
3. 或者启动了但 mcp-proxy 立即退出了
|
||||
|
||||
**查看完整的启动流程**:
|
||||
```
|
||||
06:18:32.797761 INFO [BinPath] mcp-proxy 找到: C:\Users\...\mcp-proxy.cmd
|
||||
06:18:32.797802 WARN [McpProxy] mcpServers 配置为空,跳过启动
|
||||
06:18:32.797825 INFO [Services] MCP Proxy 启动命令已发送
|
||||
|
||||
06:18:41.088275 INFO [McpProxy] 可执行文件路径: C:\Users\...\mcp-proxy.cmd
|
||||
06:18:41.088293 INFO [McpProxy] 监听地址: 127.0.0.1:18099
|
||||
... (启动了?)
|
||||
|
||||
06:19:01.052725 ERROR [McpProxy] 健康检查失败
|
||||
```
|
||||
|
||||
**矛盾点**:
|
||||
- 前面说"跳过启动"
|
||||
- 后面又有启动日志和健康检查失败
|
||||
|
||||
**可能的情况**:
|
||||
1. **第一次启动**(06:18:32)被跳过了(配置为空)
|
||||
2. **第二次启动**(06:18:41)实际执行了,但失败了
|
||||
|
||||
让我查看日志中是否有多次重启:
|
||||
|
||||
从日志确实看到多次重启:
|
||||
```
|
||||
06:18:11 开始重启所有服务
|
||||
06:18:32 第一次尝试启动 MCP Proxy (配置为空,跳过)
|
||||
06:18:35 再次重启所有服务
|
||||
06:18:40 第二次尝试启动 MCP Proxy (实际启动了)
|
||||
06:19:01 健康检查失败
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 推荐的修复方案
|
||||
|
||||
### 方案 A: 捕获 mcp-proxy 的 stdout/stderr ⭐ 最优先
|
||||
|
||||
**问题**: 看不到 mcp-proxy 的实际错误
|
||||
|
||||
**解决**: 使用前面文档中的 `start_service_with_logging` 函数
|
||||
|
||||
**nuwax-agent 代码修改**:
|
||||
```rust
|
||||
// nuwax-agent-core/src/service/mcp_proxy.rs
|
||||
|
||||
pub async fn start_mcp_proxy(config: &McpProxyConfig) -> Result<Child> {
|
||||
let cmd_path = resolve_npm_bin("mcp-proxy").await?;
|
||||
|
||||
// 准备启动参数
|
||||
let mut env_vars = vec![
|
||||
("MCP_PORT".to_string(), config.port.to_string()),
|
||||
];
|
||||
|
||||
// 如果有配置文件,添加环境变量
|
||||
if let Some(config_path) = &config.config_file {
|
||||
env_vars.push(("MCP_CONFIG".to_string(), config_path.clone()));
|
||||
}
|
||||
|
||||
// 使用带日志捕获的启动函数
|
||||
let child = crate::utils::process::start_service_with_logging(
|
||||
&cmd_path,
|
||||
"McpProxy",
|
||||
Some(env_vars),
|
||||
Some(vec!["server".to_string()]), // 子命令
|
||||
).await?;
|
||||
|
||||
tracing::info!("[McpProxy] 进程已启动,PID: {:?}", child.id());
|
||||
|
||||
// 健康检查(使用正确的端点)
|
||||
let health_url = format!("http://127.0.0.1:{}/health", config.port);
|
||||
wait_for_http_ready(&health_url, Duration::from_secs(30)).await
|
||||
.context("MCP Proxy 健康检查超时")?;
|
||||
|
||||
Ok(child)
|
||||
}
|
||||
```
|
||||
|
||||
**效果**: 现在可以在日志中看到 mcp-proxy 的所有输出!
|
||||
|
||||
### 方案 B: 修复健康检查端点
|
||||
|
||||
**问题**: 健康检查可能用错了端点
|
||||
|
||||
**验证**:
|
||||
1. 查看 mcp-proxy 的路由定义
|
||||
2. 确认健康检查端点是 `/health` 还是 `/mcp`
|
||||
|
||||
**建议**: 尝试多个端点
|
||||
|
||||
```rust
|
||||
async fn check_mcp_proxy_health(port: u16) -> Result<()> {
|
||||
let endpoints = vec![
|
||||
"/health",
|
||||
"/",
|
||||
"/mcp",
|
||||
"/api/health",
|
||||
];
|
||||
|
||||
for endpoint in &endpoints {
|
||||
let url = format!("http://127.0.0.1:{}{}", port, endpoint);
|
||||
tracing::debug!("Trying health check: {}", url);
|
||||
|
||||
match reqwest::get(&url).await {
|
||||
Ok(resp) if resp.status().is_success() => {
|
||||
tracing::info!("Health check succeeded: {}", url);
|
||||
return Ok(());
|
||||
}
|
||||
Ok(resp) => {
|
||||
tracing::warn!("Health check {} returned: {}", url, resp.status());
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::debug!("Health check {} failed: {}", url, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
anyhow::bail!("All health check endpoints failed")
|
||||
}
|
||||
```
|
||||
|
||||
### 方案 C: 处理空配置情况
|
||||
|
||||
**问题**: `mcpServers` 为空时,mcp-proxy 可能不应该启动
|
||||
|
||||
**建议**: 在 nuwax-agent 中明确处理
|
||||
|
||||
```rust
|
||||
pub async fn start_mcp_proxy(config: &McpProxyConfig) -> Result<Option<Child>> {
|
||||
// 检查配置
|
||||
if config.mcp_servers.is_empty() {
|
||||
tracing::info!("[McpProxy] mcpServers 配置为空,跳过启动");
|
||||
return Ok(None); // 返回 None 而不是启动失败
|
||||
}
|
||||
|
||||
// 启动服务
|
||||
let child = start_service_with_logging(...).await?;
|
||||
|
||||
Ok(Some(child))
|
||||
}
|
||||
```
|
||||
|
||||
### 方案 D: 增加超时时间和重试
|
||||
|
||||
**问题**: 15 秒超时可能不够
|
||||
|
||||
**建议**:
|
||||
```rust
|
||||
// 增加超时到 30 秒
|
||||
wait_for_http_ready(&health_url, Duration::from_secs(30)).await?;
|
||||
|
||||
// 或者添加重试逻辑
|
||||
for attempt in 1..=3 {
|
||||
match start_and_check_mcp_proxy(config).await {
|
||||
Ok(child) => return Ok(child),
|
||||
Err(e) if attempt < 3 => {
|
||||
tracing::warn!("MCP Proxy 启动失败 (尝试 {}/3): {}", attempt, e);
|
||||
tokio::time::sleep(Duration::from_secs(5)).await;
|
||||
}
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 需要 mcp-proxy 仓库确认的信息
|
||||
|
||||
为了完全解决这个问题,需要从 mcp-proxy 代码中确认:
|
||||
|
||||
### 1. 健康检查端点
|
||||
|
||||
**问题**: 正确的健康检查 URL 是什么?
|
||||
|
||||
**需要检查的文件**:
|
||||
- `mcp-proxy/src/server.rs` 或 `mcp-proxy/src/main.rs`
|
||||
- 查找 `Router` 或 `axum::Router` 配置
|
||||
- 查找 `health` 相关的路由
|
||||
|
||||
**期望的代码**:
|
||||
```rust
|
||||
// mcp-proxy/src/server.rs
|
||||
let app = Router::new()
|
||||
.route("/health", get(health_check)) // ← 这个!
|
||||
.route("/mcp", get(list_services))
|
||||
// ...
|
||||
```
|
||||
|
||||
### 2. 空配置行为
|
||||
|
||||
**问题**: 当没有配置 MCP 服务时,mcp-proxy 会怎么做?
|
||||
|
||||
**可能的行为**:
|
||||
1. 启动 HTTP 服务器,但没有 MCP 服务
|
||||
2. 直接退出(因为没有服务可代理)
|
||||
3. 报错
|
||||
|
||||
**需要确认**: mcp-proxy 的预期行为
|
||||
|
||||
### 3. 必需的环境变量
|
||||
|
||||
**问题**: mcp-proxy 是否需要特定的环境变量?
|
||||
|
||||
**需要检查**:
|
||||
- 配置文件路径(`MCP_CONFIG`?)
|
||||
- 日志级别(`RUST_LOG`?)
|
||||
- 其他配置
|
||||
|
||||
### 4. 启动子命令
|
||||
|
||||
**问题**: 正确的启动命令是什么?
|
||||
|
||||
**可能的形式**:
|
||||
```bash
|
||||
mcp-proxy # 默认启动
|
||||
mcp-proxy server # 明确的 server 子命令
|
||||
mcp-proxy server --port 18099
|
||||
mcp-proxy --config config.json server
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 立即可以做的
|
||||
|
||||
### 在 mcp-proxy 仓库
|
||||
|
||||
1. **添加详细的启动日志**
|
||||
```rust
|
||||
// mcp-proxy/src/main.rs
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
tracing_subscriber::fmt::init();
|
||||
|
||||
tracing::info!("mcp-proxy starting...");
|
||||
tracing::info!("Version: {}", env!("CARGO_PKG_VERSION"));
|
||||
|
||||
let config = load_config()?;
|
||||
tracing::info!("Configuration loaded: {:?}", config);
|
||||
|
||||
if config.mcp_servers.is_empty() {
|
||||
tracing::warn!("No MCP servers configured");
|
||||
// 决定是继续还是退出
|
||||
}
|
||||
|
||||
let addr = SocketAddr::from(([127, 0, 0, 1], config.port));
|
||||
tracing::info!("Starting HTTP server on {}", addr);
|
||||
|
||||
// 启动服务器...
|
||||
tracing::info!("HTTP server started successfully");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
2. **统一健康检查端点**
|
||||
```rust
|
||||
// 确保有一个明确的健康检查端点
|
||||
.route("/health", get(|| async { "OK" }))
|
||||
```
|
||||
|
||||
3. **添加版本信息端点**
|
||||
```rust
|
||||
.route("/version", get(|| async {
|
||||
Json(json!({
|
||||
"version": env!("CARGO_PKG_VERSION"),
|
||||
"name": env!("CARGO_PKG_NAME"),
|
||||
}))
|
||||
}))
|
||||
```
|
||||
|
||||
### 在 nuwax-agent 仓库
|
||||
|
||||
1. **立即实现日志捕获**(最优先)
|
||||
2. **增加健康检查超时时间** (15s → 30s)
|
||||
3. **尝试多个健康检查端点**
|
||||
4. **空配置时跳过启动,不报错**
|
||||
|
||||
---
|
||||
|
||||
## 总结
|
||||
|
||||
| 问题 | 严重性 | 解决方案 | 优先级 |
|
||||
|------|--------|----------|--------|
|
||||
| **无法看到错误日志** | 🔴 高 | 捕获 stdout/stderr | P0 |
|
||||
| **健康检查端点可能错误** | 🟡 中 | 尝试多个端点 | P1 |
|
||||
| **空配置时行为不明** | 🟡 中 | 明确处理逻辑 | P1 |
|
||||
| **超时时间太短** | 🟢 低 | 增加到 30s | P2 |
|
||||
|
||||
**下一步行动**:
|
||||
1. ✅ 已创建完整的修复文档
|
||||
2. ⏳ 需要在 Windows 上手动测试 mcp-proxy
|
||||
3. ⏳ 需要确认 mcp-proxy 的健康检查端点
|
||||
4. ⏳ 在 nuwax-agent 中实现日志捕获
|
||||
412
qiming-mcp-proxy/docs/analysis/NPM_DEPENDENCY_UPDATE_ANALYSIS.md
Normal file
412
qiming-mcp-proxy/docs/analysis/NPM_DEPENDENCY_UPDATE_ANALYSIS.md
Normal file
@@ -0,0 +1,412 @@
|
||||
# mcp-proxy npm 包依赖更新机制分析
|
||||
|
||||
## 问题
|
||||
**用户关注**: mcp-proxy 打包到 npm 时,内部依赖的平台二进制文件是否是最新的(下载的)?
|
||||
|
||||
## 快速回答
|
||||
❌ **当前机制**: cargo-dist 生成的 npm 包中的二进制文件**不是**从 npm registry 下载的最新版本,而是**构建时打包进去的**。
|
||||
|
||||
✅ **这实际上是正确的行为**: 这确保了版本一致性和可靠性。
|
||||
|
||||
---
|
||||
|
||||
## cargo-dist npm 安装器工作机制
|
||||
|
||||
### 1. 构建阶段(GitHub Actions)
|
||||
|
||||
```yaml
|
||||
# .github/workflows/release.yml
|
||||
targets = [
|
||||
"aarch64-apple-darwin",
|
||||
"aarch64-unknown-linux-gnu",
|
||||
"x86_64-apple-darwin",
|
||||
"x86_64-unknown-linux-gnu",
|
||||
"x86_64-pc-windows-msvc"
|
||||
]
|
||||
```
|
||||
|
||||
**流程**:
|
||||
```
|
||||
Tag 推送 (v0.1.39)
|
||||
↓
|
||||
GitHub Actions 触发
|
||||
↓
|
||||
为每个平台构建二进制文件
|
||||
├─ Linux x86_64: mcp-proxy (ELF)
|
||||
├─ Linux ARM64: mcp-proxy (ELF)
|
||||
├─ macOS x86_64: mcp-proxy (Mach-O)
|
||||
├─ macOS ARM64: mcp-proxy (Mach-O)
|
||||
└─ Windows: mcp-proxy.exe (PE)
|
||||
↓
|
||||
cargo-dist 打包成 tarball
|
||||
├─ mcp-stdio-proxy-aarch64-apple-darwin.tar.xz
|
||||
├─ mcp-stdio-proxy-x86_64-unknown-linux-gnu.tar.xz
|
||||
└─ mcp-stdio-proxy-x86_64-pc-windows-msvc.zip
|
||||
↓
|
||||
生成 npm 安装器包
|
||||
└─ mcp-stdio-proxy-0.1.39-npm-package.tar.gz
|
||||
```
|
||||
|
||||
### 2. npm 包结构
|
||||
|
||||
cargo-dist 生成的 npm 包包含:
|
||||
|
||||
```
|
||||
mcp-stdio-proxy-0.1.39-npm-package.tar.gz
|
||||
└── package/
|
||||
├── package.json
|
||||
│ ├── "version": "0.1.39"
|
||||
│ ├── "name": "mcp-stdio-proxy"
|
||||
│ ├── "bin": { "mcp-proxy": "./install.js" }
|
||||
│ └── "artifactDownloadUrl": "https://github.com/.../v0.1.39"
|
||||
├── install.js # 安装脚本
|
||||
└── (可能的其他元数据)
|
||||
```
|
||||
|
||||
**package.json 关键字段**:
|
||||
```json
|
||||
{
|
||||
"name": "mcp-stdio-proxy",
|
||||
"version": "0.1.39",
|
||||
"bin": {
|
||||
"mcp-proxy": "./install.js"
|
||||
},
|
||||
"artifactDownloadUrl": "https://github.com/nuwax-ai/mcp-proxy/releases/download/v0.1.39"
|
||||
}
|
||||
```
|
||||
|
||||
### 3. 用户安装流程
|
||||
|
||||
```bash
|
||||
npm install -g mcp-stdio-proxy@0.1.39
|
||||
```
|
||||
|
||||
**实际发生的事情**:
|
||||
|
||||
1. **下载 npm 包**:
|
||||
```
|
||||
npm registry → mcp-stdio-proxy-0.1.39-npm-package.tar.gz
|
||||
```
|
||||
|
||||
2. **运行 install.js**:
|
||||
```javascript
|
||||
// install.js (由 cargo-dist 生成)
|
||||
const version = "0.1.39";
|
||||
const platform = detectPlatform(); // e.g., "x86_64-pc-windows-msvc"
|
||||
const artifactUrl = `${baseUrl}/mcp-stdio-proxy-${platform}.tar.xz`;
|
||||
|
||||
// 下载平台特定的二进制包
|
||||
download(artifactUrl);
|
||||
extract(artifact);
|
||||
symlinkBinary("mcp-proxy");
|
||||
```
|
||||
|
||||
3. **下载的二进制文件来源**:
|
||||
```
|
||||
https://github.com/nuwax-ai/mcp-proxy/releases/download/v0.1.39/mcp-stdio-proxy-x86_64-pc-windows-msvc.zip
|
||||
```
|
||||
或者(如果 OSS 同步成功):
|
||||
```
|
||||
https://nuwa-packages.oss-rg-china-mainland.aliyuncs.com/mcp-stdio-proxy/v0.1.39/mcp-stdio-proxy-x86_64-pc-windows-msvc.zip
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 关键点:版本锁定机制
|
||||
|
||||
### ✅ 版本是锁定的(这是正确的)
|
||||
|
||||
| 组件 | 版本来源 | 更新时机 |
|
||||
|------|---------|---------|
|
||||
| **npm 包版本** | Cargo.toml `version = "0.1.39"` | 手动更新 + Git Tag |
|
||||
| **二进制文件** | 该版本构建时编译的二进制 | 构建时生成 |
|
||||
| **依赖的 crate** | Cargo.lock 锁定 | 更新 Cargo.lock |
|
||||
| **下载 URL** | package.json `artifactDownloadUrl` | 自动生成(包含版本号) |
|
||||
|
||||
**示例**:
|
||||
```
|
||||
用户安装: npm install -g mcp-stdio-proxy@0.1.39
|
||||
↓
|
||||
下载 npm 包(包含版本信息)
|
||||
↓
|
||||
install.js 读取 artifactDownloadUrl
|
||||
↓
|
||||
下载: https://.../v0.1.39/mcp-stdio-proxy-x86_64-pc-windows-msvc.zip
|
||||
↓
|
||||
解压得到的二进制文件就是 v0.1.39 构建时的版本
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 依赖更新策略
|
||||
|
||||
### 场景 A: Rust 依赖更新(如 rmcp, tokio 等)
|
||||
|
||||
**问题**: 如果 `rmcp` 发布了新版本,用户安装 `mcp-stdio-proxy@0.1.39` 会用到新版本吗?
|
||||
|
||||
**答案**: ❌ 不会,因为二进制文件已经编译好了
|
||||
|
||||
**更新方法**:
|
||||
```bash
|
||||
# 1. 在项目中更新依赖
|
||||
cd mcp-proxy
|
||||
cargo update -p rmcp
|
||||
|
||||
# 2. 测试
|
||||
cargo test
|
||||
|
||||
# 3. 更新版本号
|
||||
# 编辑 mcp-proxy/Cargo.toml
|
||||
version = "0.1.40"
|
||||
|
||||
# 4. 提交并打标签
|
||||
git commit -am "chore: bump version to 0.1.40 with updated rmcp"
|
||||
git tag v0.1.40
|
||||
git push origin v0.1.40
|
||||
|
||||
# 5. GitHub Actions 自动构建并发布
|
||||
# 新的 npm 包 mcp-stdio-proxy@0.1.40 将包含更新后的 rmcp
|
||||
```
|
||||
|
||||
### 场景 B: 系统依赖(如 OpenSSL、glibc)
|
||||
|
||||
**问题**: 构建的二进制依赖的系统库版本是什么?
|
||||
|
||||
**答案**: 取决于 GitHub Actions runner 的环境
|
||||
|
||||
**当前配置** (`.github/workflows/release.yml`):
|
||||
```yaml
|
||||
matrix:
|
||||
runner: "ubuntu-22.04" # Ubuntu 22.04 的 glibc 版本
|
||||
```
|
||||
|
||||
**潜在问题**:
|
||||
- 如果在 Ubuntu 22.04 上构建,二进制可能依赖较新的 glibc
|
||||
- 在旧系统(如 CentOS 7)上可能无法运行
|
||||
|
||||
**解决方案**:
|
||||
```yaml
|
||||
# 使用容器构建以控制依赖版本
|
||||
matrix:
|
||||
container:
|
||||
image: "quay.io/pypa/manylinux2014_x86_64" # 兼容性更好
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 当前流程的优缺点
|
||||
|
||||
### ✅ 优点
|
||||
|
||||
1. **版本一致性**:
|
||||
- `mcp-stdio-proxy@0.1.39` 永远下载的是 v0.1.39 构建时的二进制
|
||||
- 不会因为依赖更新导致意外行为
|
||||
|
||||
2. **确定性构建**:
|
||||
- Cargo.lock 锁定所有依赖版本
|
||||
- 可复现的构建结果
|
||||
|
||||
3. **无运行时编译**:
|
||||
- 用户安装时不需要 Rust 工具链
|
||||
- 安装速度快(直接下载预编译二进制)
|
||||
|
||||
4. **平台特定优化**:
|
||||
- 为每个平台单独编译优化
|
||||
- 无跨平台兼容性损失
|
||||
|
||||
### ❌ 潜在问题
|
||||
|
||||
1. **依赖无法自动更新**:
|
||||
- 用户不能通过 `npm update` 获取新的 Rust 依赖
|
||||
- 必须发布新版本
|
||||
|
||||
2. **二进制体积大**:
|
||||
- 每个平台的二进制都需要打包
|
||||
- npm 包本身较小(只是安装器),但下载的二进制较大
|
||||
|
||||
3. **系统兼容性**:
|
||||
- 需要确保目标系统有合适的运行时库
|
||||
- 可能在旧系统上无法运行
|
||||
|
||||
---
|
||||
|
||||
## 对比其他方案
|
||||
|
||||
### 方案 A: 当前方案(cargo-dist)
|
||||
|
||||
```
|
||||
npm install -g mcp-stdio-proxy
|
||||
↓
|
||||
下载安装器 npm 包(~1KB)
|
||||
↓
|
||||
install.js 下载平台二进制(~10MB)
|
||||
↓
|
||||
解压到 ~/.npm/bin/
|
||||
```
|
||||
|
||||
**特点**: 预编译二进制,版本锁定
|
||||
|
||||
---
|
||||
|
||||
### 方案 B: 纯 npm 包(不适用)
|
||||
|
||||
```
|
||||
npm install -g mcp-stdio-proxy
|
||||
↓
|
||||
下载 JavaScript 代码
|
||||
↓
|
||||
运行时执行
|
||||
```
|
||||
|
||||
**特点**: 不适用,因为 mcp-proxy 是 Rust 项目
|
||||
|
||||
---
|
||||
|
||||
### 方案 C: npm 包 + 源码编译(不推荐)
|
||||
|
||||
```
|
||||
npm install -g mcp-stdio-proxy
|
||||
↓
|
||||
下载源码 + package.json
|
||||
↓
|
||||
postinstall: cargo build --release
|
||||
↓
|
||||
编译二进制
|
||||
```
|
||||
|
||||
**特点**:
|
||||
- ❌ 需要用户有 Rust 工具链
|
||||
- ❌ 安装时间长(编译需要几分钟)
|
||||
- ✅ 可以获取最新依赖
|
||||
- ✅ 针对用户系统优化
|
||||
|
||||
---
|
||||
|
||||
## 建议
|
||||
|
||||
### ✅ 保持当前方案
|
||||
|
||||
**理由**:
|
||||
1. cargo-dist 是 Rust 社区标准方案
|
||||
2. 版本锁定是**优点**而非缺点(确保稳定性)
|
||||
3. 用户体验好(无需 Rust 工具链,安装快)
|
||||
|
||||
### 🔧 优化建议
|
||||
|
||||
#### 1. 明确依赖更新流程
|
||||
|
||||
创建文档说明如何更新依赖并发布新版本:
|
||||
|
||||
```markdown
|
||||
# 依赖更新指南
|
||||
|
||||
## 更新 Rust 依赖
|
||||
1. `cargo update -p <crate_name>`
|
||||
2. `cargo test` 确保无破坏性更改
|
||||
3. 更新 `Cargo.toml` 版本号
|
||||
4. `git tag v<new_version>` 触发发布
|
||||
|
||||
## 发布检查清单
|
||||
- [ ] 所有测试通过
|
||||
- [ ] CHANGELOG.md 已更新
|
||||
- [ ] 版本号已同步(Cargo.toml)
|
||||
- [ ] Git tag 格式正确(v0.1.40)
|
||||
```
|
||||
|
||||
#### 2. 添加版本信息到二进制
|
||||
|
||||
```rust
|
||||
// mcp-proxy/src/main.rs
|
||||
#[derive(Parser)]
|
||||
#[command(version = env!("CARGO_PKG_VERSION"))]
|
||||
struct Cli {
|
||||
// ...
|
||||
}
|
||||
|
||||
// 运行时显示依赖版本
|
||||
fn print_version_info() {
|
||||
println!("mcp-proxy {}", env!("CARGO_PKG_VERSION"));
|
||||
println!(" rmcp: {}", env!("CARGO_PKG_VERSION_rmcp"));
|
||||
println!(" tokio: {}", env!("CARGO_PKG_VERSION_tokio"));
|
||||
}
|
||||
```
|
||||
|
||||
#### 3. 自动化依赖检查
|
||||
|
||||
```yaml
|
||||
# .github/workflows/dependency-check.yml
|
||||
name: Dependency Check
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 0 * * 1' # 每周一检查
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
check-outdated:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- run: cargo outdated --exit-code 1
|
||||
```
|
||||
|
||||
#### 4. 添加系统兼容性说明
|
||||
|
||||
在 README.md 中明确说明:
|
||||
|
||||
```markdown
|
||||
## 系统要求
|
||||
|
||||
### Linux
|
||||
- glibc >= 2.31 (Ubuntu 20.04+, Debian 11+, RHEL 8+)
|
||||
- 或使用 musl 构建版本(待支持)
|
||||
|
||||
### macOS
|
||||
- macOS 10.15+ (Catalina or later)
|
||||
|
||||
### Windows
|
||||
- Windows 10 / Windows Server 2019 或更高版本
|
||||
- 需要 Visual C++ Redistributable 2015-2022
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 总结
|
||||
|
||||
### 问题回答
|
||||
|
||||
**Q: mcp-proxy 打包的内部平台依赖是否是最新的(下载的)?**
|
||||
|
||||
**A**:
|
||||
- ❌ **不是"下载的最新"**: 二进制文件在构建时编译,版本锁定
|
||||
- ✅ **这是正确的行为**: 确保版本一致性和可靠性
|
||||
- 🔄 **更新方式**: 通过发布新版本(如 v0.1.40)获取更新的依赖
|
||||
|
||||
### 版本管理流程
|
||||
|
||||
```
|
||||
Rust 依赖更新
|
||||
↓
|
||||
cargo update
|
||||
↓
|
||||
测试验证
|
||||
↓
|
||||
版本号递增 (0.1.39 → 0.1.40)
|
||||
↓
|
||||
Git Tag (v0.1.40)
|
||||
↓
|
||||
GitHub Actions 构建
|
||||
↓
|
||||
发布到 npm (mcp-stdio-proxy@0.1.40)
|
||||
↓
|
||||
用户 npm install -g mcp-stdio-proxy@latest
|
||||
↓
|
||||
获取包含最新依赖的二进制
|
||||
```
|
||||
|
||||
### 最佳实践
|
||||
|
||||
1. ✅ **保持当前 cargo-dist 方案**
|
||||
2. ✅ **定期检查和更新依赖**
|
||||
3. ✅ **清晰的版本发布流程**
|
||||
4. ✅ **文档化系统要求**
|
||||
5. ✅ **自动化依赖检查(GitHub Actions)**
|
||||
661
qiming-mcp-proxy/docs/analysis/NUWAX_AGENT_WINDOWS_CMD_FIX.md
Normal file
661
qiming-mcp-proxy/docs/analysis/NUWAX_AGENT_WINDOWS_CMD_FIX.md
Normal file
@@ -0,0 +1,661 @@
|
||||
# nuwax-agent Windows CMD 窗口隐藏完整解决方案
|
||||
|
||||
## 问题总结
|
||||
|
||||
### 现象
|
||||
在 Windows 上运行 nuwax-agent (Tauri 应用) 时,会弹出多个 CMD 命令行窗口,影响用户体验。
|
||||
|
||||
### 受影响的服务
|
||||
所有通过 npm 全局安装的 Node.js 服务(以 `.cmd` 文件形式存在):
|
||||
|
||||
1. **mcp-proxy.cmd** - MCP 代理服务
|
||||
```
|
||||
C:\Users\MECHREVO\.local\bin\mcp-proxy.cmd
|
||||
```
|
||||
|
||||
2. **nuwax-file-server.cmd** - 文件服务器
|
||||
```
|
||||
C:\Users\MECHREVO\.local\bin\nuwax-file-server.cmd
|
||||
```
|
||||
|
||||
3. **其他潜在的 npm 全局包**
|
||||
- `nuwaxcode`
|
||||
- `claude-code-acp-ts`
|
||||
- 任何未来通过 `npm install -g` 安装的服务
|
||||
|
||||
### 根本原因
|
||||
|
||||
#### 技术细节
|
||||
Windows 上的 npm 全局包会生成批处理包装文件:
|
||||
```
|
||||
~/.local/bin/
|
||||
├── package-name.cmd # Windows 批处理文件
|
||||
├── package-name # Unix shell 脚本(Windows 不使用)
|
||||
└── package-name.ps1 # PowerShell 脚本(可选)
|
||||
```
|
||||
|
||||
当 Rust 代码使用 `std::process::Command` 或 `tokio::process::Command` 启动这些 `.cmd` 文件时:
|
||||
|
||||
**默认行为**:
|
||||
```rust
|
||||
Command::new("C:\\...\\mcp-proxy.cmd").spawn()?;
|
||||
// ❌ 会弹出 CMD 窗口
|
||||
```
|
||||
|
||||
**需要显式设置**:
|
||||
```rust
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::process::CommandExt;
|
||||
const CREATE_NO_WINDOW: u32 = 0x08000000;
|
||||
cmd.creation_flags(CREATE_NO_WINDOW);
|
||||
}
|
||||
// ✅ 隐藏 CMD 窗口
|
||||
```
|
||||
|
||||
#### 层级关系说明
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
│ nuwax-agent.exe (Tauri GUI 应用) │
|
||||
└─────────────────┬───────────────────┘
|
||||
│
|
||||
┌───────────┴───────────┐
|
||||
│ │
|
||||
▼ ▼
|
||||
┌─────────────────┐ ┌─────────────────┐
|
||||
│ mcp-proxy.cmd │ │ file-server.cmd │ ❌ 问题1: nuwax-agent 启动时未隐藏
|
||||
└─────┬───────────┘ └─────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────┐
|
||||
│ MCP 服务子进程 │ ✅ 已修复 (mcp-proxy v0.1.39)
|
||||
└─────────────────┘
|
||||
```
|
||||
|
||||
**结论**:
|
||||
- **mcp-proxy v0.1.39** 修复了:mcp-proxy 启动 MCP 子进程时的 CMD 窗口
|
||||
- **nuwax-agent 需要修复**:启动 npm 全局包时的 CMD 窗口
|
||||
|
||||
---
|
||||
|
||||
## 解决方案
|
||||
|
||||
### 方案 1: 统一的进程启动工具函数 ✅ 推荐
|
||||
|
||||
#### 1.1 创建通用工具模块
|
||||
|
||||
**文件**: `nuwax-agent-core/src/utils/process.rs` (新建)
|
||||
|
||||
```rust
|
||||
//! 进程启动工具函数
|
||||
//!
|
||||
//! 提供跨平台的进程启动功能,Windows 上自动隐藏 CMD 窗口
|
||||
|
||||
use std::process::Command as StdCommand;
|
||||
use tokio::process::Command as TokioCommand;
|
||||
|
||||
/// 创建隐藏 CMD 窗口的同步 Command(Windows)
|
||||
///
|
||||
/// # 用法
|
||||
/// ```rust
|
||||
/// let mut cmd = create_hidden_command("C:\\path\\to\\script.cmd");
|
||||
/// cmd.env("PORT", "8080");
|
||||
/// cmd.args(&["--production"]);
|
||||
/// cmd.spawn()?;
|
||||
/// ```
|
||||
#[cfg(windows)]
|
||||
pub fn create_hidden_command(program: impl AsRef<std::ffi::OsStr>) -> StdCommand {
|
||||
use std::os::windows::process::CommandExt;
|
||||
const CREATE_NO_WINDOW: u32 = 0x08000000;
|
||||
|
||||
let mut cmd = StdCommand::new(program);
|
||||
|
||||
// 注意:必须在所有 env/args 调用之后才能确保标志生效
|
||||
// 这里先设置,但调用者不应该再次修改
|
||||
cmd.creation_flags(CREATE_NO_WINDOW);
|
||||
|
||||
cmd
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
pub fn create_hidden_command(program: impl AsRef<std::ffi::OsStr>) -> StdCommand {
|
||||
StdCommand::new(program)
|
||||
}
|
||||
|
||||
/// 创建隐藏 CMD 窗口的异步 Command(Windows)
|
||||
#[cfg(windows)]
|
||||
pub fn create_hidden_tokio_command(program: impl AsRef<std::ffi::OsStr>) -> TokioCommand {
|
||||
use std::os::windows::process::CommandExt;
|
||||
const CREATE_NO_WINDOW: u32 = 0x08000000;
|
||||
|
||||
let mut cmd = TokioCommand::new(program);
|
||||
cmd.creation_flags(CREATE_NO_WINDOW);
|
||||
|
||||
cmd
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
pub fn create_hidden_tokio_command(program: impl AsRef<std::ffi::OsStr>) -> TokioCommand {
|
||||
TokioCommand::new(program)
|
||||
}
|
||||
```
|
||||
|
||||
#### 1.2 使用方式
|
||||
|
||||
**在服务启动代码中**:
|
||||
|
||||
```rust
|
||||
use crate::utils::process::create_hidden_tokio_command;
|
||||
|
||||
// FileServer 启动
|
||||
pub async fn start_file_server(config: &FileServerConfig) -> Result<Child> {
|
||||
let cmd_path = "C:\\Users\\...\\nuwax-file-server.cmd";
|
||||
|
||||
let mut cmd = create_hidden_tokio_command(cmd_path);
|
||||
cmd.env("PORT", &config.port.to_string());
|
||||
cmd.env("NODE_ENV", "production");
|
||||
cmd.args(&["--production"]);
|
||||
|
||||
// 启动进程(CMD 窗口已自动隐藏)
|
||||
let child = cmd.spawn()?;
|
||||
|
||||
Ok(child)
|
||||
}
|
||||
|
||||
// McpProxy 启动
|
||||
pub async fn start_mcp_proxy(config: &McpProxyConfig) -> Result<Child> {
|
||||
let cmd_path = "C:\\Users\\...\\mcp-proxy.cmd";
|
||||
|
||||
let mut cmd = create_hidden_tokio_command(cmd_path);
|
||||
cmd.env("MCP_PORT", &config.port.to_string());
|
||||
|
||||
let child = cmd.spawn()?;
|
||||
|
||||
Ok(child)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 方案 2: 增强版 - 带日志捕获 ✅ 强烈推荐
|
||||
|
||||
由于 CMD 窗口隐藏后无法看到错误信息,需要捕获子进程的 stdout/stderr:
|
||||
|
||||
**文件**: `nuwax-agent-core/src/utils/process.rs`
|
||||
|
||||
```rust
|
||||
use tokio::io::{AsyncBufReadExt, BufReader};
|
||||
use tokio::process::{Child, Command as TokioCommand};
|
||||
use std::process::Stdio;
|
||||
use anyhow::Result;
|
||||
|
||||
/// 启动服务并捕获输出日志
|
||||
///
|
||||
/// # 特性
|
||||
/// - Windows: 自动隐藏 CMD 窗口
|
||||
/// - 捕获 stdout/stderr 并记录到日志
|
||||
/// - 返回子进程句柄
|
||||
///
|
||||
/// # 参数
|
||||
/// - `cmd_path`: 可执行文件路径(如 `C:\...\mcp-proxy.cmd`)
|
||||
/// - `service_name`: 服务名称(用于日志标识)
|
||||
/// - `env_vars`: 环境变量 `Vec<(String, String)>`
|
||||
/// - `args`: 命令行参数 `Vec<String>`
|
||||
pub async fn start_service_with_logging(
|
||||
cmd_path: impl AsRef<std::ffi::OsStr>,
|
||||
service_name: &str,
|
||||
env_vars: Option<Vec<(String, String)>>,
|
||||
args: Option<Vec<String>>,
|
||||
) -> Result<Child> {
|
||||
#[cfg(windows)]
|
||||
use std::os::windows::process::CommandExt;
|
||||
|
||||
let mut cmd = TokioCommand::new(&cmd_path);
|
||||
|
||||
// 设置环境变量
|
||||
if let Some(envs) = env_vars {
|
||||
for (key, value) in envs {
|
||||
cmd.env(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
// 设置参数
|
||||
if let Some(args) = args {
|
||||
cmd.args(&args);
|
||||
}
|
||||
|
||||
// 捕获 stdout/stderr
|
||||
cmd.stdout(Stdio::piped());
|
||||
cmd.stderr(Stdio::piped());
|
||||
|
||||
// Windows: 隐藏 CMD 窗口(必须在最后设置)
|
||||
#[cfg(windows)]
|
||||
{
|
||||
const CREATE_NO_WINDOW: u32 = 0x08000000;
|
||||
cmd.creation_flags(CREATE_NO_WINDOW);
|
||||
}
|
||||
|
||||
let mut child = cmd.spawn()
|
||||
.map_err(|e| anyhow::anyhow!("Failed to spawn {}: {}", service_name, e))?;
|
||||
|
||||
// 异步读取并记录 stdout
|
||||
if let Some(stdout) = child.stdout.take() {
|
||||
let service_name = service_name.to_string();
|
||||
tokio::spawn(async move {
|
||||
let reader = BufReader::new(stdout);
|
||||
let mut lines = reader.lines();
|
||||
while let Ok(Some(line)) = lines.next_line().await {
|
||||
tracing::info!("[{} stdout] {}", service_name, line);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 异步读取并记录 stderr
|
||||
if let Some(stderr) = child.stderr.take() {
|
||||
let service_name = service_name.to_string();
|
||||
tokio::spawn(async move {
|
||||
let reader = BufReader::new(stderr);
|
||||
let mut lines = reader.lines();
|
||||
while let Ok(Some(line)) = lines.next_line().await {
|
||||
tracing::error!("[{} stderr] {}", service_name, line);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
tracing::info!("[{}] 进程已启动", service_name);
|
||||
|
||||
Ok(child)
|
||||
}
|
||||
```
|
||||
|
||||
#### 使用示例
|
||||
|
||||
```rust
|
||||
use crate::utils::process::start_service_with_logging;
|
||||
|
||||
// FileServer 启动
|
||||
pub async fn start_file_server(config: &FileServerConfig) -> Result<Child> {
|
||||
let cmd_path = resolve_npm_bin("nuwax-file-server").await?;
|
||||
|
||||
let env_vars = vec![
|
||||
("PORT".to_string(), config.port.to_string()),
|
||||
("NODE_ENV".to_string(), "production".to_string()),
|
||||
("LOG_LEVEL".to_string(), "info".to_string()),
|
||||
];
|
||||
|
||||
let args = vec!["--production".to_string()];
|
||||
|
||||
let child = start_service_with_logging(
|
||||
&cmd_path,
|
||||
"FileServer",
|
||||
Some(env_vars),
|
||||
Some(args),
|
||||
).await?;
|
||||
|
||||
// 健康检查
|
||||
wait_for_port_ready(config.port, Duration::from_secs(10)).await?;
|
||||
|
||||
Ok(child)
|
||||
}
|
||||
|
||||
// McpProxy 启动
|
||||
pub async fn start_mcp_proxy(config: &McpProxyConfig) -> Result<Child> {
|
||||
let cmd_path = resolve_npm_bin("mcp-proxy").await?;
|
||||
|
||||
let env_vars = vec![
|
||||
("MCP_PORT".to_string(), config.port.to_string()),
|
||||
];
|
||||
|
||||
let child = start_service_with_logging(
|
||||
&cmd_path,
|
||||
"McpProxy",
|
||||
Some(env_vars),
|
||||
None,
|
||||
).await?;
|
||||
|
||||
// 健康检查
|
||||
let health_url = format!("http://127.0.0.1:{}/mcp", config.port);
|
||||
wait_for_http_ready(&health_url, Duration::from_secs(15)).await?;
|
||||
|
||||
Ok(child)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 方案 3: Node.js 检测逻辑改进 ✅ 必须
|
||||
|
||||
解决日志中出现的 Node.js 重复安装问题。
|
||||
|
||||
**文件**: `nuwax-agent-core/src/dependency/node.rs`
|
||||
|
||||
```rust
|
||||
use std::process::Stdio;
|
||||
use tokio::time::Duration;
|
||||
use anyhow::{Result, Context};
|
||||
|
||||
/// 检查 Node.js 是否真正可用
|
||||
pub async fn is_nodejs_available() -> bool {
|
||||
#[cfg(windows)]
|
||||
let node_cmd = "node.exe";
|
||||
#[cfg(not(windows))]
|
||||
let node_cmd = "node";
|
||||
|
||||
match tokio::process::Command::new(node_cmd)
|
||||
.arg("--version")
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.status()
|
||||
.await
|
||||
{
|
||||
Ok(status) if status.success() => {
|
||||
tracing::info!("Node.js is available in PATH");
|
||||
true
|
||||
}
|
||||
Ok(status) => {
|
||||
tracing::warn!("Node.js command exists but failed: {:?}", status);
|
||||
false
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::debug!("Node.js not found in PATH: {}", e);
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取 Node.js 版本
|
||||
pub async fn get_nodejs_version() -> Result<String> {
|
||||
#[cfg(windows)]
|
||||
let node_cmd = "node.exe";
|
||||
#[cfg(not(windows))]
|
||||
let node_cmd = "node";
|
||||
|
||||
let output = tokio::process::Command::new(node_cmd)
|
||||
.arg("--version")
|
||||
.output()
|
||||
.await
|
||||
.context("Failed to get Node.js version")?;
|
||||
|
||||
if !output.status.success() {
|
||||
anyhow::bail!("Node.js version check failed");
|
||||
}
|
||||
|
||||
let version = String::from_utf8(output.stdout)
|
||||
.context("Invalid UTF-8 in Node.js version")?
|
||||
.trim()
|
||||
.to_string();
|
||||
|
||||
Ok(version)
|
||||
}
|
||||
|
||||
/// 检查 npm 包是否已全局安装
|
||||
pub async fn is_npm_package_installed(package_name: &str) -> Result<bool> {
|
||||
if !is_nodejs_available().await {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
let npm_cmd = "npm.cmd";
|
||||
#[cfg(not(windows))]
|
||||
let npm_cmd = "npm";
|
||||
|
||||
let output = tokio::process::Command::new(npm_cmd)
|
||||
.args(&["list", "-g", package_name, "--depth=0"])
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.status()
|
||||
.await?;
|
||||
|
||||
Ok(output.success())
|
||||
}
|
||||
|
||||
/// 确保 npm 包已安装(带完整的前置检查)
|
||||
pub async fn ensure_npm_package(package_name: &str) -> Result<()> {
|
||||
tracing::info!("Ensuring npm package: {}", package_name);
|
||||
|
||||
// 1. 检查 Node.js 是否可用
|
||||
if !is_nodejs_available().await {
|
||||
tracing::warn!("Node.js not available, attempting to install...");
|
||||
|
||||
// 尝试安装 Node.js
|
||||
install_nodejs().await
|
||||
.context("Failed to install Node.js")?;
|
||||
|
||||
// 再次验证
|
||||
if !is_nodejs_available().await {
|
||||
anyhow::bail!("Node.js installation succeeded but still not available");
|
||||
}
|
||||
|
||||
// 显示安装的版本
|
||||
if let Ok(version) = get_nodejs_version().await {
|
||||
tracing::info!("Node.js {} is now available", version);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 检查包是否已安装
|
||||
if is_npm_package_installed(package_name).await? {
|
||||
tracing::info!("{} is already installed (global)", package_name);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// 3. 安装包(带重试限制)
|
||||
let max_retries = 3;
|
||||
for attempt in 1..=max_retries {
|
||||
tracing::info!(
|
||||
"Installing {} (attempt {}/{})",
|
||||
package_name, attempt, max_retries
|
||||
);
|
||||
|
||||
match install_npm_package_global(package_name).await {
|
||||
Ok(_) => {
|
||||
tracing::info!("{} installed successfully", package_name);
|
||||
|
||||
// 验证安装
|
||||
tokio::time::sleep(Duration::from_secs(1)).await;
|
||||
if is_npm_package_installed(package_name).await? {
|
||||
return Ok(());
|
||||
} else {
|
||||
tracing::warn!("{} installed but verification failed", package_name);
|
||||
}
|
||||
}
|
||||
Err(e) if attempt < max_retries => {
|
||||
tracing::warn!(
|
||||
"Install attempt {}/{} failed: {}",
|
||||
attempt, max_retries, e
|
||||
);
|
||||
tokio::time::sleep(Duration::from_secs(2)).await;
|
||||
}
|
||||
Err(e) => {
|
||||
anyhow::bail!(
|
||||
"Failed to install {} after {} attempts: {}",
|
||||
package_name, max_retries, e
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
anyhow::bail!("Failed to verify {} installation", package_name)
|
||||
}
|
||||
|
||||
/// 安装 npm 全局包
|
||||
async fn install_npm_package_global(package_name: &str) -> Result<()> {
|
||||
#[cfg(windows)]
|
||||
let npm_cmd = "npm.cmd";
|
||||
#[cfg(not(windows))]
|
||||
let npm_cmd = "npm";
|
||||
|
||||
let output = tokio::process::Command::new(npm_cmd)
|
||||
.args(&["install", "-g", package_name])
|
||||
.output()
|
||||
.await
|
||||
.context("Failed to run npm install")?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
anyhow::bail!("npm install failed: {}", stderr);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 实施步骤
|
||||
|
||||
### 阶段 1: 核心工具函数 (1 小时)
|
||||
|
||||
1. **创建 `utils/process.rs` 模块**
|
||||
```bash
|
||||
# 在 nuwax-agent-core 中
|
||||
mkdir -p src/utils
|
||||
touch src/utils/process.rs
|
||||
touch src/utils/mod.rs
|
||||
```
|
||||
|
||||
2. **实现 `start_service_with_logging` 函数**
|
||||
- 复制上面的代码
|
||||
- 添加到 `src/utils/process.rs`
|
||||
|
||||
3. **在 `lib.rs` 中导出**
|
||||
```rust
|
||||
// src/lib.rs
|
||||
pub mod utils;
|
||||
```
|
||||
|
||||
### 阶段 2: 修改服务启动代码 (2 小时)
|
||||
|
||||
4. **FileServer 服务**
|
||||
- 文件: `src/service/file_server.rs`
|
||||
- 找到: `Command::new(...)` 或 `TokioCommand::new(...)`
|
||||
- 替换为: `start_service_with_logging(...)`
|
||||
|
||||
5. **McpProxy 服务**
|
||||
- 文件: `src/service/mcp_proxy.rs`
|
||||
- 同样的修改
|
||||
|
||||
6. **其他 `.cmd` 服务**
|
||||
- 搜索所有 `Command::new` 并检查是否是 `.cmd` 文件
|
||||
- 统一使用工具函数
|
||||
|
||||
### 阶段 3: Node.js 检测改进 (1 小时)
|
||||
|
||||
7. **改进 Node.js 检测**
|
||||
- 文件: `src/dependency/node.rs`
|
||||
- 添加 `is_nodejs_available()` 函数
|
||||
- 修改 `ensure_npm_package()` 添加前置检查
|
||||
|
||||
### 阶段 4: 测试验证 (1 小时)
|
||||
|
||||
8. **Windows 测试**
|
||||
- 编译 nuwax-agent
|
||||
- 启动应用
|
||||
- 验证:
|
||||
- ✅ 无 CMD 窗口弹出
|
||||
- ✅ 日志中能看到服务的 stdout/stderr
|
||||
- ✅ mcp-proxy 健康检查成功
|
||||
- ✅ Node.js 不会重复安装
|
||||
|
||||
9. **macOS/Linux 测试**
|
||||
- 确保修改不影响其他平台
|
||||
|
||||
---
|
||||
|
||||
## 预期效果
|
||||
|
||||
### 修复前
|
||||
```
|
||||
用户启动 nuwax-agent
|
||||
↓
|
||||
[弹窗1] CMD 窗口 - nuwax-file-server.cmd
|
||||
[弹窗2] CMD 窗口 - mcp-proxy.cmd
|
||||
[弹窗3] CMD 窗口 - 其他服务...
|
||||
↓
|
||||
用户体验差 😢
|
||||
```
|
||||
|
||||
### 修复后
|
||||
```
|
||||
用户启动 nuwax-agent
|
||||
↓
|
||||
所有服务静默启动(无 CMD 窗口)
|
||||
↓
|
||||
日志文件中可以看到所有服务输出
|
||||
↓
|
||||
用户体验好 ✅
|
||||
```
|
||||
|
||||
### 日志示例
|
||||
|
||||
**修复后的日志**:
|
||||
```
|
||||
2026-02-12T06:27:06 INFO [FileServer] 进程已启动
|
||||
2026-02-12T06:27:07 INFO [FileServer stdout] 服务运行在: http://localhost:60000
|
||||
2026-02-12T06:27:08 INFO [FileServer stdout] 环境: production
|
||||
2026-02-12T06:27:09 INFO [McpProxy] 进程已启动
|
||||
2026-02-12T06:27:10 INFO [McpProxy stdout] MCP Proxy listening on 127.0.0.1:18099
|
||||
2026-02-12T06:27:11 INFO [McpProxy stdout] Health check: OK
|
||||
```
|
||||
|
||||
**如果服务启动失败**:
|
||||
```
|
||||
2026-02-12T06:27:10 INFO [McpProxy] 进程已启动
|
||||
2026-02-12T06:27:10 ERROR [McpProxy stderr] Error: Cannot find module 'express'
|
||||
2026-02-12T06:27:10 ERROR [McpProxy stderr] at Function.Module._resolveFilename (node:internal/modules/cjs/loader:1048:15)
|
||||
2026-02-12T06:27:11 ERROR [McpProxy] 健康检查失败: Connection refused
|
||||
```
|
||||
→ 现在可以清楚地看到错误原因!
|
||||
|
||||
---
|
||||
|
||||
## 参考资料
|
||||
|
||||
### Windows Process Creation Flags
|
||||
|
||||
- `CREATE_NO_WINDOW (0x08000000)`: 隐藏控制台窗口
|
||||
- 文档: https://learn.microsoft.com/en-us/windows/win32/procthread/process-creation-flags
|
||||
|
||||
### Rust 标准库
|
||||
|
||||
- `std::os::windows::process::CommandExt`: https://doc.rust-lang.org/std/os/windows/process/trait.CommandExt.html
|
||||
|
||||
### mcp-proxy v0.1.39 修复
|
||||
|
||||
- Commit: `aa42573 - fix: hide CMD windows on Windows platform for Tauri integration`
|
||||
- 文件: `mcp-streamable-proxy/src/server_builder.rs:230-241`
|
||||
|
||||
---
|
||||
|
||||
## 常见问题
|
||||
|
||||
### Q1: 为什么不能在创建 Command 时就设置 CREATE_NO_WINDOW?
|
||||
|
||||
A: 某些 Rust 库(如 `tokio::process::Command`)在内部可能会重新配置命令参数,导致早期设置的标志被覆盖。最佳实践是在所有配置(env, args, stdin/stdout/stderr)之后最后设置。
|
||||
|
||||
### Q2: 隐藏 CMD 窗口后如何调试启动失败?
|
||||
|
||||
A: 使用方案 2 的 `start_service_with_logging` 函数,它会捕获 stdout/stderr 并记录到日志文件中。
|
||||
|
||||
### Q3: 是否需要在 macOS/Linux 上做特殊处理?
|
||||
|
||||
A: 不需要。macOS/Linux 上启动 shell 脚本不会弹窗口,`create_hidden_command` 函数在非 Windows 平台上只是简单的 `Command::new()`。
|
||||
|
||||
### Q4: 如果用户已经安装了 Node.js,还会触发自动安装吗?
|
||||
|
||||
A: 修复后不会。改进的 `is_nodejs_available()` 函数会先检查 `node --version` 是否成功,只有真正不可用时才会触发安装。
|
||||
|
||||
---
|
||||
|
||||
## 总结
|
||||
|
||||
| 问题 | 现状 | 修复后 |
|
||||
|------|------|--------|
|
||||
| **CMD 窗口弹出** | ❌ 多个窗口弹出 | ✅ 完全隐藏 |
|
||||
| **mcp-proxy 启动失败** | ❌ 15s 超时,看不到错误 | ✅ 日志中能看到详细错误 |
|
||||
| **Node.js 重复安装** | ❌ 已安装仍尝试安装 10 次 | ✅ 正确检测,不重复安装 |
|
||||
| **调试困难** | ❌ 窗口隐藏后无法调试 | ✅ 完整的 stdout/stderr 日志 |
|
||||
|
||||
**实施时间**: 约 5 小时(包括测试)
|
||||
**影响范围**: nuwax-agent Windows 版本
|
||||
**风险**: 低(只影响进程启动方式,不改变业务逻辑)
|
||||
@@ -0,0 +1,249 @@
|
||||
# Windows CREATE_NO_WINDOW 处理确认报告
|
||||
|
||||
## 检查日期
|
||||
2026-02-12
|
||||
|
||||
## 检查范围
|
||||
mcp-proxy 代码库中所有启动子进程的地方
|
||||
|
||||
---
|
||||
|
||||
## ✅ 检查结果:全部已正确处理
|
||||
|
||||
### 文件 1: `mcp-proxy/src/client/core/command.rs`
|
||||
- **状态**: ✅ 已处理
|
||||
- **场景**: 本地命令模式(stdio CLI)
|
||||
- **实现方式**: `process-wrap` + `CreationFlags`
|
||||
|
||||
```rust
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use process_wrap::CreationFlags;
|
||||
// CREATE_NO_WINDOW = 0x08000000
|
||||
// 隐藏控制台窗口,避免在 GUI 应用(如 Tauri)中显示 CMD 窗口
|
||||
wrapped_cmd.wrap(CreationFlags(0x08000000));
|
||||
wrapped_cmd.wrap(JobObject);
|
||||
}
|
||||
```
|
||||
|
||||
**优点**:
|
||||
- ✅ 使用 `process-wrap` 统一 API
|
||||
- ✅ 同时使用 `JobObject` 管理进程树
|
||||
- ✅ 位置正确(在 CommandWrap::with_new 闭包之后)
|
||||
- ✅ 跨平台条件编译
|
||||
|
||||
---
|
||||
|
||||
### 文件 2: `mcp-sse-proxy/src/server_builder.rs`
|
||||
- **状态**: ✅ 已处理
|
||||
- **场景**: SSE 协议代理启动 MCP 服务子进程
|
||||
- **实现方式**: `process-wrap::tokio` + `CreationFlags`
|
||||
|
||||
```rust
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use process_wrap::tokio::CreationFlags;
|
||||
// CREATE_NO_WINDOW = 0x08000000
|
||||
// 隐藏控制台窗口,避免在 GUI 应用(如 Tauri)中显示 CMD 窗口
|
||||
wrapped_cmd.wrap(CreationFlags(0x08000000));
|
||||
wrapped_cmd.wrap(JobObject);
|
||||
}
|
||||
```
|
||||
|
||||
**优点**:
|
||||
- ✅ 使用 async 版本的 `process-wrap::tokio`
|
||||
- ✅ 同时使用 `JobObject` 管理进程树
|
||||
- ✅ 位置正确(在 TokioCommandWrap::with_new 闭包之后)
|
||||
- ✅ 详细的中英文注释
|
||||
|
||||
---
|
||||
|
||||
### 文件 3: `mcp-streamable-proxy/src/server_builder.rs`
|
||||
- **状态**: ✅ 已处理(v0.1.39 修复)
|
||||
- **场景**: Streamable HTTP 协议代理启动 MCP 服务子进程
|
||||
- **实现方式**: 标准库 `CommandExt` + `creation_flags`
|
||||
|
||||
```rust
|
||||
// Windows: 隐藏控制台窗口,避免在 GUI 应用(如 Tauri)中显示 CMD 窗口
|
||||
// 注意:必须在所有 env/args 配置之后设置,确保不被覆盖
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::process::CommandExt;
|
||||
// CREATE_NO_WINDOW = 0x08000000
|
||||
const CREATE_NO_WINDOW: u32 = 0x08000000;
|
||||
cmd.creation_flags(CREATE_NO_WINDOW);
|
||||
}
|
||||
```
|
||||
|
||||
**优点**:
|
||||
- ✅ 使用标准库 API(无需额外依赖)
|
||||
- ✅ 位置正确(在所有 env/args 之后)
|
||||
- ✅ 明确的警告注释说明顺序重要性
|
||||
- ✅ 定义为局部常量,代码清晰
|
||||
|
||||
**修复历史**:
|
||||
- v0.1.38 之前: CREATE_NO_WINDOW 设置在 env/args 之前(可能失效)
|
||||
- v0.1.39: 修复为在所有配置之后设置
|
||||
|
||||
---
|
||||
|
||||
## 代码覆盖率
|
||||
|
||||
### mcp-proxy 相关子进程启动点
|
||||
|
||||
| 组件 | 文件 | 启动场景 | Windows 处理 |
|
||||
|------|------|---------|------------|
|
||||
| **mcp-proxy CLI** | client/core/command.rs | 启动本地 MCP 服务(stdio 模式) | ✅ CreationFlags |
|
||||
| **mcp-sse-proxy** | mcp-sse-proxy/src/server_builder.rs | 启动 MCP 服务子进程(SSE 模式) | ✅ CreationFlags |
|
||||
| **mcp-streamable-proxy** | mcp-streamable-proxy/src/server_builder.rs | 启动 MCP 服务子进程(HTTP 模式) | ✅ creation_flags |
|
||||
|
||||
### 其他组件(不在检查范围)
|
||||
|
||||
| 组件 | 说明 | 是否需要处理 |
|
||||
|------|------|------------|
|
||||
| document-parser | Python 子进程(uv、MinerU) | ⚠️ 建议检查 |
|
||||
| voice-cli | Python 子进程(TTS、Whisper) | ⚠️ 建议检查 |
|
||||
|
||||
---
|
||||
|
||||
## 实现模式对比
|
||||
|
||||
### 模式 A: process-wrap + CreationFlags
|
||||
**使用位置**: mcp-proxy, mcp-sse-proxy
|
||||
|
||||
```rust
|
||||
use process_wrap::tokio::{TokioCommandWrap, CreationFlags, JobObject, KillOnDrop};
|
||||
|
||||
let mut wrapped_cmd = TokioCommandWrap::with_new(command, |cmd| {
|
||||
cmd.args(&args);
|
||||
cmd.envs(&env);
|
||||
});
|
||||
|
||||
#[cfg(windows)]
|
||||
{
|
||||
wrapped_cmd.wrap(CreationFlags(0x08000000));
|
||||
wrapped_cmd.wrap(JobObject);
|
||||
}
|
||||
|
||||
wrapped_cmd.wrap(KillOnDrop);
|
||||
let process = TokioChildProcess::new(wrapped_cmd)?;
|
||||
```
|
||||
|
||||
**优点**:
|
||||
- 统一的 API 风格
|
||||
- 自动进程树管理(JobObject)
|
||||
- 自动清理(KillOnDrop)
|
||||
|
||||
---
|
||||
|
||||
### 模式 B: 标准库 CommandExt
|
||||
**使用位置**: mcp-streamable-proxy
|
||||
|
||||
```rust
|
||||
use tokio::process::Command;
|
||||
|
||||
let mut cmd = Command::new(command);
|
||||
cmd.args(&args);
|
||||
cmd.envs(&env);
|
||||
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::process::CommandExt;
|
||||
const CREATE_NO_WINDOW: u32 = 0x08000000;
|
||||
cmd.creation_flags(CREATE_NO_WINDOW);
|
||||
}
|
||||
|
||||
let child = cmd.spawn()?;
|
||||
```
|
||||
|
||||
**优点**:
|
||||
- 无需额外依赖
|
||||
- 标准库稳定性
|
||||
- 更直接的控制
|
||||
|
||||
---
|
||||
|
||||
## 测试建议
|
||||
|
||||
### 手动测试步骤(Windows)
|
||||
|
||||
1. **编译最新版本**:
|
||||
```bash
|
||||
cargo build --release -p mcp-stdio-proxy
|
||||
```
|
||||
|
||||
2. **测试场景 A - CLI 模式**:
|
||||
```bash
|
||||
# 启动一个本地 MCP 服务
|
||||
mcp-proxy convert <local-mcp-service> --verbose
|
||||
```
|
||||
**预期**: 无 CMD 窗口弹出
|
||||
|
||||
3. **测试场景 B - Server 模式**:
|
||||
```bash
|
||||
# 启动 mcp-proxy 服务器
|
||||
mcp-proxy server --port 18099
|
||||
```
|
||||
**预期**: 启动 MCP 服务插件时无 CMD 窗口弹出
|
||||
|
||||
4. **测试场景 C - Tauri 集成**:
|
||||
- 从 nuwax-agent 启动 mcp-proxy
|
||||
**预期**: 无 CMD 窗口(这个需要 nuwax-agent 也正确设置)
|
||||
|
||||
### 自动化测试(建议添加)
|
||||
|
||||
```rust
|
||||
#[cfg(all(test, windows))]
|
||||
mod windows_tests {
|
||||
use super::*;
|
||||
use std::os::windows::process::CommandExt;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_no_window_flag_is_set() {
|
||||
// 测试 CREATE_NO_WINDOW 标志是否生效
|
||||
// 可以通过检查进程树中是否有 conhost.exe 来验证
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 结论
|
||||
|
||||
### ✅ 所有子进程启动点已正确处理
|
||||
|
||||
1. **mcp-proxy/client/core/command.rs** - ✅ 已处理
|
||||
2. **mcp-sse-proxy/src/server_builder.rs** - ✅ 已处理
|
||||
3. **mcp-streamable-proxy/src/server_builder.rs** - ✅ 已处理(v0.1.39)
|
||||
|
||||
### 关键要点
|
||||
|
||||
1. **CREATE_NO_WINDOW 值**: 所有实现都使用 `0x08000000`
|
||||
2. **设置顺序**: 所有实现都在 env/args 之后设置
|
||||
3. **条件编译**: 所有实现都使用 `#[cfg(windows)]`
|
||||
4. **注释完整**: 所有实现都包含说明目的的注释
|
||||
|
||||
### 仍需关注
|
||||
|
||||
1. **nuwax-agent 侧**: 启动 mcp-proxy.cmd 本身时需要设置 CREATE_NO_WINDOW
|
||||
2. **document-parser**: Python 子进程可能需要相同处理
|
||||
3. **voice-cli**: Python 子进程可能需要相同处理
|
||||
4. **测试验证**: 需要在 Windows 环境实际测试确认效果
|
||||
|
||||
---
|
||||
|
||||
## 版本信息
|
||||
|
||||
- **检查版本**: v0.1.39
|
||||
- **修复文件**: mcp-streamable-proxy/src/server_builder.rs
|
||||
- **修复内容**: 将 CREATE_NO_WINDOW 移到最后设置
|
||||
- **向后兼容**: 是(仅影响 Windows 平台行为)
|
||||
|
||||
---
|
||||
|
||||
## 签署
|
||||
|
||||
**检查人员**: Claude (Sonnet 4.5)
|
||||
**检查日期**: 2026-02-12
|
||||
**检查方法**: 代码审查 + 全局搜索 + Agent 验证
|
||||
**结论**: ✅ 所有 mcp-proxy 相关子进程启动点已正确处理 Windows CREATE_NO_WINDOW
|
||||
183
qiming-mcp-proxy/docs/analysis/WINDOWS_FIX_SUMMARY.md
Normal file
183
qiming-mcp-proxy/docs/analysis/WINDOWS_FIX_SUMMARY.md
Normal file
@@ -0,0 +1,183 @@
|
||||
# Windows 测试问题修复总结
|
||||
|
||||
## 测试环境
|
||||
- 版本: v0.1.37
|
||||
- 平台: Windows
|
||||
|
||||
## 发现的问题
|
||||
|
||||
### 1. ✅ 隐藏 CMD 窗口没有效果
|
||||
**问题分析**:
|
||||
- 在 `mcp-streamable-proxy/src/server_builder.rs` 中,`CREATE_NO_WINDOW` 标志设置得太早
|
||||
- 后续的 `cmd.env()` 和 `cmd.args()` 调用可能导致标志被重置或无效
|
||||
|
||||
**修复方案**:
|
||||
- 将 `CREATE_NO_WINDOW` 设置移到所有 `env()` 和 `args()` 调用之后
|
||||
- 确保在创建 `TokioChildProcess` 之前最后一步设置
|
||||
|
||||
**已修复文件**:
|
||||
- `/Users/apple/workspace/mcp-proxy/mcp-streamable-proxy/src/server_builder.rs`
|
||||
|
||||
**状态**: ✅ 已修复
|
||||
|
||||
---
|
||||
|
||||
### 2. ❓ mcp-proxy 启动不成功
|
||||
|
||||
**需要的信息**:
|
||||
1. 完整的错误日志(请提供)
|
||||
2. 使用的命令行参数
|
||||
3. 配置文件内容(如果有)
|
||||
|
||||
**可能的原因**:
|
||||
- Node.js 可执行文件路径问题
|
||||
- PATH 环境变量未正确继承
|
||||
- 子进程创建失败
|
||||
- MCP 服务配置错误
|
||||
|
||||
**诊断步骤**:
|
||||
```bash
|
||||
# 1. 检查日志文件
|
||||
cat logs/mcp-proxy.log
|
||||
|
||||
# 2. 使用详细模式运行
|
||||
mcp-proxy --verbose
|
||||
|
||||
# 3. 检查 Node.js 是否可用
|
||||
where node # Windows
|
||||
node --version
|
||||
|
||||
# 4. 检查 PATH 环境变量
|
||||
echo %PATH%
|
||||
```
|
||||
|
||||
**状态**: ⏳ 等待错误日志
|
||||
|
||||
---
|
||||
|
||||
### 3. ❓ 如果用户已经安装 Node.js 无法进入后续安装流程
|
||||
|
||||
**问题不清楚**:
|
||||
这个描述需要更多上下文。可能的理解:
|
||||
|
||||
**理解 A**: mcp-proxy 尝试安装 Node.js,但检测到已安装的 Node.js 后卡住
|
||||
- 问题: mcp-proxy 本身不包含 Node.js 安装逻辑
|
||||
- 代码中只有 PATH 继承和 npm 路径添加
|
||||
- 需要确认是否是其他工具(如 MCP 服务本身)的行为
|
||||
|
||||
**理解 B**: 某个 MCP 服务需要 Node.js,但检测逻辑有问题
|
||||
- 可能的原因: Node.js 在 PATH 中但可执行文件名不对(node.exe vs node)
|
||||
- 可能的原因: Node.js 版本不兼容
|
||||
|
||||
**理解 C**: npm 包安装过程卡住
|
||||
- 可能的原因: npm 缓存问题
|
||||
- 可能的原因: 网络连接问题
|
||||
- 可能的原因: 权限问题
|
||||
|
||||
**需要的信息**:
|
||||
1. 具体是什么"后续安装流程"?
|
||||
2. 卡在哪个步骤?有什么错误提示?
|
||||
3. 这个安装流程是 mcp-proxy 触发的还是某个 MCP 服务?
|
||||
4. Node.js 安装路径是什么?
|
||||
|
||||
**可能的修复方案**:
|
||||
如果是 Node.js 可执行文件检测问题,可以添加检测逻辑:
|
||||
|
||||
```rust
|
||||
// 检查 Node.js 是否可用
|
||||
fn is_node_available() -> bool {
|
||||
#[cfg(windows)]
|
||||
let node_cmd = "node.exe";
|
||||
#[cfg(not(windows))]
|
||||
let node_cmd = "node";
|
||||
|
||||
std::process::Command::new(node_cmd)
|
||||
.arg("--version")
|
||||
.output()
|
||||
.is_ok()
|
||||
}
|
||||
```
|
||||
|
||||
**状态**: ⏳ 等待详细描述
|
||||
|
||||
---
|
||||
|
||||
## 当前代码状态
|
||||
|
||||
### PATH 环境变量处理
|
||||
✅ 正确继承父进程 PATH
|
||||
✅ Windows 上自动添加 `%APPDATA%\npm` 到 PATH
|
||||
✅ 支持配置文件中自定义 PATH
|
||||
|
||||
### Windows CMD 窗口隐藏
|
||||
✅ `mcp-streamable-proxy`: 使用 `CREATE_NO_WINDOW` (已修复顺序)
|
||||
✅ `mcp-sse-proxy`: 使用 `CreationFlags(0x08000000)`
|
||||
✅ `mcp-proxy/client/core/command.rs`: 使用 `CreationFlags(0x08000000)`
|
||||
|
||||
---
|
||||
|
||||
## 下一步行动
|
||||
|
||||
### 立即需要
|
||||
1. **问题 2**: 提供完整的 mcp-proxy 启动错误日志
|
||||
2. **问题 3**: 详细描述"无法进入后续安装流程"的具体情况
|
||||
|
||||
### 测试验证
|
||||
1. 编译新版本并在 Windows 上测试
|
||||
2. 验证 CMD 窗口是否成功隐藏
|
||||
3. 测试各种 Node.js 安装场景:
|
||||
- 未安装 Node.js
|
||||
- 通过官方安装包安装
|
||||
- 通过 nvm-windows 安装
|
||||
- 通过 fnm 安装
|
||||
- Node.js 在自定义路径
|
||||
|
||||
### 构建测试版本
|
||||
```bash
|
||||
# 更新版本号
|
||||
# 编辑 mcp-proxy/Cargo.toml, 修改 version = "0.1.38"
|
||||
|
||||
# 构建
|
||||
cargo build --release -p mcp-proxy
|
||||
|
||||
# 生成可执行文件位置
|
||||
# target/release/mcp-proxy.exe
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 请提供以下信息
|
||||
|
||||
为了准确诊断和修复剩余的两个问题,请提供:
|
||||
|
||||
### 问题 2 (mcp-proxy 启动失败)
|
||||
```bash
|
||||
# 运行这些命令并提供输出
|
||||
mcp-proxy --version
|
||||
mcp-proxy <你的命令参数> --verbose
|
||||
|
||||
# 如果有日志文件,提供内容
|
||||
type logs\mcp-proxy.log
|
||||
```
|
||||
|
||||
### 问题 3 (Node.js 安装流程)
|
||||
请描述:
|
||||
1. 完整的操作步骤(从头到尾做了什么)
|
||||
2. 期望的行为是什么
|
||||
3. 实际发生了什么
|
||||
4. 有什么错误提示或卡住的界面
|
||||
5. 你的 Node.js 安装方式和路径
|
||||
|
||||
### 系统信息
|
||||
```powershell
|
||||
# Windows 版本
|
||||
systeminfo | findstr /B /C:"OS Name" /C:"OS Version"
|
||||
|
||||
# Node.js 信息
|
||||
where node
|
||||
node --version
|
||||
npm --version
|
||||
|
||||
# PATH 环境变量
|
||||
echo %PATH%
|
||||
```
|
||||
287
qiming-mcp-proxy/docs/analysis/WINDOWS_FIX_v0.1.39.md
Normal file
287
qiming-mcp-proxy/docs/analysis/WINDOWS_FIX_v0.1.39.md
Normal file
@@ -0,0 +1,287 @@
|
||||
# Windows 测试问题修复总结 (v0.1.39)
|
||||
|
||||
## 测试环境
|
||||
- **版本**: v0.1.37 → v0.1.39
|
||||
- **平台**: Windows
|
||||
- **日志文件**: nuwax-agent.log.2026-02-12
|
||||
|
||||
---
|
||||
|
||||
## 发现的问题及修复状态
|
||||
|
||||
### 1. ✅ 隐藏 CMD 窗口没有效果
|
||||
|
||||
**问题描述**:
|
||||
- 在 Windows 上启动 MCP 子进程时,控制台窗口仍然显示
|
||||
|
||||
**根本原因**:
|
||||
- `mcp-streamable-proxy/src/server_builder.rs` 中,`CREATE_NO_WINDOW` 标志设置得太早
|
||||
- 后续的 `cmd.env()` 和 `cmd.args()` 调用导致标志可能失效
|
||||
|
||||
**修复方案**:
|
||||
- 将 `CREATE_NO_WINDOW` 设置移到所有命令配置(env/args)之后
|
||||
- 确保在创建 `TokioChildProcess` 之前作为最后一步设置
|
||||
|
||||
**修复代码**:
|
||||
```rust
|
||||
// 在所有 env() 和 args() 调用之后
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::process::CommandExt;
|
||||
const CREATE_NO_WINDOW: u32 = 0x08000000;
|
||||
cmd.creation_flags(CREATE_NO_WINDOW);
|
||||
}
|
||||
```
|
||||
|
||||
**修复文件**:
|
||||
- `/Users/apple/workspace/mcp-proxy/mcp-streamable-proxy/src/server_builder.rs:180-241`
|
||||
|
||||
**状态**: ✅ 已修复,待测试验证
|
||||
|
||||
---
|
||||
|
||||
### 2. ❌ mcp-proxy 启动失败
|
||||
|
||||
**问题描述**:
|
||||
- mcp-proxy 进程启动后,健康检查超时失败
|
||||
- 错误: "等待 15s 后 http://127.0.0.1:18099/mcp 仍未就绪"
|
||||
|
||||
**日志证据**:
|
||||
```
|
||||
2026-02-12T06:18:41.088275Z INFO [McpProxy] 可执行文件路径: C:\Users\MECHREVO\.local\bin\mcp-proxy.cmd
|
||||
2026-02-12T06:18:41.088293Z INFO [McpProxy] 监听地址: 127.0.0.1:18099
|
||||
2026-02-12T06:19:01.052725Z ERROR [McpProxy] 健康检查失败: MCP Proxy 健康检查超时
|
||||
```
|
||||
|
||||
**分析**:
|
||||
1. **进程启动成功**: mcp-proxy.cmd 被调用
|
||||
2. **HTTP 服务未启动**: 15秒后健康检查端点仍不可用
|
||||
3. **日志缺失**: 由于 CMD 窗口隐藏,子进程的 stdout/stderr 未被捕获
|
||||
|
||||
**可能的原因**:
|
||||
- mcp-proxy 子进程内部 panic/crash
|
||||
- 缺少必要的运行时依赖
|
||||
- 配置文件解析错误
|
||||
- 端口 18099 被占用
|
||||
- Node.js 环境变量缺失
|
||||
|
||||
**诊断步骤** (需要用户执行):
|
||||
```powershell
|
||||
# 1. 检查 mcp-proxy 版本
|
||||
C:\Users\MECHREVO\.local\bin\mcp-proxy.cmd --version
|
||||
|
||||
# 2. 手动启动服务器(查看完整错误)
|
||||
C:\Users\MECHREVO\.local\bin\mcp-proxy.cmd server --port 18099
|
||||
|
||||
# 3. 检查端口占用
|
||||
netstat -ano | findstr "18099"
|
||||
|
||||
# 4. 验证 Node.js 环境
|
||||
node --version
|
||||
npm list -g mcp-stdio-proxy
|
||||
|
||||
# 5. 查看批处理文件内容
|
||||
type C:\Users\MECHREVO\.local\bin\mcp-proxy.cmd
|
||||
```
|
||||
|
||||
**建议修复** (针对 nuwax-agent):
|
||||
```rust
|
||||
// 需要捕获子进程的 stdout/stderr
|
||||
let mut child = Command::new(mcp_proxy_path)
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()?;
|
||||
|
||||
// 异步读取并记录输出
|
||||
tokio::spawn(async move {
|
||||
let mut stdout = BufReader::new(child.stdout.take().unwrap()).lines();
|
||||
while let Ok(Some(line)) = stdout.next_line().await {
|
||||
tracing::info!("[McpProxy stdout] {}", line);
|
||||
}
|
||||
});
|
||||
|
||||
tokio::spawn(async move {
|
||||
let mut stderr = BufReader::new(child.stderr.take().unwrap()).lines();
|
||||
while let Ok(Some(line)) = stderr.next_line().await {
|
||||
tracing::error!("[McpProxy stderr] {}", line);
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
**状态**: ⏳ 等待用户提供诊断信息(这是 nuwax-agent 的问题,不是 mcp-proxy 代码的问题)
|
||||
|
||||
---
|
||||
|
||||
### 3. ⚠️ Node.js 检测逻辑问题
|
||||
|
||||
**问题描述**:
|
||||
- 第一次启动时,即使系统已安装 Node.js,仍然出现多次重复安装尝试
|
||||
- 第二次启动时,应用安装了自己的 Node.js 到 `~/.local/bin`
|
||||
|
||||
**日志证据**:
|
||||
```
|
||||
# 第一次启动 - 10 次重复尝试
|
||||
2026-02-12T06:14:50.437049Z INFO [resolve_node_bin] npm -> fallback to PATH
|
||||
2026-02-12T06:15:22.509303Z INFO [Dependency] 开始全局安装 npm 包: mcp-stdio-proxy
|
||||
2026-02-12T06:15:29.360632Z INFO [Dependency] 开始全局安装 npm 包: mcp-stdio-proxy
|
||||
...(共 10 次)
|
||||
|
||||
# 第二次启动 - 成功
|
||||
2026-02-12T06:16:55.747776Z INFO [NodeInstall] 开始自动安装 Node.js...
|
||||
2026-02-12T06:17:00.275214Z INFO [resolve_node_bin] npm -> "C:\\Users\\MECHREVO\\.local\\bin\\npm.cmd"
|
||||
2026-02-12T06:17:13.857546Z INFO [Dependency] mcp-stdio-proxy 全局安装成功
|
||||
```
|
||||
|
||||
**根本原因**:
|
||||
1. **缺少 Node.js 可用性检测**: 直接假设 PATH 中的 npm 可用
|
||||
2. **未验证命令执行结果**: `fallback to PATH` 后未检查 npm 命令是否真正可用
|
||||
3. **重试逻辑问题**: 安装失败后一直重试,未触发 Node.js 安装
|
||||
|
||||
**建议修复** (针对 nuwax-agent):
|
||||
|
||||
```rust
|
||||
/// 检查 Node.js 是否真正可用
|
||||
async fn is_node_available() -> bool {
|
||||
let node_cmd = if cfg!(windows) { "node.exe" } else { "node" };
|
||||
|
||||
match tokio::process::Command::new(node_cmd)
|
||||
.arg("--version")
|
||||
.output()
|
||||
.await
|
||||
{
|
||||
Ok(output) if output.status.success() => {
|
||||
let version = String::from_utf8_lossy(&output.stdout);
|
||||
tracing::info!("Found Node.js: {}", version.trim());
|
||||
true
|
||||
}
|
||||
Ok(_) => {
|
||||
tracing::warn!("Node.js command exists but failed");
|
||||
false
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("Node.js not found: {}", e);
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 确保 npm 包安装(带前置检查)
|
||||
async fn ensure_npm_package(package_name: &str) -> Result<()> {
|
||||
// 1. 先检查 Node.js 是否可用
|
||||
if !is_node_available().await {
|
||||
tracing::info!("Node.js not available, installing...");
|
||||
install_nodejs().await?;
|
||||
|
||||
// 再次验证
|
||||
if !is_node_available().await {
|
||||
anyhow::bail!("Failed to install Node.js");
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 检查包是否已安装
|
||||
if is_package_installed(package_name).await? {
|
||||
tracing::info!("{} is already installed", package_name);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// 3. 安装包(带重试限制)
|
||||
let max_retries = 3;
|
||||
for attempt in 1..=max_retries {
|
||||
match install_npm_package(package_name).await {
|
||||
Ok(_) => return Ok(()),
|
||||
Err(e) if attempt < max_retries => {
|
||||
tracing::warn!("Install attempt {}/{} failed: {}", attempt, max_retries, e);
|
||||
tokio::time::sleep(Duration::from_secs(2)).await;
|
||||
}
|
||||
Err(e) => anyhow::bail!("Failed to install {} after {} attempts: {}", package_name, max_retries, e),
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
**状态**: ⚠️ 这是 nuwax-agent 的问题,已提供修复建议
|
||||
|
||||
---
|
||||
|
||||
## 修改文件清单
|
||||
|
||||
### mcp-proxy 仓库
|
||||
1. ✅ `mcp-streamable-proxy/src/server_builder.rs`
|
||||
- 移动 CREATE_NO_WINDOW 到最后设置
|
||||
|
||||
2. ✅ `mcp-proxy/Cargo.toml`
|
||||
- 版本号: 0.1.38 → 0.1.39
|
||||
|
||||
3. ✅ `WINDOWS_ISSUE_ANALYSIS.md`
|
||||
- 新增:完整的问题分析文档
|
||||
|
||||
4. ✅ `WINDOWS_FIX_SUMMARY.md`
|
||||
- 新增:修复总结和诊断指南
|
||||
|
||||
---
|
||||
|
||||
## 验证检查清单
|
||||
|
||||
### 立即需要用户验证
|
||||
- [ ] 手动运行 `mcp-proxy.cmd server --port 18099` 并提供完整输出
|
||||
- [ ] 检查端口 18099 是否被占用
|
||||
- [ ] 验证 Node.js 和 npm 全局包安装情况
|
||||
|
||||
### 编译新版本后验证
|
||||
- [ ] CMD 窗口是否成功隐藏(问题 1)
|
||||
- [ ] mcp-proxy 是否能正常启动(问题 2,取决于诊断结果)
|
||||
- [ ] Node.js 检测是否正常(问题 3,需要 nuwax-agent 修复)
|
||||
|
||||
---
|
||||
|
||||
## 构建和发布
|
||||
|
||||
### 构建命令
|
||||
```bash
|
||||
# 构建发布版本
|
||||
cargo build --release -p mcp-stdio-proxy
|
||||
|
||||
# 生成的二进制文件
|
||||
# target/release/mcp-proxy (或 mcp-proxy.exe on Windows)
|
||||
```
|
||||
|
||||
### 版本信息
|
||||
- **当前版本**: v0.1.39
|
||||
- **修复内容**: Windows CMD 窗口隐藏问题
|
||||
- **待验证**: 需要在 Windows 环境测试
|
||||
|
||||
---
|
||||
|
||||
## 后续行动
|
||||
|
||||
### 短期(立即)
|
||||
1. **等待用户诊断信息**: 运行诊断命令并提供输出
|
||||
2. **编译测试**: 在 Windows 上编译 v0.1.39 并测试
|
||||
|
||||
### 中期(本周)
|
||||
1. **与 nuwax-agent 团队协作**:
|
||||
- 分享子进程输出捕获建议
|
||||
- 分享 Node.js 检测逻辑改进建议
|
||||
2. **完善文档**: 添加 Windows 平台特定的故障排除指南
|
||||
|
||||
### 长期(下一版本)
|
||||
1. **考虑添加 mcp-proxy 自检命令**:
|
||||
```bash
|
||||
mcp-proxy doctor # 检查环境、依赖、配置
|
||||
```
|
||||
2. **改进错误信息**: 提供更详细的启动失败诊断
|
||||
3. **添加 Windows 集成测试**: 自动化测试 CMD 窗口隐藏等功能
|
||||
|
||||
---
|
||||
|
||||
## 联系信息
|
||||
|
||||
如有问题,请提供:
|
||||
1. 完整的错误日志
|
||||
2. 运行诊断命令的输出
|
||||
3. Windows 版本和系统信息
|
||||
4. Node.js/npm 版本信息
|
||||
|
||||
GitHub Issue: https://github.com/nuwax-ai/mcp-proxy/issues
|
||||
257
qiming-mcp-proxy/docs/analysis/WINDOWS_ISSUE_ANALYSIS.md
Normal file
257
qiming-mcp-proxy/docs/analysis/WINDOWS_ISSUE_ANALYSIS.md
Normal file
@@ -0,0 +1,257 @@
|
||||
# Windows 测试问题完整分析与修复方案
|
||||
|
||||
## 日志分析结果
|
||||
|
||||
基于 `nuwax-agent.log.2026-02-12` 的分析,我发现了以下关键问题:
|
||||
|
||||
### 问题 1: ✅ 隐藏 CMD 窗口 - 已修复
|
||||
**状态**: 已在 `mcp-streamable-proxy` 中修复
|
||||
|
||||
### 问题 2: ❌ mcp-proxy 启动失败
|
||||
|
||||
**错误日志**:
|
||||
```
|
||||
2026-02-12T06:19:01.052725Z ERROR nuwax_agent_core::service:
|
||||
[McpProxy] 健康检查失败: MCP Proxy 健康检查超时: 等待 15s 后 http://127.0.0.1:18099/mcp 仍未就绪
|
||||
```
|
||||
|
||||
**详细分析**:
|
||||
|
||||
1. **mcp-proxy 进程启动成功**:
|
||||
```
|
||||
2026-02-12T06:18:41.088275Z INFO nuwax_agent_core::service: [McpProxy] 可执行文件路径: C:\Users\MECHREVO\.local\bin\mcp-proxy.cmd
|
||||
2026-02-12T06:18:41.088293Z INFO nuwax_agent_core::service: [McpProxy] 监听地址: 127.0.0.1:18099
|
||||
```
|
||||
|
||||
2. **但是 HTTP 健康检查失败**:
|
||||
- 15秒后 `http://127.0.0.1:18099/mcp` 仍然无法访问
|
||||
- 这说明 mcp-proxy 进程虽然启动了,但 HTTP 服务器没有正常运行
|
||||
|
||||
**可能的原因**:
|
||||
|
||||
1. **mcp-proxy 自身的启动错误** (最可能):
|
||||
- 子进程可能有 panic/crash
|
||||
- 缺少必要的依赖
|
||||
- 配置文件解析错误
|
||||
- 端口被占用
|
||||
|
||||
2. **日志输出被隐藏**:
|
||||
- 由于使用了 CMD 窗口隐藏,mcp-proxy 的 stderr/stdout 可能没有被正确捕获
|
||||
- Tauri 应用需要配置子进程的输出重定向
|
||||
|
||||
3. **Node.js 环境问题**:
|
||||
- mcp-proxy.cmd 是一个 Windows 批处理文件,依赖 Node.js
|
||||
- 虽然 PATH 中有 Node.js,但可能存在其他环境变量缺失
|
||||
|
||||
**诊断步骤**:
|
||||
|
||||
```powershell
|
||||
# 1. 手动测试 mcp-proxy 是否能运行
|
||||
C:\Users\MECHREVO\.local\bin\mcp-proxy.cmd --version
|
||||
|
||||
# 2. 尝试手动启动服务器
|
||||
C:\Users\MECHREVO\.local\bin\mcp-proxy.cmd server
|
||||
|
||||
# 3. 检查端口占用
|
||||
netstat -ano | findstr "18099"
|
||||
|
||||
# 4. 查看 Node.js 全局包安装情况
|
||||
npm list -g mcp-stdio-proxy
|
||||
|
||||
# 5. 检查 mcp-proxy.cmd 内容
|
||||
type C:\Users\MECHREVO\.local\bin\mcp-proxy.cmd
|
||||
```
|
||||
|
||||
### 问题 3: ⚠️ Node.js 安装检测逻辑问题
|
||||
|
||||
**观察到的行为**:
|
||||
|
||||
```
|
||||
# 第一次启动 - Node.js 未安装
|
||||
2026-02-12T06:14:50.437049Z INFO agent_tauri_client_lib: [resolve_node_bin] npm -> fallback to PATH
|
||||
|
||||
# 多次重复尝试安装 mcp-stdio-proxy(说明检测失败)
|
||||
2026-02-12T06:15:22.509303Z INFO agent_tauri_client_lib: [Dependency] 开始全局安装 npm 包: mcp-stdio-proxy
|
||||
2026-02-12T06:15:29.360632Z INFO agent_tauri_client_lib: [Dependency] 开始全局安装 npm 包: mcp-stdio-proxy
|
||||
2026-02-12T06:15:30.769366Z INFO agent_tauri_client_lib: [Dependency] 开始全局安装 npm 包: mcp-stdio-proxy
|
||||
...(共 10 次重复)
|
||||
|
||||
# 第二次启动 - Node.js 已安装
|
||||
2026-02-12T06:16:55.747776Z INFO agent_tauri_client_lib: [NodeInstall] 开始自动安装 Node.js...
|
||||
2026-02-12T06:16:59.168654Z INFO nuwax_agent_core::dependency::node: Found local Node.js: v22.14.0
|
||||
2026-02-12T06:17:00.275214Z INFO agent_tauri_client_lib: [resolve_node_bin] npm -> "C:\\Users\\MECHREVO\\.local\\bin\\npm.cmd"
|
||||
2026-02-12T06:17:13.857546Z INFO agent_tauri_client_lib: [Dependency] mcp-stdio-proxy 全局安装成功
|
||||
```
|
||||
|
||||
**问题分析**:
|
||||
|
||||
1. **第一次启动时** (Node.js 未安装):
|
||||
- 程序没有先安装 Node.js
|
||||
- 直接尝试使用 PATH 中的 npm(可能来自系统已安装的 Node.js)
|
||||
- 但是这个 npm 可能不可用或有问题,导致安装失败
|
||||
- 触发了多次重试(10 次)
|
||||
|
||||
2. **第二次启动时** (Node.js 已通过应用安装):
|
||||
- 检测到 `~/.local/bin/node` 存在
|
||||
- 使用正确的 npm 路径
|
||||
- 安装成功
|
||||
|
||||
**根本原因**:
|
||||
- **缺少 Node.js 可用性检测**: 应该在使用 npm 之前,先检查 `node` 命令是否真正可用
|
||||
- **检测逻辑不完整**: `fallback to PATH` 意味着直接使用系统 PATH 中的 npm,但没有验证它是否能正常工作
|
||||
|
||||
## 修复方案
|
||||
|
||||
### 1. mcp-proxy 启动失败的修复
|
||||
|
||||
这个问题不在 `mcp-proxy` 代码库中,而在调用方(nuwax-agent)。但我们可以提供诊断指导:
|
||||
|
||||
**建议给 nuwax-agent 团队**:
|
||||
|
||||
```rust
|
||||
// 在启动 mcp-proxy 时,需要捕获子进程的 stdout/stderr
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
let mut child = Command::new(mcp_proxy_path)
|
||||
.args(&["server", "--port", "18099"])
|
||||
.stdout(Stdio::piped()) // 捕获标准输出
|
||||
.stderr(Stdio::piped()) // 捕获标准错误
|
||||
.spawn()?;
|
||||
|
||||
// 读取并记录输出
|
||||
let stdout = child.stdout.take().unwrap();
|
||||
let stderr = child.stderr.take().unwrap();
|
||||
|
||||
// 使用异步任务读取输出
|
||||
tokio::spawn(async move {
|
||||
use tokio::io::{AsyncBufReadExt, BufReader};
|
||||
let mut reader = BufReader::new(stdout).lines();
|
||||
while let Ok(Some(line)) = reader.next_line().await {
|
||||
tracing::info!("[McpProxy stdout] {}", line);
|
||||
}
|
||||
});
|
||||
|
||||
tokio::spawn(async move {
|
||||
use tokio::io::{AsyncBufReadExt, BufReader};
|
||||
let mut reader = BufReader::new(stderr).lines();
|
||||
while let Ok(Some(line)) = reader.next_line().await {
|
||||
tracing::error!("[McpProxy stderr] {}", line);
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
**临时解决方案(给用户)**:
|
||||
|
||||
```powershell
|
||||
# 手动启动 mcp-proxy 来查看错误信息
|
||||
cd C:\Users\MECHREVO\.local\bin
|
||||
.\mcp-proxy.cmd server --port 18099
|
||||
|
||||
# 如果报错,记录错误信息
|
||||
```
|
||||
|
||||
### 2. Node.js 检测逻辑增强
|
||||
|
||||
虽然这个逻辑在 nuwax-agent 中,但我们可以提供参考实现:
|
||||
|
||||
```rust
|
||||
/// 检查 Node.js 是否真正可用
|
||||
async fn is_node_available() -> bool {
|
||||
#[cfg(windows)]
|
||||
let node_cmd = "node.exe";
|
||||
#[cfg(not(windows))]
|
||||
let node_cmd = "node";
|
||||
|
||||
match tokio::process::Command::new(node_cmd)
|
||||
.arg("--version")
|
||||
.output()
|
||||
.await
|
||||
{
|
||||
Ok(output) if output.status.success() => {
|
||||
let version = String::from_utf8_lossy(&output.stdout);
|
||||
tracing::info!("Found Node.js: {}", version.trim());
|
||||
true
|
||||
}
|
||||
Ok(_) => {
|
||||
tracing::warn!("Node.js command exists but failed");
|
||||
false
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("Node.js not found: {}", e);
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 修正后的 npm 安装流程
|
||||
async fn ensure_npm_package(package_name: &str) -> Result<()> {
|
||||
// 1. 先检查 Node.js 是否可用
|
||||
if !is_node_available().await {
|
||||
tracing::info!("Node.js not available, installing...");
|
||||
install_nodejs().await?;
|
||||
|
||||
// 再次验证
|
||||
if !is_node_available().await {
|
||||
anyhow::bail!("Failed to install Node.js");
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 检查包是否已安装
|
||||
if is_package_installed(package_name).await? {
|
||||
tracing::info!("{} is already installed", package_name);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// 3. 安装包
|
||||
install_npm_package(package_name).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
### 3. mcp-streamable-proxy CREATE_NO_WINDOW 修复
|
||||
|
||||
**已修复**: 将 `CREATE_NO_WINDOW` 标志移到所有命令配置的最后。
|
||||
|
||||
### 4. mcp-sse-proxy CREATE_NO_WINDOW 验证
|
||||
|
||||
**当前状态**: 使用 `CreationFlags(0x08000000)` + `JobObject`,应该是正确的。
|
||||
|
||||
需要验证:
|
||||
```rust
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use process_wrap::tokio::CreationFlags;
|
||||
wrapped_cmd.wrap(CreationFlags(0x08000000));
|
||||
wrapped_cmd.wrap(JobObject);
|
||||
}
|
||||
```
|
||||
|
||||
## 总结
|
||||
|
||||
### 问题优先级
|
||||
|
||||
1. **高优先级 - mcp-proxy 启动失败**:
|
||||
- 需要 nuwax-agent 团队捕获子进程输出
|
||||
- 需要用户手动运行 mcp-proxy 来诊断具体错误
|
||||
|
||||
2. **中优先级 - Node.js 检测逻辑**:
|
||||
- 需要在 nuwax-agent 中添加 Node.js 可用性检测
|
||||
- 避免重复安装尝试
|
||||
|
||||
3. **低优先级 - CMD 窗口隐藏**:
|
||||
- mcp-streamable-proxy 已修复
|
||||
- 需要编译新版本并测试
|
||||
|
||||
### 下一步行动
|
||||
|
||||
1. **立即诊断** - 用户手动运行:
|
||||
```powershell
|
||||
C:\Users\MECHREVO\.local\bin\mcp-proxy.cmd server --port 18099
|
||||
```
|
||||
|
||||
2. **提供完整错误日志**: 运行上述命令并提供输出
|
||||
|
||||
3. **编译测试新版本**: 测试 CREATE_NO_WINDOW 修复效果
|
||||
|
||||
4. **联系 nuwax-agent 团队**: 提供子进程输出捕获和 Node.js 检测的修复建议
|
||||
312
qiming-mcp-proxy/document-parser/CUDA_SETUP_GUIDE.md
Normal file
312
qiming-mcp-proxy/document-parser/CUDA_SETUP_GUIDE.md
Normal file
@@ -0,0 +1,312 @@
|
||||
# CUDA环境配置和sglang GPU加速指南
|
||||
|
||||
## 概述
|
||||
|
||||
本指南专门针对需要GPU加速的用户,详细说明如何在支持CUDA的Linux服务器上配置sglang环境,确保MinerU能够使用GPU加速进行PDF解析。
|
||||
|
||||
## 前置条件
|
||||
|
||||
### 1. 硬件要求
|
||||
- NVIDIA GPU(支持CUDA)
|
||||
- 至少8GB GPU内存(推荐16GB+)
|
||||
- 足够的系统内存(推荐32GB+)
|
||||
|
||||
### 2. 软件要求
|
||||
- Linux操作系统(推荐Ubuntu 20.04+)
|
||||
- NVIDIA驱动(版本450+)
|
||||
- CUDA Toolkit(推荐11.8或12.x)
|
||||
- Python 3.8+
|
||||
|
||||
## 环境检查
|
||||
|
||||
### 1. 检查NVIDIA驱动
|
||||
```bash
|
||||
# 检查驱动版本
|
||||
nvidia-smi
|
||||
|
||||
# 预期输出示例:
|
||||
# +-----------------------------------------------------------------------------+
|
||||
# | NVIDIA-SMI 525.105.17 Driver Version: 525.105.17 CUDA Version: 12.0 |
|
||||
# +-----------------------------------------------------------------------------+
|
||||
```
|
||||
|
||||
### 2. 检查CUDA安装
|
||||
```bash
|
||||
# 检查CUDA版本
|
||||
nvcc --version
|
||||
|
||||
# 预期输出示例:
|
||||
# nvcc: NVIDIA (R) Cuda compiler driver
|
||||
# Copyright (c) 2005-2023 NVIDIA Corporation
|
||||
# Built on Wed_Nov_22_10:17:15_PST_2023
|
||||
# Cuda compilation tools, release 12.3, V12.3.52
|
||||
```
|
||||
|
||||
### 3. 检查GPU状态
|
||||
```bash
|
||||
# 查看GPU详细信息
|
||||
nvidia-smi --query-gpu=index,name,memory.total,memory.free,compute_cap --format=csv
|
||||
|
||||
# 预期输出示例:
|
||||
# 0, NVIDIA GeForce RTX 4090, 24576 MiB, 23552 MiB, 8.9
|
||||
```
|
||||
|
||||
## 安装sglang
|
||||
|
||||
### 1. 激活虚拟环境
|
||||
```bash
|
||||
# 进入项目目录
|
||||
cd /path/to/document-parser
|
||||
|
||||
# 激活虚拟环境
|
||||
source ./venv/bin/activate
|
||||
|
||||
# 验证Python路径
|
||||
which python
|
||||
# 应该显示: /path/to/document-parser/venv/bin/python
|
||||
```
|
||||
|
||||
### 2. 安装MinerU(包含兼容的sglang)
|
||||
```bash
|
||||
# 使用uv安装(推荐)
|
||||
uv pip install -U "mineru[all]" -i https://mirrors.aliyun.com/pypi/simple
|
||||
|
||||
# 或者使用pip安装
|
||||
pip install -U "mineru[all]" -i https://mirrors.aliyun.com/pypi/simple
|
||||
|
||||
# 安装过程可能需要几分钟,请耐心等待
|
||||
```
|
||||
|
||||
**重要**:使用 `mineru[all]` 而不是直接安装 `sglang[all]`,确保版本兼容性。
|
||||
|
||||
### 3. 验证安装
|
||||
```bash
|
||||
# 检查sglang版本
|
||||
python -c "import sglang; print('SGLang版本:', sglang.__version__)"
|
||||
|
||||
# 检查sglang server
|
||||
python -m sglang.srt.server --help
|
||||
|
||||
# 检查CUDA支持
|
||||
python -c "import torch; print('PyTorch版本:', torch.__version__); print('CUDA可用:', torch.cuda.is_available()); print('CUDA设备数:', torch.cuda.device_count())"
|
||||
```
|
||||
|
||||
## 配置MinerU使用sglang
|
||||
|
||||
### 1. 修改配置文件
|
||||
编辑 `config.yml` 文件:
|
||||
|
||||
```yaml
|
||||
# MinerU配置
|
||||
mineru:
|
||||
backend: "vlm-sglang-engine" # 关键:启用sglang后端
|
||||
python_path: "./venv/bin/python"
|
||||
max_concurrent: 2 # GPU环境下建议降低并发数
|
||||
queue_size: 100
|
||||
batch_size: 1
|
||||
quality_level: "Balanced"
|
||||
```
|
||||
|
||||
### 2. 或者通过环境变量
|
||||
```bash
|
||||
# 设置环境变量
|
||||
export MINERU_BACKEND="vlm-sglang-engine"
|
||||
|
||||
# 启动服务
|
||||
document-parser server
|
||||
```
|
||||
|
||||
## 验证GPU加速是否生效
|
||||
|
||||
### 1. 启动服务并检查日志
|
||||
```bash
|
||||
# 启动服务
|
||||
document-parser server
|
||||
|
||||
# 在另一个终端查看日志
|
||||
tail -f logs/log.$(date +%Y-%m-%d)
|
||||
```
|
||||
|
||||
查找以下关键信息:
|
||||
```
|
||||
INFO 虚拟环境已自动激活
|
||||
INFO MinerU配置: backend=vlm-sglang-engine
|
||||
DEBUG MinerU完整命令: .../mineru -p input.pdf -o output -b vlm-sglang-engine
|
||||
```
|
||||
|
||||
### 2. 实时监控GPU使用
|
||||
```bash
|
||||
# 在另一个终端监控GPU
|
||||
watch -n 1 nvidia-smi
|
||||
|
||||
# 或者使用更详细的监控
|
||||
nvidia-smi dmon -s pucvmet -d 1
|
||||
```
|
||||
|
||||
### 3. 测试PDF解析
|
||||
上传一个PDF文件进行解析,观察:
|
||||
- GPU内存使用是否增加
|
||||
- GPU计算单元是否被占用
|
||||
- 解析速度是否明显提升
|
||||
|
||||
### 4. 检查进程
|
||||
```bash
|
||||
# 查看MinerU进程
|
||||
ps aux | grep mineru
|
||||
|
||||
# 查看GPU进程
|
||||
nvidia-smi pmon -c 1
|
||||
```
|
||||
|
||||
## 性能调优
|
||||
|
||||
### 1. 并发控制
|
||||
根据GPU内存调整并发数:
|
||||
```yaml
|
||||
mineru:
|
||||
max_concurrent: 1 # 8GB GPU内存
|
||||
max_concurrent: 2 # 16GB GPU内存
|
||||
max_concurrent: 4 # 24GB+ GPU内存
|
||||
```
|
||||
|
||||
### 2. 批处理大小
|
||||
```yaml
|
||||
mineru:
|
||||
batch_size: 1 # 小批次,适合大模型
|
||||
batch_size: 2 # 中等批次
|
||||
batch_size: 4 # 大批次,适合小模型
|
||||
```
|
||||
|
||||
### 3. 质量级别
|
||||
```yaml
|
||||
mineru:
|
||||
quality_level: "Fast" # 快速模式,GPU占用低
|
||||
quality_level: "Balanced" # 平衡模式(推荐)
|
||||
quality_level: "HighQuality" # 高质量模式,GPU占用高
|
||||
```
|
||||
|
||||
## 故障排除
|
||||
|
||||
### 1. sglang导入失败
|
||||
```bash
|
||||
# 检查Python版本
|
||||
python --version
|
||||
|
||||
# 重新安装sglang
|
||||
pip uninstall sglang -y
|
||||
pip install "sglang[all]"
|
||||
|
||||
# 检查依赖
|
||||
pip list | grep sglang
|
||||
```
|
||||
|
||||
### 2. CUDA不可用
|
||||
```bash
|
||||
# 检查PyTorch CUDA支持
|
||||
python -c "import torch; print(torch.cuda.is_available())"
|
||||
|
||||
# 如果返回False,重新安装PyTorch
|
||||
pip uninstall torch torchvision torchaudio -y
|
||||
pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu118
|
||||
```
|
||||
|
||||
### 3. GPU内存不足
|
||||
```bash
|
||||
# 检查GPU内存使用
|
||||
nvidia-smi
|
||||
|
||||
# 降低并发数和批处理大小
|
||||
# 关闭其他GPU进程
|
||||
```
|
||||
|
||||
### 4. 版本兼容性问题
|
||||
```bash
|
||||
# 检查transformers版本
|
||||
pip show transformers
|
||||
|
||||
# 安装兼容版本
|
||||
pip install "transformers>=4.36.0,<4.40.0"
|
||||
|
||||
# 重新安装sglang
|
||||
pip install "sglang[all]"
|
||||
```
|
||||
|
||||
## 性能基准测试
|
||||
|
||||
### 1. 测试文件
|
||||
使用不同大小的PDF文件测试性能:
|
||||
- 小文件(<1MB):测试启动时间
|
||||
- 中等文件(1-10MB):测试处理速度
|
||||
- 大文件(>10MB):测试内存使用
|
||||
|
||||
### 2. 性能指标
|
||||
- **启动时间**:从命令执行到开始处理的时间
|
||||
- **处理速度**:每秒处理的页数或字数
|
||||
- **GPU利用率**:GPU计算单元和内存的使用率
|
||||
- **内存使用**:GPU和系统内存的峰值使用
|
||||
|
||||
### 3. 对比测试
|
||||
```bash
|
||||
# 测试pipeline后端(CPU)
|
||||
mineru -p test.pdf -o output -b pipeline
|
||||
|
||||
# 测试sglang后端(GPU)
|
||||
mineru -p test.pdf -o output -b vlm-sglang-engine
|
||||
|
||||
# 对比处理时间和资源使用
|
||||
```
|
||||
|
||||
## 监控和维护
|
||||
|
||||
### 1. 定期检查
|
||||
```bash
|
||||
# 检查GPU健康状态
|
||||
nvidia-smi --query-gpu=health --format=csv
|
||||
|
||||
# 检查温度
|
||||
nvidia-smi --query-gpu=temperature.gpu --format=csv
|
||||
|
||||
# 检查电源使用
|
||||
nvidia-smi --query-gpu=power.draw --format=csv
|
||||
```
|
||||
|
||||
### 2. 日志分析
|
||||
```bash
|
||||
# 分析性能日志
|
||||
grep "processing_time" logs/log.* | awk '{print $NF}' | sort -n
|
||||
|
||||
# 分析错误日志
|
||||
grep "ERROR" logs/log.* | tail -20
|
||||
```
|
||||
|
||||
### 3. 性能优化
|
||||
- 根据实际使用情况调整并发参数
|
||||
- 监控GPU内存使用,避免OOM错误
|
||||
- 定期清理临时文件和缓存
|
||||
|
||||
## 常见问题
|
||||
|
||||
### Q: 为什么GPU加速没有生效?
|
||||
A: 检查以下几点:
|
||||
1. sglang是否正确安装
|
||||
2. 配置文件中的backend是否为"vlm-sglang-engine"
|
||||
3. CUDA环境是否可用
|
||||
4. GPU内存是否充足
|
||||
|
||||
### Q: 如何知道MinerU正在使用GPU?
|
||||
A: 通过以下方式确认:
|
||||
1. 查看nvidia-smi输出中的进程列表
|
||||
2. 观察GPU内存使用是否增加
|
||||
3. 检查日志中的命令参数
|
||||
4. 对比CPU和GPU模式的性能差异
|
||||
|
||||
### Q: GPU内存不足怎么办?
|
||||
A: 可以尝试:
|
||||
1. 降低max_concurrent参数
|
||||
2. 减小batch_size
|
||||
3. 使用"Fast"质量级别
|
||||
4. 关闭其他GPU进程
|
||||
|
||||
---
|
||||
|
||||
**注意**:本指南基于Linux环境编写,Windows用户可能需要调整部分命令。如有问题,请参考主用户手册或运行 `document-parser troubleshoot` 获取帮助。
|
||||
124
qiming-mcp-proxy/document-parser/Cargo.toml
Normal file
124
qiming-mcp-proxy/document-parser/Cargo.toml
Normal file
@@ -0,0 +1,124 @@
|
||||
[package]
|
||||
name = "document-parser"
|
||||
version = "0.1.28"
|
||||
edition = "2024"
|
||||
repository = "https://github.com/nuwax-ai/mcp-proxy"
|
||||
|
||||
[package.metadata.dist]
|
||||
dist = false
|
||||
|
||||
[dependencies]
|
||||
# HTTP框架和异步运行时
|
||||
axum = { workspace = true }
|
||||
tokio = { workspace = true, features = [
|
||||
"macros",
|
||||
"net",
|
||||
"rt",
|
||||
"rt-multi-thread",
|
||||
"signal",
|
||||
"io-util",
|
||||
"process",
|
||||
] }
|
||||
tokio-stream = "0.1"
|
||||
tokio-util = { version = "0.7", features = ["io"] }
|
||||
tower = { workspace = true }
|
||||
tower-http = { workspace = true, features = [
|
||||
"compression-full",
|
||||
"cors",
|
||||
"fs",
|
||||
"trace",
|
||||
] }
|
||||
|
||||
# 序列化和配置
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
serde_yaml = { workspace = true }
|
||||
|
||||
# OpenAPI文档生成
|
||||
utoipa = { workspace = true, features = ["axum_extras", "chrono", "uuid"] }
|
||||
utoipa-swagger-ui = { workspace = true, features = ["axum"] }
|
||||
|
||||
# 数据库和存储
|
||||
sled = "0.34"
|
||||
aliyun-oss-rust-sdk = { workspace = true }
|
||||
oss-client = { workspace = true }
|
||||
|
||||
# Markdown处理
|
||||
pulldown-cmark = "0.13"
|
||||
pulldown-cmark-toc = "0.7"
|
||||
|
||||
# 工具库
|
||||
anyhow = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
chrono = { workspace = true, features = ["serde"] }
|
||||
|
||||
# 国际化支持
|
||||
rust-i18n = { workspace = true }
|
||||
uuid = { workspace = true, features = ["v4", "v7", "serde"] }
|
||||
log = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
tracing-subscriber = { workspace = true }
|
||||
tracing-appender = { workspace = true }
|
||||
async-trait = "0.1"
|
||||
once_cell = "1.19"
|
||||
parking_lot = "0.12"
|
||||
derive_builder = { workspace = true }
|
||||
|
||||
# HTTP客户端
|
||||
reqwest = { workspace = true, features = ["stream", "json"] }
|
||||
http = { workspace = true }
|
||||
url = "2.5"
|
||||
|
||||
# 文件处理
|
||||
mime = "0.3"
|
||||
futures = { workspace = true }
|
||||
futures-util = "0.3"
|
||||
flate2 = "1.0"
|
||||
tar = "0.4"
|
||||
|
||||
# 配置管理
|
||||
clap = { workspace = true, features = ["derive", "env"] }
|
||||
dashmap.workspace = true
|
||||
moka = { workspace = true, features = ["future"] }
|
||||
regex = "1.11.1"
|
||||
tempfile = "3.20.0"
|
||||
sha2 = "0.10"
|
||||
async-recursion = "1.0"
|
||||
num_cpus = "1.16"
|
||||
rand = { workspace = true }
|
||||
pulldown-cmark-to-cmark = "21.0.0"
|
||||
|
||||
|
||||
# 系统调用(Unix系统)
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
libc = "0.2"
|
||||
|
||||
# Windows API
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
winapi = { version = "0.3", features = ["fileapi"] }
|
||||
|
||||
[dev-dependencies]
|
||||
criterion = { workspace = true }
|
||||
env_logger = "0.11"
|
||||
tokio-test = "0.4"
|
||||
quickcheck = "1.0"
|
||||
quickcheck_macros = "1.0"
|
||||
proptest = "1.4"
|
||||
axum-test = "17.3"
|
||||
mockall = "0.13"
|
||||
wiremock = "0.6"
|
||||
rstest = "0.26"
|
||||
test-case = "3.3"
|
||||
serial_test = "3.1"
|
||||
insta = "1.40"
|
||||
fake = { version = "4.4", features = ["derive", "chrono", "uuid"] }
|
||||
bytes = "1.0"
|
||||
|
||||
[[bench]]
|
||||
name = "document_parsing_bench"
|
||||
harness = false
|
||||
|
||||
# 命令行二进制配置
|
||||
[[bin]]
|
||||
name = "document-parser"
|
||||
path = "src/main.rs"
|
||||
201
qiming-mcp-proxy/document-parser/README.md
Normal file
201
qiming-mcp-proxy/document-parser/README.md
Normal file
@@ -0,0 +1,201 @@
|
||||
# Document Parser
|
||||
|
||||
**[English](README.md)** | **[简体中文](README_zh-CN.md)**
|
||||
|
||||
---
|
||||
|
||||
# Document Parser
|
||||
|
||||
A high-performance multi-format document parsing service supporting PDF, Word, Excel, and PowerPoint with GPU acceleration capabilities.
|
||||
|
||||
## Features
|
||||
|
||||
- 🚀 **High-Performance Parsing**: MinerU and MarkItDown dual-engine support
|
||||
- 🎯 **GPU Acceleration**: CUDA/sglang support for GPU acceleration (optional)
|
||||
- 🔧 **Zero-Configuration Deployment**: Automatic environment detection and dependency installation
|
||||
- 📚 **Multi-Format Support**: PDF, Word, Excel, PowerPoint, Markdown, and more
|
||||
- 🌐 **HTTP API**: RESTful API interface for easy integration
|
||||
- 📊 **Real-time Monitoring**: Built-in performance monitoring and health checks
|
||||
- ☁️ **OSS Integration**: Alibaba Cloud OSS support for cloud storage
|
||||
|
||||
## Quick Start
|
||||
|
||||
### 1. Environment Initialization
|
||||
|
||||
```bash
|
||||
cd document-parser
|
||||
|
||||
# Initialize uv virtual environment and dependencies (first time)
|
||||
document-parser uv-init
|
||||
|
||||
# Check environment status
|
||||
document-parser check
|
||||
```
|
||||
|
||||
### 2. Start Service
|
||||
|
||||
```bash
|
||||
# Start document parsing service
|
||||
document-parser server
|
||||
|
||||
# Or specify custom port
|
||||
document-parser server --port 8088
|
||||
```
|
||||
|
||||
The service will start at `http://localhost:8087` (default) and automatically activate the virtual environment.
|
||||
|
||||
## System Requirements
|
||||
|
||||
### Basic Requirements
|
||||
- **Rust**: 1.70+
|
||||
- **Python**: 3.8+
|
||||
- **uv**: Python package manager
|
||||
|
||||
### GPU Acceleration (Optional)
|
||||
- **NVIDIA GPU**: CUDA-compatible
|
||||
- **CUDA Toolkit**: 11.8+
|
||||
- **GPU Memory**: At least 8GB recommended
|
||||
|
||||
## Supported Formats
|
||||
|
||||
| Format | Parsing Engine | Features |
|
||||
|--------|----------------|----------|
|
||||
| PDF | MinerU | Professional PDF parsing, image extraction, table recognition |
|
||||
| Word | MarkItDown | Document structure preservation, format conversion |
|
||||
| Excel | MarkItDown | Table data extraction, format preservation |
|
||||
| PowerPoint | MarkItDown | Slide content extraction, image saving |
|
||||
| Markdown | Built-in | Real-time parsing, table of contents generation |
|
||||
|
||||
## Configuration
|
||||
|
||||
### Basic Configuration
|
||||
|
||||
```yaml
|
||||
# Server configuration
|
||||
server:
|
||||
port: 8087
|
||||
host: "0.0.0.0"
|
||||
|
||||
# MinerU configuration
|
||||
mineru:
|
||||
backend: "vlm-sglang-engine" # Enable GPU acceleration
|
||||
max_concurrent: 3
|
||||
quality_level: "Balanced"
|
||||
```
|
||||
|
||||
### GPU Acceleration Configuration
|
||||
|
||||
```yaml
|
||||
mineru:
|
||||
backend: "vlm-sglang-engine" # Use sglang backend
|
||||
max_concurrent: 2 # Lower concurrency for GPU
|
||||
batch_size: 1
|
||||
```
|
||||
|
||||
## Common Commands
|
||||
|
||||
```bash
|
||||
# Environment management
|
||||
document-parser check # Check environment status
|
||||
document-parser uv-init # Initialize environment
|
||||
document-parser troubleshoot # Troubleshooting guide
|
||||
|
||||
# Service management
|
||||
document-parser server # Start service
|
||||
document-parser server --port 8088 # Specify port
|
||||
|
||||
# File parsing (CLI)
|
||||
document-parser parse --input file.pdf --output result.md --parser mineru
|
||||
```
|
||||
|
||||
## API Usage
|
||||
|
||||
### Parse Document
|
||||
|
||||
```bash
|
||||
curl -X POST "http://localhost:8087/api/v1/documents/parse" \
|
||||
-H "Content-Type: multipart/form-data" \
|
||||
-F "file=@document.pdf" \
|
||||
-F "format=pdf"
|
||||
```
|
||||
|
||||
### Get Parsing Status
|
||||
|
||||
```bash
|
||||
curl "http://localhost:8087/api/v1/documents/{task_id}/status"
|
||||
```
|
||||
|
||||
### API Documentation
|
||||
|
||||
Once the service is running, visit:
|
||||
- **OpenAPI Swagger UI**: `http://localhost:8087/swagger-ui/`
|
||||
- **OpenAPI JSON**: `http://localhost:8087/api-docs/openapi.json`
|
||||
|
||||
## Performance Optimization
|
||||
|
||||
### GPU Acceleration
|
||||
|
||||
1. Ensure `sglang[all]` is installed
|
||||
2. Configure `backend: "vlm-sglang-engine"`
|
||||
3. Adjust concurrency parameters based on GPU memory
|
||||
4. Monitor GPU usage
|
||||
|
||||
### Concurrency Control
|
||||
|
||||
```yaml
|
||||
mineru:
|
||||
max_concurrent: 2 # Adjust based on system performance
|
||||
batch_size: 1 # Process in small batches
|
||||
queue_size: 100 # Queue buffer size
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
1. **Virtual environment not activated**: Run `source ./venv/bin/activate`
|
||||
2. **Dependency installation failed**: Run `document-parser uv-init`
|
||||
3. **GPU acceleration not working**: Refer to CUDA Environment Setup Guide
|
||||
4. **Permission issues**: Check directory and user permissions
|
||||
|
||||
### Get Help
|
||||
|
||||
```bash
|
||||
# Detailed troubleshooting guide
|
||||
document-parser troubleshoot
|
||||
|
||||
# Environment status check
|
||||
document-parser check
|
||||
|
||||
# View logs
|
||||
tail -f logs/log.$(date +%Y-%m-%d)
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
### Build
|
||||
|
||||
```bash
|
||||
cargo build --release
|
||||
```
|
||||
|
||||
### Test
|
||||
|
||||
```bash
|
||||
cargo test
|
||||
```
|
||||
|
||||
### Code Check
|
||||
|
||||
```bash
|
||||
cargo fmt
|
||||
cargo clippy
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under MIT License.
|
||||
|
||||
## Contributing
|
||||
|
||||
Issues and Pull Requests are welcome!
|
||||
205
qiming-mcp-proxy/document-parser/README_zh-CN.md
Normal file
205
qiming-mcp-proxy/document-parser/README_zh-CN.md
Normal file
@@ -0,0 +1,205 @@
|
||||
# Document Parser
|
||||
|
||||
**[English](README.md)** | **[简体中文](README_zh-CN.md)**
|
||||
|
||||
---
|
||||
|
||||
# Document Parser
|
||||
|
||||
一个高性能的多格式文档解析服务,支持PDF、Word、Excel、PowerPoint等格式,具备GPU加速能力。
|
||||
|
||||
## 特性
|
||||
|
||||
- 🚀 **高性能解析**:支持MinerU和MarkItDown双引擎
|
||||
- 🎯 **GPU加速**:通过sglang支持CUDA环境下的GPU加速
|
||||
- 🔧 **零配置部署**:自动环境检测和依赖安装
|
||||
- 📚 **多格式支持**:PDF、Word、Excel、PowerPoint、Markdown等
|
||||
- 🌐 **HTTP API**:提供RESTful API接口
|
||||
- 📊 **实时监控**:内置性能监控和健康检查
|
||||
- ☁️ **OSS集成**:支持阿里云对象存储
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 1. 环境初始化
|
||||
|
||||
```bash
|
||||
cd document-parser
|
||||
|
||||
# 初始化uv虚拟环境和依赖(首次使用)
|
||||
document-parser uv-init
|
||||
|
||||
# 检查环境状态
|
||||
document-parser check
|
||||
```
|
||||
|
||||
### 2. 启动服务
|
||||
|
||||
```bash
|
||||
# 启动文档解析服务
|
||||
document-parser server
|
||||
|
||||
# 或指定端口
|
||||
document-parser server --port 8088
|
||||
```
|
||||
|
||||
服务将在 `http://localhost:8087` 启动,并自动激活虚拟环境。
|
||||
|
||||
## 系统要求
|
||||
|
||||
### 基本要求
|
||||
- **Rust**: 1.70+
|
||||
- **Python**: 3.8+
|
||||
- **uv**: Python包管理器
|
||||
|
||||
### GPU加速要求(可选)
|
||||
- **NVIDIA GPU**: 支持CUDA
|
||||
- **CUDA Toolkit**: 11.8+
|
||||
- **GPU内存**: 至少8GB
|
||||
|
||||
## 支持的格式
|
||||
|
||||
| 格式 | 解析引擎 | 特性 |
|
||||
|------|----------|------|
|
||||
| PDF | MinerU | 专业PDF解析、图片提取、表格识别 |
|
||||
| Word | MarkItDown | 文档结构保持、格式转换 |
|
||||
| Excel | MarkItDown | 表格数据提取、格式保持 |
|
||||
| PowerPoint | MarkItDown | 幻灯片内容提取、图片保存 |
|
||||
| Markdown | 内置 | 实时解析、目录生成 |
|
||||
|
||||
## 配置说明
|
||||
|
||||
### 基本配置
|
||||
|
||||
```yaml
|
||||
# 服务器配置
|
||||
server:
|
||||
port: 8087
|
||||
host: "0.0.0.0"
|
||||
|
||||
# MinerU配置
|
||||
mineru:
|
||||
backend: "vlm-sglang-engine" # 启用GPU加速
|
||||
max_concurrent: 3
|
||||
quality_level: "Balanced"
|
||||
```
|
||||
|
||||
### GPU加速配置
|
||||
|
||||
```yaml
|
||||
mineru:
|
||||
backend: "vlm-sglang-engine" # 使用sglang后端
|
||||
max_concurrent: 2 # GPU环境下建议降低并发数
|
||||
batch_size: 1
|
||||
```
|
||||
|
||||
## 常用命令
|
||||
|
||||
```bash
|
||||
# 环境管理
|
||||
document-parser check # 检查环境状态
|
||||
document-parser uv-init # 初始化环境
|
||||
document-parser troubleshoot # 故障排除指南
|
||||
|
||||
# 服务管理
|
||||
document-parser server # 启动服务
|
||||
document-parser server --port 8088 # 指定端口
|
||||
|
||||
# 文件解析(命令行)
|
||||
document-parser parse --input file.pdf --output result.md --parser mineru
|
||||
```
|
||||
|
||||
## API使用
|
||||
|
||||
### 解析文档
|
||||
|
||||
```bash
|
||||
curl -X POST "http://localhost:8087/api/v1/documents/parse" \
|
||||
-H "Content-Type: multipart/form-data" \
|
||||
-F "file=@document.pdf" \
|
||||
-F "format=pdf"
|
||||
```
|
||||
|
||||
### 获取解析状态
|
||||
|
||||
```bash
|
||||
curl "http://localhost:8087/api/v1/documents/{task_id}/status"
|
||||
```
|
||||
|
||||
### API文档
|
||||
|
||||
服务启动后,访问:
|
||||
- **OpenAPI Swagger UI**: `http://localhost:8087/swagger-ui/`
|
||||
- **OpenAPI JSON**: `http://localhost:8087/api-docs/openapi.json`
|
||||
|
||||
## 性能优化
|
||||
|
||||
### GPU加速
|
||||
|
||||
1. 确保安装了 `sglang[all]`
|
||||
2. 配置 `backend: "vlm-sglang-engine"`
|
||||
3. 根据GPU内存调整并发参数
|
||||
4. 监控GPU使用情况
|
||||
|
||||
### 并发控制
|
||||
|
||||
```yaml
|
||||
mineru:
|
||||
max_concurrent: 2 # 根据系统性能调整
|
||||
batch_size: 1 # 小批次处理
|
||||
queue_size: 100 # 队列缓冲区大小
|
||||
```
|
||||
|
||||
## 故障排除
|
||||
|
||||
### 常见问题
|
||||
|
||||
1. **虚拟环境未激活**:运行 `source ./venv/bin/activate`
|
||||
2. **依赖安装失败**:运行 `document-parser uv-init`
|
||||
3. **GPU加速不生效**:参考CUDA环境配置指南
|
||||
4. **权限问题**:检查目录权限和用户权限
|
||||
|
||||
### 获取帮助
|
||||
|
||||
```bash
|
||||
# 详细故障排除指南
|
||||
document-parser troubleshoot
|
||||
|
||||
# 环境状态检查
|
||||
document-parser check
|
||||
|
||||
# 查看日志
|
||||
tail -f logs/log.$(date +%Y-%m-%d)
|
||||
```
|
||||
|
||||
## 开发
|
||||
|
||||
### 构建
|
||||
|
||||
```bash
|
||||
cargo build --release
|
||||
```
|
||||
|
||||
### 测试
|
||||
|
||||
```bash
|
||||
cargo test
|
||||
```
|
||||
|
||||
### 代码检查
|
||||
|
||||
```bash
|
||||
cargo fmt
|
||||
cargo clippy
|
||||
```
|
||||
|
||||
## 许可证
|
||||
|
||||
本项目采用 MIT 许可证。
|
||||
|
||||
## 贡献
|
||||
|
||||
欢迎提交Issue和Pull Request!
|
||||
|
||||
---
|
||||
|
||||
**注意**:首次使用请运行 `document-parser uv-init` 初始化环境。如需GPU加速,请参考CUDA环境配置指南。
|
||||
561
qiming-mcp-proxy/document-parser/TROUBLESHOOTING.md
Normal file
561
qiming-mcp-proxy/document-parser/TROUBLESHOOTING.md
Normal file
@@ -0,0 +1,561 @@
|
||||
# Document Parser 故障排除指南
|
||||
|
||||
本指南提供了Document Parser服务常见问题的详细解决方案,特别是关于虚拟环境和依赖管理的问题。
|
||||
|
||||
## 📋 目录
|
||||
|
||||
1. [FlashInfer编译失败](#flashinfer编译失败)
|
||||
2. [虚拟环境问题](#虚拟环境问题)
|
||||
3. [依赖安装问题](#依赖安装问题)
|
||||
4. [网络和下载问题](#网络和下载问题)
|
||||
5. [系统环境问题](#系统环境问题)
|
||||
6. [常用诊断命令](#常用诊断命令)
|
||||
7. [获取帮助](#获取帮助)
|
||||
|
||||
## 🚀 FlashInfer编译失败
|
||||
|
||||
### 问题: FlashInfer CUDA内核编译失败
|
||||
|
||||
**症状:**
|
||||
- MinerU启动时出现 `fatal error: math.h: 没有那个文件或目录` 错误
|
||||
- 错误发生在CUDA图捕获阶段
|
||||
- FlashInfer ninja构建失败
|
||||
|
||||
**原因:**
|
||||
系统缺少C标准库开发头文件,导致FlashInfer无法编译CUDA内核。这通常发生在Ubuntu 24.04等较新系统上。
|
||||
|
||||
**诊断步骤:**
|
||||
```bash
|
||||
# 检查系统头文件是否存在
|
||||
ls -la /usr/include/math.h
|
||||
ls -la /usr/include/c++/13/cmath
|
||||
|
||||
# 检查构建工具版本
|
||||
gcc --version
|
||||
ninja --version
|
||||
nvcc --version
|
||||
```
|
||||
|
||||
**解决方案:**
|
||||
|
||||
**步骤1: 安装缺失的开发包**
|
||||
```bash
|
||||
# 安装C标准库开发包
|
||||
sudo apt install -y libc6-dev libm-dev
|
||||
|
||||
# 安装C++标准库开发包
|
||||
sudo apt install -y libstdc++-13-dev
|
||||
|
||||
# 安装其他可能需要的包
|
||||
sudo apt install -y build-essential ninja-build cmake
|
||||
```
|
||||
|
||||
**步骤2: 设置CUDA环境变量**
|
||||
```bash
|
||||
# 设置CUDA相关环境变量
|
||||
export CUDA_HOME=/usr/local/cuda
|
||||
export PATH=$CUDA_HOME/bin:$PATH
|
||||
export LD_LIBRARY_PATH=$CUDA_HOME/lib64:$LD_LIBRARY_PATH
|
||||
|
||||
# 将这些环境变量添加到 ~/.bashrc 或 ~/.zshrc
|
||||
echo 'export CUDA_HOME=/usr/local/cuda' >> ~/.bashrc
|
||||
echo 'export PATH=$CUDA_HOME/bin:$PATH' >> ~/.bashrc
|
||||
echo 'export LD_LIBRARY_PATH=$CUDA_HOME/lib64:$LD_LIBRARY_PATH' >> ~/.bashrc
|
||||
```
|
||||
|
||||
**步骤3: 清理缓存并重新安装**
|
||||
```bash
|
||||
# 清理FlashInfer缓存
|
||||
rm -rf ~/.cache/flashinfer
|
||||
|
||||
# 重新安装MinerU(确保包含正确的sglang版本)
|
||||
pip uninstall mineru sglang -y
|
||||
pip install -U "mineru[all]" -i https://mirrors.aliyun.com/pypi/simple
|
||||
```
|
||||
|
||||
**验证修复:**
|
||||
```bash
|
||||
# 检查头文件是否存在
|
||||
ls -la /usr/include/math.h
|
||||
ls -la /usr/include/c++/13/cmath
|
||||
|
||||
# 检查CUDA头文件
|
||||
ls -la /usr/local/cuda/include/cuda_runtime.h
|
||||
|
||||
# 重新启动服务测试
|
||||
document-parser server
|
||||
```
|
||||
|
||||
**如果问题仍然存在:**
|
||||
```bash
|
||||
# 尝试使用系统ninja而不是虚拟环境中的ninja
|
||||
which ninja
|
||||
# 如果显示虚拟环境路径,使用系统ninja
|
||||
sudo apt install -y ninja-build
|
||||
export PATH=/usr/bin:$PATH
|
||||
|
||||
# 或者尝试禁用CUDA图功能(性能会下降)
|
||||
# 在启动MinerU时添加 --disable-cuda-graph 参数
|
||||
```
|
||||
|
||||
## 🏠 虚拟环境问题
|
||||
|
||||
### 问题1: 虚拟环境创建失败
|
||||
|
||||
**症状:**
|
||||
- `document-parser uv-init` 失败
|
||||
- 错误信息包含 "权限拒绝" 或 "Permission denied"
|
||||
- 无法在当前目录创建 `venv` 文件夹
|
||||
|
||||
**诊断步骤:**
|
||||
```bash
|
||||
# 检查当前目录权限
|
||||
ls -la # Linux/macOS
|
||||
dir # Windows
|
||||
|
||||
# 检查磁盘空间
|
||||
df -h . # Linux/macOS
|
||||
dir # Windows
|
||||
|
||||
# 检查是否存在同名文件
|
||||
ls -la venv # Linux/macOS
|
||||
dir venv # Windows
|
||||
```
|
||||
|
||||
**解决方案:**
|
||||
|
||||
**Linux/macOS:**
|
||||
```bash
|
||||
# 修改目录权限
|
||||
chmod 755 .
|
||||
|
||||
# 修改目录所有者
|
||||
chown $USER .
|
||||
|
||||
# 删除现有的venv文件(如果存在)
|
||||
rm -rf ./venv
|
||||
|
||||
# 重新初始化
|
||||
document-parser uv-init
|
||||
```
|
||||
|
||||
**Windows:**
|
||||
```cmd
|
||||
# 以管理员身份运行命令提示符
|
||||
# 删除现有的venv目录
|
||||
rmdir /s .\venv
|
||||
|
||||
# 重新初始化
|
||||
document-parser uv-init
|
||||
```
|
||||
|
||||
### 问题2: 虚拟环境激活失败
|
||||
|
||||
**症状:**
|
||||
- 激活命令执行后没有效果
|
||||
- 命令提示符没有显示 `(venv)` 前缀
|
||||
- Python路径仍指向系统Python
|
||||
|
||||
**解决方案:**
|
||||
|
||||
**Linux/macOS (Bash/Zsh):**
|
||||
```bash
|
||||
# 标准激活方式
|
||||
source ./venv/bin/activate
|
||||
|
||||
# 检查是否激活成功
|
||||
which python
|
||||
python --version
|
||||
```
|
||||
|
||||
**Linux/macOS (Fish Shell):**
|
||||
```bash
|
||||
# Fish shell激活方式
|
||||
source ./venv/bin/activate.fish
|
||||
```
|
||||
|
||||
**Windows (CMD):**
|
||||
```cmd
|
||||
# 激活虚拟环境
|
||||
.\venv\Scripts\activate
|
||||
|
||||
# 检查是否激活成功
|
||||
where python
|
||||
python --version
|
||||
```
|
||||
|
||||
**Windows (PowerShell):**
|
||||
```powershell
|
||||
# 如果遇到执行策略限制
|
||||
Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser
|
||||
|
||||
# 激活虚拟环境
|
||||
.\venv\Scripts\Activate.ps1
|
||||
|
||||
# 检查是否激活成功
|
||||
Get-Command python
|
||||
python --version
|
||||
```
|
||||
|
||||
### 问题3: 虚拟环境路径问题
|
||||
|
||||
**症状:**
|
||||
- 找不到Python可执行文件
|
||||
- 路径指向错误的位置
|
||||
- 跨平台兼容性问题
|
||||
|
||||
**解决方案:**
|
||||
```bash
|
||||
# 检查虚拟环境结构
|
||||
ls -la ./venv/bin/ # Linux/macOS
|
||||
dir .\venv\Scripts\ # Windows
|
||||
|
||||
# 手动验证Python路径
|
||||
./venv/bin/python --version # Linux/macOS
|
||||
.\venv\Scripts\python --version # Windows
|
||||
|
||||
# 如果路径错误,重新创建虚拟环境
|
||||
rm -rf ./venv # Linux/macOS
|
||||
rmdir /s .\venv # Windows
|
||||
document-parser uv-init
|
||||
```
|
||||
|
||||
## 📦 依赖安装问题
|
||||
|
||||
### 问题1: UV工具未安装或不可用
|
||||
|
||||
**症状:**
|
||||
- `uv: command not found`
|
||||
- UV版本过旧
|
||||
- UV安装路径问题
|
||||
|
||||
**解决方案:**
|
||||
|
||||
**官方安装脚本 (推荐):**
|
||||
```bash
|
||||
# Linux/macOS
|
||||
curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||
|
||||
# 重启终端或重新加载shell配置
|
||||
source ~/.bashrc # 或 ~/.zshrc
|
||||
```
|
||||
|
||||
**包管理器安装:**
|
||||
```bash
|
||||
# macOS
|
||||
brew install uv
|
||||
|
||||
# 通过pip安装
|
||||
pip install uv
|
||||
|
||||
# Windows (winget)
|
||||
winget install astral-sh.uv
|
||||
```
|
||||
|
||||
**验证安装:**
|
||||
```bash
|
||||
uv --version
|
||||
which uv # Linux/macOS
|
||||
where uv # Windows
|
||||
```
|
||||
|
||||
### 问题2: MinerU或MarkItDown安装失败
|
||||
|
||||
**症状:**
|
||||
- 包下载超时
|
||||
- 编译错误
|
||||
- 依赖冲突
|
||||
|
||||
**诊断步骤:**
|
||||
```bash
|
||||
# 检查网络连接
|
||||
ping pypi.org
|
||||
|
||||
# 检查Python版本
|
||||
python --version
|
||||
|
||||
# 检查虚拟环境中的pip
|
||||
./venv/bin/pip --version # Linux/macOS
|
||||
.\venv\Scripts\pip --version # Windows
|
||||
```
|
||||
|
||||
**解决方案:**
|
||||
|
||||
**使用国内镜像源:**
|
||||
```bash
|
||||
# 清华大学镜像源
|
||||
uv pip install -i https://pypi.tuna.tsinghua.edu.cn/simple/ mineru[core]
|
||||
uv pip install -i https://pypi.tuna.tsinghua.edu.cn/simple/ markitdown
|
||||
|
||||
# 阿里云镜像源
|
||||
uv pip install -i https://mirrors.aliyun.com/pypi/simple/ mineru[core]
|
||||
```
|
||||
|
||||
**分步安装:**
|
||||
```bash
|
||||
# 1. 升级pip
|
||||
uv pip install --upgrade pip
|
||||
|
||||
# 2. 安装基础依赖
|
||||
uv pip install wheel setuptools
|
||||
|
||||
# 3. 分别安装包
|
||||
uv pip install mineru[core]
|
||||
uv pip install markitdown
|
||||
```
|
||||
|
||||
**增加超时时间:**
|
||||
```bash
|
||||
uv pip install --timeout 300 mineru[core]
|
||||
```
|
||||
|
||||
**清理缓存后重试:**
|
||||
```bash
|
||||
uv cache clean
|
||||
document-parser uv-init
|
||||
```
|
||||
|
||||
## 🌐 网络和下载问题
|
||||
|
||||
### 问题1: 网络连接超时
|
||||
|
||||
**症状:**
|
||||
- 下载包时超时
|
||||
- 连接PyPI失败
|
||||
- DNS解析问题
|
||||
|
||||
**解决方案:**
|
||||
|
||||
**检查网络连接:**
|
||||
```bash
|
||||
# 测试基本连接
|
||||
ping pypi.org
|
||||
ping pypi.tuna.tsinghua.edu.cn
|
||||
|
||||
# 测试HTTPS连接
|
||||
curl -I https://pypi.org/simple/
|
||||
```
|
||||
|
||||
**配置代理 (如果需要):**
|
||||
```bash
|
||||
# 设置HTTP代理
|
||||
export HTTP_PROXY=http://proxy.company.com:8080
|
||||
export HTTPS_PROXY=http://proxy.company.com:8080
|
||||
|
||||
# Windows
|
||||
set HTTP_PROXY=http://proxy.company.com:8080
|
||||
set HTTPS_PROXY=http://proxy.company.com:8080
|
||||
```
|
||||
|
||||
**使用镜像源:**
|
||||
```bash
|
||||
# 配置uv使用镜像源
|
||||
uv pip install --index-url https://pypi.tuna.tsinghua.edu.cn/simple/ mineru[core]
|
||||
```
|
||||
|
||||
### 问题2: 防火墙或安全软件阻止
|
||||
|
||||
**解决方案:**
|
||||
- 临时关闭防火墙进行测试
|
||||
- 将Python和uv添加到防火墙白名单
|
||||
- 检查企业网络策略
|
||||
- 联系网络管理员获取帮助
|
||||
|
||||
## ⚙️ 系统环境问题
|
||||
|
||||
### 问题1: Python版本不兼容
|
||||
|
||||
**症状:**
|
||||
- Python版本低于3.8
|
||||
- 缺少必要的Python模块
|
||||
- 系统Python与虚拟环境冲突
|
||||
|
||||
**检查Python版本:**
|
||||
```bash
|
||||
python --version
|
||||
python3 --version
|
||||
```
|
||||
|
||||
**解决方案:**
|
||||
|
||||
**Linux (Ubuntu/Debian):**
|
||||
```bash
|
||||
# 安装Python 3.11
|
||||
sudo apt update
|
||||
sudo apt install python3.11 python3.11-venv python3.11-pip
|
||||
|
||||
# 使用特定版本创建虚拟环境
|
||||
python3.11 -m venv ./venv
|
||||
```
|
||||
|
||||
**macOS:**
|
||||
```bash
|
||||
# 使用Homebrew安装
|
||||
brew install python@3.11
|
||||
|
||||
# 或使用pyenv管理多版本
|
||||
brew install pyenv
|
||||
pyenv install 3.11.0
|
||||
pyenv local 3.11.0
|
||||
```
|
||||
|
||||
**Windows:**
|
||||
- 从 [python.org](https://python.org) 下载并安装Python 3.11+
|
||||
- 确保勾选 "Add Python to PATH"
|
||||
- 重启命令提示符
|
||||
|
||||
### 问题2: CUDA环境配置 (可选)
|
||||
|
||||
**检查CUDA:**
|
||||
```bash
|
||||
nvidia-smi
|
||||
nvcc --version
|
||||
```
|
||||
|
||||
**安装CUDA (如果需要GPU加速):**
|
||||
- 安装NVIDIA驱动程序
|
||||
- 下载并安装CUDA Toolkit (推荐11.8或12.x)
|
||||
- 验证安装: `nvidia-smi` 和 `nvcc --version`
|
||||
|
||||
**注意:** CPU模式也可正常工作,GPU仅用于加速。
|
||||
|
||||
## 🔍 常用诊断命令
|
||||
|
||||
### 环境检查命令
|
||||
|
||||
```bash
|
||||
# 完整环境检查
|
||||
document-parser check
|
||||
|
||||
# 详细故障排除指南
|
||||
document-parser troubleshoot
|
||||
|
||||
# 重新初始化环境
|
||||
document-parser uv-init
|
||||
```
|
||||
|
||||
### 手动验证命令
|
||||
|
||||
```bash
|
||||
# 检查工具版本
|
||||
uv --version
|
||||
python --version
|
||||
|
||||
# 检查虚拟环境
|
||||
./venv/bin/python --version # Linux/macOS
|
||||
.\venv\Scripts\python --version # Windows
|
||||
|
||||
# 检查已安装的包
|
||||
./venv/bin/pip list # Linux/macOS
|
||||
.\venv\Scripts\pip list # Windows
|
||||
|
||||
# 测试MinerU
|
||||
./venv/bin/mineru --help # Linux/macOS
|
||||
.\venv\Scripts\mineru --help # Windows
|
||||
|
||||
# 测试MarkItDown
|
||||
./venv/bin/python -m markitdown --help # Linux/macOS
|
||||
.\venv\Scripts\python -m markitdown --help # Windows
|
||||
```
|
||||
|
||||
### 日志查看
|
||||
|
||||
```bash
|
||||
# 查看当天日志 (Linux/macOS)
|
||||
tail -f logs/log.$(date +%Y-%m-%d)
|
||||
|
||||
# 查看最新日志
|
||||
ls -la logs/
|
||||
tail -f logs/log.*
|
||||
|
||||
# Windows
|
||||
dir logs\
|
||||
type logs\log.%date:~0,10%
|
||||
```
|
||||
|
||||
### 清理和重置
|
||||
|
||||
```bash
|
||||
# 清理UV缓存
|
||||
uv cache clean
|
||||
|
||||
# 完全重置虚拟环境
|
||||
rm -rf ./venv # Linux/macOS
|
||||
rmdir /s .\venv # Windows
|
||||
document-parser uv-init
|
||||
|
||||
# 清理日志文件
|
||||
rm -rf logs/* # Linux/macOS
|
||||
del /q logs\* # Windows
|
||||
```
|
||||
|
||||
## 🆘 获取帮助
|
||||
|
||||
### 自助诊断步骤
|
||||
|
||||
1. **运行诊断命令:**
|
||||
```bash
|
||||
document-parser check
|
||||
document-parser troubleshoot
|
||||
```
|
||||
|
||||
2. **收集系统信息:**
|
||||
- 操作系统版本
|
||||
- Python版本
|
||||
- 当前工作目录
|
||||
- 完整的错误消息
|
||||
|
||||
3. **检查日志文件:**
|
||||
```bash
|
||||
ls -la logs/
|
||||
tail -100 logs/log.*
|
||||
```
|
||||
|
||||
4. **尝试在新目录中测试:**
|
||||
```bash
|
||||
mkdir test-document-parser
|
||||
cd test-document-parser
|
||||
document-parser uv-init
|
||||
```
|
||||
|
||||
### 常见解决方案总结
|
||||
|
||||
| 问题类型 | 快速解决方案 |
|
||||
|---------|-------------|
|
||||
| 权限问题 | `chmod 755 .` (Linux/macOS) 或以管理员身份运行 (Windows) |
|
||||
| 网络问题 | 使用镜像源: `-i https://pypi.tuna.tsinghua.edu.cn/simple/` |
|
||||
| 虚拟环境损坏 | `rm -rf ./venv && document-parser uv-init` |
|
||||
| UV未安装 | `curl -LsSf https://astral.sh/uv/install.sh \| sh` |
|
||||
| Python版本过旧 | 安装Python 3.8+ |
|
||||
| 磁盘空间不足 | 清理磁盘,确保至少500MB可用空间 |
|
||||
|
||||
### 最后的建议
|
||||
|
||||
如果所有方法都无法解决问题:
|
||||
|
||||
1. **完全重新开始:**
|
||||
```bash
|
||||
# 创建新的工作目录
|
||||
mkdir fresh-document-parser
|
||||
cd fresh-document-parser
|
||||
|
||||
# 重新初始化
|
||||
document-parser uv-init
|
||||
```
|
||||
|
||||
2. **检查系统限制:**
|
||||
- 企业网络策略
|
||||
- 防病毒软件设置
|
||||
- 磁盘配额限制
|
||||
- 用户权限限制
|
||||
|
||||
3. **寻求帮助时提供:**
|
||||
- 完整的错误消息
|
||||
- 系统信息 (`uname -a` 或 `systeminfo`)
|
||||
- Python版本 (`python --version`)
|
||||
- 执行的完整命令序列
|
||||
- 相关日志文件内容
|
||||
|
||||
---
|
||||
|
||||
**记住:** 大多数问题都可以通过重新运行 `document-parser uv-init` 来解决。这个命令会自动检测和修复常见的环境问题。
|
||||
308
qiming-mcp-proxy/document-parser/USER_MANUAL.md
Normal file
308
qiming-mcp-proxy/document-parser/USER_MANUAL.md
Normal file
@@ -0,0 +1,308 @@
|
||||
# Document Parser 用户使用手册
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 系统依赖安装
|
||||
|
||||
|
||||
```shell
|
||||
|
||||
sudo apt update
|
||||
sudo apt install --reinstall build-essential libc6-dev linux-libc-dev
|
||||
|
||||
|
||||
sudo apt install gcc-multilib g++-multilib
|
||||
|
||||
|
||||
```
|
||||
|
||||
|
||||
### 1. 环境初始化
|
||||
```bash
|
||||
# 在当前目录初始化uv虚拟环境和依赖
|
||||
document-parser uv-init
|
||||
```
|
||||
|
||||
这个命令会:
|
||||
- 检查并安装uv工具
|
||||
- 创建虚拟环境 `./venv/`
|
||||
- 安装MinerU和MarkItDown依赖
|
||||
- 自动检测CUDA环境并安装相应版本
|
||||
|
||||
### 2. 启动服务
|
||||
```bash
|
||||
# 启动文档解析服务
|
||||
document-parser server
|
||||
```
|
||||
|
||||
服务启动后会自动:
|
||||
- 激活虚拟环境
|
||||
- 检查环境状态
|
||||
- 启动HTTP服务器(默认端口8087)
|
||||
|
||||
## CUDA环境配置(GPU加速)
|
||||
|
||||
### 1. 检查CUDA环境
|
||||
```bash
|
||||
# 检查NVIDIA驱动和CUDA
|
||||
nvidia-smi
|
||||
|
||||
# 检查CUDA版本
|
||||
nvcc --version
|
||||
```
|
||||
|
||||
### 2. 手动安装sglang(GPU加速必需)
|
||||
|
||||
**重要**:不要直接安装sglang,应该使用MinerU官方推荐的安装方式,确保版本兼容性。
|
||||
|
||||
```bash
|
||||
# 激活虚拟环境
|
||||
source ./venv/bin/activate
|
||||
|
||||
# 使用MinerU官方命令安装(推荐)
|
||||
uv pip install -U "mineru[all]" -i https://mirrors.aliyun.com/pypi/simple
|
||||
|
||||
# 或者使用pip安装
|
||||
pip install -U "mineru[all]" -i https://mirrors.aliyun.com/pypi/simple
|
||||
```
|
||||
|
||||
**注意**:`mineru[all]` 会自动安装兼容的sglang版本,避免版本冲突问题。
|
||||
|
||||
### 3. 验证sglang安装
|
||||
```bash
|
||||
# 检查sglang版本
|
||||
python -c "import sglang; print('SGLang版本:', sglang.__version__)"
|
||||
|
||||
# 检查sglang server是否可用
|
||||
python -m sglang.srt.server --help
|
||||
```
|
||||
|
||||
### 4. 验证CUDA编译器头文件查找
|
||||
```bash
|
||||
# 测试CUDA编译器是否能找到math.h头文件
|
||||
nvcc -v -x cu - -o /dev/null <<< '#include <math.h>'
|
||||
|
||||
# 如果成功,应该显示编译信息
|
||||
# 如果失败,会显示 "fatal error: math.h: 没有那个文件或目录"
|
||||
```
|
||||
|
||||
## 配置MinerU使用sglang加速
|
||||
|
||||
### 1. 修改配置文件
|
||||
编辑 `config.yml` 文件:
|
||||
|
||||
```yaml
|
||||
# MinerU配置
|
||||
mineru:
|
||||
backend: "vlm-sglang-engine" # 启用sglang后端以支持GPU加速
|
||||
python_path: "./venv/bin/python"
|
||||
max_concurrent: 3
|
||||
queue_size: 100
|
||||
batch_size: 1
|
||||
quality_level: "Balanced"
|
||||
```
|
||||
|
||||
### 2. 或者通过命令行指定
|
||||
```bash
|
||||
# 启动服务时指定后端
|
||||
document-parser server --mineru-backend vlm-sglang-engine
|
||||
```
|
||||
|
||||
## 验证MinerU是否使用sglang加速
|
||||
|
||||
### 1. 检查服务日志
|
||||
启动服务后,查看日志中是否有以下信息:
|
||||
```
|
||||
INFO 虚拟环境已自动激活
|
||||
INFO MinerU配置: backend=vlm-sglang-engine
|
||||
```
|
||||
|
||||
### 2. 测试PDF解析
|
||||
上传一个PDF文件进行解析,查看日志输出:
|
||||
```
|
||||
DEBUG MinerU完整命令: .../mineru -p input.pdf -o output -b vlm-sglang-engine
|
||||
```
|
||||
|
||||
### 3. 检查GPU使用情况
|
||||
在另一个终端中运行:
|
||||
```bash
|
||||
# 实时监控GPU使用
|
||||
watch -n 1 nvidia-smi
|
||||
|
||||
# 或者使用htop查看进程
|
||||
htop
|
||||
```
|
||||
|
||||
如果看到MinerU进程占用GPU资源,说明sglang加速正常工作。
|
||||
|
||||
## 故障排除
|
||||
|
||||
### 1. sglang安装失败
|
||||
```bash
|
||||
# 检查Python版本(需要3.8+)
|
||||
python --version
|
||||
|
||||
# 检查pip版本
|
||||
pip --version
|
||||
|
||||
# 尝试升级pip
|
||||
pip install --upgrade pip
|
||||
|
||||
# 重新安装sglang
|
||||
pip uninstall sglang -y
|
||||
pip install "sglang[all]"
|
||||
```
|
||||
|
||||
### 2. 版本兼容性问题
|
||||
如果遇到transformers版本兼容问题:
|
||||
```bash
|
||||
# 安装兼容的transformers版本
|
||||
pip install "transformers>=4.36.0,<4.40.0"
|
||||
|
||||
# 重新安装MinerU(会自动安装兼容的sglang版本)
|
||||
pip install -U "mineru[all]" -i https://mirrors.aliyun.com/pypi/simple
|
||||
```
|
||||
|
||||
### 3. CUDA编译器头文件问题
|
||||
如果遇到 `fatal error: math.h: 没有那个文件或目录` 错误:
|
||||
|
||||
```bash
|
||||
# 验证CUDA编译器是否能找到math.h头文件
|
||||
/usr/bin/nvcc -v -x cu - -o /dev/null <<< '#include <math.h>'
|
||||
|
||||
# 如果失败,安装缺失的开发包
|
||||
sudo apt install -y libc6-dev libstdc++-13-dev
|
||||
|
||||
# 设置正确的CUDA环境变量
|
||||
export CUDA_HOME=/usr
|
||||
export PATH=/usr/lib/cuda/bin:$PATH
|
||||
export LD_LIBRARY_PATH=/usr/lib/cuda/lib64:$LD_LIBRARY_PATH
|
||||
|
||||
# 清理FlashInfer缓存
|
||||
rm -rf ~/.cache/flashinfer
|
||||
```
|
||||
|
||||
**重要提示**:如果之前直接安装了sglang,建议先卸载再重新安装MinerU:
|
||||
```bash
|
||||
# 卸载可能不兼容的sglang版本
|
||||
pip uninstall sglang -y
|
||||
|
||||
# 重新安装MinerU(包含兼容的sglang)
|
||||
pip install -U "mineru[all]" -i https://mirrors.aliyun.com/pypi/simple
|
||||
```
|
||||
|
||||
### 3. 虚拟环境问题
|
||||
```bash
|
||||
# 检查虚拟环境状态
|
||||
document-parser check
|
||||
|
||||
# 重新初始化环境
|
||||
rm -rf ./venv
|
||||
document-parser uv-init
|
||||
```
|
||||
|
||||
### 4. 权限问题
|
||||
```bash
|
||||
# 检查目录权限
|
||||
ls -la
|
||||
|
||||
# 修改权限(如果需要)
|
||||
chmod 755 .
|
||||
chown $USER .
|
||||
```
|
||||
|
||||
## 常用命令
|
||||
|
||||
### 环境诊断
|
||||
```bash
|
||||
# 验证CUDA编译器头文件查找
|
||||
/usr/bin/nvcc -v -x cu - -o /dev/null <<< '#include <math.h>'
|
||||
|
||||
# 检查系统头文件是否存在
|
||||
ls -la /usr/include/math.h
|
||||
ls -la /usr/include/c++/13/cmath
|
||||
|
||||
# 检查CUDA安装路径
|
||||
find /usr -name "cuda_runtime.h" 2>/dev/null
|
||||
which nvcc
|
||||
```
|
||||
|
||||
### 环境管理
|
||||
```bash
|
||||
# 检查环境状态
|
||||
document-parser check
|
||||
|
||||
# 显示故障排除指南
|
||||
document-parser troubleshoot
|
||||
|
||||
# 重新初始化环境
|
||||
document-parser uv-init
|
||||
```
|
||||
|
||||
### 服务管理
|
||||
```bash
|
||||
# 启动服务(默认端口8087)
|
||||
document-parser server
|
||||
|
||||
# 指定端口启动
|
||||
document-parser server --port 8088
|
||||
|
||||
# 指定配置文件
|
||||
document-parser server --config custom_config.yml
|
||||
```
|
||||
|
||||
### 文件解析
|
||||
```bash
|
||||
# 解析单个文件
|
||||
document-parser parse --input input.pdf --output output.md --parser mineru
|
||||
```
|
||||
|
||||
## 性能优化建议
|
||||
|
||||
### 1. GPU加速
|
||||
- 确保安装了 `sglang[all]`
|
||||
- 使用 `vlm-sglang-engine` 后端
|
||||
- 监控GPU内存使用情况
|
||||
|
||||
### 2. 并发控制
|
||||
根据服务器性能调整配置:
|
||||
```yaml
|
||||
mineru:
|
||||
max_concurrent: 2 # 根据GPU内存调整
|
||||
batch_size: 1 # 小批次处理
|
||||
```
|
||||
|
||||
### 3. 超时设置
|
||||
```yaml
|
||||
document_parser:
|
||||
processing_timeout: 3600 # 60分钟超时
|
||||
```
|
||||
|
||||
## 日志查看
|
||||
|
||||
### 1. 实时日志
|
||||
```bash
|
||||
# 查看当天日志
|
||||
tail -f logs/log.$(date +%Y-%m-%d)
|
||||
|
||||
# 查看最新日志
|
||||
tail -f logs/log.*
|
||||
```
|
||||
|
||||
### 2. 日志级别
|
||||
在 `config.yml` 中调整日志级别:
|
||||
```yaml
|
||||
log:
|
||||
level: "debug" # 可选: debug, info, warn, error
|
||||
```
|
||||
|
||||
## 联系支持
|
||||
|
||||
如果遇到问题:
|
||||
1. 运行 `document-parser troubleshoot` 查看详细指南
|
||||
2. 检查日志文件获取错误信息
|
||||
3. 确保环境配置正确(Python版本、CUDA版本等)
|
||||
|
||||
---
|
||||
|
||||
**注意**:本手册基于当前版本编写,如有更新请参考最新文档。
|
||||
@@ -0,0 +1,46 @@
|
||||
use criterion::{Criterion, criterion_group, criterion_main};
|
||||
use document_parser::models::{DocumentFormat, ParserEngine};
|
||||
|
||||
fn document_parsing_benchmark(c: &mut Criterion) {
|
||||
let mut group = c.benchmark_group("document_parsing");
|
||||
|
||||
group.bench_function("format_detection", |b| {
|
||||
b.iter(|| {
|
||||
let formats = vec![
|
||||
"test.pdf",
|
||||
"document.docx",
|
||||
"spreadsheet.xlsx",
|
||||
"presentation.pptx",
|
||||
"image.jpg",
|
||||
"audio.mp3",
|
||||
];
|
||||
|
||||
for file_path in formats {
|
||||
let _format =
|
||||
DocumentFormat::from_extension(file_path.split('.').next_back().unwrap_or(""));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
group.bench_function("engine_selection", |b| {
|
||||
b.iter(|| {
|
||||
let formats = vec![
|
||||
DocumentFormat::PDF,
|
||||
DocumentFormat::Word,
|
||||
DocumentFormat::Excel,
|
||||
DocumentFormat::PowerPoint,
|
||||
DocumentFormat::Image,
|
||||
DocumentFormat::Audio,
|
||||
];
|
||||
|
||||
for format in formats {
|
||||
let _engine = ParserEngine::select_for_format(&format);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
group.finish();
|
||||
}
|
||||
|
||||
criterion_group!(benches, document_parsing_benchmark);
|
||||
criterion_main!(benches);
|
||||
5
qiming-mcp-proxy/document-parser/build.rs
Normal file
5
qiming-mcp-proxy/document-parser/build.rs
Normal file
@@ -0,0 +1,5 @@
|
||||
fn main() {
|
||||
println!("cargo:rerun-if-changed=locales/en.yml");
|
||||
println!("cargo:rerun-if-changed=locales/zh-CN.yml");
|
||||
println!("cargo:rerun-if-changed=locales/zh-TW.yml");
|
||||
}
|
||||
79
qiming-mcp-proxy/document-parser/config.yml
Normal file
79
qiming-mcp-proxy/document-parser/config.yml
Normal file
@@ -0,0 +1,79 @@
|
||||
# 环境配置
|
||||
environment: "development"
|
||||
|
||||
server:
|
||||
port: 8087
|
||||
host: "0.0.0.0"
|
||||
|
||||
log:
|
||||
level: "info"
|
||||
path: "logs"
|
||||
# The number of log files to retain (default: 20)
|
||||
retain_days: 20
|
||||
|
||||
# 文档解析配置
|
||||
document_parser:
|
||||
# 并发控制
|
||||
max_concurrent: 5
|
||||
queue_size: 1000
|
||||
|
||||
# 超时设置(统一超时配置)
|
||||
download_timeout: 3600
|
||||
processing_timeout: 3600 # 60分钟,用于MinerU和MarkItDown解析超时
|
||||
|
||||
# MinerU配置
|
||||
mineru:
|
||||
# 后端选择:pipeline|vlm-transformers|vlm-sglang-engine|vlm-sglang-client, 有cuda环境目前推荐: vlm-transformers
|
||||
backend: "pipeline"
|
||||
python_path: "./venv/bin/python" # 默认使用虚拟环境,如不存在则自动检测系统Python
|
||||
# 这个是mineru的并发数,目前没用,占位使用,只需要小于document_parser.max_concurrent 即可
|
||||
max_concurrent: 1
|
||||
queue_size: 100
|
||||
# timeout 已移除,统一使用 document_parser.processing_timeout (3600秒)
|
||||
batch_size: 1
|
||||
quality_level: "Balanced"
|
||||
# 单进程最大GPU显存占用(GB),仅对pipeline后端,这个是一个软上限
|
||||
vram: 8
|
||||
|
||||
# MarkItDown配置
|
||||
markitdown:
|
||||
python_path: "./venv/bin/python" # 默认使用虚拟环境,如不存在则自动检测系统Python
|
||||
# timeout 已移除,统一使用 document_parser.processing_timeout (3600秒)
|
||||
enable_plugins: false
|
||||
|
||||
# 功能开关
|
||||
features:
|
||||
ocr: true
|
||||
audio_transcription: true
|
||||
azure_doc_intel: false
|
||||
youtube_transcription: false
|
||||
|
||||
# 存储配置
|
||||
storage:
|
||||
# Sled数据库配置
|
||||
sled:
|
||||
path: "data/document_parser"
|
||||
cache_capacity: 104857600 # 100MB
|
||||
|
||||
# OSS配置
|
||||
oss:
|
||||
endpoint: "oss-rg-china-mainland.aliyuncs.com"
|
||||
# 公共bucket,用于存储公共文件,如文档文件
|
||||
public_bucket: "nuwa-packages"
|
||||
# 私有bucket,用于存储私有文件,如模型文件
|
||||
private_bucket: "edu-nuwa-packages"
|
||||
access_key_id: "${OSS_ACCESS_KEY_ID}" # 通过环境变量设置
|
||||
access_key_secret: "${OSS_ACCESS_KEY_SECRET}" # 通过环境变量设置
|
||||
region: "oss-rg-china-mainland"
|
||||
upload_directory: "document_parser" # 上传文件的统一子目录前缀
|
||||
|
||||
# 全局文件大小配置
|
||||
file_size_config:
|
||||
max_file_size: "200MB"
|
||||
large_document_threshold: "50MB"
|
||||
|
||||
# 外部集成配置
|
||||
external_integration:
|
||||
webhook_url: ""
|
||||
api_key: ""
|
||||
timeout: 30
|
||||
@@ -0,0 +1,297 @@
|
||||
use document_parser::config::init_global_config;
|
||||
use document_parser::models::ImageInfo;
|
||||
use document_parser::parsers::DualEngineParser;
|
||||
use document_parser::processors::MarkdownProcessor;
|
||||
use document_parser::processors::markdown_processor::MarkdownProcessorConfig;
|
||||
use document_parser::services::{DocumentService, ImageProcessor, TaskService};
|
||||
use std::sync::Arc;
|
||||
use tokio::fs;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
println!("🚀 Markdown image processing core logic test");
|
||||
println!("=====================================");
|
||||
|
||||
// 初始化全局配置
|
||||
let config =
|
||||
document_parser::config::AppConfig::load_config().expect("Failed to load configuration");
|
||||
init_global_config(config).expect("Failed to initialize global config");
|
||||
println!("✅ Global configuration initialization completed");
|
||||
|
||||
// 获取项目根目录
|
||||
let project_root = std::env::current_dir()?;
|
||||
let test_file_path = project_root
|
||||
.join("document-parser")
|
||||
.join("fixtures")
|
||||
.join("upload_parse_test.md");
|
||||
|
||||
println!("📁 Test file path: {}", test_file_path.display());
|
||||
|
||||
// 检查测试文件是否存在
|
||||
if !test_file_path.exists() {
|
||||
eprintln!(
|
||||
"❌ The test file does not exist: {}",
|
||||
test_file_path.display()
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// 读取测试 Markdown 文件
|
||||
let markdown_content = fs::read_to_string(&test_file_path).await?;
|
||||
println!(
|
||||
"📖 Read Markdown file successfully, content length: {} characters",
|
||||
markdown_content.len()
|
||||
);
|
||||
|
||||
// 创建 Markdown 处理器
|
||||
let processor = MarkdownProcessor::new(MarkdownProcessorConfig::default(), None);
|
||||
println!("🔧 Markdown processor created");
|
||||
|
||||
// 测试 1: 解析 Markdown 并构建章节层次结构
|
||||
println!("\\n🧪 Test 1: Parse Markdown and build chapter hierarchy");
|
||||
let doc_structure = processor.parse_markdown_with_toc(&markdown_content).await?;
|
||||
|
||||
println!("Document title: {}", doc_structure.title);
|
||||
println!("Total number of chapters: {}", doc_structure.total_sections);
|
||||
println!("Maximum level: {}", doc_structure.max_level);
|
||||
println!("Number of TOC items: {}", doc_structure.toc.len());
|
||||
|
||||
// 显示前几个 TOC 项目
|
||||
for (i, item) in doc_structure.toc.iter().take(5).enumerate() {
|
||||
println!(
|
||||
"{}. [{}] {} (Level: {})",
|
||||
i + 1,
|
||||
item.id,
|
||||
item.title,
|
||||
item.level
|
||||
);
|
||||
}
|
||||
|
||||
// 测试 2: 提取图片路径
|
||||
println!("\\n🧪 Test 2: Extract image path in Markdown");
|
||||
let image_paths = ImageProcessor::extract_image_paths(&markdown_content);
|
||||
println!("Number of image paths found: {}", image_paths.len());
|
||||
|
||||
// 只显示前10个图片路径
|
||||
for (i, path) in image_paths.iter().take(10).enumerate() {
|
||||
println!(" {}. {}", i + 1, path);
|
||||
}
|
||||
if image_paths.len() > 10 {
|
||||
println!("...and {} image paths", image_paths.len() - 10);
|
||||
}
|
||||
|
||||
// 测试 3: 验证图片文件是否存在(修复路径匹配问题)
|
||||
println!("\\n🧪 Test 3: Verify that the image file exists");
|
||||
|
||||
// 根据测试文件路径确定图片目录位置
|
||||
let images_dir = if test_file_path.parent().unwrap().join("images").exists() {
|
||||
test_file_path.parent().unwrap().join("images")
|
||||
} else {
|
||||
// 回退到默认位置
|
||||
project_root
|
||||
.join("document-parser")
|
||||
.join("fixtures")
|
||||
.join("images")
|
||||
};
|
||||
let mut existing_images = 0;
|
||||
let mut missing_images = 0;
|
||||
let mut valid_image_paths = Vec::new();
|
||||
|
||||
for image_name in &image_paths {
|
||||
// 现在 image_paths 直接包含图片名称(如 filename.jpg)
|
||||
let filename = image_name;
|
||||
|
||||
// 检查文件是否存在
|
||||
let full_path = images_dir.join(filename);
|
||||
if full_path.exists() {
|
||||
let metadata = fs::metadata(&full_path).await?;
|
||||
println!(" ✅ {} ({} bytes)", filename, metadata.len());
|
||||
existing_images += 1;
|
||||
valid_image_paths.push(image_name.clone());
|
||||
} else {
|
||||
// 如果直接匹配失败,尝试在 images 目录中查找
|
||||
let mut found = false;
|
||||
if let Ok(mut entries) = fs::read_dir(&images_dir).await {
|
||||
while let Ok(Some(entry)) = entries.next_entry().await {
|
||||
let entry_path = entry.path();
|
||||
if let Some(entry_filename) = entry_path.file_name().and_then(|f| f.to_str()) {
|
||||
if entry_filename == filename {
|
||||
let metadata = fs::metadata(&entry_path).await?;
|
||||
println!(
|
||||
"✅ {} ({} bytes) [match by file name]",
|
||||
filename,
|
||||
metadata.len()
|
||||
);
|
||||
existing_images += 1;
|
||||
valid_image_paths.push(image_name.clone());
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !found {
|
||||
println!("❌ {filename} (File does not exist)");
|
||||
missing_images += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
println!("Existing pictures: {existing_images}");
|
||||
println!("Missing pictures: {missing_images}");
|
||||
|
||||
// 测试 4: 创建真实的图片上传结果(基于实际存在的图片)
|
||||
println!("\\n🧪 Test 4: Create realistic image upload results");
|
||||
|
||||
let mut real_image_results = Vec::new();
|
||||
for image_name in &valid_image_paths {
|
||||
// 现在 valid_image_paths 直接包含图片名称(如 filename.jpg)
|
||||
let filename = image_name;
|
||||
|
||||
// 模拟真实的 OSS URL(实际项目中这里会是真实的 OSS 上传结果)
|
||||
let oss_url = format!(
|
||||
"https://example-oss.com/processed_images/{}/{}",
|
||||
uuid::Uuid::new_v4().to_string().split('-').next().unwrap(),
|
||||
filename
|
||||
);
|
||||
|
||||
// 获取实际文件大小
|
||||
let full_path = images_dir.join(filename);
|
||||
let file_size = fs::metadata(&full_path).await?.len() as u64;
|
||||
|
||||
// 为了创建 ImageInfo,我们需要构建完整的原始路径
|
||||
let original_path = format!("images/{image_name}");
|
||||
|
||||
real_image_results.push(ImageInfo::new(
|
||||
original_path,
|
||||
oss_url,
|
||||
file_size,
|
||||
"image/jpeg".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
println!("Create real picture results: {}", real_image_results.len());
|
||||
|
||||
// 测试 5: 测试 Markdown 内容替换
|
||||
if !real_image_results.is_empty() {
|
||||
println!("\\n🧪 Test 5: Test Markdown content replacement");
|
||||
|
||||
// 创建临时的 DocumentService 来测试替换逻辑
|
||||
let temp_oss_service = None; // 不使用真实的 OSS 服务
|
||||
let temp_task_service = Arc::new(
|
||||
TaskService::new(Arc::new(
|
||||
sled::open(":memory:").expect("Failed to create in-memory DB"),
|
||||
))
|
||||
.expect("Failed to create task service"),
|
||||
);
|
||||
let temp_dual_parser = DualEngineParser::with_auto_venv_detection()
|
||||
.expect("Failed to create dual engine parser");
|
||||
let temp_markdown_processor =
|
||||
MarkdownProcessor::new(MarkdownProcessorConfig::default(), None);
|
||||
|
||||
let temp_doc_service = DocumentService::new(
|
||||
temp_dual_parser,
|
||||
temp_markdown_processor,
|
||||
temp_task_service,
|
||||
temp_oss_service,
|
||||
);
|
||||
|
||||
// 测试路径替换逻辑
|
||||
let replaced_content = temp_doc_service
|
||||
.replace_image_paths_in_markdown(&markdown_content, &real_image_results)
|
||||
.await?;
|
||||
|
||||
println!(
|
||||
"Original content length: {} characters",
|
||||
markdown_content.len()
|
||||
);
|
||||
println!(
|
||||
"Content length after replacement: {} characters",
|
||||
replaced_content.len()
|
||||
);
|
||||
|
||||
// 检查是否成功替换了图片路径
|
||||
let original_image_count = image_paths.len();
|
||||
let replaced_image_count = ImageProcessor::extract_image_paths(&replaced_content).len();
|
||||
|
||||
if replaced_image_count == 0 {
|
||||
println!(
|
||||
"✅ Image path replacement successful, all local paths have been replaced with OSS URLs"
|
||||
);
|
||||
} else {
|
||||
println!(
|
||||
"⚠️ There are still {replaced_image_count} image paths that have not been replaced"
|
||||
);
|
||||
}
|
||||
|
||||
// 显示替换前后的对比(前几行)
|
||||
println!("\\n📝 Content replacement comparison (first 10 lines):");
|
||||
println!("Original content:");
|
||||
for (i, line) in markdown_content.lines().take(10).enumerate() {
|
||||
if line.contains(" {
|
||||
println!(" {}: {}", i + 1, line);
|
||||
}
|
||||
}
|
||||
|
||||
println!("Content after replacement:");
|
||||
for (i, line) in replaced_content.lines().take(10).enumerate() {
|
||||
if line.contains(" {
|
||||
println!(" {}: {}", i + 1, line);
|
||||
}
|
||||
}
|
||||
|
||||
// 测试 6: 验证替换结果
|
||||
println!("\\n🧪 Test 6: Verify replacement results");
|
||||
let replaced_image_paths = ImageProcessor::extract_image_paths(&replaced_content);
|
||||
let oss_url_count = replaced_content.matches("https://example-oss.com").count();
|
||||
|
||||
println!(
|
||||
"Number of image paths after replacement: {}",
|
||||
replaced_image_paths.len()
|
||||
);
|
||||
println!("OSS URL quantity: {oss_url_count}");
|
||||
|
||||
if oss_url_count > 0 {
|
||||
println!("✅ Successfully replaced {oss_url_count} image paths with OSS URLs");
|
||||
} else {
|
||||
println!("❌ The OSS URL is not found and the replacement may fail.");
|
||||
}
|
||||
}
|
||||
|
||||
// 测试总结
|
||||
println!("\\n📊 Test summary");
|
||||
println!("=====================================");
|
||||
println!("✅ Markdown parsing: Success");
|
||||
println!(
|
||||
"✅ Chapter hierarchy: {} chapters",
|
||||
doc_structure.total_sections
|
||||
);
|
||||
println!("✅ Picture path extraction: {} pictures", image_paths.len());
|
||||
println!(
|
||||
"✅ Image file verification: {}/{} files exist",
|
||||
existing_images,
|
||||
image_paths.len()
|
||||
);
|
||||
println!("✅ Path replacement test: Completed");
|
||||
|
||||
if missing_images > 0 {
|
||||
println!("⚠️ Missing pictures: {missing_images} (need to check picture files)");
|
||||
}
|
||||
|
||||
if !real_image_results.is_empty() {
|
||||
println!(
|
||||
"✅ Image upload simulation: {} images",
|
||||
real_image_results.len()
|
||||
);
|
||||
println!("✅ Markdown content replacement: Completed");
|
||||
}
|
||||
|
||||
println!("\\n🎉 Core logic test completed!");
|
||||
println!("\\n💡 Note: This is a test program, actual OSS upload requires:");
|
||||
println!("1. Configure real OSS services");
|
||||
println!("2. Call the ImageProcessor::batch_upload_images method");
|
||||
println!("3. Use real OSS credentials");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
49
qiming-mcp-proxy/document-parser/fixtures/sample_config.json
Normal file
49
qiming-mcp-proxy/document-parser/fixtures/sample_config.json
Normal file
@@ -0,0 +1,49 @@
|
||||
{
|
||||
"project": {
|
||||
"name": "文档解析器",
|
||||
"version": "1.0.0",
|
||||
"description": "支持多种格式的智能文档解析系统",
|
||||
"author": "开发团队",
|
||||
"license": "MIT"
|
||||
},
|
||||
"server": {
|
||||
"host": "0.0.0.0",
|
||||
"port": 8087,
|
||||
"workers": 4,
|
||||
"timeout": 30
|
||||
},
|
||||
"database": {
|
||||
"type": "sled",
|
||||
"path": "./data/sled",
|
||||
"cache_capacity": 1048576,
|
||||
"compression": true
|
||||
},
|
||||
"parsers": {
|
||||
"markdown": {
|
||||
"enabled": true,
|
||||
"extensions": ["md", "markdown"],
|
||||
"max_file_size": 10485760
|
||||
},
|
||||
"word": {
|
||||
"enabled": true,
|
||||
"extensions": ["doc", "docx"],
|
||||
"max_file_size": 52428800
|
||||
},
|
||||
"pdf": {
|
||||
"enabled": true,
|
||||
"extensions": ["pdf"],
|
||||
"max_file_size": 104857600
|
||||
}
|
||||
},
|
||||
"storage": {
|
||||
"temp_dir": "./temp",
|
||||
"max_concurrent": 10,
|
||||
"cleanup_interval": 3600
|
||||
},
|
||||
"logging": {
|
||||
"level": "info",
|
||||
"file": "./logs/app.log",
|
||||
"max_size": 10485760,
|
||||
"backup_count": 5
|
||||
}
|
||||
}
|
||||
11
qiming-mcp-proxy/document-parser/fixtures/sample_data.csv
Normal file
11
qiming-mcp-proxy/document-parser/fixtures/sample_data.csv
Normal file
@@ -0,0 +1,11 @@
|
||||
姓名,年龄,职业,城市,薪资
|
||||
张三,25,软件工程师,北京,15000
|
||||
李四,30,产品经理,上海,20000
|
||||
王五,28,UI设计师,深圳,18000
|
||||
赵六,32,数据分析师,杭州,22000
|
||||
钱七,26,前端开发,广州,16000
|
||||
孙八,29,后端开发,成都,17000
|
||||
周九,31,测试工程师,武汉,14000
|
||||
吴十,27,运维工程师,西安,15000
|
||||
郑十一,33,架构师,南京,30000
|
||||
王十二,24,实习生,苏州,8000
|
||||
|
60
qiming-mcp-proxy/document-parser/fixtures/sample_data.xml
Normal file
60
qiming-mcp-proxy/document-parser/fixtures/sample_data.xml
Normal file
@@ -0,0 +1,60 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project>
|
||||
<metadata>
|
||||
<name>文档解析器项目</name>
|
||||
<version>1.0.0</version>
|
||||
<description>支持多种格式的智能文档解析系统</description>
|
||||
<author>开发团队</author>
|
||||
<created>2024-08-15</created>
|
||||
</metadata>
|
||||
|
||||
<components>
|
||||
<component>
|
||||
<name>格式检测器</name>
|
||||
<type>core</type>
|
||||
<description>自动识别文档格式</description>
|
||||
<enabled>true</enabled>
|
||||
</component>
|
||||
|
||||
<component>
|
||||
<name>解析引擎</name>
|
||||
<type>core</type>
|
||||
<description>支持多种格式的解析</description>
|
||||
<enabled>true</enabled>
|
||||
</component>
|
||||
|
||||
<component>
|
||||
<name>存储服务</name>
|
||||
<type>service</type>
|
||||
<description>管理解析结果和元数据</description>
|
||||
<enabled>true</enabled>
|
||||
</component>
|
||||
|
||||
<component>
|
||||
<name>任务队列</name>
|
||||
<type>service</type>
|
||||
<description>异步处理文档解析任务</description>
|
||||
<enabled>true</enabled>
|
||||
</component>
|
||||
</components>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<name>tokio</name>
|
||||
<version>1.0</version>
|
||||
<type>runtime</type>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<name>serde</name>
|
||||
<version>1.0</version>
|
||||
<type>serialization</type>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<name>sled</name>
|
||||
<version>0.34</version>
|
||||
<type>database</type>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</project>
|
||||
91
qiming-mcp-proxy/document-parser/fixtures/sample_markdown.md
Normal file
91
qiming-mcp-proxy/document-parser/fixtures/sample_markdown.md
Normal file
@@ -0,0 +1,91 @@
|
||||
# 测试文档 - Markdown 格式
|
||||
|
||||
## 简介
|
||||
这是一个用于测试 Markdown 解析功能的示例文档。它包含了各种 Markdown 元素,用于验证解析器的功能。
|
||||
|
||||
## 文本格式
|
||||
|
||||
### 粗体和斜体
|
||||
这是**粗体文本**,这是*斜体文本*,这是***粗斜体文本***。
|
||||
|
||||
### 删除线和下划线
|
||||
这是~~删除线文本~~,这是<u>下划线文本</u>。
|
||||
|
||||
## 列表
|
||||
|
||||
### 无序列表
|
||||
- 第一项
|
||||
- 第二项
|
||||
- 子项 2.1
|
||||
- 子项 2.2
|
||||
- 第三项
|
||||
|
||||
### 有序列表
|
||||
1. 第一步
|
||||
2. 第二步
|
||||
1. 子步骤 2.1
|
||||
2. 子步骤 2.2
|
||||
3. 第三步
|
||||
|
||||
## 链接和图片
|
||||
|
||||
### 链接
|
||||
访问 [GitHub](https://github.com) 了解更多信息。
|
||||
|
||||
### 图片
|
||||

|
||||
|
||||
## 代码
|
||||
|
||||
### 行内代码
|
||||
使用 `console.log()` 来输出信息。
|
||||
|
||||
### 代码块
|
||||
```python
|
||||
def hello_world():
|
||||
print("Hello, World!")
|
||||
return "success"
|
||||
```
|
||||
|
||||
```javascript
|
||||
function greet(name) {
|
||||
return `Hello, ${name}!`;
|
||||
}
|
||||
```
|
||||
|
||||
## 表格
|
||||
|
||||
| 姓名 | 年龄 | 职业 |
|
||||
|------|------|------|
|
||||
| 张三 | 25 | 工程师 |
|
||||
| 李四 | 30 | 设计师 |
|
||||
| 王五 | 28 | 产品经理 |
|
||||
|
||||
## 引用
|
||||
|
||||
> 这是一个引用块。
|
||||
>
|
||||
> 可以包含多行内容。
|
||||
|
||||
## 水平线
|
||||
|
||||
---
|
||||
|
||||
## 任务列表
|
||||
|
||||
- [x] 完成项目规划
|
||||
- [x] 编写核心代码
|
||||
- [ ] 进行单元测试
|
||||
- [ ] 部署到生产环境
|
||||
|
||||
## 总结
|
||||
|
||||
这个文档包含了:
|
||||
- 各种标题级别
|
||||
- 文本格式化
|
||||
- 列表和表格
|
||||
- 代码示例
|
||||
- 链接和图片
|
||||
- 引用
|
||||
|
||||
用于全面测试 Markdown 解析器的功能。
|
||||
28
qiming-mcp-proxy/document-parser/fixtures/sample_text.txt
Normal file
28
qiming-mcp-proxy/document-parser/fixtures/sample_text.txt
Normal file
@@ -0,0 +1,28 @@
|
||||
这是一个纯文本测试文档
|
||||
|
||||
用于测试文档解析器对纯文本格式的处理能力。
|
||||
|
||||
文档内容包含:
|
||||
1. 中文字符
|
||||
2. 英文字符
|
||||
3. 数字
|
||||
4. 标点符号
|
||||
5. 换行符
|
||||
|
||||
这个文档没有特殊的格式标记,纯粹是文本内容。
|
||||
|
||||
可以用来测试:
|
||||
- 文本提取功能
|
||||
- 字符编码处理
|
||||
- 换行符处理
|
||||
- 文本长度计算
|
||||
- 内容分析功能
|
||||
|
||||
测试用例应该能够:
|
||||
- 正确识别这是一个文本文件
|
||||
- 提取完整的文本内容
|
||||
- 保持原有的换行格式
|
||||
- 计算准确的字符数量
|
||||
- 生成合适的元数据
|
||||
|
||||
结束。
|
||||
21
qiming-mcp-proxy/document-parser/fixtures/simple_markdown.md
Normal file
21
qiming-mcp-proxy/document-parser/fixtures/simple_markdown.md
Normal file
@@ -0,0 +1,21 @@
|
||||
# 简单测试文档
|
||||
|
||||
这是一个简单的 Markdown 文档,用于基础功能测试。
|
||||
|
||||
## 内容
|
||||
|
||||
- 项目介绍
|
||||
- 功能特性
|
||||
- 使用方法
|
||||
|
||||
## 代码示例
|
||||
|
||||
```rust
|
||||
fn main() {
|
||||
println!("Hello, World!");
|
||||
}
|
||||
```
|
||||
|
||||
## 总结
|
||||
|
||||
测试完成。
|
||||
121
qiming-mcp-proxy/document-parser/fixtures/technical_doc.md
Normal file
121
qiming-mcp-proxy/document-parser/fixtures/technical_doc.md
Normal file
@@ -0,0 +1,121 @@
|
||||
# Rust 项目技术文档
|
||||
|
||||
## 项目概述
|
||||
|
||||
这是一个基于 Rust 的文档解析器项目,支持多种文档格式的解析和处理。
|
||||
|
||||
## 架构设计
|
||||
|
||||
### 核心组件
|
||||
|
||||
1. **格式检测器** - 自动识别文档格式
|
||||
2. **解析引擎** - 支持多种格式的解析
|
||||
3. **存储服务** - 管理解析结果和元数据
|
||||
4. **任务队列** - 异步处理文档解析任务
|
||||
|
||||
### 技术栈
|
||||
|
||||
- **语言**: Rust 2021 Edition
|
||||
- **异步运行时**: Tokio
|
||||
- **数据库**: Sled (嵌入式)
|
||||
- **Web 框架**: Axum
|
||||
- **序列化**: Serde
|
||||
|
||||
## API 接口
|
||||
|
||||
### 文档上传
|
||||
|
||||
```http
|
||||
POST /api/v1/documents/upload
|
||||
Content-Type: multipart/form-data
|
||||
|
||||
file: [binary data]
|
||||
format: "auto"
|
||||
```
|
||||
|
||||
### 解析状态查询
|
||||
|
||||
```http
|
||||
GET /api/v1/documents/{id}/status
|
||||
```
|
||||
|
||||
### 解析结果获取
|
||||
|
||||
```http
|
||||
GET /api/v1/documents/{id}/content
|
||||
Accept: application/json
|
||||
```
|
||||
|
||||
## 配置说明
|
||||
|
||||
### 环境变量
|
||||
|
||||
```bash
|
||||
# 服务器配置
|
||||
SERVER_PORT=8087
|
||||
SERVER_HOST=0.0.0.0
|
||||
|
||||
# 日志配置
|
||||
LOG_LEVEL=info
|
||||
LOG_PATH=./logs/app.log
|
||||
|
||||
# 存储配置
|
||||
SLED_PATH=./data/sled
|
||||
SLED_CACHE_CAPACITY=1048576
|
||||
```
|
||||
|
||||
## 部署指南
|
||||
|
||||
### 开发环境
|
||||
|
||||
```bash
|
||||
# 克隆项目
|
||||
git clone <repository-url>
|
||||
cd document-parser
|
||||
|
||||
# 安装依赖
|
||||
cargo install
|
||||
|
||||
# 运行测试
|
||||
cargo test
|
||||
|
||||
# 启动服务
|
||||
cargo run
|
||||
```
|
||||
|
||||
### 生产环境
|
||||
|
||||
```bash
|
||||
# 构建发布版本
|
||||
cargo build --release
|
||||
|
||||
# 运行服务
|
||||
./target/release/document-parser
|
||||
```
|
||||
|
||||
## 性能指标
|
||||
|
||||
### 解析速度
|
||||
|
||||
| 文档类型 | 平均解析时间 | 内存使用 |
|
||||
|----------|--------------|----------|
|
||||
| Markdown | 50ms | 2MB |
|
||||
| Word | 200ms | 10MB |
|
||||
| PDF | 500ms | 25MB |
|
||||
|
||||
### 并发能力
|
||||
|
||||
- 最大并发解析任务:10
|
||||
- 队列容量:100
|
||||
- 超时设置:30秒
|
||||
|
||||
## 总结
|
||||
|
||||
这个技术文档包含了:
|
||||
- 项目架构说明
|
||||
- API 接口定义
|
||||
- 配置和部署指南
|
||||
- 性能指标数据
|
||||
- 代码示例
|
||||
|
||||
用于测试复杂 Markdown 内容的解析能力。
|
||||
34
qiming-mcp-proxy/document-parser/fixtures/test_config.yml
Normal file
34
qiming-mcp-proxy/document-parser/fixtures/test_config.yml
Normal file
@@ -0,0 +1,34 @@
|
||||
# 测试配置文件
|
||||
app:
|
||||
name: "测试应用"
|
||||
version: "0.1.0"
|
||||
debug: true
|
||||
|
||||
database:
|
||||
host: "localhost"
|
||||
port: 5432
|
||||
name: "test_db"
|
||||
user: "test_user"
|
||||
password: "test_pass"
|
||||
|
||||
api:
|
||||
endpoints:
|
||||
- path: "/api/v1/users"
|
||||
method: "GET"
|
||||
auth: true
|
||||
- path: "/api/v1/users"
|
||||
method: "POST"
|
||||
auth: true
|
||||
- path: "/api/v1/health"
|
||||
method: "GET"
|
||||
auth: false
|
||||
|
||||
logging:
|
||||
level: "debug"
|
||||
format: "json"
|
||||
output: "stdout"
|
||||
|
||||
features:
|
||||
cache: true
|
||||
rate_limit: true
|
||||
compression: false
|
||||
468
qiming-mcp-proxy/document-parser/locales/en.yml
Normal file
468
qiming-mcp-proxy/document-parser/locales/en.yml
Normal file
@@ -0,0 +1,468 @@
|
||||
# ===========================================
|
||||
# Error Messages - mcp-proxy
|
||||
# ===========================================
|
||||
errors.mcp_proxy.service_not_found: "Service %{service} not found"
|
||||
errors.mcp_proxy.service_restart_cooldown: "Service %{service} is in restart cooldown, please try again later"
|
||||
errors.mcp_proxy.service_startup_in_progress: "Service %{service} is starting, please wait"
|
||||
errors.mcp_proxy.service_startup_failed: "Service startup failed: %{mcp_id}: %{reason}"
|
||||
errors.mcp_proxy.backend_connection: "Backend connection error: %{detail}"
|
||||
errors.mcp_proxy.config_parse: "Configuration parse error: %{detail}"
|
||||
errors.mcp_proxy.mcp_server_error: "MCP server error: %{detail}"
|
||||
errors.mcp_proxy.json_serialization: "JSON serialization error: %{detail}"
|
||||
errors.mcp_proxy.io_error: "IO error: %{detail}"
|
||||
errors.mcp_proxy.route_not_found: "Route not found: %{path}"
|
||||
errors.mcp_proxy.invalid_parameter: "Invalid request parameter: %{detail}"
|
||||
# ===========================================
|
||||
# Error Messages - document-parser
|
||||
# ===========================================
|
||||
errors.document_parser.config: "Configuration error: %{detail}"
|
||||
errors.document_parser.file: "File operation error: %{detail}"
|
||||
errors.document_parser.unsupported_format: "Unsupported file format: %{format}"
|
||||
errors.document_parser.parse: "Parse error: %{detail}"
|
||||
errors.document_parser.mineru: "MinerU error: %{detail}"
|
||||
errors.document_parser.markitdown: "MarkItDown error: %{detail}"
|
||||
errors.document_parser.oss: "OSS operation error: %{detail}"
|
||||
errors.document_parser.database: "Database error: %{detail}"
|
||||
errors.document_parser.network: "Network error: %{detail}"
|
||||
errors.document_parser.task: "Task error: %{detail}"
|
||||
errors.document_parser.internal: "Internal error: %{detail}"
|
||||
errors.document_parser.timeout: "Operation timeout: %{detail}"
|
||||
errors.document_parser.validation: "Validation error: %{detail}"
|
||||
errors.document_parser.environment: "Environment error: %{detail}"
|
||||
errors.document_parser.virtual_environment_path: "Virtual environment path error: %{detail}"
|
||||
errors.document_parser.permission: "Permission error: %{detail}"
|
||||
errors.document_parser.path: "Path error: %{detail}"
|
||||
errors.document_parser.queue: "Queue error: %{detail}"
|
||||
errors.document_parser.processing: "Processing error: %{detail}"
|
||||
# Error Suggestions - document-parser
|
||||
errors.document_parser.suggestions.config: "Check configuration file and environment variables"
|
||||
errors.document_parser.suggestions.file: "Check file path and permissions"
|
||||
errors.document_parser.suggestions.unsupported_format: "Check if file format is supported"
|
||||
errors.document_parser.suggestions.parse: "Check if file content is complete"
|
||||
errors.document_parser.suggestions.mineru: "Check MinerU environment configuration"
|
||||
errors.document_parser.suggestions.markitdown: "Check MarkItDown environment configuration"
|
||||
errors.document_parser.suggestions.oss: "Check OSS configuration and network connection"
|
||||
errors.document_parser.suggestions.database: "Check database connection and permissions"
|
||||
errors.document_parser.suggestions.network: "Check network connection and firewall settings"
|
||||
errors.document_parser.suggestions.task: "Check task parameters and status"
|
||||
errors.document_parser.suggestions.internal: "Contact technical support"
|
||||
errors.document_parser.suggestions.timeout: "Check network latency or increase timeout"
|
||||
errors.document_parser.suggestions.validation: "Check input parameter format"
|
||||
errors.document_parser.suggestions.environment: "Check system environment and dependency installation"
|
||||
errors.document_parser.suggestions.queue: "Check queue service status and configuration"
|
||||
errors.document_parser.suggestions.processing: "Check processing flow and data format"
|
||||
errors.document_parser.suggestions.virtual_environment_path: "Check virtual environment path and directory permissions"
|
||||
errors.document_parser.suggestions.permission: "Check file and directory permission settings"
|
||||
errors.document_parser.suggestions.path: "Check if path exists and is accessible"
|
||||
# ===========================================
|
||||
# Error Messages - oss-client
|
||||
# ===========================================
|
||||
errors.oss.config: "Configuration error: %{detail}"
|
||||
errors.oss.network: "Network error: %{detail}"
|
||||
errors.oss.file_not_found: "File not found: %{path}"
|
||||
errors.oss.permission: "Permission denied: %{detail}"
|
||||
errors.oss.io: "IO error: %{detail}"
|
||||
errors.oss.sdk: "OSS SDK error: %{detail}"
|
||||
errors.oss.file_size_exceeded: "File size exceeded: %{detail}"
|
||||
errors.oss.unsupported_file_type: "Unsupported file type: %{detail}"
|
||||
errors.oss.timeout: "Operation timeout: %{detail}"
|
||||
errors.oss.invalid_parameter: "Invalid parameter: %{detail}"
|
||||
# ===========================================
|
||||
# Error Messages - voice-cli
|
||||
# ===========================================
|
||||
errors.voice.config: "Configuration error: %{detail}"
|
||||
errors.voice.audio_processing: "Audio processing error: %{detail}"
|
||||
errors.voice.transcription: "Transcription error: %{detail}"
|
||||
errors.voice.model: "Model error: %{detail}"
|
||||
errors.voice.file_io: "File I/O error: %{detail}"
|
||||
errors.voice.http: "HTTP request error: %{detail}"
|
||||
errors.voice.serialization: "Serialization error: %{detail}"
|
||||
errors.voice.json: "JSON error: %{detail}"
|
||||
errors.voice.config_rs: "Config-rs error: %{detail}"
|
||||
errors.voice.daemon: "Daemon error: %{detail}"
|
||||
errors.voice.unsupported_format: "Audio format not supported: %{detail}"
|
||||
errors.voice.file_too_large: "File too large: %{size} bytes (max: %{max} bytes)"
|
||||
errors.voice.model_not_found: "Model not found: %{model}"
|
||||
errors.voice.invalid_model_name: "Invalid model name: %{model}"
|
||||
errors.voice.worker_pool: "Worker pool error: %{detail}"
|
||||
errors.voice.transcription_timeout: "Transcription timeout after %{seconds} seconds"
|
||||
errors.voice.transcription_failed: "Transcription failed: %{detail}"
|
||||
errors.voice.audio_conversion_failed: "Audio conversion failed: %{detail}"
|
||||
errors.voice.audio_probe_error: "Audio probe error: %{detail}"
|
||||
errors.voice.temp_file_error: "Temporary file error: %{detail}"
|
||||
errors.voice.multipart_error: "Multipart form error: %{detail}"
|
||||
errors.voice.missing_field: "Missing required field: %{field}"
|
||||
errors.voice.network: "Network error: %{detail}"
|
||||
errors.voice.storage: "Storage error: %{detail}"
|
||||
errors.voice.task_management_disabled: "Task management is disabled"
|
||||
errors.voice.not_found: "Resource not found: %{resource}"
|
||||
errors.voice.initialization: "Initialization error: %{detail}"
|
||||
errors.voice.tts: "TTS error: %{detail}"
|
||||
errors.voice.invalid_input: "Invalid input: %{detail}"
|
||||
errors.voice.io: "IO error: %{detail}"
|
||||
# ===========================================
|
||||
# CLI Messages - mcp-proxy startup
|
||||
# ===========================================
|
||||
cli.mirror.not_configured: "No mirror configured (npm/PyPI), using default sources"
|
||||
cli.mirror.npm: "npm mirror: %{url}"
|
||||
cli.mirror.pypi: "PyPI mirror: %{url}"
|
||||
cli.startup.service_starting: "MCP-Proxy starting..."
|
||||
cli.startup.version: "Version: %{version}"
|
||||
cli.startup.config_loaded: "Configuration loaded"
|
||||
cli.startup.port: "Port: %{port}"
|
||||
cli.startup.log_dir: "Log directory: %{path}"
|
||||
cli.startup.log_level: "Log level: %{level}"
|
||||
cli.startup.log_retain_days: "Log retention days: %{days}"
|
||||
cli.startup.success: "✅ Service started successfully, listening on: %{addr}"
|
||||
cli.startup.health_endpoint: "✅ Health check endpoint: http://%{addr}/health"
|
||||
cli.startup.mcp_list: "✅ MCP service list: http://%{addr}/mcp"
|
||||
cli.startup.schedule_task_started: "✅ MCP service status check scheduled task started"
|
||||
cli.startup.log_rotation_configured: "✅ Log rotation configured (keeping last %{count} log files)"
|
||||
cli.startup.system_info: "System information:"
|
||||
cli.startup.os: "Operating system: %{os}"
|
||||
cli.startup.arch: "Architecture: %{arch}"
|
||||
cli.startup.work_dir: "Working directory: %{path}"
|
||||
cli.startup.env_override: "Environment variable overrides:"
|
||||
cli.startup.env_port_override: "MCP_PROXY_PORT: %{port}"
|
||||
cli.startup.env_log_dir_override: "MCP_PROXY_LOG_DIR: %{dir}"
|
||||
cli.startup.env_log_level_override: "MCP_PROXY_LOG_LEVEL: %{level}"
|
||||
cli.startup.trying_bind: "Attempting to bind to address: %{addr}"
|
||||
cli.startup.bind_success: "Successfully bound to address: %{addr}"
|
||||
cli.startup.bind_failed: "Failed to bind address %{addr}: %{error}"
|
||||
cli.startup.init_state: "Initializing application state..."
|
||||
cli.startup.state_done: "Application state initialized"
|
||||
cli.startup.init_router: "Initializing router..."
|
||||
cli.startup.router_done: "Router initialized"
|
||||
cli.startup.warming_up: "\U0001F504 Warming up uv/deno environment dependencies..."
|
||||
cli.startup.warmup_success: "✅ uv/deno environment warmup complete"
|
||||
cli.startup.warmup_failed: "❌ uv/deno environment warmup failed: %{error}"
|
||||
cli.startup.http_server_starting: "\U0001F680 HTTP server starting, waiting for connections..."
|
||||
cli.startup.proxy_mode: "Command: proxy (HTTP server mode)"
|
||||
cli.startup.config_info: "Configuration info:"
|
||||
cli.startup.listen_port: "Listen port: %{port}"
|
||||
cli.startup.log_retention: "Log retention: %{days} days"
|
||||
# CLI Messages - mcp-proxy shutdown
|
||||
cli.shutdown.signal_received: "⚠️ Server received shutdown signal, cleaning up resources..."
|
||||
cli.shutdown.cleanup_success: "✅ Resource cleanup successful"
|
||||
cli.shutdown.cleanup_failed: "❌ Error during resource cleanup: %{error}"
|
||||
cli.shutdown.complete: "✅ Resource cleanup complete, service fully shut down"
|
||||
cli.shutdown.service_error: "❌ Service runtime error: %{error}"
|
||||
# CLI Messages - panic handling
|
||||
cli.panic.handler_started: "Program panic occurred, executing cleanup..."
|
||||
cli.panic.reason: "Panic reason: %{reason}"
|
||||
cli.panic.reason_unknown: "Panic reason: unknown"
|
||||
cli.panic.location: "Panic location: %{file}:%{line}"
|
||||
cli.panic.stack_trace: "Stack trace:"
|
||||
# ===========================================
|
||||
# CLI Messages - convert mode
|
||||
# ===========================================
|
||||
cli.convert.starting: "Starting URL mode processing"
|
||||
cli.convert.target_url: "Target URL: %{url}"
|
||||
cli.convert.protocol_specified: "Using specified protocol: %{protocol}"
|
||||
cli.convert.protocol_config: "Using configured protocol: %{protocol}"
|
||||
cli.convert.detecting_protocol: "Starting protocol auto-detection..."
|
||||
cli.convert.detect_failed: "Protocol detection failed: %{error}"
|
||||
cli.convert.using_protocol: "Using %{protocol} protocol mode"
|
||||
cli.convert.stdio_url_not_supported: "Stdio protocol does not support URL conversion"
|
||||
cli.convert.tool_whitelist: "Tool whitelist: %{tools}"
|
||||
cli.convert.tool_blacklist: "Tool blacklist: %{tools}"
|
||||
cli.convert.config_parsed: "Configuration parsed successfully"
|
||||
cli.convert.service_name: "MCP service name: %{name}"
|
||||
cli.convert.service_name_not_specified: "MCP service name: not specified (using direct URL)"
|
||||
cli.convert.cli_starting: "MCP-Proxy CLI starting"
|
||||
cli.convert.command: "Command: convert (stdio bridge mode)"
|
||||
cli.convert.version: "Version: %{version}"
|
||||
cli.convert.diagnostic_mode: "Diagnostic mode: %{enabled}"
|
||||
cli.convert.mode_direct_url: "Mode: direct URL mode"
|
||||
cli.convert.mode_remote_service: "Mode: remote service configuration mode"
|
||||
cli.convert.service_url: "Service URL: %{url}"
|
||||
cli.convert.config_protocol: "Configured protocol: %{protocol}"
|
||||
cli.convert.ping_config: "Ping interval: %{interval}s, Ping timeout: %{timeout}s"
|
||||
cli.convert.connecting_backend: "Connecting to backend service (timeout: %{timeout}s)..."
|
||||
cli.convert.detect_complete: "Protocol detection complete: %{protocol} (duration: %{duration})"
|
||||
# ===========================================
|
||||
# CLI Messages - SSE mode
|
||||
# ===========================================
|
||||
cli.sse.mode_starting: "SSE mode starting"
|
||||
cli.sse.connect_timeout: "Backend connection timeout (%{seconds}s)"
|
||||
cli.sse.connect_failed: "Backend connection failed: %{error}"
|
||||
cli.sse.connect_success: "Backend connected (duration: %{duration})"
|
||||
cli.sse.stdio_starting: "Starting stdio server..."
|
||||
cli.sse.stdio_started: "Stdio server started"
|
||||
cli.sse.waiting_events: "Waiting for stdio server events..."
|
||||
cli.sse.stdio_exit_eof: "Stdio server exited - reason: MCP client disconnected (stdin EOF)"
|
||||
cli.sse.watchdog_exit: "Watchdog task exited"
|
||||
cli.sse.normal_exit: "mcp-proxy convert (SSE mode) exited normally"
|
||||
cli.sse.watchdog_starting: "SSE Watchdog starting"
|
||||
cli.sse.max_retries: "Max retries: %{count} (0=unlimited)"
|
||||
cli.sse.monitoring_connection: "Monitoring initial connection..."
|
||||
cli.sse.initial_disconnect: "Initial connection disconnected: %{reason}"
|
||||
cli.sse.connection_alive: "Initial connection alive for: %{seconds}s"
|
||||
cli.sse.reconnect_attempt: "Reconnect attempt #%{attempt}/%{max}"
|
||||
cli.sse.backoff_time: "Backoff time: %{seconds}s"
|
||||
cli.sse.reconnect_success: "Reconnect successful (duration: %{duration})"
|
||||
cli.sse.monitoring_reconnect: "Monitoring reconnected connection..."
|
||||
cli.sse.reconnect_disconnect: "Disconnected after reconnect: %{reason}"
|
||||
cli.sse.reconnect_alive: "Reconnected connection alive for: %{seconds}s"
|
||||
cli.sse.max_retries_reached: "Max retries reached (%{count}), stopping reconnect"
|
||||
cli.sse.watchdog_exit_msg: "SSE Watchdog exited"
|
||||
# ===========================================
|
||||
# CLI Messages - Stream mode
|
||||
# ===========================================
|
||||
cli.stream.mode_starting: "Stream mode starting"
|
||||
cli.stream.connect_timeout: "Backend connection timeout (%{seconds}s)"
|
||||
cli.stream.connect_failed: "Backend connection failed: %{error}"
|
||||
cli.stream.connect_success: "Backend connected (duration: %{duration})"
|
||||
cli.stream.stdio_starting: "Starting stdio server..."
|
||||
cli.stream.stdio_started: "Stdio server started"
|
||||
cli.stream.waiting_events: "Waiting for stdio server events..."
|
||||
cli.stream.stdio_exit_eof: "Stdio server exited - reason: MCP client disconnected (stdin EOF)"
|
||||
cli.stream.watchdog_exit: "Watchdog task exited"
|
||||
cli.stream.normal_exit: "mcp-proxy convert (Stream mode) exited normally"
|
||||
# ===========================================
|
||||
# CLI Messages - Command mode
|
||||
# ===========================================
|
||||
cli.command.local_mode: "Mode: local command mode"
|
||||
cli.command.command: "Command: %{cmd} %{args}"
|
||||
cli.command.ctrl_c: "Received Ctrl+C signal, shutting down..."
|
||||
# ===========================================
|
||||
# CLI Messages - monitoring
|
||||
# ===========================================
|
||||
cli.monitoring.health: "Monitoring %{protocol} connection health"
|
||||
cli.monitoring.health_check_status: "\U0001F493 [%{protocol}][Health Check] Connection status: %{status} (connection check only, no list_tools), check #%{count}, alive: %{seconds}s"
|
||||
cli.monitoring.health_check_normal: "\U0001F493 [%{protocol}][Health Check] Backend service healthy (list_tools verified), Ping #%{count}, alive: %{seconds}s"
|
||||
# ===========================================
|
||||
# Diagnostic Messages
|
||||
# ===========================================
|
||||
diagnostic.report_header: "========== Diagnostic Report =========="
|
||||
diagnostic.connection_protocol: "Connection protocol: %{protocol}"
|
||||
diagnostic.service_url: "Service URL: %{url}"
|
||||
diagnostic.connection_duration: "Connection duration: %{seconds} seconds"
|
||||
diagnostic.disconnect_reason: "Disconnect reason: %{reason}"
|
||||
diagnostic.error_type: "Error type: %{error_type}"
|
||||
diagnostic.possible_causes: "Possible causes:"
|
||||
diagnostic.suggestions: "Suggestions:"
|
||||
# Diagnostic - 30s timeout analysis
|
||||
diagnostic.analysis.timeout_30s: "⚠️ Connection dropped at ~30 seconds, likely causes:"
|
||||
diagnostic.analysis.timeout_30s_cause1: "Server-side 30-second timeout limit"
|
||||
diagnostic.analysis.timeout_30s_cause2: "Load balancer (e.g., Nginx/ALB) default timeout"
|
||||
diagnostic.analysis.timeout_30s_cause3: "Cloud provider gateway timeout limit"
|
||||
# Diagnostic - Quick disconnect analysis
|
||||
diagnostic.analysis.quick_disconnect: "⚠️ Connection dropped quickly (%{seconds}s), possible causes:"
|
||||
diagnostic.analysis.quick_disconnect_cause1: "Authentication failed or invalid token"
|
||||
diagnostic.analysis.quick_disconnect_cause2: "Server rejected connection"
|
||||
diagnostic.analysis.quick_disconnect_cause3: "Network instability"
|
||||
# Diagnostic - Long connection analysis
|
||||
diagnostic.analysis.long_connection: "✅ Connection maintained for a long time (%{seconds}s), possible causes:"
|
||||
diagnostic.analysis.long_connection_cause1: "Tool call execution took too long"
|
||||
diagnostic.analysis.long_connection_cause2: "Network fluctuation caused disconnect"
|
||||
# Diagnostic suggestions
|
||||
diagnostic.suggestion.timeout_30s: "Contact service provider to increase timeout limit"
|
||||
diagnostic.suggestion.timeout_client: "Use --request-timeout parameter to set client timeout"
|
||||
diagnostic.suggestion.async_mode: "Consider using async processing mode (webhook callback)"
|
||||
diagnostic.suggestion.ping_interval: "Try increasing ping interval: --ping-interval %{seconds}"
|
||||
diagnostic.suggestion.ping_timeout: "Increase ping timeout: --ping-timeout %{seconds}"
|
||||
diagnostic.suggestion.ping_disable: "Or disable ping: --ping-interval 0"
|
||||
# ===========================================
|
||||
# Error Classification
|
||||
# ===========================================
|
||||
error_classify.timeout_30s: "30s timeout (possibly server limit)"
|
||||
error_classify.service_unavailable_503: "Service unavailable (503)"
|
||||
error_classify.internal_server_error_500: "Internal server error (500)"
|
||||
error_classify.bad_gateway_502: "Bad gateway (502)"
|
||||
error_classify.gateway_timeout_504: "Gateway timeout (504)"
|
||||
error_classify.unauthorized_401: "Unauthorized (401)"
|
||||
error_classify.forbidden_403: "Forbidden (403)"
|
||||
error_classify.not_found_404: "Not found (404)"
|
||||
error_classify.request_timeout_408: "Request timeout (408)"
|
||||
error_classify.timeout: "Timeout"
|
||||
error_classify.connection_refused: "Connection refused"
|
||||
error_classify.connection_reset: "Connection reset"
|
||||
error_classify.connection_closed: "Connection closed"
|
||||
error_classify.dns_failed: "DNS resolution failed"
|
||||
error_classify.ssl_tls_error: "SSL/TLS error"
|
||||
error_classify.network_error: "Network error"
|
||||
error_classify.session_error: "Session error"
|
||||
error_classify.unknown_error: "Unknown error"
|
||||
# ===========================================
|
||||
# CLI Messages - health command
|
||||
# ===========================================
|
||||
cli.health.checking: "\U0001F50D Health checking service: %{url}"
|
||||
cli.health.using_protocol: "\U0001F50D Using specified protocol: %{protocol}"
|
||||
cli.health.detecting_protocol: "\U0001F50D Detecting protocol..."
|
||||
cli.health.detected_protocol: "\U0001F50D Detected %{protocol} protocol"
|
||||
cli.health.healthy: "✅ Service healthy"
|
||||
cli.health.unhealthy: "❌ Service unhealthy"
|
||||
cli.health.stdio_not_supported: "health command does not support stdio protocol"
|
||||
# ===========================================
|
||||
# CLI Messages - check command
|
||||
# ===========================================
|
||||
cli.check.checking: "\U0001F50D Checking service: %{url}"
|
||||
cli.check.healthy: "✅ Service healthy, detected %{protocol} protocol"
|
||||
cli.check.failed: "❌ Service check failed: %{error}"
|
||||
# ===========================================
|
||||
# CLI Messages - document-parser
|
||||
# ===========================================
|
||||
doc_parser.startup.app_info: "=== %{name} v%{version} starting ==="
|
||||
doc_parser.startup.config_summary: "Configuration summary: %{summary}"
|
||||
doc_parser.startup.listening: "Service listening on: %{host}:%{port}"
|
||||
doc_parser.startup.checking_python: "Starting Python environment check and initialization..."
|
||||
doc_parser.startup.checking_venv: "Checking and activating virtual environment..."
|
||||
doc_parser.startup.venv_activate_failed: "Virtual environment auto-activation failed: %{error}"
|
||||
doc_parser.startup.venv_activate_manual: "Please manually activate virtual environment: source ./venv/bin/activate"
|
||||
doc_parser.startup.venv_activated: "Virtual environment auto-activated"
|
||||
doc_parser.startup.env_check_failed: "Environment check failed, will auto-install in background: %{error}"
|
||||
doc_parser.startup.mineru_not_installed: "MinerU dependencies not installed, starting background auto-install..."
|
||||
doc_parser.startup.markitdown_not_installed: "MarkItDown dependencies not installed, starting background auto-install..."
|
||||
doc_parser.startup.install_complete: "Background Python environment installation complete"
|
||||
doc_parser.startup.install_failed: "Background Python environment installation failed: %{error}"
|
||||
doc_parser.startup.install_task_started: "Python dependency installation task started (background), service will start normally"
|
||||
doc_parser.startup.mineru_ready: "MinerU dependencies installed, version: %{version}"
|
||||
doc_parser.startup.markitdown_ready: "MarkItDown dependencies installed"
|
||||
doc_parser.startup.python_ready: "Python environment check complete, all dependencies ready"
|
||||
doc_parser.startup.state_failed: "Failed to create application state: %{error}"
|
||||
doc_parser.startup.health_check_failed: "Application health check failed: %{error}"
|
||||
doc_parser.startup.state_ready: "Application state initialized successfully"
|
||||
doc_parser.startup.http_routes_ready: "HTTP routes initialized successfully"
|
||||
doc_parser.startup.background_tasks_started: "Background tasks started"
|
||||
doc_parser.startup.service_ready: "Service started successfully, waiting for connections..."
|
||||
doc_parser.shutdown.service_stopped: "Service stopped"
|
||||
doc_parser.shutdown.ctrl_c_received: "Received Ctrl+C signal, starting graceful shutdown..."
|
||||
doc_parser.shutdown.terminate_received: "Received terminate signal, starting graceful shutdown..."
|
||||
doc_parser.shutdown.closing: "Shutting down service..."
|
||||
# uv-init command
|
||||
doc_parser.uv_init.starting: "\U0001F680 Starting uv virtual environment and dependency initialization in current directory..."
|
||||
doc_parser.uv_init.current_dir: "\U0001F4C1 Current working directory: %{path}"
|
||||
doc_parser.uv_init.venv_path: "\U0001F4C1 Virtual environment will be created at: ./venv/"
|
||||
doc_parser.uv_init.validating_dir: "\U0001F50D Validating current directory settings..."
|
||||
doc_parser.uv_init.dir_valid: " ✅ Directory validation passed"
|
||||
doc_parser.uv_init.warnings_found: " ⚠️ Found %{count} warnings"
|
||||
doc_parser.uv_init.dir_invalid: " ❌ Directory validation failed, found %{count} issues"
|
||||
doc_parser.uv_init.auto_fixing: " \U0001F527 Attempting to auto-fix %{count} issues..."
|
||||
doc_parser.uv_init.fix_success: " ✅ %{message}"
|
||||
doc_parser.uv_init.fix_failed: " ❌ Cleanup failed: %{error}"
|
||||
doc_parser.uv_init.manual_fix_hint: " \U0001F4A1 Please manually resolve the following issues:"
|
||||
doc_parser.uv_init.dir_validation_failed: "Directory validation failed, please resolve the above issues and try again"
|
||||
doc_parser.uv_init.dir_validation_error: " ⚠️ Directory validation failed: %{error}"
|
||||
doc_parser.uv_init.continue_install: " Continuing installation, but may encounter issues..."
|
||||
# Environment check
|
||||
doc_parser.check.checking_env: "\U0001F50D Checking current environment status..."
|
||||
doc_parser.check.env_check_complete: " Environment check complete:"
|
||||
doc_parser.check.python_available: "✅ Available"
|
||||
doc_parser.check.python_unavailable: "❌ Unavailable"
|
||||
doc_parser.check.uv_available: "✅ Available"
|
||||
doc_parser.check.uv_unavailable: "❌ Unavailable"
|
||||
doc_parser.check.venv_active: "✅ Active"
|
||||
doc_parser.check.venv_inactive: "❌ Inactive"
|
||||
doc_parser.check.mineru_available: "✅ Available"
|
||||
doc_parser.check.mineru_unavailable: "❌ Unavailable"
|
||||
doc_parser.check.markitdown_available: "✅ Available"
|
||||
doc_parser.check.markitdown_unavailable: "❌ Unavailable"
|
||||
doc_parser.check.env_check_failed: " ⚠️ Environment check failed: %{error}"
|
||||
doc_parser.check.continue_install: " Continuing installation..."
|
||||
# Installation process
|
||||
doc_parser.install.all_ready: "✨ All dependencies are ready, no installation needed!"
|
||||
doc_parser.install.plan: "\U0001F4CB Installation plan:"
|
||||
doc_parser.install.install_uv: "Install uv tool"
|
||||
doc_parser.install.create_venv: "Create virtual environment (./venv/)"
|
||||
doc_parser.install.install_mineru: "Install MinerU dependencies"
|
||||
doc_parser.install.install_markitdown: "Install MarkItDown dependencies"
|
||||
doc_parser.install.starting: "⚙️ Starting Python environment and dependency setup..."
|
||||
doc_parser.install.wait_hint: "This may take a few minutes, please wait..."
|
||||
doc_parser.install.path_issues: "⚠️ Detected potential path issues:"
|
||||
doc_parser.install.auto_fixing: "\U0001F527 Attempting to auto-fix issues..."
|
||||
doc_parser.install.fixed: "✅ Fixed the following issues:"
|
||||
doc_parser.install.cannot_auto_fix: "Cannot auto-fix, please manually resolve the above issues"
|
||||
doc_parser.install.fix_failed: "❌ Auto-fix failed: %{error}"
|
||||
doc_parser.install.manual_fix_hint: "\U0001F4A1 Manual fix suggestions:"
|
||||
doc_parser.install.python_setup_complete: "✅ Python environment setup complete!"
|
||||
doc_parser.install.python_setup_failed: "❌ Python environment setup failed: %{error}"
|
||||
doc_parser.install.detailed_hints: "\U0001F4A1 Detailed troubleshooting suggestions:"
|
||||
doc_parser.install.diagnostic_commands: "\U0001F50D Diagnostic commands:"
|
||||
doc_parser.install.check_env: "Check environment status: document-parser check"
|
||||
doc_parser.install.view_logs: "View detailed logs: check logs/ directory"
|
||||
# Verification
|
||||
doc_parser.verify.starting: "\U0001F50D Verifying installation results..."
|
||||
doc_parser.verify.complete: "Verification complete:"
|
||||
doc_parser.verify.version: "Version: %{version}"
|
||||
doc_parser.verify.partial_issues: "⚠️ Some dependencies may have installation issues"
|
||||
doc_parser.verify.need_fix: "\U0001F527 Issues to resolve:"
|
||||
doc_parser.verify.suggestion: "Suggestion: %{suggestion}"
|
||||
doc_parser.verify.init_incomplete: "Environment initialization incomplete"
|
||||
doc_parser.verify.failed: "❌ Verification failed: %{error}"
|
||||
# Success messages
|
||||
doc_parser.success.init_complete: "\U0001F389 uv environment initialization complete!"
|
||||
doc_parser.success.all_ready: "✨ All dependencies are ready, you can now start the server"
|
||||
doc_parser.success.venv_activate: "\U0001F4CB Virtual environment activation commands:"
|
||||
doc_parser.success.start_server: "\U0001F680 Start server:"
|
||||
doc_parser.success.use_uv: "\U0001F527 Or use uv to run commands directly:"
|
||||
doc_parser.success.more_help: "\U0001F4DA More help:"
|
||||
doc_parser.success.tips: "\U0001F4A1 Tips:"
|
||||
doc_parser.success.venv_location: "Virtual environment location: ./venv/"
|
||||
doc_parser.success.python_path: "Python executable: ./venv/bin/python (Linux/macOS) or .\\venv\\Scripts\\python.exe (Windows)"
|
||||
doc_parser.success.troubleshoot_hint: "If issues occur, run 'document-parser troubleshoot' for detailed guide"
|
||||
# troubleshoot command
|
||||
doc_parser.troubleshoot.title: "\U0001F527 Document Parser Troubleshooting Guide"
|
||||
doc_parser.troubleshoot.env_overview: "\U0001F4CA Current Environment Overview:"
|
||||
doc_parser.troubleshoot.work_dir: "Working directory: %{path}"
|
||||
doc_parser.troubleshoot.venv_path: "Virtual environment: ./venv/"
|
||||
doc_parser.troubleshoot.os: "Operating system: %{os}"
|
||||
# Virtual environment issues
|
||||
doc_parser.troubleshoot.venv_problems: "\U0001F3E0 1. Virtual Environment Issues"
|
||||
doc_parser.troubleshoot.venv_create_failed: "❓ Issue: Virtual environment creation failed"
|
||||
doc_parser.troubleshoot.diagnostic_steps: "\U0001F50D Diagnostic steps:"
|
||||
doc_parser.troubleshoot.solutions: "\U0001F4A1 Solutions:"
|
||||
doc_parser.troubleshoot.venv_activate_failed: "❓ Issue: Virtual environment activation failed"
|
||||
# Dependency installation issues
|
||||
doc_parser.troubleshoot.deps_problems: "\U0001F4E6 2. Dependency Installation Issues"
|
||||
doc_parser.troubleshoot.uv_not_installed: "❓ Issue: UV tool not installed or unavailable"
|
||||
doc_parser.troubleshoot.mineru_markitdown_failed: "❓ Issue: MinerU or MarkItDown installation failed"
|
||||
# Network issues
|
||||
doc_parser.troubleshoot.network_problems: "\U0001F310 3. Network and Download Issues"
|
||||
doc_parser.troubleshoot.network_timeout: "❓ Issue: Network connection timeout or download failed"
|
||||
# System environment issues
|
||||
doc_parser.troubleshoot.system_problems: "⚙️ 4. System Environment Issues"
|
||||
doc_parser.troubleshoot.python_incompatible: "❓ Issue: Python version incompatible"
|
||||
doc_parser.troubleshoot.cuda_config: "❓ Issue: CUDA environment configuration (optional, for GPU acceleration)"
|
||||
# Diagnostic commands
|
||||
doc_parser.troubleshoot.diagnostic_commands: "\U0001F50D 5. Common Diagnostic Commands"
|
||||
doc_parser.troubleshoot.env_check: "Environment check:"
|
||||
doc_parser.troubleshoot.manual_verify: "Manual verification:"
|
||||
doc_parser.troubleshoot.view_logs: "Log viewing:"
|
||||
# Get help
|
||||
doc_parser.troubleshoot.more_help: "\U0001F198 6. Get More Help"
|
||||
doc_parser.troubleshoot.if_unsolved: "If the above methods don't resolve the issue:"
|
||||
# Real-time diagnosis
|
||||
doc_parser.troubleshoot.realtime_diagnosis: "\U0001F52C Real-time Environment Diagnosis"
|
||||
doc_parser.troubleshoot.env_good: "✅ Environment status good, all dependencies ready"
|
||||
doc_parser.troubleshoot.issues_found: "⚠️ Found the following issues:"
|
||||
doc_parser.troubleshoot.env_check_failed: "❌ Environment check failed: %{error}"
|
||||
doc_parser.troubleshoot.follow_guide: "Please follow the above guide for troubleshooting"
|
||||
# Tip
|
||||
doc_parser.troubleshoot.tip: "\U0001F4A1 Tip: Most issues can be resolved by re-running 'document-parser uv-init'"
|
||||
# Background cleanup
|
||||
doc_parser.background.cleanup_failed: "Failed to cleanup expired data: %{error}"
|
||||
doc_parser.background.cleanup_complete: "Background cleanup task completed"
|
||||
# Signal handling
|
||||
doc_parser.signal.ctrl_c_failed: "Failed to listen for Ctrl+C signal"
|
||||
doc_parser.signal.terminate_failed: "Failed to listen for terminate signal"
|
||||
# ===========================================
|
||||
# Common Messages
|
||||
# ===========================================
|
||||
common.yes: "Yes"
|
||||
common.no: "No"
|
||||
common.success: "Success"
|
||||
common.failed: "Failed"
|
||||
common.error: "Error"
|
||||
common.warning: "Warning"
|
||||
common.info: "Info"
|
||||
common.loading: "Loading..."
|
||||
common.please_wait: "Please wait..."
|
||||
common.retry: "Retry"
|
||||
common.cancel: "Cancel"
|
||||
common.confirm: "Confirm"
|
||||
common.back: "Back"
|
||||
common.next: "Next"
|
||||
common.previous: "Previous"
|
||||
common.done: "Done"
|
||||
common.skip: "Skip"
|
||||
468
qiming-mcp-proxy/document-parser/locales/zh-CN.yml
Normal file
468
qiming-mcp-proxy/document-parser/locales/zh-CN.yml
Normal file
@@ -0,0 +1,468 @@
|
||||
# ===========================================
|
||||
# 错误消息 - mcp-proxy
|
||||
# ===========================================
|
||||
errors.mcp_proxy.service_not_found: "服务 %{service} 未找到"
|
||||
errors.mcp_proxy.service_restart_cooldown: "服务 %{service} 在重启冷却期内,请稍后再试"
|
||||
errors.mcp_proxy.service_startup_in_progress: "服务 %{service} 正在启动中,请稍后再试"
|
||||
errors.mcp_proxy.service_startup_failed: "服务启动失败: %{mcp_id}: %{reason}"
|
||||
errors.mcp_proxy.backend_connection: "后端连接错误: %{detail}"
|
||||
errors.mcp_proxy.config_parse: "配置解析错误: %{detail}"
|
||||
errors.mcp_proxy.mcp_server_error: "MCP 服务器错误: %{detail}"
|
||||
errors.mcp_proxy.json_serialization: "JSON 序列化错误: %{detail}"
|
||||
errors.mcp_proxy.io_error: "IO 错误: %{detail}"
|
||||
errors.mcp_proxy.route_not_found: "路由未找到: %{path}"
|
||||
errors.mcp_proxy.invalid_parameter: "无效的请求参数: %{detail}"
|
||||
# ===========================================
|
||||
# 错误消息 - document-parser
|
||||
# ===========================================
|
||||
errors.document_parser.config: "配置错误: %{detail}"
|
||||
errors.document_parser.file: "文件操作错误: %{detail}"
|
||||
errors.document_parser.unsupported_format: "不支持的文件格式: %{format}"
|
||||
errors.document_parser.parse: "解析错误: %{detail}"
|
||||
errors.document_parser.mineru: "MinerU错误: %{detail}"
|
||||
errors.document_parser.markitdown: "MarkItDown错误: %{detail}"
|
||||
errors.document_parser.oss: "OSS操作错误: %{detail}"
|
||||
errors.document_parser.database: "数据库错误: %{detail}"
|
||||
errors.document_parser.network: "网络错误: %{detail}"
|
||||
errors.document_parser.task: "任务错误: %{detail}"
|
||||
errors.document_parser.internal: "内部错误: %{detail}"
|
||||
errors.document_parser.timeout: "操作超时: %{detail}"
|
||||
errors.document_parser.validation: "验证错误: %{detail}"
|
||||
errors.document_parser.environment: "环境错误: %{detail}"
|
||||
errors.document_parser.virtual_environment_path: "虚拟环境路径错误: %{detail}"
|
||||
errors.document_parser.permission: "权限错误: %{detail}"
|
||||
errors.document_parser.path: "路径错误: %{detail}"
|
||||
errors.document_parser.queue: "队列错误: %{detail}"
|
||||
errors.document_parser.processing: "处理错误: %{detail}"
|
||||
# 错误建议 - document-parser
|
||||
errors.document_parser.suggestions.config: "检查配置文件和环境变量"
|
||||
errors.document_parser.suggestions.file: "检查文件路径和权限"
|
||||
errors.document_parser.suggestions.unsupported_format: "检查文件格式是否支持"
|
||||
errors.document_parser.suggestions.parse: "检查文件内容是否完整"
|
||||
errors.document_parser.suggestions.mineru: "检查MinerU环境配置"
|
||||
errors.document_parser.suggestions.markitdown: "检查MarkItDown环境配置"
|
||||
errors.document_parser.suggestions.oss: "检查OSS配置和网络连接"
|
||||
errors.document_parser.suggestions.database: "检查数据库连接和权限"
|
||||
errors.document_parser.suggestions.network: "检查网络连接和防火墙设置"
|
||||
errors.document_parser.suggestions.task: "检查任务参数和状态"
|
||||
errors.document_parser.suggestions.internal: "联系技术支持"
|
||||
errors.document_parser.suggestions.timeout: "检查网络延迟或增加超时时间"
|
||||
errors.document_parser.suggestions.validation: "检查输入参数格式"
|
||||
errors.document_parser.suggestions.environment: "检查系统环境和依赖安装"
|
||||
errors.document_parser.suggestions.queue: "检查队列服务状态和配置"
|
||||
errors.document_parser.suggestions.processing: "检查处理流程和数据格式"
|
||||
errors.document_parser.suggestions.virtual_environment_path: "检查虚拟环境路径和目录权限"
|
||||
errors.document_parser.suggestions.permission: "检查文件和目录权限设置"
|
||||
errors.document_parser.suggestions.path: "检查路径是否存在和可访问"
|
||||
# ===========================================
|
||||
# 错误消息 - oss-client
|
||||
# ===========================================
|
||||
errors.oss.config: "配置错误: %{detail}"
|
||||
errors.oss.network: "网络错误: %{detail}"
|
||||
errors.oss.file_not_found: "文件不存在: %{path}"
|
||||
errors.oss.permission: "权限不足: %{detail}"
|
||||
errors.oss.io: "IO错误: %{detail}"
|
||||
errors.oss.sdk: "OSS SDK错误: %{detail}"
|
||||
errors.oss.file_size_exceeded: "文件大小超出限制: %{detail}"
|
||||
errors.oss.unsupported_file_type: "不支持的文件类型: %{detail}"
|
||||
errors.oss.timeout: "操作超时: %{detail}"
|
||||
errors.oss.invalid_parameter: "无效的参数: %{detail}"
|
||||
# ===========================================
|
||||
# 错误消息 - voice-cli
|
||||
# ===========================================
|
||||
errors.voice.config: "配置错误: %{detail}"
|
||||
errors.voice.audio_processing: "音频处理错误: %{detail}"
|
||||
errors.voice.transcription: "转录错误: %{detail}"
|
||||
errors.voice.model: "模型错误: %{detail}"
|
||||
errors.voice.file_io: "文件I/O错误: %{detail}"
|
||||
errors.voice.http: "HTTP请求错误: %{detail}"
|
||||
errors.voice.serialization: "序列化错误: %{detail}"
|
||||
errors.voice.json: "JSON错误: %{detail}"
|
||||
errors.voice.config_rs: "配置读取错误: %{detail}"
|
||||
errors.voice.daemon: "守护进程错误: %{detail}"
|
||||
errors.voice.unsupported_format: "不支持的音频格式: %{detail}"
|
||||
errors.voice.file_too_large: "文件过大: %{size} 字节 (最大: %{max} 字节)"
|
||||
errors.voice.model_not_found: "模型未找到: %{model}"
|
||||
errors.voice.invalid_model_name: "无效的模型名称: %{model}"
|
||||
errors.voice.worker_pool: "工作池错误: %{detail}"
|
||||
errors.voice.transcription_timeout: "转录超时 (%{seconds} 秒后)"
|
||||
errors.voice.transcription_failed: "转录失败: %{detail}"
|
||||
errors.voice.audio_conversion_failed: "音频转换失败: %{detail}"
|
||||
errors.voice.audio_probe_error: "音频探测错误: %{detail}"
|
||||
errors.voice.temp_file_error: "临时文件错误: %{detail}"
|
||||
errors.voice.multipart_error: "Multipart表单错误: %{detail}"
|
||||
errors.voice.missing_field: "缺少必填字段: %{field}"
|
||||
errors.voice.network: "网络错误: %{detail}"
|
||||
errors.voice.storage: "存储错误: %{detail}"
|
||||
errors.voice.task_management_disabled: "任务管理已禁用"
|
||||
errors.voice.not_found: "资源未找到: %{resource}"
|
||||
errors.voice.initialization: "初始化错误: %{detail}"
|
||||
errors.voice.tts: "TTS错误: %{detail}"
|
||||
errors.voice.invalid_input: "无效输入: %{detail}"
|
||||
errors.voice.io: "IO错误: %{detail}"
|
||||
# ===========================================
|
||||
# CLI 消息 - mcp-proxy 启动
|
||||
# ===========================================
|
||||
cli.mirror.not_configured: "未配置镜像源(npm/PyPI),将使用默认源"
|
||||
cli.mirror.npm: "npm 镜像: %{url}"
|
||||
cli.mirror.pypi: "PyPI 镜像: %{url}"
|
||||
cli.startup.service_starting: "MCP-Proxy 启动中..."
|
||||
cli.startup.version: "版本: %{version}"
|
||||
cli.startup.config_loaded: "配置加载完成"
|
||||
cli.startup.port: "端口: %{port}"
|
||||
cli.startup.log_dir: "日志目录: %{path}"
|
||||
cli.startup.log_level: "日志级别: %{level}"
|
||||
cli.startup.log_retain_days: "日志保留天数: %{days}"
|
||||
cli.startup.success: "✅ 服务启动成功,监听地址: %{addr}"
|
||||
cli.startup.health_endpoint: "✅ 健康检查端点: http://%{addr}/health"
|
||||
cli.startup.mcp_list: "✅ MCP 服务列表: http://%{addr}/mcp"
|
||||
cli.startup.schedule_task_started: "✅ MCP服务状态检查定时任务已启动"
|
||||
cli.startup.log_rotation_configured: "✅ 日志自动轮转已配置(保留最近 %{count} 个日志文件)"
|
||||
cli.startup.system_info: "系统信息:"
|
||||
cli.startup.os: "操作系统: %{os}"
|
||||
cli.startup.arch: "架构: %{arch}"
|
||||
cli.startup.work_dir: "工作目录: %{path}"
|
||||
cli.startup.env_override: "环境变量覆盖:"
|
||||
cli.startup.env_port_override: "MCP_PROXY_PORT: %{port}"
|
||||
cli.startup.env_log_dir_override: "MCP_PROXY_LOG_DIR: %{dir}"
|
||||
cli.startup.env_log_level_override: "MCP_PROXY_LOG_LEVEL: %{level}"
|
||||
cli.startup.trying_bind: "尝试绑定到地址: %{addr}"
|
||||
cli.startup.bind_success: "成功绑定到地址: %{addr}"
|
||||
cli.startup.bind_failed: "绑定地址 %{addr} 失败: %{error}"
|
||||
cli.startup.init_state: "初始化应用状态..."
|
||||
cli.startup.state_done: "应用状态初始化完成"
|
||||
cli.startup.init_router: "初始化路由..."
|
||||
cli.startup.router_done: "路由初始化完成"
|
||||
cli.startup.warming_up: "\U0001F504 开始预热 uv/deno 环境依赖..."
|
||||
cli.startup.warmup_success: "✅ 预热 uv/deno 环境依赖完成"
|
||||
cli.startup.warmup_failed: "❌ 预热 uv/deno 环境依赖失败: %{error}"
|
||||
cli.startup.http_server_starting: "\U0001F680 HTTP 服务器启动,等待连接..."
|
||||
cli.startup.proxy_mode: "命令: proxy (HTTP 服务器模式)"
|
||||
cli.startup.config_info: "配置信息:"
|
||||
cli.startup.listen_port: "监听端口: %{port}"
|
||||
cli.startup.log_retention: "日志保留: %{days} 天"
|
||||
# CLI 消息 - mcp-proxy 关闭
|
||||
cli.shutdown.signal_received: "⚠️ 服务器收到关闭信号,开始清理资源..."
|
||||
cli.shutdown.cleanup_success: "✅ 资源清理成功"
|
||||
cli.shutdown.cleanup_failed: "❌ 清理资源时出错: %{error}"
|
||||
cli.shutdown.complete: "✅ 资源清理完成,服务已完全关闭"
|
||||
cli.shutdown.service_error: "❌ 服务运行错误: %{error}"
|
||||
# CLI 消息 - panic 处理
|
||||
cli.panic.handler_started: "程序发生panic,执行清理..."
|
||||
cli.panic.reason: "Panic 原因: %{reason}"
|
||||
cli.panic.reason_unknown: "Panic 原因: 未知"
|
||||
cli.panic.location: "Panic 位置: %{file}:%{line}"
|
||||
cli.panic.stack_trace: "堆栈跟踪:"
|
||||
# ===========================================
|
||||
# CLI 消息 - convert 模式
|
||||
# ===========================================
|
||||
cli.convert.starting: "开始 URL 模式处理"
|
||||
cli.convert.target_url: "目标 URL: %{url}"
|
||||
cli.convert.protocol_specified: "使用命令行指定协议: %{protocol}"
|
||||
cli.convert.protocol_config: "使用配置文件协议: %{protocol}"
|
||||
cli.convert.detecting_protocol: "开始自动检测协议..."
|
||||
cli.convert.detect_failed: "协议检测失败: %{error}"
|
||||
cli.convert.using_protocol: "使用 %{protocol} 协议模式"
|
||||
cli.convert.stdio_url_not_supported: "Stdio 协议不支持通过 URL 转换"
|
||||
cli.convert.tool_whitelist: "工具白名单: %{tools}"
|
||||
cli.convert.tool_blacklist: "工具黑名单: %{tools}"
|
||||
cli.convert.config_parsed: "配置解析成功"
|
||||
cli.convert.service_name: "MCP 服务名称: %{name}"
|
||||
cli.convert.service_name_not_specified: "MCP 服务名称: 未指定(使用 direct URL)"
|
||||
cli.convert.cli_starting: "MCP-Proxy CLI 启动"
|
||||
cli.convert.command: "命令: convert (stdio 桥接模式)"
|
||||
cli.convert.version: "版本: %{version}"
|
||||
cli.convert.diagnostic_mode: "诊断模式: %{enabled}"
|
||||
cli.convert.mode_direct_url: "模式: 直接 URL 模式"
|
||||
cli.convert.mode_remote_service: "模式: 远程服务配置模式"
|
||||
cli.convert.service_url: "服务 URL: %{url}"
|
||||
cli.convert.config_protocol: "配置协议: %{protocol}"
|
||||
cli.convert.ping_config: "Ping 间隔: %{interval}s, Ping 超时: %{timeout}s"
|
||||
cli.convert.connecting_backend: "开始连接到后端服务 (超时: %{timeout}s)..."
|
||||
cli.convert.detect_complete: "协议检测完成: %{protocol} (耗时: %{duration})"
|
||||
# ===========================================
|
||||
# CLI 消息 - SSE 模式
|
||||
# ===========================================
|
||||
cli.sse.mode_starting: "SSE 模式启动"
|
||||
cli.sse.connect_timeout: "连接后端超时 (%{seconds}s)"
|
||||
cli.sse.connect_failed: "连接后端失败: %{error}"
|
||||
cli.sse.connect_success: "后端连接成功 (耗时: %{duration})"
|
||||
cli.sse.stdio_starting: "启动 stdio server..."
|
||||
cli.sse.stdio_started: "stdio server 已启动"
|
||||
cli.sse.waiting_events: "开始等待 stdio server 事件..."
|
||||
cli.sse.stdio_exit_eof: "stdio server 退出 - 原因: MCP 客户端断开连接 (stdin EOF)"
|
||||
cli.sse.watchdog_exit: "Watchdog 任务退出"
|
||||
cli.sse.normal_exit: "mcp-proxy convert (SSE 模式) 正常退出"
|
||||
cli.sse.watchdog_starting: "SSE Watchdog 启动"
|
||||
cli.sse.max_retries: "最大重试次数: %{count} (0=无限)"
|
||||
cli.sse.monitoring_connection: "开始监控初始连接..."
|
||||
cli.sse.initial_disconnect: "初始连接断开: %{reason}"
|
||||
cli.sse.connection_alive: "初始连接存活时长: %{seconds}s"
|
||||
cli.sse.reconnect_attempt: "重连尝试 #%{attempt}/%{max}"
|
||||
cli.sse.backoff_time: "退避时间: %{seconds}s"
|
||||
cli.sse.reconnect_success: "重连成功 (耗时: %{duration})"
|
||||
cli.sse.monitoring_reconnect: "开始监控重连后的连接..."
|
||||
cli.sse.reconnect_disconnect: "重连后断开: %{reason}"
|
||||
cli.sse.reconnect_alive: "重连后存活时长: %{seconds}s"
|
||||
cli.sse.max_retries_reached: "达到最大重试次数 (%{count}), 停止重连"
|
||||
cli.sse.watchdog_exit_msg: "SSE Watchdog 退出"
|
||||
# ===========================================
|
||||
# CLI 消息 - Stream 模式
|
||||
# ===========================================
|
||||
cli.stream.mode_starting: "Stream 模式启动"
|
||||
cli.stream.connect_timeout: "连接后端超时 (%{seconds}s)"
|
||||
cli.stream.connect_failed: "连接后端失败: %{error}"
|
||||
cli.stream.connect_success: "后端连接成功 (耗时: %{duration})"
|
||||
cli.stream.stdio_starting: "启动 stdio server..."
|
||||
cli.stream.stdio_started: "stdio server 已启动"
|
||||
cli.stream.waiting_events: "开始等待 stdio server 事件..."
|
||||
cli.stream.stdio_exit_eof: "stdio server 退出 - 原因: MCP 客户端断开连接 (stdin EOF)"
|
||||
cli.stream.watchdog_exit: "Watchdog 任务退出"
|
||||
cli.stream.normal_exit: "mcp-proxy convert (Stream 模式) 正常退出"
|
||||
# ===========================================
|
||||
# CLI 消息 - Command 模式
|
||||
# ===========================================
|
||||
cli.command.local_mode: "模式: 本地命令模式"
|
||||
cli.command.command: "命令: %{cmd} %{args}"
|
||||
cli.command.ctrl_c: "收到 Ctrl+C 信号,正在关闭..."
|
||||
# ===========================================
|
||||
# CLI 消息 - 监控
|
||||
# ===========================================
|
||||
cli.monitoring.health: "开始监控 %{protocol} 连接健康状态"
|
||||
cli.monitoring.health_check_status: "\U0001F493 [%{protocol}][健康检查] 连接状态: %{status} (仅检查连接通道, 未调用 list_tools), 检查 #%{count}, 已存活: %{seconds}s"
|
||||
cli.monitoring.health_check_normal: "\U0001F493 [%{protocol}][健康检查] 后端服务正常 (list_tools 验证通过), Ping #%{count}, 已存活: %{seconds}s"
|
||||
# ===========================================
|
||||
# 诊断消息
|
||||
# ===========================================
|
||||
diagnostic.report_header: "========== 诊断报告 =========="
|
||||
diagnostic.connection_protocol: "连接协议: %{protocol}"
|
||||
diagnostic.service_url: "服务 URL: %{url}"
|
||||
diagnostic.connection_duration: "连接存活时长: %{seconds} 秒"
|
||||
diagnostic.disconnect_reason: "断开原因: %{reason}"
|
||||
diagnostic.error_type: "错误类型: %{error_type}"
|
||||
diagnostic.possible_causes: "可能原因分析:"
|
||||
diagnostic.suggestions: "建议:"
|
||||
# 诊断 - 30秒超时分析
|
||||
diagnostic.analysis.timeout_30s: "⚠️ 连接在约 30 秒时断开,极有可能是:"
|
||||
diagnostic.analysis.timeout_30s_cause1: "服务器端设置了 30 秒超时限制"
|
||||
diagnostic.analysis.timeout_30s_cause2: "负载均衡器(如 Nginx/ALB)的默认超时"
|
||||
diagnostic.analysis.timeout_30s_cause3: "云服务商的网关超时限制"
|
||||
# 诊断 - 快速断开分析
|
||||
diagnostic.analysis.quick_disconnect: "⚠️ 连接很快断开(%{seconds}秒),可能是:"
|
||||
diagnostic.analysis.quick_disconnect_cause1: "认证失败或 token 无效"
|
||||
diagnostic.analysis.quick_disconnect_cause2: "服务器拒绝连接"
|
||||
diagnostic.analysis.quick_disconnect_cause3: "网络不稳定"
|
||||
# 诊断 - 长时间连接分析
|
||||
diagnostic.analysis.long_connection: "✅ 连接保持了较长时间(%{seconds}秒),可能是:"
|
||||
diagnostic.analysis.long_connection_cause1: "工具调用执行时间过长"
|
||||
diagnostic.analysis.long_connection_cause2: "网络波动导致断开"
|
||||
# 诊断建议
|
||||
diagnostic.suggestion.timeout_30s: "联系服务提供商增加超时限制"
|
||||
diagnostic.suggestion.timeout_client: "使用 --request-timeout 参数设置客户端超时"
|
||||
diagnostic.suggestion.async_mode: "考虑使用异步处理模式(webhook 回调)"
|
||||
diagnostic.suggestion.ping_interval: "尝试增加 ping 间隔: --ping-interval %{seconds}"
|
||||
diagnostic.suggestion.ping_timeout: "增加 ping 超时时间: --ping-timeout %{seconds}"
|
||||
diagnostic.suggestion.ping_disable: "或禁用 ping: --ping-interval 0"
|
||||
# ===========================================
|
||||
# 错误分类
|
||||
# ===========================================
|
||||
error_classify.timeout_30s: "30秒超时(可能是服务器限制)"
|
||||
error_classify.service_unavailable_503: "服务不可用(503)"
|
||||
error_classify.internal_server_error_500: "服务器内部错误(500)"
|
||||
error_classify.bad_gateway_502: "网关错误(502)"
|
||||
error_classify.gateway_timeout_504: "网关超时(504)"
|
||||
error_classify.unauthorized_401: "未授权(401)"
|
||||
error_classify.forbidden_403: "禁止访问(403)"
|
||||
error_classify.not_found_404: "资源未找到(404)"
|
||||
error_classify.request_timeout_408: "请求超时(408)"
|
||||
error_classify.timeout: "超时"
|
||||
error_classify.connection_refused: "连接被拒绝"
|
||||
error_classify.connection_reset: "连接被重置"
|
||||
error_classify.connection_closed: "连接关闭"
|
||||
error_classify.dns_failed: "DNS解析失败"
|
||||
error_classify.ssl_tls_error: "SSL/TLS错误"
|
||||
error_classify.network_error: "网络错误"
|
||||
error_classify.session_error: "会话错误"
|
||||
error_classify.unknown_error: "未知错误"
|
||||
# ===========================================
|
||||
# CLI 消息 - health 命令
|
||||
# ===========================================
|
||||
cli.health.checking: "\U0001F50D 健康检查服务: %{url}"
|
||||
cli.health.using_protocol: "\U0001F50D 使用指定协议: %{protocol}"
|
||||
cli.health.detecting_protocol: "\U0001F50D 正在检测协议..."
|
||||
cli.health.detected_protocol: "\U0001F50D 检测到 %{protocol} 协议"
|
||||
cli.health.healthy: "✅ 服务健康"
|
||||
cli.health.unhealthy: "❌ 服务不健康"
|
||||
cli.health.stdio_not_supported: "health 命令不支持 stdio 协议"
|
||||
# ===========================================
|
||||
# CLI 消息 - check 命令
|
||||
# ===========================================
|
||||
cli.check.checking: "\U0001F50D 检查服务: %{url}"
|
||||
cli.check.healthy: "✅ 服务正常,检测到 %{protocol} 协议"
|
||||
cli.check.failed: "❌ 服务检查失败: %{error}"
|
||||
# ===========================================
|
||||
# CLI 消息 - document-parser
|
||||
# ===========================================
|
||||
doc_parser.startup.app_info: "=== %{name} v%{version} 启动 ==="
|
||||
doc_parser.startup.config_summary: "配置摘要: %{summary}"
|
||||
doc_parser.startup.listening: "服务监听地址: %{host}:%{port}"
|
||||
doc_parser.startup.checking_python: "开始检查和初始化Python环境..."
|
||||
doc_parser.startup.checking_venv: "检查并激活虚拟环境..."
|
||||
doc_parser.startup.venv_activate_failed: "虚拟环境自动激活失败: %{error}"
|
||||
doc_parser.startup.venv_activate_manual: "请手动激活虚拟环境: source ./venv/bin/activate"
|
||||
doc_parser.startup.venv_activated: "虚拟环境已自动激活"
|
||||
doc_parser.startup.env_check_failed: "环境检查失败,将在后台自动安装: %{error}"
|
||||
doc_parser.startup.mineru_not_installed: "MinerU依赖未安装,开始后台自动安装..."
|
||||
doc_parser.startup.markitdown_not_installed: "MarkItDown依赖未安装,开始后台自动安装..."
|
||||
doc_parser.startup.install_complete: "后台Python环境安装完成"
|
||||
doc_parser.startup.install_failed: "后台Python环境安装失败: %{error}"
|
||||
doc_parser.startup.install_task_started: "Python依赖安装任务已启动(后台进行),服务将正常启动"
|
||||
doc_parser.startup.mineru_ready: "MinerU依赖已安装,版本: %{version}"
|
||||
doc_parser.startup.markitdown_ready: "MarkItDown依赖已安装"
|
||||
doc_parser.startup.python_ready: "Python环境检查完成,所有依赖已就绪"
|
||||
doc_parser.startup.state_failed: "无法创建应用状态: %{error}"
|
||||
doc_parser.startup.health_check_failed: "应用健康检查失败: %{error}"
|
||||
doc_parser.startup.state_ready: "应用状态初始化成功"
|
||||
doc_parser.startup.http_routes_ready: "HTTP路由初始化成功"
|
||||
doc_parser.startup.background_tasks_started: "后台任务已启动"
|
||||
doc_parser.startup.service_ready: "服务启动成功,开始监听连接..."
|
||||
doc_parser.shutdown.service_stopped: "服务已关闭"
|
||||
doc_parser.shutdown.ctrl_c_received: "收到 Ctrl+C 信号,开始优雅关闭..."
|
||||
doc_parser.shutdown.terminate_received: "收到 terminate 信号,开始优雅关闭..."
|
||||
doc_parser.shutdown.closing: "正在关闭服务..."
|
||||
# uv-init 命令
|
||||
doc_parser.uv_init.starting: "\U0001F680 开始在当前目录初始化uv虚拟环境和依赖..."
|
||||
doc_parser.uv_init.current_dir: "\U0001F4C1 当前工作目录: %{path}"
|
||||
doc_parser.uv_init.venv_path: "\U0001F4C1 虚拟环境将创建在: ./venv/"
|
||||
doc_parser.uv_init.validating_dir: "\U0001F50D 验证当前目录设置..."
|
||||
doc_parser.uv_init.dir_valid: " ✅ 目录验证通过"
|
||||
doc_parser.uv_init.warnings_found: " ⚠️ 发现 %{count} 个警告"
|
||||
doc_parser.uv_init.dir_invalid: " ❌ 目录验证失败,发现 %{count} 个问题"
|
||||
doc_parser.uv_init.auto_fixing: " \U0001F527 尝试自动修复 %{count} 个问题..."
|
||||
doc_parser.uv_init.fix_success: " ✅ %{message}"
|
||||
doc_parser.uv_init.fix_failed: " ❌ 清理失败: %{error}"
|
||||
doc_parser.uv_init.manual_fix_hint: " \U0001F4A1 请手动解决以下问题:"
|
||||
doc_parser.uv_init.dir_validation_failed: "目录验证失败,请解决上述问题后重试"
|
||||
doc_parser.uv_init.dir_validation_error: " ⚠️ 目录验证失败: %{error}"
|
||||
doc_parser.uv_init.continue_install: " 继续进行安装,但可能遇到问题..."
|
||||
# 环境检查
|
||||
doc_parser.check.checking_env: "\U0001F50D 检查当前环境状态..."
|
||||
doc_parser.check.env_check_complete: " 环境检查完成:"
|
||||
doc_parser.check.python_available: "✅ 可用"
|
||||
doc_parser.check.python_unavailable: "❌ 不可用"
|
||||
doc_parser.check.uv_available: "✅ 可用"
|
||||
doc_parser.check.uv_unavailable: "❌ 不可用"
|
||||
doc_parser.check.venv_active: "✅ 激活"
|
||||
doc_parser.check.venv_inactive: "❌ 未激活"
|
||||
doc_parser.check.mineru_available: "✅ 可用"
|
||||
doc_parser.check.mineru_unavailable: "❌ 不可用"
|
||||
doc_parser.check.markitdown_available: "✅ 可用"
|
||||
doc_parser.check.markitdown_unavailable: "❌ 不可用"
|
||||
doc_parser.check.env_check_failed: " ⚠️ 环境检查失败: %{error}"
|
||||
doc_parser.check.continue_install: " 继续进行安装..."
|
||||
# 安装过程
|
||||
doc_parser.install.all_ready: "✨ 所有依赖都已就绪,无需安装!"
|
||||
doc_parser.install.plan: "\U0001F4CB 安装计划:"
|
||||
doc_parser.install.install_uv: "安装 uv 工具"
|
||||
doc_parser.install.create_venv: "创建虚拟环境 (./venv/)"
|
||||
doc_parser.install.install_mineru: "安装 MinerU 依赖"
|
||||
doc_parser.install.install_markitdown: "安装 MarkItDown 依赖"
|
||||
doc_parser.install.starting: "⚙️ 开始设置Python环境和依赖..."
|
||||
doc_parser.install.wait_hint: "这可能需要几分钟时间,请耐心等待..."
|
||||
doc_parser.install.path_issues: "⚠️ 检测到潜在的路径问题:"
|
||||
doc_parser.install.auto_fixing: "\U0001F527 尝试自动修复问题..."
|
||||
doc_parser.install.fixed: "✅ 已修复以下问题:"
|
||||
doc_parser.install.cannot_auto_fix: "无法自动修复,请手动解决上述问题"
|
||||
doc_parser.install.fix_failed: "❌ 自动修复失败: %{error}"
|
||||
doc_parser.install.manual_fix_hint: "\U0001F4A1 手动修复建议:"
|
||||
doc_parser.install.python_setup_complete: "✅ Python环境设置完成!"
|
||||
doc_parser.install.python_setup_failed: "❌ Python环境设置失败: %{error}"
|
||||
doc_parser.install.detailed_hints: "\U0001F4A1 详细故障排除建议:"
|
||||
doc_parser.install.diagnostic_commands: "\U0001F50D 诊断命令:"
|
||||
doc_parser.install.check_env: "检查环境状态: document-parser check"
|
||||
doc_parser.install.view_logs: "查看详细日志: 检查 logs/ 目录"
|
||||
# 验证
|
||||
doc_parser.verify.starting: "\U0001F50D 验证安装结果..."
|
||||
doc_parser.verify.complete: "验证完成:"
|
||||
doc_parser.verify.version: "版本: %{version}"
|
||||
doc_parser.verify.partial_issues: "⚠️ 部分依赖安装可能存在问题"
|
||||
doc_parser.verify.need_fix: "\U0001F527 需要解决的问题:"
|
||||
doc_parser.verify.suggestion: "建议: %{suggestion}"
|
||||
doc_parser.verify.init_incomplete: "环境初始化未完全成功"
|
||||
doc_parser.verify.failed: "❌ 验证失败: %{error}"
|
||||
# 成功消息
|
||||
doc_parser.success.init_complete: "\U0001F389 uv环境初始化完成!"
|
||||
doc_parser.success.all_ready: "✨ 所有依赖都已就绪,现在可以启动服务器了"
|
||||
doc_parser.success.venv_activate: "\U0001F4CB 虚拟环境激活指令:"
|
||||
doc_parser.success.start_server: "\U0001F680 启动服务器:"
|
||||
doc_parser.success.use_uv: "\U0001F527 或者使用 uv 直接运行命令:"
|
||||
doc_parser.success.more_help: "\U0001F4DA 更多帮助:"
|
||||
doc_parser.success.tips: "\U0001F4A1 提示:"
|
||||
doc_parser.success.venv_location: "虚拟环境位置: ./venv/"
|
||||
doc_parser.success.python_path: "Python可执行文件: ./venv/bin/python (Linux/macOS) 或 .\\venv\\Scripts\\python.exe (Windows)"
|
||||
doc_parser.success.troubleshoot_hint: "如遇问题,请运行 'document-parser troubleshoot' 查看详细指南"
|
||||
# troubleshoot 命令
|
||||
doc_parser.troubleshoot.title: "\U0001F527 Document Parser 故障排除指南"
|
||||
doc_parser.troubleshoot.env_overview: "\U0001F4CA 当前环境概览:"
|
||||
doc_parser.troubleshoot.work_dir: "工作目录: %{path}"
|
||||
doc_parser.troubleshoot.venv_path: "虚拟环境: ./venv/"
|
||||
doc_parser.troubleshoot.os: "操作系统: %{os}"
|
||||
# 虚拟环境问题
|
||||
doc_parser.troubleshoot.venv_problems: "\U0001F3E0 1. 虚拟环境问题"
|
||||
doc_parser.troubleshoot.venv_create_failed: "❓ 问题: 虚拟环境创建失败"
|
||||
doc_parser.troubleshoot.diagnostic_steps: "\U0001F50D 诊断步骤:"
|
||||
doc_parser.troubleshoot.solutions: "\U0001F4A1 解决方案:"
|
||||
doc_parser.troubleshoot.venv_activate_failed: "❓ 问题: 虚拟环境激活失败"
|
||||
# 依赖安装问题
|
||||
doc_parser.troubleshoot.deps_problems: "\U0001F4E6 2. 依赖安装问题"
|
||||
doc_parser.troubleshoot.uv_not_installed: "❓ 问题: UV工具未安装或不可用"
|
||||
doc_parser.troubleshoot.mineru_markitdown_failed: "❓ 问题: MinerU或MarkItDown安装失败"
|
||||
# 网络问题
|
||||
doc_parser.troubleshoot.network_problems: "\U0001F310 3. 网络和下载问题"
|
||||
doc_parser.troubleshoot.network_timeout: "❓ 问题: 网络连接超时或下载失败"
|
||||
# 系统环境问题
|
||||
doc_parser.troubleshoot.system_problems: "⚙️ 4. 系统环境问题"
|
||||
doc_parser.troubleshoot.python_incompatible: "❓ 问题: Python版本不兼容"
|
||||
doc_parser.troubleshoot.cuda_config: "❓ 问题: CUDA环境配置 (可选,用于GPU加速)"
|
||||
# 诊断命令
|
||||
doc_parser.troubleshoot.diagnostic_commands: "\U0001F50D 5. 常用诊断命令"
|
||||
doc_parser.troubleshoot.env_check: "环境检查:"
|
||||
doc_parser.troubleshoot.manual_verify: "手动验证:"
|
||||
doc_parser.troubleshoot.view_logs: "日志查看:"
|
||||
# 获取帮助
|
||||
doc_parser.troubleshoot.more_help: "\U0001F198 6. 获取更多帮助"
|
||||
doc_parser.troubleshoot.if_unsolved: "如果上述方法都无法解决问题,请:"
|
||||
# 实时诊断
|
||||
doc_parser.troubleshoot.realtime_diagnosis: "\U0001F52C 实时环境诊断"
|
||||
doc_parser.troubleshoot.env_good: "✅ 环境状态良好,所有依赖都已就绪"
|
||||
doc_parser.troubleshoot.issues_found: "⚠️ 发现以下问题:"
|
||||
doc_parser.troubleshoot.env_check_failed: "❌ 环境检查失败: %{error}"
|
||||
doc_parser.troubleshoot.follow_guide: "请按照上述指南进行故障排除"
|
||||
# 提示
|
||||
doc_parser.troubleshoot.tip: "\U0001F4A1 提示: 大多数问题可以通过重新运行 'document-parser uv-init' 解决"
|
||||
# 后台清理
|
||||
doc_parser.background.cleanup_failed: "清理过期数据失败: %{error}"
|
||||
doc_parser.background.cleanup_complete: "后台清理任务执行完成"
|
||||
# 信号处理
|
||||
doc_parser.signal.ctrl_c_failed: "无法监听 Ctrl+C 信号"
|
||||
doc_parser.signal.terminate_failed: "无法监听 terminate 信号"
|
||||
# ===========================================
|
||||
# 通用消息
|
||||
# ===========================================
|
||||
common.yes: "是"
|
||||
common.no: "否"
|
||||
common.success: "成功"
|
||||
common.failed: "失败"
|
||||
common.error: "错误"
|
||||
common.warning: "警告"
|
||||
common.info: "信息"
|
||||
common.loading: "加载中..."
|
||||
common.please_wait: "请稍候..."
|
||||
common.retry: "重试"
|
||||
common.cancel: "取消"
|
||||
common.confirm: "确认"
|
||||
common.back: "返回"
|
||||
common.next: "下一步"
|
||||
common.previous: "上一步"
|
||||
common.done: "完成"
|
||||
common.skip: "跳过"
|
||||
468
qiming-mcp-proxy/document-parser/locales/zh-TW.yml
Normal file
468
qiming-mcp-proxy/document-parser/locales/zh-TW.yml
Normal file
@@ -0,0 +1,468 @@
|
||||
# ===========================================
|
||||
# 錯誤訊息 - mcp-proxy
|
||||
# ===========================================
|
||||
errors.mcp_proxy.service_not_found: "服務 %{service} 未找到"
|
||||
errors.mcp_proxy.service_restart_cooldown: "服務 %{service} 在重啟冷卻期內,請稍後再試"
|
||||
errors.mcp_proxy.service_startup_in_progress: "服務 %{service} 正在啟動中,請稍後再試"
|
||||
errors.mcp_proxy.service_startup_failed: "服務啟動失敗: %{mcp_id}: %{reason}"
|
||||
errors.mcp_proxy.backend_connection: "後端連線錯誤: %{detail}"
|
||||
errors.mcp_proxy.config_parse: "設定解析錯誤: %{detail}"
|
||||
errors.mcp_proxy.mcp_server_error: "MCP 伺服器錯誤: %{detail}"
|
||||
errors.mcp_proxy.json_serialization: "JSON 序列化錯誤: %{detail}"
|
||||
errors.mcp_proxy.io_error: "IO 錯誤: %{detail}"
|
||||
errors.mcp_proxy.route_not_found: "路由未找到: %{path}"
|
||||
errors.mcp_proxy.invalid_parameter: "無效的請求參數: %{detail}"
|
||||
# ===========================================
|
||||
# 錯誤訊息 - document-parser
|
||||
# ===========================================
|
||||
errors.document_parser.config: "設定錯誤: %{detail}"
|
||||
errors.document_parser.file: "檔案操作錯誤: %{detail}"
|
||||
errors.document_parser.unsupported_format: "不支援的檔案格式: %{format}"
|
||||
errors.document_parser.parse: "解析錯誤: %{detail}"
|
||||
errors.document_parser.mineru: "MinerU錯誤: %{detail}"
|
||||
errors.document_parser.markitdown: "MarkItDown錯誤: %{detail}"
|
||||
errors.document_parser.oss: "OSS操作錯誤: %{detail}"
|
||||
errors.document_parser.database: "資料庫錯誤: %{detail}"
|
||||
errors.document_parser.network: "網路錯誤: %{detail}"
|
||||
errors.document_parser.task: "任務錯誤: %{detail}"
|
||||
errors.document_parser.internal: "內部錯誤: %{detail}"
|
||||
errors.document_parser.timeout: "操作逾時: %{detail}"
|
||||
errors.document_parser.validation: "驗證錯誤: %{detail}"
|
||||
errors.document_parser.environment: "環境錯誤: %{detail}"
|
||||
errors.document_parser.virtual_environment_path: "虛擬環境路徑錯誤: %{detail}"
|
||||
errors.document_parser.permission: "權限錯誤: %{detail}"
|
||||
errors.document_parser.path: "路徑錯誤: %{detail}"
|
||||
errors.document_parser.queue: "佇列錯誤: %{detail}"
|
||||
errors.document_parser.processing: "處理錯誤: %{detail}"
|
||||
# 錯誤建議 - document-parser
|
||||
errors.document_parser.suggestions.config: "檢查設定檔案和環境變數"
|
||||
errors.document_parser.suggestions.file: "檢查檔案路徑和權限"
|
||||
errors.document_parser.suggestions.unsupported_format: "檢查檔案格式是否支援"
|
||||
errors.document_parser.suggestions.parse: "檢查檔案內容是否完整"
|
||||
errors.document_parser.suggestions.mineru: "檢查MinerU環境設定"
|
||||
errors.document_parser.suggestions.markitdown: "檢查MarkItDown環境設定"
|
||||
errors.document_parser.suggestions.oss: "檢查OSS設定和網路連線"
|
||||
errors.document_parser.suggestions.database: "檢查資料庫連線和權限"
|
||||
errors.document_parser.suggestions.network: "檢查網路連線和防火牆設定"
|
||||
errors.document_parser.suggestions.task: "檢查任務參數和狀態"
|
||||
errors.document_parser.suggestions.internal: "聯絡技術支援"
|
||||
errors.document_parser.suggestions.timeout: "檢查網路延遲或增加逾時時間"
|
||||
errors.document_parser.suggestions.validation: "檢查輸入參數格式"
|
||||
errors.document_parser.suggestions.environment: "檢查系統環境和相依套件安裝"
|
||||
errors.document_parser.suggestions.queue: "檢查佇列服務狀態和設定"
|
||||
errors.document_parser.suggestions.processing: "檢查處理流程和資料格式"
|
||||
errors.document_parser.suggestions.virtual_environment_path: "檢查虛擬環境路徑和目錄權限"
|
||||
errors.document_parser.suggestions.permission: "檢查檔案和目錄權限設定"
|
||||
errors.document_parser.suggestions.path: "檢查路徑是否存在且可存取"
|
||||
# ===========================================
|
||||
# 錯誤訊息 - oss-client
|
||||
# ===========================================
|
||||
errors.oss.config: "設定錯誤: %{detail}"
|
||||
errors.oss.network: "網路錯誤: %{detail}"
|
||||
errors.oss.file_not_found: "檔案不存在: %{path}"
|
||||
errors.oss.permission: "權限不足: %{detail}"
|
||||
errors.oss.io: "IO錯誤: %{detail}"
|
||||
errors.oss.sdk: "OSS SDK錯誤: %{detail}"
|
||||
errors.oss.file_size_exceeded: "檔案大小超出限制: %{detail}"
|
||||
errors.oss.unsupported_file_type: "不支援的檔案類型: %{detail}"
|
||||
errors.oss.timeout: "操作逾時: %{detail}"
|
||||
errors.oss.invalid_parameter: "無效的參數: %{detail}"
|
||||
# ===========================================
|
||||
# 錯誤訊息 - voice-cli
|
||||
# ===========================================
|
||||
errors.voice.config: "設定錯誤: %{detail}"
|
||||
errors.voice.audio_processing: "音訊處理錯誤: %{detail}"
|
||||
errors.voice.transcription: "轉錄錯誤: %{detail}"
|
||||
errors.voice.model: "模型錯誤: %{detail}"
|
||||
errors.voice.file_io: "檔案I/O錯誤: %{detail}"
|
||||
errors.voice.http: "HTTP請求錯誤: %{detail}"
|
||||
errors.voice.serialization: "序列化錯誤: %{detail}"
|
||||
errors.voice.json: "JSON錯誤: %{detail}"
|
||||
errors.voice.config_rs: "設定讀取錯誤: %{detail}"
|
||||
errors.voice.daemon: "常駐程式錯誤: %{detail}"
|
||||
errors.voice.unsupported_format: "不支援的音訊格式: %{detail}"
|
||||
errors.voice.file_too_large: "檔案過大: %{size} 位元組 (最大: %{max} 位元組)"
|
||||
errors.voice.model_not_found: "模型未找到: %{model}"
|
||||
errors.voice.invalid_model_name: "無效的模型名稱: %{model}"
|
||||
errors.voice.worker_pool: "工作池錯誤: %{detail}"
|
||||
errors.voice.transcription_timeout: "轉錄逾時 (%{seconds} 秒後)"
|
||||
errors.voice.transcription_failed: "轉錄失敗: %{detail}"
|
||||
errors.voice.audio_conversion_failed: "音訊轉換失敗: %{detail}"
|
||||
errors.voice.audio_probe_error: "音訊探測錯誤: %{detail}"
|
||||
errors.voice.temp_file_error: "暫存檔錯誤: %{detail}"
|
||||
errors.voice.multipart_error: "Multipart表單錯誤: %{detail}"
|
||||
errors.voice.missing_field: "缺少必填欄位: %{field}"
|
||||
errors.voice.network: "網路錯誤: %{detail}"
|
||||
errors.voice.storage: "儲存錯誤: %{detail}"
|
||||
errors.voice.task_management_disabled: "任務管理已停用"
|
||||
errors.voice.not_found: "資源未找到: %{resource}"
|
||||
errors.voice.initialization: "初始化錯誤: %{detail}"
|
||||
errors.voice.tts: "TTS錯誤: %{detail}"
|
||||
errors.voice.invalid_input: "無效輸入: %{detail}"
|
||||
errors.voice.io: "IO錯誤: %{detail}"
|
||||
# ===========================================
|
||||
# CLI 訊息 - mcp-proxy 啟動
|
||||
# ===========================================
|
||||
cli.mirror.not_configured: "未配置鏡像源(npm/PyPI),將使用預設來源"
|
||||
cli.mirror.npm: "npm 鏡像: %{url}"
|
||||
cli.mirror.pypi: "PyPI 鏡像: %{url}"
|
||||
cli.startup.service_starting: "MCP-Proxy 啟動中..."
|
||||
cli.startup.version: "版本: %{version}"
|
||||
cli.startup.config_loaded: "設定載入完成"
|
||||
cli.startup.port: "連接埠: %{port}"
|
||||
cli.startup.log_dir: "日誌目錄: %{path}"
|
||||
cli.startup.log_level: "日誌級別: %{level}"
|
||||
cli.startup.log_retain_days: "日誌保留天數: %{days}"
|
||||
cli.startup.success: "✅ 服務啟動成功,監聽位址: %{addr}"
|
||||
cli.startup.health_endpoint: "✅ 健康檢查端點: http://%{addr}/health"
|
||||
cli.startup.mcp_list: "✅ MCP 服務列表: http://%{addr}/mcp"
|
||||
cli.startup.schedule_task_started: "✅ MCP服務狀態檢查定時任務已啟動"
|
||||
cli.startup.log_rotation_configured: "✅ 日誌自動輪轉已設定(保留最近 %{count} 個日誌檔案)"
|
||||
cli.startup.system_info: "系統資訊:"
|
||||
cli.startup.os: "作業系統: %{os}"
|
||||
cli.startup.arch: "架構: %{arch}"
|
||||
cli.startup.work_dir: "工作目錄: %{path}"
|
||||
cli.startup.env_override: "環境變數覆蓋:"
|
||||
cli.startup.env_port_override: "MCP_PROXY_PORT: %{port}"
|
||||
cli.startup.env_log_dir_override: "MCP_PROXY_LOG_DIR: %{dir}"
|
||||
cli.startup.env_log_level_override: "MCP_PROXY_LOG_LEVEL: %{level}"
|
||||
cli.startup.trying_bind: "嘗試綁定到位址: %{addr}"
|
||||
cli.startup.bind_success: "成功綁定到位址: %{addr}"
|
||||
cli.startup.bind_failed: "綁定位址 %{addr} 失敗: %{error}"
|
||||
cli.startup.init_state: "初始化應用狀態..."
|
||||
cli.startup.state_done: "應用狀態初始化完成"
|
||||
cli.startup.init_router: "初始化路由..."
|
||||
cli.startup.router_done: "路由初始化完成"
|
||||
cli.startup.warming_up: "\U0001F504 開始預熱 uv/deno 環境相依套件..."
|
||||
cli.startup.warmup_success: "✅ 預熱 uv/deno 環境相依套件完成"
|
||||
cli.startup.warmup_failed: "❌ 預熱 uv/deno 環境相依套件失敗: %{error}"
|
||||
cli.startup.http_server_starting: "\U0001F680 HTTP 伺服器啟動,等待連線..."
|
||||
cli.startup.proxy_mode: "命令: proxy (HTTP 伺服器模式)"
|
||||
cli.startup.config_info: "設定資訊:"
|
||||
cli.startup.listen_port: "監聽連接埠: %{port}"
|
||||
cli.startup.log_retention: "日誌保留: %{days} 天"
|
||||
# CLI 訊息 - mcp-proxy 關閉
|
||||
cli.shutdown.signal_received: "⚠️ 伺服器收到關閉訊號,開始清理資源..."
|
||||
cli.shutdown.cleanup_success: "✅ 資源清理成功"
|
||||
cli.shutdown.cleanup_failed: "❌ 清理資源時出錯: %{error}"
|
||||
cli.shutdown.complete: "✅ 資源清理完成,服務已完全關閉"
|
||||
cli.shutdown.service_error: "❌ 服務執行錯誤: %{error}"
|
||||
# CLI 訊息 - panic 處理
|
||||
cli.panic.handler_started: "程式發生panic,執行清理..."
|
||||
cli.panic.reason: "Panic 原因: %{reason}"
|
||||
cli.panic.reason_unknown: "Panic 原因: 未知"
|
||||
cli.panic.location: "Panic 位置: %{file}:%{line}"
|
||||
cli.panic.stack_trace: "堆疊追蹤:"
|
||||
# ===========================================
|
||||
# CLI 訊息 - convert 模式
|
||||
# ===========================================
|
||||
cli.convert.starting: "開始 URL 模式處理"
|
||||
cli.convert.target_url: "目標 URL: %{url}"
|
||||
cli.convert.protocol_specified: "使用命令列指定協定: %{protocol}"
|
||||
cli.convert.protocol_config: "使用設定檔協定: %{protocol}"
|
||||
cli.convert.detecting_protocol: "開始自動偵測協定..."
|
||||
cli.convert.detect_failed: "協定偵測失敗: %{error}"
|
||||
cli.convert.using_protocol: "使用 %{protocol} 協定模式"
|
||||
cli.convert.stdio_url_not_supported: "Stdio 協定不支援透過 URL 轉換"
|
||||
cli.convert.tool_whitelist: "工具白名單: %{tools}"
|
||||
cli.convert.tool_blacklist: "工具黑名單: %{tools}"
|
||||
cli.convert.config_parsed: "設定解析成功"
|
||||
cli.convert.service_name: "MCP 服務名稱: %{name}"
|
||||
cli.convert.service_name_not_specified: "MCP 服務名稱: 未指定(使用 direct URL)"
|
||||
cli.convert.cli_starting: "MCP-Proxy CLI 啟動"
|
||||
cli.convert.command: "命令: convert (stdio 橋接模式)"
|
||||
cli.convert.version: "版本: %{version}"
|
||||
cli.convert.diagnostic_mode: "診斷模式: %{enabled}"
|
||||
cli.convert.mode_direct_url: "模式: 直接 URL 模式"
|
||||
cli.convert.mode_remote_service: "模式: 遠端服務設定模式"
|
||||
cli.convert.service_url: "服務 URL: %{url}"
|
||||
cli.convert.config_protocol: "設定協定: %{protocol}"
|
||||
cli.convert.ping_config: "Ping 間隔: %{interval}s, Ping 逾時: %{timeout}s"
|
||||
cli.convert.connecting_backend: "開始連線到後端服務 (逾時: %{timeout}s)..."
|
||||
cli.convert.detect_complete: "協定偵測完成: %{protocol} (耗時: %{duration})"
|
||||
# ===========================================
|
||||
# CLI 訊息 - SSE 模式
|
||||
# ===========================================
|
||||
cli.sse.mode_starting: "SSE 模式啟動"
|
||||
cli.sse.connect_timeout: "連線後端逾時 (%{seconds}s)"
|
||||
cli.sse.connect_failed: "連線後端失敗: %{error}"
|
||||
cli.sse.connect_success: "後端連線成功 (耗時: %{duration})"
|
||||
cli.sse.stdio_starting: "啟動 stdio server..."
|
||||
cli.sse.stdio_started: "stdio server 已啟動"
|
||||
cli.sse.waiting_events: "開始等待 stdio server 事件..."
|
||||
cli.sse.stdio_exit_eof: "stdio server 結束 - 原因: MCP 客戶端斷開連線 (stdin EOF)"
|
||||
cli.sse.watchdog_exit: "Watchdog 任務結束"
|
||||
cli.sse.normal_exit: "mcp-proxy convert (SSE 模式) 正常結束"
|
||||
cli.sse.watchdog_starting: "SSE Watchdog 啟動"
|
||||
cli.sse.max_retries: "最大重試次數: %{count} (0=無限)"
|
||||
cli.sse.monitoring_connection: "開始監控初始連線..."
|
||||
cli.sse.initial_disconnect: "初始連線斷開: %{reason}"
|
||||
cli.sse.connection_alive: "初始連線存活時長: %{seconds}s"
|
||||
cli.sse.reconnect_attempt: "重連嘗試 #%{attempt}/%{max}"
|
||||
cli.sse.backoff_time: "退避時間: %{seconds}s"
|
||||
cli.sse.reconnect_success: "重連成功 (耗時: %{duration})"
|
||||
cli.sse.monitoring_reconnect: "開始監控重連後的連線..."
|
||||
cli.sse.reconnect_disconnect: "重連後斷開: %{reason}"
|
||||
cli.sse.reconnect_alive: "重連後存活時長: %{seconds}s"
|
||||
cli.sse.max_retries_reached: "達到最大重試次數 (%{count}), 停止重連"
|
||||
cli.sse.watchdog_exit_msg: "SSE Watchdog 結束"
|
||||
# ===========================================
|
||||
# CLI 訊息 - Stream 模式
|
||||
# ===========================================
|
||||
cli.stream.mode_starting: "Stream 模式啟動"
|
||||
cli.stream.connect_timeout: "連線後端逾時 (%{seconds}s)"
|
||||
cli.stream.connect_failed: "連線後端失敗: %{error}"
|
||||
cli.stream.connect_success: "後端連線成功 (耗時: %{duration})"
|
||||
cli.stream.stdio_starting: "啟動 stdio server..."
|
||||
cli.stream.stdio_started: "stdio server 已啟動"
|
||||
cli.stream.waiting_events: "開始等待 stdio server 事件..."
|
||||
cli.stream.stdio_exit_eof: "stdio server 結束 - 原因: MCP 客戶端斷開連線 (stdin EOF)"
|
||||
cli.stream.watchdog_exit: "Watchdog 任務結束"
|
||||
cli.stream.normal_exit: "mcp-proxy convert (Stream 模式) 正常結束"
|
||||
# ===========================================
|
||||
# CLI 訊息 - Command 模式
|
||||
# ===========================================
|
||||
cli.command.local_mode: "模式: 本地命令模式"
|
||||
cli.command.command: "命令: %{cmd} %{args}"
|
||||
cli.command.ctrl_c: "收到 Ctrl+C 訊號,正在關閉..."
|
||||
# ===========================================
|
||||
# CLI 訊息 - 監控
|
||||
# ===========================================
|
||||
cli.monitoring.health: "開始監控 %{protocol} 連線健康狀態"
|
||||
cli.monitoring.health_check_status: "\U0001F493 [%{protocol}][健康檢查] 連線狀態: %{status} (僅檢查連線通道, 未呼叫 list_tools), 檢查 #%{count}, 已存活: %{seconds}s"
|
||||
cli.monitoring.health_check_normal: "\U0001F493 [%{protocol}][健康檢查] 後端服務正常 (list_tools 驗證通過), Ping #%{count}, 已存活: %{seconds}s"
|
||||
# ===========================================
|
||||
# 診斷訊息
|
||||
# ===========================================
|
||||
diagnostic.report_header: "========== 診斷報告 =========="
|
||||
diagnostic.connection_protocol: "連線協定: %{protocol}"
|
||||
diagnostic.service_url: "服務 URL: %{url}"
|
||||
diagnostic.connection_duration: "連線存活時長: %{seconds} 秒"
|
||||
diagnostic.disconnect_reason: "斷開原因: %{reason}"
|
||||
diagnostic.error_type: "錯誤類型: %{error_type}"
|
||||
diagnostic.possible_causes: "可能原因分析:"
|
||||
diagnostic.suggestions: "建議:"
|
||||
# 診斷 - 30秒逾時分析
|
||||
diagnostic.analysis.timeout_30s: "⚠️ 連線在約 30 秒時斷開,極有可能是:"
|
||||
diagnostic.analysis.timeout_30s_cause1: "伺服器端設定了 30 秒逾時限制"
|
||||
diagnostic.analysis.timeout_30s_cause2: "負載平衡器(如 Nginx/ALB)的預設逾時"
|
||||
diagnostic.analysis.timeout_30s_cause3: "雲端服務商的閘道逾時限制"
|
||||
# 診斷 - 快速斷開分析
|
||||
diagnostic.analysis.quick_disconnect: "⚠️ 連線很快斷開(%{seconds}秒),可能是:"
|
||||
diagnostic.analysis.quick_disconnect_cause1: "認證失敗或 token 無效"
|
||||
diagnostic.analysis.quick_disconnect_cause2: "伺服器拒絕連線"
|
||||
diagnostic.analysis.quick_disconnect_cause3: "網路不穩定"
|
||||
# 診斷 - 長時間連線分析
|
||||
diagnostic.analysis.long_connection: "✅ 連線保持了較長時間(%{seconds}秒),可能是:"
|
||||
diagnostic.analysis.long_connection_cause1: "工具呼叫執行時間過長"
|
||||
diagnostic.analysis.long_connection_cause2: "網路波動導致斷開"
|
||||
# 診斷建議
|
||||
diagnostic.suggestion.timeout_30s: "聯絡服務提供商增加逾時限制"
|
||||
diagnostic.suggestion.timeout_client: "使用 --request-timeout 參數設定客戶端逾時"
|
||||
diagnostic.suggestion.async_mode: "考慮使用非同步處理模式(webhook 回呼)"
|
||||
diagnostic.suggestion.ping_interval: "嘗試增加 ping 間隔: --ping-interval %{seconds}"
|
||||
diagnostic.suggestion.ping_timeout: "增加 ping 逾時時間: --ping-timeout %{seconds}"
|
||||
diagnostic.suggestion.ping_disable: "或停用 ping: --ping-interval 0"
|
||||
# ===========================================
|
||||
# 錯誤分類
|
||||
# ===========================================
|
||||
error_classify.timeout_30s: "30秒逾時(可能是伺服器限制)"
|
||||
error_classify.service_unavailable_503: "服務不可用(503)"
|
||||
error_classify.internal_server_error_500: "伺服器內部錯誤(500)"
|
||||
error_classify.bad_gateway_502: "閘道錯誤(502)"
|
||||
error_classify.gateway_timeout_504: "閘道逾時(504)"
|
||||
error_classify.unauthorized_401: "未授權(401)"
|
||||
error_classify.forbidden_403: "禁止存取(403)"
|
||||
error_classify.not_found_404: "資源未找到(404)"
|
||||
error_classify.request_timeout_408: "請求逾時(408)"
|
||||
error_classify.timeout: "逾時"
|
||||
error_classify.connection_refused: "連線被拒絕"
|
||||
error_classify.connection_reset: "連線被重設"
|
||||
error_classify.connection_closed: "連線關閉"
|
||||
error_classify.dns_failed: "DNS解析失敗"
|
||||
error_classify.ssl_tls_error: "SSL/TLS錯誤"
|
||||
error_classify.network_error: "網路錯誤"
|
||||
error_classify.session_error: "工作階段錯誤"
|
||||
error_classify.unknown_error: "未知錯誤"
|
||||
# ===========================================
|
||||
# CLI 訊息 - health 命令
|
||||
# ===========================================
|
||||
cli.health.checking: "\U0001F50D 健康檢查服務: %{url}"
|
||||
cli.health.using_protocol: "\U0001F50D 使用指定協定: %{protocol}"
|
||||
cli.health.detecting_protocol: "\U0001F50D 正在偵測協定..."
|
||||
cli.health.detected_protocol: "\U0001F50D 偵測到 %{protocol} 協定"
|
||||
cli.health.healthy: "✅ 服務健康"
|
||||
cli.health.unhealthy: "❌ 服務不健康"
|
||||
cli.health.stdio_not_supported: "health 命令不支援 stdio 協定"
|
||||
# ===========================================
|
||||
# CLI 訊息 - check 命令
|
||||
# ===========================================
|
||||
cli.check.checking: "\U0001F50D 檢查服務: %{url}"
|
||||
cli.check.healthy: "✅ 服務正常,偵測到 %{protocol} 協定"
|
||||
cli.check.failed: "❌ 服務檢查失敗: %{error}"
|
||||
# ===========================================
|
||||
# CLI 訊息 - document-parser
|
||||
# ===========================================
|
||||
doc_parser.startup.app_info: "=== %{name} v%{version} 啟動 ==="
|
||||
doc_parser.startup.config_summary: "設定摘要: %{summary}"
|
||||
doc_parser.startup.listening: "服務監聽位址: %{host}:%{port}"
|
||||
doc_parser.startup.checking_python: "開始檢查和初始化Python環境..."
|
||||
doc_parser.startup.checking_venv: "檢查並啟用虛擬環境..."
|
||||
doc_parser.startup.venv_activate_failed: "虛擬環境自動啟用失敗: %{error}"
|
||||
doc_parser.startup.venv_activate_manual: "請手動啟用虛擬環境: source ./venv/bin/activate"
|
||||
doc_parser.startup.venv_activated: "虛擬環境已自動啟用"
|
||||
doc_parser.startup.env_check_failed: "環境檢查失敗,將在背景自動安裝: %{error}"
|
||||
doc_parser.startup.mineru_not_installed: "MinerU相依套件未安裝,開始背景自動安裝..."
|
||||
doc_parser.startup.markitdown_not_installed: "MarkItDown相依套件未安裝,開始背景自動安裝..."
|
||||
doc_parser.startup.install_complete: "背景Python環境安裝完成"
|
||||
doc_parser.startup.install_failed: "背景Python環境安裝失敗: %{error}"
|
||||
doc_parser.startup.install_task_started: "Python相依套件安裝任務已啟動(背景進行),服務將正常啟動"
|
||||
doc_parser.startup.mineru_ready: "MinerU相依套件已安裝,版本: %{version}"
|
||||
doc_parser.startup.markitdown_ready: "MarkItDown相依套件已安裝"
|
||||
doc_parser.startup.python_ready: "Python環境檢查完成,所有相依套件已就緒"
|
||||
doc_parser.startup.state_failed: "無法建立應用狀態: %{error}"
|
||||
doc_parser.startup.health_check_failed: "應用健康檢查失敗: %{error}"
|
||||
doc_parser.startup.state_ready: "應用狀態初始化成功"
|
||||
doc_parser.startup.http_routes_ready: "HTTP路由初始化成功"
|
||||
doc_parser.startup.background_tasks_started: "背景任務已啟動"
|
||||
doc_parser.startup.service_ready: "服務啟動成功,開始監聽連線..."
|
||||
doc_parser.shutdown.service_stopped: "服務已關閉"
|
||||
doc_parser.shutdown.ctrl_c_received: "收到 Ctrl+C 訊號,開始優雅關閉..."
|
||||
doc_parser.shutdown.terminate_received: "收到 terminate 訊號,開始優雅關閉..."
|
||||
doc_parser.shutdown.closing: "正在關閉服務..."
|
||||
# uv-init 命令
|
||||
doc_parser.uv_init.starting: "\U0001F680 開始在目前目錄初始化uv虛擬環境和相依套件..."
|
||||
doc_parser.uv_init.current_dir: "\U0001F4C1 目前工作目錄: %{path}"
|
||||
doc_parser.uv_init.venv_path: "\U0001F4C1 虛擬環境將建立在: ./venv/"
|
||||
doc_parser.uv_init.validating_dir: "\U0001F50D 驗證目前目錄設定..."
|
||||
doc_parser.uv_init.dir_valid: " ✅ 目錄驗證通過"
|
||||
doc_parser.uv_init.warnings_found: " ⚠️ 發現 %{count} 個警告"
|
||||
doc_parser.uv_init.dir_invalid: " ❌ 目錄驗證失敗,發現 %{count} 個問題"
|
||||
doc_parser.uv_init.auto_fixing: " \U0001F527 嘗試自動修復 %{count} 個問題..."
|
||||
doc_parser.uv_init.fix_success: " ✅ %{message}"
|
||||
doc_parser.uv_init.fix_failed: " ❌ 清理失敗: %{error}"
|
||||
doc_parser.uv_init.manual_fix_hint: " \U0001F4A1 請手動解決以下問題:"
|
||||
doc_parser.uv_init.dir_validation_failed: "目錄驗證失敗,請解決上述問題後重試"
|
||||
doc_parser.uv_init.dir_validation_error: " ⚠️ 目錄驗證失敗: %{error}"
|
||||
doc_parser.uv_init.continue_install: " 繼續進行安裝,但可能遇到問題..."
|
||||
# 環境檢查
|
||||
doc_parser.check.checking_env: "\U0001F50D 檢查目前環境狀態..."
|
||||
doc_parser.check.env_check_complete: " 環境檢查完成:"
|
||||
doc_parser.check.python_available: "✅ 可用"
|
||||
doc_parser.check.python_unavailable: "❌ 不可用"
|
||||
doc_parser.check.uv_available: "✅ 可用"
|
||||
doc_parser.check.uv_unavailable: "❌ 不可用"
|
||||
doc_parser.check.venv_active: "✅ 啟用"
|
||||
doc_parser.check.venv_inactive: "❌ 未啟用"
|
||||
doc_parser.check.mineru_available: "✅ 可用"
|
||||
doc_parser.check.mineru_unavailable: "❌ 不可用"
|
||||
doc_parser.check.markitdown_available: "✅ 可用"
|
||||
doc_parser.check.markitdown_unavailable: "❌ 不可用"
|
||||
doc_parser.check.env_check_failed: " ⚠️ 環境檢查失敗: %{error}"
|
||||
doc_parser.check.continue_install: " 繼續進行安裝..."
|
||||
# 安裝過程
|
||||
doc_parser.install.all_ready: "✨ 所有相依套件都已就緒,無需安裝!"
|
||||
doc_parser.install.plan: "\U0001F4CB 安裝計畫:"
|
||||
doc_parser.install.install_uv: "安裝 uv 工具"
|
||||
doc_parser.install.create_venv: "建立虛擬環境 (./venv/)"
|
||||
doc_parser.install.install_mineru: "安裝 MinerU 相依套件"
|
||||
doc_parser.install.install_markitdown: "安裝 MarkItDown 相依套件"
|
||||
doc_parser.install.starting: "⚙️ 開始設定Python環境和相依套件..."
|
||||
doc_parser.install.wait_hint: "這可能需要幾分鐘時間,請耐心等待..."
|
||||
doc_parser.install.path_issues: "⚠️ 偵測到潛在的路徑問題:"
|
||||
doc_parser.install.auto_fixing: "\U0001F527 嘗試自動修復問題..."
|
||||
doc_parser.install.fixed: "✅ 已修復以下問題:"
|
||||
doc_parser.install.cannot_auto_fix: "無法自動修復,請手動解決上述問題"
|
||||
doc_parser.install.fix_failed: "❌ 自動修復失敗: %{error}"
|
||||
doc_parser.install.manual_fix_hint: "\U0001F4A1 手動修復建議:"
|
||||
doc_parser.install.python_setup_complete: "✅ Python環境設定完成!"
|
||||
doc_parser.install.python_setup_failed: "❌ Python環境設定失敗: %{error}"
|
||||
doc_parser.install.detailed_hints: "\U0001F4A1 詳細故障排除建議:"
|
||||
doc_parser.install.diagnostic_commands: "\U0001F50D 診斷命令:"
|
||||
doc_parser.install.check_env: "檢查環境狀態: document-parser check"
|
||||
doc_parser.install.view_logs: "查看詳細日誌: 檢查 logs/ 目錄"
|
||||
# 驗證
|
||||
doc_parser.verify.starting: "\U0001F50D 驗證安裝結果..."
|
||||
doc_parser.verify.complete: "驗證完成:"
|
||||
doc_parser.verify.version: "版本: %{version}"
|
||||
doc_parser.verify.partial_issues: "⚠️ 部分相依套件安裝可能有問題"
|
||||
doc_parser.verify.need_fix: "\U0001F527 需要解決的問題:"
|
||||
doc_parser.verify.suggestion: "建議: %{suggestion}"
|
||||
doc_parser.verify.init_incomplete: "環境初始化未完全成功"
|
||||
doc_parser.verify.failed: "❌ 驗證失敗: %{error}"
|
||||
# 成功訊息
|
||||
doc_parser.success.init_complete: "\U0001F389 uv環境初始化完成!"
|
||||
doc_parser.success.all_ready: "✨ 所有相依套件都已就緒,現在可以啟動伺服器了"
|
||||
doc_parser.success.venv_activate: "\U0001F4CB 虛擬環境啟用指令:"
|
||||
doc_parser.success.start_server: "\U0001F680 啟動伺服器:"
|
||||
doc_parser.success.use_uv: "\U0001F527 或者使用 uv 直接執行命令:"
|
||||
doc_parser.success.more_help: "\U0001F4DA 更多說明:"
|
||||
doc_parser.success.tips: "\U0001F4A1 提示:"
|
||||
doc_parser.success.venv_location: "虛擬環境位置: ./venv/"
|
||||
doc_parser.success.python_path: "Python可執行檔: ./venv/bin/python (Linux/macOS) 或 .\\venv\\Scripts\\python.exe (Windows)"
|
||||
doc_parser.success.troubleshoot_hint: "如遇問題,請執行 'document-parser troubleshoot' 查看詳細指南"
|
||||
# troubleshoot 命令
|
||||
doc_parser.troubleshoot.title: "\U0001F527 Document Parser 故障排除指南"
|
||||
doc_parser.troubleshoot.env_overview: "\U0001F4CA 目前環境概覽:"
|
||||
doc_parser.troubleshoot.work_dir: "工作目錄: %{path}"
|
||||
doc_parser.troubleshoot.venv_path: "虛擬環境: ./venv/"
|
||||
doc_parser.troubleshoot.os: "作業系統: %{os}"
|
||||
# 虛擬環境問題
|
||||
doc_parser.troubleshoot.venv_problems: "\U0001F3E0 1. 虛擬環境問題"
|
||||
doc_parser.troubleshoot.venv_create_failed: "❓ 問題: 虛擬環境建立失敗"
|
||||
doc_parser.troubleshoot.diagnostic_steps: "\U0001F50D 診斷步驟:"
|
||||
doc_parser.troubleshoot.solutions: "\U0001F4A1 解決方案:"
|
||||
doc_parser.troubleshoot.venv_activate_failed: "❓ 問題: 虛擬環境啟用失敗"
|
||||
# 相依套件安裝問題
|
||||
doc_parser.troubleshoot.deps_problems: "\U0001F4E6 2. 相依套件安裝問題"
|
||||
doc_parser.troubleshoot.uv_not_installed: "❓ 問題: UV工具未安裝或不可用"
|
||||
doc_parser.troubleshoot.mineru_markitdown_failed: "❓ 問題: MinerU或MarkItDown安裝失敗"
|
||||
# 網路問題
|
||||
doc_parser.troubleshoot.network_problems: "\U0001F310 3. 網路和下載問題"
|
||||
doc_parser.troubleshoot.network_timeout: "❓ 問題: 網路連線逾時或下載失敗"
|
||||
# 系統環境問題
|
||||
doc_parser.troubleshoot.system_problems: "⚙️ 4. 系統環境問題"
|
||||
doc_parser.troubleshoot.python_incompatible: "❓ 問題: Python版本不相容"
|
||||
doc_parser.troubleshoot.cuda_config: "❓ 問題: CUDA環境設定 (可選,用於GPU加速)"
|
||||
# 診斷命令
|
||||
doc_parser.troubleshoot.diagnostic_commands: "\U0001F50D 5. 常用診斷命令"
|
||||
doc_parser.troubleshoot.env_check: "環境檢查:"
|
||||
doc_parser.troubleshoot.manual_verify: "手動驗證:"
|
||||
doc_parser.troubleshoot.view_logs: "日誌查看:"
|
||||
# 取得幫助
|
||||
doc_parser.troubleshoot.more_help: "\U0001F198 6. 取得更多幫助"
|
||||
doc_parser.troubleshoot.if_unsolved: "如果上述方法都無法解決問題,請:"
|
||||
# 即時診斷
|
||||
doc_parser.troubleshoot.realtime_diagnosis: "\U0001F52C 即時環境診斷"
|
||||
doc_parser.troubleshoot.env_good: "✅ 環境狀態良好,所有相依套件都已就緒"
|
||||
doc_parser.troubleshoot.issues_found: "⚠️ 發現以下問題:"
|
||||
doc_parser.troubleshoot.env_check_failed: "❌ 環境檢查失敗: %{error}"
|
||||
doc_parser.troubleshoot.follow_guide: "請按照上述指南進行故障排除"
|
||||
# 提示
|
||||
doc_parser.troubleshoot.tip: "\U0001F4A1 提示: 大多數問題可以透過重新執行 'document-parser uv-init' 解決"
|
||||
# 背景清理
|
||||
doc_parser.background.cleanup_failed: "清理過期資料失敗: %{error}"
|
||||
doc_parser.background.cleanup_complete: "背景清理任務執行完成"
|
||||
# 訊號處理
|
||||
doc_parser.signal.ctrl_c_failed: "無法監聽 Ctrl+C 訊號"
|
||||
doc_parser.signal.terminate_failed: "無法監聽 terminate 訊號"
|
||||
# ===========================================
|
||||
# 通用訊息
|
||||
# ===========================================
|
||||
common.yes: "是"
|
||||
common.no: "否"
|
||||
common.success: "成功"
|
||||
common.failed: "失敗"
|
||||
common.error: "錯誤"
|
||||
common.warning: "警告"
|
||||
common.info: "資訊"
|
||||
common.loading: "載入中..."
|
||||
common.please_wait: "請稍候..."
|
||||
common.retry: "重試"
|
||||
common.cancel: "取消"
|
||||
common.confirm: "確認"
|
||||
common.back: "返回"
|
||||
common.next: "下一步"
|
||||
common.previous: "上一步"
|
||||
common.done: "完成"
|
||||
common.skip: "跳過"
|
||||
319
qiming-mcp-proxy/document-parser/src/app_state.rs
Normal file
319
qiming-mcp-proxy/document-parser/src/app_state.rs
Normal file
@@ -0,0 +1,319 @@
|
||||
use crate::config::AppConfig;
|
||||
use crate::error::AppError;
|
||||
use crate::parsers::DualEngineParser;
|
||||
use crate::processors::{MarkdownProcessor, MarkdownProcessorConfig};
|
||||
use crate::services::{
|
||||
DocumentService, DocumentTaskProcessor, StorageService, TaskQueueService, TaskService,
|
||||
};
|
||||
use oss_client::OssClientTrait;
|
||||
use sled::Db;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// 应用状态
|
||||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
pub config: Arc<AppConfig>,
|
||||
pub db: Arc<Db>,
|
||||
pub document_service: Arc<DocumentService>,
|
||||
pub task_service: Arc<TaskService>,
|
||||
/// 公有OSS客户端
|
||||
pub oss_client: Option<Arc<dyn OssClientTrait + Send + Sync>>,
|
||||
/// 私有OSS客户端
|
||||
pub private_oss_client: Option<Arc<dyn OssClientTrait + Send + Sync>>,
|
||||
pub storage_service: Arc<StorageService>,
|
||||
pub task_queue: Arc<TaskQueueService>,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
/// 创建新的应用状态
|
||||
pub async fn new(config: AppConfig) -> Result<Self, AppError> {
|
||||
// 初始化数据库
|
||||
let db = Self::init_database(&config).await?;
|
||||
let db_arc = Arc::new(db);
|
||||
|
||||
// 初始化存储服务
|
||||
let storage_service = Arc::new(StorageService::new(db_arc.clone())?);
|
||||
|
||||
// 初始化任务服务
|
||||
let task_service = Arc::new(TaskService::new(db_arc.clone())?);
|
||||
|
||||
// 使用 config.storage.oss 的配置初始化公有与私有 OSS 客户端
|
||||
let public_oss_config = oss_client::OssConfig::new(
|
||||
config.storage.oss.endpoint.clone(),
|
||||
config.storage.oss.public_bucket.clone(),
|
||||
config.storage.oss.access_key_id.clone(),
|
||||
config.storage.oss.access_key_secret.clone(),
|
||||
config.storage.oss.region.clone(),
|
||||
config.storage.oss.upload_directory.clone(),
|
||||
);
|
||||
let private_oss_config = oss_client::OssConfig::new(
|
||||
config.storage.oss.endpoint.clone(),
|
||||
config.storage.oss.private_bucket.clone(),
|
||||
config.storage.oss.access_key_id.clone(),
|
||||
config.storage.oss.access_key_secret.clone(),
|
||||
config.storage.oss.region.clone(),
|
||||
config.storage.oss.upload_directory.clone(),
|
||||
);
|
||||
|
||||
// 初始化公有OSS客户端(默认可用)
|
||||
let public_oss_client = oss_client::PublicOssClient::new(public_oss_config)
|
||||
.map_err(|e| AppError::Oss(e.to_string()))?;
|
||||
let oss_client: Option<Arc<dyn OssClientTrait + Send + Sync>> =
|
||||
Some(Arc::new(public_oss_client));
|
||||
|
||||
// 初始化私有OSS客户端(失败不致命,记录警告)
|
||||
let private_oss_client: Option<Arc<dyn OssClientTrait + Send + Sync>> =
|
||||
match oss_client::PrivateOssClient::new(private_oss_config) {
|
||||
Ok(client) => Some(Arc::new(client)),
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
"Failed to initialize private OSS client, private client will be skipped: {}",
|
||||
e
|
||||
);
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
// 初始化解析器 - 优先使用自动检测虚拟环境,回退到配置
|
||||
let dual_parser = match DualEngineParser::with_auto_venv_detection() {
|
||||
Ok(parser) => {
|
||||
tracing::info!(
|
||||
"Initialize the parser using an automatically detected virtual environment"
|
||||
);
|
||||
parser
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
"Automatic detection of virtual environment failed and fell back to configuration: {}",
|
||||
e
|
||||
);
|
||||
DualEngineParser::with_timeout(
|
||||
&config.mineru,
|
||||
&config.markitdown,
|
||||
config.document_parser.processing_timeout,
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
// 初始化Markdown处理器
|
||||
let processor_config = MarkdownProcessorConfig::with_global_config();
|
||||
let markdown_processor = MarkdownProcessor::new(processor_config, None);
|
||||
|
||||
// 初始化文档服务
|
||||
let document_service_config =
|
||||
crate::services::DocumentServiceConfig::from_app_config(&config);
|
||||
let document_service = Arc::new(DocumentService::with_config(
|
||||
dual_parser,
|
||||
markdown_processor,
|
||||
task_service.clone(),
|
||||
oss_client.clone(),
|
||||
document_service_config,
|
||||
));
|
||||
|
||||
// 初始化任务队列(使用配置中的并发和队列大小)
|
||||
let mut task_queue = TaskQueueService::with_config(
|
||||
task_service.clone(),
|
||||
crate::services::QueueConfig {
|
||||
max_concurrent_tasks: config.document_parser.max_concurrent,
|
||||
max_queue_size: config.document_parser.queue_size,
|
||||
task_timeout: std::time::Duration::from_secs(
|
||||
config.document_parser.processing_timeout as u64,
|
||||
),
|
||||
backpressure_threshold: 0.8,
|
||||
retry_base_delay: std::time::Duration::from_secs(1),
|
||||
retry_max_delay: std::time::Duration::from_secs(60),
|
||||
metrics_update_interval: std::time::Duration::from_secs(5),
|
||||
health_check_interval: std::time::Duration::from_secs(30),
|
||||
},
|
||||
);
|
||||
|
||||
// 启动 worker 池
|
||||
let processor = Arc::new(DocumentTaskProcessor::new(
|
||||
document_service.clone(),
|
||||
task_service.clone(),
|
||||
));
|
||||
task_queue
|
||||
.start(processor)
|
||||
.await
|
||||
.map_err(|e| crate::error::AppError::Internal(format!("启动任务队列失败: {e}")))?;
|
||||
let task_queue = Arc::new(task_queue);
|
||||
|
||||
Ok(Self {
|
||||
config: Arc::new(config),
|
||||
db: db_arc,
|
||||
document_service,
|
||||
task_service,
|
||||
oss_client,
|
||||
private_oss_client,
|
||||
storage_service,
|
||||
task_queue,
|
||||
})
|
||||
}
|
||||
|
||||
/// 初始化数据库
|
||||
async fn init_database(config: &AppConfig) -> Result<Db, AppError> {
|
||||
// 确保数据目录存在
|
||||
let db_path = &config.storage.sled.path;
|
||||
if let Some(parent) = std::path::Path::new(db_path).parent() {
|
||||
if !parent.exists() {
|
||||
std::fs::create_dir_all(parent)
|
||||
.map_err(|e| AppError::Config(format!("无法创建数据库目录: {e}")))?;
|
||||
}
|
||||
}
|
||||
|
||||
// 打开数据库
|
||||
let db =
|
||||
sled::open(db_path).map_err(|e| AppError::Database(format!("无法打开数据库: {e}")))?;
|
||||
|
||||
// 设置缓存容量(Sled 0.34版本不支持set_cache_capacity方法)
|
||||
// db.set_cache_capacity(config.storage.sled.cache_capacity);
|
||||
|
||||
Ok(db)
|
||||
}
|
||||
|
||||
/// 获取配置引用
|
||||
pub fn get_config(&self) -> &AppConfig {
|
||||
&self.config
|
||||
}
|
||||
|
||||
/// 获取数据库引用
|
||||
pub fn get_db(&self) -> &Db {
|
||||
&self.db
|
||||
}
|
||||
|
||||
/// 健康检查
|
||||
pub async fn health_check(&self) -> Result<(), AppError> {
|
||||
// 检查数据库连接
|
||||
self.db
|
||||
.flush()
|
||||
.map_err(|e| AppError::Database(format!("数据库健康检查失败: {e}")))?;
|
||||
|
||||
// 检查配置
|
||||
if self.config.server.port == 0 {
|
||||
return Err(AppError::Config("服务器端口配置无效".to_string()));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 清理过期数据
|
||||
pub async fn cleanup_expired_data(&self) -> Result<usize, AppError> {
|
||||
let mut cleaned_count = 0;
|
||||
let _now = chrono::Utc::now();
|
||||
|
||||
// 清理过期的任务数据
|
||||
let tasks_tree = self
|
||||
.db
|
||||
.open_tree("tasks")
|
||||
.map_err(|e| AppError::Database(format!("无法打开任务树: {e}")))?;
|
||||
|
||||
let mut to_remove = Vec::new();
|
||||
let mut expired_tasks = Vec::new();
|
||||
|
||||
for result in tasks_tree.iter() {
|
||||
match result {
|
||||
Ok((key, value)) => {
|
||||
if let Ok(task_data) =
|
||||
serde_json::from_slice::<crate::models::DocumentTask>(&value)
|
||||
{
|
||||
if task_data.is_expired() {
|
||||
to_remove.push(key);
|
||||
expired_tasks.push(task_data);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("Error reading task data: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 删除过期数据并清理相关文件
|
||||
for (i, key) in to_remove.iter().enumerate() {
|
||||
// 清理任务相关的文件
|
||||
if let Some(task) = expired_tasks.get(i) {
|
||||
self.cleanup_task_files(task).await;
|
||||
}
|
||||
|
||||
if let Err(e) = tasks_tree.remove(key) {
|
||||
log::warn!("Error deleting expired tasks: {e}");
|
||||
} else {
|
||||
cleaned_count += 1;
|
||||
}
|
||||
}
|
||||
|
||||
log::info!("Cleaned up {cleaned_count} expired data");
|
||||
Ok(cleaned_count)
|
||||
}
|
||||
|
||||
/// 清理任务相关的临时文件
|
||||
async fn cleanup_task_files(&self, task: &crate::models::DocumentTask) {
|
||||
// 清理基于 taskId 的临时文件
|
||||
if let Some(source_path) = &task.source_path {
|
||||
// 如果是基于 taskId 的文件路径,进行清理
|
||||
if source_path.contains(&task.id) {
|
||||
if let Err(e) = tokio::fs::remove_file(source_path).await {
|
||||
log::warn!(
|
||||
"Cleanup task {}'s temporary files failed: {} - {}",
|
||||
task.id,
|
||||
source_path,
|
||||
e
|
||||
);
|
||||
} else {
|
||||
log::info!(
|
||||
"Cleaned temporary files of task {}: {}",
|
||||
task.id,
|
||||
source_path
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// URL 任务不清理基于 URL 的路径(source_url),仅清理下载到本地的基于 taskId 的临时文件
|
||||
|
||||
// 清理可能的工作目录(基于 taskId 的目录)
|
||||
let temp_dir = std::env::temp_dir();
|
||||
let task_work_dir = temp_dir.join(format!("document_parser_{}", task.id));
|
||||
if task_work_dir.exists() {
|
||||
if let Err(e) = tokio::fs::remove_dir_all(&task_work_dir).await {
|
||||
log::warn!(
|
||||
"Cleanup task {}'s working directory failed: {} - {}",
|
||||
task.id,
|
||||
task_work_dir.display(),
|
||||
e
|
||||
);
|
||||
} else {
|
||||
log::info!(
|
||||
"Cleaned working directory of task {}: {}",
|
||||
task.id,
|
||||
task_work_dir.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 清理基于 taskId 命名的临时文件(格式:task_{taskId}_*)
|
||||
let temp_dir_path = std::env::temp_dir();
|
||||
if let Ok(entries) = std::fs::read_dir(&temp_dir_path) {
|
||||
for entry in entries.flatten() {
|
||||
if let Some(filename) = entry.file_name().to_str() {
|
||||
if filename.starts_with(&format!("task_{}_", task.id)) {
|
||||
if let Err(e) = tokio::fs::remove_file(entry.path()).await {
|
||||
log::warn!(
|
||||
"Cleanup task {}'s temporary files failed: {} - {}",
|
||||
task.id,
|
||||
entry.path().display(),
|
||||
e
|
||||
);
|
||||
} else {
|
||||
log::info!(
|
||||
"Cleaned temporary files of task {}: {}",
|
||||
task.id,
|
||||
entry.path().display()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
1505
qiming-mcp-proxy/document-parser/src/config.rs
Normal file
1505
qiming-mcp-proxy/document-parser/src/config.rs
Normal file
File diff suppressed because it is too large
Load Diff
432
qiming-mcp-proxy/document-parser/src/error.rs
Normal file
432
qiming-mcp-proxy/document-parser/src/error.rs
Normal file
@@ -0,0 +1,432 @@
|
||||
use thiserror::Error;
|
||||
|
||||
/// 应用错误类型
|
||||
#[derive(Error, Debug, Clone)]
|
||||
pub enum AppError {
|
||||
/// 配置错误
|
||||
#[error("{0}")]
|
||||
Config(String),
|
||||
|
||||
/// 文件操作错误
|
||||
#[error("{0}")]
|
||||
File(String),
|
||||
|
||||
/// 格式不支持错误
|
||||
#[error("{0}")]
|
||||
UnsupportedFormat(String),
|
||||
|
||||
/// 解析错误
|
||||
#[error("{0}")]
|
||||
Parse(String),
|
||||
|
||||
/// MinerU错误
|
||||
#[error("{0}")]
|
||||
MinerU(String),
|
||||
|
||||
/// MarkItDown错误
|
||||
#[error("{0}")]
|
||||
MarkItDown(String),
|
||||
|
||||
/// OSS操作错误
|
||||
#[error("{0}")]
|
||||
Oss(String),
|
||||
|
||||
/// 数据库错误
|
||||
#[error("{0}")]
|
||||
Database(String),
|
||||
|
||||
/// 网络错误
|
||||
#[error("{0}")]
|
||||
Network(String),
|
||||
|
||||
/// 任务错误
|
||||
#[error("{0}")]
|
||||
Task(String),
|
||||
|
||||
/// 内部错误
|
||||
#[error("{0}")]
|
||||
Internal(String),
|
||||
|
||||
/// 超时错误
|
||||
#[error("{0}")]
|
||||
Timeout(String),
|
||||
|
||||
/// 验证错误
|
||||
#[error("{0}")]
|
||||
Validation(String),
|
||||
|
||||
/// 环境错误
|
||||
#[error("{0}")]
|
||||
Environment(String),
|
||||
|
||||
/// 虚拟环境路径错误
|
||||
#[error("{0}")]
|
||||
VirtualEnvironmentPath(String),
|
||||
|
||||
/// 权限错误
|
||||
#[error("{0}")]
|
||||
Permission(String),
|
||||
|
||||
/// 路径错误
|
||||
#[error("{0}")]
|
||||
Path(String),
|
||||
|
||||
/// 队列错误
|
||||
#[error("{0}")]
|
||||
Queue(String),
|
||||
|
||||
/// 处理错误
|
||||
#[error("{0}")]
|
||||
Processing(String),
|
||||
}
|
||||
|
||||
impl AppError {
|
||||
// ===========================================
|
||||
// 工厂方法 - 创建带国际化消息的错误
|
||||
// ===========================================
|
||||
|
||||
/// 创建配置错误
|
||||
pub fn config_error(detail: impl Into<String>) -> Self {
|
||||
Self::Config(t!("errors.document_parser.config", detail = detail.into()).to_string())
|
||||
}
|
||||
|
||||
/// 创建文件操作错误
|
||||
pub fn file_error(detail: impl Into<String>) -> Self {
|
||||
Self::File(t!("errors.document_parser.file", detail = detail.into()).to_string())
|
||||
}
|
||||
|
||||
/// 创建格式不支持错误
|
||||
pub fn unsupported_format(format: impl Into<String>) -> Self {
|
||||
Self::UnsupportedFormat(
|
||||
t!(
|
||||
"errors.document_parser.unsupported_format",
|
||||
format = format.into()
|
||||
)
|
||||
.to_string(),
|
||||
)
|
||||
}
|
||||
|
||||
/// 创建解析错误
|
||||
pub fn parse_error(detail: impl Into<String>) -> Self {
|
||||
Self::Parse(t!("errors.document_parser.parse", detail = detail.into()).to_string())
|
||||
}
|
||||
|
||||
/// 创建 MinerU 错误
|
||||
pub fn mineru_error(detail: impl Into<String>) -> Self {
|
||||
Self::MinerU(t!("errors.document_parser.mineru", detail = detail.into()).to_string())
|
||||
}
|
||||
|
||||
/// 创建 MarkItDown 错误
|
||||
pub fn markitdown_error(detail: impl Into<String>) -> Self {
|
||||
Self::MarkItDown(
|
||||
t!("errors.document_parser.markitdown", detail = detail.into()).to_string(),
|
||||
)
|
||||
}
|
||||
|
||||
/// 创建 OSS 错误
|
||||
pub fn oss_error(detail: impl Into<String>) -> Self {
|
||||
Self::Oss(t!("errors.document_parser.oss", detail = detail.into()).to_string())
|
||||
}
|
||||
|
||||
/// 创建数据库错误
|
||||
pub fn database_error(detail: impl Into<String>) -> Self {
|
||||
Self::Database(t!("errors.document_parser.database", detail = detail.into()).to_string())
|
||||
}
|
||||
|
||||
/// 创建网络错误
|
||||
pub fn network_error(detail: impl Into<String>) -> Self {
|
||||
Self::Network(t!("errors.document_parser.network", detail = detail.into()).to_string())
|
||||
}
|
||||
|
||||
/// 创建任务错误
|
||||
pub fn task_error(detail: impl Into<String>) -> Self {
|
||||
Self::Task(t!("errors.document_parser.task", detail = detail.into()).to_string())
|
||||
}
|
||||
|
||||
/// 创建内部错误
|
||||
pub fn internal_error(detail: impl Into<String>) -> Self {
|
||||
Self::Internal(t!("errors.document_parser.internal", detail = detail.into()).to_string())
|
||||
}
|
||||
|
||||
/// 创建超时错误
|
||||
pub fn timeout_error(detail: impl Into<String>) -> Self {
|
||||
Self::Timeout(t!("errors.document_parser.timeout", detail = detail.into()).to_string())
|
||||
}
|
||||
|
||||
/// 创建验证错误
|
||||
pub fn validation_error(detail: impl Into<String>) -> Self {
|
||||
Self::Validation(
|
||||
t!("errors.document_parser.validation", detail = detail.into()).to_string(),
|
||||
)
|
||||
}
|
||||
|
||||
/// 创建环境错误
|
||||
pub fn environment_error(detail: impl Into<String>) -> Self {
|
||||
Self::Environment(
|
||||
t!("errors.document_parser.environment", detail = detail.into()).to_string(),
|
||||
)
|
||||
}
|
||||
|
||||
/// 创建虚拟环境路径错误
|
||||
pub fn virtual_environment_path_error(message: String, path: &std::path::Path) -> Self {
|
||||
let path_str = path.display().to_string();
|
||||
Self::VirtualEnvironmentPath(
|
||||
t!(
|
||||
"errors.document_parser.virtual_environment_path",
|
||||
detail = format!("{} (path: {})", message, path_str)
|
||||
)
|
||||
.to_string(),
|
||||
)
|
||||
}
|
||||
|
||||
/// 创建权限错误
|
||||
pub fn permission_error(message: String, path: &std::path::Path) -> Self {
|
||||
let path_str = path.display().to_string();
|
||||
Self::Permission(
|
||||
t!(
|
||||
"errors.document_parser.permission",
|
||||
detail = format!("{} (path: {})", message, path_str)
|
||||
)
|
||||
.to_string(),
|
||||
)
|
||||
}
|
||||
|
||||
/// 创建路径错误
|
||||
pub fn path_error(message: String, path: &std::path::Path) -> Self {
|
||||
let path_str = path.display().to_string();
|
||||
Self::Path(
|
||||
t!(
|
||||
"errors.document_parser.path",
|
||||
detail = format!("{} (path: {})", message, path_str)
|
||||
)
|
||||
.to_string(),
|
||||
)
|
||||
}
|
||||
|
||||
/// 创建队列错误
|
||||
pub fn queue_error(detail: impl Into<String>) -> Self {
|
||||
Self::Queue(t!("errors.document_parser.queue", detail = detail.into()).to_string())
|
||||
}
|
||||
|
||||
/// 创建处理错误
|
||||
pub fn processing_error(detail: impl Into<String>) -> Self {
|
||||
Self::Processing(
|
||||
t!("errors.document_parser.processing", detail = detail.into()).to_string(),
|
||||
)
|
||||
}
|
||||
|
||||
/// 获取路径相关错误的详细恢复建议
|
||||
pub fn get_path_recovery_suggestions(&self) -> Vec<String> {
|
||||
match self {
|
||||
AppError::VirtualEnvironmentPath(msg) => {
|
||||
let mut suggestions = vec![
|
||||
"检查当前目录是否有写入权限".to_string(),
|
||||
"确保当前目录下没有名为 'venv' 的文件(非目录)".to_string(),
|
||||
"尝试删除损坏的虚拟环境目录: rm -rf ./venv".to_string(),
|
||||
"检查磁盘空间是否充足".to_string(),
|
||||
];
|
||||
|
||||
if msg.contains("权限") || msg.contains("permission") {
|
||||
suggestions.insert(0, "使用 sudo 或管理员权限运行命令".to_string());
|
||||
suggestions.push("检查目录所有者和权限: ls -la".to_string());
|
||||
}
|
||||
|
||||
if msg.contains("存在") || msg.contains("exists") {
|
||||
suggestions.push("备份现有虚拟环境后重新创建".to_string());
|
||||
}
|
||||
|
||||
suggestions
|
||||
}
|
||||
AppError::Permission(_msg) => {
|
||||
let mut suggestions = vec![
|
||||
"检查文件和目录权限设置".to_string(),
|
||||
"确保当前用户有足够的权限".to_string(),
|
||||
];
|
||||
|
||||
if cfg!(unix) {
|
||||
suggestions.extend(vec![
|
||||
"使用 chmod 修改权限: chmod 755 <目录>".to_string(),
|
||||
"使用 chown 修改所有者: chown $USER <目录>".to_string(),
|
||||
"检查 SELinux 或 AppArmor 安全策略".to_string(),
|
||||
]);
|
||||
} else if cfg!(windows) {
|
||||
suggestions.extend(vec![
|
||||
"以管理员身份运行命令提示符".to_string(),
|
||||
"检查 Windows 用户账户控制 (UAC) 设置".to_string(),
|
||||
"确保目录不在受保护的系统路径中".to_string(),
|
||||
]);
|
||||
}
|
||||
|
||||
suggestions
|
||||
}
|
||||
AppError::Path(msg) => {
|
||||
let mut suggestions = vec![
|
||||
"检查路径是否正确拼写".to_string(),
|
||||
"确保路径存在且可访问".to_string(),
|
||||
"检查路径中是否包含特殊字符".to_string(),
|
||||
];
|
||||
|
||||
if msg.contains("不存在") || msg.contains("not found") {
|
||||
suggestions.push("创建缺失的目录结构".to_string());
|
||||
}
|
||||
|
||||
if msg.contains("长度") || msg.contains("length") {
|
||||
suggestions.push("使用较短的路径名称".to_string());
|
||||
}
|
||||
|
||||
suggestions
|
||||
}
|
||||
_ => vec!["检查系统环境和配置".to_string()],
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取错误代码
|
||||
pub fn get_error_code(&self) -> &'static str {
|
||||
match self {
|
||||
AppError::Config(_) => "E001",
|
||||
AppError::File(_) => "E002",
|
||||
AppError::UnsupportedFormat(_) => "E003",
|
||||
AppError::Parse(_) => "E004",
|
||||
AppError::MinerU(_) => "E005",
|
||||
AppError::MarkItDown(_) => "E006",
|
||||
AppError::Oss(_) => "E007",
|
||||
AppError::Database(_) => "E008",
|
||||
AppError::Network(_) => "E009",
|
||||
AppError::Task(_) => "E010",
|
||||
AppError::Internal(_) => "E011",
|
||||
AppError::Timeout(_) => "E012",
|
||||
AppError::Validation(_) => "E013",
|
||||
AppError::Environment(_) => "E014",
|
||||
AppError::Queue(_) => "E015",
|
||||
AppError::Processing(_) => "E016",
|
||||
AppError::VirtualEnvironmentPath(_) => "E017",
|
||||
AppError::Permission(_) => "E018",
|
||||
AppError::Path(_) => "E019",
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取错误建议(国际化)
|
||||
pub fn get_suggestion(&self) -> String {
|
||||
match self {
|
||||
AppError::Config(_) => t!("errors.document_parser.suggestions.config").to_string(),
|
||||
AppError::File(_) => t!("errors.document_parser.suggestions.file").to_string(),
|
||||
AppError::UnsupportedFormat(_) => {
|
||||
t!("errors.document_parser.suggestions.unsupported_format").to_string()
|
||||
}
|
||||
AppError::Parse(_) => t!("errors.document_parser.suggestions.parse").to_string(),
|
||||
AppError::MinerU(_) => t!("errors.document_parser.suggestions.mineru").to_string(),
|
||||
AppError::MarkItDown(_) => {
|
||||
t!("errors.document_parser.suggestions.markitdown").to_string()
|
||||
}
|
||||
AppError::Oss(_) => t!("errors.document_parser.suggestions.oss").to_string(),
|
||||
AppError::Database(_) => t!("errors.document_parser.suggestions.database").to_string(),
|
||||
AppError::Network(_) => t!("errors.document_parser.suggestions.network").to_string(),
|
||||
AppError::Task(_) => t!("errors.document_parser.suggestions.task").to_string(),
|
||||
AppError::Internal(_) => t!("errors.document_parser.suggestions.internal").to_string(),
|
||||
AppError::Timeout(_) => t!("errors.document_parser.suggestions.timeout").to_string(),
|
||||
AppError::Validation(_) => {
|
||||
t!("errors.document_parser.suggestions.validation").to_string()
|
||||
}
|
||||
AppError::Environment(_) => {
|
||||
t!("errors.document_parser.suggestions.environment").to_string()
|
||||
}
|
||||
AppError::Queue(_) => t!("errors.document_parser.suggestions.queue").to_string(),
|
||||
AppError::Processing(_) => {
|
||||
t!("errors.document_parser.suggestions.processing").to_string()
|
||||
}
|
||||
AppError::VirtualEnvironmentPath(_) => {
|
||||
t!("errors.document_parser.suggestions.virtual_environment_path").to_string()
|
||||
}
|
||||
AppError::Permission(_) => {
|
||||
t!("errors.document_parser.suggestions.permission").to_string()
|
||||
}
|
||||
AppError::Path(_) => t!("errors.document_parser.suggestions.path").to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 转换为HTTP响应格式
|
||||
pub fn to_http_result<T>(&self) -> crate::models::HttpResult<T> {
|
||||
use crate::models::HttpResult;
|
||||
|
||||
HttpResult::<T>::error(
|
||||
self.get_error_code().to_string(),
|
||||
format!("{} - {}", self, self.get_suggestion()),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// 从标准库错误转换
|
||||
impl From<std::io::Error> for AppError {
|
||||
fn from(err: std::io::Error) -> Self {
|
||||
Self::file_error(err.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// 从serde错误转换
|
||||
impl From<serde_json::Error> for AppError {
|
||||
fn from(err: serde_json::Error) -> Self {
|
||||
Self::parse_error(format!("JSON: {err}"))
|
||||
}
|
||||
}
|
||||
|
||||
/// 从serde_yaml错误转换
|
||||
impl From<serde_yaml::Error> for AppError {
|
||||
fn from(err: serde_yaml::Error) -> Self {
|
||||
Self::config_error(format!("YAML: {err}"))
|
||||
}
|
||||
}
|
||||
|
||||
/// 从sled错误转换
|
||||
impl From<sled::Error> for AppError {
|
||||
fn from(err: sled::Error) -> Self {
|
||||
Self::database_error(format!("Sled: {err}"))
|
||||
}
|
||||
}
|
||||
|
||||
/// 从reqwest错误转换
|
||||
impl From<reqwest::Error> for AppError {
|
||||
fn from(err: reqwest::Error) -> Self {
|
||||
if err.is_timeout() {
|
||||
Self::timeout_error("HTTP request")
|
||||
} else if err.is_connect() {
|
||||
Self::network_error("connection failed")
|
||||
} else {
|
||||
Self::network_error(err.to_string())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 从anyhow错误转换
|
||||
impl From<anyhow::Error> for AppError {
|
||||
fn from(err: anyhow::Error) -> Self {
|
||||
Self::internal_error(err.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// 从std::env::VarError转换
|
||||
impl From<std::env::VarError> for AppError {
|
||||
fn from(err: std::env::VarError) -> Self {
|
||||
Self::config_error(format!("environment variable: {err}"))
|
||||
}
|
||||
}
|
||||
|
||||
/// 从std::num::ParseIntError转换
|
||||
impl From<std::num::ParseIntError> for AppError {
|
||||
fn from(err: std::num::ParseIntError) -> Self {
|
||||
Self::config_error(format!("integer parse: {err}"))
|
||||
}
|
||||
}
|
||||
|
||||
/// 从std::str::ParseBoolError转换
|
||||
impl From<std::str::ParseBoolError> for AppError {
|
||||
fn from(err: std::str::ParseBoolError) -> Self {
|
||||
Self::config_error(format!("boolean parse: {err}"))
|
||||
}
|
||||
}
|
||||
|
||||
/// 从std::time::SystemTimeError转换
|
||||
impl From<std::time::SystemTimeError> for AppError {
|
||||
fn from(err: std::time::SystemTimeError) -> Self {
|
||||
Self::internal_error(err.to_string())
|
||||
}
|
||||
}
|
||||
1121
qiming-mcp-proxy/document-parser/src/handlers/document_handler.rs
Normal file
1121
qiming-mcp-proxy/document-parser/src/handlers/document_handler.rs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,37 @@
|
||||
use crate::app_state::AppState;
|
||||
use crate::models::HttpResult;
|
||||
use axum::Json;
|
||||
use utoipa;
|
||||
|
||||
/// 健康检查
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/health",
|
||||
responses(
|
||||
(status = 200, description = "服务健康", body = HttpResult<String>)
|
||||
),
|
||||
tag = "health"
|
||||
)]
|
||||
pub async fn health_check() -> Json<HttpResult<String>> {
|
||||
Json(HttpResult::success("health".to_string()))
|
||||
}
|
||||
|
||||
/// 就绪检查
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/ready",
|
||||
responses(
|
||||
(status = 200, description = "服务就绪", body = HttpResult<String>),
|
||||
(status = 500, description = "服务未就绪", body = HttpResult<String>)
|
||||
),
|
||||
tag = "health"
|
||||
)]
|
||||
pub async fn ready_check(state: axum::extract::State<AppState>) -> Json<HttpResult<String>> {
|
||||
match state.health_check().await {
|
||||
Ok(_) => Json(HttpResult::success("ready".to_string())),
|
||||
Err(e) => Json(HttpResult::<String>::error(
|
||||
"E001".to_string(),
|
||||
format!("not ready: {e}"),
|
||||
)),
|
||||
}
|
||||
}
|
||||
1084
qiming-mcp-proxy/document-parser/src/handlers/markdown_handler.rs
Normal file
1084
qiming-mcp-proxy/document-parser/src/handlers/markdown_handler.rs
Normal file
File diff suppressed because it is too large
Load Diff
20
qiming-mcp-proxy/document-parser/src/handlers/mod.rs
Normal file
20
qiming-mcp-proxy/document-parser/src/handlers/mod.rs
Normal file
@@ -0,0 +1,20 @@
|
||||
// 处理器模块
|
||||
pub mod document_handler;
|
||||
pub mod health_handler;
|
||||
pub mod markdown_handler;
|
||||
pub mod monitoring_handler;
|
||||
pub mod private_oss_handler;
|
||||
pub mod response;
|
||||
pub mod task_handler;
|
||||
pub mod toc_handler;
|
||||
pub mod validation;
|
||||
|
||||
pub use document_handler::*;
|
||||
pub use health_handler::{health_check, ready_check};
|
||||
pub use markdown_handler::*;
|
||||
pub use monitoring_handler::*;
|
||||
pub use private_oss_handler::*;
|
||||
pub use response::*;
|
||||
pub use task_handler::*;
|
||||
pub use toc_handler::*;
|
||||
pub use validation::*;
|
||||
@@ -0,0 +1,356 @@
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Query, State},
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use tracing::{info, instrument};
|
||||
|
||||
use crate::{app_state::AppState, error::AppError, models::HttpResult};
|
||||
|
||||
/// 健康检查查询参数
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct HealthCheckQuery {
|
||||
/// 组件名称,如果指定则只检查该组件
|
||||
pub component: Option<String>,
|
||||
/// 是否包含详细信息
|
||||
pub detailed: Option<bool>,
|
||||
}
|
||||
|
||||
/// 指标查询参数
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct MetricsQuery {
|
||||
/// 指标格式:json 或 prometheus
|
||||
pub format: Option<String>,
|
||||
/// 指标名称过滤
|
||||
pub name: Option<String>,
|
||||
}
|
||||
|
||||
/// 系统信息响应
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct SystemInfoResponse {
|
||||
pub service_name: String,
|
||||
pub service_version: String,
|
||||
pub environment: String,
|
||||
pub uptime_seconds: u64,
|
||||
pub build_info: BuildInfo,
|
||||
pub runtime_info: RuntimeInfo,
|
||||
}
|
||||
|
||||
/// 构建信息
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct BuildInfo {
|
||||
pub version: String,
|
||||
pub git_commit: String,
|
||||
pub build_date: String,
|
||||
pub rust_version: String,
|
||||
}
|
||||
|
||||
/// 运行时信息
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct RuntimeInfo {
|
||||
pub platform: String,
|
||||
pub architecture: String,
|
||||
pub cpu_count: usize,
|
||||
pub memory_total_mb: u64,
|
||||
pub memory_used_mb: u64,
|
||||
}
|
||||
|
||||
/// 健康检查端点
|
||||
#[instrument(skip(_state))]
|
||||
pub async fn health_check(
|
||||
State(_state): State<Arc<AppState>>,
|
||||
Query(_query): Query<HealthCheckQuery>,
|
||||
) -> Result<Response, AppError> {
|
||||
info!("Health check request");
|
||||
|
||||
// 简化的健康检查实现
|
||||
let simple_status = SimpleHealthStatus {
|
||||
status: "healthy".to_string(),
|
||||
healthy_count: 1,
|
||||
unhealthy_count: 0,
|
||||
degraded_count: 0,
|
||||
timestamp: std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs(),
|
||||
};
|
||||
|
||||
Ok((StatusCode::OK, Json(HttpResult::success(simple_status))).into_response())
|
||||
}
|
||||
|
||||
/// 简化的健康状态响应
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct SimpleHealthStatus {
|
||||
pub status: String,
|
||||
pub healthy_count: usize,
|
||||
pub unhealthy_count: usize,
|
||||
pub degraded_count: usize,
|
||||
pub timestamp: u64,
|
||||
}
|
||||
|
||||
/// 就绪检查端点(Kubernetes readiness probe)
|
||||
#[instrument(skip(_state))]
|
||||
pub async fn readiness_check(State(_state): State<Arc<AppState>>) -> Result<Response, AppError> {
|
||||
info!("Readiness check request");
|
||||
Ok((StatusCode::OK, "Ready").into_response())
|
||||
}
|
||||
|
||||
/// 存活检查端点(Kubernetes liveness probe)
|
||||
#[instrument(skip(_state))]
|
||||
pub async fn liveness_check(State(_state): State<Arc<AppState>>) -> Result<Response, AppError> {
|
||||
// 存活检查只需要确认服务进程正在运行
|
||||
Ok((StatusCode::OK, "Alive").into_response())
|
||||
}
|
||||
|
||||
/// 指标端点
|
||||
#[instrument(skip(_state))]
|
||||
pub async fn metrics(
|
||||
State(_state): State<Arc<AppState>>,
|
||||
Query(query): Query<MetricsQuery>,
|
||||
) -> Result<Response, AppError> {
|
||||
info!("Indicator request");
|
||||
|
||||
let format = query.format.as_deref().unwrap_or("prometheus");
|
||||
|
||||
match format {
|
||||
"prometheus" => {
|
||||
let metrics_data = "# Placeholder metrics\n";
|
||||
Ok((
|
||||
StatusCode::OK,
|
||||
[("content-type", "text/plain; version=0.0.4")],
|
||||
metrics_data,
|
||||
)
|
||||
.into_response())
|
||||
}
|
||||
"json" => {
|
||||
let metrics_data = r#"{"placeholder": "metrics"}"#;
|
||||
|
||||
Ok((
|
||||
StatusCode::OK,
|
||||
[("content-type", "application/json")],
|
||||
metrics_data,
|
||||
)
|
||||
.into_response())
|
||||
}
|
||||
_ => Ok(HttpResult::<()>::error::<()>(
|
||||
"INVALID_FORMAT".to_string(),
|
||||
"支持的格式: prometheus, json".to_string(),
|
||||
)
|
||||
.into_response()),
|
||||
}
|
||||
}
|
||||
|
||||
/// 系统信息端点
|
||||
#[instrument(skip(_state))]
|
||||
pub async fn system_info(State(_state): State<Arc<AppState>>) -> Result<Response, AppError> {
|
||||
info!("System information request");
|
||||
|
||||
// 获取内存信息
|
||||
let (memory_total_mb, memory_used_mb) = get_memory_info().await;
|
||||
|
||||
let system_info = SystemInfoResponse {
|
||||
service_name: "document-parser".to_string(),
|
||||
service_version: env!("CARGO_PKG_VERSION").to_string(),
|
||||
environment: std::env::var("ENVIRONMENT").unwrap_or_else(|_| "development".to_string()),
|
||||
uptime_seconds: 0, // 简化实现
|
||||
build_info: BuildInfo {
|
||||
version: env!("CARGO_PKG_VERSION").to_string(),
|
||||
git_commit: std::env::var("GIT_COMMIT").unwrap_or_else(|_| "unknown".to_string()),
|
||||
build_date: std::env::var("BUILD_DATE").unwrap_or_else(|_| "unknown".to_string()),
|
||||
rust_version: std::env::var("RUST_VERSION").unwrap_or_else(|_| "unknown".to_string()),
|
||||
},
|
||||
runtime_info: RuntimeInfo {
|
||||
platform: std::env::consts::OS.to_string(),
|
||||
architecture: std::env::consts::ARCH.to_string(),
|
||||
cpu_count: num_cpus::get(),
|
||||
memory_total_mb,
|
||||
memory_used_mb,
|
||||
},
|
||||
};
|
||||
|
||||
Ok(HttpResult::success(system_info).into_response())
|
||||
}
|
||||
|
||||
/// 配置信息端点
|
||||
#[instrument(skip(state))]
|
||||
pub async fn config_info(State(state): State<Arc<AppState>>) -> Result<Response, AppError> {
|
||||
info!("Configuration information request");
|
||||
|
||||
// 创建安全的配置摘要(隐藏敏感信息)
|
||||
let config_summary = create_config_summary(&state.config);
|
||||
|
||||
Ok(HttpResult::success(config_summary).into_response())
|
||||
}
|
||||
|
||||
/// 创建配置摘要(隐藏敏感信息)
|
||||
fn create_config_summary(config: &crate::config::AppConfig) -> HashMap<String, serde_json::Value> {
|
||||
let mut summary = HashMap::new();
|
||||
|
||||
// 服务器配置
|
||||
summary.insert(
|
||||
"server".to_string(),
|
||||
serde_json::json!({
|
||||
"host": config.server.host,
|
||||
"port": config.server.port,
|
||||
}),
|
||||
);
|
||||
|
||||
// 日志配置
|
||||
summary.insert(
|
||||
"log".to_string(),
|
||||
serde_json::json!({
|
||||
"level": config.log.level,
|
||||
"path": config.log.path,
|
||||
}),
|
||||
);
|
||||
|
||||
// 文档解析配置
|
||||
summary.insert(
|
||||
"document_parser".to_string(),
|
||||
serde_json::json!({
|
||||
"max_concurrent": config.document_parser.max_concurrent,
|
||||
"queue_size": config.document_parser.queue_size,
|
||||
"max_file_size": config.file_size_config.max_file_size.bytes(),
|
||||
"download_timeout": config.document_parser.download_timeout,
|
||||
"processing_timeout": config.document_parser.processing_timeout,
|
||||
}),
|
||||
);
|
||||
|
||||
// MinerU配置(隐藏敏感路径)
|
||||
summary.insert(
|
||||
"mineru".to_string(),
|
||||
serde_json::json!({
|
||||
"backend": config.mineru.backend,
|
||||
"max_concurrent": config.mineru.max_concurrent,
|
||||
"queue_size": config.mineru.queue_size,
|
||||
"timeout": config.mineru.timeout,
|
||||
}),
|
||||
);
|
||||
|
||||
// MarkItDown配置
|
||||
summary.insert(
|
||||
"markitdown".to_string(),
|
||||
serde_json::json!({
|
||||
"max_file_size": config.file_size_config.max_file_size.bytes(),
|
||||
"timeout": config.markitdown.timeout,
|
||||
"enable_plugins": config.markitdown.enable_plugins,
|
||||
"features": config.markitdown.features,
|
||||
}),
|
||||
);
|
||||
|
||||
// 存储配置(隐藏敏感信息)
|
||||
summary.insert(
|
||||
"storage".to_string(),
|
||||
serde_json::json!({
|
||||
"sled": {
|
||||
"cache_capacity": config.storage.sled.cache_capacity,
|
||||
},
|
||||
"oss": {
|
||||
"endpoint": config.storage.oss.endpoint,
|
||||
"public_bucket": config.storage.oss.public_bucket,
|
||||
"private_bucket": config.storage.oss.private_bucket,
|
||||
// 隐藏访问密钥
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
summary
|
||||
}
|
||||
|
||||
/// 获取内存信息
|
||||
async fn get_memory_info() -> (u64, u64) {
|
||||
tokio::task::spawn_blocking(|| {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
use std::process::Command;
|
||||
if let Ok(output) = Command::new("vm_stat").output() {
|
||||
if let Ok(output_str) = String::from_utf8(output.stdout) {
|
||||
let mut free_pages = 0u64;
|
||||
let mut active_pages = 0u64;
|
||||
let mut inactive_pages = 0u64;
|
||||
let mut wired_pages = 0u64;
|
||||
|
||||
for line in output_str.lines() {
|
||||
if line.contains("Pages free:") {
|
||||
if let Some(pages) = extract_pages(line) {
|
||||
free_pages = pages;
|
||||
}
|
||||
} else if line.contains("Pages active:") {
|
||||
if let Some(pages) = extract_pages(line) {
|
||||
active_pages = pages;
|
||||
}
|
||||
} else if line.contains("Pages inactive:") {
|
||||
if let Some(pages) = extract_pages(line) {
|
||||
inactive_pages = pages;
|
||||
}
|
||||
} else if line.contains("Pages wired down:") {
|
||||
if let Some(pages) = extract_pages(line) {
|
||||
wired_pages = pages;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let page_size = 4096u64;
|
||||
let total =
|
||||
(free_pages + active_pages + inactive_pages + wired_pages) * page_size;
|
||||
let used = (active_pages + inactive_pages + wired_pages) * page_size;
|
||||
|
||||
return (total / 1024 / 1024, used / 1024 / 1024);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
if let Ok(meminfo) = std::fs::read_to_string("/proc/meminfo") {
|
||||
let mut total = 0u64;
|
||||
let mut available = 0u64;
|
||||
|
||||
for line in meminfo.lines() {
|
||||
if line.starts_with("MemTotal:") {
|
||||
if let Some(kb) = extract_kb_value(line) {
|
||||
total = kb;
|
||||
}
|
||||
} else if line.starts_with("MemAvailable:") {
|
||||
if let Some(kb) = extract_kb_value(line) {
|
||||
available = kb;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let used = total - available;
|
||||
return (total / 1024, used / 1024);
|
||||
}
|
||||
}
|
||||
|
||||
// 默认值
|
||||
(0, 0)
|
||||
})
|
||||
.await
|
||||
.unwrap_or((0, 0))
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn extract_pages(line: &str) -> Option<u64> {
|
||||
let parts: Vec<&str> = line.split_whitespace().collect();
|
||||
if parts.len() >= 3 {
|
||||
let page_str = parts[2].trim_end_matches('.');
|
||||
page_str.parse().ok()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn extract_kb_value(line: &str) -> Option<u64> {
|
||||
let parts: Vec<&str> = line.split_whitespace().collect();
|
||||
if parts.len() >= 2 {
|
||||
parts[1].parse().ok()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,486 @@
|
||||
use axum::{
|
||||
extract::{Multipart, Query, State},
|
||||
response::IntoResponse,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::time::Duration;
|
||||
use tracing::{error, info, warn};
|
||||
use utoipa::ToSchema;
|
||||
|
||||
use crate::app_state::AppState;
|
||||
use crate::handlers::response::ApiResponse;
|
||||
|
||||
/// 文件上传响应
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
pub struct FileUploadResponse {
|
||||
pub oss_file_name: String,
|
||||
pub oss_bucket: String,
|
||||
pub download_url: String,
|
||||
pub expires_in_hours: u64,
|
||||
pub file_size: Option<u64>,
|
||||
pub original_filename: Option<String>,
|
||||
}
|
||||
|
||||
/// 下载URL响应
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
pub struct DownloadUrlResponse {
|
||||
pub download_url: String,
|
||||
pub oss_file_name: String,
|
||||
pub oss_bucket: String,
|
||||
pub expires_in_hours: u64,
|
||||
}
|
||||
|
||||
/// 获取下载URL请求参数
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
pub struct GetDownloadUrlParams {
|
||||
pub file_name: String,
|
||||
pub bucket: Option<String>,
|
||||
}
|
||||
|
||||
/// 获取上传签名URL请求参数
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
pub struct GetUploadSignUrlParams {
|
||||
pub file_name: String,
|
||||
pub content_type: Option<String>,
|
||||
pub bucket: Option<String>,
|
||||
}
|
||||
|
||||
/// 获取下载签名URL请求参数(4小时有效)
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
pub struct GetDownloadSignUrlParams {
|
||||
pub file_name: String,
|
||||
pub bucket: Option<String>,
|
||||
}
|
||||
|
||||
/// 删除文件请求参数
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
pub struct DeleteFileParams {
|
||||
pub file_name: String,
|
||||
pub bucket: Option<String>,
|
||||
}
|
||||
|
||||
/// 上传签名URL响应
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
pub struct UploadSignUrlResponse {
|
||||
pub upload_url: String,
|
||||
pub oss_file_name: String,
|
||||
pub oss_bucket: String,
|
||||
pub expires_in_hours: u64,
|
||||
pub content_type: String,
|
||||
}
|
||||
|
||||
/// 下载签名URL响应
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
pub struct DownloadSignUrlResponse {
|
||||
pub download_url: String,
|
||||
pub oss_file_name: String,
|
||||
pub oss_bucket: String,
|
||||
pub expires_in_hours: u64,
|
||||
}
|
||||
|
||||
/// 删除文件响应
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
pub struct DeleteFileResponse {
|
||||
pub oss_file_name: String,
|
||||
pub oss_bucket: String,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
/// 上传文件到OSS
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/v1/oss/upload",
|
||||
request_body(content = String, description = "文件内容", content_type = "multipart/form-data"),
|
||||
responses(
|
||||
(status = 200, description = "上传成功", body = FileUploadResponse),
|
||||
(status = 400, description = "请求参数错误"),
|
||||
(status = 413, description = "文件过大"),
|
||||
(status = 500, description = "服务器内部错误")
|
||||
),
|
||||
tag = "oss"
|
||||
)]
|
||||
pub async fn upload_file_to_oss(
|
||||
State(state): State<AppState>,
|
||||
mut multipart: Multipart,
|
||||
) -> impl IntoResponse {
|
||||
info!("OSS file upload request");
|
||||
|
||||
// 检查OSS客户端是否可用
|
||||
let oss_client = match &state.private_oss_client {
|
||||
Some(client) => client,
|
||||
None => {
|
||||
error!("The OSS client is not configured");
|
||||
return ApiResponse::internal_error::<FileUploadResponse>("OSS客户端未配置")
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let mut file_path: Option<String> = None;
|
||||
let mut original_filename: Option<String> = None;
|
||||
let mut temp_files = Vec::new();
|
||||
|
||||
// 处理multipart数据
|
||||
while let Some(field) = multipart.next_field().await.unwrap_or(None) {
|
||||
let field_name = field.name().unwrap_or("").to_string();
|
||||
|
||||
if field_name == "file" {
|
||||
let filename = field.file_name().map(|s| s.to_string());
|
||||
original_filename = filename.clone();
|
||||
|
||||
let data = match field.bytes().await {
|
||||
Ok(data) => data,
|
||||
Err(e) => {
|
||||
error!("Failed to read file data: {}", e);
|
||||
return ApiResponse::validation_error::<FileUploadResponse>("文件数据读取失败")
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
// 创建临时文件
|
||||
let temp_file = match tempfile::NamedTempFile::new() {
|
||||
Ok(file) => file,
|
||||
Err(e) => {
|
||||
error!("Failed to create temporary file: {}", e);
|
||||
return ApiResponse::internal_error::<FileUploadResponse>("临时文件创建失败")
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
if let Err(e) = std::fs::write(temp_file.path(), &data) {
|
||||
error!("Failed to write to temporary file: {}", e);
|
||||
return ApiResponse::internal_error::<FileUploadResponse>("文件写入失败")
|
||||
.into_response();
|
||||
}
|
||||
|
||||
file_path = Some(temp_file.path().to_string_lossy().to_string());
|
||||
temp_files.push(temp_file);
|
||||
}
|
||||
}
|
||||
|
||||
let file_path = match file_path {
|
||||
Some(path) => path,
|
||||
None => {
|
||||
warn!("Uploaded file not found");
|
||||
return ApiResponse::validation_error::<FileUploadResponse>("未提供文件")
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
// 获取文件大小
|
||||
let file_size = std::fs::metadata(&file_path).map(|m| m.len()).ok();
|
||||
|
||||
// 生成对象键名
|
||||
let object_key = if let Some(ref filename) = original_filename {
|
||||
// 使用原始文件名,添加时间戳避免冲突
|
||||
let timestamp = chrono::Utc::now().format("%Y%m%d_%H%M%S").to_string();
|
||||
let clean_filename = oss_client::utils::sanitize_filename(filename);
|
||||
format!("uploads/{timestamp}_{clean_filename}")
|
||||
} else {
|
||||
// 生成唯一文件名
|
||||
format!(
|
||||
"uploads/{}",
|
||||
oss_client::utils::generate_random_filename(None)
|
||||
)
|
||||
};
|
||||
|
||||
// 上传到OSS
|
||||
match oss_client.upload_file(&file_path, &object_key).await {
|
||||
Ok(oss_url) => {
|
||||
info!("File upload to OSS successful: object_key={}", object_key);
|
||||
|
||||
// 生成下载URL(4小时有效)
|
||||
let download_url = match oss_client
|
||||
.generate_download_url(&object_key, Some(Duration::from_secs(4 * 3600)))
|
||||
{
|
||||
Ok(url) => url,
|
||||
Err(_) => oss_url.clone(), // 如果生成签名URL失败,使用原始URL
|
||||
};
|
||||
|
||||
let response = FileUploadResponse {
|
||||
oss_file_name: object_key,
|
||||
oss_bucket: oss_client.get_config().bucket.clone(),
|
||||
download_url,
|
||||
expires_in_hours: 4,
|
||||
file_size,
|
||||
original_filename,
|
||||
};
|
||||
|
||||
ApiResponse::success(response).into_response()
|
||||
}
|
||||
Err(e) => {
|
||||
error!("File upload to OSS failed: {}", e);
|
||||
ApiResponse::internal_error::<FileUploadResponse>(&format!("上传失败: {e}"))
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取上传签名URL
|
||||
///
|
||||
/// ⚠️ **重要警告**: 使用相同的文件名上传会完全覆盖OSS中的现有文件,此操作不可逆!
|
||||
/// 建议在文件名中添加时间戳或UUID来避免意外覆盖,例如:document_20240101_120000.pdf
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/v1/oss/upload-sign-url",
|
||||
params(
|
||||
("file_name" = String, Query, description = "文件名(⚠️警告:相同文件名会覆盖现有文件!建议添加时间戳避免覆盖)"),
|
||||
("content_type" = Option<String>, Query, description = "文件内容类型,默认为 application/octet-stream"),
|
||||
("bucket" = Option<String>, Query, description = "存储桶名称(可选)")
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "获取成功,返回4小时有效的上传签名URL", body = UploadSignUrlResponse),
|
||||
(status = 400, description = "请求参数错误(如文件名为空)"),
|
||||
(status = 500, description = "服务器内部错误(如OSS客户端未配置)")
|
||||
),
|
||||
tag = "oss"
|
||||
)]
|
||||
pub async fn get_upload_sign_url(
|
||||
State(state): State<AppState>,
|
||||
Query(params): Query<GetUploadSignUrlParams>,
|
||||
) -> impl IntoResponse {
|
||||
info!(
|
||||
"Get the upload signature URL request: file_name={}, content_type={:?}, bucket={:?}",
|
||||
params.file_name, params.content_type, params.bucket
|
||||
);
|
||||
|
||||
// 验证文件名
|
||||
if params.file_name.trim().is_empty() {
|
||||
return ApiResponse::validation_error::<UploadSignUrlResponse>("文件名不能为空")
|
||||
.into_response();
|
||||
}
|
||||
|
||||
// 检查OSS客户端是否可用
|
||||
let oss_client = match &state.private_oss_client {
|
||||
Some(client) => client,
|
||||
None => {
|
||||
error!("The OSS client is not configured");
|
||||
return ApiResponse::internal_error::<UploadSignUrlResponse>("OSS客户端未配置")
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
// 默认内容类型
|
||||
let content_type = params
|
||||
.content_type
|
||||
.as_deref()
|
||||
.unwrap_or("application/octet-stream");
|
||||
|
||||
// 4小时有效期
|
||||
let expires_in = Duration::from_secs(4 * 3600);
|
||||
|
||||
// 构建完整的对象键名,包含edu前缀
|
||||
let object_key = if params.file_name.starts_with("edu/") {
|
||||
// 如果已经包含前缀,直接使用
|
||||
params.file_name.clone()
|
||||
} else {
|
||||
// 添加edu前缀
|
||||
format!("edu/{}", params.file_name.trim_start_matches('/'))
|
||||
};
|
||||
|
||||
// 生成上传签名URL
|
||||
match oss_client.generate_upload_url(&object_key, expires_in, Some(content_type)) {
|
||||
Ok(upload_url) => {
|
||||
info!(
|
||||
"Successfully generated upload signature URL: object_key={}, content_type={}",
|
||||
object_key, content_type
|
||||
);
|
||||
|
||||
let response = UploadSignUrlResponse {
|
||||
upload_url,
|
||||
oss_file_name: object_key,
|
||||
oss_bucket: oss_client.get_config().bucket.clone(),
|
||||
expires_in_hours: 4,
|
||||
content_type: content_type.to_string(),
|
||||
};
|
||||
|
||||
ApiResponse::success(response).into_response()
|
||||
}
|
||||
Err(e) => {
|
||||
error!(
|
||||
"Failed to generate upload signature URL: file_name={}, error={}",
|
||||
params.file_name, e
|
||||
);
|
||||
ApiResponse::internal_error::<UploadSignUrlResponse>(&format!(
|
||||
"生成上传签名URL失败: {e}"
|
||||
))
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取下载签名URL(4小时有效)
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/v1/oss/download-sign-url",
|
||||
params(
|
||||
("file_name" = String, Query, description = "文件名"),
|
||||
("bucket" = Option<String>, Query, description = "存储桶名称")
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "获取成功", body = DownloadSignUrlResponse),
|
||||
(status = 400, description = "请求参数错误"),
|
||||
(status = 404, description = "文件不存在"),
|
||||
(status = 500, description = "服务器内部错误")
|
||||
),
|
||||
tag = "oss"
|
||||
)]
|
||||
pub async fn get_download_sign_url(
|
||||
State(state): State<AppState>,
|
||||
Query(params): Query<GetDownloadSignUrlParams>,
|
||||
) -> impl IntoResponse {
|
||||
info!(
|
||||
"Get download signature URL request: file_name={}, bucket={:?}",
|
||||
params.file_name, params.bucket
|
||||
);
|
||||
|
||||
// 验证文件名
|
||||
if params.file_name.trim().is_empty() {
|
||||
return ApiResponse::validation_error::<DownloadSignUrlResponse>("文件名不能为空")
|
||||
.into_response();
|
||||
}
|
||||
|
||||
// 检查OSS客户端是否可用
|
||||
let oss_client = match &state.private_oss_client {
|
||||
Some(client) => client,
|
||||
None => {
|
||||
error!("The OSS client is not configured");
|
||||
return ApiResponse::internal_error::<DownloadSignUrlResponse>("OSS客户端未配置")
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
// 4小时有效期
|
||||
let expires_in = Duration::from_secs(4 * 3600);
|
||||
|
||||
// 构建完整的对象键名,如果没有前缀则添加edu前缀
|
||||
let object_key = if params.file_name.starts_with("edu/") {
|
||||
// 如果已经包含前缀,直接使用
|
||||
params.file_name.clone()
|
||||
} else {
|
||||
// 添加edu前缀
|
||||
format!("edu/{}", params.file_name.trim_start_matches('/'))
|
||||
};
|
||||
|
||||
// 生成下载签名URL
|
||||
match oss_client.generate_download_url(&object_key, Some(expires_in)) {
|
||||
Ok(download_url) => {
|
||||
info!(
|
||||
"Successfully generated download signature URL: object_key={}",
|
||||
object_key
|
||||
);
|
||||
|
||||
let response = DownloadSignUrlResponse {
|
||||
download_url,
|
||||
oss_file_name: object_key,
|
||||
oss_bucket: oss_client.get_config().bucket.clone(),
|
||||
expires_in_hours: 4,
|
||||
};
|
||||
|
||||
ApiResponse::success(response).into_response()
|
||||
}
|
||||
Err(e) => {
|
||||
error!(
|
||||
"Failed to generate download signature URL: file_name={}, error={}",
|
||||
params.file_name, e
|
||||
);
|
||||
ApiResponse::internal_error::<DownloadSignUrlResponse>(&format!(
|
||||
"生成下载签名URL失败: {e}"
|
||||
))
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 删除OSS文件
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/v1/oss/delete",
|
||||
params(
|
||||
("file_name" = String, Query, description = "要删除的文件名"),
|
||||
("bucket" = Option<String>, Query, description = "存储桶名称(可选)")
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "删除成功", body = DeleteFileResponse),
|
||||
(status = 400, description = "请求参数错误(如文件名为空)"),
|
||||
(status = 404, description = "文件不存在"),
|
||||
(status = 500, description = "服务器内部错误(如OSS客户端未配置)")
|
||||
),
|
||||
tag = "oss"
|
||||
)]
|
||||
pub async fn delete_file_from_oss(
|
||||
State(state): State<AppState>,
|
||||
Query(params): Query<DeleteFileParams>,
|
||||
) -> impl IntoResponse {
|
||||
info!(
|
||||
"Delete OSS file request: file_name={}, bucket={:?}",
|
||||
params.file_name, params.bucket
|
||||
);
|
||||
|
||||
// 验证文件名
|
||||
if params.file_name.trim().is_empty() {
|
||||
return ApiResponse::validation_error::<DeleteFileResponse>("文件名不能为空")
|
||||
.into_response();
|
||||
}
|
||||
|
||||
// 检查OSS客户端是否可用
|
||||
let oss_client = match &state.private_oss_client {
|
||||
Some(client) => client,
|
||||
None => {
|
||||
error!("The OSS client is not configured");
|
||||
return ApiResponse::internal_error::<DeleteFileResponse>("OSS客户端未配置")
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
// 构建完整的对象键名,如果没有前缀则添加edu前缀
|
||||
let object_key = if params.file_name.starts_with("edu/") {
|
||||
// 如果已经包含前缀,直接使用
|
||||
params.file_name.clone()
|
||||
} else {
|
||||
// 添加edu前缀
|
||||
format!("edu/{}", params.file_name.trim_start_matches('/'))
|
||||
};
|
||||
|
||||
// 先检查文件是否存在
|
||||
match oss_client.file_exists(&object_key).await {
|
||||
Ok(exists) => {
|
||||
if !exists {
|
||||
warn!("The file to be deleted does not exist: {}", object_key);
|
||||
return ApiResponse::not_found::<DeleteFileResponse>("文件不存在").into_response();
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
error!(
|
||||
"Failed to check file existence: file_name={}, error={}",
|
||||
params.file_name, e
|
||||
);
|
||||
return ApiResponse::internal_error::<DeleteFileResponse>(&format!(
|
||||
"检查文件存在性失败: {e}"
|
||||
))
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
|
||||
// 删除文件
|
||||
match oss_client.delete_file(&object_key).await {
|
||||
Ok(_) => {
|
||||
info!("OSS file deleted successfully: object_key={}", object_key);
|
||||
|
||||
let response = DeleteFileResponse {
|
||||
oss_file_name: object_key,
|
||||
oss_bucket: oss_client.get_config().bucket.clone(),
|
||||
message: "文件删除成功".to_string(),
|
||||
};
|
||||
|
||||
ApiResponse::success(response).into_response()
|
||||
}
|
||||
Err(e) => {
|
||||
error!(
|
||||
"Failed to delete OSS file: file_name={}, error={}",
|
||||
params.file_name, e
|
||||
);
|
||||
ApiResponse::internal_error::<DeleteFileResponse>(&format!("删除文件失败: {e}"))
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
340
qiming-mcp-proxy/document-parser/src/handlers/response.rs
Normal file
340
qiming-mcp-proxy/document-parser/src/handlers/response.rs
Normal file
@@ -0,0 +1,340 @@
|
||||
use crate::error::AppError;
|
||||
use crate::models::HttpResult;
|
||||
use axum::{
|
||||
Json,
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use utoipa::ToSchema;
|
||||
|
||||
/// 标准API响应构建器
|
||||
pub struct ApiResponse;
|
||||
|
||||
impl ApiResponse {
|
||||
/// 成功响应
|
||||
pub fn success<T: Serialize>(data: T) -> impl IntoResponse {
|
||||
Json(HttpResult::success(data))
|
||||
}
|
||||
|
||||
/// 成功响应(带状态码)
|
||||
pub fn success_with_status<T: Serialize>(data: T, status: StatusCode) -> impl IntoResponse {
|
||||
(status, HttpResult::success(data))
|
||||
}
|
||||
|
||||
/// 错误响应
|
||||
pub fn error<T>(error_code: String, message: String) -> Json<HttpResult<T>> {
|
||||
Json(HttpResult::<T>::error(error_code, message))
|
||||
}
|
||||
|
||||
/// 错误响应(带状态码)
|
||||
pub fn error_with_status<T>(
|
||||
error_code: String,
|
||||
message: String,
|
||||
status: StatusCode,
|
||||
) -> impl IntoResponse
|
||||
where
|
||||
T: serde::Serialize,
|
||||
{
|
||||
(status, HttpResult::<T>::error::<T>(error_code, message))
|
||||
}
|
||||
|
||||
/// 从AppError创建响应
|
||||
pub fn from_app_error<T>(error: AppError) -> impl IntoResponse
|
||||
where
|
||||
T: serde::Serialize,
|
||||
{
|
||||
let status = match &error {
|
||||
AppError::Validation(_) => StatusCode::BAD_REQUEST,
|
||||
AppError::File(_) | AppError::UnsupportedFormat(_) => StatusCode::BAD_REQUEST,
|
||||
AppError::Task(_) => StatusCode::NOT_FOUND,
|
||||
AppError::Network(_) | AppError::Timeout(_) => StatusCode::REQUEST_TIMEOUT,
|
||||
AppError::Parse(_) | AppError::MinerU(_) | AppError::MarkItDown(_) => {
|
||||
StatusCode::UNPROCESSABLE_ENTITY
|
||||
}
|
||||
AppError::Database(_) | AppError::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
AppError::Oss(_) => StatusCode::BAD_GATEWAY,
|
||||
_ => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
};
|
||||
|
||||
(status, error.to_http_result::<T>())
|
||||
}
|
||||
|
||||
/// 创建分页响应
|
||||
pub fn paginated<T: Serialize>(
|
||||
data: Vec<T>,
|
||||
total: usize,
|
||||
page: usize,
|
||||
page_size: usize,
|
||||
) -> Json<HttpResult<PaginatedResponse<T>>> {
|
||||
let total_pages = total.div_ceil(page_size);
|
||||
let response = PaginatedResponse {
|
||||
data,
|
||||
pagination: PaginationInfo {
|
||||
total,
|
||||
page,
|
||||
page_size,
|
||||
total_pages,
|
||||
has_next: page < total_pages,
|
||||
has_prev: page > 1,
|
||||
},
|
||||
};
|
||||
Json(HttpResult::success(response))
|
||||
}
|
||||
|
||||
/// 创建空响应
|
||||
pub fn empty() -> Json<HttpResult<()>> {
|
||||
Json(HttpResult::success(()))
|
||||
}
|
||||
|
||||
/// 创建消息响应
|
||||
pub fn message(message: String) -> Json<HttpResult<MessageResponse>> {
|
||||
Json(HttpResult::success(MessageResponse { message }))
|
||||
}
|
||||
|
||||
/// 创建统计响应
|
||||
pub fn stats(stats: HashMap<String, serde_json::Value>) -> Json<HttpResult<StatsResponse>> {
|
||||
Json(HttpResult::success(StatsResponse { stats }))
|
||||
}
|
||||
|
||||
/// 验证错误响应
|
||||
pub fn validation_error<T>(message: &str) -> Json<HttpResult<T>> {
|
||||
Json(HttpResult::<T>::error(
|
||||
"VALIDATION_ERROR".to_string(),
|
||||
message.to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
/// 内部错误响应
|
||||
pub fn internal_error<T>(message: &str) -> Json<HttpResult<T>> {
|
||||
Json(HttpResult::<T>::error(
|
||||
"INTERNAL_ERROR".to_string(),
|
||||
message.to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
/// 未找到错误响应
|
||||
pub fn not_found<T>(message: &str) -> Json<HttpResult<T>> {
|
||||
Json(HttpResult::<T>::error(
|
||||
"NOT_FOUND".to_string(),
|
||||
message.to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
/// 请求错误响应
|
||||
pub fn bad_request<T>(message: &str) -> Json<HttpResult<T>> {
|
||||
Json(HttpResult::<T>::error(
|
||||
"BAD_REQUEST".to_string(),
|
||||
message.to_string(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
/// 分页响应结构
|
||||
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
||||
pub struct PaginatedResponse<T> {
|
||||
pub data: Vec<T>,
|
||||
pub pagination: PaginationInfo,
|
||||
}
|
||||
|
||||
/// 分页信息
|
||||
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
||||
pub struct PaginationInfo {
|
||||
pub total: usize,
|
||||
pub page: usize,
|
||||
pub page_size: usize,
|
||||
pub total_pages: usize,
|
||||
pub has_next: bool,
|
||||
pub has_prev: bool,
|
||||
}
|
||||
|
||||
/// 消息响应
|
||||
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
||||
pub struct MessageResponse {
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
/// 统计响应
|
||||
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
||||
pub struct StatsResponse {
|
||||
pub stats: HashMap<String, serde_json::Value>,
|
||||
}
|
||||
|
||||
/// 健康检查响应
|
||||
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
||||
pub struct HealthResponse {
|
||||
pub status: String,
|
||||
pub version: String,
|
||||
pub timestamp: String,
|
||||
pub services: HashMap<String, ServiceHealth>,
|
||||
}
|
||||
|
||||
/// 服务健康状态
|
||||
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
||||
pub struct ServiceHealth {
|
||||
pub status: String,
|
||||
pub message: Option<String>,
|
||||
pub response_time_ms: Option<u64>,
|
||||
}
|
||||
|
||||
/// 文件上传响应
|
||||
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
||||
pub struct UploadResponse {
|
||||
pub task_id: String,
|
||||
pub message: String,
|
||||
pub file_info: FileInfo,
|
||||
}
|
||||
|
||||
/// 文件信息
|
||||
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
||||
pub struct FileInfo {
|
||||
pub filename: String,
|
||||
pub size: u64,
|
||||
pub format: String,
|
||||
pub mime_type: String,
|
||||
}
|
||||
|
||||
/// URL下载响应
|
||||
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
||||
pub struct DownloadResponse {
|
||||
pub task_id: String,
|
||||
pub message: String,
|
||||
pub url_info: UrlInfo,
|
||||
}
|
||||
|
||||
/// URL信息
|
||||
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
||||
pub struct UrlInfo {
|
||||
pub url: String,
|
||||
pub format: String,
|
||||
pub estimated_size: Option<u64>,
|
||||
}
|
||||
|
||||
/// 简化的任务状态枚举(用于 API 响应)
|
||||
#[derive(Debug, Serialize, Deserialize, ToSchema, Clone, PartialEq, Eq)]
|
||||
pub enum SimpleTaskStatus {
|
||||
Pending,
|
||||
Processing,
|
||||
Completed,
|
||||
Failed,
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for SimpleTaskStatus {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
SimpleTaskStatus::Pending => write!(f, "Pending"),
|
||||
SimpleTaskStatus::Processing => write!(f, "Processing"),
|
||||
SimpleTaskStatus::Completed => write!(f, "Completed"),
|
||||
SimpleTaskStatus::Failed => write!(f, "Failed"),
|
||||
SimpleTaskStatus::Cancelled => write!(f, "Cancelled"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&crate::models::TaskStatus> for SimpleTaskStatus {
|
||||
fn from(status: &crate::models::TaskStatus) -> Self {
|
||||
match status {
|
||||
crate::models::TaskStatus::Pending { .. } => SimpleTaskStatus::Pending,
|
||||
crate::models::TaskStatus::Processing { .. } => SimpleTaskStatus::Processing,
|
||||
crate::models::TaskStatus::Completed { .. } => SimpleTaskStatus::Completed,
|
||||
crate::models::TaskStatus::Failed { .. } => SimpleTaskStatus::Failed,
|
||||
crate::models::TaskStatus::Cancelled { .. } => SimpleTaskStatus::Cancelled,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 任务操作响应
|
||||
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
||||
pub struct TaskOperationResponse {
|
||||
pub task_id: String,
|
||||
pub operation: String,
|
||||
pub message: String,
|
||||
pub timestamp: String,
|
||||
// 移除 task 字段以避免循环引用
|
||||
// pub task: Option<crate::models::DocumentTask>,
|
||||
pub complete: bool,
|
||||
pub status: SimpleTaskStatus,
|
||||
}
|
||||
|
||||
/// 批量操作响应
|
||||
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
||||
pub struct BatchOperationResponse {
|
||||
pub total: usize,
|
||||
pub successful: usize,
|
||||
pub failed: usize,
|
||||
pub errors: Vec<BatchError>,
|
||||
}
|
||||
|
||||
/// 批量操作错误
|
||||
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
||||
pub struct BatchError {
|
||||
pub item_id: String,
|
||||
pub error_code: String,
|
||||
pub error_message: String,
|
||||
}
|
||||
|
||||
/// 响应头工具
|
||||
pub struct ResponseHeaders;
|
||||
|
||||
impl ResponseHeaders {
|
||||
/// 添加CORS头
|
||||
pub fn cors() -> axum::http::HeaderMap {
|
||||
let mut headers = axum::http::HeaderMap::new();
|
||||
headers.insert(
|
||||
axum::http::header::ACCESS_CONTROL_ALLOW_ORIGIN,
|
||||
"*".parse().unwrap(),
|
||||
);
|
||||
headers.insert(
|
||||
axum::http::header::ACCESS_CONTROL_ALLOW_METHODS,
|
||||
"GET, POST, PUT, DELETE, OPTIONS".parse().unwrap(),
|
||||
);
|
||||
headers.insert(
|
||||
axum::http::header::ACCESS_CONTROL_ALLOW_HEADERS,
|
||||
"Content-Type, Authorization, X-Requested-With"
|
||||
.parse()
|
||||
.unwrap(),
|
||||
);
|
||||
headers
|
||||
}
|
||||
|
||||
/// 添加缓存头
|
||||
pub fn cache_control(max_age: u32) -> axum::http::HeaderMap {
|
||||
let mut headers = axum::http::HeaderMap::new();
|
||||
headers.insert(
|
||||
axum::http::header::CACHE_CONTROL,
|
||||
format!("public, max-age={max_age}").parse().unwrap(),
|
||||
);
|
||||
headers
|
||||
}
|
||||
|
||||
/// 添加内容类型头
|
||||
pub fn content_type(content_type: &str) -> axum::http::HeaderMap {
|
||||
let mut headers = axum::http::HeaderMap::new();
|
||||
headers.insert(
|
||||
axum::http::header::CONTENT_TYPE,
|
||||
content_type.parse().unwrap(),
|
||||
);
|
||||
headers
|
||||
}
|
||||
}
|
||||
|
||||
use axum::{extract::Request, middleware::Next};
|
||||
/// 响应时间中间件
|
||||
use std::time::Instant;
|
||||
|
||||
pub async fn response_time_middleware(request: Request, next: Next) -> Response {
|
||||
let start = Instant::now();
|
||||
let mut response = next.run(request).await;
|
||||
let duration = start.elapsed();
|
||||
|
||||
response.headers_mut().insert(
|
||||
"X-Response-Time",
|
||||
format!("{:.2}ms", duration.as_secs_f64() * 1000.0)
|
||||
.parse()
|
||||
.unwrap(),
|
||||
);
|
||||
|
||||
response
|
||||
}
|
||||
1130
qiming-mcp-proxy/document-parser/src/handlers/task_handler.rs
Normal file
1130
qiming-mcp-proxy/document-parser/src/handlers/task_handler.rs
Normal file
File diff suppressed because it is too large
Load Diff
235
qiming-mcp-proxy/document-parser/src/handlers/toc_handler.rs
Normal file
235
qiming-mcp-proxy/document-parser/src/handlers/toc_handler.rs
Normal file
@@ -0,0 +1,235 @@
|
||||
use crate::app_state::AppState;
|
||||
use crate::models::{HttpResult, StructuredSection};
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Path, State},
|
||||
};
|
||||
use serde::Serialize;
|
||||
use utoipa::ToSchema;
|
||||
|
||||
/// 目录响应结构
|
||||
///
|
||||
/// 表示文档目录处理完成后的结果,包含任务ID、目录结构和统计信息。
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
pub struct TocResponse {
|
||||
/// 文档处理任务的唯一标识符
|
||||
/// 用于关联请求和响应,支持异步处理和状态查询
|
||||
pub task_id: String,
|
||||
|
||||
/// 文档的目录结构
|
||||
/// 包含所有章节和子章节的层级结构,支持无限嵌套
|
||||
pub toc: Vec<StructuredSection>,
|
||||
|
||||
/// 目录中章节的总数量
|
||||
/// 用于统计分析和分页显示
|
||||
pub total_sections: usize,
|
||||
}
|
||||
|
||||
/// 章节响应结构
|
||||
///
|
||||
/// 表示单个章节的详细信息,用于章节内容的展示和编辑。
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
pub struct SectionResponse {
|
||||
/// 章节的唯一标识符
|
||||
/// 用于章节的定位、引用和更新操作
|
||||
pub section_id: String,
|
||||
|
||||
/// 章节的标题或名称
|
||||
/// 显示在目录和导航中的章节标题
|
||||
pub title: String,
|
||||
|
||||
/// 章节的正文内容
|
||||
/// 包含章节的完整文本内容,支持Markdown格式
|
||||
pub content: String,
|
||||
|
||||
/// 章节的层级深度
|
||||
/// 1表示顶级章节,2表示二级章节,以此类推
|
||||
pub level: u8,
|
||||
|
||||
/// 是否包含子章节
|
||||
/// 用于判断章节是否可以展开显示子章节
|
||||
pub has_children: bool,
|
||||
}
|
||||
|
||||
/// 章节列表响应结构
|
||||
///
|
||||
/// 表示文档所有章节的完整信息,包含文档元数据和章节结构。
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
pub struct SectionsResponse {
|
||||
/// 文档处理任务的唯一标识符
|
||||
/// 用于关联请求和响应,支持异步处理和状态查询
|
||||
pub task_id: String,
|
||||
|
||||
/// 文档的标题或名称
|
||||
/// 显示在界面中的文档标题
|
||||
pub document_title: String,
|
||||
|
||||
/// 文档的完整目录结构
|
||||
/// 包含所有章节和子章节的层级结构,支持无限嵌套
|
||||
pub toc: Vec<StructuredSection>,
|
||||
|
||||
/// 文档中章节的总数量
|
||||
/// 用于统计分析和分页显示
|
||||
pub total_sections: usize,
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/v1/tasks/{task_id}/toc",
|
||||
params(
|
||||
("task_id" = String, Path, description = "任务ID")
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "获取文档目录成功", body = HttpResult<TocResponse>),
|
||||
(status = 404, description = "任务不存在或未生成结构化文档", body = HttpResult<TocResponse>),
|
||||
(status = 500, description = "服务器内部错误", body = HttpResult<TocResponse>)
|
||||
),
|
||||
tag = "toc"
|
||||
)]
|
||||
pub async fn get_document_toc(
|
||||
State(state): State<AppState>,
|
||||
Path(task_id): Path<String>,
|
||||
) -> Json<HttpResult<TocResponse>> {
|
||||
match state.task_service.get_task(&task_id).await {
|
||||
Ok(Some(task)) => {
|
||||
if let Some(doc) = task.structured_document {
|
||||
let resp = TocResponse {
|
||||
task_id: task_id.clone(),
|
||||
toc: doc.toc.clone(),
|
||||
total_sections: doc.total_sections,
|
||||
};
|
||||
Json(HttpResult::success(resp))
|
||||
} else {
|
||||
Json(HttpResult::<TocResponse>::error(
|
||||
"T009".to_string(),
|
||||
"任务尚未生成结构化文档".to_string(),
|
||||
))
|
||||
}
|
||||
}
|
||||
Ok(None) => Json(HttpResult::<TocResponse>::error(
|
||||
"T002".to_string(),
|
||||
format!("任务不存在: {task_id}"),
|
||||
)),
|
||||
Err(e) => Json(HttpResult::<TocResponse>::error(
|
||||
"T003".to_string(),
|
||||
format!("查询任务失败: {e}"),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/v1/tasks/{task_id}/sections/{section_id}",
|
||||
params(
|
||||
("task_id" = String, Path, description = "任务ID"),
|
||||
("section_id" = String, Path, description = "章节ID")
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "获取章节内容成功", body = HttpResult<SectionResponse>),
|
||||
(status = 404, description = "任务或章节不存在", body = HttpResult<SectionResponse>),
|
||||
(status = 500, description = "服务器内部错误", body = HttpResult<SectionResponse>)
|
||||
),
|
||||
tag = "toc"
|
||||
)]
|
||||
pub async fn get_section_content(
|
||||
State(state): State<AppState>,
|
||||
Path((task_id, section_id)): Path<(String, String)>,
|
||||
) -> Json<HttpResult<SectionResponse>> {
|
||||
match state.task_service.get_task(&task_id).await {
|
||||
Ok(Some(task)) => {
|
||||
if let Some(doc) = task.structured_document {
|
||||
// 遍历 toc 查找 section 元信息
|
||||
fn find<'a>(
|
||||
items: &'a [StructuredSection],
|
||||
id: &str,
|
||||
) -> Option<&'a StructuredSection> {
|
||||
for it in items {
|
||||
if it.id == id {
|
||||
return Some(it);
|
||||
}
|
||||
for child in &it.children {
|
||||
if let Some(found) = find(std::slice::from_ref(child.as_ref()), id) {
|
||||
return Some(found);
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
if let Some(toc_item) = find(&doc.toc, §ion_id) {
|
||||
let content = toc_item.content.clone();
|
||||
let resp = SectionResponse {
|
||||
section_id: section_id.clone(),
|
||||
title: toc_item.title.clone(),
|
||||
content,
|
||||
level: toc_item.level,
|
||||
has_children: !toc_item.children.is_empty(),
|
||||
};
|
||||
Json(HttpResult::success(resp))
|
||||
} else {
|
||||
Json(HttpResult::<SectionResponse>::error(
|
||||
"T010".to_string(),
|
||||
format!("章节不存在: {section_id}"),
|
||||
))
|
||||
}
|
||||
} else {
|
||||
Json(HttpResult::<SectionResponse>::error(
|
||||
"T009".to_string(),
|
||||
"任务尚未生成结构化文档".to_string(),
|
||||
))
|
||||
}
|
||||
}
|
||||
Ok(None) => Json(HttpResult::<SectionResponse>::error(
|
||||
"T002".to_string(),
|
||||
format!("任务不存在: {task_id}"),
|
||||
)),
|
||||
Err(e) => Json(HttpResult::<SectionResponse>::error(
|
||||
"T003".to_string(),
|
||||
format!("查询任务失败: {e}"),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/v1/tasks/{task_id}/sections",
|
||||
params(
|
||||
("task_id" = String, Path, description = "任务ID")
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "获取所有章节成功", body = HttpResult<SectionsResponse>),
|
||||
(status = 404, description = "任务不存在或未生成结构化文档", body = HttpResult<SectionsResponse>),
|
||||
(status = 500, description = "服务器内部错误", body = HttpResult<SectionsResponse>)
|
||||
),
|
||||
tag = "toc"
|
||||
)]
|
||||
pub async fn get_all_sections(
|
||||
State(state): State<AppState>,
|
||||
Path(task_id): Path<String>,
|
||||
) -> Json<HttpResult<SectionsResponse>> {
|
||||
match state.task_service.get_task(&task_id).await {
|
||||
Ok(Some(task)) => {
|
||||
if let Some(doc) = task.structured_document {
|
||||
let resp = SectionsResponse {
|
||||
task_id: task_id.clone(),
|
||||
document_title: doc.document_title.clone(),
|
||||
toc: doc.toc.clone(),
|
||||
total_sections: doc.total_sections,
|
||||
};
|
||||
Json(HttpResult::success(resp))
|
||||
} else {
|
||||
Json(HttpResult::<SectionsResponse>::error(
|
||||
"T009".to_string(),
|
||||
"任务尚未生成结构化文档".to_string(),
|
||||
))
|
||||
}
|
||||
}
|
||||
Ok(None) => Json(HttpResult::<SectionsResponse>::error(
|
||||
"T002".to_string(),
|
||||
format!("任务不存在: {task_id}"),
|
||||
)),
|
||||
Err(e) => Json(HttpResult::<SectionsResponse>::error(
|
||||
"T003".to_string(),
|
||||
format!("查询任务失败: {e}"),
|
||||
)),
|
||||
}
|
||||
}
|
||||
300
qiming-mcp-proxy/document-parser/src/handlers/validation.rs
Normal file
300
qiming-mcp-proxy/document-parser/src/handlers/validation.rs
Normal file
@@ -0,0 +1,300 @@
|
||||
use crate::error::AppError;
|
||||
use crate::models::DocumentFormat;
|
||||
use std::collections::HashSet;
|
||||
use url::Url;
|
||||
|
||||
/// 请求验证器
|
||||
pub struct RequestValidator;
|
||||
|
||||
impl RequestValidator {
|
||||
/// 验证文件大小
|
||||
pub fn validate_file_size(size: u64, max_size: u64) -> Result<(), AppError> {
|
||||
if size == 0 {
|
||||
return Err(AppError::Validation("文件大小不能为0".to_string()));
|
||||
}
|
||||
if size > max_size {
|
||||
return Err(AppError::Validation(format!(
|
||||
"文件大小超过限制: {size} > {max_size} 字节"
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 验证文件扩展名
|
||||
pub fn validate_file_extension(
|
||||
filename: &str,
|
||||
allowed_extensions: &[String],
|
||||
) -> Result<String, AppError> {
|
||||
let extension = std::path::Path::new(filename)
|
||||
.extension()
|
||||
.and_then(|ext| ext.to_str())
|
||||
.map(|ext| ext.to_lowercase())
|
||||
.ok_or_else(|| AppError::Validation("无法确定文件扩展名".to_string()))?;
|
||||
|
||||
if !allowed_extensions.contains(&extension) {
|
||||
return Err(AppError::Validation(format!(
|
||||
"不支持的文件格式: .{extension}"
|
||||
)));
|
||||
}
|
||||
|
||||
Ok(extension)
|
||||
}
|
||||
|
||||
/// 验证URL格式
|
||||
pub fn validate_url(url_str: &str) -> Result<Url, AppError> {
|
||||
let url =
|
||||
Url::parse(url_str).map_err(|e| AppError::Validation(format!("无效的URL格式: {e}")))?;
|
||||
|
||||
// 检查协议
|
||||
if !matches!(url.scheme(), "http" | "https") {
|
||||
return Err(AppError::Validation("只支持HTTP和HTTPS协议".to_string()));
|
||||
}
|
||||
|
||||
// 检查主机
|
||||
let host = url
|
||||
.host_str()
|
||||
.ok_or_else(|| AppError::Validation("URL缺少主机名".to_string()))?;
|
||||
|
||||
// 防止访问本地地址
|
||||
if Self::is_local_address(host) {
|
||||
return Err(AppError::Validation("不允许访问本地地址".to_string()));
|
||||
}
|
||||
|
||||
Ok(url)
|
||||
}
|
||||
|
||||
/// 验证URL格式(仅验证,不返回解析后的URL)
|
||||
pub fn validate_url_format(url_str: &str) -> Result<(), AppError> {
|
||||
let _url =
|
||||
Url::parse(url_str).map_err(|e| AppError::Validation(format!("无效的URL格式: {e}")))?;
|
||||
|
||||
// 检查协议
|
||||
if !url_str.starts_with("http://") && !url_str.starts_with("https://") {
|
||||
return Err(AppError::Validation("只支持HTTP和HTTPS协议".to_string()));
|
||||
}
|
||||
|
||||
// 检查是否包含主机名(简单检查)
|
||||
if !url_str.contains("://") || url_str.split("://").nth(1).unwrap_or("").is_empty() {
|
||||
return Err(AppError::Validation("URL缺少主机名".to_string()));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 验证OSS路径
|
||||
pub fn validate_oss_path(oss_path: &str) -> Result<(), AppError> {
|
||||
if oss_path.is_empty() {
|
||||
return Err(AppError::Validation("OSS路径不能为空".to_string()));
|
||||
}
|
||||
|
||||
// 检查路径格式
|
||||
if oss_path.starts_with('/') || oss_path.contains("../") || oss_path.contains("..\\") {
|
||||
return Err(AppError::Validation("OSS路径格式无效".to_string()));
|
||||
}
|
||||
|
||||
// 检查路径长度
|
||||
if oss_path.len() > 1024 {
|
||||
return Err(AppError::Validation("OSS路径过长".to_string()));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 验证任务ID格式
|
||||
pub fn validate_task_id(task_id: &str) -> Result<(), AppError> {
|
||||
if task_id.is_empty() {
|
||||
return Err(AppError::Validation("任务ID不能为空".to_string()));
|
||||
}
|
||||
|
||||
// 检查UUID格式
|
||||
if uuid::Uuid::parse_str(task_id).is_err() {
|
||||
return Err(AppError::Validation("任务ID格式无效".to_string()));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 验证分页参数
|
||||
pub fn validate_pagination(
|
||||
page: Option<usize>,
|
||||
page_size: Option<usize>,
|
||||
) -> Result<(usize, usize), AppError> {
|
||||
let page = page.unwrap_or(1);
|
||||
let page_size = page_size.unwrap_or(10);
|
||||
|
||||
if page == 0 {
|
||||
return Err(AppError::Validation("页码必须大于0".to_string()));
|
||||
}
|
||||
|
||||
if page_size == 0 || page_size > 100 {
|
||||
return Err(AppError::Validation("每页大小必须在1-100之间".to_string()));
|
||||
}
|
||||
|
||||
Ok((page, page_size))
|
||||
}
|
||||
|
||||
/// 验证排序参数
|
||||
pub fn validate_sort_params(
|
||||
sort_by: Option<&str>,
|
||||
sort_order: Option<&str>,
|
||||
) -> Result<(String, String), AppError> {
|
||||
let allowed_sort_fields =
|
||||
HashSet::from(["created_at", "updated_at", "progress", "file_size"]);
|
||||
|
||||
let sort_by = sort_by.unwrap_or("created_at");
|
||||
if !allowed_sort_fields.contains(sort_by) {
|
||||
return Err(AppError::Validation(format!("不支持的排序字段: {sort_by}")));
|
||||
}
|
||||
|
||||
let sort_order = sort_order.unwrap_or("desc");
|
||||
if !matches!(sort_order, "asc" | "desc") {
|
||||
return Err(AppError::Validation("排序方向必须是asc或desc".to_string()));
|
||||
}
|
||||
|
||||
Ok((sort_by.to_string(), sort_order.to_string()))
|
||||
}
|
||||
|
||||
/// 验证文档格式
|
||||
pub fn validate_document_format(format: &DocumentFormat) -> Result<(), AppError> {
|
||||
// 这里可以添加特定格式的验证逻辑
|
||||
match format {
|
||||
DocumentFormat::PDF
|
||||
| DocumentFormat::Word
|
||||
| DocumentFormat::Excel
|
||||
| DocumentFormat::PowerPoint
|
||||
| DocumentFormat::Image
|
||||
| DocumentFormat::Audio
|
||||
| DocumentFormat::HTML
|
||||
| DocumentFormat::Text
|
||||
| DocumentFormat::Txt
|
||||
| DocumentFormat::Md
|
||||
| DocumentFormat::Other(_) => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
/// 验证Markdown内容
|
||||
pub fn validate_markdown_content(content: &str) -> Result<(), AppError> {
|
||||
if content.is_empty() {
|
||||
return Err(AppError::Validation("Markdown内容不能为空".to_string()));
|
||||
}
|
||||
|
||||
// 检查内容长度(最大10MB)
|
||||
const MAX_CONTENT_SIZE: usize = 10 * 1024 * 1024;
|
||||
if content.len() > MAX_CONTENT_SIZE {
|
||||
return Err(AppError::Validation(format!(
|
||||
"Markdown内容过长: {} > {} 字节",
|
||||
content.len(),
|
||||
MAX_CONTENT_SIZE
|
||||
)));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 验证TOC配置
|
||||
pub fn validate_toc_config(
|
||||
enable_toc: Option<bool>,
|
||||
max_toc_depth: Option<usize>,
|
||||
) -> Result<(bool, usize), AppError> {
|
||||
let enable_toc = enable_toc.unwrap_or(true);
|
||||
let max_depth = max_toc_depth.unwrap_or(3);
|
||||
|
||||
if enable_toc && (max_depth == 0 || max_depth > 10) {
|
||||
return Err(AppError::Validation(
|
||||
"TOC最大深度必须在1-10之间".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
Ok((enable_toc, max_depth))
|
||||
}
|
||||
|
||||
/// 验证分页参数
|
||||
pub fn validate_pagination_params(page: usize, page_size: usize) -> Result<(), AppError> {
|
||||
if page == 0 {
|
||||
return Err(AppError::Validation("页码必须大于0".to_string()));
|
||||
}
|
||||
if page_size == 0 || page_size > 100 {
|
||||
return Err(AppError::Validation("每页大小必须在1-100之间".to_string()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 检查是否为本地地址
|
||||
fn is_local_address(host: &str) -> bool {
|
||||
//todo: 找rust生态的库,看能否用更简单的方式实现"检查是否为本地地址"
|
||||
matches!(
|
||||
host,
|
||||
"localhost"
|
||||
| "127.0.0.1"
|
||||
| "::1"
|
||||
| "0.0.0.0"
|
||||
| "10.0.0.0"
|
||||
| "172.16.0.0"
|
||||
| "192.168.0.0"
|
||||
) || host.starts_with("10.")
|
||||
|| host.starts_with("172.16.")
|
||||
|| host.starts_with("172.17.")
|
||||
|| host.starts_with("172.18.")
|
||||
|| host.starts_with("172.19.")
|
||||
|| host.starts_with("172.20.")
|
||||
|| host.starts_with("172.21.")
|
||||
|| host.starts_with("172.22.")
|
||||
|| host.starts_with("172.23.")
|
||||
|| host.starts_with("172.24.")
|
||||
|| host.starts_with("172.25.")
|
||||
|| host.starts_with("172.26.")
|
||||
|| host.starts_with("172.27.")
|
||||
|| host.starts_with("172.28.")
|
||||
|| host.starts_with("172.29.")
|
||||
|| host.starts_with("172.30.")
|
||||
|| host.starts_with("172.31.")
|
||||
|| host.starts_with("192.168.")
|
||||
}
|
||||
}
|
||||
|
||||
/// 文件名清理工具
|
||||
pub struct FileNameSanitizer;
|
||||
|
||||
impl FileNameSanitizer {
|
||||
/// 清理文件名
|
||||
pub fn sanitize(filename: &str) -> Result<String, AppError> {
|
||||
if filename.is_empty() {
|
||||
return Err(AppError::Validation("文件名不能为空".to_string()));
|
||||
}
|
||||
|
||||
// 移除危险字符
|
||||
let sanitized = filename
|
||||
.chars()
|
||||
.filter(|c| !matches!(c, '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|'))
|
||||
.collect::<String>();
|
||||
|
||||
if sanitized.is_empty() {
|
||||
return Err(AppError::Validation("文件名包含过多非法字符".to_string()));
|
||||
}
|
||||
|
||||
// 检查长度
|
||||
if sanitized.len() > 255 {
|
||||
return Err(AppError::Validation("文件名过长".to_string()));
|
||||
}
|
||||
|
||||
// 避免保留名称
|
||||
let reserved_names = HashSet::from([
|
||||
"CON", "PRN", "AUX", "NUL", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7",
|
||||
"COM8", "COM9", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9",
|
||||
]);
|
||||
|
||||
let name_without_ext = std::path::Path::new(&sanitized)
|
||||
.file_stem()
|
||||
.and_then(|s| s.to_str())
|
||||
.unwrap_or(&sanitized)
|
||||
.to_uppercase();
|
||||
|
||||
if reserved_names.contains(name_without_ext.as_str()) {
|
||||
return Err(AppError::Validation(
|
||||
"文件名不能使用系统保留名称".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
Ok(sanitized)
|
||||
}
|
||||
}
|
||||
202
qiming-mcp-proxy/document-parser/src/lib.rs
Normal file
202
qiming-mcp-proxy/document-parser/src/lib.rs
Normal file
@@ -0,0 +1,202 @@
|
||||
#![recursion_limit = "256"]
|
||||
|
||||
// 初始化 i18n,使用 crate 内置翻译文件
|
||||
#[macro_use]
|
||||
extern crate rust_i18n;
|
||||
|
||||
// 初始化翻译文件,使用 crate 内置 locales(支持独立发布)
|
||||
i18n!("locales", fallback = "en");
|
||||
|
||||
use utoipa::OpenApi;
|
||||
|
||||
pub mod app_state;
|
||||
pub mod config;
|
||||
pub mod error;
|
||||
pub mod handlers;
|
||||
pub mod middleware;
|
||||
pub mod models;
|
||||
pub mod parsers;
|
||||
pub mod performance;
|
||||
pub mod processors;
|
||||
pub mod production;
|
||||
pub mod routes;
|
||||
pub mod services;
|
||||
pub mod utils;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
pub use app_state::AppState;
|
||||
pub use config::AppConfig;
|
||||
pub use error::AppError;
|
||||
pub use models::*;
|
||||
|
||||
/// 应用版本
|
||||
pub const APP_VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
|
||||
/// 应用名称
|
||||
pub const APP_NAME: &str = env!("CARGO_PKG_NAME");
|
||||
|
||||
/// 应用描述
|
||||
pub const APP_DESCRIPTION: &str =
|
||||
"多格式文档解析服务 - 支持PDF、Word、Excel、PowerPoint等格式转换为结构化Markdown";
|
||||
|
||||
/// 默认配置
|
||||
pub fn get_default_config() -> AppConfig {
|
||||
serde_yaml::from_str(include_str!("../config.yml")).expect("默认配置应该有效")
|
||||
}
|
||||
|
||||
/// OpenAPI 文档配置
|
||||
#[derive(OpenApi)]
|
||||
#[openapi(
|
||||
info(
|
||||
title = "Document Parser API",
|
||||
version = "1.0.0",
|
||||
description = "多格式文档解析服务 - 支持PDF、Word、Excel、PowerPoint等格式转换为结构化Markdown",
|
||||
contact(
|
||||
name = "Document Parser Team",
|
||||
email = "support@example.com"
|
||||
),
|
||||
license(
|
||||
name = "MIT",
|
||||
url = "https://opensource.org/licenses/MIT"
|
||||
)
|
||||
),
|
||||
paths(
|
||||
// 文档处理接口
|
||||
handlers::document_handler::upload_document,
|
||||
handlers::document_handler::download_document_from_url,
|
||||
handlers::document_handler::generate_structured_document,
|
||||
handlers::document_handler::get_supported_formats,
|
||||
handlers::document_handler::get_parser_stats,
|
||||
handlers::document_handler::check_parser_health,
|
||||
|
||||
// 任务管理接口
|
||||
handlers::task_handler::create_task,
|
||||
handlers::task_handler::get_task,
|
||||
handlers::task_handler::list_tasks,
|
||||
handlers::task_handler::cancel_task,
|
||||
handlers::task_handler::delete_task,
|
||||
handlers::task_handler::batch_operation_tasks,
|
||||
handlers::task_handler::retry_task,
|
||||
handlers::task_handler::get_task_stats,
|
||||
handlers::task_handler::cleanup_expired_tasks,
|
||||
handlers::task_handler::get_task_progress,
|
||||
handlers::task_handler::get_task_result,
|
||||
|
||||
// Markdown处理接口
|
||||
handlers::markdown_handler::parse_markdown_sections,
|
||||
handlers::markdown_handler::download_markdown,
|
||||
handlers::markdown_handler::get_markdown_url,
|
||||
|
||||
// 私有桶的OSS服务接口
|
||||
handlers::private_oss_handler::upload_file_to_oss,
|
||||
handlers::private_oss_handler::get_upload_sign_url,
|
||||
handlers::private_oss_handler::get_download_sign_url,
|
||||
handlers::private_oss_handler::delete_file_from_oss,
|
||||
|
||||
// 健康检查接口
|
||||
handlers::health_handler::health_check,
|
||||
handlers::health_handler::ready_check,
|
||||
|
||||
// TOC接口
|
||||
handlers::toc_handler::get_document_toc,
|
||||
handlers::toc_handler::get_section_content,
|
||||
handlers::toc_handler::get_all_sections,
|
||||
|
||||
),
|
||||
components(
|
||||
schemas(
|
||||
// 基础模型
|
||||
models::HttpResult<String>,
|
||||
models::HttpResult<serde_json::Value>,
|
||||
models::TaskStatus,
|
||||
models::ProcessingStage,
|
||||
models::DocumentTask,
|
||||
models::StructuredDocument,
|
||||
// models::StructuredSection, // 临时移除以避免递归问题
|
||||
models::DocumentFormat,
|
||||
models::ParserEngine,
|
||||
models::TestPostMineruRequest,
|
||||
models::TestPostMineruResponse,
|
||||
// models::TocItem, // 临时移除以避免递归问题
|
||||
models::DocumentStructure,
|
||||
models::DocumentStatistics,
|
||||
models::OssData,
|
||||
models::ImageInfo,
|
||||
|
||||
// 文档处理相关
|
||||
handlers::document_handler::DocumentParseResponse,
|
||||
handlers::document_handler::StructuredDocumentResponse,
|
||||
handlers::document_handler::SupportedFormatsResponse,
|
||||
handlers::document_handler::ParserStatsResponse,
|
||||
|
||||
|
||||
// 任务管理相关
|
||||
handlers::task_handler::CreateTaskRequest,
|
||||
handlers::task_handler::TaskQueryParams,
|
||||
handlers::task_handler::BatchOperationRequest,
|
||||
handlers::task_handler::BatchOperation,
|
||||
handlers::task_handler::CancelTaskRequest,
|
||||
handlers::task_handler::TaskResponse,
|
||||
handlers::task_handler::TaskListResponse,
|
||||
handlers::task_handler::TaskStatsResponse,
|
||||
handlers::task_handler::TaskResultSummaryResponse,
|
||||
handlers::task_handler::TaskFileInfo,
|
||||
handlers::task_handler::TaskOssInfo,
|
||||
handlers::task_handler::TaskProcessingStats,
|
||||
|
||||
// Markdown处理相关
|
||||
handlers::markdown_handler::MarkdownProcessRequest,
|
||||
handlers::markdown_handler::SectionsSyncResponse,
|
||||
|
||||
// OSS服务相关
|
||||
handlers::private_oss_handler::FileUploadResponse,
|
||||
handlers::private_oss_handler::DownloadUrlResponse,
|
||||
handlers::private_oss_handler::GetDownloadUrlParams,
|
||||
handlers::private_oss_handler::GetUploadSignUrlParams,
|
||||
handlers::private_oss_handler::GetDownloadSignUrlParams,
|
||||
handlers::private_oss_handler::UploadSignUrlResponse,
|
||||
handlers::private_oss_handler::DownloadSignUrlResponse,
|
||||
|
||||
// 响应类型
|
||||
// handlers::response::PaginatedResponse<models::DocumentTask>, // 移除以避免循环引用
|
||||
handlers::response::PaginationInfo,
|
||||
handlers::response::MessageResponse,
|
||||
handlers::response::StatsResponse,
|
||||
handlers::response::HealthResponse,
|
||||
handlers::response::ServiceHealth,
|
||||
handlers::response::UploadResponse,
|
||||
handlers::response::FileInfo,
|
||||
handlers::response::DownloadResponse,
|
||||
handlers::response::UrlInfo,
|
||||
handlers::response::TaskOperationResponse,
|
||||
handlers::response::BatchOperationResponse,
|
||||
handlers::response::BatchError,
|
||||
|
||||
// 服务类型
|
||||
crate::services::TaskStats,
|
||||
|
||||
// TOC和章节相关
|
||||
handlers::toc_handler::TocResponse,
|
||||
handlers::toc_handler::SectionResponse,
|
||||
handlers::toc_handler::SectionsResponse
|
||||
)
|
||||
),
|
||||
tags(
|
||||
(name = "documents", description = "文档处理相关接口"),
|
||||
(name = "tasks", description = "任务管理相关接口"),
|
||||
(name = "markdown", description = "Markdown处理相关接口"),
|
||||
(name = "oss", description = "OSS服务相关接口"),
|
||||
(name = "health", description = "健康检查相关接口"),
|
||||
(name = "toc", description = "目录和章节相关接口"),
|
||||
(name = "test", description = "测试相关接口"),
|
||||
|
||||
)
|
||||
)]
|
||||
pub struct ApiDoc;
|
||||
|
||||
/// 获取OpenAPI规范JSON
|
||||
pub fn get_openapi_spec() -> String {
|
||||
ApiDoc::openapi().to_pretty_json().unwrap()
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user