Compare commits

6 Commits

Author SHA1 Message Date
a548425923 fix(security): 文件列表 userId 隔离 + CAS 退出清 SSO
研读模块 /gpt/file/list 之前没强制按 userId 过滤,理论上枚举
knowledgeBaseId 能拿到他人文件元信息(虽然 download 那一步有 userId 校验
所以下不下来,但文件名/大小/上传时间会泄露)。

- FileController.listFile 强制注入 userId = getSysUserId()
- UploadFileMapper.xml BaseSelect 加 <if userId != null> 过滤分支

CAS 退出登录之前只清了本地 JWT,没调 CAS server logout,导致:
- 后端 Redis 里的 token 还在
- CAS server 的 SSO cookie 还在 → 再点"统一身份登录"立即静默登入
- 其他接 CAS 的系统也还能继续访问

新增 POST /app/api/logout:
- 删 Redis 里 LOGIN_TOKEN_KEY:{userId}:{sessionId}
- SecurityContextHolder.clearContext()
- 返回 casLogoutUrl(${serverLogout}?service=前端 login 页)

前端 Operates.vue quit() 改 async:先调 logout 拿 casLogoutUrl,
removeToken 后 window.location.href 跳过去,让 CAS 清 SSO cookie 再回 /login。

CasProperties 加回 serverLogout 字段(之前清理时删了,本次需要)。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 20:34:48 +08:00
b83c540018 fix(cas): 接通 CAS 单点登录全链路 + 清理冗余配置
修复链上的 8 个真 bug:
1. UserDetail(UserVO,Set) 漏 setAuthorities → CAS token 构造抛 IllegalArgumentException
   修:CasUserDetailsService.buildGptUserDetail 手动补 empty authorities

2. frontEndUrl 写死内网 IP,localhost 隧道用户跳回时"无法访问此网站"
   修:CasUrlBuilder 用 X-Forwarded-Host / Host 动态拼 service URL

3. vite proxy 没配 /metalinfo/chat_web_backend,CAS 回跳 ticket 被前端路由吞
   修:加一条 proxy(rewrite 去 /metalinfo 前缀)+ X-Forwarded-Host 转发

4. ticket 校验 service URL 跟 entry point 不一致 → CAS server mismatch
   修:自定义 AuthenticationDetailsSource 用同一个动态 URL

5. sendRedirect URL 含 # 经容器编码成 %23,浏览器拿不到 hash → 404
   修:改用 query 参数(/metalinfo/?cas_token=xxx),前端 router beforeEach 拦截

6. CAS 登录后 HttpSession 残留,第二次访问 /cas/login 不触发 entryPoint → 落到
   DispatcherServlet → 找不到映射 → 404 Whitelabel
   修:SuccessHandler 完成后 invalidate session + clear SecurityContext

7. CAS 路径漏写 Redis token,JwtAuthenticationFilter 校验时 LOGIN_TOKEN_KEY 找不到
   → "token已失效" → 前端 axios interceptor 清 token 跳回 login
   修:SuccessHandler 同步写 redisUtils.set,与 LoginController.saveLoginLog 对齐

8. permission.ts 没拦 query 里的 cas_token,hash 路由下 location.search 取不到
   修:router beforeEach 优先消费 cas_token 再走 getSession

清理冗余:
- CasProperties 删 6 个未用字段(enabled/serverLogout/appLogout/appKey/
  appSecret/httpsFlag/frontEndUrl)
- application.yml 同步删,移除写死的 app-secret 等敏感字段
- 删外部 override 文件 chat_web_backend/config/application.yml
- casServiceProperties.setService 改占位符(实际不被读取,只满足
  ServiceProperties.afterPropertiesSet 的非空校验)
- 删 permission.ts 的 [CAS] [GUARD] debug log,保留 catch error 一条

新增:
- CasUrlBuilder 工具类:从请求动态解析 host/scheme,多个地方共用

