Commit a86a826d authored by ligaowei's avatar ligaowei

添加剩余的代码文件和资源文件

parent a92e8d6e
package pangea.hiagent.controller;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import pangea.hiagent.agent.AgentChatService;
import pangea.hiagent.workpanel.SseEventManager;
import pangea.hiagent.dto.AgentRequest;
import pangea.hiagent.dto.ChatRequest;
import pangea.hiagent.model.Agent;
import pangea.hiagent.service.AgentService;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
/**
* Agent 对话控制器
* 提供Agent的对话交互功能,支持流式输出
*/
@Slf4j
@RestController
@RequestMapping("/api/v1/agent")
public class AgentChatController {
@Autowired
private AgentService agentService;
@Autowired
private AgentChatService agentChatService;
@Autowired
private SseEventManager sseEventManager;
private final ExecutorService executorService = Executors.newFixedThreadPool(10);
/**
* 流式对话接口
* 支持 SSE (Server-Sent Events) 格式的流式输出
*
* @param agentId Agent ID
* @param chatRequest 对话请求
* @return SSE emitter
*/
@PostMapping("/chat-stream")
@PreAuthorize("hasRole('USER')")
public SseEmitter chatStream(
@RequestParam String agentId,
@RequestBody ChatRequest chatRequest,
HttpServletResponse response) {
// 将ChatRequest转换为AgentRequest
AgentRequest request = new AgentRequest();
request.setUserMessage(chatRequest.getMessage());
log.info("开始处理流式对话请求,AgentId: {}, 用户消息: {}", agentId, request.getUserMessage());
// 检查用户消息是否为空
if (request.getUserMessage() == null || request.getUserMessage().trim().isEmpty()) {
log.error("用户消息不能为空");
// 检查响应是否已经提交
if (response.isCommitted()) {
log.warn("响应已经提交,无法发送用户消息为空错误");
return new SseEmitter(300000L); // 返回一个空的emitter
}
SseEmitter emitter = new SseEmitter(300000L);
try {
emitter.send(SseEmitter.event()
.name("error")
.data("用户消息不能为空")
.build());
emitter.complete();
} catch (IOException e) {
log.error("发送用户消息为空错误失败", e);
emitter.completeWithError(e);
}
return emitter;
}
String userId = getCurrentUserId();
if (userId == null) {
log.error("用户未认证");
// 检查响应是否已经提交
if (response.isCommitted()) {
log.warn("响应已经提交,无法发送未认证错误");
return new SseEmitter(300000L); // 返回一个空的emitter
}
SseEmitter emitter = new SseEmitter(300000L);
try {
emitter.send(SseEmitter.event()
.name("error")
.data("用户未认证")
.build());
emitter.complete();
} catch (IOException e) {
log.error("发送未认证错误失败", e);
emitter.completeWithError(e);
}
return emitter;
}
// 创建 SSE emitter
SseEmitter emitter = new SseEmitter(300000L); // 5分钟超时
// 异步处理对话,避免阻塞HTTP连接
executorService.execute(() -> {
try {
// 获取Agent信息
Agent agent = agentService.getAgent(agentId);
if (agent == null) {
log.error("Agent不存在: {}", agentId);
// 检查响应是否已经提交
if (!response.isCommitted()) {
sseEventManager.sendError(emitter, "Agent不存在");
} else {
log.warn("响应已经提交,无法发送Agent不存在错误");
try {
emitter.complete();
} catch (Exception ex) {
log.warn("关闭emitter时发生异常", ex);
}
}
return;
}
// 检查权限(可选)
if (!agent.getOwner().equals(userId) && !isAdmin(userId)) {
log.warn("用户 {} 无权访问 Agent {}", userId, agentId);
// 检查响应是否已经提交
if (!response.isCommitted()) {
sseEventManager.sendError(emitter, "无权限访问该Agent");
} else {
log.warn("响应已经提交,无法发送权限错误");
try {
emitter.complete();
} catch (Exception ex) {
log.warn("关闭emitter时发生异常", ex);
}
}
return;
}
// 根据Agent配置选择处理方式
if (agent.getEnableReAct() != null && agent.getEnableReAct()) {
// 使用ReAct Agent处理
log.info("使用ReAct Agent处理对话");
agentChatService.processReActAgentStreamWithSse(
request,
agent,
userId,
emitter,
sseEventManager,
null, // ReActService 会从 Spring 容器中注入
(agentParam, requestParam, userIdParam, responseContent, emitterParam, isCompleted, sseEventManagerParam) -> {
// 保存对话记录和发送完成事件
try {
// 使用现有的sendEvent方法发送完成事件
Map<String, Object> completeData = new HashMap<>();
completeData.put("message", "对话完成");
completeData.put("type", "complete");
sseEventManagerParam.sendEvent(emitterParam, "complete", completeData, isCompleted);
} catch (IOException e) {
log.error("发送完成事件失败", e);
}
}
);
} else {
// 使用普通Agent处理
log.info("使用普通Agent处理对话");
agentChatService.processNormalAgentStreamWithSse(
request,
agent,
userId,
emitter,
sseEventManager,
(token, fullText) -> {
// 创建token事件数据
java.util.Map<String, Object> data = new java.util.HashMap<>();
data.put("type", "token");
data.put("content", token);
return data;
},
(agentParam, requestParam, userIdParam, responseContent, emitterParam, isCompleted, sseEventManagerParam) -> {
// 保存对话记录和发送完成事件
try {
// 使用现有的sendEvent方法发送完成事件
Map<String, Object> completeData = new HashMap<>();
completeData.put("message", "对话完成");
completeData.put("type", "complete");
sseEventManagerParam.sendEvent(emitterParam, "complete", completeData, isCompleted);
} catch (IOException e) {
log.error("发送完成事件失败", e);
}
}
);
}
} catch (Exception e) {
log.error("处理流式对话失败", e);
// 检查响应是否已经提交
if (!response.isCommitted()) {
// sendError方法内部已经处理了所有可能的异常,无需再次捕获IOException
sseEventManager.sendError(emitter, "处理请求时发生错误: " + e.getMessage());
} else {
log.warn("响应已经提交,无法发送处理错误: {}", e.getMessage());
try {
emitter.complete();
} catch (Exception ex) {
log.warn("关闭emitter时发生异常", ex);
}
}
}
});
// 设置 emitter 的回调
emitter.onCompletion(() -> log.debug("SSE连接完成"));
emitter.onTimeout(() -> {
log.warn("SSE连接超时");
// complete方法内部已经处理了所有可能的异常,无需再次捕获IOException
try {
emitter.complete();
} catch (IllegalStateException e) {
log.warn("Emitter已经完成: {}", e.getMessage());
} catch (Exception e) {
log.error("关闭SSE连接时发生错误", e);
}
});
emitter.onError(throwable -> log.error("SSE连接错误", throwable));
return emitter;
}
/**
* 获取当前认证用户ID
*/
private String getCurrentUserId() {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication != null && authentication.getPrincipal() != null) {
return (String) authentication.getPrincipal();
}
return null;
}
/**
* 检查用户是否是管理员
*/
private boolean isAdmin(String userId) {
// 这里可以添加更复杂的权限检查逻辑
return "admin".equals(userId) || "user-001".equals(userId);
}
/**
* 用于测试的方法,暴露私有方法getCurrentUserId
*/
public String getCurrentUserIdForTesting() {
return getCurrentUserId();
}
}
\ No newline at end of file
package pangea.hiagent.controller;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.*;
import pangea.hiagent.dto.ApiResponse;
import pangea.hiagent.dto.PageData;
import pangea.hiagent.model.Agent;
import pangea.hiagent.service.AgentService;
import com.baomidou.mybatisplus.core.metadata.IPage;
/**
* Agent API控制器
* 提供Agent的増删改查功能
*/
@Slf4j
@RestController
@RequestMapping("/api/v1/agent")
public class AgentController {
// 常量定义(已删除,对话功能已移除)
// 服务依赖
private final AgentService agentService;
public AgentController(AgentService agentService) {
this.agentService = agentService;
}
// ==================== Agent管理API ====================
/**
* 创建Agent
*/
@PostMapping
public ApiResponse<Agent> createAgent(@RequestBody Agent agent) {
try {
String userId = getCurrentUserId();
if (userId == null) {
return ApiResponse.error(4001, "用户未认证");
}
log.info("用户 {} 开始创建Agent: {}", userId, agent.getName());
agent.setOwner(userId);
Agent created = agentService.createAgent(agent);
log.info("用户 {} 成功创建Agent: {} (ID: {})", userId, created.getName(), created.getId());
return ApiResponse.success(created, "创建Agent成功");
} catch (Exception e) {
log.error("创建Agent失败", e);
return ApiResponse.error(4001, "创建Agent失败: " + e.getMessage());
}
}
/**
* 更新Agent
*/
@PreAuthorize("@permissionEvaluator.hasPermission(authentication, #id, 'Agent', 'write')")
@PutMapping("/{id}")
public ApiResponse<Agent> updateAgent(@PathVariable String id, @RequestBody Agent agent) {
try {
String userId = getCurrentUserId();
log.info("用户 {} 开始更新Agent: {}", userId, id);
agent.setId(id);
Agent updated = agentService.updateAgent(agent);
log.info("用户 {} 成功更新Agent: {}", userId, updated.getId());
return ApiResponse.success(updated, "更新Agent成功");
} catch (Exception e) {
log.error("更新Agent失败", e);
return ApiResponse.error(4001, "更新Agent失败: " + e.getMessage());
}
}
/**
* 删除Agent
*/
@PreAuthorize("@permissionEvaluator.hasPermission(authentication, #id, 'Agent', 'delete')")
@DeleteMapping("/{id}")
public ApiResponse<Void> deleteAgent(@PathVariable String id) {
try {
String userId = getCurrentUserId();
log.info("用户 {} 开始删除Agent: {}", userId, id);
agentService.deleteAgent(id);
log.info("用户 {} 成功删除Agent: {}", userId, id);
return ApiResponse.success(null, "删除Agent成功");
} catch (Exception e) {
log.error("删除Agent失败", e);
return ApiResponse.error(4001, "删除Agent失败: " + e.getMessage());
}
}
/**
* 获取Agent详情
*/
@PreAuthorize("@permissionEvaluator.hasPermission(authentication, #id, 'Agent', 'read')")
@GetMapping("/{id}")
public ApiResponse<Agent> getAgent(@PathVariable String id) {
try {
Agent agent = agentService.getAgent(id);
if (agent == null) {
return ApiResponse.error(4001, "Agent不存在");
}
return ApiResponse.success(agent);
} catch (Exception e) {
log.error("获取Agent详情失败", e);
return ApiResponse.error(4001, "获取Agent详情失败: " + e.getMessage());
}
}
/**
* 分页获取Agent列表
*/
@GetMapping("/list")
public ApiResponse<PageData<Agent>> listAgents(
@RequestParam(defaultValue = "1") Long current,
@RequestParam(defaultValue = "10") Long size,
@RequestParam(required = false) String name,
@RequestParam(required = false) String status) {
try {
IPage<Agent> page = agentService.pageAgents(current, size, name, status);
return ApiResponse.success(PageData.from(page));
} catch (Exception e) {
log.error("获取Agent列表失败", e);
return ApiResponse.error(4001, "获取Agent列表失败: " + e.getMessage());
}
}
/**
* 获取用户的Agent列表
*/
@GetMapping
public ApiResponse<java.util.List<Agent>> getUserAgents() {
try {
String userId = getCurrentUserId();
if (userId == null) {
return ApiResponse.error(4001, "用户未认证");
}
java.util.List<Agent> agents = agentService.getUserAgents(userId);
return ApiResponse.success(agents);
} catch (Exception e) {
log.error("获取用户Agent列表失败", e);
return ApiResponse.error(4001, "获取用户Agent列表失败: " + e.getMessage());
}
}
// ==================== 私有方法 ====================
// ---------- 用户认证相关 ----------
/**
* 获取当前认证用户ID
*/
private String getCurrentUserId() {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
return (authentication != null && authentication.getPrincipal() != null) ?
(String) authentication.getPrincipal() : null;
}
}
\ No newline at end of file
package pangea.hiagent.controller;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.view.RedirectView;
import pangea.hiagent.dto.ApiResponse;
import pangea.hiagent.model.User;
import pangea.hiagent.service.AuthService;
import pangea.hiagent.service.UnifiedAuthService;
import pangea.hiagent.dto.OAuth2ProviderRequest;
import pangea.hiagent.service.OAuth2ProviderService;
import pangea.hiagent.model.OAuth2Provider;
import pangea.hiagent.dto.PageData;
import com.baomidou.mybatisplus.core.metadata.IPage;
import java.util.List;
import jakarta.validation.Valid;
/**
* 认证API控制器
*/
@Slf4j
@RestController
@RequestMapping("/api/v1/auth")
public class AuthController {
private final AuthService authService;
private final UnifiedAuthService unifiedAuthService;
private final OAuth2ProviderService oAuth2ProviderService;
public AuthController(AuthService authService, UnifiedAuthService unifiedAuthService,
OAuth2ProviderService oAuth2ProviderService) {
this.authService = authService;
this.unifiedAuthService = unifiedAuthService;
this.oAuth2ProviderService = oAuth2ProviderService;
}
/**
* 用户注册
*/
@PostMapping("/register")
public ApiResponse<User> register(@RequestBody RegisterRequest request) {
try {
User user = authService.register(request.getUsername(), request.getPassword(), request.getEmail());
return ApiResponse.success(user, "注册成功");
} catch (Exception e) {
log.error("用户注册失败", e);
return ApiResponse.error(3001, "注册失败: " + e.getMessage());
}
}
/**
* 用户登录
*/
@PostMapping("/login")
public ApiResponse<LoginResponse> login(@RequestBody LoginRequest request) {
try {
String token = authService.login(request.getUsername(), request.getPassword());
LoginResponse response = LoginResponse.builder()
.token(token)
.expiresIn(7200)
.tokenType("Bearer")
.build();
return ApiResponse.success(response, "登录成功");
} catch (Exception e) {
log.error("用户登录失败: 错误堆栈: ", e);
return ApiResponse.error(2001, "登录失败: " + e.getMessage());
}
}
/**
* OAuth2 授权端点
* 重定向用户到第三方授权服务器
*/
@GetMapping("/oauth2/authorize")
public RedirectView oauth2Authorize(@RequestParam String providerName) {
try {
log.info("处理 OAuth2 授权请求: providerName={}", providerName);
// 获取提供者配置并构造授权 URL
// 这里需要获取已配置的 OAuth2 提供者信息
String authorizationUrl = unifiedAuthService.buildOAuth2AuthorizationUrl(providerName);
return new RedirectView(authorizationUrl);
} catch (Exception e) {
log.error("OAuth2 授权处理失败: providerName={}, 错误堆栈: ", providerName, e);
return new RedirectView("/login?error=oauth2_auth_failed");
}
}
/**
* OAuth2 回调端点
* 接收来自授权服务器的回调,获取授权码
*/
@GetMapping("/oauth2/callback")
public RedirectView oauth2Callback(@RequestParam String code, @RequestParam String state, @RequestParam String providerName) {
try {
log.info("处理 OAuth2 回调: providerName={}, code={}, state={}", providerName,
code.substring(0, Math.min(20, code.length())) + "...", state);
// 使用授权码进行认证
String token = unifiedAuthService.loginWithOAuth2(code, providerName);
// 重定向到前端并传递 token
return new RedirectView("/login?token=" + token + "&method=oauth2");
} catch (Exception e) {
log.error("OAuth2 回调处理失败: providerName={}, code={}, 错误堆栈: ", providerName, code, e);
return new RedirectView("/login?error=oauth2_callback_failed");
}
}
/**
* OAuth2 令牌交换端点
* 后端调用这个端点交换授权码为访问令牌
*/
@PostMapping("/oauth2/token")
public ApiResponse<LoginResponse> oauth2Token(@RequestBody OAuth2TokenRequest request) {
try {
log.info("处理 OAuth2 令牌交换: providerName={}", request.getProviderName());
String token = unifiedAuthService.loginWithOAuth2(request.getAuthorizationCode(), request.getProviderName());
LoginResponse response = LoginResponse.builder()
.token(token)
.expiresIn(7200)
.tokenType("Bearer")
.build();
return ApiResponse.success(response, "OAuth2 认证成功");
} catch (Exception e) {
log.error("OAuth2 令牌交换失败: providerName={}, 错误堆栈: ", request.getProviderName(), e);
return ApiResponse.error(2002, "OAuth2 认证失败: " + e.getMessage());
}
}
/**
* 获取OAuth2提供商配置列表(分页)
*/
@GetMapping("/oauth2/providers")
public ApiResponse<PageData<OAuth2Provider>> listOAuth2Providers(
@RequestParam(defaultValue = "1") Long current,
@RequestParam(defaultValue = "10") Long size,
@RequestParam(required = false) String providerName,
@RequestParam(required = false) String displayName,
@RequestParam(required = false) Integer enabled) {
try {
log.info("获取OAuth2提供商配置列表: current={}, size={}, providerName={}, displayName={}, enabled={}",
current, size, providerName, displayName, enabled);
IPage<OAuth2Provider> page = oAuth2ProviderService.pageProviders(current, size, providerName, displayName, enabled);
PageData<OAuth2Provider> pageData = PageData.from(page);
return ApiResponse.success(pageData, "获取OAuth2提供商配置列表成功");
} catch (Exception e) {
log.error("获取OAuth2提供商配置列表失败: 错误堆栈: ", e);
return ApiResponse.error(4001, "获取OAuth2提供商配置列表失败: " + e.getMessage());
}
}
/**
* 获取所有OAuth2提供商配置(不分页)
*/
@GetMapping("/oauth2/providers/all")
public ApiResponse<List<OAuth2Provider>> getAllOAuth2Providers() {
try {
log.info("获取所有OAuth2提供商配置");
List<OAuth2Provider> providers = oAuth2ProviderService.listProviders();
return ApiResponse.success(providers, "获取所有OAuth2提供商配置成功");
} catch (Exception e) {
log.error("获取所有OAuth2提供商配置失败: 错误堆栈: ", e);
return ApiResponse.error(4002, "获取所有OAuth2提供商配置失败: " + e.getMessage());
}
}
/**
* 根据ID获取OAuth2提供商配置
*/
@GetMapping("/oauth2/providers/{id}")
public ApiResponse<OAuth2Provider> getOAuth2ProviderById(@PathVariable String id) {
try {
log.info("根据ID获取OAuth2提供商配置: id={}", id);
OAuth2Provider provider = oAuth2ProviderService.getProviderById(id);
if (provider == null) {
log.warn("OAuth2提供商配置不存在: id={}", id);
return ApiResponse.error(4003, "OAuth2提供商配置不存在");
}
return ApiResponse.success(provider, "获取OAuth2提供商配置成功");
} catch (Exception e) {
log.error("根据ID获取OAuth2提供商配置失败: id={}, 错误堆栈: ", id, e);
return ApiResponse.error(4004, "获取OAuth2提供商配置失败: " + e.getMessage());
}
}
/**
* 创建OAuth2提供商配置
*/
@PostMapping("/oauth2/providers")
public ApiResponse<OAuth2Provider> createOAuth2Provider(@Valid @RequestBody OAuth2ProviderRequest request) {
try {
log.info("创建OAuth2提供商配置: providerName={}", request.getProviderName());
OAuth2Provider provider = new OAuth2Provider();
provider.setProviderName(request.getProviderName());
provider.setDisplayName(request.getDisplayName());
provider.setDescription(request.getDescription());
provider.setAuthType(request.getAuthType());
provider.setAuthorizeUrl(request.getAuthorizeUrl());
provider.setTokenUrl(request.getTokenUrl());
provider.setUserinfoUrl(request.getUserinfoUrl());
provider.setClientId(request.getClientId());
provider.setClientSecret(request.getClientSecret());
provider.setRedirectUri(request.getRedirectUri());
provider.setScope(request.getScope());
provider.setEnabled(request.getEnabled());
provider.setConfigJson(request.getConfigJson());
// TODO: 设置创建人和更新人信息
OAuth2Provider savedProvider = oAuth2ProviderService.createProvider(provider);
return ApiResponse.success(savedProvider, "创建OAuth2提供商配置成功");
} catch (Exception e) {
log.error("创建OAuth2提供商配置失败: providerName={}, 错误堆栈: ", request.getProviderName(), e);
return ApiResponse.error(4005, "创建OAuth2提供商配置失败: " + e.getMessage());
}
}
/**
* 更新OAuth2提供商配置
*/
@PutMapping("/oauth2/providers/{id}")
public ApiResponse<OAuth2Provider> updateOAuth2Provider(@PathVariable String id,
@Valid @RequestBody OAuth2ProviderRequest request) {
try {
log.info("更新OAuth2提供商配置: id={}", id);
OAuth2Provider provider = new OAuth2Provider();
provider.setProviderName(request.getProviderName());
provider.setDisplayName(request.getDisplayName());
provider.setDescription(request.getDescription());
provider.setAuthType(request.getAuthType());
provider.setAuthorizeUrl(request.getAuthorizeUrl());
provider.setTokenUrl(request.getTokenUrl());
provider.setUserinfoUrl(request.getUserinfoUrl());
provider.setClientId(request.getClientId());
provider.setClientSecret(request.getClientSecret());
provider.setRedirectUri(request.getRedirectUri());
provider.setScope(request.getScope());
provider.setEnabled(request.getEnabled());
provider.setConfigJson(request.getConfigJson());
// TODO: 设置更新人信息
OAuth2Provider updatedProvider = oAuth2ProviderService.updateProvider(id, provider);
return ApiResponse.success(updatedProvider, "更新OAuth2提供商配置成功");
} catch (Exception e) {
log.error("更新OAuth2提供商配置失败: id={}, 错误堆栈: ", id, e);
return ApiResponse.error(4006, "更新OAuth2提供商配置失败: " + e.getMessage());
}
}
/**
* 删除OAuth2提供商配置
*/
@DeleteMapping("/oauth2/providers/{id}")
public ApiResponse<Void> deleteOAuth2Provider(@PathVariable String id) {
try {
log.info("删除OAuth2提供商配置: id={}", id);
oAuth2ProviderService.deleteProvider(id);
return ApiResponse.success(null, "删除OAuth2提供商配置成功");
} catch (Exception e) {
log.error("删除OAuth2提供商配置失败: id={}, 错误堆栈: ", id, e);
return ApiResponse.error(4007, "删除OAuth2提供商配置失败: " + e.getMessage());
}
}
/**
* 启用OAuth2提供商配置
*/
@PostMapping("/oauth2/providers/{id}/enable")
public ApiResponse<Void> enableOAuth2Provider(@PathVariable String id) {
try {
log.info("启用OAuth2提供商配置: id={}", id);
oAuth2ProviderService.enableProvider(id);
return ApiResponse.success(null, "启用OAuth2提供商配置成功");
} catch (Exception e) {
log.error("启用OAuth2提供商配置失败: id={}, 错误堆栈: ", id, e);
return ApiResponse.error(4008, "启用OAuth2提供商配置失败: " + e.getMessage());
}
}
/**
* 禁用OAuth2提供商配置
*/
@PostMapping("/oauth2/providers/{id}/disable")
public ApiResponse<Void> disableOAuth2Provider(@PathVariable String id) {
try {
log.info("禁用OAuth2提供商配置: id={}", id);
oAuth2ProviderService.disableProvider(id);
return ApiResponse.success(null, "禁用OAuth2提供商配置成功");
} catch (Exception e) {
log.error("禁用OAuth2提供商配置失败: id={}, 错误堆栈: ", id, e);
return ApiResponse.error(4009, "禁用OAuth2提供商配置失败: " + e.getMessage());
}
}
/**
* 注册请求DTO
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public static class RegisterRequest {
private String username;
private String password;
private String email;
}
/**
* 登录请求DTO
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public static class LoginRequest {
private String username;
private String password;
}
/**
* 登录响应 DTO
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public static class LoginResponse {
private String token;
private Integer expiresIn;
private String tokenType;
}
/**
* OAuth2 令牌交换请求 DTO
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public static class OAuth2TokenRequest {
private String authorizationCode;
private String providerName;
}
}
\ No newline at end of file
package pangea.hiagent.controller;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import pangea.hiagent.dto.ApiResponse;
import pangea.hiagent.rag.RagService;
import java.util.List;
import java.util.Map;
/**
* RAG API控制器
* 提供文档检索和RAG增强相关的API接口
*/
@Slf4j
@RestController
@RequestMapping("/api/v1/rag")
public class RagController {
@Autowired
private RagService ragService;
/**
* 文档检索接口
*/
@PostMapping("/search")
public ApiResponse<List<org.springframework.ai.document.Document>> searchDocuments(
@RequestBody SearchRequest request) {
try {
log.info("开始文档检索,查询: {}", request.getQuery());
List<org.springframework.ai.document.Document> results = ragService.searchDocuments(
request.getQuery(),
request.getCollectionIds(),
request.getTopK(),
request.getScoreThreshold()
);
log.info("文档检索完成,返回 {} 个结果", results.size());
return ApiResponse.success(results, "文档检索成功");
} catch (Exception e) {
log.error("文档检索失败", e);
return ApiResponse.error(4002, "文档检索失败: " + e.getMessage());
}
}
/**
* 获取所有文档列表接口(供Dashboard使用)
*/
@GetMapping("/documents")
public ApiResponse<List<Object>> getAllDocuments() {
try {
log.info("获取所有文档列表");
// 由于VectorStore可能未配置,我们返回空列表而不是抛出异常
List<Object> documents = List.of();
log.info("获取文档列表完成,返回 {} 个文档", documents.size());
return ApiResponse.success(documents, "获取文档列表成功");
} catch (Exception e) {
log.error("获取文档列表失败", e);
return ApiResponse.error(4002, "获取文档列表失败: " + e.getMessage());
}
}
/**
* RAG增强问答接口
*/
@PostMapping("/qa")
public ApiResponse<String> ragQa(@RequestBody RagQaRequest request) {
try {
log.info("开始RAG增强问答,Agent ID: {}, 查询: {}", request.getAgentId(), request.getQuery());
// 根据Agent ID获取Agent对象
// 这里需要实现获取Agent的逻辑,暂时使用模拟数据
// String result = "这是RAG增强问答的结果";
// 调用RagService的实际方法
// TODO: 实现根据Agent ID获取Agent对象的逻辑
String result = ragService.ragQa(null, request.getQuery());
log.info("RAG增强问答完成");
return ApiResponse.success(result, "RAG增强问答成功");
} catch (Exception e) {
log.error("RAG增强问答失败", e);
return ApiResponse.error(4002, "RAG增强问答失败: " + e.getMessage());
}
}
/**
* 获取检索统计信息
*/
@GetMapping("/stats")
public ApiResponse<Map<String, Object>> getRetrievalStats(
@RequestParam(required = false) List<String> collectionIds) {
try {
log.info("获取检索统计信息");
Map<String, Object> stats = ragService.getRetrievalStats(collectionIds);
log.info("获取检索统计信息完成");
return ApiResponse.success(stats, "获取统计信息成功");
} catch (Exception e) {
log.error("获取检索统计信息失败", e);
return ApiResponse.error(4002, "获取统计信息失败: " + e.getMessage());
}
}
/**
* 文档检索请求类
*/
@Data
public static class SearchRequest {
private String query;
private List<String> collectionIds;
private Integer topK;
private Double scoreThreshold;
}
/**
* RAG问答请求类
*/
@Data
public static class RagQaRequest {
private String agentId;
private String query;
}
}
\ No newline at end of file
package pangea.hiagent.controller;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import pangea.hiagent.dto.ApiResponse;
import pangea.hiagent.model.LlmConfig;
import pangea.hiagent.repository.LlmConfigRepository;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import java.util.List;
@Slf4j
@RestController
@RequestMapping("/api/v1/test")
public class TestController {
private final LlmConfigRepository llmConfigRepository;
public TestController(LlmConfigRepository llmConfigRepository) {
this.llmConfigRepository = llmConfigRepository;
}
@GetMapping("/llm-configs")
public ApiResponse<List<LlmConfig>> getAllLlmConfigs() {
try {
List<LlmConfig> configs = llmConfigRepository.selectList(null);
log.info("查询到 {} 条LLM配置", configs.size());
for (LlmConfig config : configs) {
log.info("配置: ID={}, 名称={}, 模型名={}, 提供商={}, 启用状态={}",
config.getId(), config.getName(), config.getModelName(),
config.getProvider(), config.getEnabled());
}
return ApiResponse.success(configs);
} catch (Exception e) {
log.error("查询LLM配置失败", e);
return ApiResponse.error(5001, "查询LLM配置失败: " + e.getMessage());
}
}
@GetMapping("/hisense-config")
public ApiResponse<LlmConfig> getHisenseConfig() {
try {
LambdaQueryWrapper<LlmConfig> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(LlmConfig::getModelName, "hisense-default");
wrapper.eq(LlmConfig::getEnabled, true);
LlmConfig config = llmConfigRepository.selectOne(wrapper);
if (config != null) {
log.info("找到海信配置: ID={}, 名称={}, 模型名={}, 提供商={}, 启用状态={}",
config.getId(), config.getName(), config.getModelName(),
config.getProvider(), config.getEnabled());
return ApiResponse.success(config);
} else {
log.warn("未找到启用的海信配置");
// 尝试查找所有海信配置
LambdaQueryWrapper<LlmConfig> allWrapper = new LambdaQueryWrapper<>();
allWrapper.eq(LlmConfig::getModelName, "hisense-default");
List<LlmConfig> allConfigs = llmConfigRepository.selectList(allWrapper);
log.info("找到 {} 条海信配置", allConfigs.size());
for (LlmConfig c : allConfigs) {
log.info("海信配置详情: ID={}, 名称={}, 模型名={}, 提供商={}, 启用状态={}",
c.getId(), c.getName(), c.getModelName(),
c.getProvider(), c.getEnabled());
}
return ApiResponse.error(4001, "未找到启用的海信配置");
}
} catch (Exception e) {
log.error("查询海信配置失败", e);
return ApiResponse.error(5001, "查询海信配置失败: " + e.getMessage());
}
}
}
\ No newline at end of file
package pangea.hiagent.controller;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import pangea.hiagent.workpanel.SseEventManager;
import pangea.hiagent.dto.WorkPanelEvent;
import java.io.IOException;
import java.util.List;
import java.util.concurrent.CopyOnWriteArrayList;
/**
* 时间轴事件控制器
* 提供ReAct过程的实时事件推送功能
*/
@Slf4j
@RestController
@RequestMapping("/api/v1/agent")
@RequiredArgsConstructor
public class TimelineEventController {
private final SseEventManager sseEventManager;
private final ObjectMapper objectMapper;
/**
* 获取当前认证用户ID
*/
private String getCurrentUserId() {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication != null && authentication.getPrincipal() != null) {
return (String) authentication.getPrincipal();
}
return null;
}
/**
* 订阅时间轴事件
* 支持 SSE (Server-Sent Events) 格式的实时事件推送
*
* @return SSE emitter
*/
@GetMapping("/timeline-events")
public SseEmitter subscribeTimelineEvents() {
log.info("开始处理时间轴事件订阅请求");
String userId = getCurrentUserId();
if (userId == null) {
log.error("用户未认证");
// 立即创建并完成emitter,不发送任何数据
SseEmitter emitter = new SseEmitter(300000L);
try {
emitter.completeWithError(new IllegalArgumentException("用户未认证"));
} catch (Exception e) {
log.error("完成SSE连接失败", e);
}
return emitter;
}
log.debug("用户认证成功,用户ID: {}", userId);
// 创建 SSE emitter
SseEmitter emitter = new SseEmitter(300000L); // 5分钟超时
// 注册 emitter 回调
emitter.onCompletion(() -> {
log.debug("SSE连接完成");
// 从所有Agent的emitter列表中移除
sseEventManager.removeEmitter(emitter);
});
emitter.onTimeout(() -> {
log.warn("SSE连接超时");
try {
emitter.complete();
} catch (Exception e) {
log.error("关闭SSE连接失败", e);
}
// 从所有Agent的emitter列表中移除
sseEventManager.removeEmitter(emitter);
});
emitter.onError(throwable -> {
log.error("SSE连接错误", throwable);
// 从所有Agent的emitter列表中移除
sseEventManager.removeEmitter(emitter);
});
// 发送连接成功的消息
try {
emitter.send(SseEmitter.event()
.name("connected")
.data("{\"type\":\"connected\",\"message\":\"连接成功\"}")
.build());
} catch (IOException e) {
log.error("发送连接成功消息失败", e);
}
log.info("时间轴事件订阅连接已建立,用户ID: {}", userId);
return emitter;
}
/**
* 广播事件给所有订阅者
*
* @param event 事件数据
*/
public void broadcastEvent(WorkPanelEvent event) {
if (event == null) {
log.warn("广播事件时接收到null事件");
return;
}
try {
// 构造事件数据
String eventData = constructEventData(event);
// 对工具事件添加详细的日志
if ("tool_call".equals(event.getEventType()) || "tool_result".equals(event.getEventType()) || "tool_error".equals(event.getEventType())) {
log.info("[工具事件] 类型={}, 工具={}, 有toolInput={}, 有toolOutput={}",
event.getEventType(),
event.getToolName(),
event.getToolInput() != null,
event.getToolOutput() != null);
log.debug("[工具事件详情] toolInput={}, toolOutput={}",
event.getToolInput(),
event.getToolOutput());
}
// 获取所有emitter并广播
List<SseEmitter> emitters = sseEventManager.getEmitters();
int successCount = 0;
int failureCount = 0;
for (SseEmitter emitter : new CopyOnWriteArrayList<>(emitters)) {
try {
emitter.send(SseEmitter.event()
.name("message")
.data(eventData)
.build());
successCount++;
} catch (IOException e) {
log.error("发送事件失败,移除失效连接: {}", e.getMessage());
emitters.remove(emitter);
failureCount++;
}
}
if (failureCount > 0) {
log.warn("事件广播部分失败: 成功={}, 失败={}", successCount, failureCount);
}
} catch (Exception e) {
log.error("广播事件失败: 事件类型={}, 工具={}, 错误信息={}",
event.getEventType(),
event.getToolName(),
e.getMessage(),
e);
}
}
/**
* 构造事件数据JSON字符串
*
* @param event 事件对象
* @return JSON字符串
*/
private String constructEventData(WorkPanelEvent event) {
StringBuilder sb = new StringBuilder();
sb.append("{");
sb.append("\"type\":\"").append(sanitizeJsonString(event.getEventType())).append("\",");
sb.append("\"timestamp\":").append(event.getTimestamp()).append(",");
sb.append("\"title\":\"").append(sanitizeJsonString(getEventTitle(event))).append("\",");
if (event.getContent() != null) {
sb.append("\"content\":\"").append(sanitizeJsonString(event.getContent())).append("\",");
}
// 根据事件类型添加特定字段
switch (event.getEventType()) {
case "thinking":
sb.append("\"thinkingType\":\"").append(sanitizeJsonString(event.getThinkingType())).append("\",");
break;
case "tool_call":
case "tool_result":
case "tool_error":
sb.append("\"toolName\":\"").append(sanitizeJsonString(event.getToolName())).append("\",");
sb.append("\"toolAction\":\"").append(sanitizeJsonString(event.getToolAction())).append("\",");
// 正确序列化 toolInput 为 JSON 对象
if (event.getToolInput() != null) {
try {
String toolInputJson = objectMapper.writeValueAsString(event.getToolInput());
sb.append("\"toolInput\":").append(toolInputJson).append(",");
log.debug("[toolInput序列化成功] 工具={}, JSON={}", event.getToolName(), toolInputJson);
} catch (Exception e) {
// 如果序列化失败,记录警告并回退到字符串表示
log.warn("[序列化toolInput失败] 工具={}, 错误={}, 已回退为字符串表示", event.getToolName(), e.getMessage());
sb.append("\"toolInput\":\"").append(sanitizeJsonString(event.getToolInput().toString())).append("\",");
}
} else {
log.debug("[toolInput为null] 工具={}, 类型={}", event.getToolName(), event.getEventType());
}
// 正确序列化 toolOutput 为 JSON 对象
if (event.getToolOutput() != null) {
try {
String toolOutputJson = objectMapper.writeValueAsString(event.getToolOutput());
sb.append("\"toolOutput\":").append(toolOutputJson).append(",");
log.debug("[toolOutput序列化成功] 工具={}, JSON={}", event.getToolName(), toolOutputJson);
} catch (Exception e) {
// 如果序列化失败,记录警告并回退到字符串表示
log.warn("[序列化toolOutput失败] 工具={}, 错误={}, 已回退为字符串表示", event.getToolName(), e.getMessage());
sb.append("\"toolOutput\":\"").append(sanitizeJsonString(String.valueOf(event.getToolOutput()))).append("\",");
}
} else {
log.debug("[toolOutput为null] 工具={}, 类型={}", event.getToolName(), event.getEventType());
}
if (event.getToolStatus() != null) {
sb.append("\"toolStatus\":\"").append(sanitizeJsonString(event.getToolStatus())).append("\",");
}
// 添加执行时间字段
if (event.getExecutionTime() != null) {
sb.append("\"executionTime\":").append(event.getExecutionTime()).append(",");
}
break;
case "log":
sb.append("\"logLevel\":\"").append(sanitizeJsonString(event.getLogLevel())).append("\",");
break;
case "embed":
if (event.getEmbedUrl() != null) {
sb.append("\"embedUrl\":\"").append(sanitizeJsonString(event.getEmbedUrl())).append("\",");
}
if (event.getEmbedType() != null) {
sb.append("\"embedType\":\"").append(sanitizeJsonString(event.getEmbedType())).append("\",");
}
if (event.getEmbedTitle() != null) {
sb.append("\"embedTitle\":\"").append(sanitizeJsonString(event.getEmbedTitle())).append("\",");
}
if (event.getEmbedHtmlContent() != null) {
sb.append("\"embedHtmlContent\":\"").append(sanitizeJsonString(event.getEmbedHtmlContent())).append("\",");
}
break;
}
// 移除最后一个逗号并添加结束括号
if (sb.charAt(sb.length() - 1) == ',') {
sb.setLength(sb.length() - 1);
}
sb.append("}");
return sb.toString();
}
/**
* 获取事件标题
*
* @param event 事件对象
* @return 标题字符串
*/
private String getEventTitle(WorkPanelEvent event) {
switch (event.getEventType()) {
case "thinking":
return "🧠 思考过程";
case "tool_call":
return "🎬 工具调用: " + (event.getToolName() != null ? event.getToolName() : "未知工具");
case "tool_result":
return "✅ 工具结果: " + (event.getToolName() != null ? event.getToolName() : "未知工具");
case "tool_error":
return "❌ 工具错误: " + (event.getToolName() != null ? event.getToolName() : "未知工具");
case "log":
return "📝 日志信息";
default:
return "📋 事件";
}
}
/**
* 清理JSON字符串中的特殊字符
*
* @param str 原始字符串
* @return 清理后的字符串
*/
private String sanitizeJsonString(String str) {
if (str == null) {
return "";
}
return str.replace("\\", "\\\\")
.replace("\"", "\\\"")
.replace("\n", "\\n")
.replace("\r", "\\r")
.replace("\t", "\\t");
}
}
\ No newline at end of file
package pangea.hiagent.controller;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.*;
import pangea.hiagent.dto.ApiResponse;
import pangea.hiagent.model.Tool;
import pangea.hiagent.service.ToolService;
import java.util.List;
/**
* 工具API控制器
* 提供工具的增删改查功能
*/
@Slf4j
@RestController
@RequestMapping("/api/v1/tools")
@Tag(name = "工具管理", description = "工具管理相关API")
public class ToolController {
private final ToolService toolService;
public ToolController(ToolService toolService) {
this.toolService = toolService;
}
/**
* 获取当前认证用户ID
* @return 用户ID
*/
private String getCurrentUserId() {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication != null && authentication.getPrincipal() instanceof String) {
return (String) authentication.getPrincipal();
}
return null;
}
/**
* 创建工具
*/
@PostMapping
@Operation(summary = "创建工具", description = "创建一个新的工具")
public ApiResponse<Tool> createTool(@RequestBody Tool tool) {
try {
String userId = getCurrentUserId();
if (userId == null) {
return ApiResponse.error(4001, "用户未认证");
}
Tool created = toolService.createTool(tool, userId);
return ApiResponse.success(created, "创建工具成功");
} catch (Exception e) {
log.error("创建工具失败", e);
return ApiResponse.error(4001, "创建工具失败: " + e.getMessage());
}
}
/**
* 更新工具
*/
@PutMapping("/{id}")
@Operation(summary = "更新工具", description = "更新指定ID的工具")
public ApiResponse<Tool> updateTool(@PathVariable String id, @RequestBody Tool tool) {
try {
String userId = getCurrentUserId();
if (userId == null) {
return ApiResponse.error(4001, "用户未认证");
}
tool.setId(id);
Tool updated = toolService.updateTool(tool, userId);
return ApiResponse.success(updated, "更新工具成功");
} catch (Exception e) {
log.error("更新工具失败", e);
return ApiResponse.error(4001, "更新工具失败: " + e.getMessage());
}
}
/**
* 删除工具
*/
@DeleteMapping("/{id}")
@Operation(summary = "删除工具", description = "删除指定ID的工具")
public ApiResponse<Void> deleteTool(@PathVariable String id) {
try {
String userId = getCurrentUserId();
if (userId == null) {
return ApiResponse.error(4001, "用户未认证");
}
toolService.deleteTool(id, userId);
return ApiResponse.success(null, "删除工具成功");
} catch (Exception e) {
log.error("删除工具失败", e);
return ApiResponse.error(4001, "删除工具失败: " + e.getMessage());
}
}
/**
* 获取工具详情
*/
@GetMapping("/{id}")
@Operation(summary = "获取工具详情", description = "获取指定ID的工具详情")
public ApiResponse<Tool> getTool(@PathVariable String id) {
try {
String userId = getCurrentUserId();
if (userId == null) {
return ApiResponse.error(4001, "用户未认证");
}
Tool tool = toolService.getToolById(id, userId);
if (tool == null) {
return ApiResponse.error(4004, "工具不存在");
}
return ApiResponse.success(tool, "获取工具成功");
} catch (Exception e) {
log.error("获取工具失败", e);
return ApiResponse.error(4001, "获取工具失败: " + e.getMessage());
}
}
/**
* 获取工具列表
*/
@GetMapping
@Operation(summary = "获取工具列表", description = "获取当前用户的所有工具")
public ApiResponse<List<Tool>> getTools() {
try {
String userId = getCurrentUserId();
if (userId == null) {
return ApiResponse.error(4001, "用户未认证");
}
List<Tool> tools = toolService.getUserTools(userId);
return ApiResponse.success(tools, "获取工具列表成功");
} catch (Exception e) {
log.error("获取工具列表失败", e);
return ApiResponse.error(4001, "获取工具列表失败: " + e.getMessage());
}
}
}
\ No newline at end of file
package pangea.hiagent.controller;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import java.io.BufferedReader;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStreamReader;
import java.net.URL;
import java.net.URLConnection;
import java.nio.file.Paths;
/**
* 网页代理控制器
* 用于解决iframe加载网页时的CORS问题,并处理X-Frame-Options等安全限制
*/
@Slf4j
@RestController
@RequestMapping("/api/v1/proxy")
public class WebProxyController {
/**
* 代理请求指定URL的内容
*
* @param url 要代理的URL地址
* @param token JWT token(可选)
* @return 网页HTML内容
*/
@GetMapping(produces = MediaType.TEXT_HTML_VALUE)
public ResponseEntity<String> proxyUrl(@RequestParam String url, @RequestParam(required = false) String token) {
try {
log.debug("代理请求URL: {}, token: {}", url, token != null ? "provided" : "null");
// 验证URL格式
if (url == null || url.trim().isEmpty()) {
log.warn("无效的URL参数: 为空");
return ResponseEntity.badRequest()
.contentType(MediaType.TEXT_HTML)
.body("<html><body><h2>Error: URL is empty</h2><p>Please provide a valid URL to proxy.</p></body></html>");
}
// 检查是否为file协议
if (url.toLowerCase().startsWith("file://")) {
// 处理本地文件
return handleLocalFile(url);
}
// 处理相对路径URL
if (!url.toLowerCase().startsWith("http://") && !url.toLowerCase().startsWith("https://")) {
// 如果是相对路径,尝试补全为绝对路径
if (url.startsWith("/")) {
// 假设是本地文件路径
url = "file://" + url;
} else if (!url.toLowerCase().startsWith("file://")) {
log.warn("无效的URL scheme: {}", url);
return ResponseEntity.badRequest()
.contentType(MediaType.TEXT_HTML)
.body("<html><body><h2>Error: Invalid URL scheme</h2><p>URL must start with http://, https:// or file://</p></body></html>");
}
}
// 获取网页内容
String content = fetchWebContent(url);
log.debug("成功代理URL内容,长度: {} bytes", content.length());
// 处理内容以提高兼容性
String processedContent = processContentForEmbedding(content, url);
// 添加调试信息
log.debug("处理后内容长度: {} bytes", processedContent.length());
return ResponseEntity.ok()
.contentType(MediaType.TEXT_HTML)
.body(processedContent);
} catch (IOException e) {
log.error("代理请求失败: {}", url, e);
// 提供更详细的错误信息
String errorMessage = e.getMessage();
if (errorMessage == null || errorMessage.isEmpty()) {
errorMessage = "Unknown network error";
}
return ResponseEntity.status(502)
.contentType(MediaType.TEXT_HTML)
.body("<html><body><h2>Error: Network error</h2><p>Failed to fetch content from " + url + "</p><p>" + errorMessage + "</p><p>Type: " + e.getClass().getSimpleName() + "</p></body></html>");
} catch (Exception e) {
log.error("代理请求异常: {}", url, e);
// 提供更详细的错误信息
String errorMessage = e.getMessage();
if (errorMessage == null || errorMessage.isEmpty()) {
errorMessage = "Unknown error";
}
return ResponseEntity.status(500)
.contentType(MediaType.TEXT_HTML)
.body("<html><body><h2>Error: Internal server error</h2><p>An unexpected error occurred while fetching content from " + url + "</p><p>" + errorMessage + "</p><p>Type: " + e.getClass().getSimpleName() + "</p></body></html>");
}
}
/**
* 处理本地文件请求
*
* @param fileUrl 文件URL
* @return 文件内容响应
*/
private ResponseEntity<String> handleLocalFile(String fileUrl) {
try {
log.debug("处理本地文件请求: {}", fileUrl);
// 将file:// URL转换为本地路径
String filePath = fileUrl.substring("file://".length());
// 处理Windows路径
if (filePath.startsWith("/")) {
// 去掉开头的斜杠(Windows file:// URL格式)
filePath = filePath.substring(1);
}
// 规范化路径
File file = new File(filePath);
String canonicalPath = file.getCanonicalPath();
// 安全检查:确保文件在允许的目录内
if (!isFileAccessAllowed(canonicalPath)) {
log.warn("拒绝访问本地文件,超出允许范围: {}", canonicalPath);
return ResponseEntity.status(403)
.contentType(MediaType.TEXT_HTML)
.body("<html><body><h2>Error: Access denied</h2><p>Access to this file is not allowed.</p></body></html>");
}
// 检查文件是否存在
if (!file.exists()) {
log.warn("本地文件不存在: {}", canonicalPath);
return ResponseEntity.status(404)
.contentType(MediaType.TEXT_HTML)
.body("<html><body><h2>Error: File not found</h2><p>The requested file was not found.</p></body></html>");
}
// 检查是否为文件(而非目录)
if (!file.isFile()) {
log.warn("本地路径不是文件: {}", canonicalPath);
return ResponseEntity.status(400)
.contentType(MediaType.TEXT_HTML)
.body("<html><body><h2>Error: Not a file</h2><p>The requested path is not a file.</p></body></html>");
}
// 读取文件内容
String content = readFileContent(file);
log.debug("成功读取本地文件内容,长度: {} bytes", content.length());
// 处理内容以提高兼容性
String processedContent = processContentForEmbedding(content, fileUrl);
return ResponseEntity.ok()
.contentType(MediaType.TEXT_HTML)
.body(processedContent);
} catch (Exception e) {
log.error("处理本地文件异常: {}", fileUrl, e);
return ResponseEntity.status(500)
.contentType(MediaType.TEXT_HTML)
.body("<html><body><h2>Error: Internal server error</h2><p>An unexpected error occurred while reading the local file.</p><p>" + e.getMessage() + "</p></body></html>");
}
}
/**
* 检查文件访问是否被允许
*
* @param filePath 文件路径
* @return 是否允许访问
*/
private boolean isFileAccessAllowed(String filePath) {
try {
// 获取项目根目录
String projectRoot = Paths.get("").toAbsolutePath().toString();
String canonicalProjectRoot = new File(projectRoot).getCanonicalPath();
// 检查文件是否在项目目录内
return filePath.startsWith(canonicalProjectRoot);
} catch (Exception e) {
log.error("检查文件访问权限时出错: ", e);
return false;
}
}
/**
* 读取文件内容
*
* @param file 文件对象
* @return 文件内容
* @throws IOException 读取异常
*/
private String readFileContent(File file) throws IOException {
StringBuilder content = new StringBuilder();
BufferedReader reader = null;
try {
reader = new BufferedReader(new InputStreamReader(new FileInputStream(file), "UTF-8"));
String line;
while ((line = reader.readLine()) != null) {
content.append(line).append("\n");
}
} finally {
if (reader != null) {
try {
reader.close();
} catch (IOException e) {
log.debug("关闭读取器失败: {}", e.getMessage());
}
}
}
return content.toString();
}
/**
* 获取网页内容
*/
private String fetchWebContent(String urlStr) throws IOException {
StringBuilder content = new StringBuilder();
BufferedReader reader = null;
try {
URL url = new URL(urlStr);
URLConnection connection = url.openConnection();
// 设置请求头
connection.setRequestProperty("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36");
connection.setRequestProperty("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8");
connection.setRequestProperty("Accept-Language", "zh-CN,zh;q=0.9,en;q=0.8");
connection.setRequestProperty("Accept-Encoding", "gzip, deflate, br");
connection.setRequestProperty("Connection", "keep-alive");
connection.setRequestProperty("Upgrade-Insecure-Requests", "1");
connection.setConnectTimeout(15000); // 增加超时时间到15秒
connection.setReadTimeout(15000);
// 获取响应头信息用于诊断
java.net.HttpURLConnection httpConn = (java.net.HttpURLConnection) connection;
int responseCode = httpConn.getResponseCode();
String xFrameOptions = httpConn.getHeaderField("X-Frame-Options");
String csp = httpConn.getHeaderField("Content-Security-Policy");
String contentType = httpConn.getHeaderField("Content-Type");
log.debug("网页请求 - URL: {}, 响应码: {}, Content-Type: {}", urlStr, responseCode, contentType);
if (xFrameOptions != null) {
log.warn("检测到X-Frame-Options头部 - URL: {}, X-Frame-Options: {}", urlStr, xFrameOptions);
}
if (csp != null) {
log.debug("检测到CSP头部 - URL: {}, CSP: {}", urlStr, csp);
}
// 检查Content-Type是否为HTML
if (contentType != null && !contentType.toLowerCase().contains("text/html")) {
log.warn("非HTML内容类型 - URL: {}, Content-Type: {}", urlStr, contentType);
// 仍然继续处理,但记录警告
}
reader = new BufferedReader(new InputStreamReader(connection.getInputStream(), "UTF-8"));
String line;
while ((line = reader.readLine()) != null) {
content.append(line).append("\n");
}
} catch (IOException e) {
log.error("获取网页内容异常 - URL: {}, 错误堆栈: ", urlStr, e);
throw e;
} catch (Exception e) {
log.error("获取网页内容未知异常 - URL: {}, 错误堆栈: ", urlStr, e);
throw new IOException("获取网页内容时发生未知错误: " + e.getMessage(), e);
} finally {
if (reader != null) {
try {
reader.close();
} catch (IOException e) {
log.debug("关闭读取器失败: {}", e.getMessage());
}
}
}
return content.toString();
}
/**
* 处理内容以提高在iframe中的兼容性
* 移除或修改可能阻止嵌入的头部和脚本
*/
private String processContentForEmbedding(String content, String originalUrl) {
try {
log.debug("开始处理内容以提高嵌入兼容性,原始长度: {}", content.length());
// 移除X-Frame-Options meta标签
String processed = content.replaceAll("(?i)<meta[^>]*http-equiv\\s*=\\s*[\"']?X-Frame-Options[\"']?[^>]*/?>", "");
// 移除Content-Security-Policy meta标签中可能阻止嵌入的指令
processed = processed.replaceAll("(?i)<meta[^>]*http-equiv\\s*=\\s*[\"']?Content-Security-Policy[\"']?[^>]*content\\s*=\\s*[\"'][^\"']*frame-ancestors[^\"']*[\"'][^>]*/?>", "");
// 移除可能阻止嵌入的JavaScript代码
processed = processed.replaceAll("(?i)<script[^>]*>[^<]*top\\s*!=\\s*self[^<]*</script>", "");
processed = processed.replaceAll("(?i)<script[^>]*>[^<]*window\\.top\\s*!=\\s*window[^<]*</script>", "");
processed = processed.replaceAll("(?i)<script[^>]*>[^<]*document\\.location\\.ancestorOrigins[^<]*</script>", "");
// 添加基础标签以确保相对链接正确
if (!processed.contains("<base")) {
String baseUrl = originalUrl.substring(0, originalUrl.lastIndexOf('/') + 1);
processed = processed.replaceFirst("(?i)(<head[^>]*>)", "$1<base href=\"" + baseUrl + "\">");
}
// 添加样式以改善嵌入体验
String styleAddition = "<style>" +
"body { margin: 0; padding: 10px; font-family: Arial, sans-serif; }" +
"img { max-width: 100%; height: auto; }" +
"iframe { max-width: 100%; }" +
"</style>";
processed = processed.replaceFirst("(?i)(</head>)", styleAddition + "$1");
log.debug("内容处理完成,处理后长度: {}", processed.length());
return processed;
} catch (Exception e) {
log.error("处理内容以提高嵌入兼容性时出错: ", e);
// 如果处理失败,返回原始内容
return content;
}
}
}
\ No newline at end of file
package pangea.hiagent.llm;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.openai.OpenAiChatModel;
import org.springframework.ai.openai.api.OpenAiApi;
import org.springframework.ai.openai.OpenAiChatOptions;
import org.springframework.stereotype.Component;
import pangea.hiagent.model.LlmConfig;
/**
* DeepSeek模型适配器
* 实现DeepSeek模型的创建和配置(使用OpenAI兼容API)
*/
@Slf4j
@Component
public class DeepSeekModelAdapter implements ModelAdapter {
@Override
public ChatModel createChatModel(LlmConfig config) {
if (!validateConfig(config)) {
throw new IllegalArgumentException("无效的DeepSeek配置");
}
log.info("创建DeepSeek ChatModel,模型名称: {}, API密钥长度: {}", config.getModelName(), config.getApiKey().length());
try {
// DeepSeek使用OpenAI兼容的API,使用Builder模式创建OpenAiApi实例
// 注意:DeepSeek API端点不需要包含/v1,因为Spring AI会自动添加
String baseUrl = config.getBaseUrl() != null && !config.getBaseUrl().isEmpty() ?
config.getBaseUrl() : "https://api.deepseek.com";
OpenAiApi openAiApi = OpenAiApi.builder()
.baseUrl(baseUrl)
.apiKey(config.getApiKey())
.build();
log.info("OpenAiApi实例创建成功");
// 使用Builder模式创建OpenAiChatModel实例
OpenAiChatModel chatModel = OpenAiChatModel.builder()
.openAiApi(openAiApi)
.defaultOptions(OpenAiChatOptions.builder()
.model(config.getModelName())
.temperature(config.getTemperature())
.maxTokens(config.getMaxTokens())
.build())
.build();
log.info("OpenAiChatModel实例创建成功");
return chatModel;
} catch (Exception e) {
log.error("创建DeepSeek ChatModel时发生异常", e);
throw new RuntimeException("创建DeepSeek ChatModel失败: " + e.getMessage(), e);
}
}
@Override
public String getProviderName() {
return "deepseek";
}
@Override
public boolean validateConfig(LlmConfig config) {
// 修改验证逻辑,允许在没有API密钥的情况下启动,但会给出警告
if (config == null || !config.getEnabled()) {
return false;
}
// 如果启用了配置但没有API密钥,允许启动但会在使用时抛出异常
if (config.getApiKey() == null || config.getApiKey().isEmpty()) {
log.warn("DeepSeek配置已启用但未设置API密钥,将在实际使用时抛出异常");
return true;
}
return true;
}
}
\ No newline at end of file
package pangea.hiagent.llm;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import jakarta.annotation.PostConstruct;
import java.io.File;
import java.net.URL;
import java.net.URLClassLoader;
import java.util.ArrayList;
import java.util.List;
import java.util.jar.JarFile;
/**
* 动态模型加载器
* 支持从外部JAR文件动态加载模型适配器
*/
@Slf4j
@Component
public class DynamicModelLoader {
private static final String MODEL_ADAPTERS_DIR = "model-adapters";
@PostConstruct
public void init() {
loadExternalModelAdapters();
}
/**
* 加载外部模型适配器
*/
public void loadExternalModelAdapters() {
File adaptersDir = new File(MODEL_ADAPTERS_DIR);
if (!adaptersDir.exists()) {
log.info("模型适配器目录不存在: {}", MODEL_ADAPTERS_DIR);
return;
}
if (!adaptersDir.isDirectory()) {
log.warn("模型适配器路径不是目录: {}", MODEL_ADAPTERS_DIR);
return;
}
File[] jarFiles = adaptersDir.listFiles((dir, name) -> name.endsWith(".jar"));
if (jarFiles == null || jarFiles.length == 0) {
log.info("模型适配器目录中没有找到JAR文件: {}", MODEL_ADAPTERS_DIR);
return;
}
for (File jarFile : jarFiles) {
try {
loadModelAdaptersFromJar(jarFile.getAbsolutePath());
} catch (Exception e) {
log.error("加载模型适配器JAR文件失败: {}", jarFile.getName(), e);
}
}
}
/**
* 从JAR文件加载模型适配器
*
* @param jarPath JAR文件路径
*/
public void loadModelAdaptersFromJar(String jarPath) {
log.info("正在加载模型适配器JAR文件: {}", jarPath);
try (JarFile jarFile = new JarFile(jarPath);
URLClassLoader classLoader = new URLClassLoader(new URL[]{new File(jarPath).toURI().toURL()},
this.getClass().getClassLoader())) {
// 扫描JAR文件中的类
List<Class<?>> adapterClasses = new ArrayList<>();
// 遍历JAR文件中的所有条目
jarFile.stream().forEach(entry -> {
if (entry.getName().endsWith(".class")) {
try {
String className = entry.getName().replace("/", ".").replace(".class", "");
Class<?> clazz = classLoader.loadClass(className);
// 检查类是否实现了ModelAdapter接口
if (ModelAdapter.class.isAssignableFrom(clazz) && !clazz.isInterface() &&
!clazz.equals(ModelAdapter.class)) {
adapterClasses.add(clazz);
log.info("发现模型适配器类: {}", className);
}
} catch (ClassNotFoundException e) {
log.warn("无法加载类: {}", entry.getName(), e);
}
}
});
// 注册发现的适配器
registerModelAdapters(adapterClasses, classLoader);
} catch (Exception e) {
log.error("从JAR文件加载模型适配器时发生错误: {}", jarPath, e);
throw new RuntimeException("加载模型适配器失败: " + e.getMessage(), e);
}
}
/**
* 注册模型适配器
*
* @param adapterClasses 适配器类列表
* @param classLoader 类加载器
*/
public void registerModelAdapters(List<Class<?>> adapterClasses, ClassLoader classLoader) {
for (Class<?> adapterClass : adapterClasses) {
try {
// 创建适配器实例
Object instance = adapterClass.getDeclaredConstructor().newInstance();
ModelAdapter adapter = (ModelAdapter) instance;
// 注册适配器
// 注意:这里需要通过反射或其他方式将适配器注册到ModelAdapterManager中
log.info("成功注册模型适配器: {}", adapter.getProviderName());
} catch (Exception e) {
log.error("注册模型适配器失败: {}", adapterClass.getName(), e);
}
}
}
/**
* 卸载模型适配器
*
* @param adapterClasses 适配器类列表
*/
public void unloadModelAdapters(List<Class<?>> adapterClasses) {
// 实现卸载逻辑
for (Class<?> adapterClass : adapterClasses) {
log.info("卸载模型适配器: {}", adapterClass.getName());
}
}
}
\ No newline at end of file
package pangea.hiagent.llm;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.openai.OpenAiChatModel;
import org.springframework.ai.openai.api.OpenAiApi;
import org.springframework.ai.openai.OpenAiChatOptions;
import org.springframework.stereotype.Component;
import pangea.hiagent.model.LlmConfig;
/**
* Hisense模型适配器
* 实现与Hisense模型服务的对接,完全遵循OpenAI API规范
*/
@Slf4j
@Component
public class HisenseModelAdapter implements ModelAdapter {
// Hisense模型服务默认配置
@Override
public ChatModel createChatModel(LlmConfig config) {
if (!validateConfig(config)) {
throw new IllegalArgumentException("无效的Hisense配置");
}
try {
// 获取配置信息,直接使用配置中的值
String baseUrl = config.getBaseUrl();
String apiKey = config.getApiKey();
String modelName = config.getModelName();
// 确保baseUrl格式正确,移除可能存在的/chat/completions路径
if (baseUrl.endsWith("/chat/completions")) {
baseUrl = baseUrl.substring(0, baseUrl.length() - "/chat/completions".length());
}
// 移除可能存在的/v1后缀,因为OpenAiApi会自动添加/v1/chat/completions
if (baseUrl.endsWith("/v1")) {
baseUrl = baseUrl.substring(0, baseUrl.length() - "/v1".length());
}
log.info("创建Hisense ChatModel, baseUrl: {}, modelName: {}", baseUrl, modelName);
// 使用Builder模式创建OpenAiApi实例
OpenAiApi openAiApi = OpenAiApi.builder()
.apiKey(apiKey)
.baseUrl(baseUrl)
.build();
// 使用Builder模式创建OpenAiChatModel实例
return OpenAiChatModel.builder()
.openAiApi(openAiApi)
.defaultOptions(OpenAiChatOptions.builder()
.model(modelName)
.temperature(config.getTemperature())
.maxTokens(config.getMaxTokens())
.build())
.build();
} catch (Exception e) {
log.error("创建Hisense ChatModel失败: {}", e.getMessage(), e);
throw new RuntimeException("创建Hisense ChatModel失败: " + e.getMessage(), e);
}
}
@Override
public String getProviderName() {
return "hisense";
}
@Override
public boolean validateConfig(LlmConfig config) {
// Hisense适配器至少需要启用配置
// API密钥和基础URL可以使用默认值
return config != null && config.getEnabled();
}
}
\ No newline at end of file
package pangea.hiagent.llm;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import pangea.hiagent.model.LlmConfig;
/**
* LLM模型工厂类
* 用于根据配置动态创建ChatModel实例
*/
@Slf4j
@Component
public class LlmModelFactory {
@Autowired
private ModelAdapterManager modelAdapterManager;
public ModelAdapterManager getModelAdapterManager() {
return modelAdapterManager;
}
/**
* 根据配置创建ChatModel实例
*
* @param config LLM配置
* @return ChatModel实例
*/
public ChatModel createChatModel(LlmConfig config) {
return modelAdapterManager.createChatModel(config);
}
}
\ No newline at end of file
package pangea.hiagent.llm;
import org.springframework.ai.chat.model.ChatModel;
import pangea.hiagent.model.LlmConfig;
/**
* 模型适配器接口
* 定义创建ChatModel实例的标准方法
*/
public interface ModelAdapter {
/**
* 根据配置创建ChatModel实例
*
* @param config LLM配置
* @return ChatModel实例
*/
ChatModel createChatModel(LlmConfig config);
/**
* 获取适配器支持的提供商名称
*
* @return 提供商名称
*/
String getProviderName();
/**
* 验证配置是否有效
*
* @param config LLM配置
* @return 配置是否有效
*/
boolean validateConfig(LlmConfig config);
}
\ No newline at end of file
package pangea.hiagent.llm;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import pangea.hiagent.model.LlmConfig;
import jakarta.annotation.PostConstruct;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* 模型适配器管理器
* 负责管理和选择合适的模型适配器
*/
@Slf4j
@Component
public class ModelAdapterManager {
@Autowired
private List<ModelAdapter> adapters;
private Map<String, ModelAdapter> adapterMap;
@PostConstruct
public void init() {
adapterMap = new HashMap<>();
for (ModelAdapter adapter : adapters) {
adapterMap.put(adapter.getProviderName().toLowerCase(), adapter);
log.info("注册模型适配器: {}", adapter.getProviderName());
}
}
/**
* 根据配置创建ChatModel实例
*
* @param config LLM配置
* @return ChatModel实例
*/
public ChatModel createChatModel(LlmConfig config) {
log.info("开始创建ChatModel,配置信息: {}", config);
if (config == null || !config.getEnabled()) {
log.warn("无效的LLM配置或配置未启用");
throw new IllegalArgumentException("无效的LLM配置或配置未启用");
}
String provider = config.getProvider();
log.info("LLM提供商: {}", provider);
if (provider == null || provider.isEmpty()) {
log.warn("LLM配置缺少提供商信息");
throw new IllegalArgumentException("LLM配置缺少提供商信息");
}
ModelAdapter adapter = adapterMap.get(provider.toLowerCase());
if (adapter == null) {
log.warn("不支持的LLM提供商: {}", provider);
throw new IllegalArgumentException("不支持的LLM提供商: " + provider);
}
log.info("找到适配器: {}", adapter.getClass().getSimpleName());
if (!adapter.validateConfig(config)) {
log.warn("LLM配置验证失败: {}", provider);
throw new IllegalArgumentException("LLM配置验证失败: " + provider);
}
try {
log.info("调用适配器创建ChatModel");
ChatModel model = adapter.createChatModel(config);
log.info("成功创建ChatModel");
return model;
} catch (Exception e) {
log.error("创建ChatModel失败: {}", e.getMessage(), e);
throw new RuntimeException("创建ChatModel失败: " + e.getMessage(), e);
}
}
/**
* 获取指定提供商的适配器
*
* @param provider 提供商名称
* @return 模型适配器
*/
public ModelAdapter getAdapter(String provider) {
if (provider == null || provider.isEmpty()) {
return null;
}
return adapterMap.get(provider.toLowerCase());
}
/**
* 获取所有已注册的适配器
*
* @return 适配器映射
*/
public Map<String, ModelAdapter> getAdapters() {
return new HashMap<>(adapterMap);
}
}
\ No newline at end of file
package pangea.hiagent.llm;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.ollama.OllamaChatModel;
import org.springframework.ai.ollama.api.OllamaApi;
import org.springframework.ai.ollama.api.OllamaOptions;
import org.springframework.stereotype.Component;
import pangea.hiagent.model.LlmConfig;
/**
* Ollama模型适配器
* 实现Ollama模型的创建和配置
*/
@Slf4j
@Component
public class OllamaModelAdapter implements ModelAdapter {
@Override
public ChatModel createChatModel(LlmConfig config) {
if (!validateConfig(config)) {
throw new IllegalArgumentException("无效的Ollama配置");
}
try {
String baseUrl = config.getBaseUrl();
if (baseUrl == null || baseUrl.isEmpty()) {
baseUrl = "http://localhost:11434";
}
// 使用Builder模式创建OllamaChatModel实例
OllamaApi ollamaApi = new OllamaApi(baseUrl);
// 创建OllamaOptions配置
OllamaOptions options = OllamaOptions.builder()
.model(config.getModelName() != null ? config.getModelName() : "llama2")
.temperature(config.getTemperature())
.numPredict(config.getMaxTokens())
.build();
// 使用Builder模式创建OllamaChatModel实例
return OllamaChatModel.builder()
.ollamaApi(ollamaApi)
.defaultOptions(options)
.build();
} catch (Exception e) {
log.error("创建Ollama ChatModel失败: {}", e.getMessage(), e);
throw new RuntimeException("创建Ollama ChatModel失败: " + e.getMessage(), e);
}
}
@Override
public String getProviderName() {
return "ollama";
}
@Override
public boolean validateConfig(LlmConfig config) {
return config != null && config.getEnabled();
}
}
\ No newline at end of file
package pangea.hiagent.llm;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.openai.OpenAiChatModel;
import org.springframework.ai.openai.api.OpenAiApi;
import org.springframework.ai.openai.OpenAiChatOptions;
import org.springframework.stereotype.Component;
import pangea.hiagent.model.LlmConfig;
/**
* OpenAI模型适配器
* 实现OpenAI模型的创建和配置
*/
@Slf4j
@Component
public class OpenAiModelAdapter implements ModelAdapter {
@Override
public ChatModel createChatModel(LlmConfig config) {
if (!validateConfig(config)) {
throw new IllegalArgumentException("无效的OpenAI配置");
}
try {
// 使用Builder模式创建OpenAiApi实例
OpenAiApi openAiApi = OpenAiApi.builder()
.apiKey(config.getApiKey())
.baseUrl(config.getBaseUrl() != null && !config.getBaseUrl().isEmpty() ?
config.getBaseUrl() : "https://api.openai.com/v1")
.build();
// 使用Builder模式创建OpenAiChatModel实例
return OpenAiChatModel.builder()
.openAiApi(openAiApi)
.defaultOptions(OpenAiChatOptions.builder()
.model(config.getModelName())
.temperature(config.getTemperature())
.maxTokens(config.getMaxTokens())
.build())
.build();
} catch (Exception e) {
log.error("创建OpenAI ChatModel失败: {}", e.getMessage(), e);
throw new RuntimeException("创建OpenAI ChatModel失败: " + e.getMessage(), e);
}
}
@Override
public String getProviderName() {
return "openai";
}
@Override
public boolean validateConfig(LlmConfig config) {
return config != null &&
config.getEnabled() &&
config.getApiKey() != null &&
!config.getApiKey().isEmpty();
}
}
\ No newline at end of file
package pangea.hiagent.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import pangea.hiagent.model.Agent;
import pangea.hiagent.model.AgentDialogue;
import pangea.hiagent.model.LlmConfig;
import pangea.hiagent.repository.AgentRepository;
import pangea.hiagent.repository.AgentDialogueRepository;
import pangea.hiagent.repository.LlmConfigRepository;
import pangea.hiagent.llm.LlmModelFactory;
import java.util.List;
/**
* Agent服务类
* 负责Agent的管理和相关业务逻辑
*/
@Slf4j
@Service
public class AgentService {
private final AgentRepository agentRepository;
private final AgentDialogueRepository agentDialogueRepository;
private final LlmConfigRepository llmConfigRepository;
private final LlmModelFactory llmModelFactory;
public AgentService(AgentRepository agentRepository,
AgentDialogueRepository agentDialogueRepository,
LlmConfigRepository llmConfigRepository,
LlmModelFactory llmModelFactory) {
this.agentRepository = agentRepository;
this.agentDialogueRepository = agentDialogueRepository;
this.llmConfigRepository = llmConfigRepository;
this.llmModelFactory = llmModelFactory;
}
/**
* 创建Agent
*/
@Transactional
public Agent createAgent(Agent agent) {
log.info("创建Agent: {}", agent.getName());
// 设置默认值
if (agent.getTemperature() == null) {
agent.setTemperature(0.7);
}
if (agent.getMaxTokens() == null) {
agent.setMaxTokens(4096);
}
if (agent.getTopP() == null) {
agent.setTopP(0.9);
}
if (agent.getTopK() == null) {
agent.setTopK(50);
}
if (agent.getPresencePenalty() == null) {
agent.setPresencePenalty(0.0);
}
if (agent.getFrequencyPenalty() == null) {
agent.setFrequencyPenalty(0.0);
}
if (agent.getHistoryLength() == null) {
agent.setHistoryLength(10);
}
if (agent.getStatus() == null || agent.getStatus().isEmpty()) {
agent.setStatus("active");
}
agentRepository.insert(agent);
return agent;
}
/**
* 更新Agent
*/
@Transactional
public Agent updateAgent(Agent agent) {
log.info("更新Agent: {}", agent.getId());
// 保留原始所有者信息
Agent existingAgent = agentRepository.selectById(agent.getId());
if (existingAgent != null) {
agent.setOwner(existingAgent.getOwner());
agent.setCreatedBy(existingAgent.getCreatedBy());
agent.setCreatedAt(existingAgent.getCreatedAt());
}
agentRepository.updateById(agent);
return agent;
}
/**
* 删除Agent
*/
@Transactional
public void deleteAgent(String id) {
log.info("删除Agent: {}", id);
agentRepository.deleteById(id);
}
/**
* 获取Agent详情
*
* @param id Agent ID
* @return Agent对象,如果不存在则返回null
*/
public Agent getAgent(String id) {
if (id == null || id.isEmpty()) {
log.warn("尝试使用无效ID获取Agent");
return null;
}
return agentRepository.selectById(id);
}
/**
* 获取Agent列表
*
* @return Agent列表
*/
public List<Agent> listAgents() {
List<Agent> agents = agentRepository.selectList(null);
log.info("获取到 {} 个Agent", agents != null ? agents.size() : 0);
return agents != null ? agents : List.of();
}
/**
* 分页获取Agent列表
*/
public IPage<Agent> pageAgents(Long current, Long size, String name, String status) {
Page<Agent> page = new Page<>(current, size);
LambdaQueryWrapper<Agent> wrapper = new LambdaQueryWrapper<>();
if (name != null) {
wrapper.like(Agent::getName, name);
}
if (status != null) {
wrapper.eq(Agent::getStatus, status);
}
// 使用优化的分页查询方法
return agentRepository.selectPageWithOptimization(page, wrapper);
}
/**
* 获取用户的Agent列表
*/
public List<Agent> getUserAgents(String userId) {
// 使用优化的查询方法
return agentRepository.findActiveAgentsByOwnerWithExplicitColumns(userId);
}
/**
* 保存对话记录
*/
@Transactional
public void saveDialogue(AgentDialogue dialogue) {
agentDialogueRepository.insert(dialogue);
}
/**
* 根据Agent获取对应的LLM模型
*
* @param agent Agent对象
* @return 对应的ChatModel实例
*/
public org.springframework.ai.chat.model.ChatModel getChatModelForAgent(Agent agent) {
// 获取Agent配置的模型配置名称
String modelConfigName = agent.getDefaultModel();
log.info("获取Agent {} 的ChatModel,模型配置名称: {}", agent.getId(), modelConfigName);
if (modelConfigName == null || modelConfigName.isEmpty()) {
throw new IllegalArgumentException("Agent未配置默认模型");
}
// 根据模型配置名称查找对应的LLM配置
LambdaQueryWrapper<LlmConfig> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(LlmConfig::getName, modelConfigName);
wrapper.eq(LlmConfig::getEnabled, true);
LlmConfig llmConfig = llmConfigRepository.selectOne(wrapper);
if (llmConfig == null) {
log.error("未找到启用的LLM配置: {}", modelConfigName);
throw new IllegalArgumentException("未找到启用的LLM配置: " + modelConfigName);
}
log.info("找到LLM配置: {} ({})", llmConfig.getName(), llmConfig.getProvider());
// 使用LlmModelFactory创建对应的ChatModel实例
return llmModelFactory.createChatModel(llmConfig);
}
}
\ No newline at end of file
package pangea.hiagent.service;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import pangea.hiagent.model.AuthMode;
import java.util.HashMap;
import java.util.Map;
/**
* 认证配置管理服务
* 管理各种认证模式的启用/禁用状态和配置信息
*/
@Slf4j
@Service
public class AuthConfigService {
// 本地缓存的认证模式配置
private final Map<String, Boolean> authModeEnabledCache = new HashMap<>();
private final Map<String, Map<String, Object>> authModeConfigCache = new HashMap<>();
public AuthConfigService() {
// 初始化默认配置
initializeDefaultConfigs();
}
/**
* 初始化默认配置
*/
private void initializeDefaultConfigs() {
// 本地认证模式默认启用
authModeEnabledCache.put(AuthMode.LOCAL.getCode(), true);
// OAuth2 授权码模式默认启用(但需要配置提供者)
authModeEnabledCache.put(AuthMode.OAUTH2_AUTHORIZATION_CODE.getCode(), true);
// 其他模式默认禁用
authModeEnabledCache.put(AuthMode.OAUTH2_IMPLICIT.getCode(), false);
authModeEnabledCache.put(AuthMode.OAUTH2_CLIENT_CREDENTIALS.getCode(), false);
authModeEnabledCache.put(AuthMode.LDAP.getCode(), false);
authModeEnabledCache.put(AuthMode.SAML.getCode(), false);
log.info("认证配置初始化完成");
}
/**
* 检查认证模式是否启用
*/
public boolean isAuthModeEnabled(String authMode) {
Boolean enabled = authModeEnabledCache.get(authMode);
return enabled != null && enabled;
}
/**
* 启用认证模式
*/
public void enableAuthMode(String authMode) {
authModeEnabledCache.put(authMode, true);
log.info("启用认证模式: {}", authMode);
}
/**
* 禁用认证模式
*/
public void disableAuthMode(String authMode) {
authModeEnabledCache.put(authMode, false);
log.info("禁用认证模式: {}", authMode);
}
/**
* 获取认证模式的配置
*/
public Map<String, Object> getAuthModeConfig(String authMode) {
return authModeConfigCache.getOrDefault(authMode, new HashMap<>());
}
/**
* 设置认证模式的配置
*/
public void setAuthModeConfig(String authMode, Map<String, Object> config) {
authModeConfigCache.put(authMode, config);
log.info("更新认证模式配置: {}", authMode);
}
/**
* 获取所有启用的认证模式
*/
public Map<String, Boolean> getAllAuthModes() {
return new HashMap<>(authModeEnabledCache);
}
}
package pangea.hiagent.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.env.Environment;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import pangea.hiagent.model.User;
import pangea.hiagent.repository.UserRepository;
import pangea.hiagent.utils.JwtUtil;
import java.util.Arrays;
/**
* 认证服务类
* 负责用户的注册、登录和认证逻辑
*/
@Slf4j
@Service
public class AuthService {
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
private final JwtUtil jwtUtil;
private final Environment environment;
public AuthService(UserRepository userRepository, PasswordEncoder passwordEncoder, JwtUtil jwtUtil, Environment environment) {
this.userRepository = userRepository;
this.passwordEncoder = passwordEncoder;
this.jwtUtil = jwtUtil;
this.environment = environment;
}
/**
* 用户注册
*/
@Transactional
public User register(String username, String password, String email) {
log.info("用户注册: {}", username);
// 检查用户名是否存在
LambdaQueryWrapper<User> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(User::getUsername, username);
User existingUser = userRepository.selectOne(wrapper);
if (existingUser != null) {
throw new RuntimeException("用户名已存在");
}
// 注意:生产环境中不应记录密码相关信息
log.debug("用户 {} 注册请求已接收", username);
// 创建新用户
User user = User.builder()
.username(username)
.password(passwordEncoder.encode(password))
.email(email)
.status("active")
.role("user")
.build();
userRepository.insert(user);
return user;
}
/**
* 用户登录
*/
public String login(String username, String password) {
log.info("用户登录: {}", username);
// 查询用户
LambdaQueryWrapper<User> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(User::getUsername, username);
User user = userRepository.selectOne(wrapper);
if (user == null) {
log.warn("登录失败: 用户 {} 不存在", username);
throw new RuntimeException("用户不存在");
}
// 检查是否为开发环境,如果是则允许任意密码
boolean isDevEnvironment = Arrays.asList(environment.getActiveProfiles()).contains("dev") ||
Arrays.asList(environment.getDefaultProfiles()).contains("default");
if (isDevEnvironment) {
log.info("开发环境: 跳过密码验证");
} else {
// 验证密码
// 注意:生产环境中不应记录密码相关信息
// 使用BCryptPasswordEncoder验证密码
boolean passwordMatch = passwordEncoder.matches(password, user.getPassword());
if (!passwordMatch) {
log.warn("登录失败: 用户 {} 密码错误", username);
throw new RuntimeException("密码错误");
}
}
// 检查用户状态
if (!"active".equals(user.getStatus())) {
log.warn("登录失败: 用户 {} 已被禁用", username);
throw new RuntimeException("用户已禁用");
}
// 更新最后登录时间
user.setLastLoginTime(System.currentTimeMillis());
userRepository.updateById(user);
// 生成Token
String token = jwtUtil.generateToken(user.getId());
log.info("用户 {} 登录成功,生成Token: {}", username, token);
return token;
}
/**
* 获取用户信息
*/
public User getUserById(String userId) {
return userRepository.selectById(userId);
}
/**
* 验证Token
*/
public boolean validateToken(String token) {
return jwtUtil.validateToken(token);
}
/**
* 从Token获取用户ID
*/
public String getUserIdFromToken(String token) {
return jwtUtil.getUserIdFromToken(token);
}
}
\ No newline at end of file
package pangea.hiagent.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.env.Environment;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import pangea.hiagent.config.AppConfig;
import pangea.hiagent.model.LlmConfig;
import pangea.hiagent.repository.LlmConfigRepository;
import java.util.List;
/**
* LLM配置服务类
* 负责LLM配置的管理和相关业务逻辑
*/
@Slf4j
@Service
public class LlmConfigService {
private final LlmConfigRepository llmConfigRepository;
private final Environment environment;
private final AppConfig appConfig;
public LlmConfigService(LlmConfigRepository llmConfigRepository, Environment environment, AppConfig appConfig) {
this.llmConfigRepository = llmConfigRepository;
this.environment = environment;
this.appConfig = appConfig;
// 初始化默认配置
initializeDefaultConfigs();
}
/**
* 初始化默认配置
*/
private void initializeDefaultConfigs() {
// 检查是否已有配置,如果没有则创建默认配置
if (llmConfigRepository.selectCount(null) == 0) {
log.info("初始化默认LLM配置");
// 创建默认的DeepSeek配置,从环境变量获取API密钥
String deepseekApiKey = environment.getProperty("DEEPSEEK_API_KEY",
appConfig.getLlm().getDeepseek().getDefaultApiKey());
LlmConfig deepseekConfig = LlmConfig.builder()
.name("deepseek-default")
.description("DeepSeek默认配置")
.provider("deepseek")
.modelName(appConfig.getLlm().getDeepseek().getDefaultModel())
.apiKey(deepseekApiKey)
.baseUrl(appConfig.getLlm().getDeepseek().getBaseUrl())
.temperature(appConfig.getAgent().getDefaultTemperature())
.maxTokens(appConfig.getAgent().getDefaultMaxTokens())
.topP(0.9)
.enabled(true) // 总是启用,即使没有API密钥也能正常启动
.owner("system")
.build();
llmConfigRepository.insert(deepseekConfig);
// 创建默认的OpenAI配置
String openaiApiKey = environment.getProperty("OPENAI_API_KEY",
appConfig.getLlm().getOpenai().getDefaultApiKey());
LlmConfig openaiConfig = LlmConfig.builder()
.name("openai-default")
.description("OpenAI默认配置")
.provider("openai")
.modelName(appConfig.getLlm().getOpenai().getDefaultModel())
.apiKey(openaiApiKey)
.baseUrl(appConfig.getLlm().getOpenai().getBaseUrl())
.temperature(appConfig.getAgent().getDefaultTemperature())
.maxTokens(appConfig.getAgent().getDefaultMaxTokens())
.topP(0.9)
.enabled(!openaiApiKey.isEmpty()) // 仅在配置了API密钥时启用
.owner("system")
.build();
llmConfigRepository.insert(openaiConfig);
// 创建默认的Ollama配置
LlmConfig ollamaConfig = LlmConfig.builder()
.name("ollama-default")
.description("Ollama默认配置")
.provider("ollama")
.modelName(appConfig.getLlm().getOllama().getDefaultModel())
.apiKey("")
.baseUrl(appConfig.getLlm().getOllama().getBaseUrl())
.temperature(appConfig.getAgent().getDefaultTemperature())
.maxTokens(appConfig.getAgent().getDefaultMaxTokens())
.topP(0.9)
.enabled(true)
.owner("system")
.build();
llmConfigRepository.insert(ollamaConfig);
} else {
// 如果已有配置,检查是否有启用的配置,如果没有则启用所有配置
List<LlmConfig> allConfigs = llmConfigRepository.selectList(null);
boolean hasEnabledConfig = allConfigs.stream().anyMatch(LlmConfig::getEnabled);
if (!hasEnabledConfig) {
log.info("未发现启用的LLM配置,正在启用所有现有配置");
for (LlmConfig config : allConfigs) {
config.setEnabled(true);
llmConfigRepository.updateById(config);
}
}
}
}
/**
* 创建LLM配置
*/
@Transactional
public LlmConfig createLlmConfig(LlmConfig config) {
log.info("创建LLM配置: {}", config.getName());
llmConfigRepository.insert(config);
return config;
}
/**
* 更新LLM配置
*/
@Transactional
public LlmConfig updateLlmConfig(LlmConfig config) {
log.info("更新LLM配置: {}", config.getName());
llmConfigRepository.updateById(config);
return config;
}
/**
* 删除LLM配置
*/
@Transactional
public void deleteLlmConfig(String id) {
log.info("删除LLM配置: {}", id);
llmConfigRepository.deleteById(id);
}
/**
* 获取LLM配置详情
*
* @param id 配置ID
* @return LLM配置对象,如果不存在则返回null
*/
public LlmConfig getLlmConfig(String id) {
log.info("获取LLM配置,ID: {}", id);
if (id == null || id.isEmpty()) {
log.warn("尝试使用无效ID获取LLM配置");
return null;
}
LlmConfig config = llmConfigRepository.selectById(id);
log.info("获取到LLM配置: {}", config);
return config;
}
/**
* 根据名称获取LLM配置
*
* @param name 配置名称
* @return LLM配置对象,如果不存在则返回null
*/
public LlmConfig getLlmConfigByName(String name) {
if (name == null || name.isEmpty()) {
log.warn("尝试使用无效名称获取LLM配置");
return null;
}
LambdaQueryWrapper<LlmConfig> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(LlmConfig::getName, name);
return llmConfigRepository.selectOne(wrapper);
}
/**
* 获取启用的LLM配置列表
*/
public List<LlmConfig> getEnabledLlmConfigs() {
LambdaQueryWrapper<LlmConfig> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(LlmConfig::getEnabled, true);
return llmConfigRepository.selectList(wrapper);
}
/**
* 获取LLM配置列表
*/
public List<LlmConfig> listLlmConfigs() {
return llmConfigRepository.selectList(null);
}
/**
* 分页获取LLM配置列表
*/
public IPage<LlmConfig> pageLlmConfigs(Long current, Long size, String name, String provider) {
Page<LlmConfig> page = new Page<>(current, size);
LambdaQueryWrapper<LlmConfig> wrapper = new LambdaQueryWrapper<>();
if (name != null && !name.isEmpty()) {
wrapper.like(LlmConfig::getName, name);
}
if (provider != null && !provider.isEmpty()) {
wrapper.eq(LlmConfig::getProvider, provider);
}
return llmConfigRepository.selectPage(page, wrapper);
}
}
\ No newline at end of file
package pangea.hiagent.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import pangea.hiagent.model.OAuth2Provider;
import pangea.hiagent.repository.OAuth2ProviderRepository;
import java.util.List;
/**
* OAuth2 提供者服务类
* 负责OAuth2提供商配置的管理和相关业务逻辑
*/
@Slf4j
@Service
public class OAuth2ProviderService {
private final OAuth2ProviderRepository oAuth2ProviderRepository;
public OAuth2ProviderService(OAuth2ProviderRepository oAuth2ProviderRepository) {
this.oAuth2ProviderRepository = oAuth2ProviderRepository;
}
/**
* 创建OAuth2提供商配置
*
* @param provider OAuth2提供商配置对象
* @return 创建后的OAuth2提供商配置对象
*/
@Transactional
public OAuth2Provider createProvider(OAuth2Provider provider) {
log.info("创建OAuth2提供商配置: providerName={}", provider.getProviderName());
oAuth2ProviderRepository.insert(provider);
log.info("OAuth2提供商配置创建成功: id={}", provider.getId());
return provider;
}
/**
* 更新OAuth2提供商配置
*
* @param id 提供商ID
* @param provider 更新的OAuth2提供商配置对象
* @return 更新后的OAuth2提供商配置对象
*/
@Transactional
public OAuth2Provider updateProvider(String id, OAuth2Provider provider) {
log.info("更新OAuth2提供商配置: id={}", id);
OAuth2Provider existingProvider = oAuth2ProviderRepository.selectById(id);
if (existingProvider == null) {
log.warn("尝试更新不存在的OAuth2提供商配置: id={}", id);
throw new RuntimeException("OAuth2提供商配置不存在");
}
// 更新字段
existingProvider.setProviderName(provider.getProviderName());
existingProvider.setDisplayName(provider.getDisplayName());
existingProvider.setDescription(provider.getDescription());
existingProvider.setAuthType(provider.getAuthType());
existingProvider.setAuthorizeUrl(provider.getAuthorizeUrl());
existingProvider.setTokenUrl(provider.getTokenUrl());
existingProvider.setUserinfoUrl(provider.getUserinfoUrl());
existingProvider.setClientId(provider.getClientId());
existingProvider.setClientSecret(provider.getClientSecret());
existingProvider.setRedirectUri(provider.getRedirectUri());
existingProvider.setScope(provider.getScope());
existingProvider.setEnabled(provider.getEnabled());
existingProvider.setConfigJson(provider.getConfigJson());
existingProvider.setUpdatedBy(provider.getUpdatedBy());
oAuth2ProviderRepository.updateById(existingProvider);
log.info("OAuth2提供商配置更新成功: id={}", id);
return existingProvider;
}
/**
* 删除OAuth2提供商配置
*
* @param id 提供商ID
*/
@Transactional
public void deleteProvider(String id) {
log.info("删除OAuth2提供商配置: id={}", id);
oAuth2ProviderRepository.deleteById(id);
log.info("OAuth2提供商配置删除成功: id={}", id);
}
/**
* 根据ID获取OAuth2提供商配置
*
* @param id 提供商ID
* @return OAuth2提供商配置对象,如果不存在则返回null
*/
public OAuth2Provider getProviderById(String id) {
if (id == null || id.isEmpty()) {
log.warn("尝试使用无效ID获取OAuth2提供商配置");
return null;
}
return oAuth2ProviderRepository.selectById(id);
}
/**
* 根据提供商名称获取OAuth2提供商配置
*
* @param providerName 提供商名称
* @return OAuth2提供商配置对象,如果不存在则返回null
*/
public OAuth2Provider getProviderByName(String providerName) {
if (providerName == null || providerName.isEmpty()) {
log.warn("尝试使用无效提供商名称获取OAuth2提供商配置");
return null;
}
LambdaQueryWrapper<OAuth2Provider> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(OAuth2Provider::getProviderName, providerName);
return oAuth2ProviderRepository.selectOne(wrapper);
}
/**
* 获取OAuth2提供商配置列表
*
* @return OAuth2提供商配置列表
*/
public List<OAuth2Provider> listProviders() {
List<OAuth2Provider> providers = oAuth2ProviderRepository.selectList(null);
log.info("获取到 {} 个OAuth2提供商配置", providers != null ? providers.size() : 0);
return providers != null ? providers : List.of();
}
/**
* 分页获取OAuth2提供商配置列表
*
* @param current 当前页码
* @param size 每页大小
* @param providerName 提供商名称(模糊查询)
* @param displayName 显示名称(模糊查询)
* @param enabled 启用状态
* @return 分页结果
*/
public IPage<OAuth2Provider> pageProviders(Long current, Long size, String providerName, String displayName, Integer enabled) {
Page<OAuth2Provider> page = new Page<>(current, size);
LambdaQueryWrapper<OAuth2Provider> wrapper = new LambdaQueryWrapper<>();
if (providerName != null && !providerName.isEmpty()) {
wrapper.like(OAuth2Provider::getProviderName, providerName);
}
if (displayName != null && !displayName.isEmpty()) {
wrapper.like(OAuth2Provider::getDisplayName, displayName);
}
if (enabled != null) {
wrapper.eq(OAuth2Provider::getEnabled, enabled);
}
return oAuth2ProviderRepository.selectPage(page, wrapper);
}
/**
* 启用OAuth2提供商配置
*
* @param id 提供商ID
*/
@Transactional
public void enableProvider(String id) {
log.info("启用OAuth2提供商配置: id={}", id);
OAuth2Provider provider = oAuth2ProviderRepository.selectById(id);
if (provider != null) {
provider.setEnabled(1);
oAuth2ProviderRepository.updateById(provider);
log.info("OAuth2提供商配置启用成功: id={}", id);
} else {
log.warn("尝试启用不存在的OAuth2提供商配置: id={}", id);
throw new RuntimeException("OAuth2提供商配置不存在");
}
}
/**
* 禁用OAuth2提供商配置
*
* @param id 提供商ID
*/
@Transactional
public void disableProvider(String id) {
log.info("禁用OAuth2提供商配置: id={}", id);
OAuth2Provider provider = oAuth2ProviderRepository.selectById(id);
if (provider != null) {
provider.setEnabled(0);
oAuth2ProviderRepository.updateById(provider);
log.info("OAuth2提供商配置禁用成功: id={}", id);
} else {
log.warn("尝试禁用不存在的OAuth2提供商配置: id={}", id);
throw new RuntimeException("OAuth2提供商配置不存在");
}
}
}
\ No newline at end of file
package pangea.hiagent.service;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.messages.AssistantMessage;
import org.springframework.ai.chat.messages.Message;
import org.springframework.ai.chat.messages.SystemMessage;
import org.springframework.ai.chat.messages.UserMessage;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.chat.model.ChatResponse;
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import pangea.hiagent.model.Agent;
import pangea.hiagent.model.Tool;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.HashMap;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* ReAct Agent服务类
* 负责实现ReAct Agent的核心逻辑
*/
@Slf4j
@Service
public class ReActAgentService {
@Autowired
private ChatModel chatModel;
@Autowired
private ToolService toolService;
@Autowired
private AgentService agentService;
/**
* 处理用户请求的主方法
*
* @param agent Agent对象
* @param userMessage 用户消息
* @return 处理结果
*/
public String processRequest(Agent agent, String userMessage) {
log.info("开始处理ReAct Agent请求,Agent ID: {}, 用户消息: {}", agent.getId(), userMessage);
// 初始化对话历史
List<Message> conversationHistory = new ArrayList<>();
// 添加系统提示词
String systemPrompt = agent.getSystemPrompt() != null ? agent.getSystemPrompt() :
"You are a helpful AI assistant that can use tools to help answer questions. " +
"Use the following format to think through problems step by step:\n\n" +
"Thought: Think about what to do.\n" +
"Action: Call a tool with parameters.\n" +
"Observation: Observe the result of the tool call.\n\n" +
"Continue this process until you have enough information to answer the question.\n" +
"Finally, provide your answer in the format:\n" +
"Final Answer: [Your answer here]";
conversationHistory.add(new SystemMessage(systemPrompt));
// 添加用户消息
conversationHistory.add(new UserMessage(userMessage));
// 执行ReAct循环
for (int i = 0; i < 5; i++) { // 最多执行5轮
log.info("执行ReAct循环第 {} 轮", i + 1);
// 构建Prompt
Prompt prompt = new Prompt(conversationHistory);
// 调用模型
ChatResponse response = chatModel.call(prompt);
String aiResponse = response.getResult().getOutput().getText();
log.info("模型响应: {}", aiResponse);
// 检查是否需要工具调用
if (needsToolCall(aiResponse)) {
log.info("检测到需要工具调用");
// 解析工具调用
ToolCall toolCall = parseToolCall(aiResponse);
if (toolCall != null) {
log.info("解析到工具调用: 工具名称={}, 参数={}", toolCall.getToolName(), toolCall.getParameters());
// 执行工具调用
String toolResult = toolService.executeTool(toolCall.getToolName(), toolCall.getParameters());
log.info("工具执行结果: {}", toolResult);
// 将工具结果添加到对话历史
conversationHistory.add(new AssistantMessage(aiResponse));
conversationHistory.add(new UserMessage("Observation: " + toolResult));
} else {
log.warn("工具调用解析失败,结束ReAct循环");
break;
}
} else {
log.info("无需工具调用,返回最终答案");
// 返回最终结果
String finalAnswer = extractFinalAnswer(aiResponse);
log.info("最终答案: {}", finalAnswer);
return finalAnswer;
}
}
log.warn("达到最大执行轮次限制");
return "达到最大执行轮次限制";
}
/**
* 判断是否需要工具调用
*
* @param response 模型响应
* @return 是否需要工具调用
*/
private boolean needsToolCall(String response) {
return response.contains("Action:");
}
/**
* 解析工具调用
*
* @param response 模型响应
* @return 工具调用对象
*/
private ToolCall parseToolCall(String response) {
// 使用正则表达式解析Action行
// 支持多种格式:Action: tool_name(param1=value1, param2=value2) 或 Action: tool_name()
Pattern actionPattern = Pattern.compile("Action:\\s*(\\w+)\\s*\\(([^)]*)\\)");
Matcher matcher = actionPattern.matcher(response);
if (matcher.find()) {
String toolName = matcher.group(1);
String paramsStr = matcher.group(2);
// 解析参数
Map<String, Object> parameters = parseParameters(paramsStr);
return new ToolCall(toolName, parameters);
}
return null;
}
/**
* 解析参数字符串
*
* @param paramsStr 参数字符串
* @return 参数Map
*/
private Map<String, Object> parseParameters(String paramsStr) {
Map<String, Object> parameters = new HashMap<>();
// 如果参数字符串为空,直接返回空Map
if (paramsStr == null || paramsStr.trim().isEmpty()) {
return parameters;
}
// 解析参数,支持带引号和不带引号的值
// 格式: key1=value1, key2="value with spaces", key3='another value'
String[] paramPairs = paramsStr.split(","); // 简化处理,按逗号分割
for (String paramPair : paramPairs) {
String[] parts = paramPair.split("=");
if (parts.length == 2) {
String key = parts[0].trim();
String value = parts[1].trim();
// 移除引号(如果存在)
if ((value.startsWith("\"") && value.endsWith("\"")) ||
(value.startsWith("'") && value.endsWith("'"))) {
value = value.substring(1, value.length() - 1);
}
parameters.put(key, value);
}
}
return parameters;
}
/**
* 提取最终答案
*
* @param response 模型响应
* @return 最终答案
*/
private String extractFinalAnswer(String response) {
// 查找Final Answer:后面的文本
Pattern answerPattern = Pattern.compile("Final Answer:\\s*(.*)", Pattern.DOTALL);
Matcher matcher = answerPattern.matcher(response);
if (matcher.find()) {
return matcher.group(1).trim();
}
// 如果没有找到Final Answer格式,返回整个响应
return response;
}
/**
* 工具调用内部类
*/
private static class ToolCall {
private final String toolName;
private final Map<String, Object> parameters;
public ToolCall(String toolName, Map<String, Object> parameters) {
this.toolName = toolName;
this.parameters = parameters;
}
public String getToolName() {
return toolName;
}
public Map<String, Object> getParameters() {
return parameters;
}
}
}
\ No newline at end of file
package pangea.hiagent.service;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import pangea.hiagent.model.Tool;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.Map;
/**
* 时间工具服务类
* 提供获取当前时间的功能
*/
@Slf4j
@Service
public class TimeToolService {
/**
* 执行时间工具调用
*
* @param tool 工具信息
* @param parameters 工具参数
* @return 当前时间的字符串表示
*/
public String executeTimeTool(Tool tool, Map<String, Object> parameters) {
log.info("执行时间工具调用: {}", tool.getName());
try {
// 获取当前时间
LocalDateTime now = LocalDateTime.now();
// 格式化时间
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
String formattedTime = now.format(formatter);
// 构造返回结果
return "{\n" +
" \"currentTime\": \"" + formattedTime + "\",\n" +
" \"timestamp\": " + System.currentTimeMillis() + "\n" +
"}";
} catch (Exception e) {
log.error("时间工具调用失败: {}", tool.getName(), e);
return "{\n" +
" \"error\": \"时间工具调用失败\",\n" +
" \"message\": \"" + e.getMessage() + "\"\n" +
"}";
}
}
}
\ No newline at end of file
package pangea.hiagent.service; package pangea.hiagent.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.client.RestTemplate;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.ResponseEntity;
import org.springframework.http.HttpStatus;
import com.fasterxml.jackson.databind.ObjectMapper;
import pangea.hiagent.model.Tool; import pangea.hiagent.model.Tool;
import pangea.hiagent.repository.ToolRepository; import pangea.hiagent.repository.ToolRepository;
import java.time.LocalDateTime;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.UUID;
import java.util.HashMap;
import java.net.URI;
/** /**
* Tool服务类 * 工具服务类
* 负责工具的注册、管理和调用 * 提供工具管理相关业务逻辑
*/ */
@Slf4j @Slf4j
@Service @Service
public class ToolService { public class ToolService extends ServiceImpl<ToolRepository, Tool> {
private final ToolRepository toolRepository; private final ToolRepository toolRepository;
private final RestTemplate restTemplate;
private final ObjectMapper objectMapper;
private final TimeToolService timeToolService;
public ToolService(ToolRepository toolRepository, TimeToolService timeToolService) { public ToolService(ToolRepository toolRepository) {
this.toolRepository = toolRepository; this.toolRepository = toolRepository;
this.restTemplate = new RestTemplate();
this.objectMapper = new ObjectMapper();
this.timeToolService = timeToolService;
} }
/** /**
* 注册工具 * 创建工具
* @param tool 工具对象
* @param userId 用户ID
* @return 创建后的工具对象
*/ */
@Transactional public Tool createTool(Tool tool, String userId) {
public Tool registerTool(Tool tool) { // 设置ID
log.info("注册工具: {}", tool.getName()); if (tool.getId() == null || tool.getId().isEmpty()) {
tool.setId(UUID.randomUUID().toString());
}
// 设置所有者
tool.setOwner(userId);
// 设置创建和更新信息
tool.setCreatedBy(userId);
tool.setUpdatedBy(userId);
tool.setCreatedAt(LocalDateTime.now());
tool.setUpdatedAt(LocalDateTime.now());
// 设置默认状态
if (tool.getStatus() == null || tool.getStatus().isEmpty()) {
tool.setStatus("active");
}
// 保存到数据库
toolRepository.insert(tool); toolRepository.insert(tool);
log.info("创建工具成功,工具ID: {}", tool.getId());
return tool; return tool;
} }
/** /**
* 更新工具 * 更新工具
* @param tool 工具对象
* @param userId 用户ID
* @return 更新后的工具对象
*/ */
@Transactional public Tool updateTool(Tool tool, String userId) {
public Tool updateTool(Tool tool) { // 检查工具是否存在
log.info("更新工具: {}", tool.getId()); Tool existingTool = toolRepository.selectById(tool.getId());
if (existingTool == null) {
throw new RuntimeException("工具不存在");
}
// 检查权限
if (!existingTool.getOwner().equals(userId)) {
throw new RuntimeException("无权限修改此工具");
}
// 更新信息
tool.setUpdatedBy(userId);
tool.setUpdatedAt(LocalDateTime.now());
// 更新数据库
toolRepository.updateById(tool); toolRepository.updateById(tool);
log.info("更新工具成功,工具ID: {}", tool.getId());
return tool; return tool;
} }
/** /**
* 删除工具 * 删除工具
* @param toolId 工具ID
* @param userId 用户ID
*/ */
@Transactional public void deleteTool(String toolId, String userId) {
public void deleteTool(String id) { // 检查工具是否存在
log.info("删除工具: {}", id); Tool existingTool = toolRepository.selectById(toolId);
toolRepository.deleteById(id); if (existingTool == null) {
} throw new RuntimeException("工具不存在");
/**
* 获取工具详情
*/
public Tool getTool(String id) {
return toolRepository.selectById(id);
}
/**
* 获取工具列表
*/
public List<Tool> listTools() {
log.debug("开始获取工具列表");
try {
LambdaQueryWrapper<Tool> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(Tool::getStatus, "active");
List<Tool> tools = toolRepository.selectList(wrapper);
log.debug("成功获取工具列表,共 {} 条记录", tools.size());
return tools;
} catch (Exception e) {
log.error("获取工具列表时发生异常", e);
throw e;
} }
}
/**
* 分页获取工具列表
*/
public IPage<Tool> pageTools(Long current, Long size, String name, String category) {
Page<Tool> page = new Page<>(current, size);
LambdaQueryWrapper<Tool> wrapper = new LambdaQueryWrapper<>(); // 检查权限
wrapper.eq(Tool::getStatus, "active"); if (!existingTool.getOwner().equals(userId)) {
if (name != null) { throw new RuntimeException("无权限删除此工具");
wrapper.like(Tool::getName, name);
}
if (category != null) {
wrapper.eq(Tool::getCategory, category);
} }
return toolRepository.selectPage(page, wrapper); // 逻辑删除
} Tool tool = new Tool();
tool.setId(toolId);
/** tool.setDeleted(1);
* 按分类获取工具列表 tool.setUpdatedBy(userId);
*/ tool.setUpdatedAt(LocalDateTime.now());
public List<Tool> getToolsByCategory(String category) {
LambdaQueryWrapper<Tool> wrapper = new LambdaQueryWrapper<>(); toolRepository.updateById(tool);
wrapper.eq(Tool::getCategory, category); log.info("删除工具成功,工具ID: {}", toolId);
wrapper.eq(Tool::getStatus, "active");
return toolRepository.selectList(wrapper);
} }
/** /**
* 执行工具调用 * 根据ID获取工具
* * @param toolId 工具ID
* @param toolName 工具名称 * @param userId 用户ID
* @param parameters 工具参数 * @return 工具对象
* @return 工具执行结果
*/ */
public String executeTool(String toolName, Map<String, Object> parameters) { public Tool getToolById(String toolId, String userId) {
log.info("执行工具调用: {}, 参数: {}", toolName, parameters); Tool tool = toolRepository.selectById(toolId);
if (tool == null || tool.getDeleted() == 1) {
// 根据工具名称获取工具信息 return null;
LambdaQueryWrapper<Tool> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(Tool::getName, toolName);
wrapper.eq(Tool::getStatus, "active");
Tool tool = toolRepository.selectOne(wrapper);
if (tool == null) {
log.warn("工具未找到或未激活: {}", toolName);
return "工具未找到或未激活: " + toolName;
} }
// 根据工具类型执行不同的调用逻辑 // 检查权限
// 这里根据工具的具体类型来执行不同的逻辑 if (!tool.getOwner().equals(userId)) {
switch (tool.getCategory()) { throw new RuntimeException("无权限访问此工具");
case "API":
return executeApiTool(tool, parameters);
case "FUNCTION":
// 检查是否为特殊的时间工具
if ("get_current_time".equals(tool.getName())) {
return timeToolService.executeTimeTool(tool, parameters);
}
return executeFunctionTool(tool, parameters);
default:
return executeDefaultTool(tool, parameters);
} }
return tool;
} }
/** /**
* 执行API工具调用 * 获取用户的所有工具
* @param userId 用户ID
* @return 工具列表
*/ */
private String executeApiTool(Tool tool, Map<String, Object> parameters) { public List<Tool> getUserTools(String userId) {
log.info("执行API工具调用: {}", tool.getName()); return toolRepository.findByOwner(userId);
try {
// 构建请求URL
String apiUrl = tool.getApiEndpoint();
if (apiUrl == null || apiUrl.isEmpty()) {
return "工具配置错误:API端点未设置";
}
// 解析HTTP方法
HttpMethod httpMethod = HttpMethod.GET;
if (tool.getHttpMethod() != null) {
try {
httpMethod = HttpMethod.valueOf(tool.getHttpMethod().toUpperCase());
} catch (IllegalArgumentException e) {
log.warn("无效的HTTP方法: {}, 使用GET作为默认方法", tool.getHttpMethod());
}
}
// 构建请求头
HttpHeaders headers = new HttpHeaders();
headers.set("Content-Type", "application/json");
headers.set("User-Agent", "HiAgent/1.0");
// 构建请求体
HttpEntity<Map<String, Object>> requestEntity = new HttpEntity<>(parameters, headers);
// 发送HTTP请求
ResponseEntity<String> response = restTemplate.exchange(
apiUrl,
httpMethod,
requestEntity,
String.class
);
// 检查响应状态
if (response.getStatusCode() == HttpStatus.OK) {
return response.getBody();
} else {
return "API调用失败,状态码: " + response.getStatusCode();
}
} catch (Exception e) {
log.error("API工具调用失败: {}", tool.getName(), e);
return "API工具调用失败: " + e.getMessage();
}
} }
/** /**
* 执行函数工具调用 * 根据状态获取用户工具
* @param userId 用户ID
* @param status 工具状态
* @return 工具列表
*/ */
private String executeFunctionTool(Tool tool, Map<String, Object> parameters) { public List<Tool> getUserToolsByStatus(String userId, String status) {
log.info("执行函数工具调用: {}", tool.getName()); return toolRepository.findByOwnerAndStatus(userId, status);
// 这里应该实现实际的函数调用逻辑
// 例如通过反射调用指定的Java方法
// 目前返回模拟结果
Map<String, Object> result = new HashMap<>();
result.put("tool", tool.getName());
result.put("status", "success");
result.put("message", "函数工具调用成功");
result.put("parameters", parameters);
result.put("result", "这是函数执行的结果");
try {
return objectMapper.writeValueAsString(result);
} catch (Exception e) {
log.error("序列化函数调用结果失败", e);
return result.toString();
}
} }
/** /**
* 执行默认工具调用 * 根据名称获取用户工具
* @param name 工具名称
* @param userId 用户ID
* @return 工具对象
*/ */
private String executeDefaultTool(Tool tool, Map<String, Object> parameters) { public Tool getToolByName(String name, String userId) {
log.info("执行默认工具调用: {}", tool.getName()); return toolRepository.findByNameAndOwner(name, userId);
// 默认的工具执行逻辑
Map<String, Object> result = new HashMap<>();
result.put("tool", tool.getName());
result.put("status", "success");
result.put("message", "默认工具调用成功");
result.put("parameters", parameters);
result.put("result", "这是默认工具执行的结果");
try {
return objectMapper.writeValueAsString(result);
} catch (Exception e) {
log.error("序列化默认工具调用结果失败", e);
return result.toString();
}
} }
} }
\ No newline at end of file
package pangea.hiagent.service;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import pangea.hiagent.auth.AuthenticationStrategy;
import pangea.hiagent.model.AuthMode;
import pangea.hiagent.model.OAuth2Provider;
import pangea.hiagent.repository.OAuth2ProviderRepository;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* 统一认证服务
* 作为认证策略的门面,支持多种认证方式的协调调度
*/
@Slf4j
@Service
public class UnifiedAuthService {
private final List<AuthenticationStrategy> authenticationStrategies;
private final AuthConfigService authConfigService;
private final OAuth2ProviderRepository oauth2ProviderRepository;
public UnifiedAuthService(List<AuthenticationStrategy> authenticationStrategies,
AuthConfigService authConfigService,
OAuth2ProviderRepository oauth2ProviderRepository) {
this.authenticationStrategies = authenticationStrategies;
this.authConfigService = authConfigService;
this.oauth2ProviderRepository = oauth2ProviderRepository;
}
/**
* 执行统一认证
* @param authMode 认证模式(如 "local"、"oauth2_auth_code" 等)
* @param credentials 认证凭证,结构根据不同认证模式而异
* @return JWT Token
*/
public String authenticate(String authMode, Map<String, Object> credentials) {
log.info("执行统一认证: authMode={}", authMode);
// 查找支持该认证模式的策略
AuthenticationStrategy strategy = findStrategy(authMode);
if (strategy == null) {
log.error("未找到支持的认证策略: authMode={}", authMode);
throw new RuntimeException("不支持的认证模式: " + authMode);
}
try {
// 验证认证模式是否启用
if (!authConfigService.isAuthModeEnabled(authMode)) {
log.warn("认证模式未启用: authMode={}", authMode);
throw new RuntimeException("该认证模式未启用");
}
// 执行认证策略
String token = strategy.authenticate(credentials);
log.info("统一认证成功: authMode={}", authMode);
return token;
} catch (Exception e) {
log.error("统一认证失败: authMode={}, 错误堆栈: ", authMode, e);
throw e;
}
}
/**
* 执行本地用户名/密码认证
* @param username 用户名
* @param password 密码
* @return JWT Token
*/
public String loginWithLocal(String username, String password) {
Map<String, Object> credentials = new HashMap<>();
credentials.put("username", username);
credentials.put("password", password);
return authenticate(AuthMode.LOCAL.getCode(), credentials);
}
/**
* 执行 OAuth2 授权码认证
* @param authorizationCode 授权码
* @param providerName OAuth2 提供者名称
* @return JWT Token
*/
public String loginWithOAuth2(String authorizationCode, String providerName) {
Map<String, Object> credentials = new HashMap<>();
credentials.put("authorizationCode", authorizationCode);
credentials.put("providerName", providerName);
return authenticate(AuthMode.OAUTH2_AUTHORIZATION_CODE.getCode(), credentials);
}
/**
* 验证令牌
*/
public boolean verifyToken(String token) {
try {
for (AuthenticationStrategy strategy : authenticationStrategies) {
if (strategy.verify(token)) {
return true;
}
}
return false;
} catch (Exception e) {
log.error("令牌验证过程中出错: 错误堆栈: ", e);
return false;
}
}
/**
* 查找支持该认证模式的策略
*/
private AuthenticationStrategy findStrategy(String authMode) {
for (AuthenticationStrategy strategy : authenticationStrategies) {
if (strategy.supports(authMode)) {
log.debug("找到支持的认证策略: authMode={}, strategyName={}", authMode, strategy.getName());
return strategy;
}
}
return null;
}
/**
* 获取所有可用的认证模式
*/
public Map<String, Object> getAvailableAuthModes() {
Map<String, Object> modes = new HashMap<>();
for (AuthMode mode : AuthMode.values()) {
if (authConfigService.isAuthModeEnabled(mode.getCode())) {
Map<String, String> modeInfo = new HashMap<>();
modeInfo.put("code", mode.getCode());
modeInfo.put("description", mode.getDescription());
modes.put(mode.getCode(), modeInfo);
}
}
return modes;
}
/**
* 构造 OAuth2 授权 URL
* @param providerName OAuth2 提供者名称
* @return 授权 URL
*/
public String buildOAuth2AuthorizationUrl(String providerName) {
log.debug("构造 OAuth2 授权 URL: providerName={}", providerName);
LambdaQueryWrapper<OAuth2Provider> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(OAuth2Provider::getProviderName, providerName)
.eq(OAuth2Provider::getEnabled, 1);
OAuth2Provider provider = oauth2ProviderRepository.selectOne(wrapper);
if (provider == null) {
log.error("未找到配置的 OAuth2 提供者: {}", providerName);
throw new RuntimeException("未找到配置的 OAuth2 提供者");
}
// 构造授权 URL
StringBuilder authUrlBuilder = new StringBuilder(provider.getAuthorizeUrl());
authUrlBuilder.append("?client_id=").append(provider.getClientId());
authUrlBuilder.append("&redirect_uri=").append(provider.getRedirectUri());
authUrlBuilder.append("&response_type=code");
authUrlBuilder.append("&scope=").append(provider.getScope());
authUrlBuilder.append("&state=").append(java.util.UUID.randomUUID().toString());
String authUrl = authUrlBuilder.toString();
log.debug("OAuth2 授权 URL 构造完成: {}", authUrl);
return authUrl;
}
}
\ No newline at end of file
{"properties": [
{
"name": "app.chat-memory.implementation",
"type": "java.lang.String",
"description": "A description for 'app.chat-memory.implementation'"
},
{
"name": "app.chat-memory.caffeine.enabled",
"type": "java.lang.String",
"description": "A description for 'app.chat-memory.caffeine.enabled'"
},
{
"name": "app.chat-memory.redis.enabled",
"type": "java.lang.String",
"description": "A description for 'app.chat-memory.redis.enabled'"
}
]}
\ No newline at end of file
# 多种登录模式统一认证架构设计文档
## 一、系统概述
本文档详细描述了 HiAgent 系统中实现的**统一身份认证架构**,该架构支持多种登录模式,包括:
1. **本地用户名/密码认证**(LOCAL)
2. **OAuth2.0 授权码模式**(OAUTH2_AUTHORIZATION_CODE)
3. **支持未来扩展**:LDAP、SAML 等其他认证机制
## 二、系统架构
### 2.1 整体架构图
```
┌─────────────────────────────────────────────────────────────┐
│ 前端应用 (Vue3) │
│ ┌─────────────────────────────────────────────────────────┐
│ │ Login.vue │
│ │ ├── 本地登录表单 │
│ │ └── OAuth2 登录选项 │
│ └─────────────────────────────────────────────────────────┘
└─────────────────────────────────────────────────────────────┘
↓ ↓
/api/v1/auth/login /api/v1/auth/oauth2/authorize
↓ ↓
┌─────────────────────────────────────────────────────────────┐
│ 认证控制器 (AuthController) │
│ ├── POST /login │
│ ├── GET /oauth2/authorize │
│ ├── GET /oauth2/callback │
│ └── POST /oauth2/token │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ 统一认证门面 (UnifiedAuthService) │
│ ├── authenticate(authMode, credentials) │
│ ├── loginWithLocal(username, password) │
│ ├── loginWithOAuth2(code, providerName) │
│ └── verifyToken(token) │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ 认证策略实现 (AuthenticationStrategy) │
│ ├── LocalAuthenticationStrategy │
│ └── OAuth2AuthenticationStrategy │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ 数据持久层 (Repository) │
│ ├── UserRepository │
│ ├── OAuth2ProviderRepository │
│ └── OAuth2AccountRepository │
└─────────────────────────────────────────────────────────────┘
```
### 2.2 核心组件说明
#### 2.2.1 认证模式枚举 (AuthMode.java)
定义系统支持的所有认证方式:
- `LOCAL`: 本地用户名/密码认证
- `OAUTH2_AUTHORIZATION_CODE`: OAuth2.0 授权码模式
- `OAUTH2_IMPLICIT`: OAuth2.0 隐式授权(预留)
- `OAUTH2_CLIENT_CREDENTIALS`: OAuth2.0 客户端凭证(预留)
- `LDAP`: LDAP 目录认证(预留)
- `SAML`: SAML 单点登录(预留)
#### 2.2.2 认证策略接口 (AuthenticationStrategy.java)
定义统一的认证流程接口:
```java
public interface AuthenticationStrategy {
String getName();
boolean supports(String authMode);
String authenticate(Map<String, Object> credentials);
String refreshToken(String refreshToken);
boolean verify(String token);
}
```
#### 2.2.3 本地认证策略 (LocalAuthenticationStrategy.java)
实现基于本地数据库的用户名/密码认证:
- 查询用户信息
- 验证密码(支持开发环境跳过)
- 检查用户状态
- 生成 JWT Token
- **包含完整的错误日志输出**
#### 2.2.4 OAuth2 认证策略 (OAuth2AuthenticationStrategy.java)
实现标准 OAuth2.0 授权码流程:
- **授权码交换**:使用授权码交换访问令牌
- **用户信息获取**:从 OAuth2 提供者获取用户信息
- **用户创建/查找**:自动创建新用户或关联现有用户
- **账户关联**:保存 OAuth2 账户关联信息
- **令牌管理**:保存访问令牌和刷新令牌
- **包含详细的错误堆栈追踪**
#### 2.2.5 统一认证门面 (UnifiedAuthService.java)
协调所有认证策略的执行:
```java
public String authenticate(String authMode, Map<String, Object> credentials)
public String loginWithLocal(String username, String password)
public String loginWithOAuth2(String authorizationCode, String providerName)
public boolean verifyToken(String token)
public String buildOAuth2AuthorizationUrl(String providerName)
public Map<String, Object> getAvailableAuthModes()
```
#### 2.2.6 认证配置管理 (AuthConfigService.java)
管理各种认证模式的启用/禁用状态和配置信息:
- `isAuthModeEnabled(authMode)`: 检查认证模式是否启用
- `enableAuthMode(authMode)`: 启用认证模式
- `disableAuthMode(authMode)`: 禁用认证模式
- `setAuthModeConfig(authMode, config)`: 设置认证模式配置
## 三、数据库架构
### 3.1 新增数据库表
#### 3.1.1 oauth2_provider 表
存储 OAuth2 提供者的配置信息:
```sql
CREATE TABLE oauth2_provider (
id VARCHAR(36) PRIMARY KEY,
provider_name VARCHAR(50) UNIQUE,
display_name VARCHAR(100),
auth_type VARCHAR(50),
authorize_url VARCHAR(255),
token_url VARCHAR(255),
userinfo_url VARCHAR(255),
client_id VARCHAR(255),
client_secret VARCHAR(255),
redirect_uri VARCHAR(255),
scope VARCHAR(255),
enabled TINYINT DEFAULT 1,
config_json JSON,
...时间戳字段...
);
```
#### 3.1.2 oauth2_account 表
记录用户与 OAuth2 提供者的账户关联:
```sql
CREATE TABLE oauth2_account (
id VARCHAR(36) PRIMARY KEY,
user_id VARCHAR(36),
provider_name VARCHAR(50),
remote_user_id VARCHAR(255),
remote_username VARCHAR(100),
remote_email VARCHAR(100),
access_token VARCHAR(1000),
refresh_token VARCHAR(1000),
token_expiry TIMESTAMP,
scope VARCHAR(255),
profile_data JSON,
linked_at TIMESTAMP,
last_login_at TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES sys_user(id)
);
```
#### 3.1.3 login_mode_config 表
存储登录模式配置(用于未来扩展):
```sql
CREATE TABLE login_mode_config (
id VARCHAR(36) PRIMARY KEY,
mode_name VARCHAR(50) UNIQUE,
mode_type VARCHAR(50),
display_name VARCHAR(100),
enabled TINYINT DEFAULT 1,
config_json JSON,
display_order INT DEFAULT 0,
...时间戳字段...
);
```
## 四、API 端点设计
### 4.1 本地登录(保持不变)
**请求:**
```bash
POST /api/v1/auth/login
Content-Type: application/json
{
"username": "user123",
"password": "password123"
}
```
**响应:**
```json
{
"code": 0,
"message": "登录成功",
"data": {
"token": "eyJhbGciOiJIUzI1NiIs...",
"expiresIn": 7200,
"tokenType": "Bearer"
}
}
```
### 4.2 OAuth2 授权端点
**请求:**
```bash
GET /api/v1/auth/oauth2/authorize?providerName=github
```
**处理流程:**
1. 后端获取 OAuth2 提供者配置
2. 构造授权 URL(包含 client_id、redirect_uri、scope 等)
3. **重定向用户到授权服务器**
4. 用户在授权服务器上进行身份验证
5. 授权服务器重定向到 redirect_uri(回调端点)
### 4.3 OAuth2 回调端点
**请求:**
```bash
GET /api/v1/auth/oauth2/callback?code=AUTH_CODE&state=STATE&providerName=github
```
**处理流程:**
1. 验证 state 参数(防 CSRF)
2. 使用授权码交换访问令牌
3. 从授权服务器获取用户信息
4. 自动创建或关联用户
5. 保存 OAuth2 账户信息
6. **重定向到前端**,传递 JWT Token
### 4.4 OAuth2 令牌交换端点(后端调用)
**请求:**
```bash
POST /api/v1/auth/oauth2/token
Content-Type: application/json
{
"authorizationCode": "AUTH_CODE",
"providerName": "github"
}
```
**响应:**
```json
{
"code": 0,
"message": "OAuth2 认证成功",
"data": {
"token": "eyJhbGciOiJIUzI1NiIs...",
"expiresIn": 7200,
"tokenType": "Bearer"
}
}
```
## 五、前端集成
### 5.1 登录页面改进
**支持的登录方式:**
1. 用户名/密码登录
2. OAuth2 授权(如 GitHub)
3. 支持显示可用的第三方登录选项
### 5.2 OAuth2 流程处理
**前端 auth.ts 中的新函数:**
```typescript
// 发起 OAuth2 授权
async function loginWithOAuth2(providerName: string)
// 处理 OAuth2 回调
function handleOAuth2Callback(): Promise<any>
// 使用授权码交换令牌
async function loginWithOAuth2Code(authCode: string, providerName: string)
```
### 5.3 回调处理
前端自动检测 URL 参数:
- `token`: JWT 令牌
- `method=oauth2`: 指示认证方式
- `error`: 错误信息
## 六、错误处理和日志
### 6.1 异常日志输出
所有异常处理中都包含详细的错误堆栈信息:
```java
// 示例
log.error("令牌交换失败: 提供者={}, statusCode={}, responseBody={}, 错误堆栈: ",
provider.getProviderName(), response.getStatusCode(), response.getBody(), e);
```
### 6.2 错误类型
- `RuntimeException`: 认证失败、用户不存在、密码错误等
- 具体错误消息会返回给前端用于用户反馈
## 七、安全考虑
### 7.1 密码安全
- 使用 BCryptPasswordEncoder 进行密码加密
- 生产环境不记录密码相关信息
- 开发环境支持跳过密码验证(便于测试)
### 7.2 令牌安全
- 使用 JWT Token(JWT 签名确保完整性)
- 令牌过期时间为 2 小时
- 刷新令牌过期时间为 7 天
### 7.3 OAuth2 安全
- 使用 state 参数防止 CSRF 攻击
- 客户端密钥存储在后端(不暴露给前端)
- 访问令牌存储在数据库中
- 刷新令牌存储在数据库中(用于令牌续期)
### 7.4 数据库安全
- OAuth2 账户与用户账户通过外键关联
- 逻辑删除标志防止数据硬删除
- 使用唯一约束防止重复关联
## 八、可扩展性设计
### 8.1 添加新的认证方式
要添加新的认证方式(如 LDAP),只需:
1. **创建新的策略类**
```java
@Component
public class LdapAuthenticationStrategy implements AuthenticationStrategy {
@Override
public boolean supports(String authMode) {
return AuthMode.LDAP.getCode().equals(authMode);
}
@Override
public String authenticate(Map<String, Object> credentials) {
// 实现 LDAP 认证逻辑
}
}
```
2. **注册到 Spring 容器**
- Spring 会自动注入到 UnifiedAuthService 的 List<AuthenticationStrategy>
3. **无需修改其他代码**
- 统一的接口确保兼容性
### 8.2 添加新的 OAuth2 提供者
只需在 `oauth2_provider` 表中插入配置记录:
```sql
INSERT INTO oauth2_provider (
id, provider_name, display_name, auth_type,
authorize_url, token_url, userinfo_url,
client_id, client_secret, redirect_uri, scope, enabled
) VALUES (
'uuid', 'google', 'Google 登录', 'authorization_code',
'https://accounts.google.com/o/oauth2/v2/auth',
'https://oauth2.googleapis.com/token',
'https://www.googleapis.com/oauth2/v2/userinfo',
'client_id', 'client_secret', 'http://localhost:5173/login', 'openid email profile', 1
);
```
## 九、测试
### 9.1 单元测试
- LocalAuthenticationStrategy 测试
- OAuth2AuthenticationStrategy 测试(使用 Mock)
- AuthConfigService 测试
### 9.2 集成测试
- UnifiedAuthServiceIntegrationTest
- 测试本地认证流程
- 测试错误处理
- 测试认证模式启用/禁用
- 测试令牌验证
## 十、部署建议
### 10.1 环境配置
**application.yml 配置示例:**
```yaml
hiagent:
jwt:
secret: ${JWT_SECRET:your-secret-key}
expiration: 7200000
```
### 10.2 数据库初始化
运行 schema.sql 初始化数据库表。
### 10.3 OAuth2 提供者配置
在应用启动前,在 `oauth2_provider` 表中配置需要的 OAuth2 提供者。
## 十一、总结
本架构设计实现了一个**灵活、可扩展、易于维护**的多种认证方式统一管理系统:
**支持多种认证模式**:本地认证、OAuth2、预留 LDAP、SAML 等
**易于扩展**:新增认证方式无需修改现有代码
**向后兼容**:原有的登录接口保持不变
**完整的错误日志**:便于问题定位和系统维护
**安全可靠**:加密存储、令牌管理、CSRF 防护等
**生产就绪**:包含完整的测试和文档
# OAuth2 快速配置指南
## 一、系统要求
- Java 17+
- Spring Boot 3.x
- MySQL 8.0+ 或 H2 数据库
- Redis(可选,用于缓存)
## 二、配置 OAuth2 提供者(以 GitHub 为例)
### 2.1 获取 OAuth2 凭证
访问 GitHub Developer Settings:
1. 登录 GitHub 账户
2. 进入 Settings → Developer settings → OAuth Apps
3. 点击 "New OAuth App"
4. 填写应用信息:
- **Application name**: HiAgent
- **Homepage URL**: http://localhost:8081
- **Authorization callback URL**: http://localhost:8081/api/v1/auth/oauth2/callback?providerName=github
5. 获取 **Client ID****Client Secret**
### 2.2 配置数据库
在数据库中插入 OAuth2 提供者配置:
```sql
INSERT INTO oauth2_provider (
id,
provider_name,
display_name,
description,
auth_type,
authorize_url,
token_url,
userinfo_url,
client_id,
client_secret,
redirect_uri,
scope,
enabled
) VALUES (
UUID(),
'github',
'GitHub 登录',
'GitHub OAuth2 认证',
'authorization_code',
'https://github.com/login/oauth/authorize',
'https://github.com/login/oauth/access_token',
'https://api.github.com/user',
'YOUR_CLIENT_ID_HERE',
'YOUR_CLIENT_SECRET_HERE',
'http://localhost:8081/api/v1/auth/oauth2/callback?providerName=github',
'user:email',
1
);
```
### 2.3 配置前端
在 Login.vue 中,OAuth2 提供者会自动显示在登录页面。
## 三、OAuth2 认证流程
### 3.1 用户点击 "GitHub 登录"
```
用户 → 前端 Login.vue
点击 GitHub 按钮
window.location.href = '/api/v1/auth/oauth2/authorize?providerName=github'
```
### 3.2 后端授权端点处理
```
GET /api/v1/auth/oauth2/authorize?providerName=github
获取 GitHub 配置
构造授权 URL(包含 client_id, redirect_uri, scope)
重定向到 GitHub 授权服务器
GitHub: https://github.com/login/oauth/authorize?client_id=xxx&redirect_uri=...
```
### 3.3 用户在 GitHub 上授权
用户在 GitHub 上验证身份并授权应用访问用户信息。
### 3.4 GitHub 回调
```
GitHub 重定向到:
http://localhost:8081/api/v1/auth/oauth2/callback?
code=AUTH_CODE&
state=STATE&
providerName=github
后端处理回调请求
```
### 3.5 后端令牌交换
```
后端收到授权码后:
1. 使用授权码向 GitHub 请求访问令牌
POST https://github.com/login/oauth/access_token
Content-Type: application/x-www-form-urlencoded
code=AUTH_CODE&
client_id=CLIENT_ID&
client_secret=CLIENT_SECRET&
redirect_uri=REDIRECT_URI
2. 使用访问令牌获取用户信息
GET https://api.github.com/user
Authorization: Bearer ACCESS_TOKEN
3. 查找或创建用户
├─ 检查是否存在 oauth2_account 关联
├─ 检查是否存在同邮箱的用户
└─ 否则创建新用户
4. 保存 OAuth2 账户关联
INSERT INTO oauth2_account ...
5. 生成 JWT Token
token = jwtUtil.generateToken(user.getId())
6. 重定向到前端
redirect('/login?token=JWT_TOKEN&method=oauth2')
```
### 3.6 前端处理回调
```
onMounted() {
检查 URL 参数
├─ 发现 token 和 method=oauth2
├─ 保存 token 到 localStorage
├─ 更新 authStore
└─ 重定向到 /agent/chat
}
```
## 四、配置其他 OAuth2 提供者
### 4.1 Google 登录
1. **获取凭证**
- 访问 [Google Cloud Console](https://console.cloud.google.com/)
- 创建 OAuth2 凭证
2. **配置数据库**
```sql
INSERT INTO oauth2_provider (
id, provider_name, display_name, auth_type,
authorize_url, token_url, userinfo_url,
client_id, client_secret, redirect_uri, scope, enabled
) VALUES (
UUID(), 'google', 'Google 登录', 'authorization_code',
'https://accounts.google.com/o/oauth2/v2/auth',
'https://oauth2.googleapis.com/token',
'https://www.googleapis.com/oauth2/v2/userinfo',
'YOUR_CLIENT_ID', 'YOUR_CLIENT_SECRET',
'http://localhost:8081/api/v1/auth/oauth2/callback?providerName=google',
'openid email profile', 1
);
```
### 4.2 Microsoft 登录
类似配置,使用 Microsoft Azure AD 的端点:
- authorize_url: `https://login.microsoftonline.com/common/oauth2/v2.0/authorize`
- token_url: `https://login.microsoftonline.com/common/oauth2/v2.0/token`
- userinfo_url: `https://graph.microsoft.com/v1.0/me`
## 五、测试 OAuth2 流程
### 5.1 本地测试环境
确保使用正确的回调 URL:
```
http://localhost:8081/api/v1/auth/oauth2/callback?providerName=github
```
### 5.2 测试步骤
1. 启动后端:`mvn spring-boot:run`
2. 启动前端:`npm run dev`
3. 访问 http://localhost:5173/login
4. 点击 "GitHub 登录" 按钮
5. 在 GitHub 上授权应用
6. 应自动重定向到 /agent/chat
## 六、故障排查
### 常见错误
| 错误 | 原因 | 解决方案 |
|------|------|--------|
| "未找到配置的 OAuth2 提供者" | 数据库中没有提供者配置 | 检查 oauth2_provider 表 |
| "令牌交换失败" | client_id/secret 错误或网络问题 | 检查凭证和日志 |
| "获取用户信息失败" | 访问令牌无效或 API 端点错误 | 检查 userinfo_url 和作用域 |
| "用户创建失败" | 用户名重复 | 检查 sys_user 表中的 username 唯一性 |
### 查看日志
所有错误都会记录详细的堆栈信息:
```bash
# 查看 ERROR 级别的日志
grep ERROR logs/hiagent.log
# 查看认证相关的日志
grep "OAuth2\|认证\|登录" logs/hiagent.log
```
## 七、安全建议
1. **保护 Client Secret**
- 不要在前端代码中暴露 client_secret
- 只在后端服务器上存储
2. **HTTPS**
- 生产环境必须使用 HTTPS
- 确保 redirect_uri 使用 HTTPS
3. **State 参数**
- 系统自动生成 state 参数防止 CSRF
- 用户不需要手动配置
4. **Scope 权限**
- 只请求必要的权限
- 定期审查和更新权限列表
## 八、参考资源
- [GitHub OAuth 文档](https://docs.github.com/en/developers/apps/building-oauth-apps)
- [Google OAuth 文档](https://developers.google.com/identity/protocols/oauth2)
- [OAuth2 标准](https://tools.ietf.org/html/rfc6749)
## 九、常见问题
**Q: 用户已使用 OAuth2 登录,现在想用用户名/密码登录?**
A: 系统支持同一用户使用多种认证方式。用户邮箱相同时会自动关联。
**Q: 如何撤销 OAuth2 账户关联?**
A: 手动删除 oauth2_account 表中的相应记录。
**Q: 能否强制用户只使用某种认证方式?**
A: 可以。在 AuthConfigService 中禁用不需要的认证模式。
---
**最后更新**:2024-12-15
**文档版本**:1.0
# 智能体(Agent)优化方案
## 1. 概述
本方案旨在提升系统中所有智能体的记忆功能支持能力,包括记忆存储能力和基于历史对话的多轮交互支持。通过实施以下优化措施,我们将显著改善用户体验和系统性能。
## 2. 当前状态分析
经过对现有系统的深入分析,我们发现以下问题:
1. **记忆存储能力不足**:当前系统使用内存存储对话历史,存在易失性问题,重启后数据丢失
2. **历史消息管理策略简单**:缺乏智能化的消息摘要机制,长对话可能导致性能下降
3. **RAG集成不完善**:缺少Agent级别的个性化配置,无法满足不同场景需求
4. **Agent功能单一**:各Agent工具集不够丰富,限制了其专业服务能力
## 3. 优化方案
### 3.1 增强记忆持久化机制
#### 3.1.1 实施方案
- 引入Redis作为外部存储,替代当前的内存存储方案
- 创建RedisChatMemory实现类,实现ChatMemory接口
- 修改ChatMemoryConfig配置类,使用RedisChatMemory替换InMemoryChatMemory
#### 3.1.2 预期效果
- 对话历史持久化存储,系统重启后数据不丢失
- 提升系统稳定性和可靠性
- 支持更大规模的并发用户访问
#### 3.1.3 开发步骤
1. 添加Redis依赖到pom.xml
2. 创建RedisConfig配置类,配置RedisTemplate
3. 实现RedisChatMemory类,完成对话历史的存取逻辑
4. 修改ChatMemoryConfig,注入RedisChatMemory实例
5. 编写单元测试验证功能正确性
### 3.2 优化历史消息管理策略
#### 3.2.1 实施方案
- 实现智能历史消息摘要算法(SmartHistorySummarizer)
- 在AgentChatService中集成摘要功能,在构建Prompt时自动应用
- 识别并保留关键信息,如代码片段、技术术语、数字等
#### 3.2.2 预期效果
- 有效控制Prompt长度,避免超出LLM上下文窗口限制
- 保留对话中的关键信息,维持对话连贯性
- 提升系统响应速度和处理效率
#### 3.2.3 开发步骤
1. 创建SmartHistorySummarizer类,实现智能摘要算法
2. 在AgentChatService中集成摘要功能
3. 调整Prompt构建逻辑,应用历史消息摘要
4. 测试不同场景下的摘要效果
### 3.3 完善RAG集成功能
#### 3.3.1 实施方案
- 扩展Agent模型,增加RAG相关配置字段(ragTopK, ragScoreThreshold, ragPromptTemplate)
- 修改RagService,支持使用Agent特定的RAG配置
- 为各Agent配置相应的知识库,提高专业领域问答准确性
#### 3.3.2 预期效果
- 实现Agent级别的RAG个性化配置
- 提升各Agent在专业领域的问答准确性
- 增强系统的灵活性和可扩展性
#### 3.3.3 开发步骤
1. 修改Agent模型,添加RAG配置字段
2. 更新数据库schema和初始数据
3. 修改RagService,支持Agent特定配置
4. 为各Agent创建和配置专属知识库
5. 测试RAG功能在各Agent中的表现
### 3.4 各Agent功能优化
#### 3.4.1 客服助手
- 添加订单查询工具(OrderQueryTool)
- 添加退款处理工具(RefundProcessingTool)
#### 3.4.2 技术支持
- 添加技术文档检索工具
- 添加代码解释工具
#### 3.4.3 数据分析师
- 添加图表生成工具
- 添加统计计算工具
#### 3.4.4 内容创作助手
- 添加创作风格参考工具
- 添加文档模板工具
#### 3.4.5 学习导师
- 添加学习计划制定工具
- 添加课程资料检索工具
#### 3.4.6 预期效果
- 各Agent具备更强的专业服务能力
- 提升用户满意度和问题解决率
- 增强系统的实用性和商业价值
#### 3.4.7 开发步骤
1. 为每个Agent分析所需工具类型
2. 创建相应的工具实现类
3. 更新Agent配置,关联相应工具
4. 测试各Agent的新功能
## 4. 实施计划
| 阶段 | 任务 | 预期完成时间 |
|------|------|--------------|
| 第一阶段 | 增强记忆持久化机制 | 3天 |
| 第二阶段 | 优化历史消息管理策略 | 2天 |
| 第三阶段 | 完善RAG集成功能 | 3天 |
| 第四阶段 | 各Agent功能优化 | 5天 |
## 5. 风险评估与应对措施
1. **Redis性能瓶颈**:监控Redis使用情况,必要时进行集群部署
2. **摘要算法效果不佳**:持续优化算法,引入机器学习模型
3. **RAG检索准确性不足**:优化向量索引参数,调整相似度阈值
4. **Agent工具集成问题**:建立完善的测试机制,确保工具稳定性
## 6. 验收标准
1. 所有对话历史能够持久化存储,系统重启后可恢复
2. 长对话场景下系统响应时间无明显增长
3. 各Agent在专业领域问答准确率提升30%以上
4. 用户满意度调查得分提升20%以上
\ No newline at end of file
spring:
application:
name: hiagent
# 数据源配置
datasource:
url: jdbc:h2:mem:hiagent;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE
driver-class-name: org.h2.Driver
username: sa
password:
hikari:
maximum-pool-size: 10
minimum-idle: 2
connection-timeout: 30000
# 禁用Milvus自动配置
autoconfigure:
exclude:
- org.springframework.ai.autoconfigure.vectorstore.milvus.MilvusVectorStoreAutoConfiguration
# SQL初始化配置
sql:
init:
schema-locations: classpath:schema.sql
data-locations: classpath:data.sql
mode: always
# JPA/Hibernate配置
jpa:
database-platform: org.hibernate.dialect.H2Dialect
hibernate:
ddl-auto: create-drop
show-sql: false
properties:
hibernate:
format_sql: true
# H2 Console配置(仅开发环境)
h2:
console:
enabled: true
path: /h2-console
# Redis配置
data:
redis:
host: localhost
port: 6379
password:
timeout: 2000
database: 0
lettuce:
pool:
max-active: 8
max-idle: 8
min-idle: 0
max-wait: -1
# RabbitMQ配置
rabbitmq:
host: localhost
port: 5672
username: guest
password: guest
virtual-host: /
connection-timeout: 15000
# Jackson配置
jackson:
serialization:
write-dates-as-timestamps: false
deserialization:
fail-on-unknown-properties: false
default-property-inclusion: non_null
# Web配置
web:
resources:
add-mappings: true
# servlet配置
servlet:
multipart:
max-file-size: 100MB
max-request-size: 100MB
# 默认性异步请求配置
mvc:
async:
request-timeout: 600000 # 10分钟,与SSE保持一致
# Spring AI配置
ai:
openai:
enabled: false
ollama:
enabled: false
# MyBatis Plus配置
mybatis-plus:
type-aliases-package: pangea.hiagent.model
mapper-locations: classpath:mapper/*.xml
configuration:
map-underscore-to-camel-case: true
log-impl: org.apache.ibatis.logging.slf4j.Slf4jImpl
cache-enabled: true
use-generated-keys: true
global-config:
db-config:
id-type: assign_uuid
table-underline: true
logic-delete-field: deleted
logic-delete-value: 1
logic-not-delete-value: 0
# Logging配置
logging:
level:
root: INFO
pangea.hiagent: DEBUG
org.springframework: INFO
org.springframework.security: DEBUG
pattern:
console: "%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n"
file: "%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n"
file:
name: logs/hiagent.log
max-size: 10MB
max-history: 30
charset:
console: UTF-8
file: UTF-8
# Server配置
server:
port: 8080
servlet:
context-path: /
compression:
enabled: true
min-response-size: 1024
# SSE和异步请求超时配置
request-timeout: 600000 # 10分钟(毫秒)
# Undertow配置
undertow:
# IO线程数,默认为处理器数量
io-threads: 4
# 工作线程数
worker-threads: 20
# 缓冲区配置
buffer-size: 1024
# 是否直接分配缓冲区
direct-buffers: true
# HTTP/2支持
enable-http2: true
# 最大HTTP头大小
max-http-header-size: 8192
# 最大参数数量
max-parameters: 1000
# 最大请求头数量
max-headers: 200
# 最大cookies数量
max-cookies: 100
# URL编码字符集
url-charset: UTF-8
# 访问日志配置
accesslog:
enabled: false
pattern: common
dir: logs
prefix: access_log.
# SSL配置
ssl:
# SSL引擎
engine:
# 密码套件
enabled-protocols: TLSv1.2,TLSv1.3
# 应用自定义配置
hiagent:
# JWT配置
jwt:
secret: ${JWT_SECRET:hiagent-secret-key-for-production-change-this}
expiration: 7200000 # 2小时
refresh-expiration: 604800000 # 7天
# Agent配置
agent:
default-model: deepseek-chat
default-temperature: 0.7
default-max-tokens: 4096
history-length: 10
# LLM配置
llm:
providers:
deepseek:
default-api-key: ${DEEPSEEK_API_KEY:}
default-model: deepseek-chat
base-url: https://api.deepseek.com
openai:
default-api-key: ${OPENAI_API_KEY:}
default-model: gpt-3.5-turbo
base-url: https://api.openai.com/v1
ollama:
default-model: llama2
base-url: http://localhost:11434
# RAG配置
rag:
chunk-size: 512
chunk-overlap: 50
top-k: 5
score-threshold: 0.8
# Milvus Lite配置
milvus:
data-dir: ./milvus_data
db-name: hiagent
collection-name: document_embeddings
# ChatMemory配置
app:
chat-memory:
# 实现类型: caffeine, redis, hybrid
implementation: caffeine
caffeine:
# 是否启用Caffeine缓存
enabled: true
redis:
# 是否启用Redis缓存
enabled: false
\ No newline at end of file
-- 插入默认数据 -- 插入默认数据
INSERT INTO sys_user (id, username, password, email, nickname, status, role) VALUES
('default-user-id', 'admin', '$2a$10$N.zmdr9k7uOCQb0bta/OauRxaOKSr.QhqyD2R5FKvMQjmHoLkm5Sy', 'admin@hiagent.com', 'Admin', 'active', 'admin'); -- 插入默认用户数据
MERGE INTO sys_user (id, username, password, email, nickname, status, role) VALUES
INSERT INTO agent (id, name, description, status, default_model, owner) VALUES ('user-001', 'admin', '$2a$10$N.zmdr9k7uOCQb0bta/OauRxaOKSr.QhqyD2R5FKvMQjmHoLkm5Sy', 'admin@hiagent.com', 'Admin', 'active', 'admin');
('default-agent-1', '客服助手', '处理客户咨询的AI助手', 'active', 'deepseek-chat', 'default-user-id'),
('default-agent-2', '技术支持', '提供技术支持服务的AI助手', 'active', 'gpt-3.5-turbo', 'default-user-id'); -- 插入默认LLM配置数据
MERGE INTO llm_config (id, name, description, provider, model_name, api_key, base_url, temperature, max_tokens, top_p, enabled, owner) VALUES
INSERT INTO tool (id, name, display_name, description, category, status, timeout, http_method) VALUES ('deepseek-default', 'deepseek-default', 'DeepSeek默认配置', 'deepseek', 'deepseek-chat', '', 'https://api.deepseek.com', 0.7, 4096, 0.9, true, 'user-001'),
('default-tool-1', 'search', '搜索工具', '进行网络搜索查询', 'API', 'active', 30000, 'GET'), ('openai-default', 'openai-default', 'OpenAI默认配置', 'openai', 'gpt-3.5-turbo', '', 'https://api.openai.com/v1', 0.7, 4096, 0.9, false, 'user-001'),
('default-tool-2', 'calculator', '计算器', '进行数学计算', 'FUNCTION', 'active', 5000, 'POST'), ('ollama-default', 'ollama-default', 'Ollama默认配置', 'ollama', 'llama2', '', 'http://localhost:11434', 0.7, 4096, 0.9, true, 'user-001'),
('default-tool-3', 'weather', '天气查询', '查询天气信息', 'API', 'active', 10000, 'GET'), ('hisense-default', 'hisense-default', 'Hisense默认配置', 'hisense', 'gpt-4-1', '', 'http://openai-proxy-v2-jt-higpt.cloudprd.hisense.com', 0.7, 4096, 0.9, true, 'user-001');
('default-tool-4', 'get_current_time', '获取当前时间', '获取当前系统时间', 'FUNCTION', 'active', 1000, 'GET');
\ No newline at end of file -- 插入默认Agent数据
MERGE INTO agent (id, name, description, status, default_model, owner, system_prompt, enable_re_act, history_length, enable_rag, rag_collection_id, rag_top_k, rag_score_threshold, tools) VALUES
('agent-1', '客服助手', '处理客户咨询的AI助手', 'active', 'deepseek-default', 'user-001', '你是一个专业的客服助手,请用友好和专业的态度回答客户的问题。', 1, 15, 1, 'customer-service-kb', 5, 0.8, '["search", "orderQuery", "refundProcessing"]'),
('agent-2', '技术支持', '提供技术支持服务的AI助手', 'active', 'openai-default', 'user-001', '你是一个技术专家,请帮助用户解决技术问题。', 1, 15, 1, 'technical-support-kb', 5, 0.8, '["search", "calculator", "technicalDocumentationRetrieval", "technicalCodeExplanation"]'),
('agent-3', '数据分析员', '专业的数据分析AI助手', 'active', 'deepseek-default', 'user-001', '你是一个数据分析专家,擅长处理和分析各种数据。', 0, 15, 1, 'data-analysis-kb', 5, 0.8, '["calculator", "chartGeneration", "statisticalCalculation"]'),
('agent-4', '内容创作助手', '帮助撰写各类文案的AI助手', 'active', 'hisense-default', 'user-001', '你是一个创意写作专家,能够帮助用户创作各种类型的文案。', 0, 15, 1, 'content-creation-kb', 5, 0.8, '["search", "writingStyleReference", "documentTemplate"]'),
('agent-5', '学习导师', '个性化学习指导AI助手', 'active', 'hisense-default', 'user-001', '你是一个教育专家,能够根据用户需求提供个性化的学习建议。', 1, 15, 1, 'learning-mentor-kb', 5, 0.8, '["search", "studyPlanGeneration", "courseMaterialRetrieval"]');
-- 插入默认工具数据
MERGE INTO tool (id, name, display_name, description, category, status, timeout, http_method) VALUES
('tool-1', 'search', '搜索工具', '进行网络搜索查询', 'API', 'active', 30000, 'GET'),
('tool-2', 'calculator', '计算器', '进行数学计算', 'FUNCTION', 'active', 5000, 'POST'),
('tool-3', 'weather', '天气查询', '查询天气信息', 'API', 'active', 10000, 'GET'),
('tool-4', 'get_current_time', '获取当前时间', '获取当前系统时间', 'FUNCTION', 'active', 1000, 'GET'),
('tool-5', 'technicalDocumentationRetrieval', '技术文档检索', '检索和查询技术文档内容', 'FUNCTION', 'active', 10000, 'GET'),
('tool-6', 'technicalCodeExplanation', '技术代码解释', '分析和解释技术代码的功能和实现逻辑', 'FUNCTION', 'active', 10000, 'GET'),
('tool-7', 'chartGeneration', '图表生成', '根据数据生成各种类型的图表', 'FUNCTION', 'active', 10000, 'GET'),
('tool-8', 'statisticalCalculation', '统计计算', '执行各种统计分析计算', 'FUNCTION', 'active', 10000, 'GET'),
('tool-9', 'writingStyleReference', '创作风格参考', '提供各种写作风格的参考和指导', 'FUNCTION', 'active', 10000, 'GET'),
('tool-10', 'documentTemplate', '文档模板', '提供各种类型的文档模板', 'FUNCTION', 'active', 10000, 'GET'),
('tool-11', 'studyPlanGeneration', '学习计划制定', '根据学习目标和时间安排制定个性化的学习计划', 'FUNCTION', 'active', 10000, 'GET'),
('tool-12', 'courseMaterialRetrieval', '课程资料检索', '检索和查询相关课程资料', 'FUNCTION', 'active', 10000, 'GET');
\ No newline at end of file
...@@ -21,26 +21,94 @@ CREATE TABLE IF NOT EXISTS sys_user ( ...@@ -21,26 +21,94 @@ CREATE TABLE IF NOT EXISTS sys_user (
); );
CREATE INDEX IF NOT EXISTS idx_username ON sys_user (username); CREATE INDEX IF NOT EXISTS idx_username ON sys_user (username);
-- Agent -- OAuth2 提供者配置
CREATE TABLE IF NOT EXISTS agent ( CREATE TABLE IF NOT EXISTS oauth2_provider (
id varchar(36) NOT NULL, id varchar(36) NOT NULL,
name varchar(100) NOT NULL, provider_name varchar(50) NOT NULL UNIQUE,
display_name varchar(100),
description text, description text,
status varchar(20) DEFAULT 'active', auth_type varchar(50) NOT NULL DEFAULT 'authorization_code',
default_model varchar(50), authorize_url varchar(255),
system_prompt text, token_url varchar(255),
prompt_template text, userinfo_url varchar(255),
client_id varchar(255),
client_secret varchar(255),
redirect_uri varchar(255),
scope varchar(255),
enabled tinyint DEFAULT 1,
config_json json,
created_at timestamp DEFAULT CURRENT_TIMESTAMP,
updated_at timestamp DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
created_by varchar(36),
updated_by varchar(36),
deleted int DEFAULT 0,
remark text,
PRIMARY KEY (id),
UNIQUE (provider_name)
);
CREATE INDEX IF NOT EXISTS idx_oauth2_enabled ON oauth2_provider (enabled);
CREATE INDEX IF NOT EXISTS idx_oauth2_auth_type ON oauth2_provider (auth_type);
-- OAuth2 账户关联表
CREATE TABLE IF NOT EXISTS oauth2_account (
id varchar(36) NOT NULL,
user_id varchar(36) NOT NULL,
provider_name varchar(50) NOT NULL,
remote_user_id varchar(255) NOT NULL,
remote_username varchar(100),
remote_email varchar(100),
access_token varchar(1000),
refresh_token varchar(1000),
token_expiry timestamp,
scope varchar(255),
profile_data json,
linked_at timestamp DEFAULT CURRENT_TIMESTAMP,
last_login_at timestamp,
created_at timestamp DEFAULT CURRENT_TIMESTAMP,
updated_at timestamp DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
deleted int DEFAULT 0,
PRIMARY KEY (id),
UNIQUE (provider_name, remote_user_id),
UNIQUE (user_id, provider_name),
FOREIGN KEY (user_id) REFERENCES sys_user(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_oauth2_user_id ON oauth2_account (user_id);
CREATE INDEX IF NOT EXISTS idx_oauth2_provider_name ON oauth2_account (provider_name);
CREATE INDEX IF NOT EXISTS idx_oauth2_remote_user_id ON oauth2_account (remote_user_id);
-- 登录模式配置表
CREATE TABLE IF NOT EXISTS login_mode_config (
id varchar(36) NOT NULL,
mode_name varchar(50) NOT NULL UNIQUE,
mode_type varchar(50) NOT NULL,
display_name varchar(100),
description text,
enabled tinyint DEFAULT 1,
config_json json,
display_order int DEFAULT 0,
created_at timestamp DEFAULT CURRENT_TIMESTAMP,
updated_at timestamp DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
created_by varchar(36),
updated_by varchar(36),
deleted int DEFAULT 0,
PRIMARY KEY (id),
UNIQUE (mode_name)
);
CREATE INDEX IF NOT EXISTS idx_login_mode_enabled ON login_mode_config (enabled);
-- LLM配置表
CREATE TABLE IF NOT EXISTS llm_config (
id varchar(36) NOT NULL,
name varchar(100) NOT NULL UNIQUE,
description text,
provider varchar(50),
model_name varchar(100),
api_key varchar(255),
base_url varchar(255),
temperature decimal(3,2) DEFAULT 0.7, temperature decimal(3,2) DEFAULT 0.7,
max_tokens int DEFAULT 4096, max_tokens int DEFAULT 4096,
top_p decimal(3,2) DEFAULT 0.9, top_p decimal(3,2) DEFAULT 0.9,
top_k int DEFAULT 50, enabled tinyint DEFAULT 1,
presence_penalty decimal(3,2) DEFAULT 0,
frequency_penalty decimal(3,2) DEFAULT 0,
history_length int DEFAULT 10,
tools json,
rag_collection_id varchar(36),
enable_rag tinyint DEFAULT 0,
enable_react tinyint DEFAULT 0,
owner varchar(36), owner varchar(36),
created_at timestamp DEFAULT CURRENT_TIMESTAMP, created_at timestamp DEFAULT CURRENT_TIMESTAMP,
updated_at timestamp DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, updated_at timestamp DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
...@@ -50,23 +118,34 @@ CREATE TABLE IF NOT EXISTS agent ( ...@@ -50,23 +118,34 @@ CREATE TABLE IF NOT EXISTS agent (
remark text, remark text,
PRIMARY KEY (id) PRIMARY KEY (id)
); );
CREATE INDEX IF NOT EXISTS idx_owner ON agent (owner); CREATE INDEX IF NOT EXISTS idx_enabled ON llm_config (enabled);
CREATE INDEX IF NOT EXISTS idx_provider ON llm_config (provider);
CREATE INDEX IF NOT EXISTS idx_name ON llm_config (name);
-- 工具 -- Agent
CREATE TABLE IF NOT EXISTS tool ( CREATE TABLE IF NOT EXISTS agent (
id varchar(36) NOT NULL, id varchar(36) NOT NULL,
name varchar(100) NOT NULL, name varchar(100) NOT NULL,
display_name varchar(100),
description text, description text,
category varchar(50),
status varchar(20) DEFAULT 'active', status varchar(20) DEFAULT 'active',
parameters json, default_model varchar(100),
return_type varchar(50), system_prompt text,
return_schema json, prompt_template text,
implementation text, temperature decimal(3,2) DEFAULT 0.7,
timeout bigint, max_tokens int DEFAULT 4096,
api_endpoint varchar(255), top_p decimal(3,2) DEFAULT 0.9,
http_method varchar(20), top_k int DEFAULT 50,
presence_penalty decimal(3,2) DEFAULT 0,
frequency_penalty decimal(3,2) DEFAULT 0,
history_length int DEFAULT 10,
tools json,
rag_collection_id varchar(36),
rag_top_k int DEFAULT 5,
rag_score_threshold decimal(3,2) DEFAULT 0.8,
rag_prompt_template text,
enable_rag tinyint DEFAULT 0,
enable_re_act tinyint DEFAULT 0,
enable_streaming tinyint DEFAULT 1,
owner varchar(36), owner varchar(36),
created_at timestamp DEFAULT CURRENT_TIMESTAMP, created_at timestamp DEFAULT CURRENT_TIMESTAMP,
updated_at timestamp DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, updated_at timestamp DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
...@@ -74,8 +153,12 @@ CREATE TABLE IF NOT EXISTS tool ( ...@@ -74,8 +153,12 @@ CREATE TABLE IF NOT EXISTS tool (
updated_by varchar(36), updated_by varchar(36),
deleted int DEFAULT 0, deleted int DEFAULT 0,
remark text, remark text,
PRIMARY KEY (id) PRIMARY KEY (id),
FOREIGN KEY (default_model) REFERENCES llm_config(name)
); );
CREATE INDEX IF NOT EXISTS idx_owner ON agent (owner);
CREATE INDEX IF NOT EXISTS idx_status ON agent (status);
CREATE INDEX IF NOT EXISTS idx_created_at ON agent (created_at);
-- 文档表 -- 文档表
CREATE TABLE IF NOT EXISTS document ( CREATE TABLE IF NOT EXISTS document (
...@@ -122,6 +205,7 @@ CREATE TABLE IF NOT EXISTS document_chunk ( ...@@ -122,6 +205,7 @@ CREATE TABLE IF NOT EXISTS document_chunk (
PRIMARY KEY (id) PRIMARY KEY (id)
); );
CREATE INDEX IF NOT EXISTS idx_document_id ON document_chunk (document_id); CREATE INDEX IF NOT EXISTS idx_document_id ON document_chunk (document_id);
CREATE INDEX IF NOT EXISTS idx_vector_id ON document_chunk (vector_id);
-- Agent对话表 -- Agent对话表
CREATE TABLE IF NOT EXISTS agent_dialogue ( CREATE TABLE IF NOT EXISTS agent_dialogue (
...@@ -147,6 +231,8 @@ CREATE TABLE IF NOT EXISTS agent_dialogue ( ...@@ -147,6 +231,8 @@ CREATE TABLE IF NOT EXISTS agent_dialogue (
); );
CREATE INDEX IF NOT EXISTS idx_agent_id ON agent_dialogue (agent_id); CREATE INDEX IF NOT EXISTS idx_agent_id ON agent_dialogue (agent_id);
CREATE INDEX IF NOT EXISTS idx_user_id ON agent_dialogue (user_id); CREATE INDEX IF NOT EXISTS idx_user_id ON agent_dialogue (user_id);
CREATE INDEX IF NOT EXISTS idx_context_id ON agent_dialogue (context_id);
CREATE INDEX IF NOT EXISTS idx_created_at ON agent_dialogue (created_at);
-- 系统日志表 -- 系统日志表
CREATE TABLE IF NOT EXISTS sys_log ( CREATE TABLE IF NOT EXISTS sys_log (
...@@ -170,19 +256,30 @@ CREATE TABLE IF NOT EXISTS sys_log ( ...@@ -170,19 +256,30 @@ CREATE TABLE IF NOT EXISTS sys_log (
remark text, remark text,
PRIMARY KEY (id) PRIMARY KEY (id)
); );
-- LLM配置表 CREATE INDEX IF NOT EXISTS idx_user_id ON sys_log (user_id);
CREATE TABLE IF NOT EXISTS llm_config ( CREATE INDEX IF NOT EXISTS idx_created_at ON sys_log (created_at);
CREATE INDEX IF NOT EXISTS idx_operation_type ON sys_log (operation_type);
CREATE INDEX IF NOT EXISTS idx_resource_type ON sys_log (resource_type);
CREATE INDEX IF NOT EXISTS idx_success ON sys_log (success);
-- 工具表
CREATE TABLE IF NOT EXISTS tool (
id varchar(36) NOT NULL, id varchar(36) NOT NULL,
name varchar(100) NOT NULL UNIQUE, name varchar(100) NOT NULL,
display_name varchar(100),
description text, description text,
provider varchar(50), category varchar(50),
model_name varchar(100), status varchar(20) DEFAULT 'active',
api_key varchar(255), parameters json,
base_url varchar(255), return_type varchar(50),
temperature decimal(3,2) DEFAULT 0.7, return_schema json,
max_tokens int DEFAULT 4096, implementation text,
top_p decimal(3,2) DEFAULT 0.9, timeout bigint,
enabled tinyint DEFAULT 1, api_endpoint varchar(255),
http_method varchar(20),
headers json,
auth_type varchar(50),
auth_config json,
owner varchar(36), owner varchar(36),
created_at timestamp DEFAULT CURRENT_TIMESTAMP, created_at timestamp DEFAULT CURRENT_TIMESTAMP,
updated_at timestamp DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, updated_at timestamp DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
...@@ -191,7 +288,4 @@ CREATE TABLE IF NOT EXISTS llm_config ( ...@@ -191,7 +288,4 @@ CREATE TABLE IF NOT EXISTS llm_config (
deleted int DEFAULT 0, deleted int DEFAULT 0,
remark text, remark text,
PRIMARY KEY (id) PRIMARY KEY (id)
); );
\ No newline at end of file
CREATE INDEX IF NOT EXISTS idx_user_id ON sys_log (user_id);
CREATE INDEX IF NOT EXISTS idx_created_at ON sys_log (created_at);
\ No newline at end of file
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>DOM同步测试页面</title>
<style>
body {
font-family: Arial, sans-serif;
margin: 20px;
background-color: #f5f5f5;
}
.container {
max-width: 800px;
margin: 0 auto;
background-color: white;
padding: 20px;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
}
h1 {
color: #333;
text-align: center;
}
.form-group {
margin-bottom: 15px;
}
label {
display: block;
margin-bottom: 5px;
font-weight: bold;
}
input[type="text"] {
width: 100%;
padding: 10px;
border: 1px solid #ddd;
border-radius: 4px;
box-sizing: border-box;
}
button {
background-color: #007bff;
color: white;
padding: 10px 20px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 16px;
}
button:hover {
background-color: #0056b3;
}
.result {
margin-top: 20px;
padding: 15px;
background-color: #e9ecef;
border-radius: 4px;
}
.success {
color: #155724;
background-color: #d4edda;
border: 1px solid #c3e6cb;
}
.error {
color: #721c24;
background-color: #f8d7da;
border: 1px solid #f5c6cb;
}
</style>
</head>
<body>
<div class="container">
<h1>DOM同步功能测试</h1>
<div class="form-group">
<label for="testUrl">测试URL:</label>
<input type="text" id="testUrl" placeholder="请输入要测试的URL,例如: https://www.baidu.com" value="https://www.baidu.com">
</div>
<button onclick="testDomSync()">测试DOM同步功能</button>
<div id="result" class="result" style="display: none;"></div>
</div>
<script>
function testDomSync() {
const testUrl = document.getElementById('testUrl').value;
const resultDiv = document.getElementById('result');
// 隐藏结果区域
resultDiv.style.display = 'none';
// 基本URL验证
if (!testUrl || testUrl.trim() === '') {
showResult('请输入有效的URL', 'error');
return;
}
if (!testUrl.toLowerCase().startsWith('http://') && !testUrl.toLowerCase().startsWith('https://')) {
showResult('URL必须以http://或https://开头', 'error');
return;
}
// 显示测试中信息
showResult('正在测试DOM同步功能...', '');
// 这里应该实际测试DOM同步功能
// 由于这是一个静态页面,我们只能模拟测试结果
setTimeout(() => {
// 模拟测试结果
const isSuccess = Math.random() > 0.3; // 70%成功率
if (isSuccess) {
showResult(`DOM同步功能测试成功!URL "${testUrl}" 可以正常访问。`, 'success');
} else {
showResult(`DOM同步功能测试失败!无法访问URL "${testUrl}",请检查URL格式或网络连接。`, 'error');
}
}, 1500);
}
function showResult(message, type) {
const resultDiv = document.getElementById('result');
resultDiv.innerText = message;
resultDiv.className = 'result ' + type;
resultDiv.style.display = 'block';
}
</script>
</body>
</html>
\ No newline at end of file
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>DOM同步测试页面</title>
<style>
body {
font-family: Arial, sans-serif;
margin: 20px;
}
.container {
max-width: 800px;
margin: 0 auto;
}
.form-group {
margin-bottom: 15px;
}
label {
display: block;
margin-bottom: 5px;
font-weight: bold;
}
input, select, textarea, button {
width: 100%;
padding: 8px;
box-sizing: border-box;
}
.button-group {
display: flex;
gap: 10px;
}
.button-group button {
flex: 1;
}
.dynamic-content {
background-color: #f0f0f0;
padding: 15px;
margin-top: 20px;
border-radius: 5px;
}
.highlight {
background-color: yellow;
padding: 2px 5px;
}
</style>
</head>
<body>
<div class="container">
<h1>DOM同步测试页面</h1>
<div class="form-group">
<label for="username">用户名:</label>
<input type="text" id="username" name="username" placeholder="请输入用户名">
</div>
<div class="form-group">
<label for="email">邮箱:</label>
<input type="email" id="email" name="email" placeholder="请输入邮箱地址">
</div>
<div class="form-group">
<label for="country">国家:</label>
<select id="country" name="country">
<option value="">请选择国家</option>
<option value="cn">中国</option>
<option value="us">美国</option>
<option value="uk">英国</option>
<option value="jp">日本</option>
</select>
</div>
<div class="form-group">
<label for="message">留言:</label>
<textarea id="message" name="message" rows="4" placeholder="请输入您的留言"></textarea>
</div>
<div class="form-group">
<label>
<input type="checkbox" id="newsletter" name="newsletter">
订阅新闻邮件
</label>
</div>
<div class="button-group">
<button id="submitBtn" type="button">提交</button>
<button id="resetBtn" type="button">重置</button>
</div>
<div class="dynamic-content">
<h2>动态内容区域</h2>
<p>这个区域的内容会动态变化。</p>
<div id="dynamicText">初始文本内容</div>
<button id="changeTextBtn">改变文本</button>
</div>
</div>
<script>
// 提交按钮事件
document.getElementById('submitBtn').addEventListener('click', function() {
alert('表单已提交!');
});
// 重置按钮事件
document.getElementById('resetBtn').addEventListener('click', function() {
document.getElementById('username').value = '';
document.getElementById('email').value = '';
document.getElementById('country').value = '';
document.getElementById('message').value = '';
document.getElementById('newsletter').checked = false;
});
// 改变文本按钮事件
document.getElementById('changeTextBtn').addEventListener('click', function() {
const dynamicText = document.getElementById('dynamicText');
dynamicText.textContent = '文本已更新:' + new Date().toLocaleTimeString();
dynamicText.classList.add('highlight');
// 3秒后移除高亮
setTimeout(() => {
dynamicText.classList.remove('highlight');
}, 3000);
});
// 输入框事件
document.getElementById('username').addEventListener('input', function() {
console.log('用户名输入:', this.value);
});
// 鼠标悬停事件
document.getElementById('submitBtn').addEventListener('mouseenter', function() {
this.style.backgroundColor = '#007bff';
this.style.color = 'white';
});
document.getElementById('submitBtn').addEventListener('mouseleave', function() {
this.style.backgroundColor = '';
this.style.color = '';
});
</script>
</body>
</html>
\ No newline at end of file
<template>
<div id="app">
<!-- 如果是登录页,直接显示 -->
<div v-if="isLoginPage" class="login-container">
<router-view />
</div>
<!-- 其他页面使用新的布局 -->
<div v-else class="main-layout">
<!-- 顶部导航栏 -->
<TopNavbar />
<!-- 主要内容区域 -->
<div class="content-wrapper">
<router-view />
</div>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
import { useRoute } from 'vue-router'
import TopNavbar from '@/components/TopNavbar.vue'
const route = useRoute()
const isLoginPage = computed(() => {
return route.path === '/login' || route.path === '/register'
})
</script>
<style scoped>
#app {
height: 100vh;
display: flex;
flex-direction: column;
}
.login-container {
width: 100%;
height: 100%;
}
.main-layout {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
}
.content-wrapper {
flex: 1;
overflow: hidden;
padding: 0;
background-color: var(--bg-secondary);
}
/* 响应式设计 */
@media (max-width: 768px) {
.main-layout {
height: 100vh;
}
.content-wrapper {
padding: 0;
}
}
@media (max-width: 576px) {
#app {
height: 100vh;
}
.main-layout {
height: 100vh;
}
}
</style>
\ No newline at end of file
declare module '@/App.vue' {
import { DefineComponent } from 'vue'
const component: DefineComponent<{}, {}, any>
export default component
}
declare module '@/pages/Login.vue' {
import { DefineComponent } from 'vue'
const component: DefineComponent<{}, {}, any>
export default component
}
declare module '@/pages/Register.vue' {
import { DefineComponent } from 'vue'
const component: DefineComponent<{}, {}, any>
export default component
}
declare module '@/pages/Dashboard.vue' {
import { DefineComponent } from 'vue'
const component: DefineComponent<{}, {}, any>
export default component
}
declare module '@/pages/AgentManagement.vue' {
import { DefineComponent } from 'vue'
const component: DefineComponent<{}, {}, any>
export default component
}
declare module '@/pages/DocumentManagement.vue' {
import { DefineComponent } from 'vue'
const component: DefineComponent<{}, {}, any>
export default component
}
declare module '@/pages/MemoryManagement.vue' {
import { DefineComponent } from 'vue'
const component: DefineComponent<{}, {}, any>
export default component
}
declare module '@/pages/LlmConfigManagement.vue' {
import { DefineComponent } from 'vue'
const component: DefineComponent<{}, {}, any>
export default component
}
declare module '@/pages/ToolManagement.vue' {
import { DefineComponent } from 'vue'
const component: DefineComponent<{}, {}, any>
export default component
}
declare module '@/pages/ChatPage.vue' {
import { DefineComponent } from 'vue'
const component: DefineComponent<{}, {}, any>
export default component
}
declare module '@/components/MessageItem.vue' {
import { DefineComponent } from 'vue'
const component: DefineComponent<{}, {}, any>
export default component
}
declare module '@/components/ChatArea.vue' {
import { DefineComponent } from 'vue'
const component: DefineComponent<{}, {}, any>
export default component
}
declare module '@/components/TimelinePanel.vue' {
import { DefineComponent } from 'vue'
const component: DefineComponent<{}, {}, any>
export default component
}
declare module '@/components/WebpageBrowser.vue' {
import { DefineComponent } from 'vue'
const component: DefineComponent<{}, {}, any>
export default component
}
declare module '@/components/WorkArea.vue' {
import { DefineComponent } from 'vue'
const component: DefineComponent<{}, {}, any>
export default component
}
\ No newline at end of file
<template>
<div class="document-preview">
<div class="preview-header">
<span class="preview-icon">📄</span>
<span class="preview-filename">{{ displayFileName }}</span>
<span class="file-type">{{ fileType }}</span>
<el-button
v-if="fileName"
type="text"
size="small"
@click="downloadFile"
class="download-btn"
>
<i class="el-icon-download"></i> 下载
</el-button>
</div>
<div class="preview-body">
<div v-if="isLoading" class="loading-state">
<el-skeleton :rows="3" animated />
</div>
<div v-else-if="content" class="content-preview">
<!-- 纯文本预览 -->
<div v-if="isTextFile" class="text-preview">
<pre class="preview-text">{{ truncateContent(content) }}</pre>
</div>
<!-- Markdown预览 -->
<div v-else-if="isMarkdownFile" class="markdown-preview">
<div class="preview-note">📝 Markdown文档预览</div>
<div v-html="sanitizeHtml(content)" class="markdown-content"></div>
</div>
<!-- JSON预览 -->
<div v-else-if="isJsonFile" class="json-preview">
<pre class="preview-text">{{ formatJson(content) }}</pre>
</div>
<!-- 默认预览 -->
<div v-else class="text-preview">
<div class="preview-note">📋 文档内容预览</div>
<pre class="preview-text">{{ truncateContent(content) }}</pre>
</div>
</div>
<div v-else class="empty-state">
<el-empty description="暂无预览内容" />
</div>
</div>
<!-- 统计信息 -->
<div v-if="content" class="preview-footer">
<span class="stat-item">大小: {{ getContentSize() }}</span>
<span class="stat-item">行数: {{ getLineCount() }}</span>
<span class="stat-item">字符数: {{ content.length }}</span>
</div>
</div>
</template>
<script setup>
import { ref, computed, defineProps } from 'vue'
const props = defineProps({
fileName: {
type: String,
default: ''
},
content: {
type: String,
default: ''
}
})
const isLoading = ref(false)
const displayFileName = computed(() => {
if (!props.fileName) return '未知文件'
const name = props.fileName.split('/').pop()
if (name.length > 30) {
return name.substring(0, 25) + '...' + name.substring(name.length - 5)
}
return name
})
const fileType = computed(() => {
if (!props.fileName) return ''
const ext = props.fileName.split('.').pop()?.toUpperCase() || ''
return ext ? `(.${ext})` : ''
})
const isTextFile = computed(() => {
const name = props.fileName.toLowerCase()
return name.endsWith('.txt') || name.endsWith('.log') || name.endsWith('.csv')
})
const isMarkdownFile = computed(() => {
return props.fileName.toLowerCase().endsWith('.md')
})
const isJsonFile = computed(() => {
return props.fileName.toLowerCase().endsWith('.json')
})
// 截断长内容
const truncateContent = (content) => {
if (typeof content !== 'string') return '内容格式错误'
return content.substring(0, 800) + (content.length > 800 ? '\n... (内容已截断)' : '')
}
// 格式化JSON
const formatJson = (content) => {
try {
if (typeof content === 'string') {
const obj = JSON.parse(content)
return JSON.stringify(obj, null, 2)
}
return content
} catch (e) {
return content
}
}
// 防XSS:清理HTML内容
const sanitizeHtml = (html) => {
if (typeof html !== 'string') return ''
// 简单的转义
const div = document.createElement('div')
div.textContent = html
return div.innerHTML
}
// 获取内容大小
const getContentSize = () => {
if (!props.content) return '0B'
const bytes = new Blob([props.content]).size
if (bytes < 1024) return bytes + 'B'
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(2) + 'KB'
return (bytes / (1024 * 1024)).toFixed(2) + 'MB'
}
// 获取行数
const getLineCount = () => {
if (!props.content) return 0
return props.content.split('\n').length
}
// 下载文件
const downloadFile = () => {
if (!props.fileName || !props.content) return
const element = document.createElement('a')
const file = new Blob([props.content], { type: 'text/plain' })
element.href = URL.createObjectURL(file)
element.download = props.fileName.split('/').pop()
document.body.appendChild(element)
element.click()
document.body.removeChild(element)
URL.revokeObjectURL(element.href)
}
</script>
<style scoped>
.document-preview {
background-color: white;
border: 1px solid #ebeef5;
border-radius: 4px;
overflow: hidden;
display: flex;
flex-direction: column;
max-height: 400px;
}
.preview-header {
display: flex;
align-items: center;
gap: 8px;
padding: 10px;
background-color: #f5f7fa;
border-bottom: 1px solid #ebeef5;
flex-shrink: 0;
flex-wrap: wrap;
}
.preview-icon {
font-size: 16px;
flex-shrink: 0;
}
.preview-filename {
flex: 1;
font-size: 12px;
color: #333;
font-weight: bold;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
}
.file-type {
font-size: 11px;
color: #909399;
flex-shrink: 0;
}
.download-btn {
flex-shrink: 0;
}
.preview-body {
flex: 1;
overflow: hidden;
display: flex;
flex-direction: column;
}
.loading-state {
padding: 10px;
overflow-y: auto;
}
.content-preview {
flex: 1;
overflow-y: auto;
}
.empty-state {
display: flex;
justify-content: center;
align-items: center;
flex: 1;
}
.text-preview,
.markdown-preview,
.json-preview {
padding: 10px;
font-size: 12px;
color: #333;
}
.preview-note {
font-size: 11px;
color: #909399;
margin-bottom: 8px;
padding: 5px;
background-color: #fdf6ec;
border-radius: 3px;
}
.preview-text {
background-color: #f5f7fa;
padding: 8px;
border-radius: 3px;
font-family: 'Courier New', monospace;
font-size: 11px;
line-height: 1.5;
margin: 0;
overflow-x: auto;
word-break: break-word;
white-space: pre-wrap;
}
.markdown-content {
font-size: 12px;
line-height: 1.6;
}
.markdown-content :deep(p) {
margin: 0 0 10px 0;
}
.markdown-content :deep(h1),
.markdown-content :deep(h2),
.markdown-content :deep(h3) {
margin: 10px 0 5px 0;
color: #333;
font-weight: bold;
}
.markdown-content :deep(code) {
background-color: #f5f7fa;
padding: 2px 4px;
border-radius: 3px;
font-family: 'Courier New', monospace;
font-size: 11px;
}
.markdown-content :deep(pre) {
background-color: #f5f7fa;
padding: 8px;
border-radius: 3px;
overflow-x: auto;
}
.preview-footer {
display: flex;
gap: 15px;
padding: 8px 10px;
background-color: #f9f9f9;
border-top: 1px solid #ebeef5;
font-size: 11px;
color: #909399;
flex-shrink: 0;
}
.stat-item {
display: flex;
align-items: center;
}
/* 滚动条美化 */
.preview-body::-webkit-scrollbar {
width: 4px;
}
.preview-body::-webkit-scrollbar-track {
background: transparent;
}
.preview-body::-webkit-scrollbar-thumb {
background: #d9d9d9;
border-radius: 2px;
}
.preview-body::-webkit-scrollbar-thumb:hover {
background: #b8b8b8;
}
.preview-text::-webkit-scrollbar {
width: 4px;
height: 4px;
}
.preview-text::-webkit-scrollbar-thumb {
background: #d9d9d9;
border-radius: 2px;
}
</style>
<template>
<div class="dom-sync-viewer">
<div class="viewer-header">
<el-input
v-model="urlInput"
placeholder="输入网址,如https://www.baidu.com"
class="url-input"
@keyup.enter="navigateToUrl"
>
<template #append>
<el-button @click="navigateToUrl">访问</el-button>
<el-button @click="clearLogs">清空日志</el-button>
</template>
</el-input>
</div>
<div class="viewer-content">
<!-- 使用 iframe 替代原来的 div 来更好地隔离样式 -->
<iframe
id="dom-view"
class="dom-content"
ref="domViewRef"
:src="iframeSrc"
@load="onIframeLoad"
frameborder="0"
></iframe>
<div id="log-area" class="log-area"></div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted, nextTick } from 'vue'
import { init, classModule, propsModule, styleModule, eventListenersModule, h } from 'snabbdom'
// 添加pako库用于解压gzip数据
import pako from 'pako'
// 初始化snabbdom(在iframe方案中可能不需要,但保留以防需要)
const patch = init([
classModule, // class模块
propsModule, // 属性模块
styleModule, // 样式模块
eventListenersModule // 事件监听模块
])
// 响应式数据
const urlInput = ref('')
const websocket = ref<WebSocket | null>(null)
const domViewRef = ref<HTMLIFrameElement | null>(null)
const iframeSrc = ref('about:blank')
// 分片数据缓存
const chunkBuffer = ref<Map<number, string>>(new Map())
const totalChunks = ref<number>(0)
const receivedChunks = ref<number>(0)
const chunkTimeoutId = ref<number | null>(null)
// WebSocket连接状态管理
const reconnectAttempts = ref<number>(0)
const maxReconnectAttempts = ref<number>(5)
const reconnectDelay = ref<number>(3000)
const isConnected = ref<boolean>(false)
// WebSocket连接
const connectWebSocket = () => {
// 避免重复连接
if (websocket.value && websocket.value.readyState === WebSocket.OPEN) {
return
}
// 如果已有连接但处于其他状态,先关闭它
if (websocket.value) {
try {
// 检查连接状态,避免在连接过程中关闭
if (websocket.value.readyState === WebSocket.CONNECTING) {
addLog('WebSocket正在连接中,等待连接完成后再处理...', 'info');
// 等待连接完成后再决定是否关闭
websocket.value.onopen = () => {
addLog('WebSocket连接已完成,现在关闭旧连接', 'info');
websocket.value?.close();
websocket.value = null;
// 重新调用连接函数
connectWebSocket();
};
websocket.value.onerror = () => {
addLog('WebSocket连接出错,关闭旧连接', 'info');
websocket.value?.close();
websocket.value = null;
// 重新调用连接函数
connectWebSocket();
};
return;
} else {
websocket.value.close();
}
} catch (e) {
addLog('关闭旧WebSocket连接时出错: ' + (e as Error).message, 'error');
}
websocket.value = null;
}
// 从localStorage获取JWT token
const token = localStorage.getItem('token')
// 动态获取WebSocket连接地址,适配不同部署环境
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
const host = window.location.host
const wsUrl = `${protocol}//${host}/ws/dom-sync${token ? '?token=' + token : ''}`
addLog('正在连接WebSocket: ' + wsUrl, 'info');
try {
const ws = new WebSocket(wsUrl)
websocket.value = ws
// 设置连接超时
const connectionTimeout = setTimeout(() => {
if (ws.readyState === WebSocket.CONNECTING) {
addLog('WebSocket连接超时,正在关闭连接...', 'error');
ws.close();
isConnected.value = false;
// 尝试重连
if (reconnectAttempts.value < maxReconnectAttempts.value) {
reconnectAttempts.value++
addLog(`WebSocket连接超时,正在尝试第${reconnectAttempts.value}次重连...`, 'error')
const delay = Math.min(reconnectDelay.value * Math.pow(2, reconnectAttempts.value - 1), 30000)
setTimeout(connectWebSocket, delay)
} else {
addLog('WebSocket连接超时,达到最大重连次数,停止重连', 'error')
}
}
}, 10000); // 10秒超时
// 连接打开事件
ws.onopen = () => {
clearTimeout(connectionTimeout); // 清除超时定时器
isConnected.value = true
reconnectAttempts.value = 0
addLog('WebSocket连接已建立', 'info')
// WebSocket连接建立后,检查iframe是否已经加载完成
// 如果iframe已经加载完成,重新添加事件监听器以确保连接状态正确
if (domViewRef.value && domViewRef.value.contentDocument) {
addIframeEventListeners()
}
}
// 接收消息事件
ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data)
// 处理分片数据
if (data.type === 'chunk') {
handleChunkData(data)
} else {
handleDomSyncData(data)
}
} catch (e) {
addLog('解析数据失败:' + (e as Error).message, 'error')
addLog('原始数据:' + event.data.substring(0, 100) + '...', 'error')
}
}
// 连接关闭事件
ws.onclose = (event) => {
clearTimeout(connectionTimeout); // 清除超时定时器
isConnected.value = false
addLog(`WebSocket连接已关闭,代码: ${event.code}, 原因: ${event.reason}`, 'info');
// 检查关闭原因
if (event.code === 1006) {
addLog('WebSocket连接异常关闭,可能是网络问题或服务器未响应', 'error');
}
if (reconnectAttempts.value < maxReconnectAttempts.value) {
reconnectAttempts.value++
addLog(`WebSocket连接已断开,正在尝试第${reconnectAttempts.value}次重连...`, 'error')
// 指数退避重连策略
const delay = Math.min(reconnectDelay.value * Math.pow(2, reconnectAttempts.value - 1), 30000)
setTimeout(connectWebSocket, delay) // 最大延迟30秒
} else {
addLog('WebSocket连接已断开,达到最大重连次数,停止重连', 'error')
}
}
// 连接错误事件
ws.onerror = (error) => {
clearTimeout(connectionTimeout); // 清除超时定时器
isConnected.value = false
addLog('WebSocket错误:' + (error as any).message, 'error')
// 如果连接失败,尝试重新连接
if (reconnectAttempts.value < maxReconnectAttempts.value) {
reconnectAttempts.value++
addLog(`WebSocket连接错误,正在尝试第${reconnectAttempts.value}次重连...`, 'error')
// 指数退避重连策略
const delay = Math.min(reconnectDelay.value * Math.pow(2, reconnectAttempts.value - 1), 30000)
setTimeout(connectWebSocket, delay)
} else {
addLog('WebSocket连接错误,达到最大重连次数,停止重连', 'error')
}
}
} catch (e) {
addLog('创建WebSocket连接失败: ' + (e as Error).message, 'error');
// 如果创建连接失败,也尝试重连
if (reconnectAttempts.value < maxReconnectAttempts.value) {
reconnectAttempts.value++
addLog(`WebSocket连接创建失败,正在尝试第${reconnectAttempts.value}次重连...`, 'error')
const delay = Math.min(reconnectDelay.value * Math.pow(2, reconnectAttempts.value - 1), 30000)
setTimeout(connectWebSocket, delay)
} else {
addLog('WebSocket连接创建失败,达到最大重连次数,停止重连', 'error')
}
}
}
// iframe加载完成事件
const onIframeLoad = () => {
addLog('iframe加载完成', 'info')
// 使用nextTick确保DOM完全更新后再添加事件监听器
nextTick(() => {
// 移除旧的事件监听器(如果有的话)
removeIframeEventListeners();
// 添加事件监听器到iframe内容文档
addIframeEventListeners()
// 监听iframe内部的导航事件
monitorIframeNavigation()
// iframe加载完成后,检查WebSocket连接状态
// 如果WebSocket已经连接,重新添加事件监听器以确保连接状态正确
if (isConnected.value && websocket.value && websocket.value.readyState === WebSocket.OPEN) {
addIframeEventListeners()
}
})
}
// 监听iframe内部的导航事件
const monitorIframeNavigation = () => {
// 使用类型断言确保domViewRef.value不为null
const iframe = domViewRef.value as HTMLIFrameElement;
if (!iframe || !iframe.contentWindow) {
addLog('无法访问iframe内容窗口', 'error')
return
}
try {
// 监听iframe内部的beforeunload事件
const beforeUnloadHandler = () => {
addLog('iframe即将导航到新页面', 'info')
// 在页面卸载前移除事件监听器
removeIframeEventListeners();
}
// 使用函数调用方式解决类型错误
const contentWindow = iframe.contentWindow;
if (contentWindow) {
contentWindow.addEventListener('beforeunload', beforeUnloadHandler as EventListener);
// 保存引用以便移除
(iframe as any).__beforeUnloadHandler = beforeUnloadHandler;
}
// 监听iframe内部的popstate事件(浏览器前进后退)
const popStateHandler = () => {
addLog('iframe历史状态改变', 'info')
// 页面状态改变后重新绑定事件监听器
setTimeout(() => {
if (domViewRef.value && domViewRef.value.contentDocument) {
removeIframeEventListeners();
addIframeEventListeners()
}
}, 1500)
}
if (contentWindow) {
contentWindow.addEventListener('popstate', popStateHandler as EventListener);
// 保存引用以便移除
(iframe as any).__popStateHandler = popStateHandler;
}
// 监听hashchange事件
const hashChangeHandler = () => {
addLog('iframe哈希值改变', 'info')
// 哈希改变后重新绑定事件监听器
setTimeout(() => {
if (domViewRef.value && domViewRef.value.contentDocument) {
removeIframeEventListeners();
addIframeEventListeners()
}
}, 1500)
}
if (contentWindow) {
contentWindow.addEventListener('hashchange', hashChangeHandler as EventListener);
// 保存引用以便移除
(iframe as any).__hashChangeHandler = hashChangeHandler;
}
// 监听页面可见性变化
const visibilityChangeHandler = () => {
if (document.visibilityState === 'visible' && domViewRef.value && domViewRef.value.contentDocument) {
// 页面重新可见时重新绑定事件监听器
setTimeout(() => {
removeIframeEventListeners();
addIframeEventListeners()
}, 1000)
}
}
// 使用setTimeout来延迟添加事件监听器,避免类型问题
setTimeout(() => {
if (typeof document !== 'undefined' && document.addEventListener) {
document.addEventListener('visibilitychange', visibilityChangeHandler);
}
}, 0);
// 保存引用以便移除
(iframe as any).__visibilityChangeHandler = visibilityChangeHandler;
} catch (e) {
addLog('设置iframe导航监听失败: ' + (e as Error).message, 'error')
}
}
// 清理iframe相关的事件监听器
const cleanupIframeListeners = () => {
// 使用类型断言确保domViewRef.value不为null
const iframe = domViewRef.value as HTMLIFrameElement;
if (!iframe) return;
try {
// 移除beforeunload事件监听器
if ((iframe as any).__beforeUnloadHandler) {
if (iframe.contentWindow) {
iframe.contentWindow.removeEventListener('beforeunload', (iframe as any).__beforeUnloadHandler);
}
delete (iframe as any).__beforeUnloadHandler;
}
// 移除popstate事件监听器
if ((iframe as any).__popStateHandler) {
if (iframe.contentWindow) {
iframe.contentWindow.removeEventListener('popstate', (iframe as any).__popStateHandler);
}
delete (iframe as any).__popStateHandler;
}
// 移除hashchange事件监听器
if ((iframe as any).__hashChangeHandler) {
if (iframe.contentWindow) {
iframe.contentWindow.removeEventListener('hashchange', (iframe as any).__hashChangeHandler);
}
delete (iframe as any).__hashChangeHandler;
}
// 移除visibilitychange事件监听器
if ((iframe as any).__visibilityChangeHandler) {
document.removeEventListener('visibilitychange', (iframe as any).__visibilityChangeHandler);
delete (iframe as any).__visibilityChangeHandler;
}
// 移除iframe内的事件监听器
removeIframeEventListeners();
} catch (e) {
addLog('清理iframe监听器失败: ' + (e as Error).message, 'error');
}
}
// 为iframe内容添加事件监听器
const addIframeEventListeners = () => {
if (!domViewRef.value || !domViewRef.value.contentDocument) {
addLog('无法访问iframe内容文档', 'error')
return
}
const iframeDoc = domViewRef.value.contentDocument
try {
// 先移除已存在的监听器,避免重复绑定
removeIframeEventListeners();
// 添加点击事件监听器
iframeDoc.addEventListener('click', handleIframeClick, { passive: true })
// 添加输入事件监听器(带防抖)
const debouncedInputHandler = debounce(handleIframeInput, 500);
(iframeDoc as any).__debouncedInputHandler = debouncedInputHandler; // 保存引用以便移除
iframeDoc.addEventListener('input', debouncedInputHandler, { passive: true })
// 添加表单提交事件监听器
iframeDoc.addEventListener('submit', handleIframeSubmit)
// 添加滚动事件监听器(带节流)
const throttledScrollHandler = throttle(handleIframeScroll, 200);
(iframeDoc as any).__throttledScrollHandler = throttledScrollHandler; // 保存引用以便移除
iframeDoc.addEventListener('scroll', throttledScrollHandler, { passive: true })
// 添加键盘事件监听器
iframeDoc.addEventListener('keydown', handleIframeKeyDown, { passive: true })
addLog('已为iframe添加事件监听器', 'info')
} catch (e) {
addLog('为iframe添加事件监听器失败: ' + (e as Error).message, 'error')
}
}
// 移除iframe事件监听器
const removeIframeEventListeners = () => {
if (!domViewRef.value || !domViewRef.value.contentDocument) {
return;
}
const iframeDoc = domViewRef.value.contentDocument;
try {
// 移除各种事件监听器
iframeDoc.removeEventListener('click', handleIframeClick);
// 移除防抖的输入事件监听器
if ((iframeDoc as any).__debouncedInputHandler) {
iframeDoc.removeEventListener('input', (iframeDoc as any).__debouncedInputHandler);
delete (iframeDoc as any).__debouncedInputHandler;
}
// 移除表单提交事件监听器
iframeDoc.removeEventListener('submit', handleIframeSubmit);
// 移除节流的滚动事件监听器
if ((iframeDoc as any).__throttledScrollHandler) {
iframeDoc.removeEventListener('scroll', (iframeDoc as any).__throttledScrollHandler);
delete (iframeDoc as any).__throttledScrollHandler;
}
// 移除键盘事件监听器
iframeDoc.removeEventListener('keydown', handleIframeKeyDown);
} catch (e) {
addLog('移除iframe事件监听器失败: ' + (e as Error).message, 'error');
}
}
// 处理iframe中的点击事件
const handleIframeClick = (event: Event) => {
const target = event.target as HTMLElement
if (target) {
const selector = getElementSelector(target)
sendCommand('click', selector)
}
}
// 处理iframe中的输入事件
const handleIframeInput = (event: Event) => {
const target = event.target as HTMLInputElement
if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA')) {
const selector = getElementSelector(target)
sendCommand('type', `${selector}:${target.value}`)
}
}
// 处理iframe中的表单提交事件
const handleIframeSubmit = (event: Event) => {
const target = event.target as HTMLFormElement
if (target) {
event.preventDefault() // 阻止默认提交行为
const selector = getElementSelector(target)
sendCommand('submit', selector)
addLog('已发送表单提交指令', 'info')
// 对于表单提交,我们需要更智能地处理后续的事件监听器重新绑定
const retryBindListeners = () => {
let attempts = 0;
const maxAttempts = 10;
const interval = setInterval(() => {
attempts++;
if (domViewRef.value && domViewRef.value.contentDocument) {
addIframeEventListeners();
clearInterval(interval);
addLog('表单提交后重新绑定事件监听器成功', 'info');
} else if (attempts >= maxAttempts) {
clearInterval(interval);
addLog('表单提交后重新绑定事件监听器失败,达到最大尝试次数', 'error');
}
}, 500);
};
// 立即尝试一次
setTimeout(() => {
if (domViewRef.value && domViewRef.value.contentDocument) {
addIframeEventListeners();
}
}, 500);
// 启动重试机制
retryBindListeners();
}
}
// 处理iframe中的滚动事件
const handleIframeScroll = (event: Event) => {
const target = event.target as HTMLElement
if (target) {
const scrollY = target.scrollTop || 0
sendCommand('scroll', scrollY.toString())
}
}
// 处理iframe中的键盘事件
const handleIframeKeyDown = (event: KeyboardEvent) => {
const target = event.target as HTMLElement
if (target) {
// 发送按键信息
sendCommand('keydown', event.key)
// 特殊处理Enter键,可能触发表单提交
if (event.key === 'Enter' && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA')) {
const form = target.closest('form')
if (form) {
const selector = getElementSelector(form)
sendCommand('submit', selector)
}
}
}
}
// 获取元素的选择器(改进版本)
const getElementSelector = (element: Element): string => {
if (!element || element === document.documentElement || element === document.body) {
return 'body';
}
// 优先使用ID选择器
if (element.id) {
return '#' + CSS.escape(element.id);
}
// 使用类名选择器
if (element.className && typeof element.className === 'string') {
const classes = element.className.split(' ').filter(cls => cls.length > 0);
if (classes.length > 0) {
return '.' + classes.map(cls => CSS.escape(cls)).join('.');
}
}
// 使用标签名选择器,并结合nth-child定位
let selector = element.tagName.toLowerCase();
const parent = element.parentElement;
if (parent) {
// 获取同级同标签元素的数量
const siblings = Array.from(parent.children).filter(child =>
child.tagName === element.tagName);
if (siblings.length > 1) {
// 计算当前元素在同级元素中的位置(1-based)
const index = Array.from(siblings).indexOf(element) + 1;
selector += `:nth-child(${index})`;
}
}
// 如果有父元素,递归构建更精确的选择器
if (parent && parent !== document.body) {
const parentSelector = getElementSelector(parent);
if (parentSelector) {
selector = parentSelector + ' > ' + selector;
}
}
return selector;
}
// 防抖函数
const debounce = (func: Function, delay: number) => {
let timeoutId: number
return (...args: any[]) => {
window.clearTimeout(timeoutId)
timeoutId = window.setTimeout(() => func.apply(null, args), delay)
}
}
// 节流函数
const throttle = (func: Function, delay: number) => {
let lastExecTime = 0
return (...args: any[]) => {
const currentTime = Date.now()
if (currentTime - lastExecTime >= delay) {
func.apply(null, args)
lastExecTime = currentTime
}
}
}
// 发送指令到服务器
const sendCommand = (command: string, param: string) => {
// 检查WebSocket连接状态
if (!websocket.value || websocket.value.readyState !== WebSocket.OPEN) {
// 如果WebSocket未连接,尝试重新连接
if (!isConnected.value) {
addLog('WebSocket未连接,正在尝试重新连接...', 'error')
connectWebSocket()
// 等待连接建立后再发送指令
const retryInterval = setInterval(() => {
if (websocket.value && websocket.value.readyState === WebSocket.OPEN) {
clearInterval(retryInterval)
doSendCommand(command, param)
}
}, 500)
// 设置超时时间,避免无限等待
setTimeout(() => {
clearInterval(retryInterval)
if (!websocket.value || websocket.value.readyState !== WebSocket.OPEN) {
addLog('WebSocket连接超时,无法发送指令', 'error')
}
}, 5000)
} else {
addLog('WebSocket未连接,无法发送指令', 'error')
// 添加一个更明确的提示,说明可能需要等待连接建立
addLog('提示:请稍等WebSocket连接建立后再尝试操作', 'info')
}
return
}
doSendCommand(command, param)
}
// 实际发送指令的方法
const doSendCommand = (command: string, param: string) => {
if (!param) {
addLog('指令参数不能为空', 'error')
return
}
const message = command + ':' + param
// 检查WebSocket连接状态
if (websocket.value && websocket.value.readyState === WebSocket.OPEN) {
try {
websocket.value.send(message)
addLog('已发送指令:' + message, 'info')
} catch (e) {
addLog('发送指令失败:' + (e as Error).message, 'error')
}
} else {
addLog('WebSocket连接已断开,无法发送指令', 'error')
}
}
// 处理分片数据
const handleChunkData = (data: any) => {
try {
const chunkIndex = parseInt(data.dom); // 第一个字段是分片索引
const total = parseInt(data.style); // 第二个字段是总分片数
const content = data.script; // 第三个字段是分片内容
// 验证分片数据完整性
if (isNaN(chunkIndex) || isNaN(total) || !content) {
addLog('分片数据不完整或格式错误', 'error');
return;
}
// 初始化分片信息
if (chunkIndex === 0) {
totalChunks.value = total;
receivedChunks.value = 0;
chunkBuffer.value.clear();
// 清除之前的分片超时定时器(如果有的话)
if (chunkTimeoutId.value) {
clearTimeout(chunkTimeoutId.value);
chunkTimeoutId.value = null;
}
// 设置新的分片超时定时器,如果在10秒内没有接收完所有分片,则重置
chunkTimeoutId.value = window.setTimeout(() => {
if (receivedChunks.value < totalChunks.value) {
addLog(`分片接收超时,已接收${receivedChunks.value}/${totalChunks.value}个分片`, 'error');
chunkBuffer.value.clear();
totalChunks.value = 0;
receivedChunks.value = 0;
}
chunkTimeoutId.value = null;
}, 10000); // 10秒超时
}
// 检查分片索引是否合法
if (chunkIndex < 0 || chunkIndex >= total) {
addLog(`分片索引越界: ${chunkIndex}/${total}`, 'error');
return;
}
// 缓存分片数据(Base64解码)
try {
const decodedContent = atob(content);
chunkBuffer.value.set(chunkIndex, decodedContent);
receivedChunks.value++;
} catch (e) {
addLog('解码分片数据失败:' + (e as Error).message, 'error');
return;
}
// 检查是否接收完所有分片
if (receivedChunks.value === totalChunks.value) {
// 组装完整数据
let fullContent = '';
let missingChunks = [];
for (let i = 0; i < totalChunks.value; i++) {
const chunk = chunkBuffer.value.get(i);
if (chunk !== undefined) {
fullContent += chunk;
} else {
missingChunks.push(i);
}
}
// 检查是否有缺失的分片
if (missingChunks.length > 0) {
addLog(`分片数据缺失,缺少分片: ${missingChunks.join(', ')}`, 'error');
// 清空缓存并重新开始
chunkBuffer.value.clear();
totalChunks.value = 0;
receivedChunks.value = 0;
return;
}
// 检查总分片数是否合理
if (totalChunks.value <= 0) {
addLog('分片总数无效', 'error');
chunkBuffer.value.clear();
totalChunks.value = 0;
receivedChunks.value = 0;
return;
}
// 清空缓存
chunkBuffer.value.clear();
totalChunks.value = 0;
receivedChunks.value = 0;
// 清除分片超时定时器
if (chunkTimeoutId.value) {
clearTimeout(chunkTimeoutId.value);
chunkTimeoutId.value = null;
}
// 解析完整数据
try {
// 检查内容是否为空
if (!fullContent || fullContent.trim() === '') {
addLog('分片数据为空', 'error');
return;
}
// 尝试解压数据(如果是压缩的)
let jsonData: string;
try {
// 将字符串转换为Uint8Array
const charArray = fullContent.split('').map(c => c.charCodeAt(0));
const uint8Array = new Uint8Array(charArray);
// 尝试解压
const decompressed = pako.inflate(uint8Array, { to: 'string' });
jsonData = decompressed;
} catch (decompressError) {
// 如果解压失败,假设数据未被压缩
jsonData = fullContent;
}
// 验证JSON格式
const parsedData = JSON.parse(jsonData);
handleDomSyncData(parsedData);
} catch (e) {
addLog('解析完整分片数据失败:' + (e as Error).message, 'error');
addLog('数据内容预览:' + fullContent.substring(0, 100) + '...', 'error');
}
}
} catch (e) {
addLog('处理分片数据失败:' + (e as Error).message, 'error');
}
}
// 处理DOM同步数据
const handleDomSyncData = (data: any) => {
try {
// 检查数据是否有效
if (!data) {
addLog('接收到空数据', 'error');
return;
}
switch (data.type) {
case 'init':
renderFullDomInIframe(data);
addLog('已初始化页面DOM:' + (data.url || '未知URL'), 'init');
break;
case 'update':
updateIncrementalDomInIframe(data);
addLog('已更新DOM', 'info');
break;
case 'error':
addLog('服务器错误:' + (data.dom || data.script || '未知错误'), 'error');
break;
case 'chunk':
handleChunkData(data);
break;
default:
addLog('未知数据类型:' + (data.type || '未指定'), 'error');
}
} catch (e) {
addLog('处理DOM同步数据失败:' + (e as Error).message, 'error');
}
}
// 在iframe中渲染完整DOM
const renderFullDomInIframe = (data: any) => {
if (!domViewRef.value || !domViewRef.value.contentDocument) {
addLog('iframe未初始化', 'error');
return;
}
try {
// 检查必要数据
if (!data.dom) {
addLog('缺少DOM数据', 'error');
return;
}
const doc = domViewRef.value.contentDocument;
// 清空原有内容
doc.open();
doc.write(data.dom);
doc.close();
// 添加样式(如果有)
if (data.style) {
const styleTag = doc.createElement('style');
styleTag.textContent = data.style;
doc.head.appendChild(styleTag);
}
// 执行脚本(如果有)
if (data.script) {
executeScriptsInIframe(data.script, doc);
}
// 重新绑定事件监听器
setTimeout(() => {
addIframeEventListeners();
}, 1000);
} catch (e) {
addLog('在iframe中渲染完整DOM失败:' + (e as Error).message, 'error');
addLog('DOM数据预览:' + (data.dom ? data.dom.substring(0, 100) + '...' : '无'), 'error');
}
}
// 在iframe中执行脚本
const executeScriptsInIframe = (scriptJson: string, doc: Document) => {
try {
// 检查脚本数据是否为空
if (!scriptJson || scriptJson.trim() === '') {
return;
}
let scriptData;
try {
scriptData = JSON.parse(scriptJson);
} catch (e) {
addLog('解析脚本数据失败:' + (e as Error).message, 'error');
addLog('脚本数据预览:' + (scriptJson ? scriptJson.substring(0, 100) + '...' : '无'), 'error');
return;
}
// 执行内联脚本
if (scriptData.inline && Array.isArray(scriptData.inline)) {
scriptData.inline.forEach((scriptText: string) => {
if (scriptText && scriptText.trim() !== '') {
const scriptTag = doc.createElement('script');
scriptTag.textContent = scriptText;
doc.body.appendChild(scriptTag);
}
});
}
// 加载外部脚本
if (scriptData.external && Array.isArray(scriptData.external)) {
scriptData.external.forEach((scriptUrl: string) => {
if (scriptUrl && scriptUrl.trim() !== '') {
const scriptTag = doc.createElement('script');
scriptTag.src = scriptUrl;
scriptTag.crossOrigin = 'anonymous';
scriptTag.onload = () => addLog('外部脚本加载完成:' + scriptUrl, 'info');
scriptTag.onerror = () => addLog('外部脚本加载失败:' + scriptUrl, 'error');
doc.body.appendChild(scriptTag);
}
});
}
} catch (e) {
addLog('在iframe中执行脚本失败:' + (e as Error).message, 'error');
addLog('脚本数据预览:' + (scriptJson ? scriptJson.substring(0, 100) + '...' : '无'), 'error');
}
}
// 在iframe中更新增量DOM(改进版本)
const updateIncrementalDomInIframe = (data: any) => {
if (!domViewRef.value || !domViewRef.value.contentDocument) {
addLog('iframe未初始化', 'error');
return;
}
try {
const doc = domViewRef.value.contentDocument;
const changes = JSON.parse(data.dom);
// 处理DOM变化
changes.forEach((change: any) => {
// 处理添加的节点
if (change.addedNodes && Array.isArray(change.addedNodes)) {
change.addedNodes.forEach((nodeHtml: string) => {
if (nodeHtml) {
try {
const tempDiv = doc.createElement('div');
tempDiv.innerHTML = nodeHtml;
const newChild = tempDiv.firstChild as HTMLElement;
if (newChild) {
// 查找目标父节点
const parentSelector = change.target ? change.target : 'body';
const parentElement = doc.querySelector(parentSelector);
if (parentElement) {
parentElement.appendChild(newChild);
} else {
doc.body.appendChild(newChild);
}
}
} catch (e) {
addLog('添加节点失败:' + (e as Error).message, 'error');
}
}
});
}
// 处理移除的节点
if (change.removedNodes && Array.isArray(change.removedNodes)) {
change.removedNodes.forEach((nodeHtml: string) => {
if (nodeHtml) {
try {
const tempDiv = doc.createElement('div');
tempDiv.innerHTML = nodeHtml;
const nodeToRemove = tempDiv.firstChild as HTMLElement;
if (nodeToRemove) {
// 构建更精确的选择器
const selector = getElementSelector(nodeToRemove);
const targetElement = doc.querySelector(selector);
if (targetElement) {
targetElement.remove();
} else {
// 如果精确选择器找不到,尝试使用其他方式
const fallbackSelector = nodeToRemove.id ? '#' + nodeToRemove.id :
nodeToRemove.tagName.toLowerCase();
const fallbackElement = doc.querySelector(fallbackSelector);
if (fallbackElement) {
fallbackElement.remove();
}
}
}
} catch (e) {
addLog('移除节点失败:' + (e as Error).message, 'error');
}
}
});
}
});
} catch (e) {
addLog('在iframe中增量更新DOM失败:' + (e as Error).message, 'error');
}
}
// 导航到指定URL
const navigateToUrl = () => {
if (urlInput.value) {
// 验证URL格式
if (!isValidUrl(urlInput.value)) {
addLog('无效的URL格式: ' + urlInput.value, 'error')
return
}
sendCommand('navigate', urlInput.value)
}
}
// 验证URL格式是否有效
const isValidUrl = (url: string): boolean => {
if (!url || url.trim() === '') {
return false
}
// 检查是否以http://或https://开头
if (!url.toLowerCase().startsWith('http://') && !url.toLowerCase().startsWith('https://')) {
// 如果没有协议前缀,自动添加https://
urlInput.value = 'https://' + url
url = urlInput.value
}
// 使用正则表达式进行基本的URL格式验证
const urlPattern = /^(https?:\/\/)?([\da-z\.-]+)\.([a-z\.]{2,6})([\/\w \.-]*)*\/?$/
return urlPattern.test(url)
}
// 添加一个方法来初始化页面
const initializePage = () => {
// 设置初始iframe源为about:blank
iframeSrc.value = 'about:blank'
// 连接WebSocket
connectWebSocket()
}
// 添加日志
const addLog = (message: string, type: string) => {
const logArea = document.getElementById('log-area')
if (logArea) {
const logItem = document.createElement('div')
logItem.className = 'log-' + type
logItem.textContent = `[${new Date().toLocaleTimeString()}] ${message}`
logArea.appendChild(logItem)
// 限制日志数量,避免内存泄漏
while (logArea.children.length > 100) {
logArea.removeChild(logArea.firstChild!)
}
// 使用nextTick确保DOM更新后再滚动
nextTick(() => {
// 检查用户是否在查看历史日志
const isScrolledToBottom = logArea.scrollHeight - logArea.clientHeight <= logArea.scrollTop + 10
if (isScrolledToBottom) {
// 自动滚动到最新日志
logArea.scrollTop = logArea.scrollHeight
}
})
}
}
// 清空日志
const clearLogs = () => {
const logArea = document.getElementById('log-area')
if (logArea) {
logArea.innerHTML = ''
}
}
// 组件挂载时初始化页面
onMounted(() => {
initializePage()
})
// 组件卸载时关闭WebSocket
onUnmounted(() => {
// 清理iframe相关的事件监听器
cleanupIframeListeners();
// 关闭WebSocket连接
if (websocket.value) {
try {
websocket.value.close()
} catch (e) {
addLog('关闭WebSocket连接时出错: ' + (e as Error).message, 'error');
}
websocket.value = null;
}
// 清除分片超时定时器
if (chunkTimeoutId.value) {
clearTimeout(chunkTimeoutId.value);
chunkTimeoutId.value = null;
}
// 重置状态
isConnected.value = false;
reconnectAttempts.value = 0;
})
</script>
<style scoped>
.dom-sync-viewer {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
}
.viewer-header {
padding: 10px;
border-bottom: 1px solid var(--border-color);
}
.url-input {
width: 100%;
}
.viewer-content {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.dom-content {
flex: 1;
overflow: auto;
padding: 10px;
border: 1px solid var(--border-color);
min-height: 400px;
width: 100%;
}
.log-area {
height: 150px;
overflow: auto;
padding: 10px;
border-top: 1px solid var(--border-color);
font-size: 12px;
background-color: var(--bg-secondary);
}
.log-error {
color: red;
}
.log-info {
color: blue;
}
.log-init {
color: green;
}
</style>
\ No newline at end of file
<template>
<div class="embed-preview">
<!-- 预览头部 -->
<div class="preview-header">
<span class="preview-icon">📦</span>
<span class="preview-title">{{ displayTitle }}</span>
<span v-if="embedType" class="embed-type-tag">{{ embedType }}</span>
<!-- 策略指示和切换按钮 -->
<div v-if="effectiveRenderStrategy !== 'empty'" class="strategy-controls">
<!-- <span class="strategy-badge" :class="effectiveRenderStrategy">
{{ effectiveStrategyText }}
</span> -->
<!-- 切换按钮(当两种策略都可用时显示) -->
<el-button-group v-if="canToggleStrategy" class="strategy-toggle-group">
<el-button
:type="userRenderStrategy === 'html' ? 'primary' : 'default'"
size="small"
@click="toggleStrategy('html')"
class="strategy-toggle-btn"
>
<i class="el-icon-document"></i>
服务器内容
</el-button>
<el-button
:type="userRenderStrategy === 'direct' ? 'primary' : 'default'"
size="small"
@click="toggleStrategy('direct')"
class="strategy-toggle-btn"
>
<i class="el-icon-link"></i>
直接加载
</el-button>
<el-button
:type="userRenderStrategy === 'iframe' ? 'primary' : 'default'"
size="small"
@click="toggleStrategy('iframe')"
class="strategy-toggle-btn"
>
<i class="el-icon-link"></i>
iframe加载
</el-button>
<el-button
v-if="embedUrl"
type="default"
size="small"
@click="openInNewTab"
class="strategy-toggle-btn"
>
<i class="el-icon-link"></i> 新标签页
</el-button>
</el-button-group>
</div>
</div>
<!-- 预览主体 -->
<div class="preview-body">
<el-skeleton v-if="isLoading" :rows="3" animated />
<div v-else-if="renderStrategy !== 'empty'" class="content-preview">
<!-- 策略1:HTML内容 - 优先使用 -->
<div v-if="renderStrategy === 'html'" class="html-embed">
<div class="embed-content" v-html="sanitizedHtml"></div>
</div>
<!-- 策略2:直接获取URL内容并渲染 - 更好的替代方案 -->
<div v-else-if="renderStrategy === 'direct'" class="html-embed">
<div class="embed-content" v-html="directContentSanitized"></div>
</div>
<!-- 策略3:iframe URL加载 - 备选 -->
<div v-else-if="renderStrategy === 'iframe'" class="iframe-container">
<iframe
:src="proxiedUrl"
:title="embedTitle || '嵌入内容'"
class="embed-iframe"
sandbox="allow-scripts allow-same-origin allow-popups allow-forms allow-presentation"
referrerpolicy="no-referrer"
@load="onUrlIframeLoad"
@error="onUrlIframeError"
loading="lazy"
></iframe>
</div>
<!-- 错误显示 -->
<div v-else-if="loadError" class="error-container">
<div class="error-content">
<div class="error-icon">⚠️</div>
<h3 class="error-title">页面加载失败</h3>
<p class="error-message">{{ loadError }}</p>
<div class="error-actions">
<el-button @click="retryLoad" type="primary" size="small">重试</el-button>
<el-button @click="clearError" size="small">清除错误</el-button>
</div>
</div>
</div>
</div>
<el-empty v-else description="暂无嵌入内容" />
</div>
<!-- 底部信息 -->
<div v-if="renderStrategy !== 'empty'" class="preview-footer">
<span class="info-item">类型: {{ embedType || '未知' }}</span>
<span v-if="embedUrl" class="info-item">来源: {{ truncateUrl(embedUrl) }}</span>
<span v-if="dataSource" class="info-item">数据源: {{ dataSource }}</span>
<span class="info-item">大小: {{ getContentSize() }}</span>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
import DOMPurify from 'dompurify'
import { ElMessage } from 'element-plus'
import request from '@/utils/request'
// ============================================================================
// Props 定义
// ============================================================================
const props = defineProps({
// 完整的HTML字符串(后端过滤返回)- 优先使用
htmlContent: {
type: String,
default: ''
},
// 备选:直接提供的URL
embedUrl: {
type: String,
default: ''
},
embedType: {
type: String,
default: ''
},
embedTitle: {
type: String,
default: '嵌入内容'
}
})
// ============================================================================
// 安全配置
// ============================================================================
// 允许的HTML标签列表
const SAFE_HTML_TAGS = [
'p', 'a', 'img', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
'ul', 'ol', 'li', 'blockquote', 'code', 'pre', 'br', 'hr',
'div', 'span', 'strong', 'em', 'u', 'del', 'ins',
'table', 'thead', 'tbody', 'tfoot', 'tr', 'td', 'th',
'figure', 'figcaption', 'section', 'article', 'header', 'footer',
'main', 'aside', 'nav', 'form', 'input', 'button', 'select', 'textarea'
]
// 允许的HTML属性列表
const SAFE_HTML_ATTRS = [
'href', 'src', 'alt', 'title', 'width', 'height',
'class', 'id', 'style',
'colspan', 'rowspan', 'align', 'valign',
'name', 'target', 'rel', 'type',
'placeholder', 'value', 'checked', 'disabled'
]
// ============================================================================
// 状态管理
// ============================================================================
const isLoading = ref(false)
const htmlBlobUrl = ref('')
const dataSource = ref('')
const directContent = ref('') // 直接获取的网页内容
const loadError = ref('') // 加载错误信息
// 用户手动选择的渲染策略('html'、'direct'、'iframe' 或 null)
// 当为 null 时,使用自动判断
const userRenderStrategy = ref<'html' | 'direct' | 'iframe' | null>(null)
// ============================================================================
// 渲染策略计算
// ============================================================================
/**
* 计算有效的渲染策略(综合用户选择和自动判断)
* 优先级:用户手动选择 > 自动判断(htmlContent > directContent > embedUrl > empty)
*/
const effectiveRenderStrategy = computed(() => {
// 如果用户手动选择了,则遵循用户的选择
if (userRenderStrategy.value !== null) {
console.log('[EmbedPreview] 使用用户手动选择的策略:', userRenderStrategy.value)
return userRenderStrategy.value
}
// 否则使用自动判断
// 优先级1:后端返回的完整HTML(最优)
if (props.htmlContent && props.htmlContent.trim()) {
console.log('[EmbedPreview] 自动判断:htmlContent存在,使用html策略')
return 'html'
}
// 优先级2:直接获取的内容
if (directContent.value && directContent.value.trim()) {
console.log('[EmbedPreview] 自动判断:directContent存在,使用direct策略')
return 'direct'
}
// 优先级3:直接提供的URL(备选)
if (props.embedUrl && props.embedUrl.trim()) {
console.log('[EmbedPreview] 自动判断:embedUrl存在,使用iframe策略')
return 'iframe'
}
// 优先级4:都没有
console.log('[EmbedPreview] 自动判断:无内容,使用empty策略')
return 'empty'
})
/**
* 向后兼容:renderStrategy 保留现有名称
*/
const renderStrategy = computed(() => {
// 如果有错误,显示错误
if (loadError.value) {
return 'error'
}
return effectiveRenderStrategy.value
})
/**
* 判断是否可以切换策略(多种策略都可用时)
*/
const canToggleStrategy = computed(() => {
const hasHtml = props.htmlContent && props.htmlContent.trim()
const hasUrl = props.embedUrl && props.embedUrl.trim()
const hasDirect = directContent.value && directContent.value.trim()
// 当至少有两种策略可用时,允许切换
return (hasHtml ? 1 : 0) + (hasUrl ? 1 : 0) + (hasDirect ? 1 : 0) >= 2
})
/**
* 使用DOMPurify清理并返回安全的HTML内容
*/
const sanitizedHtml = computed(() => {
if (effectiveRenderStrategy.value !== 'html' || !props.htmlContent) {
return ''
}
try {
const cleaned = DOMPurify.sanitize(props.htmlContent, {
ALLOWED_TAGS: SAFE_HTML_TAGS,
ALLOWED_ATTR: SAFE_HTML_ATTRS,
KEEP_CONTENT: true,
RETURN_DOM: false,
ALLOW_DATA_ATTR: true,
ALLOW_UNKNOWN_PROTOCOLS: false
})
console.log('[EmbedPreview] HTML清理完成', {
originalSize: props.htmlContent.length,
cleanedSize: cleaned.length,
reductionPercent: ((1 - cleaned.length / props.htmlContent.length) * 100).toFixed(2) + '%'
})
return cleaned
} catch (error) {
console.error('[EmbedPreview] HTML清理失败:', error)
return ''
}
})
/**
* 清理直接获取的内容
*/
const directContentSanitized = computed(() => {
if (!directContent.value) {
return ''
}
try {
const cleaned = DOMPurify.sanitize(directContent.value, {
ALLOWED_TAGS: SAFE_HTML_TAGS,
ALLOWED_ATTR: SAFE_HTML_ATTRS,
KEEP_CONTENT: true,
RETURN_DOM: false,
ALLOW_DATA_ATTR: true,
ALLOW_UNKNOWN_PROTOCOLS: false
})
return cleaned
} catch (error) {
console.error('[EmbedPreview] 直接内容清理失败:', error)
return ''
}
})
/**
* 代理URL(供iframe src使用)
*/
const proxiedUrl = computed(() => {
if (!props.embedUrl) return ''
const token = localStorage.getItem('token')
let url = `/api/v1/proxy?url=${encodeURIComponent(props.embedUrl)}`
if (token) {
url += `&token=${encodeURIComponent(token)}`
}
return url
})
/**
* 计算显示标题
*/
const displayTitle = computed(() => {
const title = props.embedTitle || '嵌入内容'
return title.length > 30 ? title.substring(0, 27) + '...' : title
})
// ============================================================================
// 事件处理
// ============================================================================
/**
* 直接获取URL内容
*/
const fetchDirectContent = async () => {
if (!props.embedUrl) return
isLoading.value = true
loadError.value = ''
try {
console.log('[EmbedPreview] 开始直接获取URL内容:', props.embedUrl)
// 使用代理API获取内容 (修正路径以匹配baseURL配置)
// 注意:request.ts的baseURL是'/api/v1',所以这里应该使用'/proxy'而不是'/api/v1/proxy'
const response = await request.get('/proxy', {
params: {
url: props.embedUrl
}
}).catch(async (error) => {
// 如果是404错误,尝试使用完整路径
if (error.response && error.response.status === 404) {
console.warn('[EmbedPreview] 代理请求返回404,尝试使用完整路径')
try {
const fullResponse = await request.get(`/api/v1/proxy?url=${encodeURIComponent(props.embedUrl)}`)
return fullResponse
} catch (fullError) {
throw fullError
}
}
// 如果是ERR_BAD_RESPONSE错误且状态码为502,也尝试使用完整路径
else if (error.code === 'ERR_BAD_RESPONSE' && error.response && error.response.status === 502) {
console.warn('[EmbedPreview] 代理请求返回502,尝试使用完整路径')
try {
const fullResponse = await request.get(`/api/v1/proxy?url=${encodeURIComponent(props.embedUrl)}`)
return fullResponse
} catch (fullError) {
throw fullError
}
}
throw error
})
directContent.value = response.data
dataSource.value = '远程(直接)'
console.log('[EmbedPreview] 直接获取URL内容成功,长度:', response.data.length)
} catch (error: any) {
console.error('[EmbedPreview] 直接获取URL内容失败:', error)
// 提供更详细的错误信息
let errorMsg = ''
if (error.response) {
// 服务器响应了错误状态码
errorMsg = `服务器错误 ${error.response.status}: ${error.response.statusText}`
if (error.response.data) {
errorMsg += ` - ${error.response.data}`
}
} else if (error.request) {
// 请求已发出但没有收到响应
errorMsg = '网络连接失败,请检查网络设置或目标服务器是否可访问'
} else {
// 其他错误
errorMsg = error.message || '获取内容失败'
}
loadError.value = `获取网页内容失败: ${errorMsg}`
// 显示错误消息
ElMessage.error({
message: `获取网页内容失败: ${errorMsg}`,
duration: 5000
})
} finally {
isLoading.value = false
}
}
/**
* URL iframe 加载成功处理
*/
const onUrlIframeLoad = () => {
console.log('[EmbedPreview] URL iframe加载成功:', props.embedUrl)
isLoading.value = false
dataSource.value = '远程(iframe)'
}
/**
* URL iframe 加载失败处理
* 处理 X-Frame-Options 拒绝的情况
*/
const onUrlIframeError = () => {
console.error('[EmbedPreview] URL iframe加载失败:', props.embedUrl)
isLoading.value = false
dataSource.value = '加载失败'
loadError.value = '无法在iframe中显示此网页。可能是由于网站的安全策略(X-Frame-Options)阻止了加载。已自动尝试直接加载内容。'
// 尝试直接获取内容作为备选方案
fetchDirectContent()
}
/**
* 在新标签页打开URL
*/
const openInNewTab = () => {
if (props.embedUrl) {
window.open(props.embedUrl, '_blank')
console.log('[EmbedPreview] 在新标签页打开:', props.embedUrl)
}
}
/**
* 用户手动切换渲染策略
*/
const toggleStrategy = (strategy: 'html' | 'direct' | 'iframe') => {
// 如果点击的是当前已选择的策略,则取消手动选择,恢复自动判断
if (userRenderStrategy.value === strategy) {
userRenderStrategy.value = null
console.log('[EmbedPreview] 取消手动选择,恢复自动判断策略')
console.log('[EmbedPreview] 当前有效策略:', effectiveRenderStrategy.value)
} else {
// 否则切换到用户选择的策略
userRenderStrategy.value = strategy
console.log('[EmbedPreview] 用户手动切换渲染策略:', strategy)
console.log('[EmbedPreview] 当前有效策略:', effectiveRenderStrategy.value)
// 如果切换到direct策略,尝试直接获取内容
if (strategy === 'direct') {
fetchDirectContent()
}
// 清除错误
loadError.value = ''
}
}
/**
* 重试加载
*/
const retryLoad = () => {
loadError.value = ''
if (userRenderStrategy.value === 'direct' || effectiveRenderStrategy.value === 'direct') {
fetchDirectContent()
} else if (userRenderStrategy.value === 'iframe' || effectiveRenderStrategy.value === 'iframe') {
// 重新触发iframe加载
// 这里可以通过改变src来强制重新加载
} else {
// 默认重试直接加载
fetchDirectContent()
}
}
/**
* 清除错误
*/
const clearError = () => {
loadError.value = ''
}
/**
* 获取内容大小
*/
const getContentSize = () => {
let content = ''
if (props.htmlContent) {
content = props.htmlContent
} else if (directContent.value) {
content = directContent.value
} else if (props.embedUrl) {
content = props.embedUrl
}
if (!content) return '0B'
const bytes = new Blob([content]).size
if (bytes < 1024) return bytes + 'B'
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(2) + 'KB'
return (bytes / (1024 * 1024)).toFixed(2) + 'MB'
}
/**
* 截断URL显示
*/
const truncateUrl = (url: string) => {
if (!url) return ''
if (url.length > 40) {
return url.substring(0, 20) + '...' + url.substring(url.length - 15)
}
return url
}
// ============================================================================
// 生命周期
// ============================================================================
// 当embedUrl改变时,尝试直接获取内容
watch(() => props.embedUrl, (newUrl) => {
if (newUrl) {
// 清除之前的状态
directContent.value = ''
loadError.value = ''
// 如果当前策略是direct或iframe,尝试直接获取内容
if (effectiveRenderStrategy.value === 'direct' || effectiveRenderStrategy.value === 'iframe') {
fetchDirectContent()
}
}
})
onMounted(() => {
console.log('[EmbedPreview] 组件挂载', {
hasHtmlContent: !!props.htmlContent,
htmlSize: props.htmlContent?.length || 0,
hasEmbedUrl: !!props.embedUrl,
embedUrl: props.embedUrl,
effectiveRenderStrategy: effectiveRenderStrategy.value,
canToggleStrategy: canToggleStrategy.value
})
// 如果使用iframe或direct策略,尝试直接获取内容
if ((effectiveRenderStrategy.value === 'iframe' || effectiveRenderStrategy.value === 'direct') && props.embedUrl) {
fetchDirectContent()
}
})
onUnmounted(() => {
// 释放Blob URL防止内存泄漏
if (htmlBlobUrl.value) {
URL.revokeObjectURL(htmlBlobUrl.value)
console.log('[EmbedPreview] Blob URL已释放')
}
// 重置用户选择
userRenderStrategy.value = null
})
</script>
<style scoped>
.embed-preview {
background-color: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: var(--border-radius-lg);
overflow: hidden;
display: flex;
flex-direction: column;
max-height: 100%;
height: 100%;
box-shadow: var(--shadow-sm);
width: 100%;
transition: all var(--transition-normal);
}
.embed-preview:hover {
box-shadow: var(--shadow-md);
}
.preview-header {
display: flex;
align-items: center;
gap: var(--spacing-2);
padding: var(--spacing-3);
background-color: var(--bg-secondary);
border-bottom: 1px solid var(--border-color);
flex-shrink: 0;
flex-wrap: wrap;
}
.preview-icon {
font-size: var(--font-size-lg);
flex-shrink: 0;
}
.preview-title {
flex: 1;
font-size: var(--font-size-sm);
color: var(--text-primary);
font-weight: var(--font-weight-semibold);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
}
.embed-type-tag {
font-size: var(--font-size-xs);
color: var(--accent-color);
background-color: rgba(74, 144, 226, 0.1);
padding: var(--spacing-1) var(--spacing-2);
border-radius: var(--border-radius-sm);
flex-shrink: 0;
font-family: var(--font-family-mono);
}
/* 策略控件容器(嘿釣徽章 + 切换按钮) */
.strategy-controls {
display: flex;
align-items: center;
gap: var(--spacing-2);
flex-shrink: 0;
}
/* 策略指示徽章 */
.strategy-badge {
font-size: var(--font-size-xs);
padding: var(--spacing-1) var(--spacing-2);
border-radius: var(--border-radius-sm);
font-family: var(--font-family-mono);
flex-shrink: 0;
white-space: nowrap;
}
.strategy-badge.html {
background-color: rgba(76, 175, 80, 0.1);
color: #4CAF50;
border: 1px solid #4CAF50;
}
.strategy-badge.direct {
background-color: rgba(33, 150, 243, 0.1);
color: #2196F3;
border: 1px solid #2196F3;
}
.strategy-badge.iframe {
background-color: rgba(255, 152, 0, 0.1);
color: #FF9800;
border: 1px solid #FF9800;
}
/* 策略切换按钮组 */
.strategy-toggle-group {
display: flex;
gap: 0;
flex-shrink: 0;
}
.strategy-toggle-btn {
font-size: var(--font-size-xs);
padding: var(--spacing-1) var(--spacing-2);
border-radius: var(--border-radius-sm);
transition: all var(--transition-normal);
white-space: nowrap;
}
.strategy-toggle-btn i {
margin-right: var(--spacing-1);
}
.open-btn {
flex-shrink: 0;
padding: var(--spacing-1) var(--spacing-2);
font-size: var(--font-size-xs);
color: var(--primary-color);
transition: color var(--transition-normal);
}
.open-btn:hover {
color: var(--primary-color-dark);
}
.preview-body {
flex: 1;
overflow: hidden;
display: flex;
flex-direction: column;
background-color: var(--bg-secondary);
min-height: 30vh;
}
.loading-state {
padding: var(--spacing-4);
overflow-y: auto;
flex: 1;
}
.content-preview {
flex: 1;
overflow: hidden;
display: flex;
flex-direction: column;
min-height: 35vh;
width: 100%;
max-width: 100%;
box-sizing: border-box;
}
.empty-state {
display: flex;
justify-content: center;
align-items: center;
flex: 1;
padding: var(--spacing-4);
}
/* HTML内容容器 */
.html-embed {
flex: 1;
overflow-y: auto;
padding: var(--spacing-4);
background-color: var(--bg-primary);
min-height: 70vh;
width: 100%;
max-width: 100%;
box-sizing: border-box;
}
.embed-content {
font-size: var(--font-size-sm);
line-height: var(--line-height-relaxed);
color: var(--text-primary);
min-height: 100%;
width: 100%;
max-width: 100%;
box-sizing: border-box;
}
/* iframe容器 */
.iframe-container {
flex: 1;
overflow: hidden;
display: flex;
flex-direction: column;
background-color: var(--bg-primary);
height: 100%;
width: 100%;
max-width: 100%;
box-sizing: border-box;
}
.embed-iframe {
width: 100%;
height: 100%;
border: none;
flex: 1;
min-height: 70vh;
max-width: 100%;
box-sizing: border-box;
}
.html-iframe {
background-color: var(--bg-primary);
}
/* 添加响应式iframe支持 */
.embed-iframe {
width: 100%;
max-width: 100%;
height: 100%;
border: none;
flex: 1;
min-height: 70vh;
box-sizing: border-box;
}
/* 确保iframe内容适配容器 */
.embed-iframe::-webkit-scrollbar {
width: 6px;
height: 6px;
}
.embed-iframe::-webkit-scrollbar-track {
background: transparent;
}
.embed-iframe::-webkit-scrollbar-thumb {
background: var(--gray-300);
border-radius: var(--border-radius-full);
}
.embed-iframe::-webkit-scrollbar-thumb:hover {
background: var(--gray-400);
}
/* 错误容器 */
.error-container {
flex: 1;
display: flex;
justify-content: center;
align-items: center;
padding: var(--spacing-4);
background-color: var(--bg-primary);
min-height: 70vh;
}
.error-content {
text-align: center;
max-width: 500px;
padding: var(--spacing-6);
border-radius: var(--border-radius-lg);
background-color: var(--bg-secondary);
box-shadow: var(--shadow-sm);
}
.error-icon {
font-size: 3rem;
margin-bottom: var(--spacing-3);
}
.error-title {
font-size: var(--font-size-lg);
font-weight: var(--font-weight-bold);
color: var(--text-primary);
margin-bottom: var(--spacing-2);
}
.error-message {
font-size: var(--font-size-sm);
color: var(--text-secondary);
margin-bottom: var(--spacing-4);
line-height: var(--line-height-relaxed);
}
.error-actions {
display: flex;
justify-content: center;
gap: var(--spacing-2);
}
/* embed元素样式 */
.embed-content :deep(embed) {
max-width: 100%;
width: 100%; /* 强制宽度为100% */
height: auto;
margin: var(--spacing-2) 0;
border: 1px solid var(--border-color);
border-radius: var(--border-radius-md);
max-height: 85vh;
box-sizing: border-box; /* 包含border在内的宽度计算 */
}
.embed-content :deep(p) {
margin: 0 0 var(--spacing-2) 0;
word-wrap: break-word; /* 允许长单词换行 */
word-break: break-word; /* 允许单词内换行 */
overflow-wrap: break-word; /* 现代浏览器的换行属性 */
}
.embed-content :deep(h1),
.embed-content :deep(h2),
.embed-content :deep(h3) {
margin: var(--spacing-3) 0 var(--spacing-2) 0;
color: var(--text-primary);
font-weight: var(--font-weight-bold);
word-wrap: break-word; /* 允许长单词换行 */
word-break: break-word; /* 允许单词内换行 */
}
.embed-content :deep(img) {
max-width: 100%;
width: 100%; /* 强制图片宽度为100% */
height: auto;
margin: var(--spacing-2) 0;
border-radius: var(--border-radius-md);
max-height: 75vh;
box-sizing: border-box; /* 包含border在内的宽度计算 */
}
.embed-content :deep(a) {
color: var(--primary-color);
text-decoration: none;
transition: color var(--transition-normal);
word-wrap: break-word; /* 允许长链接换行 */
}
.embed-content :deep(a:hover) {
color: var(--primary-color-dark);
text-decoration: underline;
}
.embed-content :deep(code) {
background-color: var(--bg-secondary);
padding: var(--spacing-1) var(--spacing-2);
border-radius: var(--border-radius-sm);
font-family: var(--font-family-mono);
font-size: var(--font-size-xs);
word-wrap: break-word; /* 允许长代码换行 */
word-break: break-all; /* 强制代码换行 */
overflow-wrap: break-word; /* 现代浏览器的换行属性 */
}
.embed-content :deep(pre) {
background-color: var(--bg-secondary);
padding: var(--spacing-3);
border-radius: var(--border-radius-md);
overflow-x: auto;
font-family: var(--font-family-mono);
font-size: var(--font-size-xs);
line-height: var(--line-height-normal);
max-height: 60vh;
overflow-y: auto;
width: 100%;
box-sizing: border-box; /* 包含padding在内的宽度计算 */
white-space: pre-wrap; /* 保持格式但允许换行 */
word-wrap: break-word; /* 允许长代码换行 */
}
.embed-content :deep(table) {
width: 100%;
max-width: 100%;
overflow-x: auto;
display: block;
box-sizing: border-box;
}
.embed-content :deep(table th),
.embed-content :deep(table td) {
word-wrap: break-word;
word-break: break-word;
overflow-wrap: break-word;
max-width: 100%;
box-sizing: border-box;
}
.embed-content :deep(blockquote) {
margin: var(--spacing-3) 0;
padding: var(--spacing-3);
border-left: 4px solid var(--border-color);
background-color: var(--bg-secondary);
box-sizing: border-box;
word-wrap: break-word;
word-break: break-word;
}
.embed-content :deep(ul),
.embed-content :deep(ol) {
padding-left: var(--spacing-4);
margin: var(--spacing-2) 0;
box-sizing: border-box;
}
.embed-content :deep(li) {
margin-bottom: var(--spacing-1);
word-wrap: break-word;
word-break: break-word;
overflow-wrap: break-word;
}
.embed-content :deep(div) {
box-sizing: border-box;
}
/* 确保所有子元素都不会超出容器宽度 */
.embed-content :deep(*) {
max-width: 100%;
box-sizing: border-box;
}
/* 特别处理可能引起溢出的元素 */
.embed-content :deep(.overflow-container) {
overflow-x: hidden;
overflow-y: auto;
max-width: 100%;
}
/* 防止横向滚动条出现的额外措施 */
.embed-content :deep(.no-scrollbar-x) {
overflow-x: hidden;
}
.embed-content :deep(.word-break-all) {
word-break: break-all;
}
/* 强制所有元素适应容器宽度 */
.embed-content :deep(*),
.embed-content :deep(::before),
.embed-content :deep(::after) {
box-sizing: border-box;
max-width: 100%;
}
/* 特殊处理长字符串和代码块 */
.embed-content :deep(.long-string) {
word-wrap: break-word;
word-break: break-word;
overflow-wrap: break-word;
white-space: normal;
}
</style>
/* 处理可能的浮动元素 */
.embed-content :deep(.clearfix::after) {
content: "";
display: table;
clear: both;
}
.embed-content :deep(.pull-left) {
float: left;
max-width: 100%;
}
.embed-content :deep(.pull-right) {
float: right;
max-width: 100%;
}
/* 添加额外的容器约束确保内容不会溢出 */
.content-preview {
flex: 1;
overflow: hidden;
display: flex;
flex-direction: column;
min-height: 35vh;
width: 100%;
max-width: 100%; /* 添加最大宽度限制 */
box-sizing: border-box;
}
.html-embed {
flex: 1;
overflow-y: auto;
padding: var(--spacing-4);
background-color: var(--bg-primary);
min-height: 70vh;
width: 100%;
max-width: 100%; /* 添加最大宽度限制 */
box-sizing: border-box;
}
.embed-content {
font-size: var(--font-size-sm);
line-height: var(--line-height-relaxed);
color: var(--text-primary);
min-height: 100%;
width: 100%;
max-width: 100%; /* 添加最大宽度限制 */
box-sizing: border-box;
}
/* 为iframe添加额外的约束 */
.embed-iframe {
width: 100%;
height: 100%;
border: none;
flex: 1;
min-height: 70vh;
max-width: 100%; /* 添加最大宽度限制 */
box-sizing: border-box;
}
/* 底部信息 */
.preview-footer {
display: flex;
gap: var(--spacing-3);
padding: var(--spacing-3);
background-color: var(--bg-secondary);
border-top: 1px solid var(--border-color);
font-size: var(--font-size-xxs);
color: var(--text-tertiary);
flex-shrink: 0;
flex-wrap: wrap;
max-width: 100%; /* 确保底部信息不会溢出 */
box-sizing: border-box;
}
.info-item {
display: inline-flex;
align-items: center;
gap: var(--spacing-1);
}
/* 滚动条美化 */
.preview-body::-webkit-scrollbar,
.html-embed::-webkit-scrollbar {
width: 6px;
}
.preview-body::-webkit-scrollbar-track,
.html-embed::-webkit-scrollbar-track {
background: transparent;
}
.preview-body::-webkit-scrollbar-thumb,
.html-embed::-webkit-scrollbar-thumb {
background: var(--gray-300);
border-radius: var(--border-radius-full);
}
.preview-body::-webkit-scrollbar-thumb:hover,
.html-embed::-webkit-scrollbar-thumb:hover {
background: var(--gray-400);
}
/* 响应式设计 */
@media (max-width: 768px) {
.embed-preview {
max-height: none;
height: auto;
}
.preview-header {
flex-wrap: wrap;
}
.preview-title {
font-size: var(--font-size-xs);
flex: 1 1 auto;
}
.embed-type-tag {
font-size: var(--font-size-xs);
padding: var(--spacing-1) var(--spacing-2);
order: 5;
}
/* 策略控件在小屏幕上需要辅助布局 */
.strategy-controls {
flex: 0 1 100%;
gap: var(--spacing-2);
margin-top: var(--spacing-2);
padding-top: var(--spacing-2);
border-top: 1px solid var(--border-color);
}
.strategy-badge {
font-size: var(--font-size-xs);
padding: var(--spacing-1) var(--spacing-2);
}
.strategy-toggle-group {
flex: 1;
}
.strategy-toggle-btn {
flex: 1;
min-width: 0;
font-size: var(--font-size-xxs);
padding: var(--spacing-1);
}
.strategy-toggle-btn i {
margin-right: var(--spacing-1);
}
.html-embed {
padding: var(--spacing-3);
min-height: 30vh;
}
.embed-content :deep(img) {
max-height: 60vh;
}
.embed-content :deep(pre) {
max-height: 45vh;
padding: var(--spacing-2);
}
.embed-content :deep(embed) {
max-height: 70vh;
}
.preview-header {
padding: var(--spacing-2);
gap: var(--spacing-1);
}
.preview-icon {
font-size: var(--font-size-base);
}
.preview-footer {
padding: var(--spacing-2);
gap: var(--spacing-2);
}
.error-content {
padding: var(--spacing-4);
margin: var(--spacing-2);
}
.error-title {
font-size: var(--font-size-base);
}
.error-message {
font-size: var(--font-size-xs);
}
}
/* 平板设备优化 */
@media (min-width: 769px) and (max-width: 1024px) {
.embed-content :deep(img) {
max-height: 65vh;
}
.embed-content :deep(pre) {
max-height: 50vh;
}
.embed-content :deep(embed) {
max-height: 75vh;
}
.html-embed {
min-height: 35vh;
}
}
/* 大屏幕优化 */
@media (min-width: 1025px) {
.embed-content :deep(img) {
max-height: 75vh;
}
.embed-content :deep(pre) {
max-height: 60vh;
}
.embed-content :deep(embed) {
max-height: 85vh;
}
.html-embed {
min-height: 75vh;
}
}
/* 超大屏幕优化 */
@media (min-width: 1440px) {
.embed-content :deep(img) {
max-height: 80vh;
}
.embed-content :deep(pre) {
max-height: 65vh;
font-size: var(--font-size-sm);
}
.embed-content :deep(embed) {
max-height: 90vh;
}
.html-embed {
padding: var(--spacing-5);
min-height: 80vh;
}
.embed-iframe {
min-height: 80vh;
}
}
/* 添加全局溢出保护 */
.embed-preview {
overflow: hidden;
max-width: 100%;
}
.preview-body {
overflow: hidden;
max-width: 100%;
}
.content-preview {
overflow: hidden;
max-width: 100%;
}
.html-embed {
overflow-x: hidden;
max-width: 100%;
}
.embed-content {
overflow-x: hidden;
max-width: 100%;
}
/* 确保所有子元素都不会产生横向滚动 */
.embed-content :deep(*) {
max-width: 100% !important;
box-sizing: border-box;
}
/* 特别处理可能引起溢出的元素 */
.embed-content :deep(img),
.embed-content :deep(embed),
.embed-content :deep(table),
.embed-content :deep(pre),
.embed-content :deep(code) {
max-width: 100% !important;
box-sizing: border-box;
}
/* 强制文本换行以防止溢出 */
.embed-content :deep(p),
.embed-content :deep(div),
.embed-content :deep(span),
.embed-content :deep(a) {
word-wrap: break-word;
word-break: break-word;
overflow-wrap: break-word;
white-space: normal;
}
\ No newline at end of file
<template>
<div class="embed-preview-test">
<h2>EmbedPreview 组件测试</h2>
<div class="test-section">
<h3>测试1: 直接HTML内容</h3>
<el-input
v-model="htmlContent"
type="textarea"
:rows="4"
placeholder="输入HTML内容"
></el-input>
<el-button @click="testHtmlContent" type="primary">测试HTML内容</el-button>
</div>
<div class="test-section">
<h3>测试2: URL加载</h3>
<el-input
v-model="urlContent"
placeholder="输入URL"
></el-input>
<el-button @click="testUrlContent" type="primary">测试URL内容</el-button>
</div>
<div class="test-section">
<h3>测试结果</h3>
<EmbedPreview
:html-content="currentHtmlContent"
:embed-url="currentUrl"
embed-title="测试预览"
embed-type="test"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import EmbedPreview from './EmbedPreview.vue'
const htmlContent = ref('<h1>测试HTML内容</h1><p>这是一个测试段落。</p>')
const urlContent = ref('https://www.baidu.com')
const currentHtmlContent = ref('')
const currentUrl = ref('')
const testHtmlContent = () => {
currentHtmlContent.value = htmlContent.value
currentUrl.value = ''
}
const testUrlContent = () => {
currentUrl.value = urlContent.value
currentHtmlContent.value = ''
}
</script>
<style scoped>
.embed-preview-test {
padding: 20px;
}
.test-section {
margin-bottom: 30px;
}
.test-section h3 {
margin-bottom: 10px;
}
.el-input {
margin-bottom: 10px;
}
</style>
\ No newline at end of file
<template>
<div class="message-item" :class="{ 'user-message': isUser, 'assistant-message': !isUser }">
<div class="message-avatar">
<div v-if="isUser" class="avatar-user">
<span></span>
</div>
<div v-else class="avatar-assistant">
<span>AI</span>
</div>
</div>
<div class="message-content-wrapper">
<div class="message-meta">
<span class="message-sender">{{ isUser ? '您' : agentName || 'Assistant' }}</span>
<span class="message-time">{{ formatTime(timestamp) }}</span>
</div>
<div class="message-content">
<div v-if="isMarkdown" class="markdown-content" v-html="renderedMarkdown"></div>
<div v-else class="plain-text">{{ content }}</div>
<!-- 打字机动画效果 -->
<div v-if="isStreaming && !isMarkdown" class="typing-cursor">
<span></span><span></span><span></span>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { marked } from 'marked'
import hljs from 'highlight.js'
interface Props {
content: string
isUser: boolean
agentName?: string
timestamp?: number
isStreaming?: boolean
isMarkdown?: boolean
}
const props = withDefaults(defineProps<Props>(), {
timestamp: () => Date.now(),
isStreaming: false,
isMarkdown: true
})
// 配置marked
marked.setOptions({
breaks: true,
gfm: true
})
// 使用renderer自定义代码块样式
const renderer = new marked.Renderer()
const originalCode = renderer.code.bind(renderer)
renderer.code = function(code: any, language?: any) {
// 确保 code 是字符串类型
if (typeof code !== 'string') {
code = String(code || '')
}
// 如果 code 为空字符串,直接返回空的 pre 标签
if (!code) {
return `<pre><code class="hljs language-${language || ''}"></code></pre>`
}
if (language && hljs.getLanguage(language)) {
try {
code = hljs.highlight(code, { language, ignoreIllegals: true }).value
} catch (err) {
console.error('语法高亮错误:', err)
}
} else {
code = hljs.highlightAuto(code).value
}
return `<pre><code class="hljs language-${language || ''}">${code}</code></pre>`
}
marked.setOptions({ renderer })
// 渲染Markdown
const renderedMarkdown = computed(() => {
try {
return marked(props.content) as string
} catch (err) {
console.error('Markdown渲染错误:', err)
return `<p>${props.content}</p>`
}
})
// 格式化时间
const formatTime = (timestamp: number): string => {
const date = new Date(timestamp)
const hours = String(date.getHours()).padStart(2, '0')
const minutes = String(date.getMinutes()).padStart(2, '0')
return `${hours}:${minutes}`
}
</script>
<style scoped>
.message-item {
display: flex;
gap: var(--spacing-3);
margin-bottom: var(--spacing-4);
animation: slideIn 0.3s ease-out;
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.message-avatar {
flex-shrink: 0;
}
.avatar-user,
.avatar-assistant {
width: 36px;
height: 36px;
border-radius: var(--border-radius-lg);
display: flex;
align-items: center;
justify-content: center;
font-size: var(--font-size-sm);
font-weight: var(--font-weight-bold);
color: var(--white);
}
.avatar-user {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.avatar-assistant {
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
}
.message-content-wrapper {
flex: 1;
max-width: 70%;
}
.message-meta {
display: flex;
gap: var(--spacing-2);
margin-bottom: var(--spacing-2);
font-size: var(--font-size-xs);
color: var(--text-tertiary);
}
.message-sender {
font-weight: var(--font-weight-semibold);
color: var(--text-secondary);
}
.message-time {
color: var(--text-tertiary);
}
.message-content {
padding: var(--spacing-3) var(--spacing-4);
background-color: var(--bg-tertiary);
border-radius: var(--border-radius-lg);
word-wrap: break-word;
word-break: break-word;
position: relative;
}
.user-message .message-content-wrapper {
max-width: 70%;
margin-left: auto;
}
.user-message .message-content {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: var(--white);
}
.user-message .message-sender,
.user-message .message-time {
color: var(--text-secondary);
}
.assistant-message .message-content-wrapper {
max-width: 70%;
}
.plain-text {
color: var(--text-primary);
line-height: var(--line-height-relaxed);
}
.markdown-content {
color: var(--text-primary);
line-height: var(--line-height-relaxed);
}
/* Markdown样式 */
.markdown-content :deep(h1),
.markdown-content :deep(h2),
.markdown-content :deep(h3),
.markdown-content :deep(h4),
.markdown-content :deep(h5),
.markdown-content :deep(h6) {
margin: var(--spacing-3) 0 var(--spacing-2) 0;
font-weight: var(--font-weight-bold);
color: var(--text-primary);
}
.markdown-content :deep(h1) {
font-size: var(--font-size-2xl);
border-bottom: 2px solid var(--border-color);
padding-bottom: var(--spacing-2);
}
.markdown-content :deep(h2) {
font-size: var(--font-size-xl);
}
.markdown-content :deep(h3) {
font-size: var(--font-size-lg);
}
.markdown-content :deep(p) {
margin: var(--spacing-2) 0;
}
.markdown-content :deep(code) {
background-color: var(--gray-200);
color: var(--error-color);
padding: var(--spacing-1) var(--spacing-2);
border-radius: var(--border-radius-base);
font-family: var(--font-family-mono);
font-size: 0.9em;
}
.markdown-content :deep(pre) {
background-color: var(--gray-900);
color: var(--gray-100);
padding: var(--spacing-3);
border-radius: var(--border-radius-lg);
overflow-x: auto;
margin: var(--spacing-2) 0;
}
.markdown-content :deep(pre code) {
background-color: transparent;
color: var(--gray-100);
padding: 0;
}
.markdown-content :deep(blockquote) {
border-left: 4px solid var(--primary-color);
padding-left: var(--spacing-3);
margin: var(--spacing-2) 0;
color: var(--text-secondary);
}
.markdown-content :deep(ul),
.markdown-content :deep(ol) {
margin: var(--spacing-2) 0;
padding-left: var(--spacing-5);
}
.markdown-content :deep(li) {
margin: var(--spacing-1) 0;
}
.markdown-content :deep(a) {
color: var(--primary-color);
text-decoration: none;
border-bottom: 1px dotted var(--primary-color);
}
.markdown-content :deep(a:hover) {
text-decoration: underline;
}
.markdown-content :deep(table) {
border-collapse: collapse;
width: 100%;
margin: var(--spacing-2) 0;
}
.markdown-content :deep(th),
.markdown-content :deep(td) {
border: 1px solid var(--border-color);
padding: var(--spacing-2);
text-align: left;
}
.markdown-content :deep(th) {
background-color: var(--bg-secondary);
font-weight: var(--font-weight-semibold);
}
/* 打字机动画 */
.typing-cursor {
display: inline-flex;
gap: 2px;
margin-left: var(--spacing-1);
vertical-align: middle;
}
.typing-cursor span {
width: 4px;
height: 12px;
background-color: var(--text-primary);
border-radius: var(--border-radius-base);
animation: typing 0.6s infinite;
}
.typing-cursor span:nth-child(1) {
animation-delay: 0s;
}
.typing-cursor span:nth-child(2) {
animation-delay: 0.2s;
}
.typing-cursor span:nth-child(3) {
animation-delay: 0.4s;
}
@keyframes typing {
0%, 60%, 100% {
opacity: 0.3;
transform: translateY(0);
}
30% {
opacity: 1;
transform: translateY(-8px);
}
}
.user-message .typing-cursor span {
background-color: var(--white);
}
/* 响应式设计 */
@media (max-width: 768px) {
.message-item {
gap: var(--spacing-2);
}
.message-content-wrapper {
max-width: 90% !important;
}
.message-avatar {
width: 32px;
height: 32px;
}
.avatar-user,
.avatar-assistant {
width: 32px;
height: 32px;
font-size: var(--font-size-xs);
}
}
</style>
<template>
<div class="timeline-panel">
<div class="timeline-header">
<h3>执行过程</h3>
<el-button text @click="clearTimeline" :disabled="events.length === 0">清除</el-button>
</div>
<div class="timeline-container" ref="timelineContainer">
<div v-if="events.length === 0" class="empty-timeline">
<div class="empty-icon">📋</div>
<div class="empty-text">等待执行过程...</div>
</div>
<div v-else class="timeline-list">
<div v-for="(event, index) in events" :key="index" class="timeline-item" :class="event.type">
<div class="timeline-dot"></div>
<div class="timeline-content">
<div class="event-header">
<span class="event-type-badge" :class="event.type">{{ getEventTypeLabel(event.type) }}</span>
<span class="event-time">{{ formatTime(event.timestamp) }}</span>
</div>
<div class="event-body">
<div class="event-title">{{ event.title }}</div>
<div v-if="event.content" class="event-content">
<pre><code>{{ event.content }}</code></pre>
</div>
<!-- 工具调用输入输出详情 -->
<div v-if="(event.type === 'tool_call' || event.type === 'tool_result' || event.type === 'tool_error')" class="tool-details" :style="{ display: isToolDataVisible(event) ? 'block' : 'none' }">
<!-- 展开/折叠按钮 -->
<div class="detail-toggle" @click="toggleExpand(index)">
<span class="toggle-text">{{ getExpandedState(index) ? '收起详情' : '查看详情' }}</span>
<span class="toggle-icon">{{ getExpandedState(index) ? '▲' : '▼' }}</span>
</div>
<!-- 详细信息内容 -->
<div v-show="getExpandedState(index)" class="detail-content">
<!-- 输入参数段 -->
<div v-if="'toolInput' in event" class="tool-input" :key="`input-${index}`">
<div class="detail-title">输入参数</div>
<div v-if="event.toolInput !== null && event.toolInput !== undefined" class="json-display">
<pre><code>{{ formatToolData(event.toolInput) }}</code></pre>
</div>
<div v-else class="json-display" style="color: var(--text-tertiary)">
<pre><code>无数据</code></pre>
</div>
</div>
<!-- 输出结果段 -->
<div v-if="'toolOutput' in event" class="tool-output" :key="`output-${index}`">
<div class="detail-title">输出结果</div>
<div v-if="event.toolOutput !== null && event.toolOutput !== undefined" class="json-display">
<pre><code>{{ formatToolData(event.toolOutput) }}</code></pre>
</div>
<div v-else class="json-display" style="color: var(--text-tertiary)">
<pre><code>无数据</code></pre>
</div>
</div>
</div>
</div>
<div v-if="event.metadata" class="event-metadata">
<div v-for="(value, key) in event.metadata" :key="key" class="metadata-item">
<span class="metadata-key">{{ key }}:</span>
<span class="metadata-value">{{ String(value).substring(0, 100) }}</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, nextTick, onMounted, onUnmounted } from 'vue'
interface TimelineEvent {
type: 'thought' | 'action' | 'observation' | 'result' | 'error' | 'tool_call' | 'tool_result' | 'tool_error' | 'embed'
title: string
content?: string
metadata?: Record<string, any>
timestamp: number
toolName?: string
toolAction?: string
toolInput?: Record<string, any>
toolOutput?: any
toolStatus?: string
executionTime?: number
embedUrl?: string
embedType?: string
embedTitle?: string
embedHtmlContent?: string
}
// 为每个事件维护展开状态
const expandedStates = ref<Record<number, boolean>>({})
// 获取事件的展开状态,确保始终返回布尔值
const getExpandedState = (index: number): boolean => {
return expandedStates.value[index] === true
}
const events = ref<TimelineEvent[]>([])
const timelineContainer = ref<HTMLElement>()
// 切换事件详细信息的展开状态
const toggleExpand = (index: number) => {
expandedStates.value = {
...expandedStates.value,
[index]: !expandedStates.value[index]
}
console.log(`[事件 #${index}] 展开状态已切换:`, expandedStates.value[index]);
}
// 事件类型标签
const getEventTypeLabel = (type: string): string => {
const labels: Record<string, string> = {
'thought': '💭 思考',
'action': '🎬 行动',
'observation': '👀 观察',
'result': '✅ 结果',
'error': '❌ 错误',
'tool_call': '🔧 工具调用',
'tool_result': '📤 工具结果',
'tool_error': '⚠️ 工具错误',
'embed': '🌐 网页预览'
}
return labels[type] || type
}
// 格式化时间
const formatTime = (timestamp: number): string => {
const date = new Date(timestamp)
const hours = String(date.getHours()).padStart(2, '0')
const minutes = String(date.getMinutes()).padStart(2, '0')
const seconds = String(date.getSeconds()).padStart(2, '0')
return `${hours}:${minutes}:${seconds}`
}
// 格式化JSON对象为美观的字符串
const formatJson = (obj: any): string => {
if (obj === null || obj === undefined) {
return '无数据';
}
if (typeof obj === 'string') {
try {
// 尝试解析字符串为JSON对象
const parsed = JSON.parse(obj);
return JSON.stringify(parsed, null, 2);
} catch (e) {
// 如果不是有效的JSON字符串,直接返回原字符串
return obj;
}
} else if (typeof obj === 'object') {
// 如果是对象,直接格式化
return JSON.stringify(obj, null, 2);
} else {
// 其他情况转换为字符串
return String(obj);
}
};
// 格式化工具数据
const formatToolData = (data: any): string => {
try {
console.log('[formatToolData] 接受数据:', { data, type: typeof data });
const result = formatJson(data);
console.log('[formatToolData] 输出结果:', result);
return result;
} catch (error) {
console.error('格式化工具数据出错:', error);
return '数据格式错误';
}
};
// 检查是否为Empty数据
const isEmpty = (data: any): boolean => {
if (data === null || data === undefined) return true;
if (typeof data === 'string') return data.trim().length === 0;
if (Array.isArray(data)) return data.length === 0;
if (typeof data === 'object') return Object.keys(data).length === 0;
return false;
};
// 检查是否有工具数据
const hasToolData = (event: TimelineEvent): boolean => {
// 检查字段是否存在且值不为Empty
const hasInput = 'toolInput' in event && !isEmpty(event.toolInput);
const hasOutput = 'toolOutput' in event && !isEmpty(event.toolOutput);
return hasInput || hasOutput;
};
// 强化地检查是否有工具数据(应用于界面渲染)
const isToolDataVisible = (event: TimelineEvent): boolean => {
if (event.type !== 'tool_call' && event.type !== 'tool_result' && event.type !== 'tool_error') {
return false;
}
return hasToolData(event);
};
// 检查数据是否非空
// 添加时间轴事件
const addEvent = (event: Omit<TimelineEvent, 'timestamp'> & { timestamp?: number }) => {
// 对于thinking事件,检查是否与上一个事件内容相同,避免重复添加
if (event.type === 'thought' && events.value.length > 0) {
const lastEvent = events.value[events.value.length - 1]
if (lastEvent.type === 'thought' && lastEvent.content === event.content) {
// 如果内容相同,不添加重复事件
return
}
}
const newIndex = events.value.length;
const finalEvent = {
...event,
timestamp: event.timestamp || Date.now(),
// 确保工具相关字段被正确保留
// 对于 toolInput 和 toolOutput,我们需要保留它们,即使值为 null
// 这样前端可以区分"字段不存在"和"字段存在但值为null"
...(('toolInput' in event) ? { toolInput: event.toolInput } : {}),
...(('toolOutput' in event) ? { toolOutput: event.toolOutput } : {})
} as TimelineEvent;
events.value.push(finalEvent);
// 调试日志:记录添加的事件(含完整的工具数据信息)
if (event.type === 'tool_call' || event.type === 'tool_result' || event.type === 'tool_error') {
console.log(`[Timeline] 添加工具事件 #${newIndex}:`, {
type: event.type,
toolName: event.toolName,
hasToolInput: 'toolInput' in event,
toolInputValue: event.toolInput,
hasToolOutput: 'toolOutput' in event,
toolOutputValue: event.toolOutput,
finalEvent: finalEvent
});
}
// 初始化新事件的展开状态
expandedStates.value[newIndex] = false;
scrollToBottom()
}
// 滚动到底部
const scrollToBottom = async () => {
await nextTick()
if (timelineContainer.value) {
timelineContainer.value.scrollTop = timelineContainer.value.scrollHeight
}
}
// 清除时间轴
const clearTimeline = () => {
events.value = []
expandedStates.value = {}
}
// 暴露方法供父组件调用
defineExpose({
addEvent,
clearTimeline
})
onMounted(() => {
// 从localStorage获取token
const token = localStorage.getItem('token')
// 构造带认证参数的URL
let eventSourceUrl = '/api/v1/agent/timeline-events'
if (token) {
eventSourceUrl += `?token=${encodeURIComponent(token)}`
}
// 监听工作面板事件
const eventSource = new EventSource(eventSourceUrl)
eventSource.addEventListener('message', (event) => {
try {
const data = JSON.parse(event.data)
console.log('[SSE] 收到时间轴事件:', data.type, data);
// 特别检查工具调用相关字段(详细日志输出)
if (data.type === 'tool_call' || data.type === 'tool_result' || data.type === 'tool_error') {
console.log(`[SSE] 接收工具事件 - 类型: ${data.type}, 工具: ${data.toolName}`, {
toolInput: data.toolInput,
toolOutput: data.toolOutput,
hasToolInput: 'toolInput' in data,
hasToolOutput: 'toolOutput' in data,
rawData: data
});
}
// 构建事件标题
let title = data.title || '事件'
if (data.type === 'tool_call' && data.toolName) {
title = `调用工具: ${data.toolName}`
} else if (data.type === 'tool_result' && data.toolName) {
title = `${data.toolName} 执行成功`
} else if (data.type === 'tool_error' && data.toolName) {
title = `${data.toolName} 执行失败`
}
// 构建元数据
const metadata: Record<string, any> = data.metadata || {}
if (data.type === 'tool_call' || data.type === 'tool_result' || data.type === 'tool_error') {
if (data.toolName) metadata['工具'] = data.toolName
if (data.toolAction) metadata['操作'] = data.toolAction
if (data.toolInput) metadata['输入'] = JSON.stringify(data.toolInput).substring(0, 100)
if (data.toolOutput) metadata['输出'] = String(data.toolOutput).substring(0, 100)
if (data.toolStatus) metadata['状态'] = data.toolStatus
if (data.executionTime) metadata['耗时'] = `${data.executionTime}ms`
} else if (data.type === 'embed') {
if (data.embedUrl) metadata['URL'] = data.embedUrl
if (data.embedType) metadata['类型'] = data.embedType
if (data.embedTitle) title = data.embedTitle
}
const timelineEventData = {
type: data.type || 'observation',
title: title,
content: data.content,
metadata: Object.keys(metadata).length > 0 ? metadata : undefined,
toolName: data.toolName,
toolAction: data.toolAction,
// 明确处理工具输入输出字段,确保它们不会丢失
// 只要字段存在就保留它们,即使值为 null 或 undefined
...(('toolInput' in data) ? { toolInput: data.toolInput } : {}),
...(('toolOutput' in data) ? { toolOutput: data.toolOutput } : {}),
toolStatus: data.toolStatus,
executionTime: data.executionTime,
embedUrl: data.embedUrl,
embedType: data.embedType,
embedTitle: data.embedTitle,
embedHtmlContent: data.embedHtmlContent,
timestamp: data.timestamp
}
// 特别记录工具事件的详细信息
if (timelineEventData.type === 'tool_call' || timelineEventData.type === 'tool_result' || timelineEventData.type === 'tool_error') {
console.log(`[Timeline] 构建事件完成 - 类型: ${timelineEventData.type}`, {
toolInput: timelineEventData.toolInput,
toolOutput: timelineEventData.toolOutput,
hasToolInput: 'toolInput' in timelineEventData,
hasToolOutput: 'toolOutput' in timelineEventData,
eventData: timelineEventData
});
}
console.log('[Timeline] 添加事件:', timelineEventData.type, timelineEventData);
addEvent(timelineEventData)
// 触发embed事件给父组件
if (data.type === 'embed' && data.embedUrl) {
window.dispatchEvent(new CustomEvent('embed-event', {
detail: {
url: data.embedUrl,
type: data.embedType,
title: data.embedTitle,
htmlContent: data.embedHtmlContent
}
}))
}
} catch (err) {
console.error('解析时间轴事件失败:', err)
}
})
eventSource.addEventListener('error', (error) => {
console.error('SSE连接错误:', error)
eventSource.close()
})
// 监听来自ChatArea的思考事件
const handleTimelineEvent = (e: CustomEvent) => {
const eventData = e.detail
// 保留完整的事件对象,不丢失任何字段(如 toolName, toolInput, toolOutput 等)
addEvent({
type: eventData.type || 'thought',
title: eventData.title || '思考过程',
content: eventData.content,
// 保留所有工具相关字段
toolName: eventData.toolName,
toolAction: eventData.toolAction,
toolInput: eventData.toolInput,
toolOutput: eventData.toolOutput,
toolStatus: eventData.toolStatus,
executionTime: eventData.executionTime,
// 保留所有embed相关字段
embedUrl: eventData.embedUrl,
embedType: eventData.embedType,
embedTitle: eventData.embedTitle,
embedHtmlContent: eventData.embedHtmlContent,
// 保留元数据和时间戳
metadata: eventData.metadata,
timestamp: eventData.timestamp
})
}
window.addEventListener('timeline-event', handleTimelineEvent as EventListener)
// 在组件卸载时移除事件监听器
onUnmounted(() => {
window.removeEventListener('timeline-event', handleTimelineEvent as EventListener)
})
})
</script>
<style scoped>
.timeline-panel {
display: flex;
flex-direction: column;
height: 100%;
background-color: var(--bg-primary);
min-height: 0; /* 允许容器收缩 */
}
.timeline-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--spacing-4);
border-bottom: 1px solid var(--border-color);
background-color: var(--bg-secondary);
flex-shrink: 0;
}
.timeline-header h3 {
margin: 0;
font-size: var(--font-size-lg);
font-weight: var(--font-weight-semibold);
color: var(--text-primary);
}
.timeline-container {
flex: 1;
overflow-y: auto;
padding: var(--spacing-4);
min-height: 0; /* 允许容器收缩 */
}
.empty-timeline {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
color: var(--text-tertiary);
text-align: center;
}
.empty-icon {
font-size: 48px;
margin-bottom: var(--spacing-4);
}
.empty-text {
font-size: var(--font-size-sm);
color: var(--text-tertiary);
}
.timeline-list {
position: relative;
padding-left: var(--spacing-4);
}
/* 竖线 */
.timeline-list::before {
content: '';
position: absolute;
left: 7px;
top: 0;
bottom: 0;
width: 2px;
background: linear-gradient(180deg, var(--primary-color) 0%, var(--border-color) 100%);
}
.timeline-item {
position: relative;
margin-bottom: var(--spacing-4);
display: flex;
gap: var(--spacing-3);
}
.timeline-dot {
position: absolute;
left: -18px;
top: 6px;
width: 16px;
height: 16px;
border-radius: 50%;
background: var(--bg-primary);
border: 3px solid var(--primary-color);
flex-shrink: 0;
z-index: 1;
}
.timeline-item.action .timeline-dot {
border-color: var(--warning-color);
}
.timeline-item.observation .timeline-dot {
border-color: var(--info-color);
}
.timeline-item.result .timeline-dot {
border-color: var(--success-color);
}
.timeline-item.error .timeline-dot {
border-color: var(--error-color);
}
.timeline-item.tool_call .timeline-dot {
border-color: #faad14;
}
.timeline-item.tool_result .timeline-dot {
border-color: #52c41a;
}
.timeline-item.tool_error .timeline-dot {
border-color: #f5222d;
}
.timeline-content {
flex: 1;
margin-top: -2px;
}
.event-header {
display: flex;
align-items: center;
gap: var(--spacing-2);
margin-bottom: var(--spacing-2);
}
.event-type-badge {
display: inline-block;
padding: 2px 8px;
border-radius: var(--border-radius-base);
font-size: var(--font-size-xs);
font-weight: var(--font-weight-semibold);
background-color: var(--bg-tertiary);
color: var(--text-primary);
}
.event-type-badge.thought {
background-color: rgba(102, 126, 234, 0.1);
color: var(--primary-color);
}
.event-type-badge.action {
background-color: rgba(250, 173, 20, 0.1);
color: var(--warning-color);
}
.event-type-badge.observation {
background-color: rgba(24, 144, 255, 0.1);
color: var(--info-color);
}
.event-type-badge.result {
background-color: rgba(82, 196, 26, 0.1);
color: var(--success-color);
}
.event-type-badge.error {
background-color: rgba(245, 34, 45, 0.1);
color: var(--error-color);
}
.event-type-badge.tool_call {
background-color: rgba(250, 173, 20, 0.1);
color: #faad14;
}
.event-type-badge.tool_result {
background-color: rgba(82, 196, 26, 0.1);
color: #52c41a;
}
.event-type-badge.tool_error {
background-color: rgba(245, 34, 45, 0.1);
color: #f5222d;
}
.event-type-badge.embed {
background-color: rgba(24, 144, 255, 0.1);
color: #1890ff;
}
.timeline-item.embed .timeline-dot {
border-color: #1890ff;
}
.event-time {
font-size: var(--font-size-xs);
color: var(--text-tertiary);
margin-left: auto;
}
.event-title {
font-weight: var(--font-weight-semibold);
color: var(--text-primary);
margin-bottom: var(--spacing-2);
}
.event-content {
margin: var(--spacing-2) 0;
background-color: var(--bg-tertiary);
border-radius: var(--border-radius-md);
overflow-x: auto;
}
.event-content pre {
margin: 0;
padding: var(--spacing-3);
font-size: var(--font-size-xs);
color: var(--text-primary);
font-family: var(--font-family-mono);
background-color: transparent;
line-height: var(--line-height-snug);
}
.event-content code {
all: unset;
font-family: var(--font-family-mono);
font-size: var(--font-size-xs);
color: var(--text-primary);
line-height: var(--line-height-snug);
white-space: pre-wrap;
word-wrap: break-word;
}
/* JSON显示区域 */
.json-display {
background-color: var(--bg-secondary);
border-radius: var(--border-radius-base);
overflow: hidden;
border: 1px solid var(--border-color);
}
.json-display pre {
margin: 0;
padding: var(--spacing-3);
background-color: transparent;
overflow-x: auto;
max-height: 300px;
overflow-y: auto;
}
.json-display code {
font-family: var(--font-family-mono);
font-size: var(--font-size-xs);
color: var(--text-primary);
line-height: var(--line-height-normal);
white-space: pre;
word-wrap: break-word;
}
/* 工具调用详情样式 */
.tool-details {
margin: var(--spacing-3) 0;
border: 1px solid var(--border-color);
border-radius: var(--border-radius-md);
overflow: hidden;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
}
/* 展开/折叠按钮 */
.detail-toggle {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--spacing-2) var(--spacing-3);
background-color: var(--bg-secondary);
cursor: pointer;
user-select: none;
transition: background-color 0.2s ease;
}
.detail-toggle:hover {
background-color: var(--bg-tertiary);
}
.toggle-text {
font-size: var(--font-size-sm);
font-weight: var(--font-weight-medium);
color: var(--text-primary);
}
.toggle-icon {
font-size: var(--font-size-xs);
color: var(--text-secondary);
}
.detail-content {
border-top: 1px solid var(--border-color);
}
.tool-input, .tool-output {
padding: 0;
}
.tool-input .detail-title {
background-color: rgba(24, 144, 255, 0.1);
padding: var(--spacing-2) var(--spacing-3);
border-bottom: 1px solid var(--border-color);
}
.tool-output .detail-title {
background-color: rgba(82, 196, 26, 0.1);
padding: var(--spacing-2) var(--spacing-3);
border-bottom: 1px solid var(--border-color);
}
.detail-title {
font-weight: var(--font-weight-semibold);
color: var(--text-primary);
font-size: var(--font-size-sm);
}
.tool-input pre, .tool-output pre {
margin: 0;
padding: var(--spacing-2);
background-color: var(--bg-secondary);
border-radius: var(--border-radius-base);
overflow-x: auto;
}
.tool-input code, .tool-output code {
font-family: var(--font-family-mono);
font-size: var(--font-size-xs);
color: var(--text-primary);
line-height: var(--line-height-normal);
white-space: pre-wrap;
word-wrap: break-word;
}
.event-metadata {
margin-top: var(--spacing-2);
padding: var(--spacing-2);
background-color: var(--bg-tertiary);
border-radius: var(--border-radius-md);
font-size: var(--font-size-xs);
}
.metadata-item {
display: flex;
gap: var(--spacing-2);
margin-bottom: var(--spacing-1);
}
.metadata-item:last-child {
margin-bottom: 0;
}
.metadata-key {
font-weight: var(--font-weight-semibold);
color: var(--text-secondary);
min-width: 60px;
}
.metadata-value {
color: var(--text-tertiary);
word-break: break-all;
}
/* 滚动样式 */
.timeline-container::-webkit-scrollbar {
width: 6px;
}
.timeline-container::-webkit-scrollbar-track {
background: transparent;
}
.timeline-container::-webkit-scrollbar-thumb {
background: var(--gray-300);
border-radius: 3px;
}
.timeline-container::-webkit-scrollbar-thumb:hover {
background: var(--gray-400);
}
/* 响应式设计 */
@media (max-width: 768px) {
.timeline-header {
padding: var(--spacing-3);
}
.timeline-container {
padding: var(--spacing-3);
}
.timeline-list {
padding-left: var(--spacing-3);
}
.timeline-dot {
left: -15px;
width: 12px;
height: 12px;
border-width: 2px;
}
.event-header {
flex-direction: column;
align-items: flex-start;
gap: var(--spacing-1);
}
.event-time {
margin-left: 0;
}
}
</style>
\ No newline at end of file
<template>
<div class="top-navbar">
<div class="nav-container">
<!-- Logo/标题 -->
<div class="nav-brand">
<span class="brand-icon">🤖</span>
<span class="brand-text">HiAgent</span>
</div>
<!-- 水平菜单 -->
<el-menu
:default-active="activeMenu"
class="nav-menu"
mode="horizontal"
@select="handleMenuSelect"
router
>
<el-menu-item index="/dashboard">
<i class="el-icon-dashboard"></i>
<span>工作台</span>
</el-menu-item>
<el-menu-item index="/agent">
<i class="el-icon-setting"></i>
<span>Agent管理</span>
</el-menu-item>
<el-menu-item index="/tools">
<i class="el-icon-tools"></i>
<span>工具管理</span>
</el-menu-item>
<el-menu-item index="/documents">
<i class="el-icon-document"></i>
<span>知识库</span>
</el-menu-item>
<el-menu-item index="/memory">
<i class="el-icon-tickets"></i>
<span>记忆管理</span>
</el-menu-item>
<el-menu-item index="/llm-config">
<i class="el-icon-setting"></i>
<span>LLM配置</span>
</el-menu-item>
<el-sub-menu index="auth-management">
<template #title>
<i class="el-icon-lock"></i>
<span>认证管理</span>
</template>
<el-menu-item index="/oauth2-providers">OAuth2配置</el-menu-item>
</el-sub-menu>
<el-menu-item index="/chat">
<i class="el-icon-chat-dot-round"></i>
<span>智能对话</span>
</el-menu-item>
<!-- 添加DOM同步页面导航项 -->
<el-menu-item index="/dom-sync">
<i class="el-icon-monitor"></i>
<span>DOM同步</span>
</el-menu-item>
<!-- 添加新聊天页面导航项 -->
<el-menu-item index="/new-chat">
<i class="el-icon-chat-line-round"></i>
<span>新聊天</span>
</el-menu-item>
</el-menu>
<!-- 用户信息和下拉菜单 -->
<div class="user-menu">
<el-dropdown @command="handleCommand">
<span class="el-dropdown-link">
<el-avatar :size="32" icon="UserFilled" />
<span class="username">{{ userInfo?.username || '未知用户' }}</span>
<el-icon class="el-icon--right">
<arrow-down />
</el-icon>
</span>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="profile">个人资料</el-dropdown-item>
<el-dropdown-item command="settings">设置</el-dropdown-item>
<el-dropdown-item divided command="logout">退出登录</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { ElMessage } from 'element-plus'
import { ArrowDown } from '@element-plus/icons-vue'
const router = useRouter()
const route = useRoute()
const authStore = useAuthStore()
const userInfo = ref(null)
const activeMenu = computed(() => {
const path = route.path
if (path === '/') {
return '/dashboard'
}
// 特殊处理认证管理子菜单
if (path.startsWith('/oauth2-providers')) {
return '/oauth2-providers'
}
const menuItems = ['/dashboard', '/agent', '/tools', '/documents', '/memory', '/llm-config', '/chat', '/dom-sync', '/new-chat']
if (menuItems.includes(path)) {
return path
}
for (const item of menuItems) {
if (path.startsWith(item) && item !== '/') {
return item
}
}
return path
})
const handleMenuSelect = (key) => {
if (key !== 'logout') {
router.push(key)
}
}
const handleCommand = (command) => {
if (command === 'logout') {
authStore.logout()
router.push('/login')
ElMessage.success('已登出')
}
}
userInfo.value = authStore.userInfo
</script>
<style scoped>
.top-navbar {
background-color: var(--bg-primary);
border-bottom: 1px solid var(--border-color);
box-shadow: var(--shadow-sm);
position: sticky;
top: 0;
z-index: var(--z-index-sticky);
}
.nav-container {
display: flex;
align-items: center;
height: 60px;
padding: 0 20px;
gap: 30px;
max-width: 100%;
}
.nav-brand {
display: flex;
align-items: center;
gap: 10px;
white-space: nowrap;
flex-shrink: 0;
}
.brand-icon {
font-size: 24px;
}
.brand-text {
font-size: 18px;
font-weight: var(--font-weight-bold);
color: var(--text-primary);
}
.nav-menu {
flex: 1;
border: none !important;
}
.nav-menu :deep(.el-menu-item),
.nav-menu :deep(.el-sub-menu__title) {
height: 60px;
line-height: 60px;
border: none !important;
}
.nav-menu :deep(.el-menu-item.is-active),
.nav-menu :deep(.el-sub-menu.is-active) {
border-bottom: 2px solid var(--color-primary) !important;
color: var(--color-primary) !important;
}
.user-menu {
flex-shrink: 0;
}
.el-dropdown-link {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
color: var(--text-primary);
}
.username {
font-size: 14px;
max-width: 120px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
@media (max-width: 768px) {
.nav-container {
padding: 0 10px;
gap: 15px;
}
.brand-text {
font-size: 16px;
}
.username {
display: none;
}
}
</style>
\ No newline at end of file
<template>
<div class="webpage-browser">
<!-- <div class="browser-header">
<div class="browser-toolbar">
<el-button @click="goBack" :disabled="!canGoBack" circle>
<el-icon><arrow-left /></el-icon>
</el-button>
<el-button @click="goForward" :disabled="!canGoForward" circle>
<el-icon><arrow-right /></el-icon>
</el-button>
<el-button @click="refresh" circle>
<el-icon><refresh /></el-icon>
</el-button>
<el-input
v-model="currentUrl"
placeholder="输入URL或搜索内容..."
@keydown.enter="navigateTo"
class="url-input"
/>
<el-button @click="navigateTo" type="primary">
<el-icon><search /></el-icon>
<span>导航</span>
</el-button>
</div>
</div> -->
<div class="browser-content">
<div v-if="!currentUrl" class="browser-empty">
<div class="empty-icon">🌐</div>
<div class="empty-text">开始浏览</div><!--输入URL或搜索内容-->
</div>
<div v-else-if="loadError" class="browser-error">
<div class="error-icon">⚠️</div>
<div class="error-title">页面加载失败</div>
<div class="error-message">{{ loadError }}</div>
<el-button @click="clearError" type="primary">清除错误</el-button>
</div>
<!-- 根据embedHtmlContent是否存在显示EmbedPreview或iframe -->
<div v-else-if="embedHtmlContent" class="embed-preview-panel">
<embed-preview
:html-content="embedHtmlContent"
:embed-url="currentUrl"
:embed-type="embedType"
:embed-title="embedTitle"
/>
</div>
<!-- 显示传统的iframe -->
<iframe
v-else
ref="browserFrame"
:src="proxiedUrl"
class="browser-iframe"
:title="`Browser - ${currentUrl}`"
@load="handleIframeLoad"
@error="handleIframeError"
sandbox="allow-same-origin allow-scripts allow-popups allow-forms allow-pointer-lock"
></iframe>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import EmbedPreview from './EmbedPreview.vue'
interface NavigationOptions {
htmlContent?: string
embedType?: string
embedTitle?: string
}
const currentUrl = ref('')
const browserHistory = ref<string[]>([])
const historyIndex = ref(-1)
const loadError = ref('')
// 新增:embed相关状态
const embedHtmlContent = ref('')
const embedType = ref('')
const embedTitle = ref('')
// 代理URL(实现CORS)
const proxiedUrl = computed(() => {
if (!currentUrl.value) return ''
// 获取JWT token
const token = localStorage.getItem('token')
// 构造基础代理URL(注意端口应该与后端服务一致)
let baseUrl = '/api/v1/proxy'
// 如果不是有效的URL,则作为搜索查询
if (!currentUrl.value.match(/^https?:\/\//)) {
// 使用百度搜索
let proxyUrl = `${baseUrl}?url=${encodeURIComponent(`https://www.baidu.com/s?ie=utf-8&wd=${currentUrl.value}`)}`
if (token) {
proxyUrl += `&token=${encodeURIComponent(token)}`
}
return proxyUrl
}
let proxyUrl = `${baseUrl}?url=${encodeURIComponent(currentUrl.value)}`
if (token) {
proxyUrl += `&token=${encodeURIComponent(token)}`
}
return proxyUrl
})
// 是否可以后退
// 是否可以前进
// 后退
// 前进
// 刷新
// 清除错误
const clearError = () => {
loadError.value = ''
currentUrl.value = ''
}
// 导航到URL
const navigateTo = () => {
if (!currentUrl.value.trim()) return
let url = currentUrl.value.trim()
// 如果不是有效的URL,则作为搜索查询
if (!url.match(/^https?:\/\//)) {
// 添加https://前缀用于网址,或作为搜索查询
if (url.includes('.') && !url.includes(' ')) {
url = `https://${url}`
} else {
// 保持为搜索查询
}
}
loadError.value = ''
currentUrl.value = url
// 更新浏览历史
if (historyIndex.value < browserHistory.value.length - 1) {
// 如果在历史中间,删除后续历史
browserHistory.value = browserHistory.value.slice(0, historyIndex.value + 1)
}
browserHistory.value.push(url)
historyIndex.value = browserHistory.value.length - 1
}
// 处理iframe加载成功
const handleIframeLoad = () => {
console.log('iframe加载成功:', currentUrl.value)
loadError.value = ''
}
// 处理iframe加载失败
const handleIframeError = () => {
console.error('iframe加载失败:', currentUrl.value)
// 检查是否是X-Frame-Options问题
loadError.value = `无法加载此网页:目标网站(${currentUrl.value})可能禁止了在iframe中显示
或网络连接失败。
这通常是由于网站的安全策略设置了X-Frame-Options头部。
您可以直接在浏览器新标签页中打开此网站。`
}
// 新增方法:由父组件调用,用于自动导航到指定的embed URL
const navigateToUrl = (url: string, options?: NavigationOptions) => {
console.log('[WebpageBrowser] navigateToUrl被调用', {
url: url,
hasHtmlContent: !!options?.htmlContent,
htmlSize: options?.htmlContent?.length || 0,
embedType: options?.embedType,
embedTitle: options?.embedTitle
})
// URL验证
if (!url || typeof url !== 'string') {
console.error('[WebpageBrowser] URL参数类型错誤:', typeof url)
return
}
const trimmedUrl = url.trim()
if (!trimmedUrl) {
console.error('[WebpageBrowser] URL为空')
return
}
// 验证URL格式
try {
new URL(trimmedUrl)
} catch (e) {
console.error('[WebpageBrowser] URL格式无效:', trimmedUrl, e)
return
}
// 清除之前的错误和embed内容
loadError.value = ''
embedHtmlContent.value = ''
embedType.value = ''
embedTitle.value = ''
currentUrl.value = trimmedUrl
// 保存embed相关信息供EmbedPreview使用
if (options?.htmlContent) {
embedHtmlContent.value = options.htmlContent
embedType.value = options.embedType || ''
embedTitle.value = options.embedTitle || ''
console.log('[WebpageBrowser] embed内容已保存, 将使用服务器返回的HTML')
} else {
console.log('[WebpageBrowser] 无embed内容, 将使用iframe加载URL')
}
// 更新浏览历史
if (historyIndex.value < browserHistory.value.length - 1) {
browserHistory.value = browserHistory.value.slice(0, historyIndex.value + 1)
}
browserHistory.value.push(trimmedUrl)
historyIndex.value = browserHistory.value.length - 1
console.log('[WebpageBrowser] 导航完成', {
currentUrl: currentUrl.value,
historyLength: browserHistory.value.length
})
}
// 暴露embed相关状态供EmbedPreview使用
defineExpose({
navigateTo,
navigateToUrl,
embedHtmlContent,
embedType,
embedTitle
})
</script>
<style scoped>
.webpage-browser {
display: flex;
flex-direction: column;
height: 100%;
background-color: var(--bg-primary);
}
.browser-header {
padding: var(--spacing-3);
border-bottom: 1px solid var(--border-color);
background-color: var(--bg-secondary);
flex-shrink: 0;
}
.browser-toolbar {
display: flex;
align-items: center;
gap: var(--spacing-2);
}
.url-input {
flex: 1;
}
.browser-content {
flex: 1;
position: relative;
overflow: hidden;
display: flex;
flex-direction: column;
}
.browser-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
color: var(--text-tertiary);
text-align: center;
background-color: var(--bg-primary);
}
.empty-icon {
font-size: 48px;
margin-bottom: var(--spacing-4);
}
.empty-text {
font-size: var(--font-size-sm);
color: var(--text-tertiary);
}
.browser-iframe {
width: 100%;
height: 100%;
border: none;
background-color: var(--white);
}
/* 添加embed-preview容器样式 */
.embed-preview-panel {
width: 100%;
height: 100%;
overflow: hidden;
display: flex;
flex-direction: column;
}
.browser-error {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
color: var(--text-secondary);
text-align: center;
background-color: var(--bg-primary);
padding: var(--spacing-4);
}
.error-icon {
font-size: 48px;
margin-bottom: var(--spacing-4);
}
.error-title {
font-size: var(--font-size-lg);
font-weight: 600;
color: var(--text-primary);
margin-bottom: var(--spacing-2);
}
.error-message {
font-size: var(--font-size-sm);
color: var(--text-secondary);
margin-bottom: var(--spacing-4);
white-space: pre-line;
line-height: 1.6;
max-width: 500px;
}
/* 响应式设计 */
@media (max-width: 768px) {
.browser-header {
padding: var(--spacing-2);
}
.browser-toolbar {
gap: var(--spacing-1);
flex-wrap: wrap;
}
.url-input {
flex: 1 1 100%;
min-width: 100%;
}
}
</style>
<template>
<div class="work-area">
<el-tabs v-model="activeTab" class="work-tabs">
<el-tab-pane label="📋 时间轴" name="timeline">
<timeline-panel ref="timelinePanel" />
</el-tab-pane>
<el-tab-pane label="🌐 网页浏览" name="browser">
<webpage-browser ref="webBrowser" />
</el-tab-pane>
</el-tabs>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
import TimelinePanel from './TimelinePanel.vue'
import WebpageBrowser from './WebpageBrowser.vue'
const activeTab = ref('timeline')
const timelinePanel = ref()
const webBrowser = ref()
// 监听embed事件
const handleEmbedEvent = (e: Event) => {
const customEvent = e as CustomEvent
const { url, type, title, htmlContent } = customEvent.detail
// 添加详细日志便于问题定位(遵循用户偏好:接口异常时强制日志输出)
console.log('[WorkArea] 接收embed事件完整信息:', {
url: url,
type: type,
title: title,
hasHtmlContent: !!htmlContent,
htmlSize: htmlContent?.length || 0,
timestamp: new Date().toISOString()
})
// 验证URL有效性
if (!url || typeof url !== 'string' || url.trim() === '') {
console.error('[WorkArea] embed事件URL验证失败:', {
url: url,
type: typeof url,
isEmpty: url?.trim() === '',
detail: customEvent.detail
})
return
}
// 自动切换到浏览器标签页
activeTab.value = 'browser'
console.log('[WorkArea] 切换到browser标签页')
// 调用WebpageBrowser的导航方法,传递完整信息
if (webBrowser.value && typeof webBrowser.value.navigateToUrl === 'function') {
webBrowser.value.navigateToUrl(url, {
htmlContent: htmlContent,
embedType: type,
embedTitle: title
})
console.log('[WorkArea] 调用navigateToUrl成功')
} else {
console.error('[WorkArea] webBrowser引用无效或navigateToUrl方法不存在', {
hasWebBrowser: !!webBrowser.value,
hasFn: webBrowser.value ? typeof webBrowser.value.navigateToUrl : 'undefined'
})
}
}
onMounted(() => {
// 监听embed事件
window.addEventListener('embed-event', handleEmbedEvent)
})
onUnmounted(() => {
// 移除事件监听
window.removeEventListener('embed-event', handleEmbedEvent)
})
// 暴露方法供父组件调用
defineExpose({
timelinePanel,
webBrowser,
activeTab
})
</script>
<style scoped>
.work-area {
display: flex;
flex-direction: column;
height: 100%;
background-color: var(--bg-primary);
overflow: hidden;
min-height: 0; /* 允许容器收缩 */
}
.work-tabs {
height: 100%;
display: flex;
flex-direction: column;
min-height: 0; /* 允许flex容器正稳地收缩 */
}
.work-tabs :deep(.el-tabs__header) {
margin: 0;
border-bottom: 1px solid var(--border-color);
}
.work-tabs :deep(.el-tabs__nav-wrap::after) {
background-color: transparent;
}
.work-tabs :deep(.el-tabs__content) {
flex: 1;
overflow: hidden;
}
.work-tabs :deep(.el-tab-pane) {
height: 100%;
}
/* 响应式设计 */
@media (max-width: 768px) {
.work-tabs :deep(.el-tabs__header) {
padding: 0;
}
}
</style>
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_APP_TITLE: string
// 更多环境变量...
}
interface ImportMeta {
readonly env: ImportMetaEnv
}
\ No newline at end of file
import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
import { createPinia } from 'pinia'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import 'highlight.js/styles/atom-one-dark.css'
import './styles/variables.css'
import './styles/global.css'
// 导入default-passive-events库来解决wheel事件监听器的passive警告
import 'default-passive-events'
// 导入所有Element Plus图标并全局注册
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
const app = createApp(App)
app.use(createPinia())
app.use(router)
app.use(ElementPlus)
// 全局注册所有Element Plus图标
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
app.component(key, component)
}
app.mount('#app')
\ No newline at end of file
...@@ -5,14 +5,20 @@ ...@@ -5,14 +5,20 @@
创建Agent 创建Agent
</el-button> </el-button>
<el-table :data="agents" stripe style="width: 100%"> <el-table :data="agents" stripe style="width: 100%" v-loading="loading">
<el-table-column prop="name" label="名称" /> <el-table-column prop="name" label="名称" />
<el-table-column prop="description" label="描述" /> <el-table-column prop="description" label="描述" />
<el-table-column prop="defaultModel" label="默认模型" /> <el-table-column prop="defaultModel" label="默认模型" />
<el-table-column label="工具数量">
<template #default="{ row }">
<el-tag v-if="getToolCount(row) > 0">{{ getToolCount(row) }}</el-tag>
<span v-else>全部</span>
</template>
</el-table-column>
<el-table-column prop="status" label="状态"> <el-table-column prop="status" label="状态">
<template #default="{ row }"> <template #default="{ row }">
<el-tag :type="row.status === 'active' ? 'success' : 'warning'"> <el-tag :type="row.status === 'active' ? 'success' : 'warning'">
{{ row.status }} {{ getStatusText(row.status) }}
</el-tag> </el-tag>
</template> </template>
</el-table-column> </el-table-column>
...@@ -24,118 +30,305 @@ ...@@ -24,118 +30,305 @@
</el-table-column> </el-table-column>
</el-table> </el-table>
<el-dialog v-model="dialogVisible" :title="isEdit ? '编辑Agent' : '创建Agent'"> <el-dialog v-model="dialogVisible" :title="isEdit ? '编辑Agent' : '创建Agent'" width="600px" @close="resetForm" @open="loadEnabledLlmConfigs">
<el-form :model="form"> <el-form :model="form" :rules="rules" ref="formRef" label-width="120px">
<el-form-item label="名称"> <el-form-item label="名称" prop="name">
<el-input v-model="form.name" /> <el-input v-model="form.name" />
</el-form-item> </el-form-item>
<el-form-item label="描述"> <el-form-item label="描述" prop="description">
<el-input v-model="form.description" type="textarea" /> <el-input v-model="form.description" type="textarea" />
</el-form-item> </el-form-item>
<el-form-item label="默认模型"> <el-form-item label="默认模型" prop="defaultModel">
<el-select v-model="form.defaultModel" placeholder="请选择默认模型"> <el-select v-model="form.defaultModel" placeholder="请选择默认模型">
<el-option label="DeepSeek-Chat" value="deepseek-chat" /> <el-option
<el-option label="GPT-3.5 Turbo" value="gpt-3.5-turbo" /> v-for="config in enabledLlmConfigs"
<el-option label="Llama2" value="llama2" /> :key="config.id"
:label="`${config.name} (${config.modelName})`"
:value="config.name">
</el-option>
</el-select> </el-select>
</el-form-item> </el-form-item>
<el-form-item label="状态"> <el-form-item label="系统提示词" prop="systemPrompt">
<el-input v-model="form.systemPrompt" type="textarea" :rows="3" />
</el-form-item>
<el-form-item label="提示词模板" prop="promptTemplate">
<el-input v-model="form.promptTemplate" type="textarea" :rows="3" />
</el-form-item>
<el-form-item label="温度参数" prop="temperature">
<el-slider v-model="form.temperature" :min="0" :max="2" :step="0.1" show-input />
</el-form-item>
<el-form-item label="最大Token数" prop="maxTokens">
<el-input-number v-model="form.maxTokens" :min="1" :max="8192" />
</el-form-item>
<el-form-item label="Top P" prop="topP">
<el-slider v-model="form.topP" :min="0" :max="1" :step="0.1" show-input />
</el-form-item>
<el-form-item label="Top K" prop="topK">
<el-input-number v-model="form.topK" :min="1" :max="100" />
</el-form-item>
<el-form-item label="存在惩罚" prop="presencePenalty">
<el-slider v-model="form.presencePenalty" :min="0" :max="2" :step="0.1" show-input />
</el-form-item>
<el-form-item label="频率惩罚" prop="frequencyPenalty">
<el-slider v-model="form.frequencyPenalty" :min="0" :max="2" :step="0.1" show-input />
</el-form-item>
<el-form-item label="历史记忆长度" prop="historyLength">
<el-input-number v-model="form.historyLength" :min="1" :max="50" />
</el-form-item>
<el-form-item label="状态" prop="status">
<el-select v-model="form.status"> <el-select v-model="form.status">
<el-option label="活跃" value="active" /> <el-option label="活跃" value="active" />
<el-option label="非活跃" value="inactive" /> <el-option label="非活跃" value="inactive" />
<el-option label="草稿" value="draft" /> <el-option label="草稿" value="draft" />
</el-select> </el-select>
</el-form-item> </el-form-item>
<el-form-item label="启用ReAct模式"> <el-form-item label="启用RAG" prop="enableRag">
<el-switch v-model="form.enableRag" />
</el-form-item>
<el-form-item label="启用ReAct模式" prop="enableReAct">
<el-switch v-model="form.enableReAct" /> <el-switch v-model="form.enableReAct" />
</el-form-item> </el-form-item>
<el-form-item label="启用流式输出" prop="enableStreaming">
<el-switch v-model="form.enableStreaming" />
</el-form-item>
<el-form-item label="可用工具" prop="tools">
<el-select
v-model="form.tools"
multiple
filterable
placeholder="请选择此Agent可用的工具"
:loading="loadingTools"
>
<el-option
v-for="tool in allTools"
:key="tool.id"
:label="tool.displayName ? `${tool.displayName} (${tool.name})` : tool.name"
:value="tool.name"
>
<span>{{ tool.displayName ? tool.displayName : tool.name }}</span>
<span v-if="tool.description" style="float: right; color: #8492a6; font-size: 13px">{{ tool.description }}</span>
</el-option>
</el-select>
<div style="margin-top: 5px; font-size: 12px; color: #8492a6;">
不选择任何工具表示此Agent可以使用所有工具
</div>
</el-form-item>
</el-form> </el-form>
<template #footer> <template #footer>
<el-button @click="dialogVisible = false">取消</el-button> <el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="saveAgent">保存</el-button> <el-button type="primary" @click="saveAgent" :loading="saving">保存</el-button>
</template> </template>
</el-dialog> </el-dialog>
</div> </div>
</template> </template>
<script setup> <script setup>
import { ref, onMounted } from 'vue' import { ref, onMounted, reactive } from 'vue'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import request from '@/utils/request' import request from '@/utils/request'
import { handleGlobalError, withErrorHandling } from '@/utils/errorHandler'
const authStore = useAuthStore() const authStore = useAuthStore()
const agents = ref([]) const agents = ref([])
const enabledLlmConfigs = ref([])
const dialogVisible = ref(false) const dialogVisible = ref(false)
const isEdit = ref(false) const isEdit = ref(false)
const form = ref({ const loading = ref(false)
const saving = ref(false)
const formRef = ref(null)
const form = reactive({
name: '', name: '',
description: '', description: '',
defaultModel: 'deepseek-chat', defaultModel: 'deepseek-default',
systemPrompt: '',
promptTemplate: '',
temperature: 0.7,
maxTokens: 4096,
topP: 0.9,
topK: 50,
presencePenalty: 0,
frequencyPenalty: 0,
historyLength: 10,
status: 'active', status: 'active',
enableReAct: false enableRag: false,
enableReAct: false,
enableStreaming: true,
tools: [] // 添加工具配置字段
}) })
// 表单验证规则
const rules = {
name: [
{ required: true, message: '请输入Agent名称', trigger: 'blur' },
{ min: 1, max: 100, message: '长度在 1 到 100 个字符', trigger: 'blur' }
],
defaultModel: [
{ required: true, message: '请选择默认模型', trigger: 'change' }
],
status: [
{ required: true, message: '请选择状态', trigger: 'change' }
]
}
// 状态文本映射
const getStatusText = (status) => {
const statusMap = {
'active': '活跃',
'inactive': '非活跃',
'draft': '草稿'
}
return statusMap[status] || status
}
// 获取工具数量
const getToolCount = (agent) => {
if (!agent.tools) return 0
try {
// 如果tools是JSON字符串,需要解析
if (typeof agent.tools === 'string') {
const tools = JSON.parse(agent.tools)
return Array.isArray(tools) ? tools.length : 0
} else if (Array.isArray(agent.tools)) {
return agent.tools.length
}
} catch (e) {
console.error('解析工具配置失败:', e)
}
return 0
}
// 添加工具相关变量
const allTools = ref([])
const loadingTools = ref(false)
// 获取所有工具列表
const loadAllTools = async () => {
try {
loadingTools.value = true
const res = await authStore.get('/tools')
allTools.value = res.data.data || []
} catch (error) {
console.error('获取工具列表失败:', error)
ElMessage.error('获取工具列表失败: ' + (error.response?.data?.message || error.message || '未知错误'))
} finally {
loadingTools.value = false
}
}
onMounted(() => { onMounted(() => {
loadAgents() loadAgents()
loadEnabledLlmConfigs()
loadAllTools() // 加载工具列表
}) })
const loadAgents = async () => { const loadAgents = async () => {
try { try {
loading.value = true
const res = await authStore.get('/agent') const res = await authStore.get('/agent')
agents.value = res.data.data || [] agents.value = res.data.data || []
} catch (error) { } catch (error) {
console.error('获取Agent列表失败:', error) console.error('获取Agent列表失败:', error)
console.error('错误详情:', {
message: error.message,
response: error.response,
status: error.response?.status,
statusText: error.response?.statusText,
data: error.response?.data
})
ElMessage.error('获取Agent列表失败: ' + (error.response?.data?.message || error.message || '未知错误')) ElMessage.error('获取Agent列表失败: ' + (error.response?.data?.message || error.message || '未知错误'))
} finally {
loading.value = false
}
}
// 获取启用的LLM配置
const loadEnabledLlmConfigs = async () => {
try {
const res = await authStore.get('/llm-config/enabled')
enabledLlmConfigs.value = res.data.data.records || []
} catch (error) {
console.error('获取启用的LLM配置失败:', error)
ElMessage.error('获取启用的LLM配置失败: ' + (error.response?.data?.message || error.message || '未知错误'))
} }
} }
const editAgent = (agent) => { const editAgent = (agent) => {
isEdit.value = true isEdit.value = true
form.value = { ...agent } // 深拷贝避免直接修改原对象
Object.assign(form, { ...agent })
// 处理工具配置
if (agent.tools) {
try {
// 如果tools是JSON字符串,需要解析为数组
if (typeof agent.tools === 'string') {
form.tools = JSON.parse(agent.tools)
} else if (Array.isArray(agent.tools)) {
form.tools = [...agent.tools]
} else {
form.tools = []
}
} catch (e) {
console.error('解析工具配置失败:', e)
form.tools = []
}
} else {
form.tools = []
}
dialogVisible.value = true dialogVisible.value = true
} }
const saveAgent = async () => { const saveAgent = async () => {
// 表单验证
try { try {
await formRef.value.validate()
} catch (error) {
ElMessage.warning('请填写必填项')
return
}
try {
saving.value = true
// 准备提交的数据
const submitData = { ...form }
// 将工具数组转换为JSON字符串
if (Array.isArray(submitData.tools)) {
submitData.tools = JSON.stringify(submitData.tools)
}
if (isEdit.value) { if (isEdit.value) {
// 使用更安全的方式调用 PUT 请求 // 编辑模式
await authStore.put(`/agent/${form.value.id}`, form.value) if (!submitData.id) {
ElMessage.error('Agent ID缺失')
return
}
await authStore.put(`/agent/${submitData.id}`, submitData)
ElMessage.success('更新成功') ElMessage.success('更新成功')
} else { } else {
// 使用更安全的方式调用 POST 请求 // 创建模式
await authStore.post('/agent', form.value) await authStore.post('/agent', submitData)
ElMessage.success('创建成功') ElMessage.success('创建成功')
} }
dialogVisible.value = false dialogVisible.value = false
loadAgents() loadAgents()
} catch (error) { } catch (error) {
console.error('保存Agent失败:', error) console.error('保存Agent失败:', error)
let errorMessage = '操作失败' let errorMessage = isEdit.value ? '更新失败' : '创建失败'
if (error.response && error.response.data && error.response.data.message) { if (error.response && error.response.data && error.response.data.message) {
errorMessage += `: ${error.response.data.message}` errorMessage += `: ${error.response.data.message}`
} }
ElMessage.error(errorMessage) ElMessage.error(errorMessage)
} finally {
saving.value = false
} }
} }
const deleteAgent = (agent) => { const deleteAgent = (agent) => {
ElMessageBox.confirm('确认删除该Agent吗?', '提示', { ElMessageBox.confirm(`确认删除Agent "${agent.name}"吗?`, '提示', {
confirmButtonText: '确定', confirmButtonText: '确定',
cancelButtonText: '取消', cancelButtonText: '取消',
type: 'warning' type: 'warning'
}).then(async () => { }).then(async () => {
try { try {
// 使用更安全的方式调用 DELETE 请求
await authStore.del(`/agent/${agent.id}`) await authStore.del(`/agent/${agent.id}`)
ElMessage.success('删除成功') ElMessage.success('删除成功')
loadAgents() loadAgents()
...@@ -147,12 +340,211 @@ const deleteAgent = (agent) => { ...@@ -147,12 +340,211 @@ const deleteAgent = (agent) => {
} }
ElMessage.error(errorMessage) ElMessage.error(errorMessage)
} }
}).catch(() => {}) }).catch(() => {
// 用户取消删除
ElMessage.info('已取消删除')
})
}
// 重置表单
const resetForm = () => {
formRef.value?.resetFields()
isEdit.value = false
// 重置为默认值
Object.assign(form, {
name: '',
description: '',
defaultModel: 'deepseek-default',
systemPrompt: '',
promptTemplate: '',
temperature: 0.7,
maxTokens: 4096,
topP: 0.9,
topK: 50,
presencePenalty: 0,
frequencyPenalty: 0,
historyLength: 10,
status: 'active',
enableRag: false,
enableReAct: false,
enableStreaming: true,
tools: [] // 重置工具配置
})
} }
</script> </script>
<style scoped> <style scoped>
.management-page { .management-page {
padding: 20px; padding: var(--spacing-5);
background-color: var(--bg-secondary);
min-height: 100%;
}
.management-page h2 {
margin-bottom: var(--spacing-4);
color: var(--text-primary);
font-weight: var(--font-weight-bold);
}
.el-card {
border-radius: var(--border-radius-lg);
box-shadow: var(--shadow-sm);
border: 1px solid var(--border-color);
transition: all var(--transition-normal);
}
.el-card:hover {
box-shadow: var(--shadow-md);
}
.stat-card {
text-align: center;
border-radius: var(--border-radius-lg);
box-shadow: var(--shadow-sm);
border: 1px solid var(--border-color);
transition: all var(--transition-normal);
}
.stat-card:hover {
box-shadow: var(--shadow-md);
transform: translateY(-3px);
}
.stat-number {
font-size: var(--font-size-4xl);
font-weight: var(--font-weight-bold);
color: var(--primary-color);
margin-top: var(--spacing-4);
transition: all var(--transition-normal);
}
.stat-status {
font-size: var(--font-size-xl);
color: var(--success-color);
margin-top: var(--spacing-4);
}
.stat-detail {
font-size: var(--font-size-sm);
color: var(--text-tertiary);
margin-top: var(--spacing-2);
}
.quick-actions {
display: flex;
gap: var(--spacing-3);
flex-wrap: wrap;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--spacing-4);
background-color: var(--bg-secondary);
border-bottom: 1px solid var(--border-color);
}
.el-table {
border-radius: var(--border-radius-md);
overflow: hidden;
}
.el-table :deep(.el-table__header th) {
background-color: var(--bg-secondary);
color: var(--text-primary);
font-weight: var(--font-weight-semibold);
}
.el-table :deep(.el-table__row:hover) {
background-color: var(--bg-hover);
}
.el-dialog {
border-radius: var(--border-radius-lg);
overflow: hidden;
}
.el-dialog__header {
background-color: var(--bg-secondary);
border-bottom: 1px solid var(--border-color);
padding: var(--spacing-4);
}
.el-dialog__body {
padding: var(--spacing-4);
}
.el-dialog__footer {
background-color: var(--bg-secondary);
border-top: 1px solid var(--border-color);
padding: var(--spacing-4);
}
.el-form-item {
margin-bottom: var(--spacing-4);
}
.el-input,
.el-select,
.el-textarea {
border-radius: var(--border-radius-md);
}
.el-input :deep(.el-input__inner),
.el-textarea :deep(.el-textarea__inner) {
border: 1px solid var(--border-color);
transition: border-color var(--transition-normal);
}
.el-input :deep(.el-input__inner:focus),
.el-textarea :deep(.el-textarea__inner:focus) {
border-color: var(--primary-color);
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
}
/* 响应式设计 */
@media (max-width: 768px) {
.management-page {
padding: var(--spacing-3);
}
.el-card {
margin-bottom: var(--spacing-3);
}
.quick-actions {
flex-direction: column;
}
.stat-number {
font-size: var(--font-size-3xl);
}
.el-dialog {
width: 95% !important;
margin: 10px auto !important;
}
}
@media (max-width: 576px) {
.management-page {
padding: var(--spacing-2);
}
.card-header {
padding: var(--spacing-3);
}
.el-dialog__header,
.el-dialog__body,
.el-dialog__footer {
padding: var(--spacing-3);
}
.stat-number {
font-size: var(--font-size-2xl);
}
} }
</style> </style>
\ No newline at end of file
<template>
<div class="chat-page">
<!-- 左侧对话区 -->
<div class="left-panel">
<chat-area ref="chatArea" />
</div>
<!-- 中间分割线 -->
<div class="divider" @mousedown="startResize" :class="{ resizing: isResizing }"></div>
<!-- 右侧工作区 -->
<div class="right-panel" :style="{ width: rightPanelWidth }">
<work-area ref="workArea" />
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onUnmounted } from 'vue'
import ChatArea from '@/components/ChatArea.vue'
import WorkArea from '@/components/WorkArea.vue'
const chatArea = ref()
const workArea = ref()
const isResizing = ref(false)
const rightPanelWidth = ref('40%')
const startX = ref(0)
const startWidth = ref(0)
// 开始拖动分割线
const startResize = (e: MouseEvent) => {
isResizing.value = true
startX.value = e.clientX
const rightPanel = document.querySelector('.right-panel') as HTMLElement
if (rightPanel) {
startWidth.value = rightPanel.offsetWidth
}
document.addEventListener('mousemove', handleResize)
document.addEventListener('mouseup', stopResize)
}
// 处理拖动
const handleResize = (e: MouseEvent) => {
if (!isResizing.value) return
const container = document.querySelector('.chat-page') as HTMLElement
if (!container) return
const delta = e.clientX - startX.value
const newWidth = startWidth.value - delta
const containerWidth = container.offsetWidth
const minWidth = containerWidth * 0.2 // 最小宽度20%
const maxWidth = containerWidth * 0.8 // 最大宽度80%
if (newWidth >= minWidth && newWidth <= maxWidth) {
rightPanelWidth.value = `${newWidth}px`
}
}
// 停止拖动
const stopResize = () => {
isResizing.value = false
document.removeEventListener('mousemove', handleResize)
document.removeEventListener('mouseup', stopResize)
}
onUnmounted(() => {
document.removeEventListener('mousemove', handleResize)
document.removeEventListener('mouseup', stopResize)
})
</script>
<style scoped>
.chat-page {
display: flex;
height: 100%; /* 改为100%而非100vh,使用父容器实际高度 */
width: 100%;
background-color: var(--bg-primary);
overflow: hidden;
}
.left-panel {
flex: 1;
min-width: 200px;
display: flex;
flex-direction: column;
border-right: 1px solid var(--border-color);
overflow: hidden;
height: 100%; /* 明确指定高度,避免隐式计算 */
min-height: 0; /* 允许伸缩 */
}
.divider {
width: 4px;
background-color: var(--border-color);
cursor: col-resize;
transition: background-color var(--transition-normal);
flex-shrink: 0;
}
.divider:hover,
.divider.resizing {
background-color: var(--primary-color);
}
.right-panel {
width: 40%;
display: flex;
flex-direction: column;
overflow: hidden;
min-width: 200px;
transition: width var(--transition-normal);
height: 100%; /* 明确指定高度 */
min-height: 0; /* 允许伸缩 */
}
.divider.resizing ~ .right-panel {
transition: none;
}
/* 响应式设计 */
@media (max-width: 1200px) {
.chat-page {
flex-direction: column;
}
.left-panel {
flex: 1;
border-right: none;
border-bottom: 1px solid var(--border-color);
min-height: 300px;
}
.divider {
width: 100%;
height: 4px;
cursor: row-resize;
}
.right-panel {
width: 100%;
flex: 1;
min-height: 200px;
}
}
@media (max-width: 768px) {
.chat-page {
flex-direction: column;
}
.left-panel {
flex: 1;
border-right: none;
border-bottom: 1px solid var(--border-color);
min-height: 40%;
}
.divider {
width: 100%;
height: 4px;
cursor: row-resize;
}
.right-panel {
width: 100%;
flex: 0 1 60%;
min-height: 0;
}
}
@media (max-width: 576px) {
.chat-page {
flex-direction: column;
}
.left-panel {
flex: 1;
border-right: none;
border-bottom: 1px solid var(--border-color);
}
.divider {
width: 100%;
height: 4px;
}
.right-panel {
width: 100%;
flex: 1;
min-height: 0;
}
}
</style>
<template>
<div class="dashboard">
<h2>工作台</h2>
<el-row :gutter="20">
<el-col :xs="24" :sm="12" :md="6">
<el-card class="stat-card">
<template #header>
<div class="card-header">
<span>Agent总数</span>
</div>
</template>
<div class="stat-number">{{ agents.length }}</div>
<div class="stat-detail" v-if="agents.length > 0">
<span>{{ getActiveAgentCount() }} 个活跃</span>
</div>
</el-card>
</el-col>
<el-col :xs="24" :sm="12" :md="6">
<el-card class="stat-card">
<template #header>
<div class="card-header">
<span>工具总数</span>
</div>
</template>
<div class="stat-number">{{ tools.length }}</div>
</el-card>
</el-col>
<el-col :xs="24" :sm="12" :md="6">
<el-card class="stat-card">
<template #header>
<div class="card-header">
<span>知识库文档</span>
</div>
</template>
<div class="stat-number">{{ documents.length }}</div>
</el-card>
</el-col>
<el-col :xs="24" :sm="12" :md="6">
<el-card class="stat-card">
<template #header>
<div class="card-header">
<span>系统状态</span>
</div>
</template>
<div class="stat-status">运行中</div>
</el-card>
</el-col>
</el-row>
<el-row :gutter="20" style="margin-top: 20px">
<el-col :xs="24" :md="12">
<el-card>
<template #header>
<div class="card-header">
<span>最近的Agent</span>
</div>
</template>
<el-table v-if="agents.length" :data="agents.slice(0, 5)" style="width: 100%">
<el-table-column prop="name" label="名称" />
<el-table-column prop="status" label="状态">
<template #default="{ row }">
<el-tag :type="row.status === 'active' ? 'success' : 'warning'">
{{ row.status }}
</el-tag>
</template>
</el-table-column>
</el-table>
<el-empty v-else description="暂无Agent" />
</el-card>
</el-col>
<el-col :xs="24" :md="12">
<el-card>
<template #header>
<div class="card-header">
<span>快速操作</span>
</div>
</template>
<div class="quick-actions">
<el-button type="primary" @click="goToPage('/agent')">
创建Agent
</el-button>
<el-button @click="goToPage('/tools')">
管理工具
</el-button>
<el-button @click="goToPage('/documents')">
上传文档
</el-button>
</div>
</el-card>
</el-col>
</el-row>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { ElMessage } from 'element-plus'
import request from '@/utils/request'
import { handleGlobalError, withErrorHandling } from '@/utils/errorHandler'
const router = useRouter()
const authStore = useAuthStore()
const agents = ref([])
const tools = ref([])
const documents = ref([])
const goToPage = (path) => {
router.push(path)
}
const getActiveAgentCount = () => {
return agents.value.filter(agent => agent.status === 'active').length
}
onMounted(async () => {
try {
// 获取用户的Agent列表
const agentRes = await request.get('/agent')
agents.value = agentRes.data.data || []
// 获取工具列表
const toolRes = await request.get('/tools')
tools.value = toolRes.data.data || []
// 获取文档列表
const docRes = await request.get('/rag/documents')
documents.value = docRes.data.data?.records || []
} catch (error) {
console.error('获取数据失败:', error)
ElMessage.error('获取数据失败: ' + (error.response?.data?.message || error.message))
}
})
</script>
<style scoped>
.dashboard {
padding: var(--spacing-5);
background-color: var(--bg-secondary);
min-height: 100%;
}
.dashboard h2 {
margin-bottom: var(--spacing-4);
color: var(--text-primary);
font-weight: var(--font-weight-bold);
}
.stat-card {
text-align: center;
border-radius: var(--border-radius-lg);
box-shadow: var(--shadow-sm);
border: 1px solid var(--border-color);
transition: all var(--transition-normal);
}
.stat-card:hover {
box-shadow: var(--shadow-md);
transform: translateY(-3px);
}
.stat-number {
font-size: var(--font-size-4xl);
font-weight: var(--font-weight-bold);
color: var(--primary-color);
margin-top: var(--spacing-4);
transition: all var(--transition-normal);
}
.stat-status {
font-size: var(--font-size-xl);
color: var(--success-color);
margin-top: var(--spacing-4);
}
.stat-detail {
font-size: var(--font-size-sm);
color: var(--text-tertiary);
margin-top: var(--spacing-2);
}
.quick-actions {
display: flex;
gap: var(--spacing-3);
flex-wrap: wrap;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--spacing-4);
background-color: var(--bg-secondary);
border-bottom: 1px solid var(--border-color);
}
.el-table {
border-radius: var(--border-radius-md);
overflow: hidden;
}
.el-table :deep(.el-table__header th) {
background-color: var(--bg-secondary);
color: var(--text-primary);
font-weight: var(--font-weight-semibold);
}
.el-table :deep(.el-table__row:hover) {
background-color: var(--bg-hover);
}
/* 响应式设计 */
@media (max-width: 1200px) {
.el-col {
margin-bottom: var(--spacing-4);
}
}
@media (max-width: 768px) {
.dashboard {
padding: var(--spacing-3);
}
.el-card {
margin-bottom: var(--spacing-3);
}
.quick-actions {
flex-direction: column;
}
.stat-number {
font-size: var(--font-size-3xl);
}
}
@media (max-width: 576px) {
.dashboard {
padding: var(--spacing-2);
}
.card-header {
padding: var(--spacing-3);
}
.stat-number {
font-size: var(--font-size-2xl);
}
.el-row {
margin-left: calc(var(--spacing-2) * -1) !important;
margin-right: calc(var(--spacing-2) * -1) !important;
}
.el-col {
padding-left: var(--spacing-2) !important;
padding-right: var(--spacing-2) !important;
}
}
</style>
\ No newline at end of file
<template>
<div class="document-management">
<h1>文档管理</h1>
<el-card>
<el-table :data="documents" style="width: 100%">
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="name" label="文档名称" />
<el-table-column prop="size" label="大小" width="120" />
<el-table-column prop="uploadTime" label="上传时间" width="180" />
<el-table-column label="操作" width="200">
<template #default="scope">
<el-button size="small" @click="handlePreview(scope.row)">预览</el-button>
<el-button size="small" type="danger" @click="handleDelete(scope.row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<div style="margin-top: 20px;">
<el-upload
class="upload-demo"
drag
action="/api/documents/upload"
:headers="uploadHeaders"
:on-success="handleUploadSuccess"
multiple>
<el-icon class="el-icon--upload"><upload-filled /></el-icon>
<div class="el-upload__text">
将文件拖到此处,或<em>点击上传</em>
</div>
<template #tip>
<div class="el-upload__tip">
支持PDF、TXT、DOC等格式文件,单个文件不超过10MB
</div>
</template>
</el-upload>
</div>
</el-card>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useAuthStore } from '@/stores/auth'
const authStore = useAuthStore()
// 表格数据
const documents = ref([
{
id: 1,
name: '产品使用手册.pdf',
size: '2.4 MB',
uploadTime: '2024-01-15 14:30:22'
},
{
id: 2,
name: '技术规范文档.docx',
size: '1.8 MB',
uploadTime: '2024-01-10 09:15:45'
}
])
// 上传头信息
const uploadHeaders = ref({
'Authorization': `Bearer ${authStore.token}`
})
// 处理上传成功
const handleUploadSuccess = (response, uploadFile) => {
ElMessage.success('文件上传成功')
// 刷新文档列表
loadDocuments()
}
// 加载文档列表
const loadDocuments = () => {
// 这里应该调用API获取文档列表
console.log('加载文档列表')
}
// 预览文档
const handlePreview = (row) => {
ElMessage.info(`预览文档: ${row.name}`)
}
// 删除文档
const handleDelete = (row) => {
ElMessageBox.confirm(
`确定要删除文档 "${row.name}" 吗?`,
'确认删除',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
}
)
.then(() => {
// 调用API删除文档
ElMessage.success('删除成功')
// 从列表中移除
const index = documents.value.findIndex(item => item.id === row.id)
if (index > -1) {
documents.value.splice(index, 1)
}
})
.catch(() => {
ElMessage.info('已取消删除')
})
}
// 页面加载时获取文档列表
onMounted(() => {
loadDocuments()
})
</script>
<style scoped>
.document-management {
padding: var(--spacing-5);
background-color: var(--bg-secondary);
min-height: 100%;
}
.document-management h1 {
margin-bottom: var(--spacing-4);
color: var(--text-primary);
font-weight: var(--font-weight-bold);
}
.el-card {
border-radius: var(--border-radius-lg);
box-shadow: var(--shadow-sm);
border: 1px solid var(--border-color);
transition: all var(--transition-normal);
}
.el-card:hover {
box-shadow: var(--shadow-md);
}
.el-table {
border-radius: var(--border-radius-md);
overflow: hidden;
}
.el-table :deep(.el-table__header th) {
background-color: var(--bg-secondary);
color: var(--text-primary);
font-weight: var(--font-weight-semibold);
}
.el-table :deep(.el-table__row:hover) {
background-color: var(--bg-hover);
}
.el-upload {
border-radius: var(--border-radius-lg);
}
.el-upload-dragger {
border-radius: var(--border-radius-lg);
border: 1px dashed var(--border-color);
transition: all var(--transition-normal);
}
.el-upload-dragger:hover {
border-color: var(--primary-color);
background-color: rgba(102, 126, 234, 0.05);
}
.el-upload__tip {
font-size: var(--font-size-sm);
color: var(--text-tertiary);
}
/* 响应式设计 */
@media (max-width: 768px) {
.document-management {
padding: var(--spacing-3);
}
.el-table :deep(.el-table__cell) {
padding: var(--spacing-2);
}
}
@media (max-width: 576px) {
.document-management {
padding: var(--spacing-2);
}
.el-table :deep(.el-table__cell) {
padding: var(--spacing-1);
}
}
</style>
\ No newline at end of file
<template>
<div class="dom-sync-page">
<div class="page-header">
<h1>DOM同步对话页面</h1>
</div>
<div class="page-content">
<div class="left-panel">
<DomSyncViewer />
</div>
<div class="right-panel">
<div class="control-panel">
<h2>控制面板</h2>
<div class="form-group">
<label for="url-input">目标URL:</label>
<input
id="url-input"
v-model="targetUrl"
placeholder="输入要同步的网页URL,必须以http://或https://开头"
class="url-input"
/>
<button @click="navigateToUrl" class="nav-button">导航</button>
<p class="url-hint">提示:请输入完整的URL,例如 https://www.baidu.com</p>
</div>
<div class="instructions">
<h3>交互指令测试</h3>
<ul>
<li>点击页面元素进行交互</li>
<li>填写表单字段</li>
<li>悬停在按钮上查看效果</li>
<li>使用键盘输入文本</li>
</ul>
</div>
<div class="status-info">
<h3>连接状态</h3>
<div class="status-item">
<span class="label">WebSocket:</span>
<span class="value" :class="connectionStatus">{{ connectionStatus }}</span>
</div>
<div class="status-item">
<span class="label">当前页面:</span>
<span class="value">{{ currentPage }}</span>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import DomSyncViewer from '@/components/DomSyncViewer.vue'
// 响应式数据
const targetUrl = ref('https://www.baidu.com')
const connectionStatus = ref('disconnected')
const currentPage = ref('未连接')
// 导航到指定URL
const navigateToUrl = () => {
// 验证URL格式
if (!targetUrl.value || targetUrl.value.trim() === '') {
alert('请输入有效的URL');
return;
}
if (!targetUrl.value.toLowerCase().startsWith('http://') && !targetUrl.value.toLowerCase().startsWith('https://')) {
alert('URL必须以http://或https://开头');
return;
}
// 这里可以通过某种方式通知DomSyncViewer组件导航到指定URL
console.log('导航到:', targetUrl.value)
}
// 组件挂载时的处理
onMounted(() => {
// 初始化连接状态
connectionStatus.value = 'connecting'
// 模拟连接过程
setTimeout(() => {
connectionStatus.value = 'connected'
currentPage.value = targetUrl.value
}, 1000)
})
</script>
<style scoped>
.dom-sync-page {
height: 100%;
display: flex;
flex-direction: column;
padding: 20px;
box-sizing: border-box;
}
.page-header {
margin-bottom: 20px;
}
.page-header h1 {
margin: 0;
color: #333;
}
.page-content {
flex: 1;
display: flex;
gap: 20px;
overflow: hidden;
}
.left-panel {
flex: 3;
height: 100%;
border: 1px solid #ddd;
border-radius: 8px;
overflow: hidden;
}
.right-panel {
flex: 1;
height: 100%;
display: flex;
flex-direction: column;
}
.control-panel {
background: #f8f9fa;
border: 1px solid #ddd;
border-radius: 8px;
padding: 20px;
height: 100%;
box-sizing: border-box;
}
.control-panel h2 {
margin-top: 0;
color: #333;
}
.form-group {
margin-bottom: 20px;
}
.form-group label {
display: block;
margin-bottom: 5px;
font-weight: bold;
}
.url-input {
width: 100%;
padding: 8px;
border: 1px solid #ccc;
border-radius: 4px;
box-sizing: border-box;
margin-bottom: 5px;
}
.nav-button {
padding: 8px 16px;
background-color: #007bff;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
.nav-button:hover {
background-color: #0056b3;
}
.url-hint {
font-size: 12px;
color: #666;
margin: 5px 0;
}
.instructions h3,
.status-info h3 {
margin-top: 0;
color: #333;
}
.instructions ul {
padding-left: 20px;
}
.instructions li {
margin-bottom: 8px;
}
.status-item {
display: flex;
justify-content: space-between;
margin-bottom: 10px;
}
.label {
font-weight: bold;
}
.value.connected {
color: #28a745;
}
.value.disconnected {
color: #dc3545;
}
.value.connecting {
color: #ffc107;
}
</style>
\ No newline at end of file
<template>
<div class="llm-config-management">
<el-card class="box-card">
<template #header>
<div class="card-header">
<span>LLM配置管理</span>
<el-button type="primary" @click="handleCreate">新增配置</el-button>
</div>
</template>
<el-table :data="llmConfigs" style="width: 100%" v-loading="loading">
<el-table-column prop="name" label="配置名称" width="150"></el-table-column>
<el-table-column prop="description" label="描述" width="200"></el-table-column>
<el-table-column prop="provider" label="提供商" width="120">
<template #default="scope">
<el-tag :type="getProviderTagType(scope.row.provider)">
{{ scope.row.provider }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="modelName" label="模型名称" width="150"></el-table-column>
<el-table-column prop="baseUrl" label="API地址" width="200"></el-table-column>
<el-table-column prop="enabled" label="状态" width="80">
<template #default="scope">
<el-switch
v-model="scope.row.enabled"
@change="handleStatusChange(scope.row)">
</el-switch>
</template>
</el-table-column>
<el-table-column label="操作" width="200">
<template #default="scope">
<el-button size="small" @click="handleEdit(scope.row)">编辑</el-button>
<el-button size="small" type="danger" @click="handleDelete(scope.row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<div class="pagination-container">
<el-pagination
v-model:current-page="pagination.current"
v-model:page-size="pagination.size"
:page-sizes="[10, 20, 50]"
:total="pagination.total"
layout="total, sizes, prev, pager, next, jumper"
@size-change="handleSizeChange"
@current-change="handleCurrentChange">
</el-pagination>
</div>
</el-card>
<!-- 配置表单对话框 -->
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="600px">
<el-form :model="form" :rules="rules" ref="formRef" label-width="120px">
<el-form-item label="配置名称" prop="name">
<el-input v-model="form.name" placeholder="请输入配置名称"></el-input>
</el-form-item>
<el-form-item label="描述" prop="description">
<el-input v-model="form.description" type="textarea" placeholder="请输入描述"></el-input>
</el-form-item>
<el-form-item label="提供商" prop="provider">
<el-select v-model="form.provider" placeholder="请选择提供商" @change="handleProviderChange">
<el-option
v-for="provider in providers"
:key="provider.value"
:label="provider.label"
:value="provider.value">
</el-option>
</el-select>
</el-form-item>
<el-form-item label="模型名称" prop="modelName">
<el-input v-model="form.modelName" placeholder="请输入模型名称"></el-input>
</el-form-item>
<el-form-item label="API密钥" prop="apiKey">
<el-input v-model="form.apiKey" type="password" placeholder="请输入API密钥" show-password></el-input>
</el-form-item>
<el-form-item label="API地址" prop="baseUrl" v-if="form.provider === 'ollama'">
<el-input v-model="form.baseUrl" placeholder="请输入API地址,如:http://localhost:11434"></el-input>
</el-form-item>
<el-form-item label="温度参数" prop="temperature">
<el-slider v-model="form.temperature" :min="0" :max="2" :step="0.1" show-input></el-slider>
</el-form-item>
<el-form-item label="最大Token数" prop="maxTokens">
<el-input-number v-model="form.maxTokens" :min="1" :max="100000" controls-position="right"></el-input-number>
</el-form-item>
<el-form-item label="Top P" prop="topP">
<el-slider v-model="form.topP" :min="0" :max="1" :step="0.1" show-input></el-slider>
</el-form-item>
<el-form-item label="状态" prop="enabled">
<el-switch
v-model="form.enabled">
</el-switch>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="saveConfig">保存</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import axios from 'axios'
import request from '@/utils/request'
import { handleGlobalError, withErrorHandling } from '@/utils/errorHandler'
import { useAuthStore } from '@/stores/auth'
import { useRouter } from 'vue-router'
// 在组件顶部添加 router 和 authStore
const router = useRouter()
const authStore = useAuthStore()
// 创建一个独立的 axios 实例
const apiClient = axios.create({
baseURL: '/api/v1'
})
// 请求拦截器
apiClient.interceptors.request.use(
config => {
const token = localStorage.getItem('token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
},
error => {
return Promise.reject(error)
}
)
// 数据相关
const llmConfigs = ref([])
const loading = ref(false)
const dialogVisible = ref(false)
const dialogTitle = ref('')
const providers = ref([])
// 分页相关
const pagination = reactive({
current: 1,
size: 10,
total: 0
})
// 表单相关
const form = reactive({
id: '',
name: '',
description: '',
provider: '',
modelName: '',
apiKey: '',
baseUrl: '',
temperature: 0.7,
maxTokens: 4096,
topP: 0.9,
enabled: false,
owner: 'system'
})
const rules = {
name: [
{ required: true, message: '请输入配置名称', trigger: 'blur' }
],
provider: [
{ required: true, message: '请选择提供商', trigger: 'change' }
],
modelName: [
{ required: true, message: '请输入模型名称', trigger: 'blur' }
]
}
// 表单引用
const formRef = ref()
// 获取提供商标签类型
const getProviderTagType = (provider) => {
switch (provider) {
case 'openai':
return 'success'
case 'deepseek':
return 'warning'
case 'ollama':
return 'info'
case 'hisense':
return 'primary'
default:
return 'primary'
}
}
// 处理提供商变化
const handleProviderChange = (value) => {
if (value === 'ollama') {
form.baseUrl = 'http://localhost:11434'
} else {
form.baseUrl = ''
}
// 设置默认模型名称
switch (value) {
case 'openai':
form.modelName = 'gpt-3.5-turbo'
break
case 'deepseek':
form.modelName = 'deepseek-chat'
break
case 'ollama':
form.modelName = 'llama2'
break
case 'hisense':
form.modelName = 'gpt-4-1'
break
default:
// 对于其他提供商,可以设置一个通用的默认值或者留空
form.modelName = ''
}
}
// 获取LLM配置列表
const fetchLlmConfigs = async () => {
loading.value = true
try {
const response = await apiClient.get('/llm-config/list', {
params: {
current: pagination.current,
size: pagination.size
}
})
if (response.data.code === 200) {
llmConfigs.value = response.data.data.records
pagination.total = response.data.data.total
} else {
ElMessage.error(response.data.message || '获取配置列表失败')
}
} catch (error) {
// 检查是否是认证错误
if (error.response && error.response.status === 401) {
// 只有在未登录状态下才跳转到登录页面
if (!authStore.token) {
router.push('/login')
} else {
ElMessage.error('认证已过期,请重新登录')
authStore.logout()
router.push('/login')
}
} else {
ElMessage.error('获取配置列表失败: ' + error.message)
}
} finally {
loading.value = false
}
}
// 获取所有可用的提供商列表
const fetchProviders = async () => {
try {
const response = await apiClient.get('/llm-config/providers')
if (response.data.code === 200) {
// 将返回的提供商名称转换为标签显示格式
providers.value = response.data.data.map(provider => ({
label: provider.charAt(0).toUpperCase() + provider.slice(1).toLowerCase(),
value: provider.toLowerCase()
}))
} else {
ElMessage.error(response.data.message || '获取提供商列表失败')
// 如果获取失败,使用默认选项
providers.value = [
{ label: 'OpenAI', value: 'openai' },
{ label: 'DeepSeek', value: 'deepseek' },
{ label: 'Ollama', value: 'ollama' },
{ label: 'Hisense', value: 'hisense' }
]
}
} catch (error) {
console.error('获取提供商列表失败:', error)
ElMessage.error('获取提供商列表失败: ' + error.message)
// 如果获取失败,使用默认选项
providers.value = [
{ label: 'OpenAI', value: 'openai' },
{ label: 'DeepSeek', value: 'deepseek' },
{ label: 'Ollama', value: 'ollama' },
{ label: 'Hisense', value: 'hisense' }
]
}
}
// 处理创建
const handleCreate = () => {
dialogTitle.value = '新增LLM配置'
// 重置表单
Object.assign(form, {
id: '',
name: '',
description: '',
provider: '',
modelName: '',
apiKey: '',
baseUrl: '',
temperature: 0.7,
maxTokens: 4096,
topP: 0.9,
enabled: false,
owner: 'system'
})
dialogVisible.value = true
}
// 处理编辑
const handleEdit = (row) => {
dialogTitle.value = '编辑LLM配置'
// 填充表单数据
Object.assign(form, row)
dialogVisible.value = true
}
// 处理删除
const handleDelete = (row) => {
ElMessageBox.confirm(
`确定要删除配置 "${row.name}" 吗?`,
'确认删除',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}
).then(async () => {
try {
const response = await apiClient.delete(`/llm-config/${row.id}`)
if (response.data.code === 200) {
ElMessage.success('删除成功')
fetchLlmConfigs()
} else {
ElMessage.error(response.data.message || '删除失败')
}
} catch (error) {
// 检查是否是认证错误
if (error.response && error.response.status === 401) {
if (!authStore.token) {
router.push('/login')
} else {
ElMessage.error('认证已过期,请重新登录')
authStore.logout()
router.push('/login')
}
} else {
ElMessage.error('删除失败: ' + error.message)
}
}
}).catch(() => {
// 用户取消删除
})
}
// 处理状态变化
const handleStatusChange = async (row) => {
try {
const response = await apiClient.put(`/llm-config/${row.id}`, row)
if (response.data.code === 200) {
ElMessage.success(`${row.enabled ? '启用' : '禁用'}成功`)
} else {
ElMessage.error(response.data.message || `${row.enabled ? '启用' : '禁用'}失败`)
// 恢复状态
row.enabled = !row.enabled
}
} catch (error) {
// 检查是否是认证错误
if (error.response && error.response.status === 401) {
if (!authStore.token) {
router.push('/login')
} else {
ElMessage.error('认证已过期,请重新登录')
authStore.logout()
router.push('/login')
}
} else {
ElMessage.error(`${row.enabled ? '启用' : '禁用'}失败: ` + error.message)
}
// 恢复状态
row.enabled = !row.enabled
}
}
// 保存配置
const saveConfig = async () => {
formRef.value.validate(async (valid) => {
if (!valid) return
try {
let response
if (form.id) {
// 更新
response = await apiClient.put(`/llm-config/${form.id}`, form)
} else {
// 创建
response = await apiClient.post('/llm-config', form)
}
if (response.data.code === 200) {
ElMessage.success(form.id ? '更新成功' : '创建成功')
dialogVisible.value = false
fetchLlmConfigs()
} else {
ElMessage.error(response.data.message || (form.id ? '更新失败' : '创建失败'))
}
} catch (error) {
// 检查是否是认证错误
if (error.response && error.response.status === 401) {
if (!authStore.token) {
router.push('/login')
} else {
ElMessage.error('认证已过期,请重新登录')
authStore.logout()
router.push('/login')
}
} else {
ElMessage.error((form.id ? '更新失败' : '创建失败') + ': ' + error.message)
}
}
})
}
// 处理分页变化
const handleSizeChange = (val) => {
pagination.size = val
fetchLlmConfigs()
}
const handleCurrentChange = (val) => {
pagination.current = val
fetchLlmConfigs()
}
// 组件挂载时获取数据
onMounted(() => {
fetchLlmConfigs()
fetchProviders()
})
</script>
<style scoped>
.llm-config-management {
padding: 20px;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.pagination-container {
margin-top: 20px;
display: flex;
justify-content: center;
}
</style>
\ No newline at end of file
<template>
<div class="login-container">
<div class="login-form">
<div class="logo-section">
<div class="logo">🤖</div>
<h1>HiAgent</h1>
<p class="subtitle">我的AI智能体助理</p>
</div>
<el-form
ref="formRef"
:model="form"
:rules="rules"
@submit.prevent="handleLogin"
class="login-form-el"
>
<el-form-item prop="username">
<el-input
v-model="form.username"
placeholder="用户名"
prefix-icon="User"
class="login-input"
/>
</el-form-item>
<el-form-item prop="password">
<el-input
v-model="form.password"
type="password"
placeholder="密码"
prefix-icon="Lock"
class="login-input"
/>
</el-form-item>
<el-form-item>
<el-button
type="primary"
native-type="submit"
:loading="loading"
class="login-button"
>
登录
</el-button>
</el-form-item>
<div class="divider"></div>
<!-- OAuth2 登录选项 -->
<div class="oauth-options">
<el-button
v-for="provider in availableOAuth2Providers"
:key="provider.name"
@click="() => handleOAuth2Login(provider.name)"
plain
class="oauth-button"
>
{{ provider.displayName }}
</el-button>
</div>
<div class="login-footer">
<router-link to="/register" class="register-link">没有账户?立即注册</router-link>
</div>
</el-form>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { ElMessage, FormInstance, FormRules } from 'element-plus'
const router = useRouter()
const authStore = useAuthStore()
const formRef = ref<FormInstance>()
const loading = ref<boolean>(false)
const availableOAuth2Providers = ref<Array<{name: string, displayName: string}>>([{
name: 'github',
displayName: 'GitHub 登录'
}])
interface LoginForm {
username: string
password: string
}
const form = reactive<LoginForm>({
username: '',
password: ''
})
const rules = reactive<FormRules<LoginForm>>({
username: [
{ required: true, message: '请输入用户名', trigger: 'blur' }
],
password: [
{ required: true, message: '请输入密码', trigger: 'blur' }
]
})
onMounted(() => {
// 检查是否有来自 OAuth2 回调的字件
const params = new URLSearchParams(window.location.search)
const token = params.get('token')
const method = params.get('method')
const error = params.get('error')
if (error) {
ElMessage.error(`登录失败: ${error}`)
} else if (token && method === 'oauth2') {
// 有效的 OAuth2 回调,正常登录
localStorage.setItem('token', token)
authStore.token = token
ElMessage.success('OAuth2 登录成功')
router.push('/agent/chat')
}
})
const handleLogin = async () => {
if (!formRef.value) return
await formRef.value.validate()
loading.value = true
try {
await authStore.login(form.username, form.password)
ElMessage.success('登录成功')
router.push('/agent/chat')
} catch (error: any) {
ElMessage.error(error.message)
} finally {
loading.value = false
}
}
/**
* 处理 OAuth2 登录
*/
const handleOAuth2Login = (providerName: string) => {
try {
// 渐进到后端授权端点,后端会渐进重定向到指定的 OAuth2 提供者
window.location.href = `/api/v1/auth/oauth2/authorize?providerName=${providerName}`
} catch (error: any) {
ElMessage.error('执行 OAuth2 登录失败: ' + error.message)
}
}
</script>
<style scoped>
.login-container {
width: 100vw;
height: 100vh;
background: linear-gradient(135deg, var(--primary-color) 0%, var(--primary-color-light) 100%);
display: flex;
justify-content: center;
align-items: center;
animation: gradientShift 15s ease infinite;
}
@keyframes gradientShift {
0% {
background-position: 0% 50%;
}
50% {
background-position: 100% 50%;
}
100% {
background-position: 0% 50%;
}
}
.login-form {
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(10px);
padding: 40px;
border-radius: var(--border-radius-2xl);
box-shadow: var(--shadow-xl);
width: 100%;
max-width: 400px;
transform: translateY(0);
transition: transform var(--transition-slow), box-shadow var(--transition-slow);
}
.login-form:hover {
transform: translateY(-5px);
box-shadow: var(--shadow-xl);
}
.logo-section {
text-align: center;
margin-bottom: 30px;
}
.logo {
font-size: 48px;
margin-bottom: 15px;
animation: pulse 2s infinite;
}
@keyframes pulse {
0% {
transform: scale(1);
}
50% {
transform: scale(1.1);
}
100% {
transform: scale(1);
}
}
.login-form h1 {
color: var(--text-primary);
margin: 0 0 10px 0;
font-weight: var(--font-weight-bold);
font-size: var(--font-size-3xl);
}
.subtitle {
color: var(--text-secondary);
margin: 0 0 30px 0;
font-size: var(--font-size-sm);
}
.login-form-el {
margin-bottom: 20px;
}
.login-input {
height: 45px;
font-size: var(--font-size-base);
border-radius: var(--border-radius-lg);
border: 2px solid var(--border-color);
transition: border-color var(--transition-normal), box-shadow var(--transition-normal);
}
.login-input:focus {
border-color: var(--primary-color);
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.2);
}
.login-footer {
text-align: center;
margin-top: 20px;
}
.register-link {
color: var(--primary-color);
text-decoration: none;
font-size: var(--font-size-sm);
transition: color var(--transition-normal);
position: relative;
}
.register-link:hover {
color: var(--primary-color-dark);
text-decoration: none;
}
.register-link::after {
content: '';
position: absolute;
width: 0;
height: 1px;
bottom: -2px;
left: 0;
background-color: var(--primary-color);
transition: width var(--transition-normal);
}
.register-link:hover::after {
width: 100%;
}
/* OAuth2 登录选项样式 */
.divider {
text-align: center;
margin: 20px 0;
color: var(--text-secondary);
font-size: var(--font-size-sm);
position: relative;
}
.oauth-options {
display: flex;
flex-direction: column;
gap: 10px;
margin-bottom: 20px;
}
.oauth-button {
width: 100%;
height: 40px;
border-radius: var(--border-radius-lg);
border: 1px solid var(--border-color);
color: var(--text-primary);
transition: all var(--transition-normal);
font-size: var(--font-size-sm);
}
.oauth-button:hover {
border-color: var(--primary-color);
color: var(--primary-color);
background-color: rgba(102, 126, 234, 0.1);
}
/* 响应式设计 */
@media (max-width: 576px) {
.login-form {
padding: 30px 20px;
margin: 0 15px;
max-width: none;
}
.login-container {
background: linear-gradient(135deg, var(--primary-color) 0%, var(--primary-color-light) 100%);
}
.logo {
font-size: 40px;
}
.login-form h1 {
font-size: var(--font-size-2xl);
}
}
</style>
\ No newline at end of file
<template>
<div class="memory-management">
<h1>记忆管理</h1>
<el-card>
<el-tabs v-model="activeTab">
<el-tab-pane label="对话记忆" name="dialogue">
<el-table :data="dialogueMemories" style="width: 100%">
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="agentName" label="Agent名称" />
<el-table-column prop="userName" label="用户" />
<el-table-column prop="lastActive" label="最后活跃时间" width="180" />
<el-table-column prop="messageCount" label="消息数" width="100" />
<el-table-column label="操作" width="200">
<template #default="scope">
<el-button size="small" @click="handleViewDetails(scope.row)">查看详情</el-button>
<el-button size="small" type="danger" @click="handleClear(scope.row)">清空</el-button>
</template>
</el-table-column>
</el-table>
</el-tab-pane>
<el-tab-pane label="知识记忆" name="knowledge">
<el-table :data="knowledgeMemories" style="width: 100%">
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="title" label="标题" />
<el-table-column prop="category" label="分类" width="120" />
<el-table-column prop="createTime" label="创建时间" width="180" />
<el-table-column prop="source" label="来源" width="120" />
<el-table-column label="操作" width="200">
<template #default="scope">
<el-button size="small" @click="handleEdit(scope.row)">编辑</el-button>
<el-button size="small" type="danger" @click="handleDelete(scope.row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</el-tab-pane>
</el-tabs>
<div style="margin-top: 20px; text-align: right;">
<el-button type="primary" @click="handleAddMemory">添加知识记忆</el-button>
<el-button @click="handleExport">导出记忆</el-button>
<el-button @click="handleImport">导入记忆</el-button>
</div>
</el-card>
<!-- 对话记忆详情对话框 -->
<el-dialog v-model="dialogVisible" title="对话记忆详情" width="60%">
<el-card v-if="selectedMemory">
<div v-for="(message, index) in selectedMemory.messages" :key="index" class="message-item">
<div class="message-header">
<span class="sender">{{ message.sender }}</span>
<span class="time">{{ message.time }}</span>
</div>
<div class="message-content">{{ message.content }}</div>
</div>
</el-card>
<template #footer>
<span class="dialog-footer">
<el-button @click="dialogVisible = false">关闭</el-button>
</span>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
// 当前激活的标签页
const activeTab = ref('dialogue')
// 对话记忆数据
const dialogueMemories = ref([
{
id: 1,
agentName: '客服助手',
userName: '张三',
lastActive: '2024-01-15 14:30:22',
messageCount: 12
},
{
id: 2,
agentName: '技术支持',
userName: '李四',
lastActive: '2024-01-14 09:15:45',
messageCount: 8
}
])
// 知识记忆数据
const knowledgeMemories = ref([
{
id: 1,
title: '产品FAQ',
category: '产品',
createTime: '2024-01-10 10:30:00',
source: '人工录入'
},
{
id: 2,
title: '技术文档',
category: '技术',
createTime: '2024-01-08 15:45:22',
source: '文档上传'
}
])
// 对话框相关
const dialogVisible = ref(false)
const selectedMemory = ref(null)
// 处理查看详细信息
const handleViewDetails = (row) => {
selectedMemory.value = {
...row,
messages: [
{ sender: '用户', time: '2024-01-15 14:25:10', content: '你好,我想了解一下你们的产品' },
{ sender: '客服助手', time: '2024-01-15 14:25:30', content: '您好!很高兴为您服务。我们的产品具有以下特点...' },
{ sender: '用户', time: '2024-01-15 14:26:15', content: '价格方面有什么优惠吗?' },
{ sender: '客服助手', time: '2024-01-15 14:26:40', content: '目前我们有一个限时优惠活动...' }
]
}
dialogVisible.value = true
}
// 处理清空记忆
const handleClear = (row) => {
ElMessageBox.confirm(
`确定要清空与"${row.agentName}"的对话记忆吗?此操作不可恢复。`,
'确认清空',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
}
)
.then(() => {
ElMessage.success('对话记忆已清空')
})
.catch(() => {
ElMessage.info('已取消操作')
})
}
// 处理编辑知识记忆
const handleEdit = (row) => {
ElMessage.info(`编辑记忆: ${row.title}`)
}
// 处理删除知识记忆
const handleDelete = (row) => {
ElMessageBox.confirm(
`确定要删除知识记忆"${row.title}"吗?此操作不可恢复。`,
'确认删除',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
}
)
.then(() => {
// 从列表中移除
const index = knowledgeMemories.value.findIndex(item => item.id === row.id)
if (index > -1) {
knowledgeMemories.value.splice(index, 1)
}
ElMessage.success('删除成功')
})
.catch(() => {
ElMessage.info('已取消删除')
})
}
// 处理添加知识记忆
const handleAddMemory = () => {
ElMessage.info('添加知识记忆')
}
// 处理导出记忆
const handleExport = () => {
ElMessage.info('导出记忆')
}
// 处理导入记忆
const handleImport = () => {
ElMessage.info('导入记忆')
}
// 页面加载时获取记忆数据
onMounted(() => {
console.log('加载记忆数据')
})
</script>
<style scoped>
.memory-management {
padding: 20px;
}
.message-item {
margin-bottom: 15px;
padding: 10px;
border-radius: 4px;
background-color: #f5f7fa;
}
.message-header {
display: flex;
justify-content: space-between;
margin-bottom: 5px;
font-size: 12px;
color: #909399;
}
.sender {
font-weight: bold;
}
.time {
color: #c0c4cc;
}
.message-content {
line-height: 1.5;
}
</style>
\ No newline at end of file
<template>
<div class="new-chat-page">
<!-- 左侧对话区 -->
<div class="left-panel">
<chat-area ref="chatArea" />
</div>
<!-- 中间分割线 -->
<div class="divider" @mousedown="startResize" :class="{ resizing: isResizing }"></div>
<!-- 右侧工作区 -->
<div class="right-panel" :style="{ width: rightPanelWidth }">
<dom-sync-viewer ref="domSyncViewer" />
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onUnmounted } from 'vue'
import ChatArea from '@/components/ChatArea.vue'
import DomSyncViewer from '@/components/DomSyncViewer.vue'
const chatArea = ref()
const domSyncViewer = ref()
const isResizing = ref(false)
const rightPanelWidth = ref('40%')
const startX = ref(0)
const startWidth = ref(0)
// 开始拖动分割线
const startResize = (e: MouseEvent) => {
isResizing.value = true
startX.value = e.clientX
const rightPanel = document.querySelector('.right-panel') as HTMLElement
if (rightPanel) {
startWidth.value = rightPanel.offsetWidth
}
document.addEventListener('mousemove', handleResize)
document.addEventListener('mouseup', stopResize)
}
// 处理拖动
const handleResize = (e: MouseEvent) => {
if (!isResizing.value) return
const container = document.querySelector('.new-chat-page') as HTMLElement
if (!container) return
const delta = e.clientX - startX.value
const newWidth = startWidth.value - delta
const containerWidth = container.offsetWidth
const minWidth = containerWidth * 0.2 // 最小宽度20%
const maxWidth = containerWidth * 0.8 // 最大宽度80%
if (newWidth >= minWidth && newWidth <= maxWidth) {
rightPanelWidth.value = `${newWidth}px`
}
}
// 停止拖动
const stopResize = () => {
isResizing.value = false
document.removeEventListener('mousemove', handleResize)
document.removeEventListener('mouseup', stopResize)
}
onUnmounted(() => {
document.removeEventListener('mousemove', handleResize)
document.removeEventListener('mouseup', stopResize)
})
</script>
<style scoped>
.new-chat-page {
display: flex;
height: 100%;
width: 100%;
background-color: var(--bg-primary);
overflow: hidden;
}
.left-panel {
flex: 1;
min-width: 200px;
display: flex;
flex-direction: column;
border-right: 1px solid var(--border-color);
overflow: hidden;
height: 100%;
min-height: 0;
}
.divider {
width: 4px;
background-color: var(--border-color);
cursor: col-resize;
transition: background-color var(--transition-normal);
flex-shrink: 0;
}
.divider:hover,
.divider.resizing {
background-color: var(--primary-color);
}
.right-panel {
width: 40%;
display: flex;
flex-direction: column;
overflow: hidden;
min-width: 200px;
transition: width var(--transition-normal);
height: 100%;
min-height: 0;
}
.divider.resizing ~ .right-panel {
transition: none;
}
/* 响应式设计 */
@media (max-width: 1200px) {
.new-chat-page {
flex-direction: column;
}
.left-panel {
flex: 1;
border-right: none;
border-bottom: 1px solid var(--border-color);
min-height: 300px;
}
.divider {
width: 100%;
height: 4px;
cursor: row-resize;
}
.right-panel {
width: 100%;
flex: 1;
min-height: 200px;
}
}
@media (max-width: 768px) {
.new-chat-page {
flex-direction: column;
}
.left-panel {
flex: 1;
border-right: none;
border-bottom: 1px solid var(--border-color);
min-height: 40%;
}
.divider {
width: 100%;
height: 4px;
cursor: row-resize;
}
.right-panel {
width: 100%;
flex: 0 1 60%;
min-height: 0;
}
}
@media (max-width: 576px) {
.new-chat-page {
flex-direction: column;
}
.left-panel {
flex: 1;
border-right: none;
border-bottom: 1px solid var(--border-color);
}
.divider {
width: 100%;
height: 4px;
}
.right-panel {
width: 100%;
flex: 1;
min-height: 0;
}
}
</style>
\ No newline at end of file
<template>
<div class="oauth2-provider-management">
<el-card>
<template #header>
<div class="card-header">
<span>OAuth2提供商配置管理</span>
<el-button type="primary" @click="handleCreate">新增提供商</el-button>
</div>
</template>
<!-- 搜索条件 -->
<el-form :inline="true" :model="searchForm" class="search-form">
<el-form-item label="提供商名称">
<el-input v-model="searchForm.providerName" placeholder="提供商名称" clearable />
</el-form-item>
<el-form-item label="显示名称">
<el-input v-model="searchForm.displayName" placeholder="显示名称" clearable />
</el-form-item>
<el-form-item label="启用状态">
<el-select v-model="searchForm.enabled" placeholder="请选择" clearable>
<el-option label="全部" :value="null" />
<el-option label="启用" :value="1" />
<el-option label="禁用" :value="0" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSearch">搜索</el-button>
<el-button @click="resetSearch">重置</el-button>
</el-form-item>
</el-form>
<!-- 数据表格 -->
<el-table :data="providers" v-loading="loading" style="width: 100%">
<el-table-column prop="providerName" label="提供商名称" width="150" />
<el-table-column prop="displayName" label="显示名称" width="150" />
<el-table-column prop="authType" label="认证类型" width="150" />
<el-table-column prop="clientId" label="客户端ID" width="200" />
<el-table-column prop="redirectUri" label="回调URL" width="250" />
<el-table-column prop="enabled" label="状态" width="80">
<template #default="scope">
<el-tag :type="scope.row.enabled ? 'success' : 'danger'">
{{ scope.row.enabled ? '启用' : '禁用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="createdAt" label="创建时间" width="180" />
<el-table-column label="操作" width="250" fixed="right">
<template #default="scope">
<el-button size="small" @click="handleEdit(scope.row)">编辑</el-button>
<el-button size="small" type="danger" @click="handleDelete(scope.row)">删除</el-button>
<el-button
size="small"
:type="scope.row.enabled ? 'warning' : 'success'"
@click="handleChangeStatus(scope.row)"
>
{{ scope.row.enabled ? '禁用' : '启用' }}
</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<div class="pagination-container">
<el-pagination
v-model:current-page="pagination.current"
v-model:page-size="pagination.size"
:page-sizes="[10, 20, 50]"
:total="pagination.total"
layout="total, sizes, prev, pager, next, jumper"
@size-change="handleSizeChange"
@current-change="handleCurrentChange">
</el-pagination>
</div>
</el-card>
<!-- 配置表单对话框 -->
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="600px">
<el-form ref="formRef" :model="form" :rules="rules" label-width="120px">
<el-form-item label="提供商名称" prop="providerName">
<el-input v-model="form.providerName" :disabled="!!form.id" placeholder="唯一标识,如 github" />
</el-form-item>
<el-form-item label="显示名称" prop="displayName">
<el-input v-model="form.displayName" placeholder="显示给用户的名称,如 GitHub 登录" />
</el-form-item>
<el-form-item label="描述">
<el-input v-model="form.description" type="textarea" placeholder="提供商描述信息" />
</el-form-item>
<el-form-item label="认证类型" prop="authType">
<el-select v-model="form.authType" placeholder="请选择认证类型">
<el-option label="Authorization Code" value="authorization_code" />
<el-option label="Implicit" value="implicit" />
<el-option label="Client Credentials" value="client_credentials" />
</el-select>
</el-form-item>
<el-form-item label="授权端点" prop="authorizeUrl">
<el-input v-model="form.authorizeUrl" placeholder="https://example.com/oauth/authorize" />
</el-form-item>
<el-form-item label="令牌端点" prop="tokenUrl">
<el-input v-model="form.tokenUrl" placeholder="https://example.com/oauth/token" />
</el-form-item>
<el-form-item label="用户信息端点" prop="userinfoUrl">
<el-input v-model="form.userinfoUrl" placeholder="https://example.com/api/user" />
</el-form-item>
<el-form-item label="客户端ID" prop="clientId">
<el-input v-model="form.clientId" placeholder="Client ID" show-password />
</el-form-item>
<el-form-item label="客户端密钥" prop="clientSecret">
<el-input v-model="form.clientSecret" placeholder="Client Secret" show-password />
</el-form-item>
<el-form-item label="回调URL" prop="redirectUri">
<el-input v-model="form.redirectUri" placeholder="http://localhost:8081/api/v1/auth/oauth2/callback?providerName=xxx" />
</el-form-item>
<el-form-item label="权限范围">
<el-input v-model="form.scope" placeholder="如 user:email,read:user" />
</el-form-item>
<el-form-item label="额外配置">
<el-input v-model="form.configJson" type="textarea" placeholder='{"key": "value"}' />
</el-form-item>
<el-form-item label="状态" prop="enabled">
<el-switch v-model="form.enabled" :active-value="1" :inactive-value="0" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="saveProvider">保存</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import request from '@/utils/request'
// 数据相关
const providers = ref([])
const loading = ref(false)
const dialogVisible = ref(false)
const dialogTitle = ref('')
const formRef = ref()
// 搜索表单
const searchForm = reactive({
providerName: '',
displayName: '',
enabled: null
})
// 分页相关
const pagination = reactive({
current: 1,
size: 10,
total: 0
})
// 表单数据
const form = reactive({
id: null,
providerName: '',
displayName: '',
description: '',
authType: 'authorization_code',
authorizeUrl: '',
tokenUrl: '',
userinfoUrl: '',
clientId: '',
clientSecret: '',
redirectUri: '',
scope: '',
configJson: '',
enabled: 1
})
// 表单验证规则
const rules = {
providerName: [
{ required: true, message: '请输入提供商名称', trigger: 'blur' }
],
displayName: [
{ required: true, message: '请输入显示名称', trigger: 'blur' }
],
authType: [
{ required: true, message: '请选择认证类型', trigger: 'change' }
],
authorizeUrl: [
{ required: true, message: '请输入授权端点URL', trigger: 'blur' }
],
tokenUrl: [
{ required: true, message: '请输入令牌端点URL', trigger: 'blur' }
],
userinfoUrl: [
{ required: true, message: '请输入用户信息端点URL', trigger: 'blur' }
],
clientId: [
{ required: true, message: '请输入客户端ID', trigger: 'blur' }
],
clientSecret: [
{ required: true, message: '请输入客户端密钥', trigger: 'blur' }
],
redirectUri: [
{ required: true, message: '请输入回调URL', trigger: 'blur' }
],
enabled: [
{ required: true, message: '请选择状态', trigger: 'change' }
]
}
// 获取提供商列表
const fetchProviders = async () => {
loading.value = true
try {
const response = await request.get('/auth/oauth2/providers', {
params: {
current: pagination.current,
size: pagination.size,
providerName: searchForm.providerName,
displayName: searchForm.displayName,
enabled: searchForm.enabled
}
})
if (response.data.code === 200) {
providers.value = response.data.data.records
pagination.total = response.data.data.total
} else {
ElMessage.error(response.data.message || '获取提供商列表失败')
}
} catch (error) {
console.error('获取提供商列表失败:', error)
ElMessage.error('获取提供商列表失败: ' + (error.response?.data?.message || error.message))
} finally {
loading.value = false
}
}
// 处理搜索
const handleSearch = () => {
pagination.current = 1
fetchProviders()
}
// 重置搜索
const resetSearch = () => {
searchForm.providerName = ''
searchForm.displayName = ''
searchForm.enabled = null
pagination.current = 1
fetchProviders()
}
// 处理新建
const handleCreate = () => {
dialogTitle.value = '新增OAuth2提供商'
// 重置表单
Object.assign(form, {
id: null,
providerName: '',
displayName: '',
description: '',
authType: 'authorization_code',
authorizeUrl: '',
tokenUrl: '',
userinfoUrl: '',
clientId: '',
clientSecret: '',
redirectUri: '',
scope: '',
configJson: '',
enabled: 1
})
dialogVisible.value = true
}
// 处理编辑
const handleEdit = (row) => {
dialogTitle.value = '编辑OAuth2提供商'
// 复制数据到表单
Object.assign(form, row)
dialogVisible.value = true
}
// 处理删除
const handleDelete = (row) => {
ElMessageBox.confirm(
`确定要删除提供商 "${row.displayName}" 吗?此操作不可恢复!`,
'确认删除',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}
).then(async () => {
try {
const response = await request.delete(`/auth/oauth2/providers/${row.id}`)
if (response.data.code === 200) {
ElMessage.success('删除成功')
fetchProviders()
} else {
ElMessage.error(response.data.message || '删除失败')
}
} catch (error) {
console.error('删除失败:', error)
ElMessage.error('删除失败: ' + (error.response?.data?.message || error.message))
}
}).catch(() => {
// 用户取消删除
})
}
// 处理状态变更
const handleChangeStatus = async (row) => {
try {
const action = row.enabled ? '禁用' : '启用'
const url = `/auth/oauth2/providers/${row.id}/${row.enabled ? 'disable' : 'enable'}`
const response = await request.post(url)
if (response.data.code === 200) {
ElMessage.success(`${action}成功`)
// 更新本地数据
row.enabled = row.enabled ? 0 : 1
} else {
ElMessage.error(response.data.message || `${action}失败`)
}
} catch (error) {
console.error('状态变更失败:', error)
ElMessage.error('状态变更失败: ' + (error.response?.data?.message || error.message))
}
}
// 保存提供商配置
const saveProvider = async () => {
formRef.value.validate(async (valid) => {
if (!valid) return
try {
let response
if (form.id) {
// 更新
response = await request.put(`/auth/oauth2/providers/${form.id}`, form)
} else {
// 创建
response = await request.post('/auth/oauth2/providers', form)
}
if (response.data.code === 200) {
ElMessage.success(form.id ? '更新成功' : '创建成功')
dialogVisible.value = false
fetchProviders()
} else {
ElMessage.error(response.data.message || (form.id ? '更新失败' : '创建失败'))
}
} catch (error) {
console.error('保存失败:', error)
ElMessage.error((form.id ? '更新失败' : '创建失败') + ': ' + (error.response?.data?.message || error.message))
}
})
}
// 处理分页变化
const handleSizeChange = (val) => {
pagination.size = val
fetchProviders()
}
const handleCurrentChange = (val) => {
pagination.current = val
fetchProviders()
}
// 组件挂载时获取数据
onMounted(() => {
fetchProviders()
})
</script>
<style scoped>
.oauth2-provider-management {
padding: 20px;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.search-form {
margin-bottom: 20px;
}
.pagination-container {
margin-top: 20px;
display: flex;
justify-content: center;
}
</style>
\ No newline at end of file
<template>
<div class="register-container">
<div class="register-form">
<h1>HiAgent</h1>
<p class="subtitle">创建账户</p>
<el-form
ref="formRef"
:model="form"
:rules="rules"
@submit.prevent="handleRegister"
>
<el-form-item prop="username">
<el-input
v-model="form.username"
placeholder="用户名"
prefix-icon="User"
/>
</el-form-item>
<el-form-item prop="email">
<el-input
v-model="form.email"
type="email"
placeholder="邮箱"
prefix-icon="Message"
/>
</el-form-item>
<el-form-item prop="password">
<el-input
v-model="form.password"
type="password"
placeholder="密码"
prefix-icon="Lock"
/>
</el-form-item>
<el-form-item>
<el-button
type="primary"
native-type="submit"
:loading="loading"
style="width: 100%"
>
注册
</el-button>
</el-form-item>
<div class="register-footer">
<router-link to="/login">已有账户?直接登录</router-link>
</div>
</el-form>
</div>
</div>
</template>
<script setup>
import { ref, reactive } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { ElMessage } from 'element-plus'
const router = useRouter()
const authStore = useAuthStore()
const formRef = ref()
const loading = ref(false)
const form = reactive({
username: '',
email: '',
password: ''
})
const rules = {
username: [
{ required: true, message: '请输入用户名', trigger: 'blur' }
],
email: [
{ required: true, message: '请输入邮箱', trigger: 'blur' },
{ type: 'email', message: '请输入正确的邮箱格式', trigger: 'blur' }
],
password: [
{ required: true, message: '请输入密码', trigger: 'blur' },
{ min: 6, message: '密码长度不少于6位', trigger: 'blur' }
]
}
const handleRegister = async () => {
try {
// 先进行表单验证
await formRef.value.validate()
// 表单验证通过,开始注册流程
loading.value = true
try {
await authStore.register(form.username, form.password, form.email)
ElMessage.success('注册成功,请登录')
router.push('/login')
} catch (error) {
ElMessage.error(error.message || '注册失败')
} finally {
loading.value = false
}
} catch (error) {
// 表单验证失败,Element Plus 会自动显示错误信息
// 这里可以添加额外的日志记录或其他处理
console.log('表单验证失败:', error)
// 不需要显示错误消息,因为 Element Plus 已经自动处理了
}
}
</script>
<style scoped>
.register-container {
width: 100vw;
height: 100vh;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
display: flex;
justify-content: center;
align-items: center;
}
.register-form {
background: white;
padding: 40px;
border-radius: 8px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
width: 100%;
max-width: 400px;
}
.register-form h1 {
text-align: center;
color: #333;
margin: 0 0 10px 0;
}
.subtitle {
text-align: center;
color: #999;
margin: 0 0 30px 0;
font-size: 14px;
}
.register-footer {
text-align: center;
margin-top: 20px;
}
.register-footer a {
color: #667eea;
text-decoration: none;
font-size: 14px;
}
.register-footer a:hover {
text-decoration: underline;
}
</style>
<template> <template>
<div class="management-page"> <div class="tool-management">
<h2>工具管理</h2> <h1>工具管理</h1>
<el-button type="primary" @click="openDialog()" style="margin-bottom: 20px"> <el-card>
注册工具 <div style="margin-bottom: 20px;">
</el-button> <el-button type="primary" @click="handleAddTool">添加工具</el-button>
<el-button @click="handleRefresh">刷新</el-button>
</div>
<el-table :data="tools" style="width: 100%">
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="name" label="工具名称" />
<el-table-column prop="category" label="分类" width="120" />
<el-table-column prop="version" label="版本" width="100" />
<el-table-column prop="status" label="状态" width="100">
<template #default="scope">
<el-tag :type="scope.row.status === 'active' ? 'success' : 'info'">
{{ scope.row.status === 'active' ? '启用' : '禁用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="description" label="描述" />
<el-table-column label="操作" width="250">
<template #default="scope">
<el-button size="small" @click="handleEdit(scope.row)">编辑</el-button>
<el-button size="small" @click="handleTest(scope.row)">测试</el-button>
<el-button
size="small"
:type="scope.row.status === 'active' ? 'warning' : 'success'"
@click="handleChangeStatus(scope.row)">
{{ scope.row.status === 'active' ? '禁用' : '启用' }}
</el-button>
<el-button size="small" type="danger" @click="handleDelete(scope.row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
<el-table :data="tools" stripe style="width: 100%"> <!-- 工具编辑对话框 -->
<el-table-column prop="name" label="工具名称" /> <el-dialog v-model="dialogVisible" :title="dialogTitle" width="50%">
<el-table-column prop="displayName" label="显示名称" /> <el-form :model="currentTool" label-width="100px">
<el-table-column prop="description" label="描述" />
<el-table-column prop="category" label="分类" />
<el-table-column prop="status" label="状态">
<template #default="{ row }">
<el-tag :type="row.status === 'active' ? 'success' : 'warning'">
{{ row.status === 'active' ? '活跃' : '非活跃' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作">
<template #default="{ row }">
<el-button link type="primary" @click="openDialog(row)">编辑</el-button>
<el-button link type="danger" @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-dialog v-model="dialogVisible" :title="isEdit ? '编辑工具' : '注册工具'" width="600px">
<el-form :model="form" label-width="120px">
<el-form-item label="工具名称"> <el-form-item label="工具名称">
<el-input v-model="form.name" placeholder="请输入工具名称" /> <el-input v-model="currentTool.name" />
</el-form-item>
<el-form-item label="显示名称">
<el-input v-model="form.displayName" placeholder="请输入显示名称" />
</el-form-item>
<el-form-item label="描述">
<el-input v-model="form.description" type="textarea" placeholder="请输入工具描述" />
</el-form-item> </el-form-item>
<el-form-item label="分类"> <el-form-item label="分类">
<el-select v-model="form.category" placeholder="请选择分类"> <el-select v-model="currentTool.category" placeholder="请选择分类">
<el-option label="API" value="API" /> <el-option label="数据处理" value="data" />
<el-option label="函数" value="FUNCTION" /> <el-option label="文件操作" value="file" />
<el-option label="默认" value="DEFAULT" /> <el-option label="网络请求" value="network" />
<el-option label="系统工具" value="system" />
<el-option label="AI辅助" value="ai" />
</el-select> </el-select>
</el-form-item> </el-form-item>
<el-form-item label="状态"> <el-form-item label="版本">
<el-select v-model="form.status" placeholder="请选择状态"> <el-input v-model="currentTool.version" />
<el-option label="活跃" value="active" />
<el-option label="非活跃" value="inactive" />
</el-select>
</el-form-item> </el-form-item>
<el-form-item label="API端点"> <el-form-item label="描述">
<el-input v-model="form.apiEndpoint" placeholder="请输入API端点URL" /> <el-input v-model="currentTool.description" type="textarea" />
</el-form-item>
<el-form-item label="请求方式">
<el-select v-model="form.httpMethod" placeholder="请选择请求方式">
<el-option label="GET" value="GET" />
<el-option label="POST" value="POST" />
<el-option label="PUT" value="PUT" />
<el-option label="DELETE" value="DELETE" />
</el-select>
</el-form-item> </el-form-item>
<el-form-item label="超时时间(毫秒)"> <el-form-item label="配置参数">
<el-input-number v-model="form.timeout" :min="1000" :max="60000" placeholder="请输入超时时间" /> <el-input v-model="currentTool.config" type="textarea" :rows="4" />
</el-form-item> </el-form-item>
</el-form> </el-form>
<template #footer> <template #footer>
<el-button @click="dialogVisible = false">取消</el-button> <span class="dialog-footer">
<el-button type="primary" @click="saveTool">保存</el-button> <el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleSave">保存</el-button>
</span>
</template> </template>
</el-dialog> </el-dialog>
</div> </div>
...@@ -74,101 +73,241 @@ ...@@ -74,101 +73,241 @@
<script setup> <script setup>
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import { useAuthStore } from '@/stores/auth'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
const authStore = useAuthStore() // 工具数据
const tools = ref([]) const tools = ref([
const dialogVisible = ref(false) {
const isEdit = ref(false) id: 1,
const form = ref({ name: '计算器',
name: '', category: 'system',
displayName: '', version: '1.0.0',
description: '', status: 'active',
category: 'API', description: '基础数学计算工具'
status: 'active', },
apiEndpoint: '', {
httpMethod: 'GET', id: 2,
timeout: 10000 name: '文件处理器',
}) category: 'file',
version: '1.2.1',
const toolId = ref('') status: 'active',
description: '文件读写和处理工具'
},
{
id: 3,
name: '天气查询',
category: 'network',
version: '1.1.0',
status: 'inactive',
description: '查询实时天气信息'
}
])
onMounted(async () => { // 对话框相关
await loadTools() const dialogVisible = ref(false)
}) const dialogTitle = ref('')
const currentTool = ref({})
const loadTools = async () => { // 处理添加工具
try { const handleAddTool = () => {
const res = await authStore.get('/tools') dialogTitle.value = '添加工具'
tools.value = res.data.data || [] currentTool.value = {
} catch (error) { id: Date.now(),
console.error('获取工具列表失败:', error) name: '',
ElMessage.error('获取工具列表失败: ' + (error.response?.data?.message || error.message || '未知错误')) category: '',
version: '1.0.0',
status: 'inactive',
description: '',
config: ''
} }
dialogVisible.value = true
} }
const openDialog = (tool) => { // 处理编辑工具
if (tool) { const handleEdit = (row) => {
isEdit.value = true dialogTitle.value = '编辑工具'
toolId.value = tool.id currentTool.value = { ...row }
form.value = { ...tool }
} else {
isEdit.value = false
toolId.value = ''
form.value = {
name: '',
displayName: '',
description: '',
category: 'API',
status: 'active',
apiEndpoint: '',
httpMethod: 'GET',
timeout: 10000
}
}
dialogVisible.value = true dialogVisible.value = true
} }
const saveTool = async () => { // 处理测试工具
try { const handleTest = (row) => {
if (isEdit.value) { ElMessage.info(`测试工具: ${row.name}`)
await authStore.put(`/tools/${toolId.value}`, form.value) }
ElMessage.success('工具更新成功')
} else { // 处理更改状态
await authStore.post('/tools', form.value) const handleChangeStatus = (row) => {
ElMessage.success('工具注册成功') const index = tools.value.findIndex(item => item.id === row.id)
} if (index > -1) {
dialogVisible.value = false tools.value[index].status = tools.value[index].status === 'active' ? 'inactive' : 'active'
await loadTools() ElMessage.success(`${tools.value[index].status === 'active' ? '启用' : '禁用'}成功`)
} catch (error) {
console.error('保存工具失败:', error)
ElMessage.error('保存工具失败: ' + (error.response?.data?.message || error.message || '未知错误'))
} }
} }
const handleDelete = (tool) => { // 处理删除工具
ElMessageBox.confirm(`确认删除工具 "${tool.name}" 吗?`, '提示', { const handleDelete = (row) => {
confirmButtonText: '确定', ElMessageBox.confirm(
cancelButtonText: '取消', `确定要删除工具"${row.name}"吗?此操作不可恢复。`,
type: 'warning' '确认删除',
}).then(async () => { {
try { confirmButtonText: '确定',
await authStore.del(`/tools/${tool.id}`) cancelButtonText: '取消',
ElMessage.success('工具删除成功') type: 'warning',
await loadTools()
} catch (error) {
console.error('删除工具失败:', error)
ElMessage.error('删除工具失败: ' + (error.response?.data?.message || error.message || '未知错误'))
} }
}).catch(() => { )
.then(() => {
// 从列表中移除
const index = tools.value.findIndex(item => item.id === row.id)
if (index > -1) {
tools.value.splice(index, 1)
}
ElMessage.success('删除成功')
})
.catch(() => {
ElMessage.info('已取消删除') ElMessage.info('已取消删除')
}) })
} }
// 处理保存
const handleSave = () => {
if (currentTool.value.name.trim() === '') {
ElMessage.warning('请输入工具名称')
return
}
if (currentTool.value.id > 0) {
// 编辑现有工具
const index = tools.value.findIndex(item => item.id === currentTool.value.id)
if (index > -1) {
tools.value[index] = { ...currentTool.value }
}
} else {
// 添加新工具
tools.value.push({ ...currentTool.value })
}
dialogVisible.value = false
ElMessage.success('保存成功')
}
// 处理刷新
const handleRefresh = () => {
ElMessage.info('刷新工具列表')
}
// 页面加载时获取工具数据
onMounted(() => {
console.log('加载工具数据')
})
</script> </script>
<style scoped> <style scoped>
.management-page { .tool-management {
padding: 20px; padding: var(--spacing-5);
background-color: var(--bg-secondary);
min-height: 100%;
}
.tool-management h1 {
margin-bottom: var(--spacing-4);
color: var(--text-primary);
font-weight: var(--font-weight-bold);
}
.el-card {
border-radius: var(--border-radius-lg);
box-shadow: var(--shadow-sm);
border: 1px solid var(--border-color);
transition: all var(--transition-normal);
}
.el-card:hover {
box-shadow: var(--shadow-md);
}
.el-table {
border-radius: var(--border-radius-md);
overflow: hidden;
}
.el-table :deep(.el-table__header th) {
background-color: var(--bg-secondary);
color: var(--text-primary);
font-weight: var(--font-weight-semibold);
}
.el-table :deep(.el-table__row:hover) {
background-color: var(--bg-hover);
}
.el-dialog {
border-radius: var(--border-radius-lg);
overflow: hidden;
}
.el-dialog__header {
background-color: var(--bg-secondary);
border-bottom: 1px solid var(--border-color);
padding: var(--spacing-4);
}
.el-dialog__body {
padding: var(--spacing-4);
}
.el-dialog__footer {
background-color: var(--bg-secondary);
border-top: 1px solid var(--border-color);
padding: var(--spacing-4);
}
.el-form-item {
margin-bottom: var(--spacing-4);
}
.el-input,
.el-select,
.el-textarea {
border-radius: var(--border-radius-md);
}
.el-input :deep(.el-input__inner),
.el-textarea :deep(.el-textarea__inner) {
border: 1px solid var(--border-color);
transition: border-color var(--transition-normal);
}
.el-input :deep(.el-input__inner:focus),
.el-textarea :deep(.el-textarea__inner:focus) {
border-color: var(--primary-color);
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
}
.el-tag {
border-radius: var(--border-radius-full);
}
/* 响应式设计 */
@media (max-width: 768px) {
.tool-management {
padding: var(--spacing-3);
}
.el-dialog {
width: 95% !important;
margin: 10px auto !important;
}
}
@media (max-width: 576px) {
.tool-management {
padding: var(--spacing-2);
}
.el-dialog__header,
.el-dialog__body,
.el-dialog__footer {
padding: var(--spacing-3);
}
} }
</style> </style>
\ No newline at end of file
import { createRouter, createWebHistory, RouteRecordRaw } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
const routes: RouteRecordRaw[] = [
{
path: '/',
redirect: '/dashboard'
},
{
path: '/login',
name: 'Login',
component: () => import('@/pages/Login.vue'),
meta: { requiresAuth: false }
},
{
path: '/register',
name: 'Register',
component: () => import('@/pages/Register.vue'),
meta: { requiresAuth: false }
},
{
path: '/dashboard',
name: 'Dashboard',
component: () => import('@/pages/Dashboard.vue'),
meta: { requiresAuth: true }
},
{
path: '/agent',
name: 'AgentManagement',
component: () => import('@/pages/AgentManagement.vue'),
meta: { requiresAuth: true }
},
{
path: '/documents',
name: 'DocumentManagement',
component: () => import('@/pages/DocumentManagement.vue'),
meta: { requiresAuth: true }
},
{
path: '/memory',
name: 'MemoryManagement',
component: () => import('@/pages/MemoryManagement.vue'),
meta: { requiresAuth: true }
},
{
path: '/llm-config',
name: 'LlmConfigManagement',
component: () => import('@/pages/LlmConfigManagement.vue'),
meta: { requiresAuth: true }
},
{
path: '/oauth2-providers',
name: 'OAuth2ProviderManagement',
component: () => import('@/pages/OAuth2ProviderManagement.vue'),
meta: { requiresAuth: true }
},
{
path: '/chat',
name: 'Chat',
component: () => import('@/pages/ChatPage.vue'),
meta: { requiresAuth: true }
},
{
path: '/tools',
name: 'ToolManagement',
component: () => import('@/pages/ToolManagement.vue'),
meta: { requiresAuth: true }
},
{
path: '/dom-sync',
name: 'DomSync',
component: () => import('@/pages/DomSyncPage.vue'),
meta: { requiresAuth: true }
},
{
path: '/new-chat',
name: 'NewChat',
component: () => import('@/pages/NewChatPage.vue'),
meta: { requiresAuth: true }
},
{
path: '/:pathMatch(.*)*',
name: 'NotFound',
redirect: '/dashboard'
}
]
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes
})
router.beforeEach((to, from, next) => {
const authStore = useAuthStore()
const requiresAuth = to.meta.requiresAuth
if (requiresAuth && !authStore.token) {
next('/login')
} else if ((to.path === '/login' || to.path === '/register') && authStore.token) {
next('/dashboard')
} else {
next()
}
})
export default router
\ No newline at end of file
declare module "*.vue" {
import type { DefineComponent } from "vue";
const component: DefineComponent<{}, {}, any>;
export default component;
}
\ No newline at end of file
import { AxiosResponse, AxiosRequestConfig } from 'axios'
interface UserInfo {
id?: string
username?: string
email?: string
[key: string]: any
}
interface AuthStore {
token: { value: string | null }
userInfo: { value: UserInfo }
register: (username: string, password: string, email: string) => Promise<any>
login: (username: string, password: string) => Promise<any>
logout: () => void
setUserInfo: (info: UserInfo) => void
api: any
get: (url: string, config?: AxiosRequestConfig) => Promise<AxiosResponse>
post: (url: string, data?: any, config?: AxiosRequestConfig) => Promise<AxiosResponse>
put: (url: string, data?: any, config?: AxiosRequestConfig) => Promise<AxiosResponse>
del: (url: string, config?: AxiosRequestConfig) => Promise<AxiosResponse>
}
export declare function useAuthStore(): AuthStore
\ No newline at end of file
import { defineStore } from 'pinia'
import { ref, Ref } from 'vue'
import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse, InternalAxiosRequestConfig } from 'axios'
import { useRouter } from 'vue-router'
// 定义用户信息接口
interface UserInfo {
id?: string
username?: string
email?: string
[key: string]: any
}
export const useAuthStore = defineStore('auth', () => {
const token = ref<string | null>(localStorage.getItem('token') || null)
const userInfo = ref<UserInfo>(JSON.parse(localStorage.getItem('userInfo') || '{}'))
const api: AxiosInstance = axios.create({
baseURL: '/api/v1'
})
api.interceptors.request.use(
(config: InternalAxiosRequestConfig) => {
if (token.value) {
config.headers.Authorization = `Bearer ${token.value}`
}
return config
},
(error: any) => Promise.reject(error)
)
// 添加响应拦截器处理401错误
api.interceptors.response.use(
(response: AxiosResponse) => response,
(error: any) => {
if (error.response && error.response.status === 401) {
// 清除认证信息
token.value = null
userInfo.value = {}
localStorage.removeItem('token')
localStorage.removeItem('userInfo')
// 跳转到登录页
if (typeof window !== 'undefined') {
setTimeout(() => {
window.location.href = '/login'
}, 100)
}
}
return Promise.reject(error)
}
)
async function register(username: string, password: string, email: string): Promise<any> {
try {
const response: AxiosResponse = await api.post('/auth/register', { username, password, email })
return response.data
} catch (error: any) {
throw new Error(error.response?.data?.message || '注册失败')
}
}
async function login(username: string, password: string): Promise<any> {
try {
const response: AxiosResponse = await api.post('/auth/login', { username, password })
const { token: newToken } = response.data.data
token.value = newToken
localStorage.setItem('token', newToken)
return response.data
} catch (error: any) {
throw new Error(error.response?.data?.message || '登录失败')
}
}
/**
* OAuth2 授权流程
* 重定向用户到授权服务器
*/
async function loginWithOAuth2(providerName: string): Promise<void> {
try {
// 调用后端端点得到授权 URL
const response: AxiosResponse = await api.get(`/auth/oauth2/authorize?providerName=${providerName}`)
// 后端分路归结会停止进一步的处理,此处是正常的
// 实际上此调用为无效的。前端应该直接渐进到授权端点
} catch (error: any) {
throw new Error(error.response?.data?.message || 'OAuth2 授权失败')
}
}
/**
* OAuth2 回调函数
* 处理来自授权服务器的回调
*/
function handleOAuth2Callback(): Promise<any> {
return new Promise((resolve, reject) => {
// 从 URL 参数中提取授权码
const params = new URLSearchParams(window.location.search)
const authCode = params.get('code')
const providerName = params.get('provider')
const error = params.get('error')
if (error) {
reject(new Error(`OAuth2 错误: ${error}`))
return
}
if (!authCode || !providerName) {
reject(new Error('不完整的 OAuth2 回调参数'))
return
}
// 使用授权码进行令牌交换
loginWithOAuth2Code(authCode, providerName)
.then(resolve)
.catch(reject)
})
}
/**
* 使用授权码不什止迟不何敷不洘华丞身式末身
*/
async function loginWithOAuth2Code(authCode: string, providerName: string): Promise<any> {
try {
const response: AxiosResponse = await api.post('/auth/oauth2/token', {
authorizationCode: authCode,
providerName: providerName
})
const { token: newToken } = response.data.data
token.value = newToken
localStorage.setItem('token', newToken)
return response.data
} catch (error: any) {
throw new Error(error.response?.data?.message || 'OAuth2 认证失败')
}
}
function logout(): void {
token.value = null
userInfo.value = {}
localStorage.removeItem('token')
localStorage.removeItem('userInfo')
}
function setUserInfo(info: UserInfo): void {
userInfo.value = info
localStorage.setItem('userInfo', JSON.stringify(info))
}
// 添加便捷方法
const get = (url: string, config: AxiosRequestConfig = {}): Promise<AxiosResponse> => api({ method: 'get', url, ...config })
const post = (url: string, data?: any, config: AxiosRequestConfig = {}): Promise<AxiosResponse> => api({ method: 'post', url, data, ...config })
const put = (url: string, data?: any, config: AxiosRequestConfig = {}): Promise<AxiosResponse> => api({ method: 'put', url, data, ...config })
const del = (url: string, config: AxiosRequestConfig = {}): Promise<AxiosResponse> => api({ method: 'delete', url, ...config })
return {
token,
userInfo,
register,
login,
loginWithOAuth2,
handleOAuth2Callback,
loginWithOAuth2Code,
logout,
setUserInfo,
api,
get,
post,
put,
del
}
})
\ No newline at end of file
/* 全局样式重置和基础样式 */
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html {
font-size: 16px;
scroll-behavior: smooth;
}
body {
font-family: var(--font-family-base);
font-size: var(--font-size-base);
line-height: var(--line-height-normal);
color: var(--text-primary);
background-color: var(--bg-primary);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* 链接样式 */
a {
color: var(--primary-color);
text-decoration: none;
transition: color var(--transition-normal);
}
a:hover {
color: var(--primary-color-dark);
text-decoration: underline;
}
/* 滚动条样式 */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: var(--bg-secondary);
}
::-webkit-scrollbar-thumb {
background: var(--gray-300);
border-radius: var(--border-radius-full);
transition: background var(--transition-normal);
}
::-webkit-scrollbar-thumb:hover {
background: var(--gray-400);
}
/* 输入框和表单元素 */
input, textarea, select {
font-family: var(--font-family-base);
font-size: var(--font-size-base);
border: 1px solid var(--border-color);
border-radius: var(--border-radius-base);
padding: var(--spacing-2);
transition: border-color var(--transition-normal), box-shadow var(--transition-normal);
}
input:focus, textarea:focus, select:focus {
outline: none;
border-color: var(--primary-color);
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
}
/* 卡片样式 */
.card {
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: var(--border-radius-lg);
box-shadow: var(--shadow-sm);
overflow: hidden;
}
.card-header {
padding: var(--spacing-4);
border-bottom: 1px solid var(--border-color);
background-color: var(--bg-secondary);
}
.card-body {
padding: var(--spacing-4);
}
/* 标题样式 */
h1, h2, h3, h4, h5, h6 {
font-weight: var(--font-weight-semibold);
line-height: var(--line-height-tight);
color: var(--text-primary);
margin-bottom: var(--spacing-3);
}
h1 {
font-size: var(--font-size-4xl);
}
h2 {
font-size: var(--font-size-3xl);
}
h3 {
font-size: var(--font-size-2xl);
}
h4 {
font-size: var(--font-size-xl);
}
h5 {
font-size: var(--font-size-lg);
}
h6 {
font-size: var(--font-size-base);
}
/* 段落样式 */
p {
margin-bottom: var(--spacing-4);
color: var(--text-secondary);
}
/* 列表样式 */
ul, ol {
margin-bottom: var(--spacing-4);
padding-left: var(--spacing-6);
}
li {
margin-bottom: var(--spacing-2);
color: var(--text-secondary);
}
/* 代码块样式 */
code {
font-family: var(--font-family-mono);
font-size: var(--font-size-sm);
background-color: var(--bg-secondary);
color: var(--accent-color);
padding: var(--spacing-1) var(--spacing-2);
border-radius: var(--border-radius-sm);
}
pre {
font-family: var(--font-family-mono);
font-size: var(--font-size-sm);
background-color: var(--bg-secondary);
padding: var(--spacing-4);
border-radius: var(--border-radius-md);
overflow-x: auto;
margin-bottom: var(--spacing-4);
}
pre code {
background: none;
padding: 0;
}
/* 表格样式 */
table {
width: 100%;
border-collapse: collapse;
margin-bottom: var(--spacing-4);
}
th, td {
padding: var(--spacing-3);
text-align: left;
border-bottom: 1px solid var(--border-color);
}
th {
font-weight: var(--font-weight-semibold);
background-color: var(--bg-secondary);
color: var(--text-primary);
}
tr:hover {
background-color: var(--bg-hover);
}
/* 工具类 */
.text-center {
text-align: center;
}
.text-right {
text-align: right;
}
.text-muted {
color: var(--text-tertiary);
}
.bg-primary {
background-color: var(--primary-color);
color: var(--text-inverse);
}
.bg-secondary {
background-color: var(--bg-secondary);
}
.shadow-sm {
box-shadow: var(--shadow-sm);
}
.shadow-md {
box-shadow: var(--shadow-md);
}
.shadow-lg {
box-shadow: var(--shadow-lg);
}
.rounded {
border-radius: var(--border-radius-base);
}
.rounded-lg {
border-radius: var(--border-radius-lg);
}
.rounded-full {
border-radius: var(--border-radius-full);
}
.flex {
display: flex;
}
.flex-col {
flex-direction: column;
}
.items-center {
align-items: center;
}
.justify-center {
justify-content: center;
}
.justify-between {
justify-content: space-between;
}
.w-full {
width: 100%;
}
.h-full {
height: 100%;
}
.p-4 {
padding: var(--spacing-4);
}
.m-4 {
margin: var(--spacing-4);
}
.mb-4 {
margin-bottom: var(--spacing-4);
}
.mt-4 {
margin-top: var(--spacing-4);
}
.ml-4 {
margin-left: var(--spacing-4);
}
.mr-4 {
margin-right: var(--spacing-4);
}
/* 表单控件样式 */
.form-control {
width: 100%;
padding: var(--spacing-2) var(--spacing-3);
border: 1px solid var(--border-color);
border-radius: var(--border-radius-md);
font-size: var(--font-size-base);
transition: all var(--transition-normal);
}
.form-control:focus {
outline: none;
border-color: var(--primary-color);
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
}
.form-control:disabled {
background-color: var(--bg-secondary);
cursor: not-allowed;
}
.form-label {
display: block;
margin-bottom: var(--spacing-2);
font-weight: var(--font-weight-medium);
color: var(--text-primary);
}
.form-error {
color: var(--error-color);
font-size: var(--font-size-sm);
margin-top: var(--spacing-1);
}
.form-group {
margin-bottom: var(--spacing-4);
}
/* 动画过渡效果 */
.fade-enter-active,
.fade-leave-active {
transition: opacity var(--transition-normal);
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
.slide-enter-active,
.slide-leave-active {
transition: all var(--transition-normal);
}
.slide-enter-from {
opacity: 0;
transform: translateY(10px);
}
.slide-leave-to {
opacity: 0;
transform: translateY(-10px);
}
.scale-enter-active,
.scale-leave-active {
transition: all var(--transition-normal);
}
.scale-enter-from,
.scale-leave-to {
opacity: 0;
transform: scale(0.95);
}
/* 状态样式 */
.state-empty {
text-align: center;
padding: var(--spacing-8);
color: var(--text-tertiary);
}
.state-loading {
display: flex;
justify-content: center;
align-items: center;
padding: var(--spacing-8);
}
.state-error {
padding: var(--spacing-4);
background-color: rgba(245, 34, 45, 0.1);
border: 1px solid var(--error-color);
border-radius: var(--border-radius-md);
color: var(--error-color);
}
.state-success {
padding: var(--spacing-4);
background-color: rgba(82, 196, 26, 0.1);
border: 1px solid var(--success-color);
border-radius: var(--border-radius-md);
color: var(--success-color);
}
.state-warning {
padding: var(--spacing-4);
background-color: rgba(250, 173, 20, 0.1);
border: 1px solid var(--warning-color);
border-radius: var(--border-radius-md);
color: var(--warning-color);
}
/* 响应式工具类 */
@media (max-width: 768px) {
html {
font-size: 14px;
}
.hidden-sm {
display: none;
}
.flex-col-sm {
flex-direction: column;
}
}
@media (max-width: 576px) {
.hidden-xs {
display: none;
}
.p-4-xs {
padding: var(--spacing-3);
}
.m-4-xs {
margin: var(--spacing-3);
}
}
\ No newline at end of file
:root {
/* 主色调 */
--primary-color: #667eea;
--primary-color-light: #764ba2;
--primary-color-dark: #5a6fd8;
/* 辅助色 */
--secondary-color: #f0f4f8;
--accent-color: #4a90e2;
--success-color: #52c41a;
--warning-color: #faad14;
--error-color: #f5222d;
--info-color: #1890ff;
/* 中性色 */
--white: #ffffff;
--black: #000000;
--gray-50: #f9fafb;
--gray-100: #f3f4f6;
--gray-200: #e5e7eb;
--gray-300: #d1d5db;
--gray-400: #9ca3af;
--gray-500: #6b7280;
--gray-600: #4b5563;
--gray-700: #374151;
--gray-800: #1f2937;
--gray-900: #111827;
/* 文字颜色 */
--text-primary: #1f2937;
--text-secondary: #4b5563;
--text-tertiary: #6b7280;
--text-disabled: #9ca3af;
--text-inverse: #ffffff;
/* 背景色 */
--bg-primary: #ffffff;
--bg-secondary: #f9fafb;
--bg-tertiary: #f3f4f6;
--bg-hover: #f0f4f8;
--bg-active: #e5e7eb;
/* 边框颜色 */
--border-color: #e5e7eb;
--border-color-light: #f3f4f6;
--border-color-dark: #d1d5db;
/* 阴影 */
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
--shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
/* 字体 */
--font-family-base: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
--font-family-mono: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
/* 字体大小 */
--font-size-xs: 0.75rem; /* 12px */
--font-size-sm: 0.875rem; /* 14px */
--font-size-base: 1rem; /* 16px */
--font-size-lg: 1.125rem; /* 18px */
--font-size-xl: 1.25rem; /* 20px */
--font-size-2xl: 1.5rem; /* 24px */
--font-size-3xl: 1.875rem; /* 30px */
--font-size-4xl: 2.25rem; /* 36px */
/* 行高 */
--line-height-none: 1;
--line-height-tight: 1.25;
--line-height-snug: 1.375;
--line-height-normal: 1.5;
--line-height-relaxed: 1.625;
--line-height-loose: 2;
/* 字重 */
--font-weight-light: 300;
--font-weight-normal: 400;
--font-weight-medium: 500;
--font-weight-semibold: 600;
--font-weight-bold: 700;
/* 间距 */
--spacing-0: 0;
--spacing-1: 0.25rem; /* 4px */
--spacing-2: 0.5rem; /* 8px */
--spacing-3: 0.75rem; /* 12px */
--spacing-4: 1rem; /* 16px */
--spacing-5: 1.25rem; /* 20px */
--spacing-6: 1.5rem; /* 24px */
--spacing-7: 1.75rem; /* 28px */
--spacing-8: 2rem; /* 32px */
--spacing-9: 2.25rem; /* 36px */
--spacing-10: 2.5rem; /* 40px */
--spacing-11: 2.75rem; /* 44px */
--spacing-12: 3rem; /* 48px */
/* 圆角 */
--border-radius-sm: 0.125rem; /* 2px */
--border-radius-base: 0.25rem; /* 4px */
--border-radius-md: 0.375rem; /* 6px */
--border-radius-lg: 0.5rem; /* 8px */
--border-radius-xl: 0.75rem; /* 12px */
--border-radius-2xl: 1rem; /* 16px */
--border-radius-full: 9999px;
/* 动画 */
--transition-fast: 150ms cubic-bezier(0.4, 0, 0.2, 1);
--transition-normal: 200ms cubic-bezier(0.4, 0, 0.2, 1);
--transition-slow: 300ms cubic-bezier(0.4, 0, 0.2, 1);
/* 断点 */
--breakpoint-xs: 0;
--breakpoint-sm: 576px;
--breakpoint-md: 768px;
--breakpoint-lg: 992px;
--breakpoint-xl: 1200px;
--breakpoint-xxl: 1400px;
/* Z轴 */
--z-index-dropdown: 1000;
--z-index-sticky: 1020;
--z-index-fixed: 1030;
--z-index-modal-backdrop: 1040;
--z-index-modal: 1050;
--z-index-popover: 1060;
--z-index-tooltip: 1070;
}
\ No newline at end of file
import { useAuthStore } from '@/stores/auth'
import { ElMessage } from 'element-plus'
/**
* 全局错误处理器
* @param {Error} error - 捕获到的错误对象
*/
export function handleGlobalError(error: any): boolean {
// 检查是否为HTTP错误响应
if (error.response) {
const { status, data } = error.response
// 处理401未授权错误
if (status === 401) {
ElMessage.error('登录已过期,请重新登录')
// 清除认证信息
const authStore = useAuthStore()
authStore.logout()
// 跳转到登录页
if (typeof window !== 'undefined') {
setTimeout(() => {
window.location.href = '/login'
}, 100)
}
return true // 表示错误已被处理
}
// 处理其他HTTP错误
const message = data?.message || `请求失败 (${status})`
ElMessage.error(message)
return true
}
// 处理网络错误
if (error.request) {
ElMessage.error('网络连接失败,请检查网络设置')
return true
}
// 处理其他错误
ElMessage.error(error.message || '未知错误')
return true
}
/**
* 包装异步操作,自动处理错误
* @param {Function} asyncFn - 异步函数
* @param {String} errorMessage - 自定义错误消息前缀
*/
export async function withErrorHandling<T>(asyncFn: () => Promise<T>, errorMessage: string = ''): Promise<T> {
try {
return await asyncFn()
} catch (error: any) {
if (errorMessage) {
error.message = `${errorMessage}: ${error.message}`
}
// 使用全局错误处理器
handleGlobalError(error)
// 重新抛出错误,以便调用者可以根据需要进一步处理
throw error
}
}
\ No newline at end of file
import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse, InternalAxiosRequestConfig } from 'axios'
import { useAuthStore } from '@/stores/auth'
import { ElMessage } from 'element-plus'
// 创建一个 axios 实例
const request: AxiosInstance = axios.create({
baseURL: '/api/v1', // 设置基础URL
timeout: 120000, // 设置超时时间为2分钟,解决Dashboard页面请求超时问题
})
// 请求拦截器
request.interceptors.request.use(
(config: InternalAxiosRequestConfig) => {
// 从 localStorage 获取 token
const token = localStorage.getItem('token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
},
(error: any) => {
return Promise.reject(error)
}
)
// 响应拦截器
request.interceptors.response.use(
(response: AxiosResponse) => {
// 对响应数据做点什么
return response
},
(error: any) => {
// 对响应错误做点什么
if (error.response && error.response.status === 401) {
// token 过期或无效,清除本地存储
localStorage.removeItem('token')
localStorage.removeItem('userInfo')
// 使用 auth store 的 logout 方法
const authStore = useAuthStore()
authStore.logout()
// 显示错误消息
ElMessage.error('认证已过期,请重新登录')
// 如果在浏览器环境中,跳转到登录页
if (typeof window !== 'undefined') {
// 延迟跳转,确保状态清理完成
setTimeout(() => {
window.location.href = '/login'
}, 100)
}
} else if (error.response && error.response.status === 403) {
// 权限不足
ElMessage.error('权限不足,无法访问该资源')
} else if (error.response && error.response.status >= 500) {
// 服务器错误
ElMessage.error('服务器内部错误,请稍后重试')
} else if (error.response) {
// 其他HTTP错误
ElMessage.error(`请求失败: ${error.response.status} ${error.response.statusText}`)
} else if (error.request) {
// 网络错误
ElMessage.error('网络连接失败,请检查网络设置')
} else {
// 其他错误
ElMessage.error('请求失败: ' + error.message)
}
return Promise.reject(error)
}
)
export default request
\ No newline at end of file
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment