Compare commits

...

14 Commits

Author SHA1 Message Date
baiyanyun
a574b2c661 重构客户端左侧导航 2026-06-30 15:49:58 +08:00
baiyanyun
5d87c9eee7 重构客户端登录页并更新品牌图标 2026-06-30 15:09:46 +08:00
baiyanyun
44fe160880 fix: use child menu title for work tabs 2026-06-04 16:59:50 +08:00
baiyanyun
f9e090125c feat: improve theme configuration 2026-06-04 16:48:47 +08:00
baiyanyun
a01fc93594 add online monitor and fix admin scopes 2026-06-02 20:50:42 +08:00
baiyanyun
f69fb46c59 fix user group permission scope 2026-06-02 19:51:45 +08:00
baiyanyun
873cd6ef53 产品化改造:完善本地集成与启动体验 2026-06-02 17:55:08 +08:00
baiyanyun
a280774f50 Ignore IDE and log artifacts 2026-06-01 19:31:45 +08:00
Codex
142b8b78fa 添加开发环境启动手册 2026-06-01 19:18:04 +08:00
Codex
6ff9bfc6b5 添加开发环境配置文件 2026-06-01 13:59:01 +08:00
Codex
4b1a580132 添加qiming-rcoder模块 2026-06-01 13:54:52 +08:00
Codex
8092c4b1f8 "添加前端模板和运行代码模块" 2026-06-01 13:43:09 +08:00
Codex
24045274ad 提交qiming-noVNC 2026-06-01 13:15:17 +08:00
Codex
afb3d9f4e6 提交qiming-mcp-proxy 2026-06-01 13:03:20 +08:00
1868 changed files with 441848 additions and 1442 deletions

10
.gitignore vendored
View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -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);
}
}
}

View File

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

View File

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

View File

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

View File

@@ -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() {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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"),

View File

@@ -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();
}
}

View File

@@ -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() {

View File

@@ -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() {

View 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;

View 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 };

View 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 };

View 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,
};

View 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 };

View 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 };

View 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 };

View 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,
};

View File

@@ -0,0 +1,2 @@
[env]
CMAKE_CUDA_ARCHITECTURES = "60-real;61-real;62-real;70-real;75-real;80-real;86-real"

View 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"
}

View 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

View 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
View 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/

View 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

View 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)

View 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` 宏,
统一 UnixProcessGroup和 WindowsJobObject + CREATE_NO_WINDOW的进程包装
### Fixed
- Windows 平台隐藏控制台窗口配置

365
qiming-mcp-proxy/CLAUDE.md Normal file
View 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

File diff suppressed because it is too large Load Diff

108
qiming-mcp-proxy/Cargo.toml Normal file
View File

@@ -0,0 +1,108 @@
[workspace]
# 排除 fastembedort-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.10mcp-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
View 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

View 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.

View 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
View 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-proxydocker-compose -d"
@echo " make run-fg - 构建 + 前台启动 mcp-proxydocker-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
View 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: MIT OR Apache-2.0](https://img.shields.io/badge/License-MIT%20OR%20Apache--2.0-blue.svg)](LICENSE)
[![Rust](https://img.shields.io/badge/rust-1.70%2B-orange.svg)](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)

View File

@@ -0,0 +1,237 @@
# MCP-Proxy Workspace
**[English](README.md)** | **[简体中文](README_zh-CN.md)**
---
# MCP-Proxy 工作空间
一个基于 Rust 的综合工作空间,实现了 MCP (Model Context Protocol) 代理系统,包含文档解析、语音转录和协议转换等多个服务。
[![License: MIT OR Apache-2.0](https://img.shields.io/badge/License-MIT%20OR%20Apache--2.0-blue.svg)](LICENSE)
[![Rust](https://img.shields.io/badge/rust-1.70%2B-orange.svg)](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
View 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)

View File

@@ -0,0 +1,4 @@
[default.extend-words]
[files]
extend-exclude = ["CHANGELOG.md", "notebooks/*"]

View File

@@ -0,0 +1,3 @@
# Assets
- [juventus.csv](./juventus.csv): dataset from [The-Football-Data](https://github.com/buckthorndev/The-Football-Data).

View 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
1 Name Position DOB Nationality Kit Number
2 Wojciech Szczesny Goalkeeper Apr 18, 1990 (29) Poland 1
3 Mattia Perin Goalkeeper Nov 10, 1992 (26) Italy 37
4 Gianluigi Buffon Goalkeeper Jan 28, 1978 (41) Italy 77
5 Carlo Pinsoglio Goalkeeper Mar 16, 1990 (29) Italy 31
6 Matthijs de Ligt Centre-Back Aug 12, 1999 (20) Netherlands 4
7 Leonardo Bonucci Centre-Back May 1, 1987 (32) Italy 19
8 Daniele Rugani Centre-Back Jul 29, 1994 (25) Italy 24
9 Merih Demiral Centre-Back Mar 5, 1998 (21) Turkey 28
10 Giorgio Chiellini Centre-Back Aug 14, 1984 (35) Italy 3
11 Alex Sandro Left-Back Jan 26, 1991 (28) Brazil 12
12 Danilo Right-Back Jul 15, 1991 (28) Brazil 13
13 Mattia De Sciglio Right-Back Oct 20, 1992 (27) Italy 2
14 Emre Can Defensive Midfield Jan 12, 1994 (25) Germany 23
15 Miralem Pjanic Central Midfield Apr 2, 1990 (29) Bosnia-Herzegovina 5
16 Aaron Ramsey Central Midfield Dec 26, 1990 (28) Wales 8
17 Adrien Rabiot Central Midfield Apr 3, 1995 (24) France 25
18 Rodrigo Bentancur Central Midfield Jun 25, 1997 (22) Uruguay 30
19 Blaise Matuidi Central Midfield Apr 9, 1987 (32) France 14
20 Sami Khedira Central Midfield Apr 4, 1987 (32) Germany 6
21 Cristiano Ronaldo Left Winger Feb 5, 1985 (34) Portugal 7
22 Marko Pjaca Left Winger May 6, 1995 (24) Croatia 15
23 Federico Bernardeschi Right Winger Feb 16, 1994 (25) Italy 33
24 Douglas Costa Right Winger Sep 14, 1990 (29) Brazil 11
25 Juan Cuadrado Right Winger May 26, 1988 (31) Colombia 16
26 Paulo Dybala Second Striker Nov 15, 1993 (25) Argentina 10
27 Gonzalo Higuaín Centre-Forward Dec 10, 1987 (31) Argentina 21
28 Mario Mandzukic Centre-Forward May 21, 1986 (33) Croatia 17

View 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

View 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
View 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 = []

View 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"]

View File

@@ -0,0 +1 @@
registry=https://registry.npmmirror.com/

View 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

View 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"]

View 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 # 本文档
```