UI:
- welcome 页面玻璃按钮 + 呼吸光晕/光感动画(用户自己调过,本次保留)
- App.vue:/welcome 路径不渲染 Operates 侧边栏

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 20:22:57 +08:00
cc54b24a77 feat(login): CASSIC 红金风登录页 + 玻璃按钮欢迎页
需求:
1. 默认访问 /metalinfo 未登录时跳到 CASSIC 风格登录页
2. 登录页支持两种模式: 账号密码登录 / 统一身份登录(CAS)
3. 登录成功跳转 /welcome (原蓝色登录页布局,但右侧表单换成玻璃风"立即体验"按钮)

变更:
- 新增 chat_web_front/src/views/welcome/index.vue
  - 复用现有蓝色 Waves + projectLogo + "聚尖端之力" 文案
  - 玻璃磨砂按钮(backdrop-filter blur),圆角胶囊 + 圆形箭头
  - 点击 → router.push('/chat')
- 重写 chat_web_front/src/views/login/index.vue
  - CASSIC 红金背景图(cassicLoginBg.jpg, 1920x1080 by /Users/jayliu/gangyan/ui)
  - "登录注册中心" 标题 + 红色短分隔条
  - 账号/密码/验证码 三段式表单 (el-icon User/Lock/Stamp)
  - "短信验证登录" 链接切换到 SMS 模式
  - 主红登录按钮 + 次级"统一身份登录(CASSIC)"白底红边按钮
  - 登录成功后 router.push('/welcome')
  - CAS 回调 cas_token 处理保留
- chat_web_front/src/router/index.ts:
  - / → 默认重定向改为 /welcome (原来到 /chat)
  - 新增 /welcome 路由
- 复制 ui/微信图片_20260423163044_262_1531.jpg 到
  src/assets/images/login/cassicLoginBg.jpg

未做:
- SMS 验证码后端接口 (loginByTel) 仅留前端倒计时占位
- 图形验证码后端 (showCaptcha 默认关)
- 老素材 loginBg.png 仍由 welcome 页使用,未删

测试:
- 访问 /metalinfo → 未登录跳 /login (CASSIC 红金)
- 账号密码登录 / CAS 登录 → 跳 /welcome
- /welcome 点"立即体验" → /chat
2026-05-07 16:17:10 +08:00
846380879b fix(langchain-chat): R1 思考过程显示 + 选题推荐放宽 + RAG 诊断日志
三个独立修复 / 排查:

1. R1 思考过程不显示
   - 根因: chat_test.py 等 <think> 开标签出现才进思考态,但 R1
     流式输出本来就在 reasoning 态启动,永远不出 <think>,所有
     reasoning 全部当 text 走到答案区
   - 修法: 引入 r1_thinking_done 状态机,默认在思考态,
     看到 </think> 切换;R1-70B 直连本地代理 deepseek-r1
     (官方 deepseek-reasoner 把 reasoning 放独立字段,旧版
     callback 取不到)
   - 结果验证: "1+1" → 269 think + 40 text,思考与答案正确分流

2. 选题推荐场景拒答 + chat 模板标记泄漏
   - 根因: prompt 写死了 "你只能回答有关选题推荐的问题"
     + 直接嵌入 <|im_start|>/<|im_end|> Qwen chat 标记
   - 修法: 改写 Topic Recommend Assistant prompt,删 chat 标记,
     行为准则改为"沾边查询用工具回答";agent_v2 增加 strip
     防守层
   - 结果验证: "钢铁行业研究重点方向" → agent 调工具,不再拒答

3. 知识库召回 0 排查(数据问题,未根治)
   - 根因: kb_config.py 把所有 KB(政策库/钢铁库/报告库等)
     都映射到 t_policy_total_bge_new_v2,但 Milvus 里根本没有
     这个 collection(实际只有 11 个 p_* 个人库 + 1 个
     t_journal_article_bge_v1)
   - 临时改: search_tool.py 加诊断日志 [RAG诊断] 输出每个 KB
     召回数;rag_search 内 for 循环里首个 KB 空就 return 的
     bug 改 continue
   - 待决策: kb_config 是否把默认 KB 映射到唯一存在的
     t_journal_article_bge_v1,或重建对应集合

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 15:44:05 +08:00
316def2145 feat(langchain-chat): LangGraph 重写 agent 内核
主要变化:
- 新增 agent_v2.py: 用 LangGraph create_react_agent + astream_events
  替代原 agent_chat_test 的 LLM step-routing 死循环
