diff --git a/pom.xml b/pom.xml index 7469bb4516d8e19459e1b78c925d39ba5b70c511..ab452184f1f68ac04637d6b2c46403345d7084a8 100644 --- a/pom.xml +++ b/pom.xml @@ -30,6 +30,8 @@ 1.1.2.0 2.6 0.9.1 + 2.3.1 + 2.3.9 5.8.25 1.18.20 3.5.7 diff --git a/super-agent-business/super-agent-business-chat/pom.xml b/super-agent-business/super-agent-business-chat/pom.xml index 4571ae626aa800be948174d204e473ce4f83fd88..612e9ab956fb8d0b99a8bbb5d3a866d6c39d8961 100644 --- a/super-agent-business/super-agent-business-chat/pom.xml +++ b/super-agent-business/super-agent-business-chat/pom.xml @@ -62,6 +62,10 @@ org.springframework.ai spring-ai-rag + + org.springframework.ai + spring-ai-template-st + org.springframework.boot spring-boot-starter-log4j2 diff --git a/super-agent-business/super-agent-business-chat/src/main/java/org/javaup/ai/auth/config/AdminAuthProperties.java b/super-agent-business/super-agent-business-chat/src/main/java/org/javaup/ai/auth/config/AdminAuthProperties.java new file mode 100644 index 0000000000000000000000000000000000000000..d67fe0755a2b781d315ff5c752c9f8187e52fa9f --- /dev/null +++ b/super-agent-business/super-agent-business-chat/src/main/java/org/javaup/ai/auth/config/AdminAuthProperties.java @@ -0,0 +1,32 @@ +package org.javaup.ai.auth.config; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * 后台管理登录配置。 + */ +@Data +@ConfigurationProperties(prefix = "app.admin-auth") +public class AdminAuthProperties { + + /** + * 后台登录用户名。 + */ + private String username = "admin"; + + /** + * 后台登录密码。 + */ + private String password = "admin123456"; + + /** + * JWT 签名密钥。 + */ + private String tokenSecret = "super-agent-admin-token-secret-change-me"; + + /** + * token 有效期,单位分钟。 + */ + private Long tokenExpireMinutes = 720L; +} diff --git a/super-agent-business/super-agent-business-chat/src/main/java/org/javaup/ai/auth/config/AdminWebMvcConfiguration.java b/super-agent-business/super-agent-business-chat/src/main/java/org/javaup/ai/auth/config/AdminWebMvcConfiguration.java new file mode 100644 index 0000000000000000000000000000000000000000..5ae3e32fc7da445eaa01bbc02de73f74e2288c42 --- /dev/null +++ b/super-agent-business/super-agent-business-chat/src/main/java/org/javaup/ai/auth/config/AdminWebMvcConfiguration.java @@ -0,0 +1,35 @@ +package org.javaup.ai.auth.config; + +import org.javaup.ai.auth.support.AdminAuthInterceptor; +import org.javaup.ai.auth.support.PreviewModeInterceptor; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.InterceptorRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +/** + * 后台管理登录与预览模式的 MVC 配置。 + */ +@Configuration +@EnableConfigurationProperties({AdminAuthProperties.class, PreviewModeProperties.class}) +public class AdminWebMvcConfiguration implements WebMvcConfigurer { + + private final AdminAuthInterceptor adminAuthInterceptor; + + private final PreviewModeInterceptor previewModeInterceptor; + + public AdminWebMvcConfiguration(AdminAuthInterceptor adminAuthInterceptor, + PreviewModeInterceptor previewModeInterceptor) { + this.adminAuthInterceptor = adminAuthInterceptor; + this.previewModeInterceptor = previewModeInterceptor; + } + + @Override + public void addInterceptors(InterceptorRegistry registry) { + registry.addInterceptor(adminAuthInterceptor) + .addPathPatterns("/manage/**", "/admin/auth/me"); + + registry.addInterceptor(previewModeInterceptor) + .addPathPatterns("/**"); + } +} diff --git a/super-agent-business/super-agent-business-chat/src/main/java/org/javaup/ai/auth/config/PreviewModeProperties.java b/super-agent-business/super-agent-business-chat/src/main/java/org/javaup/ai/auth/config/PreviewModeProperties.java new file mode 100644 index 0000000000000000000000000000000000000000..06631fd3168eef34153a595c0c370dc44f3b7c4c --- /dev/null +++ b/super-agent-business/super-agent-business-chat/src/main/java/org/javaup/ai/auth/config/PreviewModeProperties.java @@ -0,0 +1,22 @@ +package org.javaup.ai.auth.config; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * 线上演示只读模式配置。 + */ +@Data +@ConfigurationProperties(prefix = "app.preview-mode") +public class PreviewModeProperties { + + /** + * 是否开启只读展示模式。 + */ + private Boolean enabled = Boolean.FALSE; + + /** + * 只读模式提示语。 + */ + private String message = "当前环境为只读展示模式,仅开放浏览与检索能力"; +} diff --git a/super-agent-business/super-agent-business-chat/src/main/java/org/javaup/ai/auth/controller/AdminAuthController.java b/super-agent-business/super-agent-business-chat/src/main/java/org/javaup/ai/auth/controller/AdminAuthController.java new file mode 100644 index 0000000000000000000000000000000000000000..94ee65cd3e86e2501ee19ec5a4f11271ae7c84bb --- /dev/null +++ b/super-agent-business/super-agent-business-chat/src/main/java/org/javaup/ai/auth/controller/AdminAuthController.java @@ -0,0 +1,43 @@ +package org.javaup.ai.auth.controller; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.Valid; +import org.javaup.ai.auth.dto.AdminLoginRequest; +import org.javaup.ai.auth.service.AdminAuthService; +import org.javaup.ai.auth.vo.AdminLoginVo; +import org.javaup.ai.auth.vo.AdminProfileVo; +import org.javaup.common.ApiResponse; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * 后台登录认证接口。 + */ +@RestController +@RequestMapping("/admin/auth") +public class AdminAuthController { + + private final AdminAuthService adminAuthService; + + public AdminAuthController(AdminAuthService adminAuthService) { + this.adminAuthService = adminAuthService; + } + + @PostMapping("/login") + public ApiResponse login(@Valid @RequestBody AdminLoginRequest request) { + return ApiResponse.ok(adminAuthService.login(request)); + } + + @PostMapping("/logout") + public ApiResponse logout() { + return ApiResponse.ok(); + } + + @GetMapping("/me") + public ApiResponse me(HttpServletRequest request) { + return ApiResponse.ok(adminAuthService.currentProfile(request)); + } +} diff --git a/super-agent-business/super-agent-business-chat/src/main/java/org/javaup/ai/auth/dto/AdminLoginRequest.java b/super-agent-business/super-agent-business-chat/src/main/java/org/javaup/ai/auth/dto/AdminLoginRequest.java new file mode 100644 index 0000000000000000000000000000000000000000..25996282ddea281fbb909e85c7a91381a7e2be02 --- /dev/null +++ b/super-agent-business/super-agent-business-chat/src/main/java/org/javaup/ai/auth/dto/AdminLoginRequest.java @@ -0,0 +1,31 @@ +package org.javaup.ai.auth.dto; + +import jakarta.validation.constraints.NotBlank; + +/** + * 后台登录请求。 + */ +public class AdminLoginRequest { + + @NotBlank(message = "请输入账号") + private String username; + + @NotBlank(message = "请输入密码") + private String password; + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public String getPassword() { + return password; + } + + public void setPassword(String password) { + this.password = password; + } +} diff --git a/super-agent-business/super-agent-business-chat/src/main/java/org/javaup/ai/auth/service/AdminAuthService.java b/super-agent-business/super-agent-business-chat/src/main/java/org/javaup/ai/auth/service/AdminAuthService.java new file mode 100644 index 0000000000000000000000000000000000000000..1bda520c85a644d9acff115dd7c484d5cfc4b14c --- /dev/null +++ b/super-agent-business/super-agent-business-chat/src/main/java/org/javaup/ai/auth/service/AdminAuthService.java @@ -0,0 +1,16 @@ +package org.javaup.ai.auth.service; + +import jakarta.servlet.http.HttpServletRequest; +import org.javaup.ai.auth.dto.AdminLoginRequest; +import org.javaup.ai.auth.vo.AdminLoginVo; +import org.javaup.ai.auth.vo.AdminProfileVo; + +/** + * 后台登录认证服务。 + */ +public interface AdminAuthService { + + AdminLoginVo login(AdminLoginRequest request); + + AdminProfileVo currentProfile(HttpServletRequest request); +} diff --git a/super-agent-business/super-agent-business-chat/src/main/java/org/javaup/ai/auth/service/impl/AdminAuthServiceImpl.java b/super-agent-business/super-agent-business-chat/src/main/java/org/javaup/ai/auth/service/impl/AdminAuthServiceImpl.java new file mode 100644 index 0000000000000000000000000000000000000000..cdb474aaa5a7908820ff42731c36d7ef7ed6a7fc --- /dev/null +++ b/super-agent-business/super-agent-business-chat/src/main/java/org/javaup/ai/auth/service/impl/AdminAuthServiceImpl.java @@ -0,0 +1,49 @@ +package org.javaup.ai.auth.service.impl; + +import cn.hutool.core.util.StrUtil; +import jakarta.servlet.http.HttpServletRequest; +import org.javaup.ai.auth.config.AdminAuthProperties; +import org.javaup.ai.auth.dto.AdminLoginRequest; +import org.javaup.ai.auth.service.AdminAuthService; +import org.javaup.ai.auth.support.AdminJwtTokenService; +import org.javaup.ai.auth.support.AdminRequestContext; +import org.javaup.ai.auth.vo.AdminLoginVo; +import org.javaup.ai.auth.vo.AdminProfileVo; +import org.javaup.exception.SuperAgentFrameException; +import org.springframework.stereotype.Service; + +/** + * 后台登录认证实现。 + */ +@Service +public class AdminAuthServiceImpl implements AdminAuthService { + + private final AdminAuthProperties adminAuthProperties; + + private final AdminJwtTokenService adminJwtTokenService; + + public AdminAuthServiceImpl(AdminAuthProperties adminAuthProperties, + AdminJwtTokenService adminJwtTokenService) { + this.adminAuthProperties = adminAuthProperties; + this.adminJwtTokenService = adminJwtTokenService; + } + + @Override + public AdminLoginVo login(AdminLoginRequest request) { + String username = StrUtil.trim(request.getUsername()); + String password = StrUtil.trim(request.getPassword()); + if (!StrUtil.equals(username, adminAuthProperties.getUsername()) + || !StrUtil.equals(password, adminAuthProperties.getPassword())) { + throw new SuperAgentFrameException(401, "账号或密码不正确"); + } + + String token = adminJwtTokenService.generateToken(username); + return new AdminLoginVo(username, token, adminAuthProperties.getTokenExpireMinutes()); + } + + @Override + public AdminProfileVo currentProfile(HttpServletRequest request) { + String username = AdminRequestContext.resolveUsername(request); + return new AdminProfileVo(username); + } +} diff --git a/super-agent-business/super-agent-business-chat/src/main/java/org/javaup/ai/auth/support/AdminAuthInterceptor.java b/super-agent-business/super-agent-business-chat/src/main/java/org/javaup/ai/auth/support/AdminAuthInterceptor.java new file mode 100644 index 0000000000000000000000000000000000000000..cbe7b42b8912daafb5c94186b0bea891edd2a599 --- /dev/null +++ b/super-agent-business/super-agent-business-chat/src/main/java/org/javaup/ai/auth/support/AdminAuthInterceptor.java @@ -0,0 +1,74 @@ +package org.javaup.ai.auth.support; + +import cn.hutool.core.util.StrUtil; +import com.fasterxml.jackson.databind.ObjectMapper; +import io.jsonwebtoken.Claims; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.nio.charset.StandardCharsets; +import org.javaup.common.ApiResponse; +import org.javaup.exception.SuperAgentFrameException; +import org.springframework.stereotype.Component; +import org.springframework.web.servlet.HandlerInterceptor; + +/** + * 后台管理接口鉴权拦截器。 + */ +@Component +public class AdminAuthInterceptor implements HandlerInterceptor { + + private final AdminJwtTokenService adminJwtTokenService; + + private final ObjectMapper objectMapper; + + public AdminAuthInterceptor(AdminJwtTokenService adminJwtTokenService, + ObjectMapper objectMapper) { + this.adminJwtTokenService = adminJwtTokenService; + this.objectMapper = objectMapper; + } + + @Override + public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { + if ("OPTIONS".equalsIgnoreCase(request.getMethod())) { + return true; + } + + String authorization = request.getHeader("Authorization"); + String token = resolveToken(authorization); + if (StrUtil.isBlank(token)) { + writeUnauthorized(response, "请先登录后台管理台"); + return false; + } + + try { + Claims claims = adminJwtTokenService.parseToken(token); + String username = claims.getSubject(); + if (StrUtil.isBlank(username)) { + writeUnauthorized(response, "后台登录无效,请重新登录"); + return false; + } + AdminRequestContext.storeUsername(request, username); + return true; + } catch (SuperAgentFrameException exception) { + writeUnauthorized(response, exception.getMessage()); + return false; + } + } + + private String resolveToken(String authorization) { + if (StrUtil.isBlank(authorization)) { + return null; + } + if (StrUtil.startWithIgnoreCase(authorization, "Bearer ")) { + return StrUtil.trim(authorization.substring(7)); + } + return StrUtil.trim(authorization); + } + + private void writeUnauthorized(HttpServletResponse response, String message) throws Exception { + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + response.setCharacterEncoding(StandardCharsets.UTF_8.name()); + response.setContentType("application/json;charset=UTF-8"); + response.getWriter().write(objectMapper.writeValueAsString(ApiResponse.error(401, message))); + } +} diff --git a/super-agent-business/super-agent-business-chat/src/main/java/org/javaup/ai/auth/support/AdminJwtTokenService.java b/super-agent-business/super-agent-business-chat/src/main/java/org/javaup/ai/auth/support/AdminJwtTokenService.java new file mode 100644 index 0000000000000000000000000000000000000000..d200b71e37a6de012c87816918e2af0bc817f554 --- /dev/null +++ b/super-agent-business/super-agent-business-chat/src/main/java/org/javaup/ai/auth/support/AdminJwtTokenService.java @@ -0,0 +1,53 @@ +package org.javaup.ai.auth.support; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.ExpiredJwtException; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.JwtException; +import io.jsonwebtoken.SignatureAlgorithm; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.Date; +import org.javaup.ai.auth.config.AdminAuthProperties; +import org.javaup.exception.SuperAgentFrameException; +import org.springframework.stereotype.Component; + +/** + * 后台登录 JWT 服务。 + */ +@Component +public class AdminJwtTokenService { + + private final AdminAuthProperties adminAuthProperties; + + public AdminJwtTokenService(AdminAuthProperties adminAuthProperties) { + this.adminAuthProperties = adminAuthProperties; + } + + public String generateToken(String username) { + Instant now = Instant.now(); + Instant expireAt = now.plusSeconds(adminAuthProperties.getTokenExpireMinutes() * 60); + return Jwts.builder() + .setSubject(username) + .setIssuedAt(Date.from(now)) + .setExpiration(Date.from(expireAt)) + .signWith( + SignatureAlgorithm.HS256, + adminAuthProperties.getTokenSecret().getBytes(StandardCharsets.UTF_8) + ) + .compact(); + } + + public Claims parseToken(String token) { + try { + return Jwts.parser() + .setSigningKey(adminAuthProperties.getTokenSecret().getBytes(StandardCharsets.UTF_8)) + .parseClaimsJws(token) + .getBody(); + } catch (ExpiredJwtException exception) { + throw new SuperAgentFrameException(401, "后台登录已过期,请重新登录", exception); + } catch (JwtException | IllegalArgumentException exception) { + throw new SuperAgentFrameException(401, "后台登录无效,请重新登录", exception); + } + } +} diff --git a/super-agent-business/super-agent-business-chat/src/main/java/org/javaup/ai/auth/support/AdminRequestContext.java b/super-agent-business/super-agent-business-chat/src/main/java/org/javaup/ai/auth/support/AdminRequestContext.java new file mode 100644 index 0000000000000000000000000000000000000000..b07d36dd036b0dc57742b84f892b30d83e90ceb8 --- /dev/null +++ b/super-agent-business/super-agent-business-chat/src/main/java/org/javaup/ai/auth/support/AdminRequestContext.java @@ -0,0 +1,23 @@ +package org.javaup.ai.auth.support; + +import jakarta.servlet.http.HttpServletRequest; + +/** + * 当前请求中的后台管理员上下文。 + */ +public final class AdminRequestContext { + + public static final String ADMIN_USERNAME_ATTRIBUTE = "super.agent.admin.username"; + + private AdminRequestContext() { + } + + public static void storeUsername(HttpServletRequest request, String username) { + request.setAttribute(ADMIN_USERNAME_ATTRIBUTE, username); + } + + public static String resolveUsername(HttpServletRequest request) { + Object username = request.getAttribute(ADMIN_USERNAME_ATTRIBUTE); + return username == null ? "" : String.valueOf(username); + } +} diff --git a/super-agent-business/super-agent-business-chat/src/main/java/org/javaup/ai/auth/support/PreviewModeInterceptor.java b/super-agent-business/super-agent-business-chat/src/main/java/org/javaup/ai/auth/support/PreviewModeInterceptor.java new file mode 100644 index 0000000000000000000000000000000000000000..c1cfbd6cbe9e1fd393cffc60f69c674bd3cc9dba --- /dev/null +++ b/super-agent-business/super-agent-business-chat/src/main/java/org/javaup/ai/auth/support/PreviewModeInterceptor.java @@ -0,0 +1,91 @@ +package org.javaup.ai.auth.support; + +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.nio.charset.StandardCharsets; +import java.util.Set; +import org.javaup.ai.auth.config.PreviewModeProperties; +import org.javaup.ai.chatagent.support.StreamEventWriter; +import org.javaup.common.ApiResponse; +import org.springframework.stereotype.Component; +import org.springframework.web.servlet.HandlerInterceptor; + +/** + * 线上只读展示模式拦截器。 + */ +@Component +public class PreviewModeInterceptor implements HandlerInterceptor { + + private static final Set BLOCKED_PATHS = Set.of( + "/api/chat/stream", + "/api/chat/session/stop", + "/api/chat/session/reset", + "/api/chat/session/summary/rebuild", + "/manage/document/upload", + "/manage/document/delete", + "/manage/document/strategy/confirm", + "/manage/document/index/build", + "/manage/knowledge/scope/save", + "/manage/knowledge/scope/delete", + "/manage/knowledge/topic/save", + "/manage/knowledge/topic/delete", + "/manage/knowledge/document/profile/regenerate", + "/manage/knowledge/document/profile/batch/regenerate", + "/manage/knowledge/topic/document/save", + "/manage/knowledge/topic/document/remove" + ); + + private final PreviewModeProperties previewModeProperties; + + private final ObjectMapper objectMapper; + + private final StreamEventWriter streamEventWriter; + + public PreviewModeInterceptor(PreviewModeProperties previewModeProperties, + ObjectMapper objectMapper, + StreamEventWriter streamEventWriter) { + this.previewModeProperties = previewModeProperties; + this.objectMapper = objectMapper; + this.streamEventWriter = streamEventWriter; + } + + @Override + public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { + if (!Boolean.TRUE.equals(previewModeProperties.getEnabled())) { + return true; + } + if ("OPTIONS".equalsIgnoreCase(request.getMethod())) { + return true; + } + + String path = request.getRequestURI().substring(request.getContextPath().length()); + if (!BLOCKED_PATHS.contains(path)) { + return true; + } + + if ("/api/chat/stream".equals(path)) { + writeStreamReject(response); + } else { + writeJsonReject(response); + } + return false; + } + + private void writeStreamReject(HttpServletResponse response) throws Exception { + response.setStatus(HttpServletResponse.SC_OK); + response.setCharacterEncoding(StandardCharsets.UTF_8.name()); + response.setContentType("text/event-stream;charset=UTF-8"); + response.setHeader("Cache-Control", "no-cache"); + response.setHeader("Connection", "keep-alive"); + response.getWriter().write("data: " + streamEventWriter.error(previewModeProperties.getMessage()) + "\n\n"); + response.getWriter().flush(); + } + + private void writeJsonReject(HttpServletResponse response) throws Exception { + response.setStatus(HttpServletResponse.SC_OK); + response.setCharacterEncoding(StandardCharsets.UTF_8.name()); + response.setContentType("application/json;charset=UTF-8"); + response.getWriter().write(objectMapper.writeValueAsString(ApiResponse.error(previewModeProperties.getMessage()))); + } +} diff --git a/super-agent-business/super-agent-business-chat/src/main/java/org/javaup/ai/auth/vo/AdminLoginVo.java b/super-agent-business/super-agent-business-chat/src/main/java/org/javaup/ai/auth/vo/AdminLoginVo.java new file mode 100644 index 0000000000000000000000000000000000000000..fc6a36e0dc80a12391c03c3d3cd7b9078d7ba633 --- /dev/null +++ b/super-agent-business/super-agent-business-chat/src/main/java/org/javaup/ai/auth/vo/AdminLoginVo.java @@ -0,0 +1,43 @@ +package org.javaup.ai.auth.vo; + +/** + * 后台登录返回值。 + */ +public class AdminLoginVo { + + private String username; + + private String token; + + private Long expireMinutes; + + public AdminLoginVo(String username, String token, Long expireMinutes) { + this.username = username; + this.token = token; + this.expireMinutes = expireMinutes; + } + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public String getToken() { + return token; + } + + public void setToken(String token) { + this.token = token; + } + + public Long getExpireMinutes() { + return expireMinutes; + } + + public void setExpireMinutes(Long expireMinutes) { + this.expireMinutes = expireMinutes; + } +} diff --git a/super-agent-business/super-agent-business-chat/src/main/java/org/javaup/ai/auth/vo/AdminProfileVo.java b/super-agent-business/super-agent-business-chat/src/main/java/org/javaup/ai/auth/vo/AdminProfileVo.java new file mode 100644 index 0000000000000000000000000000000000000000..f455a5b98eada2f0274dedcd70005d67d64eb2fb --- /dev/null +++ b/super-agent-business/super-agent-business-chat/src/main/java/org/javaup/ai/auth/vo/AdminProfileVo.java @@ -0,0 +1,21 @@ +package org.javaup.ai.auth.vo; + +/** + * 当前后台管理员信息。 + */ +public class AdminProfileVo { + + private String username; + + public AdminProfileVo(String username) { + this.username = username; + } + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } +} diff --git a/super-agent-business/super-agent-business-chat/src/main/java/org/javaup/ai/chatagent/rag/service/ChatQueryRewriteService.java b/super-agent-business/super-agent-business-chat/src/main/java/org/javaup/ai/chatagent/rag/service/ChatQueryRewriteService.java index 1b7800da59f3abe54eea6c5bda9f64a16bf604e2..e531b415eacdf58d9aaf226ef0f6fb77e59f905b 100644 --- a/super-agent-business/super-agent-business-chat/src/main/java/org/javaup/ai/chatagent/rag/service/ChatQueryRewriteService.java +++ b/super-agent-business/super-agent-business-chat/src/main/java/org/javaup/ai/chatagent/rag/service/ChatQueryRewriteService.java @@ -8,6 +8,8 @@ import org.javaup.ai.chatagent.rag.config.ChatRagProperties; import org.javaup.ai.chatagent.rag.model.RagRewriteResult; import org.javaup.ai.chatagent.service.ConversationTraceRecorder; import org.javaup.ai.chatagent.service.ObservedChatModelService; +import org.javaup.ai.prompt.PromptTemplateNames; +import org.javaup.ai.prompt.PromptTemplateService; import org.springframework.ai.chat.prompt.ChatOptions; import org.springframework.ai.openai.OpenAiChatOptions; import org.springframework.stereotype.Service; @@ -32,47 +34,19 @@ public class ChatQueryRewriteService { private static final Pattern NUMBERED_MULTI_QUESTION_PATTERN = Pattern.compile("(^|\\s)(\\d+[)\\.、]|[A-Za-z][)])"); private static final Pattern MULTI_LINE_PATTERN = Pattern.compile("\\n+"); - private static final String REWRITE_PROMPT = """ - 你是企业文档问答系统的问题改写助手。 - 请结合历史上下文和当前问题,输出一个 JSON: - { - "rewrite": "改写后的独立问题", - "should_split": true, - "sub_questions": ["子问题1", "子问题2"] - } - - 改写规则: - 1. 只做指代消解、上下文补全、口语转书面化,不要发散扩写。 - 2. 专有名词、时间范围、环境、角色、终端类型等限制条件必须保留。 - 3. 不得添加原文没有的条件、维度、假设,不得引入“方面/维度/角度”等枚举词。 - 4. 如果当前问题已经完整,就尽量少改。 - 5. 不要根据你自己的理解去提前规划章节、结构或检索模式。 - - 拆分规则: - 1. 默认 should_split=false,sub_questions 只保留 1 条,且必须与 rewrite 表达同一件事。 - 2. 只有当前问题原文里显式存在多个独立问题时,才允许 should_split=true。 - 3. 可拆分的典型情况只有:多个问号、分号、换行列举、编号列举、明确“分别”提问。 - 4. 抽象对比、笼统追问、承接式追问一律不要拆分;只做改写。 - 5. 不确定时必须不拆分。 - 6. 只返回合法 JSON,不要输出额外解释。 - - 历史上下文: - {history} - - 当前问题: - {question} - """; - private final ObservedChatModelService observedChatModelService; private final ObjectMapper objectMapper; private final ChatRagProperties properties; + private final PromptTemplateService promptTemplateService; public ChatQueryRewriteService(ObservedChatModelService observedChatModelService, ObjectMapper objectMapper, - ChatRagProperties properties) { + ChatRagProperties properties, + PromptTemplateService promptTemplateService) { this.observedChatModelService = observedChatModelService; this.objectMapper = objectMapper; this.properties = properties; + this.promptTemplateService = promptTemplateService; } public RagRewriteResult rewrite(String question, String historySummary) { @@ -95,9 +69,10 @@ public class ChatQueryRewriteService { return fallback; } try { - String prompt = REWRITE_PROMPT - .replace("{history}", StrUtil.isNotBlank(historySummary) ? historySummary : "无历史上下文") - .replace("{question}", normalizedQuestion); + String prompt = promptTemplateService.render(PromptTemplateNames.CHAT_QUERY_REWRITE, Map.of( + "history", StrUtil.isNotBlank(historySummary) ? historySummary : "无历史上下文", + "question", normalizedQuestion + )); String raw = observedChatModelService.callText("rewrite", null, prompt, buildRewriteCallOptions(), traceRecorder); RagRewriteResult parsed = normalizeRewriteResult(normalizedQuestion, parse(raw)); if (parsed != null && StrUtil.isNotBlank(parsed.getRewrittenQuestion())) { diff --git a/super-agent-business/super-agent-business-chat/src/main/java/org/javaup/ai/chatagent/rag/service/RagPromptAssemblyService.java b/super-agent-business/super-agent-business-chat/src/main/java/org/javaup/ai/chatagent/rag/service/RagPromptAssemblyService.java index f491aba583b5d7dc31a30e62acb55c4b24a56958..e2400b474f581a15296669b239e57038e0b3530f 100644 --- a/super-agent-business/super-agent-business-chat/src/main/java/org/javaup/ai/chatagent/rag/service/RagPromptAssemblyService.java +++ b/super-agent-business/super-agent-business-chat/src/main/java/org/javaup/ai/chatagent/rag/service/RagPromptAssemblyService.java @@ -8,11 +8,14 @@ import org.javaup.ai.chatagent.rag.model.ConversationExecutionPlan; import org.javaup.ai.chatagent.rag.model.RagPromptAssemblyResult; import org.javaup.ai.chatagent.rag.model.RagRetrievalContext; import org.javaup.ai.chatagent.rag.model.SubQuestionEvidence; +import org.javaup.ai.prompt.PromptTemplateNames; +import org.javaup.ai.prompt.PromptTemplateService; import org.springframework.stereotype.Service; import java.util.ArrayList; import java.util.LinkedHashSet; import java.util.List; +import java.util.Map; import java.util.Set; /** @@ -24,26 +27,20 @@ import java.util.Set; @Service public class RagPromptAssemblyService { - private static final String DEFAULT_SYSTEM_PROMPT = """ - 你是 JavaUp 的企业知识问答助手。 - 你必须严格基于给定证据回答,不要编造证据中没有出现的事实。 - 如果提供了“对话承接上下文”,它只用于理解当前问题中的指代关系,不能替代证据材料,也不能作为事实来源。 - 如果证据不足以支持明确结论,请直接说明资料不足。 - 如果问题被拆成多个子问题,请按编号逐一回答。 - 如果引用了证据,请在对应句子末尾标注 [1][2] 这样的引用编号。 - """; - private final ChatRagProperties properties; + private final PromptTemplateService promptTemplateService; - public RagPromptAssemblyService(ChatRagProperties properties) { + public RagPromptAssemblyService(ChatRagProperties properties, + PromptTemplateService promptTemplateService) { this.properties = properties; + this.promptTemplateService = promptTemplateService; } public String buildSystemPrompt() { return StrUtil.isNotBlank(properties.getAnswerSystemPrompt()) ? properties.getAnswerSystemPrompt().trim() - : DEFAULT_SYSTEM_PROMPT.trim(); + : promptTemplateService.render(PromptTemplateNames.RAG_ANSWER_SYSTEM, Map.of()); } public String buildUserPrompt(ConversationExecutionPlan plan, RagRetrievalContext context) { @@ -51,45 +48,25 @@ public class RagPromptAssemblyService { } public RagPromptAssemblyResult assemble(ConversationExecutionPlan plan, RagRetrievalContext context) { - StringBuilder builder = new StringBuilder(); PromptBudget promptBudget = new PromptBudget( Math.max(0, properties.getTotalEvidenceMaxChars()), Math.max(0, properties.getPerSubQuestionEvidenceMaxChars()) ); Set renderedReferenceKeys = new LinkedHashSet<>(); - - builder.append("当前日期:").append(plan.getCurrentDateText()).append("\n\n"); - builder.append("用户原始问题:\n").append(plan.getOriginalQuestion()).append("\n\n"); - - if (StrUtil.isNotBlank(plan.getRetrievalQuestion()) && !plan.getRetrievalQuestion().equals(plan.getOriginalQuestion())) { - - builder.append("检索理解后的问题:\n").append(plan.getRetrievalQuestion()).append("\n\n"); - } - - appendHistoryContext(builder, plan); - - if (plan.getRetrievalSubQuestions() != null && plan.getRetrievalSubQuestions().size() > 1) { - - builder.append("请按下面这些子问题逐一回答:\n"); - for (int index = 0; index < plan.getRetrievalSubQuestions().size(); index++) { - builder.append(index + 1).append(". ").append(plan.getRetrievalSubQuestions().get(index)).append("\n"); - } - builder.append("\n"); - } - - builder.append("证据材料:\n"); - for (SubQuestionEvidence evidence : context.getSubQuestionEvidenceList()) { - - builder.append("\n## 子问题") - .append(evidence.getSubQuestionIndex()) - .append(":") - .append(evidence.getSubQuestion()) - .append("\n"); - appendReferences(builder, evidence.getReferences(), renderedReferenceKeys, promptBudget); - } + String userPrompt = promptTemplateService.render(PromptTemplateNames.RAG_ANSWER_USER, Map.of( + "currentDate", StrUtil.blankToDefault(plan.getCurrentDateText(), ""), + "originalQuestion", StrUtil.blankToDefault(plan.getOriginalQuestion(), ""), + "hasRetrievalQuestion", hasRetrievalQuestion(plan), + "retrievalQuestion", StrUtil.blankToDefault(plan.getRetrievalQuestion(), ""), + "hasHistoryContext", hasHistoryContext(plan), + "historyContext", buildHistoryContext(plan), + "hasSubQuestions", hasSubQuestions(plan), + "subQuestions", buildSubQuestions(plan), + "evidenceBlocks", buildEvidenceBlocks(context, renderedReferenceKeys, promptBudget) + )); return new RagPromptAssemblyResult( buildSystemPrompt(), - builder.toString().trim(), + userPrompt, promptBudget.totalBudget, promptBudget.perSubQuestionBudget, promptBudget.renderedReferenceCount, @@ -99,12 +76,56 @@ public class RagPromptAssemblyService { ); } + private boolean hasRetrievalQuestion(ConversationExecutionPlan plan) { + return StrUtil.isNotBlank(plan.getRetrievalQuestion()) && !plan.getRetrievalQuestion().equals(plan.getOriginalQuestion()); + } + + private boolean hasHistoryContext(ConversationExecutionPlan plan) { + AnswerHistoryContext answerHistoryContext = plan.getAnswerHistoryContext(); + return answerHistoryContext != null && !answerHistoryContext.isEmpty(); + } + + private String buildHistoryContext(ConversationExecutionPlan plan) { + return hasHistoryContext(plan) ? plan.getAnswerHistoryContext().getRenderedText().trim() : ""; + } + + private boolean hasSubQuestions(ConversationExecutionPlan plan) { + return plan.getRetrievalSubQuestions() != null && plan.getRetrievalSubQuestions().size() > 1; + } + + private String buildSubQuestions(ConversationExecutionPlan plan) { + if (!hasSubQuestions(plan)) { + return ""; + } + StringBuilder builder = new StringBuilder(); + for (int index = 0; index < plan.getRetrievalSubQuestions().size(); index++) { + builder.append(index + 1).append(". ").append(plan.getRetrievalSubQuestions().get(index)).append("\n"); + } + return builder.toString().trim(); + } + + private String buildEvidenceBlocks(RagRetrievalContext context, + Set renderedReferenceKeys, + PromptBudget promptBudget) { + StringBuilder builder = new StringBuilder(); + for (SubQuestionEvidence evidence : context.getSubQuestionEvidenceList()) { + StringBuilder referenceBuilder = new StringBuilder(); + appendReferences(referenceBuilder, evidence.getReferences(), renderedReferenceKeys, promptBudget); + builder.append(promptTemplateService.render(PromptTemplateNames.RAG_ANSWER_SUB_QUESTION_EVIDENCE, Map.of( + "subQuestionIndex", evidence.getSubQuestionIndex(), + "subQuestion", StrUtil.blankToDefault(evidence.getSubQuestion(), ""), + "references", referenceBuilder.toString().trim() + ))).append("\n\n"); + } + return builder.toString().trim(); + } + private void appendReferences(StringBuilder builder, List references, Set renderedReferenceKeys, PromptBudget promptBudget) { if (references == null || references.isEmpty()) { - builder.append("- 当前子问题没有检索到证据\n"); + builder.append(promptTemplateService.render(PromptTemplateNames.RAG_ANSWER_NO_EVIDENCE, Map.of())).append('\n'); return; } promptBudget.resetSubQuestionBudget(); @@ -112,7 +133,9 @@ public class RagPromptAssemblyService { for (SearchReference reference : references) { String uniqueKey = reference.uniqueKey(); if (renderedReferenceKeys.contains(uniqueKey)) { - String reuseLine = "- 复用证据 [" + reference.getReferenceId() + "]\n"; + String reuseLine = promptTemplateService.render(PromptTemplateNames.RAG_ANSWER_REUSE_REFERENCE, Map.of( + "referenceId", StrUtil.blankToDefault(reference.getReferenceId(), "") + )) + "\n"; if (promptBudget.tryConsume(reuseLine.length())) { builder.append(reuseLine); } @@ -144,34 +167,29 @@ public class RagPromptAssemblyService { } } if (omitted) { - builder.append("- 其余证据因上下文预算限制已省略\n"); + builder.append(promptTemplateService.render(PromptTemplateNames.RAG_ANSWER_OMITTED_EVIDENCE, Map.of())).append('\n'); } } private String buildWebReferenceBlock(SearchReference reference) { - return new StringBuilder("[") - .append(reference.getReferenceId()) - .append("] 网页:") - .append(StrUtil.blankToDefault(reference.getTitle(), "网页来源")) - .append(";链接:") - .append(StrUtil.blankToDefault(reference.getUrl(), "未知")) - .append("\n摘要:") - .append(trimSnippet(reference.getSnippet(), 900)) - .append("\n\n") - .toString(); + return promptTemplateService.render(PromptTemplateNames.RAG_ANSWER_WEB_REFERENCE, Map.of( + "referenceId", StrUtil.blankToDefault(reference.getReferenceId(), ""), + "title", StrUtil.blankToDefault(reference.getTitle(), "网页来源"), + "url", StrUtil.blankToDefault(reference.getUrl(), "未知"), + "snippet", trimSnippet(reference.getSnippet(), 900) + )) + "\n\n"; } private String buildDocumentReferenceBlock(SearchReference reference) { - return new StringBuilder("[") - .append(reference.getReferenceId()) - .append("] 文档:") - .append(StrUtil.blankToDefault(reference.getDocumentName(), reference.getTitle())) - .append(";章节:") - .append(StrUtil.blankToDefault(reference.getSectionPath(), "未识别")) - .append("\n内容:") - .append(trimSnippet(reference.getSnippet(), 1100)) - .append("\n\n") - .toString(); + return promptTemplateService.render(PromptTemplateNames.RAG_ANSWER_DOCUMENT_REFERENCE, Map.of( + "referenceId", StrUtil.blankToDefault(reference.getReferenceId(), ""), + "documentName", StrUtil.blankToDefault( + StrUtil.blankToDefault(reference.getDocumentName(), reference.getTitle()), + "文档来源" + ), + "sectionPath", StrUtil.blankToDefault(reference.getSectionPath(), "未识别"), + "snippet", trimSnippet(reference.getSnippet(), 1100) + )) + "\n\n"; } private String trimSnippet(String snippet, int maxChars) { @@ -182,15 +200,6 @@ public class RagPromptAssemblyService { return snippet.length() <= maxChars ? snippet : snippet.substring(0, maxChars) + "..."; } - private void appendHistoryContext(StringBuilder builder, ConversationExecutionPlan plan) { - AnswerHistoryContext answerHistoryContext = plan.getAnswerHistoryContext(); - if (answerHistoryContext == null || answerHistoryContext.isEmpty()) { - return; - } - - builder.append(answerHistoryContext.getRenderedText().trim()).append("\n\n"); - } - private String referenceSummary(SearchReference reference, String suffix) { if (reference == null) { return suffix; diff --git a/super-agent-business/super-agent-business-chat/src/main/java/org/javaup/ai/chatagent/service/BusinessChatService.java b/super-agent-business/super-agent-business-chat/src/main/java/org/javaup/ai/chatagent/service/BusinessChatService.java index 93a739687155081186359f7986a649cfec19539c..44cbca901073c3810df407866306d1a55f4268fd 100644 --- a/super-agent-business/super-agent-business-chat/src/main/java/org/javaup/ai/chatagent/service/BusinessChatService.java +++ b/super-agent-business/super-agent-business-chat/src/main/java/org/javaup/ai/chatagent/service/BusinessChatService.java @@ -34,6 +34,8 @@ import org.javaup.ai.chatagent.support.StreamEventWriter; import org.javaup.ai.chatagent.vo.ConversationResetVo; import org.javaup.ai.chatagent.vo.ConversationSessionListVo; import org.javaup.ai.chatagent.vo.ConversationStopVo; +import org.javaup.ai.prompt.PromptTemplateNames; +import org.javaup.ai.prompt.PromptTemplateService; import org.javaup.enums.ChatTurnStatus; import org.javaup.enums.ChatQueryMode; import org.javaup.exception.SuperAgentFrameException; @@ -94,6 +96,7 @@ public class BusinessChatService { private final ConversationTraceStageStore conversationTraceStageStore; private final RetrievalObserveStore retrievalObserveStore; private final StageBenchmarkService stageBenchmarkService; + private final PromptTemplateService promptTemplateService; public Flux openConversationStream(ChatRequestDto request) { @@ -1239,36 +1242,14 @@ public class BusinessChatService { } private String buildAgentQuestion(ConversationExecutionPlan executionPlan) { - - StringBuilder builder = new StringBuilder(); - builder.append("系统时间信息:\n"); - builder.append("当前日期是 ").append(executionPlan.getCurrentDateText()).append(",时区为 Asia/Shanghai。\n"); - - if (executionPlan.isRequiresCurrentDateAnchoring()) { - - builder.append("当前问题包含相对时间或强时效语义。"); - builder.append("当用户提到“今天、明天、昨天、现在、当前、最新、本周、本月、今年”等表达时,"); - builder.append("必须以这个日期为准,不要把搜索结果里的旧日期误当成今天。\n"); - } else { - - builder.append("当用户提到“今天、明天、昨天、现在、当前、最新”等相对时间时,必须以这个日期为准。\n"); - } - - if (executionPlan.isRequiresFreshSearch()) { - - builder.append("当前问题需要核实最新外部事实,回答前必须优先调用联网搜索工具。\n"); - builder.append("如果搜索结果里的日期与当前日期不一致,必须明确说明来源日期,不要把旧日期说成今天。\n"); - builder.append("如果无法找到与当前日期匹配的可靠结果,要明确说明不确定性,不要编造最新信息。\n"); - } - - if (StrUtil.isNotBlank(executionPlan.getHistorySummary())) { - builder.append("\n相关会话背景:\n"); - builder.append(executionPlan.getHistorySummary()).append("\n"); - } - - builder.append("\n用户问题:\n"); - builder.append(executionPlan.getOriginalQuestion()); - return builder.toString(); + return promptTemplateService.render(PromptTemplateNames.AGENT_QUESTION, Map.of( + "currentDateText", StrUtil.blankToDefault(executionPlan.getCurrentDateText(), ""), + "requiresCurrentDateAnchoring", executionPlan.isRequiresCurrentDateAnchoring(), + "requiresFreshSearch", executionPlan.isRequiresFreshSearch(), + "hasHistorySummary", StrUtil.isNotBlank(executionPlan.getHistorySummary()), + "historySummary", StrUtil.blankToDefault(executionPlan.getHistorySummary(), ""), + "question", StrUtil.blankToDefault(executionPlan.getOriginalQuestion(), "") + )); } private String formatCurrentDate(LocalDate currentDate) { diff --git a/super-agent-business/super-agent-business-chat/src/main/java/org/javaup/ai/chatagent/service/PersistentConversationMemoryService.java b/super-agent-business/super-agent-business-chat/src/main/java/org/javaup/ai/chatagent/service/PersistentConversationMemoryService.java index a1afd7d81541ec816109cae0c7e700c28fcc2f76..971d9e71420a2ec45add353de50070400c41829c 100644 --- a/super-agent-business/super-agent-business-chat/src/main/java/org/javaup/ai/chatagent/service/PersistentConversationMemoryService.java +++ b/super-agent-business/super-agent-business-chat/src/main/java/org/javaup/ai/chatagent/service/PersistentConversationMemoryService.java @@ -14,6 +14,8 @@ import org.javaup.ai.chatagent.model.ConversationMemorySummaryView; import org.javaup.ai.chatagent.model.memory.ConversationMemoryContext; import org.javaup.ai.chatagent.model.memory.ConversationSummaryPayload; import org.javaup.ai.chatagent.rag.config.ChatRagProperties; +import org.javaup.ai.prompt.PromptTemplateNames; +import org.javaup.ai.prompt.PromptTemplateService; import org.javaup.enums.BusinessStatus; import org.javaup.enums.ChatTurnStatus; import org.springframework.beans.factory.annotation.Qualifier; @@ -24,6 +26,7 @@ import java.util.Collections; import java.util.Date; import java.util.LinkedHashSet; import java.util.List; +import java.util.Map; import java.util.Optional; import java.util.Set; import java.time.Instant; @@ -43,18 +46,6 @@ import java.util.regex.Pattern; @Service public class PersistentConversationMemoryService implements ConversationMemoryService { - private static final String SUMMARY_SYSTEM_PROMPT = """ - 你是企业会话长期记忆压缩助手。 - 你的任务不是回答业务问题,而是把已有长期摘要与新增对话批次合并成新的长期记忆。 - 你必须只保留跨轮仍然有价值的信息,例如: - 1. 用户真正的目标、范围和限制。 - 2. 已经确认的业务事实、术语、系统名、模块名。 - 3. 已经解决的结论和仍待继续追问的问题。 - 4. 对后续知识检索仍有帮助的关键词。 - 不要保留寒暄、重复确认、纯过程性客套话,也不要把失败猜测写成既定事实。 - 最终只返回合法 JSON,不要输出 Markdown,不要附加解释。 - """; - private static final Pattern JSON_OBJECT_PATTERN = Pattern.compile("\\{.*}", Pattern.DOTALL); private static final Pattern RETRIEVAL_HINT_PATTERN = Pattern.compile("[a-zA-Z0-9._-]{2,}|[\\p{IsHan}]{2,12}"); private static final int MAX_SECTION_ITEMS = 6; @@ -70,6 +61,7 @@ public class PersistentConversationMemoryService implements ConversationMemorySe private final ChatRagProperties properties; private final ExecutorService chatMemorySummaryExecutorService; private final ObservedChatModelService observedChatModelService; + private final PromptTemplateService promptTemplateService; private final Set refreshingConversationIds = ConcurrentHashMap.newKeySet(); @Resource @@ -80,13 +72,15 @@ public class PersistentConversationMemoryService implements ConversationMemorySe ObjectMapper objectMapper, ChatRagProperties properties, @Qualifier("chatMemorySummaryExecutorService") ExecutorService chatMemorySummaryExecutorService, - ObservedChatModelService observedChatModelService) { + ObservedChatModelService observedChatModelService, + PromptTemplateService promptTemplateService) { this.conversationArchiveStore = conversationArchiveStore; this.summaryMapper = summaryMapper; this.objectMapper = objectMapper; this.properties = properties; this.chatMemorySummaryExecutorService = chatMemorySummaryExecutorService; this.observedChatModelService = observedChatModelService; + this.promptTemplateService = promptTemplateService; } @Override @@ -267,7 +261,7 @@ public class PersistentConversationMemoryService implements ConversationMemorySe try { String content = observedChatModelService.callText( "summary", - SUMMARY_SYSTEM_PROMPT, + promptTemplateService.render(PromptTemplateNames.CONVERSATION_SUMMARY_SYSTEM, Map.of()), buildSummaryMergePrompt(existingPayload, batch), traceRecorder ); @@ -285,35 +279,10 @@ public class PersistentConversationMemoryService implements ConversationMemorySe private String buildSummaryMergePrompt(ConversationSummaryPayload existingPayload, List batch) { String existingJson = writePayloadJson(normalizePayload(copyPayload(existingPayload))); - return """ - 请把下面的已有长期摘要和新增对话批次合并成新的长期记忆 JSON。 - JSON 结构必须为: - { - "summary": "一段 180~260 字的中文摘要", - "conversation_goal": "一句话描述用户长期目标", - "stable_facts": ["事实1", "事实2"], - "user_preferences": ["偏好1", "偏好2"], - "resolved_points": ["已解决点1", "已解决点2"], - "pending_questions": ["待跟进点1", "待跟进点2"], - "retrieval_hints": ["系统名或关键词1", "系统名或关键词2"] - } - - 输出要求: - 1. 只返回 JSON,不要输出解释。 - 2. 不要把寒暄、重复确认和无关聊天写进去。 - 3. 未确认的信息不要写成既定事实。 - 4. 每个数组最多保留 6 条,尽量去重。 - 5. summary 只保留下一轮理解问题真正需要的长期背景。 - - 已有长期摘要 JSON: - %s - - 新增对话批次: - %s - """.formatted( - StrUtil.isNotBlank(existingJson) ? existingJson : "{}", - renderCompressionTranscript(batch) - ); + return promptTemplateService.render(PromptTemplateNames.CONVERSATION_SUMMARY_MERGE, Map.of( + "existingSummaryJson", StrUtil.isNotBlank(existingJson) ? existingJson : "{}", + "newConversationBatch", renderCompressionTranscript(batch) + )); } private ConversationSummaryPayload fallbackMerge(ConversationSummaryPayload existingPayload, diff --git a/super-agent-business/super-agent-business-chat/src/main/java/org/javaup/ai/chatagent/service/RecommendationService.java b/super-agent-business/super-agent-business-chat/src/main/java/org/javaup/ai/chatagent/service/RecommendationService.java index d27733b2dbd043c65bfba099fd5018f3c2aa80b6..ebec3c270caff5fa079130bffd6315ef34043230 100644 --- a/super-agent-business/super-agent-business-chat/src/main/java/org/javaup/ai/chatagent/service/RecommendationService.java +++ b/super-agent-business/super-agent-business-chat/src/main/java/org/javaup/ai/chatagent/service/RecommendationService.java @@ -9,6 +9,8 @@ import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; import org.javaup.ai.chatagent.config.ChatAgentProperties; import org.javaup.ai.chatagent.model.ConversationExchangeView; +import org.javaup.ai.prompt.PromptTemplateNames; +import org.javaup.ai.prompt.PromptTemplateService; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Qualifier; @@ -17,6 +19,7 @@ import org.springframework.stereotype.Service; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ExecutorService; import java.util.concurrent.TimeUnit; +import java.util.Map; /** * @program: 企业级别深度设计 AI Agent。添加 阿星不是程序员 微信,添加时备注 super 来获取项目的完整资料 @@ -32,15 +35,18 @@ public class RecommendationService { private final ObjectMapper objectMapper; private final ExecutorService recommendationExecutorService; private final ObservedChatModelService observedChatModelService; + private final PromptTemplateService promptTemplateService; public RecommendationService(ChatAgentProperties properties, ObjectMapper objectMapper, @Qualifier("chatPostProcessExecutorService") ExecutorService recommendationExecutorService, - ObservedChatModelService observedChatModelService) { + ObservedChatModelService observedChatModelService, + PromptTemplateService promptTemplateService) { this.properties = properties; this.objectMapper = objectMapper; this.recommendationExecutorService = recommendationExecutorService; this.observedChatModelService = observedChatModelService; + this.promptTemplateService = promptTemplateService; } public List generateRecommendations(String question, @@ -77,25 +83,29 @@ public class RecommendationService { List recentExchanges, ConversationTraceRecorder traceRecorder) { - StringBuilder prompt = new StringBuilder(properties.getRecommendationPrompt()) - .append("\n\n最近上下文:\n"); + List safeRecentExchanges = recentExchanges == null ? List.of() : recentExchanges; + StringBuilder recentContext = new StringBuilder(); - int startIndex = Math.max(0, recentExchanges.size() - properties.getHistoryPreviewTurns()); + int startIndex = Math.max(0, safeRecentExchanges.size() - properties.getHistoryPreviewTurns()); - for (int index = startIndex; index < recentExchanges.size(); index++) { - ConversationExchangeView exchange = recentExchanges.get(index); - prompt.append("用户:").append(exchange.getQuestion()).append('\n'); + for (int index = startIndex; index < safeRecentExchanges.size(); index++) { + ConversationExchangeView exchange = safeRecentExchanges.get(index); + recentContext.append("用户:").append(exchange.getQuestion()).append('\n'); if (StrUtil.isNotBlank(exchange.getAnswer())) { - prompt.append("助手:").append(exchange.getAnswer()).append('\n'); + recentContext.append("助手:").append(exchange.getAnswer()).append('\n'); } } - prompt.append("当前问题:").append(question).append('\n'); - prompt.append("当前答案:").append(answer).append('\n'); + String prompt = promptTemplateService.render(PromptTemplateNames.RECOMMENDATION_USER, Map.of( + "basePrompt", StrUtil.blankToDefault(properties.getRecommendationPrompt(), ""), + "recentContext", recentContext.toString().trim(), + "question", StrUtil.blankToDefault(question, ""), + "answer", StrUtil.blankToDefault(answer, "") + )); try { - String content = observedChatModelService.callText("recommendation", null, prompt.toString(), traceRecorder); + String content = observedChatModelService.callText("recommendation", null, prompt, traceRecorder); if (StrUtil.isBlank(content)) { return List.of(); diff --git a/super-agent-business/super-agent-business-chat/src/main/java/org/javaup/ai/manage/service/impl/DocumentManageServiceImpl.java b/super-agent-business/super-agent-business-chat/src/main/java/org/javaup/ai/manage/service/impl/DocumentManageServiceImpl.java index 2fe3820402049c3ff3ad6c570b10765eb3eef4f2..15519134ddd708b70decea81735fd6a2a93994df 100644 --- a/super-agent-business/super-agent-business-chat/src/main/java/org/javaup/ai/manage/service/impl/DocumentManageServiceImpl.java +++ b/super-agent-business/super-agent-business-chat/src/main/java/org/javaup/ai/manage/service/impl/DocumentManageServiceImpl.java @@ -94,6 +94,7 @@ import org.javaup.exception.SuperAgentFrameException; import org.springframework.beans.factory.ObjectProvider; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionTemplate; import org.springframework.web.multipart.MultipartFile; import java.io.IOException; @@ -155,11 +156,12 @@ public class DocumentManageServiceImpl implements DocumentManageService { private final ObjectProvider knowledgeRouteIndexServiceProvider; private final DocumentKafkaProducer kafkaProducer; + + private final TransactionTemplate transactionTemplate; private final UidGenerator uidGenerator; @Override - @Transactional(rollbackFor = Exception.class) public DocumentUploadVo upload(MultipartFile file, DocumentUploadDto dto) { if (file == null || file.isEmpty()) { @@ -207,7 +209,6 @@ public class DocumentManageServiceImpl implements DocumentManageService { document.setBusinessCategory(StrUtil.trimToNull(dto.getBusinessCategory())); document.setDocumentTags(StrUtil.trimToNull(dto.getDocumentTags())); document.setStatus(BusinessStatus.YES.getCode()); - documentMapper.insert(document); Long taskId = uidGenerator.getUid(); SuperAgentDocumentTask task = new SuperAgentDocumentTask(); @@ -220,21 +221,27 @@ public class DocumentManageServiceImpl implements DocumentManageService { task.setTriggerSource(resolveTriggerSource(operatorId)); task.setRetryCount(0); task.setStatus(BusinessStatus.YES.getCode()); - taskMapper.insert(task); - taskLogService.saveLog(taskId, documentId, - DocumentTaskStageEnum.FILE_UPLOAD.getCode(), - DocumentTaskEventTypeEnum.COMPLETE.getCode(), - DocumentLogLevelEnum.INFO.getCode(), - resolveOperatorType(operatorId), - operatorId, - "文件上传完成,已进入解析与策略推荐队列。", - Map.of("originalFileName", originalFileName, "fileSize", fileBytes.length)); + DocumentUploadVo uploadVo = transactionTemplate.execute(status -> { + documentMapper.insert(document); + taskMapper.insert(task); + + taskLogService.saveLog(taskId, documentId, + DocumentTaskStageEnum.FILE_UPLOAD.getCode(), + DocumentTaskEventTypeEnum.COMPLETE.getCode(), + DocumentLogLevelEnum.INFO.getCode(), + resolveOperatorType(operatorId), + operatorId, + "文件上传完成,已进入解析与策略推荐队列。", + Map.of("originalFileName", originalFileName, "fileSize", fileBytes.length)); + + return new DocumentUploadVo(documentId, taskId, document.getDocumentName(), + document.getParseStatus(), document.getStrategyStatus(), document.getIndexStatus()); + }); kafkaProducer.sendParseRoute(new DocumentParseRouteMessage(documentId, taskId)); - return new DocumentUploadVo(documentId, taskId, document.getDocumentName(), - document.getParseStatus(), document.getStrategyStatus(), document.getIndexStatus()); + return uploadVo; } @Override diff --git a/super-agent-business/super-agent-business-chat/src/main/java/org/javaup/ai/manage/service/impl/DocumentStrategyServiceImpl.java b/super-agent-business/super-agent-business-chat/src/main/java/org/javaup/ai/manage/service/impl/DocumentStrategyServiceImpl.java index 8c9295b37cc4cb0aaeb1cc271bfdd2c631252ac3..6993483b1b1b24893fa985baf770569949df8194 100644 --- a/super-agent-business/super-agent-business-chat/src/main/java/org/javaup/ai/manage/service/impl/DocumentStrategyServiceImpl.java +++ b/super-agent-business/super-agent-business-chat/src/main/java/org/javaup/ai/manage/service/impl/DocumentStrategyServiceImpl.java @@ -18,6 +18,8 @@ import org.javaup.ai.manage.support.DocumentLineClassifier; import org.javaup.ai.manage.support.DocumentStrategyPlanDraft; import org.javaup.ai.manage.support.DocumentStrategyStepDraft; import org.javaup.ai.manage.support.ParentBlockCandidate; +import org.javaup.ai.prompt.PromptTemplateNames; +import org.javaup.ai.prompt.PromptTemplateService; import org.javaup.enums.DocumentChunkSourceTypeEnum; import org.javaup.enums.DocumentContentQualityLevelEnum; import org.javaup.enums.DocumentFileTypeEnum; @@ -71,6 +73,7 @@ public class DocumentStrategyServiceImpl implements DocumentStrategyService { private final ObjectProvider chatModelProvider; private final DocumentLineClassifier documentLineClassifier; private final DocumentStructureNodeService structureNodeService; + private final PromptTemplateService promptTemplateService; @Override public DocumentStrategyPlanDraft recommendStrategy(SuperAgentDocument document, DocumentAnalysisResult analysisResult) { @@ -862,17 +865,9 @@ public class DocumentStrategyServiceImpl implements DocumentStrategyService { } private List llmSplit(ChatModel chatModel, String sourceText) { - String prompt = """ - 你是 RAG 文档切块助手。 - 请把下面文本切成适合知识检索的若干片段,并严格返回 JSON 数组字符串。 - 要求: - 1. 每个片段尽量语义完整。 - 2. 不要输出解释文字。 - 3. 不要丢失原文关键信息。 - 4. 返回格式示例:[\"片段1\",\"片段2\"] - - 文本如下: - """ + sourceText; + String prompt = promptTemplateService.render(PromptTemplateNames.DOCUMENT_LLM_SPLIT, Map.of( + "sourceText", StrUtil.blankToDefault(sourceText, "") + )); try { diff --git a/super-agent-business/super-agent-business-chat/src/main/java/org/javaup/ai/manage/support/DocumentStructureAmbiguityResolver.java b/super-agent-business/super-agent-business-chat/src/main/java/org/javaup/ai/manage/support/DocumentStructureAmbiguityResolver.java index 207f71364058c5185b587c84734c95e1aebd3d87..d9f748cee7a592fa5d7c2d46bf8c6195399520a9 100644 --- a/super-agent-business/super-agent-business-chat/src/main/java/org/javaup/ai/manage/support/DocumentStructureAmbiguityResolver.java +++ b/super-agent-business/super-agent-business-chat/src/main/java/org/javaup/ai/manage/support/DocumentStructureAmbiguityResolver.java @@ -5,6 +5,8 @@ import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; import lombok.extern.slf4j.Slf4j; import org.javaup.ai.manage.config.DocumentManageProperties; +import org.javaup.ai.prompt.PromptTemplateNames; +import org.javaup.ai.prompt.PromptTemplateService; import org.springframework.ai.chat.client.ChatClient; import org.springframework.ai.chat.model.ChatModel; import org.springframework.beans.factory.ObjectProvider; @@ -28,13 +30,16 @@ public class DocumentStructureAmbiguityResolver { private final DocumentManageProperties properties; private final ObjectProvider chatModelProvider; private final ObjectMapper objectMapper; + private final PromptTemplateService promptTemplateService; public DocumentStructureAmbiguityResolver(DocumentManageProperties properties, ObjectProvider chatModelProvider, - ObjectMapper objectMapper) { + ObjectMapper objectMapper, + PromptTemplateService promptTemplateService) { this.properties = properties; this.chatModelProvider = chatModelProvider; this.objectMapper = objectMapper; + this.promptTemplateService = promptTemplateService; } public List resolve(String documentTitle, @@ -97,50 +102,41 @@ public class DocumentStructureAmbiguityResolver { private String buildPrompt(String documentTitle, List ambiguousSignals, List allLines) { - StringBuilder builder = new StringBuilder(""" - 你是文档结构判歧助手。 - 你的任务是判断若干低置信度文本行,在当前上下文中更像: - - HEADING:章节/小节标题 - - LIST_ITEM:普通列表项 - - BODY:普通正文 - - 请严格返回 JSON 数组,不要附加解释: - [ - { - "line_no": 12, - "resolved_kind": "HEADING | LIST_ITEM | BODY", - "level_hint": 1 - } - ] - - 规则: - 1. 只有在非常像章节标题时才输出 HEADING。 - 2. 连续出现的编号项、步骤项、清单项优先判断为 LIST_ITEM。 - 3. 表格说明行、引用行、解释性句子优先判断为 BODY。 - 4. level_hint 只有 resolved_kind=HEADING 时才填写;没有把握时填 null。 - 5. 不要脑补目录结构,只依据提供的局部上下文判断。 - - 文档标题: - """).append(StrUtil.blankToDefault(documentTitle, "未命名文档")).append("\n\n"); + return promptTemplateService.render(PromptTemplateNames.DOCUMENT_STRUCTURE_AMBIGUITY, Map.of( + "documentTitle", StrUtil.blankToDefault(documentTitle, "未命名文档"), + "candidateBlocks", buildCandidateBlocks(ambiguousSignals, allLines) + )); + } + private String buildCandidateBlocks(List ambiguousSignals, + List allLines) { + StringBuilder builder = new StringBuilder(); + List safeLines = allLines == null ? List.of() : allLines; int contextWindow = Math.max(1, properties.getStructureParsing().getContextWindowLines()); for (DocumentStructureSignal signal : ambiguousSignals) { - builder.append("### 候选行 ").append(signal.getLineNo()).append('\n'); + if (signal == null) { + continue; + } int currentIndex = Math.max(0, signal.getLineNo() - 1); int start = Math.max(0, currentIndex - contextWindow); - int end = Math.min(allLines.size() - 1, currentIndex + contextWindow); + int end = Math.min(safeLines.size() - 1, currentIndex + contextWindow); + StringBuilder contextBuilder = new StringBuilder(); for (int index = start; index <= end; index++) { - builder.append(index + 1 == signal.getLineNo() ? ">> " : " ") + contextBuilder.append(index + 1 == signal.getLineNo() ? ">> " : " ") .append(index + 1) .append(": ") - .append(StrUtil.blankToDefault(allLines.get(index), "")) + .append(StrUtil.blankToDefault(safeLines.get(index), "")) .append('\n'); } - builder.append("初始判断:").append(signal.getKind()).append('\n'); - builder.append("初始标题:").append(StrUtil.blankToDefault(signal.getTitle(), "")).append('\n'); - builder.append("初始编码:").append(StrUtil.blankToDefault(signal.getNodeCode(), "")).append("\n\n"); + builder.append(promptTemplateService.render(PromptTemplateNames.DOCUMENT_STRUCTURE_AMBIGUITY_CANDIDATE, Map.of( + "lineNo", signal.getLineNo(), + "contextLines", contextBuilder.toString().stripTrailing(), + "initialKind", signal.getKind() == null ? "" : signal.getKind().name(), + "initialTitle", StrUtil.blankToDefault(signal.getTitle(), ""), + "initialCode", StrUtil.blankToDefault(signal.getNodeCode(), "") + ))).append("\n\n"); } - return builder.toString(); + return builder.toString().trim(); } private List parse(String raw) throws Exception { diff --git a/super-agent-business/super-agent-business-chat/src/main/java/org/javaup/ai/prompt/PromptTemplateNames.java b/super-agent-business/super-agent-business-chat/src/main/java/org/javaup/ai/prompt/PromptTemplateNames.java new file mode 100644 index 0000000000000000000000000000000000000000..b174a497bf079cd502e1e2bd53e8e47fd78abaa4 --- /dev/null +++ b/super-agent-business/super-agent-business-chat/src/main/java/org/javaup/ai/prompt/PromptTemplateNames.java @@ -0,0 +1,29 @@ +package org.javaup.ai.prompt; + +/** + * @program: 企业级别深度设计 AI Agent。添加 阿星不是程序员 微信,添加时备注 super 来获取项目的完整资料 + * @description: Prompt 模板名称常量 + * @author: 阿星不是程序员 + **/ +public final class PromptTemplateNames { + + public static final String AGENT_QUESTION = "agent-question"; + public static final String CHAT_QUERY_REWRITE = "chat-query-rewrite"; + public static final String CONVERSATION_SUMMARY_MERGE = "conversation-summary-merge"; + public static final String CONVERSATION_SUMMARY_SYSTEM = "conversation-summary-system"; + public static final String DOCUMENT_LLM_SPLIT = "document-llm-split"; + public static final String DOCUMENT_STRUCTURE_AMBIGUITY = "document-structure-ambiguity"; + public static final String DOCUMENT_STRUCTURE_AMBIGUITY_CANDIDATE = "document-structure-ambiguity-candidate"; + public static final String RAG_ANSWER_DOCUMENT_REFERENCE = "rag-answer-document-reference"; + public static final String RAG_ANSWER_NO_EVIDENCE = "rag-answer-no-evidence"; + public static final String RAG_ANSWER_OMITTED_EVIDENCE = "rag-answer-omitted-evidence"; + public static final String RAG_ANSWER_REUSE_REFERENCE = "rag-answer-reuse-reference"; + public static final String RAG_ANSWER_SUB_QUESTION_EVIDENCE = "rag-answer-sub-question-evidence"; + public static final String RAG_ANSWER_SYSTEM = "rag-answer-system"; + public static final String RAG_ANSWER_USER = "rag-answer-user"; + public static final String RAG_ANSWER_WEB_REFERENCE = "rag-answer-web-reference"; + public static final String RECOMMENDATION_USER = "recommendation-user"; + + private PromptTemplateNames() { + } +} diff --git a/super-agent-business/super-agent-business-chat/src/main/java/org/javaup/ai/prompt/PromptTemplateService.java b/super-agent-business/super-agent-business-chat/src/main/java/org/javaup/ai/prompt/PromptTemplateService.java new file mode 100644 index 0000000000000000000000000000000000000000..44fbad563c40aefd29bb3ceeb27ee40c1980ad90 --- /dev/null +++ b/super-agent-business/super-agent-business-chat/src/main/java/org/javaup/ai/prompt/PromptTemplateService.java @@ -0,0 +1,84 @@ +package org.javaup.ai.prompt; + +import org.springframework.ai.template.ValidationMode; +import org.springframework.ai.template.st.StTemplateRenderer; +import org.springframework.core.io.Resource; +import org.springframework.core.io.ResourceLoader; +import org.springframework.stereotype.Component; +import org.springframework.util.FileCopyUtils; + +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +/** + * @program: 企业级别深度设计 AI Agent。添加 阿星不是程序员 微信,添加时备注 super 来获取项目的完整资料 + * @description: Prompt 模板渲染组件 + * @author: 阿星不是程序员 + **/ +@Component +public class PromptTemplateService { + + private static final String PROMPT_DIR = "prompt/"; + private static final String TEMPLATE_SUFFIX = ".st"; + + private final ResourceLoader resourceLoader; + private final StTemplateRenderer templateRenderer; + private final Map templateCache = new ConcurrentHashMap<>(); + + public PromptTemplateService(ResourceLoader resourceLoader) { + this.resourceLoader = resourceLoader; + this.templateRenderer = StTemplateRenderer.builder() + .startDelimiterToken('<') + .endDelimiterToken('>') + .validationMode(ValidationMode.THROW) + .build(); + } + + public String render(String templateName, Map variables) { + String templatePath = normalizeTemplatePath(templateName); + String template = templateCache.computeIfAbsent(templatePath, this::loadTemplate); + return templateRenderer.apply(template, normalizeVariables(variables)).trim(); + } + + private Map normalizeVariables(Map variables) { + Map normalized = new LinkedHashMap<>(); + if (variables == null || variables.isEmpty()) { + return normalized; + } + variables.forEach((key, value) -> normalized.put(key, value == null ? "" : value)); + return normalized; + } + + private String normalizeTemplatePath(String templateName) { + String normalized = templateName == null ? "" : templateName.trim(); + if (normalized.startsWith("classpath:")) { + normalized = normalized.substring("classpath:".length()); + } + while (normalized.startsWith("/")) { + normalized = normalized.substring(1); + } + if (!normalized.startsWith(PROMPT_DIR)) { + normalized = PROMPT_DIR + normalized; + } + if (!normalized.endsWith(TEMPLATE_SUFFIX)) { + normalized = normalized + TEMPLATE_SUFFIX; + } + return normalized; + } + + private String loadTemplate(String templatePath) { + Resource resource = resourceLoader.getResource("classpath:" + templatePath); + if (!resource.exists()) { + throw new IllegalArgumentException("Prompt 模板不存在: classpath:" + templatePath); + } + try (InputStreamReader reader = new InputStreamReader(resource.getInputStream(), StandardCharsets.UTF_8)) { + return FileCopyUtils.copyToString(reader); + } + catch (Exception exception) { + throw new IllegalStateException("读取 Prompt 模板失败: classpath:" + templatePath, exception); + } + } +} diff --git a/super-agent-business/super-agent-business-chat/src/main/resources/application.yaml b/super-agent-business/super-agent-business-chat/src/main/resources/application.yaml index 0ddf13c6972f56a10a32b1237d271b3f187f89de..9bb89f88d3c2206c483a2349683973639e88a276 100644 --- a/super-agent-business/super-agent-business-chat/src/main/resources/application.yaml +++ b/super-agent-business/super-agent-business-chat/src/main/resources/application.yaml @@ -78,6 +78,14 @@ mybatis-plus: local-cache-scope: statement app: + admin-auth: + username: ${SUPER_AGENT_ADMIN_USERNAME:admin} + password: ${SUPER_AGENT_ADMIN_PASSWORD:admin123456} + token-secret: ${SUPER_AGENT_ADMIN_TOKEN_SECRET:super-agent-admin-token-secret-change-me} + token-expire-minutes: ${SUPER_AGENT_ADMIN_TOKEN_EXPIRE_MINUTES:720} + preview-mode: + enabled: ${SUPER_AGENT_PREVIEW_MODE_ENABLED:false} + message: ${SUPER_AGENT_PREVIEW_MODE_MESSAGE:线上环境为只读展示模式,仅开放浏览与检索能力} chat: # 是否开启“推荐追问问题”能力。 # 开启后,主回答生成完成后会额外调用一次模型,产出最多 3 个可继续追问的问题。 diff --git a/super-agent-business/super-agent-business-chat/src/main/resources/prompt/agent-question.st b/super-agent-business/super-agent-business-chat/src/main/resources/prompt/agent-question.st new file mode 100644 index 0000000000000000000000000000000000000000..d431456f42b07c770c1ab43d049efcceafeca99d --- /dev/null +++ b/super-agent-business/super-agent-business-chat/src/main/resources/prompt/agent-question.st @@ -0,0 +1,13 @@ +系统时间信息: +当前日期是 ,时区为 Asia/Shanghai。 +当前问题包含相对时间或强时效语义。当用户提到“今天、明天、昨天、现在、当前、最新、本周、本月、今年”等表达时,必须以这个日期为准,不要把搜索结果里的旧日期误当成今天。 +当用户提到“今天、明天、昨天、现在、当前、最新”等相对时间时,必须以这个日期为准。 +当前问题需要核实最新外部事实,回答前必须优先调用联网搜索工具。 +如果搜索结果里的日期与当前日期不一致,必须明确说明来源日期,不要把旧日期说成今天。 +如果无法找到与当前日期匹配的可靠结果,要明确说明不确定性,不要编造最新信息。 + +相关会话背景: + + +用户问题: + diff --git a/super-agent-business/super-agent-business-chat/src/main/resources/prompt/chat-query-rewrite.st b/super-agent-business/super-agent-business-chat/src/main/resources/prompt/chat-query-rewrite.st new file mode 100644 index 0000000000000000000000000000000000000000..2171c1d3330ba266390ea40cc3f498b8c5c96d42 --- /dev/null +++ b/super-agent-business/super-agent-business-chat/src/main/resources/prompt/chat-query-rewrite.st @@ -0,0 +1,28 @@ +你是企业文档问答系统的问题改写助手。 +请结合历史上下文和当前问题,输出一个 JSON: +{ + "rewrite": "改写后的独立问题", + "should_split": true, + "sub_questions": ["子问题1", "子问题2"] +} + +改写规则: +1. 只做指代消解、上下文补全、口语转书面化,不要发散扩写。 +2. 专有名词、时间范围、环境、角色、终端类型等限制条件必须保留。 +3. 不得添加原文没有的条件、维度、假设,不得引入“方面/维度/角度”等枚举词。 +4. 如果当前问题已经完整,就尽量少改。 +5. 不要根据你自己的理解去提前规划章节、结构或检索模式。 + +拆分规则: +1. 默认 should_split=false,sub_questions 只保留 1 条,且必须与 rewrite 表达同一件事。 +2. 只有当前问题原文里显式存在多个独立问题时,才允许 should_split=true。 +3. 可拆分的典型情况只有:多个问号、分号、换行列举、编号列举、明确“分别”提问。 +4. 抽象对比、笼统追问、承接式追问一律不要拆分;只做改写。 +5. 不确定时必须不拆分。 +6. 只返回合法 JSON,不要输出额外解释。 + +历史上下文: + + +当前问题: + diff --git a/super-agent-business/super-agent-business-chat/src/main/resources/prompt/conversation-summary-merge.st b/super-agent-business/super-agent-business-chat/src/main/resources/prompt/conversation-summary-merge.st new file mode 100644 index 0000000000000000000000000000000000000000..421936f4ebf25c6f8c6df12318350d3bab906ddf --- /dev/null +++ b/super-agent-business/super-agent-business-chat/src/main/resources/prompt/conversation-summary-merge.st @@ -0,0 +1,24 @@ +请把下面的已有长期摘要和新增对话批次合并成新的长期记忆 JSON。 +JSON 结构必须为: +{ + "summary": "一段 180~260 字的中文摘要", + "conversation_goal": "一句话描述用户长期目标", + "stable_facts": ["事实1", "事实2"], + "user_preferences": ["偏好1", "偏好2"], + "resolved_points": ["已解决点1", "已解决点2"], + "pending_questions": ["待跟进点1", "待跟进点2"], + "retrieval_hints": ["系统名或关键词1", "系统名或关键词2"] +} + +输出要求: +1. 只返回 JSON,不要输出解释。 +2. 不要把寒暄、重复确认和无关聊天写进去。 +3. 未确认的信息不要写成既定事实。 +4. 每个数组最多保留 6 条,尽量去重。 +5. summary 只保留下一轮理解问题真正需要的长期背景。 + +已有长期摘要 JSON: + + +新增对话批次: + diff --git a/super-agent-business/super-agent-business-chat/src/main/resources/prompt/conversation-summary-system.st b/super-agent-business/super-agent-business-chat/src/main/resources/prompt/conversation-summary-system.st new file mode 100644 index 0000000000000000000000000000000000000000..30c9601e684523c649cb65046a20077d8587eab5 --- /dev/null +++ b/super-agent-business/super-agent-business-chat/src/main/resources/prompt/conversation-summary-system.st @@ -0,0 +1,9 @@ +你是企业会话长期记忆压缩助手。 +你的任务不是回答业务问题,而是把已有长期摘要与新增对话批次合并成新的长期记忆。 +你必须只保留跨轮仍然有价值的信息,例如: +1. 用户真正的目标、范围和限制。 +2. 已经确认的业务事实、术语、系统名、模块名。 +3. 已经解决的结论和仍待继续追问的问题。 +4. 对后续知识检索仍有帮助的关键词。 +不要保留寒暄、重复确认、纯过程性客套话,也不要把失败猜测写成既定事实。 +最终只返回合法 JSON,不要输出 Markdown,不要附加解释。 diff --git a/super-agent-business/super-agent-business-chat/src/main/resources/prompt/document-llm-split.st b/super-agent-business/super-agent-business-chat/src/main/resources/prompt/document-llm-split.st new file mode 100644 index 0000000000000000000000000000000000000000..2b2d8e7215fb1d4c0ddf1f837a22ec22d6c97a1e --- /dev/null +++ b/super-agent-business/super-agent-business-chat/src/main/resources/prompt/document-llm-split.st @@ -0,0 +1,10 @@ +你是 RAG 文档切块助手。 +请把下面文本切成适合知识检索的若干片段,并严格返回 JSON 数组字符串。 +要求: +1. 每个片段尽量语义完整。 +2. 不要输出解释文字。 +3. 不要丢失原文关键信息。 +4. 返回格式示例:["片段1","片段2"] + +文本如下: + diff --git a/super-agent-business/super-agent-business-chat/src/main/resources/prompt/document-structure-ambiguity-candidate.st b/super-agent-business/super-agent-business-chat/src/main/resources/prompt/document-structure-ambiguity-candidate.st new file mode 100644 index 0000000000000000000000000000000000000000..73977c2917acc4b87ac85d353374097433059188 --- /dev/null +++ b/super-agent-business/super-agent-business-chat/src/main/resources/prompt/document-structure-ambiguity-candidate.st @@ -0,0 +1,5 @@ +### 候选行 + +初始判断: +初始标题: +初始编码: diff --git a/super-agent-business/super-agent-business-chat/src/main/resources/prompt/document-structure-ambiguity.st b/super-agent-business/super-agent-business-chat/src/main/resources/prompt/document-structure-ambiguity.st new file mode 100644 index 0000000000000000000000000000000000000000..2854edbe53775dbf7933025705f7e800d8076a68 --- /dev/null +++ b/super-agent-business/super-agent-business-chat/src/main/resources/prompt/document-structure-ambiguity.st @@ -0,0 +1,26 @@ +你是文档结构判歧助手。 +你的任务是判断若干低置信度文本行,在当前上下文中更像: +- HEADING:章节/小节标题 +- LIST_ITEM:普通列表项 +- BODY:普通正文 + +请严格返回 JSON 数组,不要附加解释: +[ + { + "line_no": 12, + "resolved_kind": "HEADING | LIST_ITEM | BODY", + "level_hint": 1 + } +] + +规则: +1. 只有在非常像章节标题时才输出 HEADING。 +2. 连续出现的编号项、步骤项、清单项优先判断为 LIST_ITEM。 +3. 表格说明行、引用行、解释性句子优先判断为 BODY。 +4. level_hint 只有 resolved_kind=HEADING 时才填写;没有把握时填 null。 +5. 不要脑补目录结构,只依据提供的局部上下文判断。 + +文档标题: + + + diff --git a/super-agent-business/super-agent-business-chat/src/main/resources/prompt/rag-answer-document-reference.st b/super-agent-business/super-agent-business-chat/src/main/resources/prompt/rag-answer-document-reference.st new file mode 100644 index 0000000000000000000000000000000000000000..d76277e2f2f3b39af88c7061a79404cbe088da97 --- /dev/null +++ b/super-agent-business/super-agent-business-chat/src/main/resources/prompt/rag-answer-document-reference.st @@ -0,0 +1,2 @@ +[] 文档:;章节: +内容: diff --git a/super-agent-business/super-agent-business-chat/src/main/resources/prompt/rag-answer-no-evidence.st b/super-agent-business/super-agent-business-chat/src/main/resources/prompt/rag-answer-no-evidence.st new file mode 100644 index 0000000000000000000000000000000000000000..3afbb74749c70a7318244993e14d14ab740f4497 --- /dev/null +++ b/super-agent-business/super-agent-business-chat/src/main/resources/prompt/rag-answer-no-evidence.st @@ -0,0 +1 @@ +- 当前子问题没有检索到证据 diff --git a/super-agent-business/super-agent-business-chat/src/main/resources/prompt/rag-answer-omitted-evidence.st b/super-agent-business/super-agent-business-chat/src/main/resources/prompt/rag-answer-omitted-evidence.st new file mode 100644 index 0000000000000000000000000000000000000000..285851d18ac23ad7ceb53b07e9824bf1ec01d9d0 --- /dev/null +++ b/super-agent-business/super-agent-business-chat/src/main/resources/prompt/rag-answer-omitted-evidence.st @@ -0,0 +1 @@ +- 其余证据因上下文预算限制已省略 diff --git a/super-agent-business/super-agent-business-chat/src/main/resources/prompt/rag-answer-reuse-reference.st b/super-agent-business/super-agent-business-chat/src/main/resources/prompt/rag-answer-reuse-reference.st new file mode 100644 index 0000000000000000000000000000000000000000..b9da04cd18bf08d2461d255e14289dff62876da8 --- /dev/null +++ b/super-agent-business/super-agent-business-chat/src/main/resources/prompt/rag-answer-reuse-reference.st @@ -0,0 +1 @@ +- 复用证据 [] diff --git a/super-agent-business/super-agent-business-chat/src/main/resources/prompt/rag-answer-sub-question-evidence.st b/super-agent-business/super-agent-business-chat/src/main/resources/prompt/rag-answer-sub-question-evidence.st new file mode 100644 index 0000000000000000000000000000000000000000..1c1036c558fdba15542b4d97a21862401c660dd0 --- /dev/null +++ b/super-agent-business/super-agent-business-chat/src/main/resources/prompt/rag-answer-sub-question-evidence.st @@ -0,0 +1,2 @@ +## 子问题 + diff --git a/super-agent-business/super-agent-business-chat/src/main/resources/prompt/rag-answer-system.st b/super-agent-business/super-agent-business-chat/src/main/resources/prompt/rag-answer-system.st new file mode 100644 index 0000000000000000000000000000000000000000..6804074899fe0681690de7ee53ae86e7ddc77777 --- /dev/null +++ b/super-agent-business/super-agent-business-chat/src/main/resources/prompt/rag-answer-system.st @@ -0,0 +1,6 @@ +你是 JavaUp 的企业知识问答助手。 +你必须严格基于给定证据回答,不要编造证据中没有出现的事实。 +如果提供了“对话承接上下文”,它只用于理解当前问题中的指代关系,不能替代证据材料,也不能作为事实来源。 +如果证据不足以支持明确结论,请直接说明资料不足。 +如果问题被拆成多个子问题,请按编号逐一回答。 +如果引用了证据,请在对应句子末尾标注 [1][2] 这样的引用编号。 diff --git a/super-agent-business/super-agent-business-chat/src/main/resources/prompt/rag-answer-user.st b/super-agent-business/super-agent-business-chat/src/main/resources/prompt/rag-answer-user.st new file mode 100644 index 0000000000000000000000000000000000000000..3808df741dccfc9151d90dc00cdc6c4c0141b87c --- /dev/null +++ b/super-agent-business/super-agent-business-chat/src/main/resources/prompt/rag-answer-user.st @@ -0,0 +1,15 @@ +当前日期: + +用户原始问题: + + +检索理解后的问题: + + + + +请按下面这些子问题逐一回答: + + +证据材料: + diff --git a/super-agent-business/super-agent-business-chat/src/main/resources/prompt/rag-answer-web-reference.st b/super-agent-business/super-agent-business-chat/src/main/resources/prompt/rag-answer-web-reference.st new file mode 100644 index 0000000000000000000000000000000000000000..5bada40483dbc8f463eb04765b67f58fff51f662 --- /dev/null +++ b/super-agent-business/super-agent-business-chat/src/main/resources/prompt/rag-answer-web-reference.st @@ -0,0 +1,2 @@ +[] 网页:;链接:<url> +摘要:<snippet> diff --git a/super-agent-business/super-agent-business-chat/src/main/resources/prompt/recommendation-user.st b/super-agent-business/super-agent-business-chat/src/main/resources/prompt/recommendation-user.st new file mode 100644 index 0000000000000000000000000000000000000000..a357536b82f24bf903bf7ae7d759c015f2102407 --- /dev/null +++ b/super-agent-business/super-agent-business-chat/src/main/resources/prompt/recommendation-user.st @@ -0,0 +1,6 @@ +<basePrompt> + +最近上下文: +<recentContext> +当前问题:<question> +当前答案:<answer> diff --git a/super-agent-common/super-agent-common-frame/pom.xml b/super-agent-common/super-agent-common-frame/pom.xml index 21f7ce611065ad68bb28140672754b2d91cc1afb..62e2fba126f0d1c18f83f94733f96f0c1ce6b200 100644 --- a/super-agent-common/super-agent-common-frame/pom.xml +++ b/super-agent-common/super-agent-common-frame/pom.xml @@ -37,6 +37,21 @@ <artifactId>jjwt</artifactId> <version>${jjwt.version}</version> </dependency> + <!-- + jjwt 0.9.1 在 Java 11+ 仍然依赖 javax.xml.bind.DatatypeConverter。 + 当前项目运行在 Java 17,因此这里显式补回 JAXB API 与 runtime, + 避免登录签发 token 时出现 ClassNotFoundException。 + --> + <dependency> + <groupId>javax.xml.bind</groupId> + <artifactId>jaxb-api</artifactId> + <version>${jaxb.api.version}</version> + </dependency> + <dependency> + <groupId>org.glassfish.jaxb</groupId> + <artifactId>jaxb-runtime</artifactId> + <version>${jaxb.runtime.version}</version> + </dependency> <dependency> <groupId>cn.hutool</groupId> <artifactId>hutool-all</artifactId> diff --git a/vue/src/api/api.js b/vue/src/api/api.js index 4af475ec7f8bdcd4b600b30ee25ccca7074509b2..625e9fce11f47f9fc786eec44b66201e26b07dc4 100644 --- a/vue/src/api/api.js +++ b/vue/src/api/api.js @@ -1,5 +1,7 @@ const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || '' const REQUEST_TIMEOUT = 30000 +const ADMIN_TOKEN_KEY = 'super-agent-admin-token' +const ADMIN_USER_KEY = 'super-agent-admin-user' export class APIError extends Error { constructor(message, status, cause) { @@ -14,6 +16,36 @@ function buildApiUrl(path) { return API_BASE_URL ? new URL(path, API_BASE_URL).toString() : path } +function getAdminToken() { + return window.localStorage.getItem(ADMIN_TOKEN_KEY) || '' +} + +function clearAdminAuth() { + window.localStorage.removeItem(ADMIN_TOKEN_KEY) + window.localStorage.removeItem(ADMIN_USER_KEY) +} + +function buildAuthHeaders(headers = {}) { + const token = getAdminToken() + if (!token) { + return headers + } + return { + Authorization: `Bearer ${token}`, + ...headers + } +} + +function handleUnauthorized(response) { + if (response.status !== 401) { + return + } + clearAdminAuth() + if (window.location.pathname.startsWith('/admin') && window.location.pathname !== '/admin/login') { + window.location.href = '/admin/login' + } +} + function stringifyManageValue(value) { if (Array.isArray(value)) { return value.map((item) => stringifyManageValue(item)) @@ -68,13 +100,14 @@ async function requestJson(path, options = {}) { method: options.method || 'GET', headers: { 'Content-Type': 'application/json', - ...(options.headers || {}) + ...buildAuthHeaders(options.headers || {}) }, body: options.body ? JSON.stringify(options.body) : undefined, signal: controller.signal }) if (!response.ok) { + handleUnauthorized(response) throw new APIError(await readResponseMessage(response), response.status) } @@ -105,13 +138,14 @@ async function requestApiEnvelope(path, options = {}) { method: options.method || 'POST', headers: { 'Content-Type': 'application/json', - ...(options.headers || {}) + ...buildAuthHeaders(options.headers || {}) }, body: options.body ? JSON.stringify(options.body) : undefined, signal: controller.signal }) if (!response.ok) { + handleUnauthorized(response) throw new APIError(await readResponseMessage(response), response.status) } @@ -130,13 +164,14 @@ async function requestMultipartApiEnvelope(path, formData, options = {}) { const response = await fetch(buildApiUrl(path), { method: options.method || 'POST', headers: { - ...(options.headers || {}) + ...buildAuthHeaders(options.headers || {}) }, body: formData, signal: controller.signal }) if (!response.ok) { + handleUnauthorized(response) throw new APIError(await readResponseMessage(response), response.status) } @@ -355,7 +390,8 @@ export const chatApi = { method: 'POST', headers: { 'Content-Type': 'application/json', - Accept: 'text/event-stream' + Accept: 'text/event-stream', + ...buildAuthHeaders() }, body: JSON.stringify(payload), signal: controller.signal @@ -379,6 +415,27 @@ export const chatApi = { } } +export const adminAuthApi = { + login(payload) { + return requestApiEnvelope('/admin/auth/login', { + method: 'POST', + body: payload + }) + }, + + logout() { + return requestApiEnvelope('/admin/auth/logout', { + method: 'POST', + body: {} + }) + }, + + currentUser() { + return requestJson('/admin/auth/me') + .then((payload) => unwrapApiResponse(payload)) + } +} + export const manageApi = { uploadDocument({ file, documentName, operatorId, knowledgeScopeCode, knowledgeScopeName, businessCategory, documentTags }) { const formData = new FormData() diff --git a/vue/src/utils/adminAuth.js b/vue/src/utils/adminAuth.js index 356bb7497a7eb72c053e952665d53f6c88b1263e..30bb1913e494cd9f93ede2abadc96e5d21ced47e 100644 --- a/vue/src/utils/adminAuth.js +++ b/vue/src/utils/adminAuth.js @@ -1,34 +1,77 @@ -const ADMIN_AUTH_KEY = 'super-agent-admin-auth' +const ADMIN_TOKEN_KEY = 'super-agent-admin-token' const ADMIN_USER_KEY = 'super-agent-admin-user' +function decodeBase64Url(value) { + const normalized = value.replace(/-/g, '+').replace(/_/g, '/') + const padding = normalized.length % 4 + const base64 = padding ? normalized + '='.repeat(4 - padding) : normalized + return window.atob(base64) +} + +function parseTokenPayload(token) { + if (!token) { + return null + } + const parts = token.split('.') + if (parts.length < 2) { + return null + } + try { + return JSON.parse(decodeBase64Url(parts[1])) + } catch { + return null + } +} + +function isTokenExpired(token) { + const payload = parseTokenPayload(token) + if (!payload?.exp) { + return true + } + return Date.now() >= Number(payload.exp) * 1000 +} + +/** + * 读取当前后台 token。 + */ +export function getAdminToken() { + return window.localStorage.getItem(ADMIN_TOKEN_KEY) || '' +} + /** - * 判断当前是否已进入后台管理台演示态。 - * - * <p>这里故意不走真实登录接口,而是用本地存储模拟一个轻量登录态, - * 方便学员直接体验管理端功能,不引入额外鉴权复杂度。</p> + * 判断当前后台 token 是否存在且仍有效。 */ export function isAdminAuthenticated() { - return window.localStorage.getItem(ADMIN_AUTH_KEY) === '1' + const token = getAdminToken() + if (!token || isTokenExpired(token)) { + clearAdminAuth() + return false + } + return true } /** - * 写入一个假的后台登录态。 + * 写入后台登录态。 */ -export function loginAdminDemo(username) { - window.localStorage.setItem(ADMIN_AUTH_KEY, '1') - window.localStorage.setItem(ADMIN_USER_KEY, username || 'admin') +export function saveAdminAuth(payload = {}) { + if (payload.token) { + window.localStorage.setItem(ADMIN_TOKEN_KEY, payload.token) + } + if (payload.username) { + window.localStorage.setItem(ADMIN_USER_KEY, payload.username) + } } /** - * 清理假的后台登录态。 + * 清理后台登录态。 */ -export function logoutAdminDemo() { - window.localStorage.removeItem(ADMIN_AUTH_KEY) +export function clearAdminAuth() { + window.localStorage.removeItem(ADMIN_TOKEN_KEY) window.localStorage.removeItem(ADMIN_USER_KEY) } /** - * 获取当前演示用户名称。 + * 获取当前后台用户名。 */ export function getAdminUsername() { return window.localStorage.getItem(ADMIN_USER_KEY) || 'admin' diff --git a/vue/src/views/AdminLoginView.vue b/vue/src/views/AdminLoginView.vue index ea910135fb0a2fb271ba0cd168197c37b128fc43..a99b2ed008789ec3401b3c8313e17c03bd755929 100644 --- a/vue/src/views/AdminLoginView.vue +++ b/vue/src/views/AdminLoginView.vue @@ -4,7 +4,7 @@ <div class="login-copy"> <h1>进入管理后台工作台</h1> <p class="login-description"> - 这里用于演示文档上传、策略确认、索引构建与对话观测。登录采用假登录模式,方便直接体验完整业务流转。 + 这里用于管理文档接入、知识路由与对话观测。账号和密码由当前部署环境配置,登录后才能进入后台。 </p> </div> @@ -16,19 +16,21 @@ <label class="field"> <span>账号</span> - <input v-model="form.username" type="text" placeholder="账号自动填充中..." autocomplete="username" /> + <input v-model="form.username" type="text" placeholder="请输入后台账号" autocomplete="username" /> </label> <label class="field"> <span>密码</span> - <input v-model="form.password" type="password" placeholder="密码自动填充中..." autocomplete="current-password" /> + <input v-model="form.password" type="password" placeholder="请输入后台密码" autocomplete="current-password" /> </label> <p v-if="errorMessage" class="error-message">{{ errorMessage }}</p> <div class="form-actions"> <button class="secondary-button" type="button" @click="goBackChat">返回聊天</button> - <button class="primary-button" type="submit">{{ fillReady ? '进入管理台' : '正在填充账号...' }}</button> + <button class="primary-button" type="submit" :disabled="submitting"> + {{ submitting ? '登录中...' : '进入管理台' }} + </button> </div> </form> </div> @@ -36,46 +38,54 @@ </template> <script setup> -import { computed, onMounted, reactive, ref } from 'vue' +import { reactive, ref } from 'vue' import { useRoute, useRouter } from 'vue-router' -import { loginAdminDemo } from '../utils/adminAuth' +import { adminAuthApi, APIError } from '../api/api' +import { saveAdminAuth } from '../utils/adminAuth' const router = useRouter() const route = useRoute() const form = reactive({ - username: '', - password: '' + username: 'admin', + password: 'admin123456' }) const errorMessage = ref('') -const fillReady = computed(() => form.username.trim() && form.password.trim()) +const submitting = ref(false) -function fillDemoAccount() { - form.username = 'admin' - form.password = 'admin123456' -} - -function submitLogin() { +async function submitLogin() { errorMessage.value = '' - if (!fillReady.value) { - errorMessage.value = '演示账号还在填充,请稍候再试。' + if (!form.username.trim() || !form.password.trim()) { + errorMessage.value = '请输入账号和密码。' return } - loginAdminDemo(form.username.trim()) - const redirect = typeof route.query.redirect === 'string' && route.query.redirect.startsWith('/admin') - ? route.query.redirect - : '/admin/dashboard' - router.replace(redirect) + submitting.value = true + try { + const result = await adminAuthApi.login({ + username: form.username.trim(), + password: form.password + }) + saveAdminAuth({ + username: result?.username || form.username.trim(), + token: result?.token || '' + }) + const redirect = typeof route.query.redirect === 'string' && route.query.redirect.startsWith('/admin') + ? route.query.redirect + : '/admin/dashboard' + router.replace(redirect) + } catch (error) { + errorMessage.value = error instanceof APIError || error instanceof Error + ? error.message + : '登录失败,请稍后重试。' + } finally { + submitting.value = false + } } function goBackChat() { router.push('/chat') } - -onMounted(() => { - window.setTimeout(fillDemoAccount, 320) -}) </script> <style scoped> diff --git a/vue/src/views/BusinessChatView.vue b/vue/src/views/BusinessChatView.vue index 31b3731836bcb086a0bd604cbb56d26e34a5bad0..c2238987895bad86692b36d439c8f3f228893cfa 100644 --- a/vue/src/views/BusinessChatView.vue +++ b/vue/src/views/BusinessChatView.vue @@ -748,7 +748,10 @@ async function sendMessage(presetQuestion) { try { await refreshSessions() - await loadConversation(conversationId) + const sessionExists = sessions.value.some((item) => item.conversationId === conversationId) + if (sessionExists) { + await loadConversation(conversationId) + } } catch { // 这里的错误已经在各自方法里落到页面提示了,不需要再次抛出。 } diff --git a/vue/src/views/admin/AdminLayoutView.vue b/vue/src/views/admin/AdminLayoutView.vue index 2870b0e6f7ff323856f8346eb6de336d1ddb35ef..a9682b2e2fcbdad6f25a8d8e8cddc17cc110d913 100644 --- a/vue/src/views/admin/AdminLayoutView.vue +++ b/vue/src/views/admin/AdminLayoutView.vue @@ -27,7 +27,7 @@ <div class="sidebar-avatar">{{ username.slice(0, 1).toUpperCase() }}</div> <div> <strong>{{ username }}</strong> - <span class="user-role">演示账号</span> + <span class="user-role">管理员</span> </div> </div> <button class="logout-btn" type="button" title="退出登录" @click="logout"> @@ -82,7 +82,8 @@ import { ShareIcon, EyeIcon } from '@heroicons/vue/24/outline' -import { getAdminUsername, logoutAdminDemo } from '../../utils/adminAuth' +import { adminAuthApi } from '../../api/api' +import { clearAdminAuth, getAdminUsername } from '../../utils/adminAuth' const route = useRoute() const router = useRouter() @@ -99,9 +100,15 @@ const navItems = [ const pageTitle = computed(() => route.meta?.title || '管理后台') const username = computed(() => getAdminUsername()) -function logout() { - logoutAdminDemo() - router.replace('/admin/login') +async function logout() { + try { + await adminAuthApi.logout() + } catch { + // token 失效或网络异常时,前端仍然要允许本地退出。 + } finally { + clearAdminAuth() + router.replace('/admin/login') + } } function isNavItemActive(targetPath) { diff --git a/vue/vite.config.js b/vue/vite.config.js index a54fce836942a969255d120f16b72786432912d8..84e1f0b8b7a98aea401d3aac0c5f7528d2da9ce8 100644 --- a/vue/vite.config.js +++ b/vue/vite.config.js @@ -25,6 +25,10 @@ export default defineConfig(({ mode }) => { target: proxyTarget, changeOrigin: true }, + '/admin/auth': { + target: proxyTarget, + changeOrigin: true + }, '/manage': { target: proxyTarget, changeOrigin: true