Compare commits
6 Commits
main
...
feat/langg
| Author | SHA1 | Date | |
|---|---|---|---|
| a548425923 | |||
| b83c540018 | |||
| cc54b24a77 | |||
| 846380879b | |||
| 316def2145 | |||
| 911f7adee6 |
@@ -191,6 +191,17 @@
|
||||
<artifactId>spring-boot-starter-security</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- CAS 单点登录 -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.security</groupId>
|
||||
<artifactId>spring-security-cas</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.jasig.cas.client</groupId>
|
||||
<artifactId>cas-client-core</artifactId>
|
||||
<version>3.5.0</version>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-data-jdbc</artifactId>
|
||||
|
||||
@@ -7,6 +7,7 @@ import com.inspur.llm.chat.base.security.UserDetailsServiceImpl;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.core.annotation.Order;
|
||||
import org.springframework.security.authentication.AuthenticationManager;
|
||||
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
|
||||
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
|
||||
@@ -27,6 +28,7 @@ import org.springframework.security.web.authentication.UsernamePasswordAuthentic
|
||||
* Copyright Ⓒ 2024 Inspur Corporation Limited All rights reserved.
|
||||
*/
|
||||
@Configuration
|
||||
@Order(100)
|
||||
@EnableWebSecurity
|
||||
@EnableGlobalMethodSecurity(prePostEnabled = true)
|
||||
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
package com.inspur.llm.chat.base.security.cas;
|
||||
|
||||
import com.inspur.llm.chat.base.constant.RedisConstants;
|
||||
import com.inspur.llm.chat.base.constant.StringPoolConstant;
|
||||
import com.inspur.llm.chat.base.security.JwtTokenUtils;
|
||||
import com.inspur.llm.chat.base.security.UserDetail;
|
||||
import com.inspur.llm.chat.base.util.RedisUtils;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import javax.servlet.ServletException;
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import javax.servlet.http.HttpServletResponse;
|
||||
import javax.servlet.http.HttpSession;
|
||||
import java.io.IOException;
|
||||
import java.net.URLEncoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
/**
|
||||
* CAS 登录成功 → 生成 JWT → 重定向前端登录页(带 cas_token)。
|
||||
*
|
||||
* 重定向的前端 URL 用当前请求的 X-Forwarded-Host / Host 动态拼,
|
||||
* 这样回到前端时浏览器地址不会突变(用户从哪个 host 来就回哪个 host),
|
||||
* 避免内网 IP 不可达造成"无法访问此网站"。
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
public class CasLoginSuccessHandler implements AuthenticationSuccessHandler {
|
||||
|
||||
/**
|
||||
* 前端 base 路径,token 用 query 参数传(不放 hash 里)。
|
||||
* 原本想用 /metalinfo/#/login?cas_token=xxx,但 sendRedirect 经过容器/代理时
|
||||
* # 字符容易被 encode 成 %23,导致浏览器拿不到 hash → vue-router 不知道路由 → 404。
|
||||
* 改用 query:/metalinfo/?cas_token=xxx,前端 router beforeEach 拦截。
|
||||
*/
|
||||
private static final String FRONTEND_BASE_PATH = "/metalinfo/";
|
||||
|
||||
@Autowired
|
||||
private CasProperties casProperties;
|
||||
|
||||
@Autowired
|
||||
private RedisUtils redisUtils;
|
||||
|
||||
@Override
|
||||
public void onAuthenticationSuccess(HttpServletRequest request,
|
||||
HttpServletResponse response,
|
||||
Authentication authentication) throws IOException, ServletException {
|
||||
log.info("CAS 登录成功: {}", authentication.getName());
|
||||
String token = JwtTokenUtils.generateToken(authentication);
|
||||
|
||||
// 把 token 写进 Redis,否则 JwtAuthenticationFilter 校验时查不到会返"token已失效"
|
||||
// 跟 LoginController.saveLoginLog 同样逻辑
|
||||
try {
|
||||
UserDetail userDetail = JwtTokenUtils.getUserDetail(authentication);
|
||||
String key = RedisConstants.LOGIN_TOKEN_KEY
|
||||
+ userDetail.getId() + StringPoolConstant.COLON + userDetail.getSessionId();
|
||||
redisUtils.set(key, token, JwtTokenUtils.EXPIRE_TIME / 1000L);
|
||||
log.info("CAS token 已写入 Redis, key={}", key);
|
||||
} catch (Exception e) {
|
||||
log.warn("CAS token 写 Redis 失败: {}", e.getMessage(), e);
|
||||
}
|
||||
|
||||
String redirect = CasUrlBuilder.buildServiceUrl(request, FRONTEND_BASE_PATH)
|
||||
+ "?cas_token=" + URLEncoder.encode(token, StandardCharsets.UTF_8.name());
|
||||
log.info("CAS 登录成功跳转 URL: {}", redirect);
|
||||
|
||||
// 销毁 HttpSession + 清 SecurityContext
|
||||
// 否则下次访问 /cas/login 时 Spring Security 看到 session 已认证 → 不触发 CAS entryPoint
|
||||
// → 请求穿透到 DispatcherServlet → 找不到 /cas/login 映射 → 404 Whitelabel。
|
||||
// 我们用 JWT 做认证,不需要 server-side session。
|
||||
HttpSession session = request.getSession(false);
|
||||
if (session != null) {
|
||||
session.invalidate();
|
||||
}
|
||||
SecurityContextHolder.clearContext();
|
||||
|
||||
response.sendRedirect(redirect);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.inspur.llm.chat.base.security.cas;
|
||||
|
||||
import lombok.Data;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
/**
|
||||
* CAS 单点登录配置(绑定 application.yml 中的 security.cas.*)。
|
||||
*
|
||||
* 只保留实际使用的字段。logout/appKey/appSecret 等之前预留但未实现,
|
||||
* 真正接入时再加,避免 dead config。
|
||||
*/
|
||||
@Data
|
||||
@Configuration
|
||||
@ConfigurationProperties(prefix = "security.cas")
|
||||
public class CasProperties {
|
||||
/** CAS 服务器根地址,例如 http://192.168.203.20:8180 */
|
||||
private String serverHost;
|
||||
/** CAS 服务器登录入口,通常 ${serverHost}/login */
|
||||
private String serverLogin;
|
||||
/** CAS 服务器登出入口,通常 ${serverHost}/logout */
|
||||
private String serverLogout;
|
||||
/** 本应用的 CAS 回调路径(servlet path),通常 /cas/login */
|
||||
private String appLogin;
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
package com.inspur.llm.chat.base.security.cas;
|
||||
|
||||
import org.jasig.cas.client.validation.Cas20ServiceTicketValidator;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.core.annotation.Order;
|
||||
import org.springframework.security.cas.ServiceProperties;
|
||||
import org.springframework.security.cas.authentication.CasAssertionAuthenticationToken;
|
||||
import org.springframework.security.cas.authentication.CasAuthenticationProvider;
|
||||
import org.springframework.security.cas.web.CasAuthenticationEntryPoint;
|
||||
import org.springframework.security.cas.web.CasAuthenticationFilter;
|
||||
import org.springframework.security.cas.web.authentication.ServiceAuthenticationDetails;
|
||||
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
|
||||
import org.springframework.security.authentication.AuthenticationDetailsSource;
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
|
||||
import org.springframework.security.core.userdetails.AuthenticationUserDetailsService;
|
||||
import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
|
||||
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import javax.servlet.http.HttpServletResponse;
|
||||
|
||||
/**
|
||||
* CAS 单点登录 Security 配置(仅处理 /cas/** 路径,与 WebSecurityConfig 共存)。
|
||||
*
|
||||
* 流程:
|
||||
* /cas/login(无 ticket) → 触发 CasEntryPoint → 302 到 CAS 登录页
|
||||
* /cas/login?ticket=xxx → CasAuthenticationFilter 验证 → 成功 → CasLoginSuccessHandler 生成 JWT → 302 前端
|
||||
*
|
||||
* service URL 用 X-Forwarded-Host / Host 动态拼,不依赖固定 frontEndUrl,
|
||||
* 这样浏览器在 localhost:3000 / 内网 IP / 公网域名 各种入口下 CAS 回跳都能命中。
|
||||
*/
|
||||
@Configuration
|
||||
@Order(1)
|
||||
public class CasSecurityConfig extends WebSecurityConfigurerAdapter {
|
||||
|
||||
/** 给 CAS 的 service URL 后缀(不含 host) */
|
||||
static final String SERVICE_URL_SUFFIX = "/metalinfo/chat_web_backend/cas/login";
|
||||
|
||||
@Autowired
|
||||
private CasProperties casProperties;
|
||||
|
||||
@Autowired
|
||||
private AuthenticationUserDetailsService<CasAssertionAuthenticationToken> casUserDetailsService;
|
||||
|
||||
@Autowired
|
||||
private AuthenticationSuccessHandler casLoginSuccessHandler;
|
||||
|
||||
@Override
|
||||
protected void configure(HttpSecurity http) throws Exception {
|
||||
http
|
||||
.antMatcher("/cas/**")
|
||||
.csrf().disable()
|
||||
.anonymous().disable()
|
||||
.authorizeRequests()
|
||||
.anyRequest().authenticated()
|
||||
.and()
|
||||
.exceptionHandling()
|
||||
.authenticationEntryPoint(casAuthenticationEntryPoint())
|
||||
.and()
|
||||
.addFilter(casAuthenticationFilter());
|
||||
http.headers().frameOptions().disable();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void configure(AuthenticationManagerBuilder auth) {
|
||||
auth.authenticationProvider(casAuthenticationProvider());
|
||||
}
|
||||
|
||||
/**
|
||||
* ServiceProperties 仅作为 bean 占位被 entry point / filter / provider 引用,
|
||||
* 它的 service 字段实际不会被读到——entry point override 了 createServiceUrl,
|
||||
* filter 设了自定义 AuthenticationDetailsSource,校验时用的是 details.getServiceUrl()。
|
||||
*
|
||||
* 但 ServiceProperties.afterPropertiesSet() 会硬性校验 service 字段非空,
|
||||
* 所以这里 set 一个占位符值满足校验,不影响实际行为。
|
||||
*/
|
||||
@Bean
|
||||
public ServiceProperties casServiceProperties() {
|
||||
ServiceProperties sp = new ServiceProperties();
|
||||
// 占位符(必须非空,afterPropertiesSet 会校验),实际值由 entry point 的 createServiceUrl
|
||||
// 和 filter 的 AuthenticationDetailsSource 动态构造
|
||||
sp.setService("placeholder-not-used");
|
||||
sp.setSendRenew(false);
|
||||
sp.setAuthenticateAllArtifacts(true);
|
||||
return sp;
|
||||
}
|
||||
|
||||
/**
|
||||
* 自定义 entry point:override createServiceUrl,用当前请求的 X-Forwarded-Host / Host
|
||||
* 动态拼 service URL,而不是用固定的 frontEndUrl 配置(commence 是 final,只能改这里)。
|
||||
*
|
||||
* 注意返回 raw URL(未 encode),commence 内部的 constructRedirectUrl 会再 URLEncoder.encode 一次。
|
||||
*/
|
||||
@Bean
|
||||
public CasAuthenticationEntryPoint casAuthenticationEntryPoint() {
|
||||
CasAuthenticationEntryPoint ep = new CasAuthenticationEntryPoint() {
|
||||
@Override
|
||||
protected String createServiceUrl(HttpServletRequest request, HttpServletResponse response) {
|
||||
return CasUrlBuilder.buildServiceUrl(request, SERVICE_URL_SUFFIX);
|
||||
}
|
||||
};
|
||||
ep.setLoginUrl(casProperties.getServerLogin());
|
||||
ep.setServiceProperties(casServiceProperties());
|
||||
return ep;
|
||||
}
|
||||
|
||||
@Bean
|
||||
public CasAuthenticationFilter casAuthenticationFilter() throws Exception {
|
||||
CasAuthenticationFilter filter = new CasAuthenticationFilter();
|
||||
filter.setAuthenticationManager(authenticationManager());
|
||||
filter.setFilterProcessesUrl(casProperties.getAppLogin());
|
||||
filter.setAuthenticationSuccessHandler(casLoginSuccessHandler);
|
||||
filter.setServiceProperties(casServiceProperties());
|
||||
// 关键:ticket 校验阶段也得用同一个动态 service URL(含 /metalinfo 前缀),
|
||||
// 否则 CAS 服务器对 ticket 的 service 校验会 mismatch(vite proxy 已经把 /metalinfo
|
||||
// 前缀剥掉,request URL 没法直接拼出正确 service URL,所以自定义一个)。
|
||||
AuthenticationDetailsSource<javax.servlet.http.HttpServletRequest, ServiceAuthenticationDetails> source =
|
||||
request -> () -> CasUrlBuilder.buildServiceUrl(request, SERVICE_URL_SUFFIX);
|
||||
filter.setAuthenticationDetailsSource(source);
|
||||
// 仅在 URL 含 ticket 时才尝试认证;无 ticket → 让 ExceptionTranslationFilter 触发 EntryPoint 重定向 CAS
|
||||
final String appLogin = casProperties.getAppLogin();
|
||||
filter.setRequiresAuthenticationRequestMatcher(req ->
|
||||
appLogin.equals(req.getServletPath()) && req.getParameter("ticket") != null
|
||||
);
|
||||
return filter;
|
||||
}
|
||||
|
||||
@Bean
|
||||
public CasAuthenticationProvider casAuthenticationProvider() {
|
||||
CasAuthenticationProvider provider = new CasAuthenticationProvider();
|
||||
provider.setAuthenticationUserDetailsService(casUserDetailsService);
|
||||
provider.setServiceProperties(casServiceProperties());
|
||||
provider.setTicketValidator(new Cas20ServiceTicketValidator(casProperties.getServerHost()));
|
||||
provider.setKey("casAuthProviderKey");
|
||||
return provider;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package com.inspur.llm.chat.base.security.cas;
|
||||
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
|
||||
/**
|
||||
* 从 HTTP 请求动态构造 CAS 相关 URL 的工具类。
|
||||
*
|
||||
* 优先用 X-Forwarded-Host / X-Forwarded-Proto(vite proxy 转发的原始 Host),
|
||||
* 退而求其次用请求自身的 Host。这样无论用户是 localhost、内网 IP 还是公网域名访问,
|
||||
* 拼出来的 URL 都跟用户浏览器的实际地址栏一致。
|
||||
*/
|
||||
public final class CasUrlBuilder {
|
||||
|
||||
private CasUrlBuilder() {}
|
||||
|
||||
/** 取请求实际的 host(优先 X-Forwarded-Host)。 */
|
||||
public static String resolveHost(HttpServletRequest request) {
|
||||
String forwarded = request.getHeader("X-Forwarded-Host");
|
||||
if (forwarded != null && !forwarded.isEmpty()) {
|
||||
// X-Forwarded-Host 可能是 "host1, host2"(多层代理),取第一个
|
||||
int comma = forwarded.indexOf(',');
|
||||
return (comma > 0 ? forwarded.substring(0, comma) : forwarded).trim();
|
||||
}
|
||||
String host = request.getHeader("Host");
|
||||
if (host != null && !host.isEmpty()) {
|
||||
return host;
|
||||
}
|
||||
return request.getServerName() + ":" + request.getServerPort();
|
||||
}
|
||||
|
||||
/** 取请求实际的 scheme(优先 X-Forwarded-Proto)。 */
|
||||
public static String resolveScheme(HttpServletRequest request) {
|
||||
String proto = request.getHeader("X-Forwarded-Proto");
|
||||
if (proto != null && !proto.isEmpty()) {
|
||||
return proto.trim();
|
||||
}
|
||||
return request.getScheme();
|
||||
}
|
||||
|
||||
/** 拼一个绝对 URL:scheme://host + suffix */
|
||||
public static String buildServiceUrl(HttpServletRequest request, String suffix) {
|
||||
return resolveScheme(request) + "://" + resolveHost(request) + suffix;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package com.inspur.llm.chat.base.security.cas;
|
||||
|
||||
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
|
||||
import com.inspur.llm.chat.base.security.UserDetail;
|
||||
import com.inspur.llm.chat.gpt.mapper.SysUserMapper;
|
||||
import com.inspur.llm.chat.gpt.mapper.UserMapper;
|
||||
import com.inspur.llm.chat.gpt.pojo.entity.SysUser;
|
||||
import com.inspur.llm.chat.gpt.pojo.entity.User;
|
||||
import com.inspur.llm.chat.gpt.pojo.vo.SysUserVO;
|
||||
import com.inspur.llm.chat.gpt.pojo.vo.UserVO;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.BeanUtils;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.security.cas.authentication.CasAssertionAuthenticationToken;
|
||||
import org.springframework.security.core.userdetails.AuthenticationUserDetailsService;
|
||||
import org.springframework.security.core.userdetails.UserDetails;
|
||||
import org.springframework.security.core.userdetails.UsernameNotFoundException;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
|
||||
/**
|
||||
* 把 CAS 返回的 principal 映射到本地用户:
|
||||
* 1) sys_user.cas_username
|
||||
* 2) gpt_user.cas_username
|
||||
* 3) gpt_user.tel(fallback)
|
||||
* 4) 都没找到 → 抛 UsernameNotFoundException
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
public class CasUserDetailsService implements AuthenticationUserDetailsService<CasAssertionAuthenticationToken> {
|
||||
|
||||
@Autowired
|
||||
private SysUserMapper sysUserMapper;
|
||||
|
||||
@Autowired
|
||||
private UserMapper userMapper;
|
||||
|
||||
@Override
|
||||
public UserDetails loadUserDetails(CasAssertionAuthenticationToken token) {
|
||||
String casPrincipal = token.getName();
|
||||
log.info("CAS 用户加载: principal={}", casPrincipal);
|
||||
|
||||
SysUser sysUser = sysUserMapper.selectOne(
|
||||
Wrappers.<SysUser>lambdaQuery().eq(SysUser::getCasUsername, casPrincipal).last("limit 1"));
|
||||
if (sysUser != null) {
|
||||
log.info("CAS 命中 sys_user id={}", sysUser.getId());
|
||||
SysUserVO vo = new SysUserVO();
|
||||
BeanUtils.copyProperties(sysUser, vo);
|
||||
return new UserDetail(vo, new HashSet<>());
|
||||
}
|
||||
|
||||
User user = userMapper.selectOne(
|
||||
Wrappers.<User>lambdaQuery().eq(User::getCasUsername, casPrincipal).last("limit 1"));
|
||||
if (user != null) {
|
||||
log.info("CAS 命中 gpt_user (cas_username) id={}", user.getId());
|
||||
return buildGptUserDetail(user);
|
||||
}
|
||||
|
||||
user = userMapper.selectOne(
|
||||
Wrappers.<User>lambdaQuery().eq(User::getTel, casPrincipal).last("limit 1"));
|
||||
if (user != null) {
|
||||
log.info("CAS 命中 gpt_user (tel fallback) id={}", user.getId());
|
||||
return buildGptUserDetail(user);
|
||||
}
|
||||
|
||||
log.warn("CAS 用户未在战知库中找到: {}", casPrincipal);
|
||||
throw new UsernameNotFoundException("CAS 用户未绑定战知账号: " + casPrincipal);
|
||||
}
|
||||
|
||||
/**
|
||||
* 包装 gpt_user → UserDetail。
|
||||
* 注意:UserDetail(UserVO, Set) 构造器没初始化 authorities,导致 getAuthorities() 返 null,
|
||||
* 而 CasAuthenticationToken 构造会校验 authorities 非 null(不是非空 list)→ IllegalArgumentException。
|
||||
* 这里手动补一个空 list。
|
||||
*/
|
||||
private UserDetail buildGptUserDetail(User user) {
|
||||
UserVO vo = new UserVO();
|
||||
BeanUtils.copyProperties(user, vo);
|
||||
UserDetail ud = new UserDetail(vo, new HashSet<>());
|
||||
ud.setAuthorities(new ArrayList<>());
|
||||
return ud;
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,8 @@ import com.inspur.llm.chat.base.constant.SysConfigConstants;
|
||||
import com.inspur.llm.chat.base.security.JwtTokenUtils;
|
||||
import com.inspur.llm.chat.base.security.Oauth2Token;
|
||||
import com.inspur.llm.chat.base.security.UserDetail;
|
||||
import com.inspur.llm.chat.base.security.cas.CasProperties;
|
||||
import com.inspur.llm.chat.base.security.cas.CasUrlBuilder;
|
||||
import com.inspur.llm.chat.base.util.RedisUtils;
|
||||
import com.inspur.llm.chat.base.validator.ValidatorUtil;
|
||||
import com.inspur.llm.chat.gpt.enums.ResponseEnum;
|
||||
@@ -22,10 +24,17 @@ import org.springframework.security.authentication.UsernamePasswordAuthenticatio
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.AuthenticationException;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
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;
|
||||
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import java.net.URLEncoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
|
||||
@@ -46,6 +55,8 @@ public class LoginController {
|
||||
private IUserService userService;
|
||||
@Autowired
|
||||
private RedisUtils redisUtils;
|
||||
@Autowired
|
||||
private CasProperties casProperties;
|
||||
|
||||
/**
|
||||
* 登录
|
||||
@@ -54,8 +65,8 @@ public class LoginController {
|
||||
* @date: 2024/1/9
|
||||
* @version: 1.0.0
|
||||
*/
|
||||
@GetMapping("/api/oauth/token")
|
||||
public ResponseInfo<Oauth2Token> login(LoginCommand command) {
|
||||
@PostMapping("/api/oauth/token")
|
||||
public ResponseInfo<Oauth2Token> login(@RequestBody LoginCommand command) {
|
||||
UsernamePasswordAuthenticationToken authenticationToken;
|
||||
if (UserTypeEnum.TEL.getValue().equals(command.getLoginType())) {
|
||||
UserVO user = userService.loginByTel(command.getTel(), command.getPassword(), command.getCode()).getData();
|
||||
@@ -100,6 +111,46 @@ public class LoginController {
|
||||
return ResponseInfo.success(token);
|
||||
}
|
||||
|
||||
/**
|
||||
* 退出登录:删 Redis 里的 token + 返回 CAS server logout URL(若用户用 CAS 登录的)
|
||||
*
|
||||
* 前端拿到 casLogoutUrl 后 window.location.href 跳过去,CAS 服务器会清掉 SSO cookie
|
||||
* 并跳回 service 参数指定的页面(我们的 /login)。这样下次再点"统一身份登录"会重新跑
|
||||
* CAS 认证流,不会因为 SSO cookie 还在而直接静默登入。
|
||||
*/
|
||||
@PostMapping("/api/logout")
|
||||
public ResponseInfo<Map<String, String>> logout(HttpServletRequest request) {
|
||||
Map<String, String> data = new HashMap<>();
|
||||
try {
|
||||
UserDetail userDetail = JwtTokenUtils.getLoginUser();
|
||||
if (userDetail != null) {
|
||||
// 删 Redis 里这个 session 的 token
|
||||
String key = RedisConstants.LOGIN_TOKEN_KEY
|
||||
+ userDetail.getId() + StringPoolConstant.COLON + userDetail.getSessionId();
|
||||
redisUtils.del(key);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
// Redis 不可用不影响登出流程
|
||||
e.printStackTrace();
|
||||
}
|
||||
SecurityContextHolder.clearContext();
|
||||
|
||||
// 拼 CAS server logout URL
|
||||
// service 参数:让 CAS 退出后回跳到我们的前端 login 页(动态用 X-Forwarded-Host)
|
||||
String backToFront;
|
||||
try {
|
||||
backToFront = URLEncoder.encode(
|
||||
CasUrlBuilder.buildServiceUrl(request, "/metalinfo/#/login"),
|
||||
StandardCharsets.UTF_8.name());
|
||||
} catch (Exception e) {
|
||||
backToFront = "";
|
||||
}
|
||||
String casLogoutUrl = casProperties.getServerLogout()
|
||||
+ (backToFront.isEmpty() ? "" : "?service=" + backToFront);
|
||||
data.put("casLogoutUrl", casLogoutUrl);
|
||||
return ResponseInfo.success(data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加登录日志
|
||||
*
|
||||
|
||||
@@ -152,6 +152,8 @@ public class FileController extends BaseController {
|
||||
*/
|
||||
@GetMapping("/list")
|
||||
public ResponseInfo<List<UploadFileVO>> listFile(@RequestParam Map map) {
|
||||
// 强制按当前登录用户隔离,防止枚举别人的 knowledgeBaseId/folderId 拿到他人文件元信息
|
||||
map.put("userId", getSysUserId());
|
||||
return fileService.listFile(new Query(map));
|
||||
}
|
||||
|
||||
|
||||
@@ -70,6 +70,11 @@ public class SysUser extends BaseEntity {
|
||||
*/
|
||||
private String email;
|
||||
|
||||
/**
|
||||
* CAS 单点登录用户名(钢研统一身份)
|
||||
*/
|
||||
private String casUsername;
|
||||
|
||||
/**
|
||||
* 性别 0->女;1->男
|
||||
*/
|
||||
|
||||
@@ -74,6 +74,11 @@ public class User extends BaseEntity {
|
||||
*/
|
||||
private String unionid;
|
||||
|
||||
/**
|
||||
* CAS 单点登录用户名(钢研统一身份)
|
||||
*/
|
||||
private String casUsername;
|
||||
|
||||
/**
|
||||
* 登录ip
|
||||
*/
|
||||
|
||||
@@ -92,4 +92,12 @@ xss:
|
||||
# 词向量模型名称
|
||||
embedding-model-name: bge_m3
|
||||
|
||||
# CAS 单点登录配置(service URL 用 X-Forwarded-Host 动态拼,无需 front-end-url)
|
||||
security:
|
||||
cas:
|
||||
server-host: http://192.168.203.20:8180
|
||||
server-login: ${security.cas.server-host}/login
|
||||
server-logout: ${security.cas.server-host}/logout
|
||||
app-login: /cas/login
|
||||
|
||||
|
||||
|
||||
@@ -44,6 +44,7 @@
|
||||
<if test="q.folderId != null and q.folderId > -1"> and t.folder_id = #{q.folderId}</if>
|
||||
<if test="q.embeddingId != null and q.embeddingId != ''"> and t.embedding_id = #{q.embeddingId}</if>
|
||||
<if test="q.knowledgeBaseId != null and q.knowledgeBaseId != ''"> and t.knowledge_base_id = #{q.knowledgeBaseId}</if>
|
||||
<if test="q.userId != null"> and t.user_id = #{q.userId}</if>
|
||||
<if test="q.type != null"> and t.type = #{q.type}</if>
|
||||
<if test="q.startDate != null and q.startDate != ''"> and date_format(t.create_time,'%Y-%m-%d') >= #{q.startDate} </if>
|
||||
<if test="q.endDate != null and q.endDate != ''"> and date_format(t.create_time,'%Y-%m-%d') <= #{q.endDate} </if>
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg id="_图层_2" data-name="图层 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 31.79 26.46">
|
||||
<g id="_图层_1-2" data-name="图层 1">
|
||||
<g>
|
||||
<g fill="#636E77">
|
||||
<path d="M4.91,6.25c.18,0,.74-.6,.87-.71,.17,0,.65-.53,.79-.65,.32-.26,.63-.52,.95-.78,.56-.46,1.12-.92,1.69-1.38,.15,0,.72,1.25,.84,1.45,.45,.78,.9,1.56,1.35,2.35l-1.69,1.36c-1.6-.55-3.19-1.1-4.79-1.65Z"/>
|
||||
<path d="M13.06,5.43c-.5-1.29,.38-3.57,1.27-4.51C15.49-.3,17.51,0,19.03,.14c0,0-2.48,2.93-3.55,5.08-.82-.01-1.63,.02-2.42,.21Z"/>
|
||||
<path d="M17.66,4.96L25.6,.8s2.68,1.49,3.63,2.72c-1.4,.41-9.1,2.6-9.37,2.77-.88-.55-2.2-1.33-2.2-1.33Z"/>
|
||||
<polygon points="20.85 7.78 31.75 7.86 31.79 12.24 20.74 9.98 20.85 7.78"/>
|
||||
<path d="M19.56,13.33c-.17,0-.29,.12-.57,.33-.62,.47-1.24,.94-1.86,1.41,1.64,1.94,3.21,3.94,4.81,5.91,.62,.77,1.23,1.55,1.87,2.3,.97-.59,1.7-1.47,2.6-2.15,.32-.25,1.43-.76,1.45-1.22,0-.13-.86-.71-.99-.82-.42-.34-.84-.69-1.26-1.03-1.49-1.22-2.97-2.43-4.47-3.63-.4-.32-.81-.71-1.25-.98-.14-.08-.24-.12-.32-.13Z"/>
|
||||
<path d="M14.11,26.46l-.16-17.73c0-.06-2.36,.1-2.45,.11-1.79,.15-3.65,.33-5.45,.55-1.43,.18-3.03,.26-4.27,1.12C.46,11.43-.13,13.17,.02,14.73l.17,.55s3.27-1.55,3.58-1.7c.63-.3,1.26-.58,1.9-.84,.54-.22,1.32-.65,1.9-.64-.17,.37-.57,.73-.84,1.05l-1.51,1.81-3.24,3.89c-.48,.57-1.02,.92-.79,1.69,.43,1.47,1.54,2.63,2.77,3.5,1.21-1.91,4.39-7.62,6.15-10.35l-.89,12.76h4.89Z"/>
|
||||
<path fill="#FF2500" d="M14.11,26.46l-.16-17.73c0-.06-2.36,.1-2.45,.11-1.79,.15-3.65,.33-5.45,.55-1.43,.18-3.03,.26-4.27,1.12C.46,11.43-.13,13.17,.02,14.73l.17,.55s3.27-1.55,3.58-1.7c.63-.3,1.26-.58,1.9-.84,.54-.22,1.32-.65,1.9-.64-.17,.37-.57,.73-.84,1.05l-1.51,1.81-3.24,3.89c-.48,.57-1.02,.92-.79,1.69,.43,1.47,1.54,2.63,2.77,3.5,1.21-1.91,4.39-7.62,6.15-10.35l-.89,12.76h4.89Z"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 1.4 KiB |
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div class="content">
|
||||
<Operates v-show="url != '/login' && url != '/writing/edit'" />
|
||||
<Operates v-show="url != '/login' && url != '/welcome' && url != '/writing/edit'" />
|
||||
<RouterView />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -8,14 +8,21 @@ export function fetchChatConfig<T>() {
|
||||
})
|
||||
}
|
||||
|
||||
// 登录
|
||||
// 登录(POST + body,避免密码出现在 URL/access log)
|
||||
export function fetchVerify<T>(data: object) {
|
||||
return get<T>({
|
||||
return post<T>({
|
||||
url: '/app/api/oauth/token',
|
||||
data,
|
||||
})
|
||||
}
|
||||
|
||||
// 退出登录:清后端 Redis 里的 token,返回 CAS server logout URL(前端跳过去清 SSO cookie)
|
||||
export function fetchLogout<T>() {
|
||||
return post<T>({
|
||||
url: '/app/api/logout',
|
||||
})
|
||||
}
|
||||
|
||||
// 获取用户信息
|
||||
export function fetchSession<T>() {
|
||||
return get<T>({
|
||||
|
||||
BIN
chat_web_front/src/assets/images/login/cassicLoginBg.jpg
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
@@ -7,13 +7,13 @@
|
||||
<path d="M109.69,12.44s3.91,7.74,7.82,10.54c.39,.28,.92,.27,1.31-.01,1.96-1.45,2.1-1.86,2.1-1.86,0,0-3.8-1.82-9.5-9.58-1.73,.41-1.73,.91-1.73,.91Z"/>
|
||||
<path d="M109.23,5.22h3.14s.24,10.69-8.85,17.49c-.4,.3-.93,.37-1.39,.17-.52-.23-.99-.57-.99-.57,0,0,7.02-4.62,8.09-17.09Z"/>
|
||||
</g>
|
||||
<g>
|
||||
<g fill="#636E77">
|
||||
<path d="M4.91,6.25c.18,0,.74-.6,.87-.71,.17,0,.65-.53,.79-.65,.32-.26,.63-.52,.95-.78,.56-.46,1.12-.92,1.69-1.38,.15,0,.72,1.25,.84,1.45,.45,.78,.9,1.56,1.35,2.35l-1.69,1.36c-1.6-.55-3.19-1.1-4.79-1.65Z"/>
|
||||
<path d="M13.06,5.43c-.5-1.29,.38-3.57,1.27-4.51C15.49-.3,17.51,0,19.03,.14c0,0-2.48,2.93-3.55,5.08-.82-.01-1.63,.02-2.42,.21Z"/>
|
||||
<path d="M17.66,4.96L25.6,.8s2.68,1.49,3.63,2.72c-1.4,.41-9.1,2.6-9.37,2.77-.88-.55-2.2-1.33-2.2-1.33Z"/>
|
||||
<polygon points="20.85 7.78 31.75 7.86 31.79 12.24 20.74 9.98 20.85 7.78"/>
|
||||
<path d="M19.56,13.33c-.17,0-.29,.12-.57,.33-.62,.47-1.24,.94-1.86,1.41,1.64,1.94,3.21,3.94,4.81,5.91,.62,.77,1.23,1.55,1.87,2.3,.97-.59,1.7-1.47,2.6-2.15,.32-.25,1.43-.76,1.45-1.22,0-.13-.86-.71-.99-.82-.42-.34-.84-.69-1.26-1.03-1.49-1.22-2.97-2.43-4.47-3.63-.4-.32-.81-.71-1.25-.98-.14-.08-.24-.12-.32-.13Z"/>
|
||||
<path d="M14.11,26.46l-.16-17.73c0-.06-2.36,.1-2.45,.11-1.79,.15-3.65,.33-5.45,.55-1.43,.18-3.03,.26-4.27,1.12C.46,11.43-.13,13.17,.02,14.73l.17,.55s3.27-1.55,3.58-1.7c.63-.3,1.26-.58,1.9-.84,.54-.22,1.32-.65,1.9-.64-.17,.37-.57,.73-.84,1.05l-1.51,1.81-3.24,3.89c-.48,.57-1.02,.92-.79,1.69,.43,1.47,1.54,2.63,2.77,3.5,1.21-1.91,4.39-7.62,6.15-10.35l-.89,12.76h4.89Z"/>
|
||||
<path fill="#FF2500" d="M14.11,26.46l-.16-17.73c0-.06-2.36,.1-2.45,.11-1.79,.15-3.65,.33-5.45,.55-1.43,.18-3.03,.26-4.27,1.12C.46,11.43-.13,13.17,.02,14.73l.17,.55s3.27-1.55,3.58-1.7c.63-.3,1.26-.58,1.9-.84,.54-.22,1.32-.65,1.9-.64-.17,.37-.57,.73-.84,1.05l-1.51,1.81-3.24,3.89c-.48,.57-1.02,.92-.79,1.69,.43,1.47,1.54,2.63,2.77,3.5,1.21-1.91,4.39-7.62,6.15-10.35l-.89,12.76h4.89Z"/>
|
||||
</g>
|
||||
<path d="M46.69,9.69v-1.09h-3.34v-3.7h-2.81V13.08h-2.44v7.1h8.57v-5.93c0-.61-.5-1.11-1.11-1.11h-2.21v-3.45h3.34Zm-3.32,4.42c.26,0,.46,.21,.46,.47v4.22c0,.26-.2,.46-.46,.46h-1.89c-.26,0-.47-.2-.47-.46v-4.22c0-.26,.21-.47,.47-.47h1.89Z"/>
|
||||
<path d="M47.63,4.92h2.83l3.11,16.59h3.2v1.19h-3.69c-.88,0-1.7-.42-2.22-1.13l-.05-.07c-.15-.21-.25-.44-.3-.69l-2.88-15.89Z"/>
|
||||
|
||||
|
Before Width: | Height: | Size: 5.5 KiB After Width: | Height: | Size: 5.6 KiB |
@@ -57,6 +57,7 @@ import { useAuthStore } from "@/store";
|
||||
import { reactive, watch, computed } from "vue";
|
||||
import { useRouter, useRoute } from "vue-router";
|
||||
import { User, Setting, SwitchButton, TopRight } from "@element-plus/icons-vue";
|
||||
import { fetchLogout } from "@/api";
|
||||
|
||||
const authStore = useAuthStore();
|
||||
const isAdmin = computed(() => authStore.session?.admind === true);
|
||||
@@ -112,9 +113,22 @@ const openExtLink = (url: string) => {
|
||||
window.open(url, '_blank');
|
||||
};
|
||||
|
||||
const quit = () => {
|
||||
const quit = async () => {
|
||||
// 调后端 logout:清 Redis token + 拿 CAS 服务器登出 URL
|
||||
// 拿到后跳过去清 CAS SSO cookie,否则下次"统一身份登录"会因为 cookie 还在直接静默登入
|
||||
let casLogoutUrl: string | null = null;
|
||||
try {
|
||||
const res: any = await fetchLogout();
|
||||
casLogoutUrl = res?.data?.casLogoutUrl || null;
|
||||
} catch (e) {
|
||||
console.warn("logout API 调用失败,本地清理后跳登录页", e);
|
||||
}
|
||||
authStore.removeToken();
|
||||
router.push("/login");
|
||||
if (casLogoutUrl) {
|
||||
window.location.href = casLogoutUrl;
|
||||
} else {
|
||||
router.push("/login");
|
||||
}
|
||||
};
|
||||
|
||||
const goProfile = () => {
|
||||
|
||||
@@ -7,13 +7,18 @@ const routes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: '/',
|
||||
name: '/',
|
||||
redirect: '/chat'
|
||||
redirect: '/welcome'
|
||||
},
|
||||
{
|
||||
path: '/login',
|
||||
name: 'Login',
|
||||
component: () => import('@/views/login/index.vue'),
|
||||
},
|
||||
{
|
||||
path: '/welcome',
|
||||
name: 'Welcome',
|
||||
component: () => import('@/views/welcome/index.vue'),
|
||||
},
|
||||
{
|
||||
path: '/chat',
|
||||
name: 'Chat',
|
||||
|
||||
@@ -1,9 +1,31 @@
|
||||
import type { Router } from 'vue-router'
|
||||
import { useAuthStore } from '@/store/modules/auth'
|
||||
|
||||
/**
|
||||
* 检查 URL search 部分有没有 cas_token(CAS 回调时后端把 token 拼在 query 里),
|
||||
* 有的话存下来并清掉 URL 上的痕迹。
|
||||
*/
|
||||
function consumeCasTokenFromUrl(authStore: ReturnType<typeof useAuthStore>) {
|
||||
const search = window.location.search || ''
|
||||
if (!search.includes('cas_token=')) return false
|
||||
const params = new URLSearchParams(search.replace(/^\?/, ''))
|
||||
const token = params.get('cas_token')
|
||||
if (!token) return false
|
||||
authStore.setToken(token)
|
||||
// 把 cas_token 从 URL 上清掉,留住 hash 部分
|
||||
const cleanUrl = window.location.pathname + window.location.hash
|
||||
window.history.replaceState({}, document.title, cleanUrl)
|
||||
return true
|
||||
}
|
||||
|
||||
export function setupPageGuard(router: Router) {
|
||||
router.beforeEach(async (to, from, next) => {
|
||||
const authStore = useAuthStore()
|
||||
// CAS 回调先拿 token,再走普通鉴权
|
||||
if (consumeCasTokenFromUrl(authStore)) {
|
||||
next({ path: '/welcome', replace: true })
|
||||
return
|
||||
}
|
||||
if (to.path == '/login') {
|
||||
next()
|
||||
return
|
||||
@@ -25,6 +47,7 @@ export function setupPageGuard(router: Router) {
|
||||
}
|
||||
next()
|
||||
} catch (error) {
|
||||
console.warn('[auth-guard] getSession 失败:', error)
|
||||
if (to.path === '/login') next()
|
||||
else next({ name: 'Login' })
|
||||
}
|
||||
|
||||
@@ -1,191 +1,356 @@
|
||||
<template>
|
||||
<div class="loginPage">
|
||||
<Waves></Waves>
|
||||
<div>
|
||||
<img :src="projectLogo" alt="" class="loginLogo" />
|
||||
</div>
|
||||
<div class="loginContent">
|
||||
<div class="loginTitle">
|
||||
<div>
|
||||
聚尖端之力,创多维平台
|
||||
<br />
|
||||
<span class="loginInfo">
|
||||
聚合科技动能,扩展创新疆界,引领行业跃迁升级
|
||||
</span>
|
||||
<div class="cassicLogin">
|
||||
<!-- 背景:CASSIC logo + 红金渐变 + 波纹粒子(图片直接做底) -->
|
||||
<div class="bgImage"></div>
|
||||
|
||||
<!-- 中央登录卡 -->
|
||||
<div class="loginCard">
|
||||
<h2 class="title">登录注册中心</h2>
|
||||
<div class="titleBar"></div>
|
||||
|
||||
<el-form
|
||||
ref="formRef"
|
||||
:model="form"
|
||||
:rules="rules"
|
||||
hide-required-asterisk
|
||||
class="form"
|
||||
@submit.prevent
|
||||
>
|
||||
<!-- 账号 -->
|
||||
<el-form-item prop="tel">
|
||||
<el-input
|
||||
v-model.trim="form.tel"
|
||||
:placeholder="loginMode === 'sms' ? '请输入手机号' : '请输入账号'"
|
||||
size="large"
|
||||
>
|
||||
<template #prefix>
|
||||
<span class="iconBox"><el-icon><User /></el-icon></span>
|
||||
</template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
|
||||
<!-- 密码 -->
|
||||
<el-form-item v-if="loginMode === 'password'" prop="password">
|
||||
<el-input
|
||||
v-model.trim="form.password"
|
||||
type="password"
|
||||
placeholder="请输入密码"
|
||||
size="large"
|
||||
show-password
|
||||
>
|
||||
<template #prefix>
|
||||
<span class="iconBox"><el-icon><Lock /></el-icon></span>
|
||||
</template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
|
||||
<!-- 验证码(图形 / 短信) -->
|
||||
<el-form-item prop="captcha" v-if="loginMode === 'sms' || showCaptcha">
|
||||
<div class="captchaRow">
|
||||
<el-input
|
||||
v-model.trim="form.captcha"
|
||||
:placeholder="loginMode === 'sms' ? '请输入短信验证码' : '请输入验证码'"
|
||||
size="large"
|
||||
>
|
||||
<template #prefix>
|
||||
<span class="iconBox"><el-icon><Stamp /></el-icon></span>
|
||||
</template>
|
||||
</el-input>
|
||||
<button
|
||||
type="button"
|
||||
class="captchaBtn"
|
||||
:disabled="smsCountdown > 0"
|
||||
@click="handleCaptcha"
|
||||
>
|
||||
{{ smsCountdown > 0 ? `${smsCountdown}s` : (loginMode === 'sms' ? '获取验证码' : '看不清') }}
|
||||
</button>
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<!-- 切换模式 -->
|
||||
<div class="switchModeRow">
|
||||
<a class="modeLink" @click="toggleMode">
|
||||
{{ loginMode === 'password' ? '短信验证登录' : '账号密码登录' }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="loginOperate">
|
||||
<img
|
||||
:src="projectLogo2"
|
||||
class="logoImg"
|
||||
alt=""
|
||||
/>
|
||||
<el-form
|
||||
ref="ruleFormRef"
|
||||
label-position="top"
|
||||
label-width="auto"
|
||||
:model="ruleForm"
|
||||
:rules="rules"
|
||||
style="width: 20vw; margin-top: 5%"
|
||||
hide-required-asterisk
|
||||
|
||||
<!-- 主登录按钮 -->
|
||||
<el-button
|
||||
class="primaryBtn"
|
||||
:loading="submitting"
|
||||
@click="handleSubmit"
|
||||
>
|
||||
<el-form-item label="手机号" prop="tel">
|
||||
<el-input v-model.trim="ruleForm.tel" />
|
||||
</el-form-item>
|
||||
<el-form-item label="密码" prop="password">
|
||||
<el-input v-model.trim="ruleForm.password" show-password />
|
||||
</el-form-item>
|
||||
<el-form-item prop="checked">
|
||||
<el-checkbox v-model="ruleForm.checked" label="" size="large" />
|
||||
<span
|
||||
>勾选即代表您阅读并同意<a
|
||||
target="_blank"
|
||||
href="http://www.metalinfo.cn/agreement.html?pageId=c03923c64e6b4d0896488212054b1742"
|
||||
style="color: #0969da"
|
||||
>《用户协议》</a
|
||||
></span
|
||||
>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button
|
||||
type="primary"
|
||||
style="width: 100%; margin-top: 20px"
|
||||
@click="submitForm(ruleFormRef)"
|
||||
:disabled="isDisabled"
|
||||
>
|
||||
{{ loginTip }}
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
登 录
|
||||
</el-button>
|
||||
|
||||
<!-- 统一身份登录 -->
|
||||
<el-button class="casBtn" @click="goCasLogin">
|
||||
统一身份登录(CASSIC)
|
||||
</el-button>
|
||||
</el-form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang='ts'>
|
||||
import { computed, ref, onMounted, reactive } from "vue";
|
||||
import projectLogo from "@/assets/images/login/projectLogo-white.svg";
|
||||
import projectLogo2 from "@/assets/images/login/projectLogo.svg";
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import type { FormInstance, FormRules } from "element-plus";
|
||||
import { ElMessage } from "element-plus";
|
||||
import { User, Lock, Stamp } from "@element-plus/icons-vue";
|
||||
import { fetchVerify } from "@/api";
|
||||
import { useAuthStore } from "@/store";
|
||||
import { useRouter } from "vue-router";
|
||||
import Waves from "../../components/Waves.vue";
|
||||
|
||||
interface RuleForm {
|
||||
interface LoginForm {
|
||||
tel: string;
|
||||
password: string;
|
||||
checked: boolean;
|
||||
captcha: string;
|
||||
loginType: number;
|
||||
}
|
||||
interface Token {
|
||||
token: string;
|
||||
refreshToken: string;
|
||||
expiresIn: number;
|
||||
}
|
||||
const ruleForm = reactive<RuleForm>({
|
||||
|
||||
const form = reactive<LoginForm>({
|
||||
tel: "",
|
||||
password: "",
|
||||
checked: true,
|
||||
captcha: "",
|
||||
loginType: 3,
|
||||
});
|
||||
const ruleFormRef = ref<FormInstance>();
|
||||
const authStore = useAuthStore();
|
||||
const router = useRouter();
|
||||
const isDisabled = ref<Boolean>(false);
|
||||
const loginTip = ref<string>("登录");
|
||||
|
||||
const validateChecked = (rule: any, value: any, callback: any) => {
|
||||
if (!value) {
|
||||
callback(new Error("请勾选“用户协议”"));
|
||||
const formRef = ref<FormInstance>();
|
||||
const router = useRouter();
|
||||
const authStore = useAuthStore();
|
||||
const submitting = ref(false);
|
||||
const loginMode = ref<"password" | "sms">("password"); // password = 账号密码登录;sms = 短信验证登录
|
||||
const showCaptcha = ref(false); // 账号密码模式下,可选图形验证码(暂未启用,留位)
|
||||
const smsCountdown = ref(0);
|
||||
|
||||
const rules = reactive<FormRules<LoginForm>>({
|
||||
tel: [{ required: true, message: "请输入账号 / 手机号", trigger: "blur" }],
|
||||
password: [{ required: true, message: "请输入密码", trigger: "blur" }],
|
||||
});
|
||||
|
||||
const toggleMode = () => {
|
||||
loginMode.value = loginMode.value === "password" ? "sms" : "password";
|
||||
form.captcha = "";
|
||||
};
|
||||
|
||||
let countdownTimer: number | null = null;
|
||||
const handleCaptcha = () => {
|
||||
if (loginMode.value === "sms") {
|
||||
if (!form.tel) {
|
||||
ElMessage.warning("请先输入手机号");
|
||||
return;
|
||||
}
|
||||
// 触发短信发送(后端接口待联调;先做倒计时占位)
|
||||
smsCountdown.value = 60;
|
||||
countdownTimer = window.setInterval(() => {
|
||||
smsCountdown.value -= 1;
|
||||
if (smsCountdown.value <= 0 && countdownTimer) {
|
||||
clearInterval(countdownTimer);
|
||||
countdownTimer = null;
|
||||
}
|
||||
}, 1000);
|
||||
ElMessage.info("短信验证码功能尚未接入后端,先用账号密码模式登录");
|
||||
} else {
|
||||
callback();
|
||||
ElMessage.info("图形验证码暂未启用");
|
||||
}
|
||||
};
|
||||
const rules = reactive<FormRules<RuleForm>>({
|
||||
tel: [{ required: true, message: "请输入手机号", trigger: "blur" }],
|
||||
password: [{ required: true, message: "请输入密码", trigger: "blur" }],
|
||||
checked: {
|
||||
required: true,
|
||||
message: "请勾选“用户协议”",
|
||||
trigger: "blur",
|
||||
validator: validateChecked,
|
||||
},
|
||||
});
|
||||
const submitForm = async (formEl: FormInstance | undefined) => {
|
||||
if (!formEl) return;
|
||||
await formEl.validate((valid, fields) => {
|
||||
if (valid) {
|
||||
isDisabled.value = true;
|
||||
loginTip.value = "登录中...";
|
||||
handleVerify();
|
||||
} else {
|
||||
console.log("error submit!", fields);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!formRef.value) return;
|
||||
await formRef.value.validate(async (valid) => {
|
||||
if (!valid) return;
|
||||
submitting.value = true;
|
||||
try {
|
||||
const res = await fetchVerify<{ token: string }>(form);
|
||||
if (res.code === 200) {
|
||||
authStore.setToken(res.data.token);
|
||||
router.push("/welcome");
|
||||
} else {
|
||||
ElMessage.error(res.msg || "登录失败");
|
||||
}
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
});
|
||||
};
|
||||
const handleVerify = async () => {
|
||||
try {
|
||||
fetchVerify<Token>(ruleForm).then((res) => {
|
||||
if (res.code === 200) {
|
||||
authStore.setToken(res.data.token);
|
||||
router.push("/chat");
|
||||
} else {
|
||||
isDisabled.value = false;
|
||||
loginTip.value = "登录";
|
||||
ElMessage.error(res.msg);
|
||||
}
|
||||
});
|
||||
} finally {
|
||||
}
|
||||
|
||||
const goCasLogin = () => {
|
||||
window.location.href = `/chat_web_backend/cas/login`;
|
||||
};
|
||||
|
||||
// CAS 回调:URL 含 cas_token → 存 token + 跳 welcome
|
||||
// 注意 hash 路由下 token 在 hash 里(#/login?cas_token=xxx),不在 location.search
|
||||
onMounted(() => {
|
||||
const hash = window.location.hash || "";
|
||||
const qIdx = hash.indexOf("?");
|
||||
const queryStr = qIdx >= 0 ? hash.substring(qIdx + 1) : window.location.search.replace(/^\?/, "");
|
||||
const params = new URLSearchParams(queryStr);
|
||||
const casToken = params.get("cas_token");
|
||||
if (casToken) {
|
||||
authStore.setToken(casToken);
|
||||
// 清掉 URL 上的 cas_token 参数
|
||||
window.history.replaceState(
|
||||
{},
|
||||
document.title,
|
||||
window.location.pathname + (qIdx >= 0 ? hash.substring(0, qIdx) : window.location.hash)
|
||||
);
|
||||
router.push("/welcome");
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.loginPage {
|
||||
.cassicLogin {
|
||||
position: relative;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
background-image: url("../../assets/images/login/loginBg.png");
|
||||
background-size: cover;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.loginLogo {
|
||||
|
||||
.bgImage {
|
||||
position: absolute;
|
||||
height: 6.1vh;
|
||||
margin: 2rem;
|
||||
inset: 0;
|
||||
background-image: url("../../assets/images/login/cassicLoginBg.jpg");
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
z-index: 0;
|
||||
}
|
||||
.loginContent {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
align-items: center;
|
||||
|
||||
.loginCard {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
width: 460px;
|
||||
padding: 40px 40px 36px;
|
||||
border-radius: 8px;
|
||||
background: rgba(255, 255, 255, 0.85);
|
||||
box-shadow: 0 12px 40px rgba(120, 0, 0, 0.18);
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
.loginTitle {
|
||||
|
||||
.title {
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
font-size: 26px;
|
||||
font-weight: 700;
|
||||
font-size: 7vh;
|
||||
color: white;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
color: #c10b08;
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
.loginInfo {
|
||||
font-weight: 300;
|
||||
font-size: 2vh;
|
||||
|
||||
.titleBar {
|
||||
width: 36px;
|
||||
height: 3px;
|
||||
background: #c10b08;
|
||||
border-radius: 2px;
|
||||
margin: 12px auto 28px;
|
||||
}
|
||||
.loginOperate {
|
||||
background: white;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-radius: 0.75rem;
|
||||
align-items: center;
|
||||
padding: 4vh;
|
||||
.logoImg {
|
||||
height: 5vh;
|
||||
|
||||
.form {
|
||||
:deep(.el-form-item) {
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
:deep(.el-input__wrapper) {
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 0 0 1px #f3d2cf inset;
|
||||
background: #fff;
|
||||
padding: 4px 12px;
|
||||
}
|
||||
|
||||
:deep(.el-input__wrapper.is-focus) {
|
||||
box-shadow: 0 0 0 1px #c10b08 inset;
|
||||
}
|
||||
}
|
||||
::v-deep .is-required .el-form-item__label::after {
|
||||
content: "*";
|
||||
color: #ff0000;
|
||||
margin-left: 4px;
|
||||
|
||||
.iconBox {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 4px;
|
||||
background: #c10b08;
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
margin-right: 4px;
|
||||
i {
|
||||
font-style: normal;
|
||||
}
|
||||
}
|
||||
|
||||
.captchaRow {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
gap: 10px;
|
||||
align-items: stretch;
|
||||
.el-input {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.captchaBtn {
|
||||
flex: 0 0 110px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background: #f3d2cf;
|
||||
color: #c10b08;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
.captchaBtn:hover:not(:disabled) {
|
||||
background: #e8b9b6;
|
||||
}
|
||||
.captchaBtn:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.switchModeRow {
|
||||
margin: 4px 0 18px;
|
||||
}
|
||||
.modeLink {
|
||||
font-size: 13px;
|
||||
color: #c10b08;
|
||||
cursor: pointer;
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
.primaryBtn {
|
||||
width: 100%;
|
||||
height: 48px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background: #c10b08;
|
||||
color: #fff;
|
||||
font-size: 18px;
|
||||
letter-spacing: 8px;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: #a30907;
|
||||
}
|
||||
}
|
||||
|
||||
.casBtn {
|
||||
width: 100%;
|
||||
height: 44px;
|
||||
margin-top: 12px;
|
||||
margin-left: 0;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #c10b08;
|
||||
background: rgba(255, 255, 255, 0.6);
|
||||
color: #c10b08;
|
||||
font-size: 15px;
|
||||
|
||||
&:hover {
|
||||
background: rgba(255, 255, 255, 0.85);
|
||||
border-color: #a30907;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
220
chat_web_front/src/views/welcome/index.vue
Normal file
@@ -0,0 +1,220 @@
|
||||
<template>
|
||||
<div class="welcomePage">
|
||||
<Waves color="#ffd060"></Waves>
|
||||
<img :src="projectLogo" alt="" class="welcomeLogo" />
|
||||
<div class="welcomeContent">
|
||||
<div class="welcomeTitle">
|
||||
<div>
|
||||
聚尖端之力,创多维平台
|
||||
<br />
|
||||
<span class="welcomeInfo">
|
||||
聚合科技动能,扩展创新疆界,引领行业跃迁升级
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="enterAction">
|
||||
<button class="glassBtn" @click="goChat">
|
||||
<span>立即体验</span>
|
||||
<i class="arrow">→</i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useRouter } from "vue-router";
|
||||
import projectLogo from "@/assets/images/login/projectLogo-white.svg";
|
||||
import Waves from "../../components/Waves.vue";
|
||||
|
||||
const router = useRouter();
|
||||
const goChat = () => {
|
||||
router.push("/chat");
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.welcomePage {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
background:
|
||||
/* 中心更亮的径向高光(让画面有"光源" + 立体感) */
|
||||
radial-gradient(
|
||||
ellipse 80% 60% at 50% 38%,
|
||||
rgba(255, 220, 130, 0.18) 0%,
|
||||
rgba(255, 80, 50, 0) 55%
|
||||
),
|
||||
/* 主红渐变(深红 → 中红 → 深红,营造曲面感) */
|
||||
linear-gradient(
|
||||
135deg,
|
||||
#8a0a05 0%,
|
||||
#c10b08 35%,
|
||||
#d6201a 50%,
|
||||
#c10b08 65%,
|
||||
#8a0a05 100%
|
||||
);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
/* 底部金色光带(CASSIC 风格的金色波纹) */
|
||||
.welcomePage::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: -10%;
|
||||
right: -10%;
|
||||
bottom: 0;
|
||||
height: 36%;
|
||||
background:
|
||||
radial-gradient(
|
||||
ellipse 60% 100% at 30% 90%,
|
||||
rgba(255, 200, 90, 0.45) 0%,
|
||||
rgba(255, 200, 90, 0) 60%
|
||||
),
|
||||
radial-gradient(
|
||||
ellipse 70% 80% at 75% 100%,
|
||||
rgba(255, 215, 110, 0.35) 0%,
|
||||
rgba(255, 215, 110, 0) 60%
|
||||
);
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
}
|
||||
.welcomeLogo {
|
||||
position: absolute;
|
||||
height: 6.1vh;
|
||||
margin: 2rem;
|
||||
}
|
||||
.welcomeContent {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
align-items: center;
|
||||
z-index: 1;
|
||||
}
|
||||
.welcomeTitle {
|
||||
font-weight: 700;
|
||||
font-size: 7vh;
|
||||
color: white;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.welcomeInfo {
|
||||
font-weight: 300;
|
||||
font-size: 2vh;
|
||||
}
|
||||
.enterAction {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
/* 平面玻璃片:均匀透明 + 重模糊 + 极细单色边沿,不模拟立体凸起 */
|
||||
.glassBtn {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 18px 46px;
|
||||
font-size: 22px;
|
||||
font-weight: 500;
|
||||
color: rgba(255, 255, 255, 0.95);
|
||||
letter-spacing: 0.5px;
|
||||
|
||||
/* 玻璃本体:12% 白 — 透明感主导,本身几乎隐形 */
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
/* 重磨砂打散后景,不上 saturate 避免蓝被加重 */
|
||||
backdrop-filter: blur(28px);
|
||||
-webkit-backdrop-filter: blur(28px);
|
||||
|
||||
/* 边框:更亮,整圈勾出玻璃片的轮廓 */
|
||||
border: 1px solid rgba(255, 255, 255, 0.5);
|
||||
border-radius: 999px;
|
||||
|
||||
cursor: pointer;
|
||||
transition:
|
||||
transform 0.45s cubic-bezier(0.22, 1, 0.36, 1),
|
||||
background 0.35s ease,
|
||||
border-color 0.35s ease,
|
||||
box-shadow 0.45s ease;
|
||||
|
||||
/* 关键:靠 inset 高光"勾形",不靠白底覆盖
|
||||
- 顶 inset 较亮(玻璃上沿折射到的光,定义"这是玻璃片的上边")
|
||||
- 底 inset 微暗(玻璃下沿) */
|
||||
box-shadow:
|
||||
inset 0 1.5px 1px rgba(255, 255, 255, 0.65),
|
||||
inset 0 -1px 1px rgba(0, 0, 0, 0.08),
|
||||
0 2px 4px rgba(0, 0, 0, 0.1),
|
||||
0 10px 26px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
/* 横扫光:玻璃面被光划过的一瞬,慢且稀疏 */
|
||||
.glassBtn::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: -120%;
|
||||
width: 35%;
|
||||
height: 100%;
|
||||
background: linear-gradient(
|
||||
100deg,
|
||||
transparent 0%,
|
||||
rgba(255, 255, 255, 0) 35%,
|
||||
rgba(255, 255, 255, 0.35) 50%,
|
||||
rgba(255, 255, 255, 0) 65%,
|
||||
transparent 100%
|
||||
);
|
||||
filter: blur(3px);
|
||||
transform: skewX(-20deg);
|
||||
animation: glassShimmer 6s ease-in-out infinite;
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
/* 内容层级在扫光之上 */
|
||||
.glassBtn > * {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.glassBtn:hover {
|
||||
background: rgba(255, 255, 255, 0.18);
|
||||
border-color: rgba(255, 255, 255, 0.7);
|
||||
transform: translateY(-1px);
|
||||
box-shadow:
|
||||
inset 0 2px 1px rgba(255, 255, 255, 0.8),
|
||||
inset 0 -1px 1px rgba(0, 0, 0, 0.1),
|
||||
0 4px 8px rgba(0, 0, 0, 0.14),
|
||||
0 14px 30px rgba(0, 0, 0, 0.26);
|
||||
}
|
||||
.glassBtn:hover .arrow {
|
||||
transform: translateX(5px);
|
||||
border-color: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
.glassBtn:active {
|
||||
transform: translateY(0);
|
||||
transition-duration: 0.1s;
|
||||
}
|
||||
.glassBtn .arrow {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
font-style: normal;
|
||||
font-size: 16px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.35);
|
||||
border-radius: 50%;
|
||||
transition: all 0.4s cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
|
||||
/* 横扫光感动画(6s 周期,划过 35% → 220%) */
|
||||
@keyframes glassShimmer {
|
||||
0% {
|
||||
left: -120%;
|
||||
}
|
||||
55%, 100% {
|
||||
left: 220%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -35,6 +35,26 @@ export default defineConfig((env) => {
|
||||
[viteEnv.VITE_GLOB_API_CTX]: {
|
||||
target: viteEnv.VITE_GLOB_API_DEV_IP,
|
||||
changeOrigin: true,
|
||||
// 转发原 Host,让后端能用 X-Forwarded-Host 拼出用户浏览器的实际 URL
|
||||
configure: (proxy) => {
|
||||
proxy.on('proxyReq', (proxyReq, req) => {
|
||||
if (req.headers.host) proxyReq.setHeader('X-Forwarded-Host', req.headers.host)
|
||||
proxyReq.setHeader('X-Forwarded-Proto', 'http')
|
||||
})
|
||||
},
|
||||
},
|
||||
// CAS 回调:service URL 形如 /metalinfo/chat_web_backend/cas/login?ticket=xxx
|
||||
// 把 /metalinfo 前缀去掉转给后端,否则 vite 当前端路由吃掉
|
||||
[`${viteEnv.VITE_GLOB_FRONT_CTX}${viteEnv.VITE_GLOB_API_CTX}`]: {
|
||||
target: viteEnv.VITE_GLOB_API_DEV_IP,
|
||||
changeOrigin: true,
|
||||
rewrite: (p) => p.replace(new RegExp(`^${viteEnv.VITE_GLOB_FRONT_CTX}`), ''),
|
||||
configure: (proxy) => {
|
||||
proxy.on('proxyReq', (proxyReq, req) => {
|
||||
if (req.headers.host) proxyReq.setHeader('X-Forwarded-Host', req.headers.host)
|
||||
proxyReq.setHeader('X-Forwarded-Proto', 'http')
|
||||
})
|
||||
},
|
||||
},
|
||||
// 工具服务通过 Nginx(:18000) 反代,sub_filter 处理子资源路径
|
||||
'/pdf/': {
|
||||
|
||||
13
images/1.svg
Normal file
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg id="_图层_2" data-name="图层 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 31.79 26.46">
|
||||
<g id="_图层_1-2" data-name="图层 1">
|
||||
<g>
|
||||
<path d="M4.91,6.25c.18,0,.74-.6,.87-.71,.17,0,.65-.53,.79-.65,.32-.26,.63-.52,.95-.78,.56-.46,1.12-.92,1.69-1.38,.15,0,.72,1.25,.84,1.45,.45,.78,.9,1.56,1.35,2.35l-1.69,1.36c-1.6-.55-3.19-1.1-4.79-1.65Z"/>
|
||||
<path d="M13.06,5.43c-.5-1.29,.38-3.57,1.27-4.51C15.49-.3,17.51,0,19.03,.14c0,0-2.48,2.93-3.55,5.08-.82-.01-1.63,.02-2.42,.21Z"/>
|
||||
<path d="M17.66,4.96L25.6,.8s2.68,1.49,3.63,2.72c-1.4,.41-9.1,2.6-9.37,2.77-.88-.55-2.2-1.33-2.2-1.33Z"/>
|
||||
<polygon points="20.85 7.78 31.75 7.86 31.79 12.24 20.74 9.98 20.85 7.78"/>
|
||||
<path d="M19.56,13.33c-.17,0-.29,.12-.57,.33-.62,.47-1.24,.94-1.86,1.41,1.64,1.94,3.21,3.94,4.81,5.91,.62,.77,1.23,1.55,1.87,2.3,.97-.59,1.7-1.47,2.6-2.15,.32-.25,1.43-.76,1.45-1.22,0-.13-.86-.71-.99-.82-.42-.34-.84-.69-1.26-1.03-1.49-1.22-2.97-2.43-4.47-3.63-.4-.32-.81-.71-1.25-.98-.14-.08-.24-.12-.32-.13Z"/>
|
||||
<path d="M14.11,26.46l-.16-17.73c0-.06-2.36,.1-2.45,.11-1.79,.15-3.65,.33-5.45,.55-1.43,.18-3.03,.26-4.27,1.12C.46,11.43-.13,13.17,.02,14.73l.17,.55s3.27-1.55,3.58-1.7c.63-.3,1.26-.58,1.9-.84,.54-.22,1.32-.65,1.9-.64-.17,.37-.57,.73-.84,1.05l-1.51,1.81-3.24,3.89c-.48,.57-1.02,.92-.79,1.69,.43,1.47,1.54,2.63,2.77,3.5,1.21-1.91,4.39-7.62,6.15-10.35l-.89,12.76h4.89Z"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
52
images/2.svg
Normal file
@@ -0,0 +1,52 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg id="_图层_2" data-name="图层 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 162.4 26.46">
|
||||
<g id="_图层_1-2" data-name="图层 1">
|
||||
<g>
|
||||
<g>
|
||||
<path d="M119.14,11.98h-17.36v-1.49h18.11v.73c0,.42-.34,.75-.75,.75Z"/>
|
||||
<path d="M109.69,12.44s3.91,7.74,7.82,10.54c.39,.28,.92,.27,1.31-.01,1.96-1.45,2.1-1.86,2.1-1.86,0,0-3.8-1.82-9.5-9.58-1.73,.41-1.73,.91-1.73,.91Z"/>
|
||||
<path d="M109.23,5.22h3.14s.24,10.69-8.85,17.49c-.4,.3-.93,.37-1.39,.17-.52-.23-.99-.57-.99-.57,0,0,7.02-4.62,8.09-17.09Z"/>
|
||||
</g>
|
||||
<g>
|
||||
<path d="M4.91,6.25c.18,0,.74-.6,.87-.71,.17,0,.65-.53,.79-.65,.32-.26,.63-.52,.95-.78,.56-.46,1.12-.92,1.69-1.38,.15,0,.72,1.25,.84,1.45,.45,.78,.9,1.56,1.35,2.35l-1.69,1.36c-1.6-.55-3.19-1.1-4.79-1.65Z"/>
|
||||
<path d="M13.06,5.43c-.5-1.29,.38-3.57,1.27-4.51C15.49-.3,17.51,0,19.03,.14c0,0-2.48,2.93-3.55,5.08-.82-.01-1.63,.02-2.42,.21Z"/>
|
||||
<path d="M17.66,4.96L25.6,.8s2.68,1.49,3.63,2.72c-1.4,.41-9.1,2.6-9.37,2.77-.88-.55-2.2-1.33-2.2-1.33Z"/>
|
||||
<polygon points="20.85 7.78 31.75 7.86 31.79 12.24 20.74 9.98 20.85 7.78"/>
|
||||
<path d="M19.56,13.33c-.17,0-.29,.12-.57,.33-.62,.47-1.24,.94-1.86,1.41,1.64,1.94,3.21,3.94,4.81,5.91,.62,.77,1.23,1.55,1.87,2.3,.97-.59,1.7-1.47,2.6-2.15,.32-.25,1.43-.76,1.45-1.22,0-.13-.86-.71-.99-.82-.42-.34-.84-.69-1.26-1.03-1.49-1.22-2.97-2.43-4.47-3.63-.4-.32-.81-.71-1.25-.98-.14-.08-.24-.12-.32-.13Z"/>
|
||||
<path d="M14.11,26.46l-.16-17.73c0-.06-2.36,.1-2.45,.11-1.79,.15-3.65,.33-5.45,.55-1.43,.18-3.03,.26-4.27,1.12C.46,11.43-.13,13.17,.02,14.73l.17,.55s3.27-1.55,3.58-1.7c.63-.3,1.26-.58,1.9-.84,.54-.22,1.32-.65,1.9-.64-.17,.37-.57,.73-.84,1.05l-1.51,1.81-3.24,3.89c-.48,.57-1.02,.92-.79,1.69,.43,1.47,1.54,2.63,2.77,3.5,1.21-1.91,4.39-7.62,6.15-10.35l-.89,12.76h4.89Z"/>
|
||||
</g>
|
||||
<path d="M46.69,9.69v-1.09h-3.34v-3.7h-2.81V13.08h-2.44v7.1h8.57v-5.93c0-.61-.5-1.11-1.11-1.11h-2.21v-3.45h3.34Zm-3.32,4.42c.26,0,.46,.21,.46,.47v4.22c0,.26-.2,.46-.46,.46h-1.89c-.26,0-.47-.2-.47-.46v-4.22c0-.26,.21-.47,.47-.47h1.89Z"/>
|
||||
<path d="M47.63,4.92h2.83l3.11,16.59h3.2v1.19h-3.69c-.88,0-1.7-.42-2.22-1.13l-.05-.07c-.15-.21-.25-.44-.3-.69l-2.88-15.89Z"/>
|
||||
<path d="M46.25,11.17s3.66,.11,8.06-1.4c.4-.01,.8-.01,1.2,0,.2,0,.4,0,.59,0,.1,0,.2,0,.3,0,.09,0,.18-.01,.27,0,.07,.01,.13,.05,.16,.12,.01,.04,0,.06-.03,.08-.55,.41-1.13,.76-1.78,1-.02,0-.04,.01-.06,.02-.23,.08-.46,.15-.69,.21-.32,.09-.64,.16-.96,.23-.42,.09-.85,.16-1.27,.23-.54,.08-1.07,.15-1.61,.21-.66,.07-1.32,.12-1.99,.17-.34,.02-.68,.04-1.03,.06-.1,0-1.1,.06-1.1,.04-.11-.83-.06-.96-.06-.96Z"/>
|
||||
<polygon points="51.95 6.57 53.16 5.28 55.75 6.93 54.1 8.96 51.95 6.57"/>
|
||||
<path d="M53.99,12.13l2.92,1.18s-3.85,7.29-10.4,9.3c-1.16-1.13-1.27-1.51-1.27-1.51,0,0,4.76-2.01,8.75-8.97Z"/>
|
||||
<path d="M61.29,4.5l1.16,.36,1.99,.63s-3.52,5.89-3.99,6.11-1.84-.61-1.84-.61l2.68-6.5Z"/>
|
||||
<path d="M62.9,8.85c-.07,.01-.05,2.52-.06,2.78-.11,3.63-.83,7.26-3.6,9.85-.04,.04-.31,.33-.39,.25l1.13,1.07s3.29-1.64,5.12-6.3c.85-2.17,1.49-5.04,1.18-7.98,0,0-3.39,.33-3.39,.33Z"/>
|
||||
<path d="M67.33,21.71l2.48-1.6s-3.25-3.77-4.65-3.61-.77,1.32-.77,1.32l2.94,3.88Z"/>
|
||||
<rect x="59.13" y="13.28" width="9.91" height="1.29"/>
|
||||
<rect x="62.22" y="7.67" width="6.47" height="1.21"/>
|
||||
<path d="M76.24,6.59h-6.73v14.76h8.45V8.31c0-.95-.77-1.72-1.72-1.72Zm-1.14,13.02c0,.23-.18,.42-.41,.42h-1.9c-.23,0-.41-.19-.41-.42V8.11c0-.23,.18-.41,.41-.41h1.9c.23,0,.41,.18,.41,.41v11.5Z"/>
|
||||
<rect x="88.14" y="12.65" width="2.81" height="2.64"/>
|
||||
<path d="M122.59,8.83h6.72v.4c0,.53-.43,.97-.97,.97h-5.75v-1.36h0Z"/>
|
||||
<path d="M124.65,4.9h2.52V22.22c0,.33-.27,.6-.6,.6h-1.31c-.33,0-.6-.27-.6-.6V4.9h0Z"/>
|
||||
<rect x="129.08" y="6.29" width="12.55" height="1.03"/>
|
||||
<rect x="144.94" y="5.74" width="9.72" height="1.03"/>
|
||||
<rect x="144.16" y="10.2" width="11.03" height="1.03"/>
|
||||
<rect x="146.47" y="17.28" width="13.48" height="1.03"/>
|
||||
<path d="M144.02,20.69h18.37v.45c0,.43-.35,.79-.79,.79h-17.58v-1.24h0Z"/>
|
||||
<rect x="129.08" y="17.34" width="12.55" height="1.03"/>
|
||||
<path d="M131.16,4.89l.58,4.47c0,.06,.07,.09,.12,.07l2.4-.88c.06-.02,.09-.09,.07-.15l-1.29-3.51h-1.87Z"/>
|
||||
<path d="M136.51,4.9l-.07,1.44-.54,2.41,1.13,.7s2.31-1.43,2.3-4.56h-2.82Z"/>
|
||||
<path d="M133.33,15.84s.72,3.52-4.57,6.06c.11,.83,.33,.83,1.1,.72s3.36-.83,5.28-3.14,1.27-3.74,1.27-3.74l-3.08,.11Z"/>
|
||||
<path d="M135.92,17.06s1.27,3.19,5.39,3.69c.44,.11-.94,2.15-.94,2.15,0,0-3.69-1.05-5.61-4.46,.72-.61,1.16-1.38,1.16-1.38Z"/>
|
||||
<path d="M124.71,10.09s-1.5,3.33-2.66,5.37c.22,2.53,.66,2.92,.66,2.92l2.59-3.52-.28-4.84-.32,.08Z"/>
|
||||
<path d="M126.62,13.37l1.82,1.93,1.6-1.27s-2.97-3.08-3.36-2.81-.06,2.15-.06,2.15Z"/>
|
||||
<path d="M139.53,9.76h-9.6v6.19h10.87v-4.92c0-.7-.57-1.27-1.27-1.27Zm-1.26,4.67c0,.31-.26,.56-.57,.56h-5.13c-.32-.01-.32-.25-.32-.56v-.75c0-.31,.01-.58,.33-.58h5.35c.31,0,.34,.27,.34,.58v.75Zm0-2.55c0,.31-.26,.57-.57,.57h-5.17c-.32,0-.28-.26-.28-.57v-.71c-.01-.32,0-.56,.31-.56l5.38-.02c.31,0,.32,.26,.32,.58v.71Z"/>
|
||||
<path d="M146.55,6.43s.58,5.92-2.5,9.06c.63,.69,1.07,.99,1.07,.99,0,0,5.06-3.91,4.4-10.05-.77-.55-2.97,0-2.97,0Z"/>
|
||||
<polygon points="150.87 6.41 150.87 15.51 153.67 15.51 153.67 6.35 150.87 6.41"/>
|
||||
<path d="M162.4,4.92V13.59c0,1.28-1.04,2.31-2.31,2.31h-2.56v-1.03h2.07V4.99l2.8-.07Z"/>
|
||||
<path d="M155.68,6.42v6.25c0,.25,.2,.45,.45,.45h1.91c.25,0,.45-.2,.45-.45V6.39c0-.25-.21-.45-.46-.45l-1.91,.03c-.25,0-.44,.2-.44,.45Z"/>
|
||||
<rect x="151.77" y="15.47" width="3.01" height="5.33"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 5.5 KiB |
28
images/3.svg
Normal file
@@ -0,0 +1,28 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg id="_图层_2" data-name="图层 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 31.79 46.77">
|
||||
<g id="_图层_1-2" data-name="图层 1">
|
||||
<g>
|
||||
<g>
|
||||
<path d="M9.21,2.74c.13,.23,.72,1.25,.84,1.45,.45,.78,.9,1.56,1.35,2.35l-1.69,1.36c-1.6-.55-3.19-1.1-4.79-1.65"/>
|
||||
<path d="M13.06,5.43c-.5-1.29,.38-3.57,1.27-4.51C15.49-.3,17.51,0,19.03,.14c0,0-2.48,2.93-3.55,5.08-.82-.01-1.63,.02-2.42,.21Z"/>
|
||||
<path d="M17.66,4.96L25.6,.8s2.68,1.49,3.63,2.72c-1.4,.41-9.1,2.6-9.37,2.77-.88-.55-2.2-1.33-2.2-1.33Z"/>
|
||||
<polygon points="20.85 7.78 31.75 7.86 31.79 12.24 20.74 9.98 20.85 7.78"/>
|
||||
<path d="M19.56,13.33c-.17,0-.29,.12-.57,.33-.62,.47-1.24,.94-1.86,1.41,1.64,1.94,3.21,3.94,4.81,5.91,.62,.77,1.23,1.55,1.87,2.3,.97-.59,1.7-1.47,2.6-2.15,.32-.25,1.43-.76,1.45-1.22,0-.13-.86-.71-.99-.82-.42-.34-.84-.69-1.26-1.03-1.49-1.22-2.97-2.43-4.47-3.63-.4-.32-.81-.71-1.25-.98-.14-.08-.24-.12-.32-.13Z"/>
|
||||
<path d="M14.11,26.46s-.16-17.73-.16-17.73c0-.06-2.36,.1-2.45,.11-1.79,.15-3.65,.33-5.45,.55-1.43,.18-3.03,.26-4.27,1.12C.46,11.43-.13,13.17,.02,14.73l.17,.55s3.27-1.55,3.58-1.7c.63-.3,1.26-.58,1.9-.84,.54-.22,1.32-.65,1.9-.64-.17,.37-.57,.73-.84,1.05l-1.51,1.81-3.24,3.89c-.48,.57-1.02,.92-.79,1.69,.43,1.47,1.54,2.63,2.77,3.5,1.21-1.91,4.39-7.62,6.15-10.35l-.89,12.76h4.89Z"/>
|
||||
</g>
|
||||
<g>
|
||||
<path d="M7.06,36.55v-.85h-2.6v-2.88H2.27v6.37H.37v5.53H7.04v-4.62c0-.48-.39-.86-.86-.86h-1.72v-2.69h2.6Zm-2.59,3.44c.2,0,.36,.16,.36,.37v3.29c0,.2-.16,.36-.36,.36h-1.47c-.2,0-.37-.16-.37-.36v-3.29c0-.2,.16-.37,.37-.37h1.47Z"/>
|
||||
<path d="M7.79,32.83h2.21l2.42,12.92h2.49v.93h-2.88c-.68,0-1.33-.33-1.73-.88l-.04-.05c-.12-.16-.2-.35-.23-.54l-2.24-12.38Z"/>
|
||||
<path d="M6.72,37.7s2.85,.09,6.28-1.09c.31,0,.62,0,.94,0,.15,0,.31,0,.46,0,.08,0,.15,0,.23,0,.07,0,.14,0,.21,0,.06,.01,.1,.04,.13,.09,.01,.03,0,.04-.03,.06-.43,.32-.88,.59-1.39,.78-.02,0-.03,.01-.05,.02-.18,.06-.36,.11-.54,.16-.25,.07-.5,.13-.75,.18-.33,.07-.66,.13-.99,.18-.42,.06-.84,.12-1.26,.16-.52,.05-1.03,.1-1.55,.13-.27,.02-.53,.03-.8,.05-.07,0-.85,.05-.86,.03-.09-.64-.04-.75-.04-.75Z"/>
|
||||
<polygon points="11.16 34.12 12.1 33.11 14.12 34.39 12.83 35.98 11.16 34.12"/>
|
||||
<path d="M12.75,38.45l2.27,.92s-3,5.68-8.11,7.25c-.9-.88-.99-1.18-.99-1.18,0,0,3.71-1.57,6.82-6.99Z"/>
|
||||
<path d="M18.44,32.5l.91,.28,1.55,.49s-2.74,4.59-3.11,4.76-1.44-.47-1.44-.47l2.09-5.06Z"/>
|
||||
<path d="M19.69,35.9c-.06,0-.04,1.97-.05,2.16-.09,2.83-.64,5.66-2.8,7.68-.03,.03-.24,.25-.3,.2l.88,.84s2.56-1.28,3.99-4.91c.66-1.69,1.16-3.92,.92-6.22,0,0-2.64,.26-2.64,.26Z"/>
|
||||
<path d="M23.15,45.91l1.93-1.24s-2.53-2.94-3.62-2.81-.6,1.03-.6,1.03l2.29,3.02Z"/>
|
||||
<rect x="16.76" y="39.35" width="7.72" height="1.01"/>
|
||||
<rect x="19.16" y="34.97" width="5.04" height=".94"/>
|
||||
<path d="M30.08,34.13h-5.24v11.5h6.58v-10.16c0-.74-.6-1.34-1.34-1.34Zm-.89,10.14c0,.18-.14,.33-.32,.33h-1.48c-.18,0-.32-.15-.32-.33v-8.96c0-.18,.14-.32,.32-.32h1.48c.18,0,.32,.14,.32,.32v8.96Z"/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.0 KiB |
@@ -75,7 +75,7 @@ MAX_TOKENS = None
|
||||
MAX_CUT_TOKENS = 30 * 1024
|
||||
|
||||
TEMPERATURE = 0.7
|
||||
DEEPSEEK_MODELS = ["deepseek-reasoner", "deepseek-chat"]
|
||||
DEEPSEEK_MODELS = ["deepseek-r1", "deepseek-reasoner", "deepseek-chat"]
|
||||
CAST_MODELS = ["kexie_0.5b"]
|
||||
ONLINE_LLM_MODEL = {
|
||||
# 本地部署的大模型 API (10.102.24.75:3000)
|
||||
|
||||
@@ -552,28 +552,28 @@ PROMPT_TEMPLATES = {
|
||||
,
|
||||
|
||||
"Topic Recommend Assistant":
|
||||
'<角色> 你是由浪潮开发的选题推荐助手。</角色> \n\n'
|
||||
'<|im_start|>system 今天的日期为:{{time}} <|im_end|>'
|
||||
'<角色> 你是由浪潮开发的选题推荐助手。</角色>\n\n'
|
||||
'今天的日期为:{{time}}\n'
|
||||
'<选题样例>'
|
||||
'**有色金属尾矿等大宗固废资源化及综合治理模式**'
|
||||
'**机械产品的数字化设计与制造战略研究**'
|
||||
'**材料延寿与可持续发展战略研究综合报告**'
|
||||
'**民营科技企业创新机制研究**'
|
||||
'**水产养殖业“十四五"规划战略研究报告 **'
|
||||
'**污水资源化能源化的工程科技发展与战略研究污水资源化能源化的工程科技发展与战略研究报告**'
|
||||
'**水产养殖业"十四五"规划战略研究报告**'
|
||||
'**污水资源化能源化的工程科技发展与战略研究综合报告**'
|
||||
'**油价大幅波动情景下我国的油气勘探战略研究**'
|
||||
'**洞庭湖大水脉研究咨询报告**'
|
||||
'**流程工业与循环经济**'
|
||||
'**流程工业装备绿色化、智能化与在役再制造“海上风电场建设重大工程问题研究"咨询项目研究结题报告 **'
|
||||
'**流程工业装备绿色化、智能化与在役再制造**'
|
||||
'**海上风电场建设重大工程问题研究咨询项目研究结题报告**'
|
||||
'</选题样例>\n'
|
||||
'你的任务是根据user输入的方向或者主题,依据多学科研究现状、前沿动态和发展趋势,为科研人员提供选题推荐。\n'
|
||||
'注意!当user不明确的话只推荐一个选题!!\n'
|
||||
'【重要指令】:如果用户的问题不是一个选题推荐的请求,禁止使用工具!!!然后你要给出友好回复,比如"抱歉,我无法回答这个问题"。\n'
|
||||
'【重要指令】:如果用户的问题是一个选题推荐的请求,请使用工具!!!\n'
|
||||
'【重要指令】:你只能回答有关选题推荐的问题!!!!!!如果回答了别的问题,会有十分恶劣的后果!!!!!\n'
|
||||
'现在开始:\n'
|
||||
'<|im_start|>user {{input}} <|im_end|>'
|
||||
'<|im_start|>assistant <|im_end|>\n'
|
||||
'你的任务是根据用户输入的方向或主题,依据多学科研究现状、前沿动态和发展趋势,为科研人员提供选题推荐。\n'
|
||||
'注意:当用户问题不明确时,只推荐一个选题;当用户明确要求多个时,按要求数量给出。\n'
|
||||
'【行为准则】:\n'
|
||||
'1. 用户问题与"研究方向/选题/课题/报告/趋势/调研"等沾边时,使用工具检索资料后给出有帮助的回答。\n'
|
||||
'2. 即使用户的问法不是"推荐选题"原话(如"写一份研究报告"、"有哪些研究重点"),也应理解为相关需求并提供选题建议或研究方向梳理。\n'
|
||||
'3. 仅当用户问题明显与科研选题/研究方向无关(如闲聊、生活问题、纯技术求解),才礼貌说明本助手专注于科研选题推荐。\n'
|
||||
'用户问题:{{input}}\n'
|
||||
,
|
||||
|
||||
"Topic Recommend Assistant_with_history":
|
||||
|
||||
@@ -111,9 +111,11 @@ def rag_search(query: str,uid):
|
||||
knowledge_base_name=knownledge,
|
||||
expr=expr_param
|
||||
)
|
||||
|
||||
logging.info(f"[RAG诊断] kb={knownledge!r} expr={expr_param!r} 召回 {len(doc_list)} docs")
|
||||
|
||||
if len(doc_list)==0:
|
||||
return result,source_docs
|
||||
# 修 bug: 原代码 return 导致首个 KB 空就放弃全部 KB;改 continue 继续尝试下一个
|
||||
continue
|
||||
titles = temp["title"]
|
||||
doc_list,title = utils.remove_docs1(titles,doc_list)
|
||||
titles.extend(title)
|
||||
@@ -264,12 +266,15 @@ def search_tool(query: str):
|
||||
result3 = []
|
||||
# 获取结果
|
||||
result1,sourcedocs = future1.result()
|
||||
# 诊断:看 rag_search 实际召回多少
|
||||
logging.info(f"[RAG诊断] rag_search 返回 result1={len(result1) if isinstance(result1, list) else type(result1).__name__}, sourcedocs={len(sourcedocs) if isinstance(sourcedocs, list) else type(sourcedocs).__name__}, kb={search.get('knowledge_name')}, query={search.get('query')!r}")
|
||||
result2 = {}
|
||||
if "type" in utils.get_shared_variable(time_based_uuid):
|
||||
result2[0] =[]
|
||||
result2[1] = []
|
||||
else:
|
||||
result2 = future2.result()
|
||||
logging.info(f"[RAG诊断] zhipu 返回 result2[0]={len(result2[0]) if isinstance(result2[0], list) else type(result2[0]).__name__}, result2[1]={len(result2[1]) if isinstance(result2[1], list) else type(result2[1]).__name__}")
|
||||
# if "type" in utils.get_shared_variable(time_based_uuid):
|
||||
# result2[0] =[]
|
||||
# result2[1] = []
|
||||
|
||||
177
langchain-chat/server/chat/agent_v2.py
Normal file
@@ -0,0 +1,177 @@
|
||||
"""
|
||||
LangGraph 版 Agent runner。
|
||||
|
||||
替代旧的 agent_chat_test 内核:
|
||||
- 不再用 LLM 做 step routing(thinking/select_tool/answer),让模型 function-calling 自己决定
|
||||
- 同一轮的多个 tool_calls 自动并行(ToolNode)
|
||||
- 把 LangGraph 事件流映射到现有前端协议({"text":...}/{"docs":...}/{"detail":...})
|
||||
|
||||
输入:query + history + uuid + model_name
|
||||
输出:和旧版 agent_chat_test 一样的 dict 序列("answer"/"docs"/"detail"/...)
|
||||
"""
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from typing import AsyncIterable, List, Optional
|
||||
|
||||
from langgraph.prebuilt import create_react_agent
|
||||
from langchain_core.messages import HumanMessage, AIMessage, ToolMessage
|
||||
from langchain_openai import ChatOpenAI
|
||||
|
||||
from configs import LLM_MODELS, prompt_config
|
||||
from server.utils import get_prompt_template, get_model_worker_config
|
||||
from server.chat import utils as shared_utils
|
||||
from server.chat.tools_v2 import make_tools
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
_CHAT_MARKER_RE = re.compile(r"<\|im_(?:start|end)\|>")
|
||||
|
||||
|
||||
def _strip_chat_markers(text: str) -> str:
|
||||
"""剥掉 prompt 内嵌的 Qwen chat template 标记,避免模型 echo 泄漏到答案。"""
|
||||
return _CHAT_MARKER_RE.sub("", text or "")
|
||||
|
||||
|
||||
def _build_system_prompt(user_prompt_name: str, query: str, think_content: str) -> str:
|
||||
"""复用旧版 Think Test Bak + 用户业务 prompt 的拼装逻辑,但简化为单条 system message。"""
|
||||
user = get_prompt_template("llm_chat", user_prompt_name) if user_prompt_name else ""
|
||||
user = _strip_chat_markers(user)
|
||||
think_content = _strip_chat_markers(think_content)
|
||||
|
||||
parts = []
|
||||
parts.append("你是浪潮开发的智能专家。回答用户问题前可以使用工具检索资料。")
|
||||
parts.append("严格要求:")
|
||||
parts.append("1. 优先使用工具获取资料后再回答,禁止虚构内容")
|
||||
parts.append("2. 同一个工具同一参数禁止反复调用超过 2 次")
|
||||
parts.append("3. 回答时必须基于工具返回的资料,引用要标注【】序号")
|
||||
parts.append("4. 涉及国家政策优先用 知识库联想 + 政策库")
|
||||
parts.append("5. 答案紧扣用户问题,不要主观臆想")
|
||||
parts.append("")
|
||||
parts.append(f"思考提示:{think_content}")
|
||||
parts.append("")
|
||||
if user:
|
||||
parts.append(f"业务约束:{user}")
|
||||
return "\n".join(parts)
|
||||
|
||||
|
||||
def _convert_history(history: list) -> list:
|
||||
"""把 chat_test.py 的 history list(dict role/content)转成 LangChain messages。"""
|
||||
msgs = []
|
||||
for h in history or []:
|
||||
role = h.get("role")
|
||||
content = h.get("content", "")
|
||||
if role == "user":
|
||||
msgs.append(("user", content))
|
||||
elif role == "assistant":
|
||||
msgs.append(("assistant", content))
|
||||
return msgs
|
||||
|
||||
|
||||
async def agent_run(
|
||||
*,
|
||||
query: str,
|
||||
uuid: str,
|
||||
history: Optional[list] = None,
|
||||
model_name: str = None,
|
||||
temperature: float = 0.3,
|
||||
max_tokens: Optional[int] = None,
|
||||
user_prompt_name: str = "",
|
||||
think_content: str = "",
|
||||
) -> AsyncIterable[str]:
|
||||
"""运行 LangGraph agent,yield 事件 JSON 字符串。
|
||||
|
||||
yield 协议(向后兼容 chat_test.py 的消费逻辑):
|
||||
{"text": str} → 思考框/答案框文本(按出现位置区分)
|
||||
{"answer": str} → token 级答案流(chat_test 包装为 {"text":...})
|
||||
{"docs": str} → 工具返回的资料文档(参考文献区)
|
||||
{"detail": str} → 详细资料累积(detail_answer 用)
|
||||
{"tool_start": dict} → 调试/日志:工具开始
|
||||
{"tool_end": dict} → 调试/日志:工具结束
|
||||
"""
|
||||
model_name = model_name or LLM_MODELS[0]
|
||||
# 必须用 langchain_openai.ChatOpenAI(支持现代 tool calling 协议)
|
||||
# 不能用 server.utils.get_ChatOpenAI(返回 langchain_community 老版,不支持 bind_tools)
|
||||
cfg = get_model_worker_config(model_name)
|
||||
llm = ChatOpenAI(
|
||||
model=model_name,
|
||||
base_url=cfg.get("api_base_url"),
|
||||
api_key=cfg.get("api_key", "EMPTY"),
|
||||
temperature=temperature,
|
||||
max_tokens=max_tokens,
|
||||
streaming=True,
|
||||
)
|
||||
|
||||
tools = make_tools(uuid)
|
||||
|
||||
# 用 Think Test Bak + user_prompt 构造 system message
|
||||
system_prompt = _build_system_prompt(user_prompt_name, query, think_content)
|
||||
agent = create_react_agent(llm, tools=tools, messages_modifier=system_prompt)
|
||||
|
||||
msgs = _convert_history(history)
|
||||
msgs.append(("user", query))
|
||||
inputs = {"messages": msgs}
|
||||
config = {"recursion_limit": 12} # 最多 12 步(远小于旧版 11 次外层 × N 内层)
|
||||
|
||||
answer_buf = []
|
||||
try:
|
||||
async for ev in agent.astream_events(inputs, config=config, version="v1"):
|
||||
# 检查停止信号
|
||||
if not shared_utils.get_shared_variable(uuid).get("status", True):
|
||||
logger.info("Agent 收到停止信号")
|
||||
break
|
||||
|
||||
kind = ev["event"]
|
||||
name = ev.get("name", "")
|
||||
|
||||
if kind == "on_chat_model_stream":
|
||||
chunk = ev["data"]["chunk"]
|
||||
content = chunk.content or ""
|
||||
if content:
|
||||
answer_buf.append(content)
|
||||
yield json.dumps({"answer": content}, ensure_ascii=False)
|
||||
|
||||
elif kind == "on_tool_start":
|
||||
tool_input = ev["data"].get("input", {})
|
||||
logger.info(f"工具调用开始: {name}({tool_input})")
|
||||
# 工具说明落到思考框(前端的 thinking 区域)
|
||||
yield json.dumps(
|
||||
{"think": f"\n→ 调用工具:{name}\n"},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
||||
elif kind == "on_tool_end":
|
||||
output = str(ev["data"].get("output", ""))
|
||||
logger.info(f"工具调用结束: {name} → {len(output)} chars")
|
||||
|
||||
# 知识库联想 / 联网思索 → 提取 source_docs 给前端参考文献区
|
||||
if name in ("知识库联想", "联网思索"):
|
||||
source = shared_utils.get_shared_variable(uuid)
|
||||
source_docs = source.get("source_docs", [])
|
||||
if source_docs:
|
||||
try:
|
||||
docs_string = "\n" + "\n".join(f"{str(d)}\n" for d in source_docs)
|
||||
yield json.dumps({"docs": docs_string}, ensure_ascii=False)
|
||||
except Exception:
|
||||
logger.exception("docs 序列化失败")
|
||||
|
||||
# detail(详细搜索内容)累积到 docs_detail,给后续幻觉校验用
|
||||
if name in ("知识库联想", "联网思索"):
|
||||
yield json.dumps({"detail": output}, ensure_ascii=False)
|
||||
|
||||
except asyncio.CancelledError:
|
||||
logger.info("Agent 被取消")
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.exception(f"Agent 运行异常: {e}")
|
||||
# 给前端一个兜底答案
|
||||
yield json.dumps(
|
||||
{"answer": f"\n\n[Agent 运行异常] 已尽力使用工具但未能完整生成答案,请重试或简化问题。"},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
||||
# 终态收尾
|
||||
full_answer = "".join(answer_buf)
|
||||
logger.info(f"Agent 完成:答案长度 {len(full_answer)} chars")
|
||||
@@ -55,11 +55,11 @@ async def get_image(file_name: str):
|
||||
|
||||
|
||||
async def thinking_generator(content: str) -> AsyncIterable[str]:
|
||||
"""思考过程的异步生成器"""
|
||||
"""思考过程的异步生成器(打字机效果,整段约 0.3s 完成)"""
|
||||
yield json.dumps({'think': '\n'}, ensure_ascii=False)
|
||||
for i in content:
|
||||
for i in content:
|
||||
yield json.dumps({'think': i}, ensure_ascii=False)
|
||||
await asyncio.sleep(0.1)
|
||||
await asyncio.sleep(0.02)
|
||||
yield json.dumps({'think': '\n'}, ensure_ascii=False)
|
||||
|
||||
|
||||
@@ -123,7 +123,9 @@ async def chat_test(
|
||||
query = query if len(query)<20000 else TextRank(query,num_sentences=70)
|
||||
query = query if len(query)<20000 else TextRank(query,num_sentences=10)
|
||||
if model_name == "R1-70B":
|
||||
model_name = DEEPSEEK_MODELS[0]
|
||||
# 本地代理 deepseek-r1 把 reasoning 放 content 里,能被 callback 捕获;
|
||||
# 官方 deepseek-reasoner 把 reasoning 放独立的 reasoning_content 字段,旧版 langchain callback 取不到
|
||||
model_name = "deepseek-r1"
|
||||
elif model_name in ["QIANWEN", "Qwen1.5-32B-Chat"]:
|
||||
model_name = LLM_MODELS[0]
|
||||
if prompt_name == "customer_service":
|
||||
@@ -264,63 +266,45 @@ async def chat_test(
|
||||
count_process = 0
|
||||
# await agent_chat_test(query=query, history=history, model_name=model_name,temperature=temperature,max_tokens=max_tokens,prompt_name="answer_question_history",think_content=res)
|
||||
if stream:
|
||||
while i<1:
|
||||
if count_process>10:
|
||||
# ============================================================
|
||||
# LangGraph 版 agent(v2)—— 替换原来 11 次外层重试 + LLM 路由
|
||||
# 旧代码见 git tag: checkpoint-pre-langgraph
|
||||
# ============================================================
|
||||
from server.chat.agent_v2 import agent_run
|
||||
|
||||
# 初始化共享状态(工具内部仍用它写 source_docs)
|
||||
tip["END"] = ""
|
||||
tip["source_docs"] = []
|
||||
tip["num"] = 0
|
||||
tip["title"] = []
|
||||
utils.set_shared_variable(time_based_uuid, tip)
|
||||
|
||||
async for response in agent_run(
|
||||
query=query,
|
||||
uuid=time_based_uuid,
|
||||
history=history,
|
||||
model_name=model_name,
|
||||
temperature=temperature,
|
||||
max_tokens=max_tokens,
|
||||
user_prompt_name=user_prompt_name,
|
||||
think_content=res["text"],
|
||||
):
|
||||
if not utils.get_shared_variable(time_based_uuid)["status"]:
|
||||
logging.info("\n==========STOPPED==========\n")
|
||||
break
|
||||
tip["END"]=""
|
||||
stop = ""
|
||||
temp = ""
|
||||
tip["source_docs"]=[]
|
||||
tip["num"]=0
|
||||
tip["title"]=[]
|
||||
# tip["status"] = True
|
||||
utils.set_shared_variable(time_based_uuid,tip)
|
||||
count = 0
|
||||
count_process += 1
|
||||
logging.info(f"\n\ncount_process:{count_process}\n\n")
|
||||
async for response in agent_chat_test(user_prompt_name = user_prompt_name,query=query,uuid=time_based_uuid, history=history, model_name=model_name,temperature=temperature,max_tokens=max_tokens,prompt_name="Think Test",think_content=res["text"]):
|
||||
# print("------------"+response)
|
||||
if not utils.get_shared_variable(time_based_uuid)["status"]:
|
||||
logging.info("\n==============================STOPPED==============================\n")
|
||||
break
|
||||
if "answer" in json.loads(response):
|
||||
# logging.info(f"answer:{json.loads(response)['answer']}")
|
||||
answer = json.loads(response)["answer"]
|
||||
history_summary+=answer
|
||||
stop = "1"
|
||||
yield json.dumps({"text": answer}, ensure_ascii=False)
|
||||
elif "tools" in json.loads(response):
|
||||
# print("tools:", json.loads(response)["tools"])
|
||||
tools.append(json.loads(response)["tools"])
|
||||
# yield json.dumps({"tools": tools}, ensure_ascii=False)
|
||||
elif "search_answer" in json.loads(response):
|
||||
search_answer = json.loads(response)["search_answer"]
|
||||
# history_summary+= search_answer
|
||||
yield json.dumps({"texts": search_answer}, ensure_ascii=False)
|
||||
elif "docs" in json.loads(response):
|
||||
docs = json.loads(response)["docs"]
|
||||
elif "detail" in json.loads(response):
|
||||
docs_detail += json.loads(response)["detail"]
|
||||
elif "pic" in json.loads(response):
|
||||
# 获取图片路径
|
||||
image_name = json.loads(response)["pic"]
|
||||
image_name = f"\n\n\n\n"
|
||||
# yield json.dumps({"text": image_name}, ensure_ascii=False)
|
||||
else :
|
||||
#history_summary += json.loads(response)["final_answer"]
|
||||
yield json.dumps({"texts": json.loads(response)["final_answer"]}, ensure_ascii=False)
|
||||
if stop == "":
|
||||
continue
|
||||
else:
|
||||
stop = ""
|
||||
temp1 = utils.get_shared_variable(time_based_uuid)
|
||||
temp1["END"]=""
|
||||
i+=1
|
||||
# if index3 == 0 and not "Action" in answer:
|
||||
# yield json.dumps({"text": answer}, ensure_ascii=False)
|
||||
yield json.dumps({"text":"\n"}, ensure_ascii=False)
|
||||
import importlib
|
||||
importlib.reload(prompt_config)
|
||||
msg = json.loads(response)
|
||||
if "answer" in msg:
|
||||
history_summary += msg["answer"]
|
||||
yield json.dumps({"text": msg["answer"]}, ensure_ascii=False)
|
||||
elif "think" in msg:
|
||||
yield json.dumps({"think": msg["think"]}, ensure_ascii=False)
|
||||
elif "docs" in msg:
|
||||
docs += msg["docs"]
|
||||
elif "detail" in msg:
|
||||
docs_detail += msg["detail"]
|
||||
|
||||
yield json.dumps({"text": "\n"}, ensure_ascii=False)
|
||||
|
||||
if not docs_detail.strip() == "" and uid and uid in prompt_config.detail_answer_uid:
|
||||
yield json.dumps({"text": f"\n\n"}, ensure_ascii=False)
|
||||
async for chunk in thinking_generator("正在进行幻觉校验,请稍等待..."):
|
||||
@@ -608,6 +592,8 @@ async def chat_test(
|
||||
if stream:
|
||||
menu = 0 #0处于deepseek思考过程中的状态1处于生成正文状态
|
||||
include_think = False #是否包含思考(源码修改的手动拼接的思考标签)
|
||||
include_think1 = False
|
||||
r1_thinking_done = False # R1: 见到 </think> 之前默认在思考态
|
||||
async for token in callback.aiter():
|
||||
if not utils.get_shared_variable(time_based_uuid)["status"]:
|
||||
logging.info("\n==============================STOPPED==============================\n")
|
||||
@@ -651,19 +637,24 @@ async def chat_test(
|
||||
yield json.dumps({"text": token}, ensure_ascii=False)
|
||||
else:
|
||||
if model_name in DEEPSEEK_MODELS:
|
||||
if "<think>" in token:
|
||||
include_think = True
|
||||
token = token.replace("<think>","")
|
||||
logger.info(f"think:{token}")
|
||||
yield json.dumps({"think": token}, ensure_ascii=False)
|
||||
# R1 流式输出特点:默认从 reasoning 开始(不带 <think> 开标签),
|
||||
# 看到 </think> 才切换到正式答案。
|
||||
if not r1_thinking_done:
|
||||
if "</think>" in token:
|
||||
before, _, after = token.partition("</think>")
|
||||
if before:
|
||||
yield json.dumps({"think": before}, ensure_ascii=False)
|
||||
r1_thinking_done = True
|
||||
if after.strip():
|
||||
yield json.dumps({"text": after}, ensure_ascii=False)
|
||||
else:
|
||||
# 兼容偶发输出 <think> 开标签的场景:剥掉后直接 yield think
|
||||
if "<think>" in token:
|
||||
token = token.replace("<think>", "")
|
||||
if token:
|
||||
yield json.dumps({"think": token}, ensure_ascii=False)
|
||||
else:
|
||||
if menu == 1:
|
||||
yield json.dumps({"text": token}, ensure_ascii=False)
|
||||
if menu == 0 and include_think:
|
||||
yield json.dumps({"text": token}, ensure_ascii=False)
|
||||
menu = 1
|
||||
if not include_think:
|
||||
yield json.dumps({"text": token}, ensure_ascii=False)
|
||||
yield json.dumps({"text": token}, ensure_ascii=False)
|
||||
else:
|
||||
yield json.dumps(
|
||||
{"text": token, "message_id": message_id},
|
||||
|
||||
@@ -165,16 +165,28 @@ async def get_llm_model_response_stream_openai(
|
||||
for key in prompt_param_dict:
|
||||
prompt_template = prompt_template.replace(f"{{{{{key}}}}}", prompt_param_dict[key])
|
||||
messages = [HumanMessage(content=prompt_template)]
|
||||
if type == 0 or type == 2:
|
||||
start_flag = False
|
||||
async for chunk in model.astream(messages):
|
||||
if start_flag:
|
||||
yield chunk.content
|
||||
if "<think>" in chunk.content:
|
||||
start_flag = True
|
||||
else:
|
||||
async for chunk in model.astream(messages):
|
||||
yield chunk.content
|
||||
# 跳过 <think>...</think> 块,其余照常 yield
|
||||
# 兼容 R1 等输出 think 块的模型;非 think 模型不受影响
|
||||
in_think = False
|
||||
async for chunk in model.astream(messages):
|
||||
text = chunk.content or ""
|
||||
while text:
|
||||
if not in_think:
|
||||
i = text.find("<think>")
|
||||
if i < 0:
|
||||
yield text
|
||||
break
|
||||
if i > 0:
|
||||
yield text[:i]
|
||||
text = text[i + len("<think>"):]
|
||||
in_think = True
|
||||
else:
|
||||
i = text.find("</think>")
|
||||
if i < 0:
|
||||
text = "" # 全在 think 块内,丢弃
|
||||
else:
|
||||
text = text[i + len("</think>"):]
|
||||
in_think = False
|
||||
return # 成功完成,退出函数
|
||||
|
||||
except Exception as e:
|
||||
|
||||
142
langchain-chat/server/chat/tools_v2.py
Normal file
@@ -0,0 +1,142 @@
|
||||
"""
|
||||
LangGraph 版工具集:闭包工厂注入 uuid,统一异常包装。
|
||||
|
||||
为什么要重写:
|
||||
1. 旧版 tools 用 `query` 字符串里塞 JSON + uuid 的 hack 传 metadata
|
||||
2. 旧版 LLM 工具调度靠多次 LLM 路由,慢且容易循环
|
||||
3. 这里给每个工具暴露结构化 args_schema,交给 LangGraph ReAct 直接 function-calling
|
||||
"""
|
||||
import json
|
||||
import logging
|
||||
from typing import List, Optional
|
||||
from langchain_core.tools import tool
|
||||
|
||||
# 旧版工具函数仍然复用——只改包装层
|
||||
from server.agent.tools.search_tool import search_tool as _legacy_kb_search
|
||||
from server.agent.tools.knowledgebase_kgo_search import knowledgebase_kgo_search as _legacy_kgo_search
|
||||
from server.agent.tools.draw_plot import create_and_save_plot as _legacy_draw_plot
|
||||
from server.agent.tools.math import math_count as _legacy_math, code_count as _legacy_code
|
||||
from server.agent.tools.weather_check import weathercheck as _legacy_weather
|
||||
from server.agent.tools.search_picture import search_pic as _legacy_search_pic
|
||||
from server.agent.tools.get_statistical_data import mysql_statistic as _legacy_mysql
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _safe_call(name: str, fn, *args, **kwargs) -> str:
|
||||
"""统一异常包装:把 raise 转成给模型的字符串提示,让 ReAct 可恢复。"""
|
||||
try:
|
||||
result = fn(*args, **kwargs)
|
||||
return result if isinstance(result, str) else json.dumps(result, ensure_ascii=False)
|
||||
except Exception as e:
|
||||
logger.exception(f"工具 {name} 调用异常")
|
||||
return f"[工具 {name} 调用异常] {type(e).__name__}: {str(e)[:200]}。请使用其他工具或基于已有信息回答。"
|
||||
|
||||
|
||||
def make_tools(uuid: str) -> list:
|
||||
"""根据本次请求的 uuid 生成一组闭包工具。
|
||||
|
||||
每个工具内部用闭包捕获 uuid,调用旧版 func 时按旧 hack 拼装入参字符串。
|
||||
模型看到的工具入参是结构化的,看不到 uuid。
|
||||
"""
|
||||
|
||||
@tool("知识库联想")
|
||||
def kb_search(
|
||||
query: str,
|
||||
knowledge_name: List[str],
|
||||
keywords: Optional[List[str]] = None,
|
||||
) -> str:
|
||||
"""从指定知识库检索资料。
|
||||
|
||||
knowledge_name 必须从如下列表中选择(可多选):
|
||||
【中国钢铁行业动态库、政策库、期刊论文库、冶金新闻库、冶金中文期刊库、
|
||||
冶金外文期刊库、冶金OA期刊库、冶金行业新闻库、冶金专业知识库、
|
||||
冶金行业报告库、报告库、美术专业知识库】。
|
||||
涉及国家政策时优先选政策库;钢铁行业问题优先选中国钢铁行业动态库。
|
||||
keywords 是相关关键词,2-4 个为宜。
|
||||
"""
|
||||
payload = json.dumps({
|
||||
"query": query,
|
||||
"knowledge_name": knowledge_name,
|
||||
"keywords": keywords or [],
|
||||
}, ensure_ascii=False)
|
||||
legacy_input = payload + json.dumps({"uuid": uuid}, ensure_ascii=False)
|
||||
return _safe_call("知识库联想", _legacy_kb_search, legacy_input)
|
||||
|
||||
@tool("联网思索")
|
||||
def web_search(query: str) -> str:
|
||||
"""联网搜索(智谱 search)。query 必须是用户原文,禁止改写。"""
|
||||
payload = json.dumps({"query": query}, ensure_ascii=False)
|
||||
legacy_input = payload + json.dumps({"uuid": uuid}, ensure_ascii=False)
|
||||
return _safe_call("联网思索", _legacy_kgo_search, legacy_input)
|
||||
|
||||
@tool("图表绘制")
|
||||
def draw_plot(
|
||||
data: dict,
|
||||
title: str,
|
||||
xlabel: str,
|
||||
ylabel: str,
|
||||
plot_type: str,
|
||||
) -> str:
|
||||
"""绘制图表。
|
||||
|
||||
data 形如 {"分类A": 23, "分类B": 17},xlabel/ylabel 描述坐标轴含义。
|
||||
plot_type 必须是 bar / pie / line 之一。
|
||||
本工具一次只能画一张图;输出图片链接后必须按工具说明输出 markdown 引用。
|
||||
"""
|
||||
payload = json.dumps({
|
||||
"data": data,
|
||||
"title": title,
|
||||
"xlabel": xlabel,
|
||||
"ylabel": ylabel,
|
||||
"plot_type": plot_type,
|
||||
}, ensure_ascii=False)
|
||||
# 旧版 draw_plot 接受 <param>...</param> 包裹的 JSON
|
||||
wrapped = f"<param>{payload}</param>{json.dumps({'uuid': uuid})}"
|
||||
return _safe_call("图表绘制", _legacy_draw_plot, wrapped)
|
||||
|
||||
@tool("数学运算")
|
||||
def math_solve(query: str) -> str:
|
||||
"""数学问题求解。query 描述要求解的数学问题。"""
|
||||
payload = json.dumps({"query": query}, ensure_ascii=False)
|
||||
legacy_input = payload + json.dumps({"uuid": uuid}, ensure_ascii=False)
|
||||
return _safe_call("数学运算", _legacy_math, legacy_input)
|
||||
|
||||
@tool("代码专家")
|
||||
def code_solve(query: str) -> str:
|
||||
"""代码相关问题,包括写代码、解释代码、调试。"""
|
||||
payload = json.dumps({"query": query}, ensure_ascii=False)
|
||||
legacy_input = payload + json.dumps({"uuid": uuid}, ensure_ascii=False)
|
||||
return _safe_call("代码专家", _legacy_code, legacy_input)
|
||||
|
||||
@tool("天气工具")
|
||||
def weather(location: str) -> str:
|
||||
"""查询某城市三天内天气。location 是中文城市名,如"北京"。"""
|
||||
payload = json.dumps({"location": location}, ensure_ascii=False)
|
||||
legacy_input = payload + json.dumps({"uuid": uuid}, ensure_ascii=False)
|
||||
return _safe_call("天气工具", _legacy_weather, legacy_input)
|
||||
|
||||
@tool("美术作品获取")
|
||||
def art_search(query: str) -> str:
|
||||
"""查询美术作品图片。query 是作品类型描述(如"山水画"、"草原"),不要传"美术作品"等通用词。"""
|
||||
payload = json.dumps({"query": query}, ensure_ascii=False)
|
||||
legacy_input = payload + json.dumps({"uuid": uuid}, ensure_ascii=False)
|
||||
return _safe_call("美术作品获取", _legacy_search_pic, legacy_input)
|
||||
|
||||
@tool("统计数据查询")
|
||||
def stat_query(query: str) -> str:
|
||||
"""统计数据库查询。仅有 199x-2023 数据。query 是详细的查询问题描述。"""
|
||||
payload = json.dumps({"query": query}, ensure_ascii=False)
|
||||
wrapped = f"<param>{payload}</param>{json.dumps({'uuid': uuid})}"
|
||||
return _safe_call("统计数据查询", _legacy_mysql, wrapped)
|
||||
|
||||
return [
|
||||
kb_search,
|
||||
web_search,
|
||||
draw_plot,
|
||||
math_solve,
|
||||
code_solve,
|
||||
weather,
|
||||
art_search,
|
||||
stat_query,
|
||||
]
|
||||
247
scripts/gateway-nginx.conf
Normal file
@@ -0,0 +1,247 @@
|
||||
server {
|
||||
listen 3000;
|
||||
server_name _;
|
||||
client_max_body_size 500M;
|
||||
|
||||
# 前端 Vite dev server (3001)
|
||||
location /metalinfo {
|
||||
proxy_pass http://127.0.0.1:3001;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
}
|
||||
|
||||
# Java 后端
|
||||
location /chat_web_backend {
|
||||
proxy_pass http://127.0.0.1:8099;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
}
|
||||
|
||||
# ===== Stirling PDF (相对路径,直接代理) =====
|
||||
location /tool/pdf/ {
|
||||
proxy_pass http://127.0.0.1:18080/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_buffering off;
|
||||
}
|
||||
|
||||
# ===== Excalidraw (/assets/ 绝对路径,需要 sub_filter) =====
|
||||
location /tool/draw/ {
|
||||
proxy_pass http://127.0.0.1:18081/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header Accept-Encoding "";
|
||||
sub_filter_once off;
|
||||
sub_filter_types text/html application/javascript;
|
||||
sub_filter 'href="/' 'href="/tool/draw/';
|
||||
sub_filter 'src="/' 'src="/tool/draw/';
|
||||
sub_filter '"/assets/' '"/tool/draw/assets/';
|
||||
}
|
||||
# Excalidraw 资源回落
|
||||
location /assets/ {
|
||||
proxy_pass http://127.0.0.1:18081/assets/;
|
||||
}
|
||||
|
||||
# ===== TrWebOCR (SPA打包,无外部资源) =====
|
||||
location /tool/ocr/ {
|
||||
proxy_pass http://127.0.0.1:18083/;
|
||||
proxy_set_header Host $host;
|
||||
}
|
||||
# TrWebOCR API
|
||||
location /tool/ocr/api/ {
|
||||
proxy_pass http://127.0.0.1:18083/api/;
|
||||
proxy_set_header Host $host;
|
||||
}
|
||||
|
||||
# ===== LibreTranslate (/static/ 绝对路径) =====
|
||||
location /tool/translate/ {
|
||||
proxy_pass http://127.0.0.1:18084/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header Accept-Encoding "";
|
||||
sub_filter_once off;
|
||||
sub_filter_types text/html;
|
||||
sub_filter 'href="/static/' 'href="/tool/translate/static/';
|
||||
sub_filter 'src="/static/' 'src="/tool/translate/static/';
|
||||
sub_filter 'action="/' 'action="/tool/translate/';
|
||||
sub_filter 'url: "/' 'url: "/tool/translate/';
|
||||
}
|
||||
location /tool/translate/static/ {
|
||||
proxy_pass http://127.0.0.1:18084/static/;
|
||||
}
|
||||
location /tool/translate/translate {
|
||||
proxy_pass http://127.0.0.1:18084/translate;
|
||||
proxy_set_header Host $host;
|
||||
}
|
||||
location /tool/translate/detect {
|
||||
proxy_pass http://127.0.0.1:18084/detect;
|
||||
proxy_set_header Host $host;
|
||||
}
|
||||
location /tool/translate/languages {
|
||||
proxy_pass http://127.0.0.1:18084/languages;
|
||||
proxy_set_header Host $host;
|
||||
}
|
||||
location /tool/translate/frontend/settings {
|
||||
proxy_pass http://127.0.0.1:18084/frontend/settings;
|
||||
proxy_set_header Host $host;
|
||||
}
|
||||
|
||||
# ===== PPTist (相对路径,直接代理) =====
|
||||
location /tool/ppt/ {
|
||||
proxy_pass http://127.0.0.1:18085/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_buffering off;
|
||||
}
|
||||
# PPTist AI 后端 (容器内nginx已配 /pptapi/ -> 18086)
|
||||
location /tool/ppt/pptapi/ {
|
||||
proxy_pass http://127.0.0.1:18086/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Connection "";
|
||||
proxy_buffering off;
|
||||
proxy_cache off;
|
||||
}
|
||||
|
||||
# ===== imgcompress (/_next/ 绝对路径) =====
|
||||
location /tool/imgcompress/ {
|
||||
proxy_pass http://127.0.0.1:18087/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header Accept-Encoding "";
|
||||
sub_filter_once off;
|
||||
sub_filter_types text/html;
|
||||
sub_filter 'href="/_next/' 'href="/tool/imgcompress/_next/';
|
||||
sub_filter 'src="/_next/' 'src="/tool/imgcompress/_next/';
|
||||
sub_filter 'href="/favicon' 'href="/tool/imgcompress/favicon';
|
||||
}
|
||||
location /tool/imgcompress/_next/ {
|
||||
proxy_pass http://127.0.0.1:18087/_next/;
|
||||
}
|
||||
location /tool/imgcompress/favicon.ico {
|
||||
proxy_pass http://127.0.0.1:18087/favicon.ico;
|
||||
}
|
||||
location /tool/imgcompress/api/ {
|
||||
proxy_pass http://127.0.0.1:18087/api/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_buffering off;
|
||||
}
|
||||
|
||||
# ===== Lama Cleaner (/static/ 绝对路径) =====
|
||||
location /tool/lama/ {
|
||||
proxy_pass http://127.0.0.1:18088/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header Accept-Encoding "";
|
||||
sub_filter_once off;
|
||||
sub_filter_types text/html;
|
||||
sub_filter 'src="/static/' 'src="/tool/lama/static/';
|
||||
sub_filter 'href="/static/' 'href="/tool/lama/static/';
|
||||
proxy_buffering off;
|
||||
}
|
||||
location /tool/lama/static/ {
|
||||
proxy_pass http://127.0.0.1:18088/static/;
|
||||
}
|
||||
location /tool/lama/api/ {
|
||||
proxy_pass http://127.0.0.1:18088/api/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_buffering off;
|
||||
}
|
||||
location /tool/lama/inpaint {
|
||||
proxy_pass http://127.0.0.1:18088/inpaint;
|
||||
proxy_set_header Host $host;
|
||||
proxy_buffering off;
|
||||
proxy_read_timeout 300s;
|
||||
}
|
||||
|
||||
# ===== webp2jpg (相对路径为主) =====
|
||||
location /tool/webp2jpg/ {
|
||||
proxy_pass http://127.0.0.1:18089/;
|
||||
proxy_set_header Host $host;
|
||||
}
|
||||
# webp2jpg 的 cdn 资源
|
||||
location /cdn/ {
|
||||
proxy_pass http://127.0.0.1:18089/cdn/;
|
||||
}
|
||||
location /version/ {
|
||||
proxy_pass http://127.0.0.1:18089/version/;
|
||||
}
|
||||
|
||||
# ===== Overleaf (复杂路径) =====
|
||||
location /tool/overleaf/ {
|
||||
proxy_pass http://127.0.0.1:18090/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Accept-Encoding "";
|
||||
proxy_buffering off;
|
||||
sub_filter_once off;
|
||||
sub_filter_types text/html application/javascript;
|
||||
sub_filter 'href="/' 'href="/tool/overleaf/';
|
||||
sub_filter 'src="/' 'src="/tool/overleaf/';
|
||||
sub_filter 'action="/' 'action="/tool/overleaf/';
|
||||
sub_filter '"/login' '"/tool/overleaf/login';
|
||||
sub_filter '"/register' '"/tool/overleaf/register';
|
||||
sub_filter '"/project' '"/tool/overleaf/project';
|
||||
}
|
||||
location /tool/overleaf/stylesheets/ {
|
||||
proxy_pass http://127.0.0.1:18090/stylesheets/;
|
||||
}
|
||||
location /tool/overleaf/js/ {
|
||||
proxy_pass http://127.0.0.1:18090/js/;
|
||||
}
|
||||
location /tool/overleaf/img/ {
|
||||
proxy_pass http://127.0.0.1:18090/img/;
|
||||
}
|
||||
location /tool/overleaf/fonts/ {
|
||||
proxy_pass http://127.0.0.1:18090/fonts/;
|
||||
}
|
||||
location /tool/overleaf/login {
|
||||
proxy_pass http://127.0.0.1:18090/login;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header Accept-Encoding "";
|
||||
sub_filter_once off;
|
||||
sub_filter_types text/html;
|
||||
sub_filter 'href="/' 'href="/tool/overleaf/';
|
||||
sub_filter 'src="/' 'src="/tool/overleaf/';
|
||||
sub_filter 'action="/' 'action="/tool/overleaf/';
|
||||
}
|
||||
location /tool/overleaf/register {
|
||||
proxy_pass http://127.0.0.1:18090/register;
|
||||
proxy_set_header Host $host;
|
||||
}
|
||||
location /tool/overleaf/project {
|
||||
proxy_pass http://127.0.0.1:18090/project;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header Accept-Encoding "";
|
||||
sub_filter_once off;
|
||||
sub_filter_types text/html;
|
||||
sub_filter 'href="/' 'href="/tool/overleaf/';
|
||||
sub_filter 'src="/' 'src="/tool/overleaf/';
|
||||
}
|
||||
location /tool/overleaf/socket.io/ {
|
||||
proxy_pass http://127.0.0.1:18090/socket.io/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
}
|
||||
location /tool/overleaf/launchpad {
|
||||
proxy_pass http://127.0.0.1:18090/launchpad;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header Accept-Encoding "";
|
||||
sub_filter_once off;
|
||||
sub_filter_types text/html;
|
||||
sub_filter 'href="/' 'href="/tool/overleaf/';
|
||||
sub_filter 'src="/' 'src="/tool/overleaf/';
|
||||
sub_filter 'action="/' 'action="/tool/overleaf/';
|
||||
}
|
||||
|
||||
# ===== LaTeX 公式编辑器 (CDN外链,无问题) =====
|
||||
location /tool/latex/ {
|
||||
proxy_pass http://127.0.0.1:18091/;
|
||||
proxy_set_header Host $host;
|
||||
}
|
||||
|
||||
# 默认跳转前端
|
||||
location / {
|
||||
return 302 /metalinfo;
|
||||
}
|
||||
}
|
||||