- 新增 tools_v2.py: 闭包工厂模式,每个请求按 uuid 生成工具列表,
  消除 toolinput 字符串拼 JSON 注入 uuid 的旧 hack
- chat_test.py:266-346: 删 11 次 count_process 重试外层和事件
  分发 spaghetti,换成 agent_run 单次调用 + 简单事件 dispatcher
- policy_fun_iast.py:168-187: 修 broken <think> filter
  老代码把 start_flag 设反了(看见 <think> 才开始 yield)导致
  非 think 模型 yield 不出任何内容;改为正确跳过 <think>...</think> 块

模型函数调用通过 langchain_openai.ChatOpenAI(不能用旧版
langchain_community.chat_models.ChatOpenAI,没有现代 tool calling)。
依赖: langgraph==0.0.49 + langchain-core==0.1.53(已在服务器装好)。

非 stream 分支保留旧 agent_chat_test 路径(极少触发,回归风险低)。
旧版回滚: git checkout backup/pre-langgraph

实测对比:
- 旧版 30-60s,答案 0 字(filter 卡死后展示 11 次重试)
- 新版 25-40s,答案完整(含工具调用、参考文献、推荐问题、摘要)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 15:20:00 +08:00
911f7adee6 checkpoint: 重写 langchain agent 前的快照
包含未合入 main 的工作:
- CAS 单点登录接入 (CasSecurityConfig + 4 个 CAS 类)
- LoginController 改 POST + body, 修 URL 密码暴露
- chat_test.py 修 texts→text typo
- 前端外部系统侧边栏 + login API POST
- gateway-nginx.conf

下一步: 从这个分支拉 feat/langgraph-rewrite 做 langchain-chat agent 重写
回滚点: git checkout backup/pre-langgraph

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 14:58:53 +08:00
35 changed files with 1840 additions and 258 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 pointoverride createServiceUrl用当前请求的 X-Forwarded-Host / Host
* 动态拼 service URL而不是用固定的 frontEndUrl 配置commence 是 final只能改这里
*
* 注意返回 raw URL未 encodecommence 内部的 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 校验会 mismatchvite 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;
}
}

View File

@@ -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-Protovite 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();
}
/** 拼一个绝对 URLscheme://host + suffix */
public static String buildServiceUrl(HttpServletRequest request, String suffix) {
return resolveScheme(request) + "://" + resolveHost(request) + suffix;
}
}

View File

@@ -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.telfallback
* 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;
}
}

View File

@@ -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);
}
/**
* 添加登录日志
*

View File

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

View File

@@ -70,6 +70,11 @@ public class SysUser extends BaseEntity {
*/
private String email;
/**
* CAS 单点登录用户名(钢研统一身份)
*/
private String casUsername;
/**
* 性别 0->女;1->男
*/

View File

@@ -74,6 +74,11 @@ public class User extends BaseEntity {
*/
private String unionid;
/**
* CAS 单点登录用户名(钢研统一身份)
*/
private String casUsername;
/**
* 登录ip
*/

View File

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

View File

@@ -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') &gt;= #{q.startDate} </if>
<if test="q.endDate != null and q.endDate != ''"> and date_format(t.create_time,'%Y-%m-%d') &lt;= #{q.endDate} </if>

View File

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

View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

View File

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

View File

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

View File

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

View File

@@ -1,9 +1,31 @@
import type { Router } from 'vue-router'
import { useAuthStore } from '@/store/modules/auth'
/**
* 检查 URL search 部分有没有 cas_tokenCAS 回调时后端把 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' })
}

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,177 @@
"""
LangGraph 版 Agent runner。
替代旧的 agent_chat_test 内核:
- 不再用 LLM 做 step routingthinking/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 listdict 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 agentyield 事件 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")

View File

@@ -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 版 agentv2—— 替换原来 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![图片](http://127.0.0.1:8099/chat_web_backend/get-image?file_name={image_name})\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},

View File

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

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