View 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

View 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

View File

@@ -0,0 +1,3 @@
[global]
index-url = https://pypi.tuna.tsinghua.edu.cn/simple
trusted-host = pypi.tuna.tsinghua.edu.cn

View 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

View 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)

View File

@@ -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 中实现日志捕获

View 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**

View 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 窗口的同步 CommandWindows
///
/// # 用法
/// ```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 窗口的异步 CommandWindows
#[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 版本
**风险**: 低(只影响进程启动方式,不改变业务逻辑)

View File

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

View 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%
```

View 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

View 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 检测的修复建议

View 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` 获取帮助。

View 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"

View 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!

View 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环境配置指南。

View 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` 来解决。这个命令会自动检测和修复常见的环境问题。

View 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. 手动安装sglangGPU加速必需
**重要**不要直接安装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版本等
---
**注意**:本手册基于当前版本编写,如有更新请参考最新文档。

View File

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

View 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");
}

View 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

View File

@@ -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("![") || line.contains("](") {
println!(" {}: {}", i + 1, line);
}
}
println!("Content after replacement:");
for (i, line) in replaced_content.lines().take(10).enumerate() {
if line.contains("![") || 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(())
}

View 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
}
}

View 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
1 姓名 年龄 职业 城市 薪资
2 张三 25 软件工程师 北京 15000
3 李四 30 产品经理 上海 20000
4 王五 28 UI设计师 深圳 18000
5 赵六 32 数据分析师 杭州 22000
6 钱七 26 前端开发 广州 16000
7 孙八 29 后端开发 成都 17000
8 周九 31 测试工程师 武汉 14000
9 吴十 27 运维工程师 西安 15000
10 郑十一 33 架构师 南京 30000
11 王十二 24 实习生 苏州 8000

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

View 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) 了解更多信息。
### 图片
![示例图片](https://via.placeholder.com/300x200)
## 代码
### 行内代码
使用 `console.log()` 来输出信息。
### 代码块
```python
def hello_world():
print("Hello, World!")
return "success"
```
```javascript
function greet(name) {
return `Hello, ${name}!`;
}
```
## 表格
| 姓名 | 年龄 | 职业 |
|------|------|------|
| 张三 | 25 | 工程师 |
| 李四 | 30 | 设计师 |
| 王五 | 28 | 产品经理 |
## 引用
> 这是一个引用块。
>
> 可以包含多行内容。
## 水平线
---
## 任务列表
- [x] 完成项目规划
- [x] 编写核心代码
- [ ] 进行单元测试
- [ ] 部署到生产环境
## 总结
这个文档包含了:
- 各种标题级别
- 文本格式化
- 列表和表格
- 代码示例
- 链接和图片
- 引用
用于全面测试 Markdown 解析器的功能。

View File

@@ -0,0 +1,28 @@
这是一个纯文本测试文档
用于测试文档解析器对纯文本格式的处理能力。
文档内容包含:
1. 中文字符
2. 英文字符
3. 数字
4. 标点符号
5. 换行符
这个文档没有特殊的格式标记,纯粹是文本内容。
可以用来测试:
- 文本提取功能
- 字符编码处理
- 换行符处理
- 文本长度计算
- 内容分析功能
测试用例应该能够:
- 正确识别这是一个文本文件
- 提取完整的文本内容
- 保持原有的换行格式
- 计算准确的字符数量
- 生成合适的元数据
结束。

View File

@@ -0,0 +1,21 @@
# 简单测试文档
这是一个简单的 Markdown 文档,用于基础功能测试。
## 内容
- 项目介绍
- 功能特性
- 使用方法
## 代码示例
```rust
fn main() {
println!("Hello, World!");
}
```
## 总结
测试完成。

View 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 内容的解析能力。

View 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

View 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"

View 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: "跳过"

View 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: "跳過"

View 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()
);
}
}
}
}
}
}
}

File diff suppressed because it is too large Load Diff

View 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())
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -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}"),
)),
}
}

File diff suppressed because it is too large Load Diff

View 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::*;

View File

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

View File

@@ -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);
// 生成下载URL4小时有效
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()
}
}
}
/// 获取下载签名URL4小时有效
#[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()
}
}
}

View 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
}

File diff suppressed because it is too large Load Diff

View 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, &section_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}"),
)),
}
}

View 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)
}
}

View 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