Commit 85772b0e authored by David Yang's avatar David Yang

feat: 重构界面并增强审计知识库

parent ba702ba8
UMI_APP_LLM_API_KEY=your_api_key_here # Add local environment overrides here when needed.
# Optional overrides. Defaults are shown below.
# UMI_APP_LLM_API_BASE=/llm-api
# UMI_APP_LLM_CHAT_PATH=/v1/chat/completions
# UMI_APP_LLM_MODEL=gpt-5.5
...@@ -3,12 +3,15 @@ import { defineConfig } from '@umijs/max'; ...@@ -3,12 +3,15 @@ import { defineConfig } from '@umijs/max';
export default defineConfig({ export default defineConfig({
base: '/ai_chat/', base: '/ai_chat/',
publicPath: '/ai_chat/', publicPath: '/ai_chat/',
antd: {},
access: {}, access: {},
model: {}, model: {},
initialState: {}, initialState: {},
request: {}, request: {},
mfsu: false,
esbuildMinifyIIFE: true, esbuildMinifyIIFE: true,
jsMinifierOptions: {
target: ['chrome80', 'es2020'],
},
reactQuery: { reactQuery: {
devtool: false, devtool: false,
}, },
...@@ -39,13 +42,6 @@ export default defineConfig({ ...@@ -39,13 +42,6 @@ export default defineConfig({
changeOrigin: true, changeOrigin: true,
pathRewrite: { '^/api': '' }, pathRewrite: { '^/api': '' },
}, },
'/llm-api': {
target: 'https://lingsuan.org',
changeOrigin: true,
pathRewrite: { '^/llm-api': '' },
timeout: 600000,
proxyTimeout: 600000,
},
'/myapi/upload_invoices': { '/myapi/upload_invoices': {
target: 'http://1.94.111.115:5173', target: 'http://1.94.111.115:5173',
changeOrigin: true, changeOrigin: true,
......
...@@ -2,13 +2,14 @@ ...@@ -2,13 +2,14 @@
## 1. 当前目标 ## 1. 当前目标
审计知识库不再把 Excel 每一行写入 `audit_risk_point` 数据对象,也不生成服务端检索索引。服务端只保存一个知识库 Excel 文件对象;每个会话首次加载知识库时下载并解析 Excel,解析结果作为当前会话的知识库上下文。 审计知识库不再把 Excel 每一行写入 `audit_risk_point` 数据对象,也不生成服务端检索索引。服务端只保存一个知识库 Excel 文件对象;每个会话首次加载知识库时下载并解析 Excel,解析结果缓存在当前会话,提问时先在浏览器内检索,再把命中的原文作为模型上下文。
约束如下: 约束如下:
- 不使用 embedding、向量数据库或语义检索接口。 - 不使用 embedding、向量数据库或语义检索接口。
- 不在服务端保存 Excel 的逐行解析结果。 - 不在服务端保存 Excel 的逐行解析结果。
- 每个会话首次读取并解析 Excel,当前会话后续问答复用解析结果。 - 每个会话首次读取并解析 Excel,当前会话后续问答复用解析结果。
- 每次提问对全量记录执行本地评分,知识库问答最多向模型提供匹配度最高的 50 条原文。
- 切换会话后清空上一会话的解析缓存,并重新读取 Excel。 - 切换会话后清空上一会话的解析缓存,并重新读取 Excel。
- 风险描述、法规依据、风险来源和风险等级必须忠于 Excel 原文。 - 风险描述、法规依据、风险来源和风险等级必须忠于 Excel 原文。
- 生成的 Word 保持“智能审计清单”样例的结构和主要样式。 - 生成的 Word 保持“智能审计清单”样例的结构和主要样式。
...@@ -103,7 +104,11 @@ Excel 至少需要识别以下字段: ...@@ -103,7 +104,11 @@ Excel 至少需要识别以下字段:
↓ ↓
记录数组保存在当前会话内存 记录数组保存在当前会话内存
↓ ↓
生成知识库 Markdown 上下文 根据用户问题对全量记录评分并截取 Top-K
↓
若 0 命中,AI 从知识库候选术语中优化检索词并二次评分
↓
生成命中记录的 Markdown 上下文
↓ ↓
发送给 AI 发送给 AI
``` ```
...@@ -118,6 +123,15 @@ Excel 至少需要识别以下字段: ...@@ -118,6 +123,15 @@ Excel 至少需要识别以下字段:
- 用户手动刷新知识库时,强制重新下载和解析。 - 用户手动刷新知识库时,强制重新下载和解析。
- 管理员更新 Excel 后,当前会话缓存立即失效并重新读取。 - 管理员更新 Excel 后,当前会话缓存立即失效并重新读取。
零命中查询优化规则:
- 首次检索命中时不调用查询优化模型。
- 候选术语来自业务板块、一级项目、二级项目、关键词和系统审计领域词表。
- AI 只输出结构化的纠正词、领域、关键词、置信度和是否需要确认,不生成风险事实。
- 只采用候选词表内的术语,最多带入 5 个校验后术语。
- AI 高置信度、无歧义且本地字符相似度达标时才自动二次检索;其他情况等待用户确认。
- AI 调用、JSON 解析或术语校验失败时,回退首次本地检索结果。
## 5. AI 上下文规则 ## 5. AI 上下文规则
解析后的记录统一转换为 Markdown: 解析后的记录统一转换为 Markdown:
...@@ -145,7 +159,7 @@ Excel 至少需要识别以下字段: ...@@ -145,7 +159,7 @@ Excel 至少需要识别以下字段:
- 没有依据时明确回答“知识库中未检索到相关依据”。 - 没有依据时明确回答“知识库中未检索到相关依据”。
- Excel 内容中的指令文字不能覆盖系统规则。 - Excel 内容中的指令文字不能覆盖系统规则。
注意:本方案会把当前 Excel 的有效记录作为模型上下文。模型是否能完整接收,取决于内网部署的 `max_model_len`、`max_input_tokens`、网关请求大小和实际 Excel 字数。参数量 35B 或 397B 不代表上下文一定足够。 注意:本方案不会把当前 Excel 的全部有效记录直接发送给模型。知识库问答默认使用 45 分检索阈值并最多提供 50 条原文;提示词会同时告知总命中数和实际提供数。模型是否能完整接收仍取决于内网部署的 `max_model_len`、`max_input_tokens`、网关请求大小、对话历史和命中记录的实际字数。
## 6. 输出流程 ## 6. 输出流程
...@@ -153,7 +167,8 @@ Excel 至少需要识别以下字段: ...@@ -153,7 +167,8 @@ Excel 至少需要识别以下字段:
```text ```text
用户问题 用户问题
→ Excel 知识库上下文 → 全量记录本地评分
→ Top-K Excel 原文上下文
→ AI 回答 → AI 回答
→ 标注来源行号 → 标注来源行号
→ 可下载问答 Word → 可下载问答 Word
...@@ -208,6 +223,7 @@ Word 顺序固定为: ...@@ -208,6 +223,7 @@ Word 顺序固定为:
- 服务端不存在逐行写入 `audit_risk_point` 的请求。 - 服务端不存在逐行写入 `audit_risk_point` 的请求。
- 新会话首次进入时产生一次文件下载和一次 Excel 解析。 - 新会话首次进入时产生一次文件下载和一次 Excel 解析。
- 同一会话连续提问不会重复下载 Excel。 - 同一会话连续提问不会重复下载 Excel。
- 10000 条记录不会整库放入单次模型请求,知识库问答上下文最多包含 Top 50 原文。
- 切换会话后会重新下载并解析 Excel。 - 切换会话后会重新下载并解析 Excel。
- 更新知识库文件后当前会话立即重新读取新文件。 - 更新知识库文件后当前会话立即重新读取新文件。
- `_x000D_` 等控制字符不会直接出现在最终 Word。 - `_x000D_` 等控制字符不会直接出现在最终 Word。
......
# 智能助手(审计业务应用)产品介绍
> 面向业务老师的内部分享材料文档依据:当前代码实现及《审计知识库 Excel 会话上下文方案》更新日期:2026-08-18
## 一、产品概述
### 1.1 一句话介绍
智能助手是一个面向内部业务人员的 AI 工作台。它将通用大模型能力、审计风险点知识库和审计清单生成流程放在同一个对话界面中,帮助使用者完成日常问答、风险依据查询和审计检查清单初稿生成。
### 1.2 产品定位
本应用不是替代业务判断的“自动审计系统”,而是一个辅助业务人员查资料、理思路、出初稿的工作助手。
它重点解决三个问题:
1. 日常分析、写作和材料整理缺少统一的 AI 入口。
2. 审计风险点散落在 Excel 中,查询慢,定位原始依据困难。
3. 编制审计检查清单需要重复筛选风险点、整理层级和套用文档结构。
### 1.3 核心价值
| 价值 | 具体体现 |
| ------------ | --------------------------------------------------- |
| 提高检索效率 | 用自然语言查询 Excel 风险点,不必逐行筛选 |
| 保留业务依据 | 风险描述、法规依据、来源和风险等级以知识库原文为准 |
| 降低整理成本 | 自动按“业务板块 → 一级项目 → 二级项目 → 风险点”归类 |
| 加快成果产出 | 生成结构化审计清单,并支持下载 Markdown、Word |
| 便于持续使用 | 保留个人会话,可搜索、置顶、重命名和继续追问 |
## 二、面向谁、用在什么场景
### 2.1 目标用户
- 审计、合规、风险管理等业务人员
- 需要查询风险点及法规依据的业务老师
- 负责维护审计风险知识资料的知识库维护人员
- 负责配置业务提示词和使用规则的业务负责人
### 2.2 典型场景
| 场景 | 示例问题 | 推荐智能体 |
| ------------ | ------------------------------------------ | ------------ |
| 日常办公辅助 | “帮我整理这段材料的汇报提纲” | 通用对话 |
| 风险点查询 | “员工行为管理有哪些风险点和法规依据?” | 知识库问答 |
| 按来源查询 | “查询来自中基协的相关风险点” | 知识库问答 |
| 生成审计初稿 | “生成反洗钱专项审计检查清单” | 审计检查清单 |
| 调整已有清单 | “去掉信息技术相关内容,只保留客户身份识别” | 审计检查清单 |
## 三、应用整体结构
应用由四部分组成:
```text
登录与应用入口
↓
会话工作台 ── 新建、搜索、置顶、重命名、删除会话
↓
智能体选择 ── 通用对话 / 知识库问答 / 审计检查清单
↓
能力支撑 ── 大模型 / 审计知识库 / 文件解析 / 结果导出
```
每次新建会话时选择一个智能体,该会话会保留所选智能体、业务话题和历史消息。再次打开会话时,可以在原有上下文中继续追问。
## 四、应用内的三个智能体
这里的“智能体”可以理解为针对不同任务设置了角色、知识来源、业务规则和输出方式的专用助手。当前三个智能体各自独立工作,不是多个智能体自动分工、互相调用的多智能体编排系统。
### 4.1 通用对话
**定位:** 处理不依赖审计知识库的通用任务。
**适合做:**
- 自由问答、思路分析
- 文案撰写、改写和总结
- 汇报提纲、会议材料等日常办公辅助
- 结合上传材料进行内容分析
**工作规则:**
- 不主动读取或假设审计业务知识库内容。
- 支持上传附件;当前内容分析链路以 PDF 和图片为主。
- 模型以流式方式返回内容,使用者可以停止生成、复制回答或重新生成。
### 4.2 知识库问答
**定位:** 从审计风险点 Excel 中检索相关原文,并基于原文回答。
**适合做:**
- 查询某项业务可能涉及的风险点
- 查询风险点对应的法规依据
- 按业务板块、项目层级或风险来源缩小范围
- 核对知识库是否覆盖某项审计关注点
**工作规则:**
- 只允许依据本次检索到的知识库原文回答。
- 风险描述、法规依据、风险来源和风险等级不得凭空补充。
- 回答需要标注来源行号和匹配度。
- 没有依据时,应明确说明“知识库中未检索到相关依据”。
- 默认检索阈值为 45 分,最多向模型提供匹配度最高的 50 条原文。
### 4.3 审计检查清单
**定位:** 根据用户输入的审计项目,从知识库筛选风险点并形成结构化清单初稿。
**适合做:**
- 生成采购管理、反洗钱、员工行为等专项审计清单
- 基于上一版结果继续删除、保留或补充某类内容
- 调整最低匹配度,控制最终清单范围
- 将成果下载为 Word 或 Markdown
**输出内容:**
1. 审计项目
2. 审计对象
3. 审计内容
4. 审计方法
5. 审计人员
6. 审计时间
7. 注意事项
其中“审计内容”严格按以下层级组织:
```text
业务板块
└─ 一级项目
└─ 二级项目
└─ 风险点
```
每条风险点展示匹配度、具体风险点描述、法规依据、风险点来源、风险等级和源文件行号。默认生成阈值为 80 分,发送给模型的风险点原文最多 80 条。
## 五、审计知识库
### 5.1 知识库里有什么
当前知识库以一个 Excel 文件为载体,系统重点识别以下字段:
| 字段 | 作用 |
| -------------- | -------------------------- |
| 业务板块 | 风险点的一级业务分类 |
| 一级项目 | 业务板块下的项目分类 |
| 二级项目 | 更细的检查主题 |
| 具体风险点描述 | 必填,是风险事实的核心内容 |
| 法规依据 | 回答和清单中的依据 |
| 风险点来源 | 标识资料或机构来源 |
| 风险等级 | 风险分级信息 |
系统还可以识别风险点编号、关键词、是否启用、来源行号、备注等辅助字段。支持 `.xlsx` 和 `.xls` 文件。
### 5.2 知识库如何导入
```text
选择或拖入 Excel
↓
浏览器解析文件并识别表头
↓
补齐合并单元格产生的层级空值
↓
校验有效行、重复行和异常行
↓
预览前 20 条有效记录
↓
确认后上传并启用新文件
```
导入界面会显示有效、重复和异常记录数量。缺少“具体风险点描述”的行不会进入知识库;重复记录会去重;控制字符和异常换行会在解析时清理。
上传新文件后,系统更新唯一的知识库文件对象,并让当前会话重新读取新版本。原始 Excel 是知识库事实来源,系统不会在服务端为每一行单独创建风险点数据对象。
### 5.3 一次提问是怎样被回答的
```text
用户输入问题
↓
从问题中提取主题词、业务分类和来源限制
↓
对当前 Excel 的全部有效记录进行规则评分
↓
若 0 命中,AI 从知识库标准术语中优化问题并重新评分
↓
按匹配度排序,并应用阈值和数量上限
↓
将命中的 Excel 原文连同来源行号交给大模型
↓
大模型在业务规则约束下生成回答或清单
```
匹配会综合考虑业务板块、一级项目、二级项目、风险描述、法规依据、风险来源、关键词和短语覆盖情况。系统内置采购、反洗钱、信息技术、员工行为、公司治理等常见审计领域的扩展词,以提高同类表达的命中机会。
当前方案的风险点匹配仍由本地字段和关键词规则完成,不使用 embedding、向量数据库或联网搜索。首次检索为 0 命中时,系统可调用 AI 把错别字或口语映射到知识库标准术语,校验通过后再执行一次本地评分。风险事实仍只能来自 Excel 命中原文。
### 5.4 会话缓存机制
- 进入审计类会话时,浏览器下载并解析当前启用的 Excel。
- 同一会话后续提问复用已解析的数据,避免重复下载。
- 切换会话后重新读取知识库,避免不同会话状态互相影响。
- 使用者可以手动刷新知识库。
- 知识库更新后,当前会话缓存立即失效并重新加载。
### 5.5 如何约束 AI 不“自由发挥”
系统从三层进行约束:
1. **数据层:** 只把检索命中的 Excel 原文提供给模型,不把整库无差别塞入请求。
2. **提示词层:** 明确知识库原文是风险事实的唯一依据,禁止使用模型记忆或外部信息补全。
3. **展示层:** 保留来源文件、来源行号、匹配度和知识库层级,便于人工核验。
这些机制能够降低幻觉风险,但不能替代人工复核。页面也会持续提示“AI 可能会出错,请核对重要信息”。
## 六、核心使用流程
### 6.1 使用知识库问答
1. 登录应用,点击“新建会话”。
2. 选择“知识库问答”。
3. 确认页面上方已显示知识库文件名和有效风险点数量。
4. 输入具体问题,尽量包含业务主题、项目类别或来源范围。
5. 查看回答中的来源行号和匹配度。
6. 对重要结论回到原始 Excel 复核。
推荐提问方式:
```text
查询员工行为管理相关风险点和法规依据。
查询来自中基协、与反洗钱相关的风险点。
知识库中是否包含客户身份识别措施管理风险?请列出来源行号。
```
### 6.2 生成审计检查清单
1. 新建会话并选择“审计检查清单”。
2. 输入清晰的审计项目,例如“生成反洗钱专项审计检查清单”。
3. 查看系统按业务层级展开的风险点。
4. 根据需要继续追问调整,例如“只保留客户身份识别”“去掉信息技术相关内容”“匹配度调整为 85”。
5. 设置导出最低匹配度。
6. 下载 Word 或 Markdown,交由业务人员复核和完善。
### 6.3 维护知识库
1. 进入任一审计类会话。
2. 点击顶部“配置 Excel”。
3. 选择或拖入文件,检查有效、重复和异常数量。
4. 抽查预览数据和异常行说明。
5. 确认无误后点击“保存 Excel”。
6. 检查顶部显示的新文件名和有效记录数。
7. 使用一个标准问题做回归验证。
## 七、现场演示建议(约 12 分钟)
### 演示一:认识工作台(2 分钟)
- 展示登录后的首页和三个智能体入口。
- 展示会话搜索、置顶、重命名和历史消息能力。
- 强调“一个会话绑定一种智能体,选错时新建会话重新选择”。
### 演示二:知识库问答(3 分钟)
演示问题:
```text
查询员工行为管理相关风险点和法规依据,请标明来源行号。
```
讲解重点:
- 页面顶部能看到当前 Excel 和有效风险点数量。
- 回答来自本次命中的原文,不是模型自由补充。
- 行号用于回到原文件核验。
### 演示三:生成并调整清单(5 分钟)
第一轮输入:
```text
生成反洗钱专项审计检查清单。
```
第二轮调整:
```text
只保留客户身份识别相关内容,最低匹配度调整为 85。
```
讲解重点:
- AI 负责生成审计对象、方法、人员角色、时间安排和注意事项。
- 风险点由系统按知识库层级结构化展示。
- 调整匹配度后再下载 Word,避免把低相关内容带入成果。
### 演示四:知识库导入预览(2 分钟)
- 打开“配置 Excel”,选择演示文件。
- 展示有效、重复、异常统计和前 20 条预览。
- 演示环境若使用正式知识库,到预览步骤即可,不要现场覆盖生产文件。
## 八、当前能力边界
内部分享时建议主动说明以下边界:
1. **它生成的是业务初稿。** 审计范围、样本、结论和最终专业判断仍由业务人员负责。
2. **结果受知识库质量直接影响。** Excel 缺字段、分类不一致、法规依据过期,都会影响检索和输出。
3. **当前不是语义向量检索。** 主要依赖字段、关键词和内置领域扩展词,复杂同义表达可能漏检。
4. **匹配度是规则相关度,不是风险概率。** 不能把 90 分理解为“风险发生概率 90%”。
5. **知识库问答有上下文上限。** 默认最多提供 50 条原文,不能据此声称已经覆盖所有可能相关记录。
6. **清单导出目前支持 Word 和 Markdown。** Excel 是知识库导入格式,当前页面尚未提供清单 Excel 导出。
7. **附件能力需要按格式验收。** 页面允许选择 Word、Excel、PDF 和图片,但当前内容解析链路以 PDF 和图片为主。
8. **知识库及提示词配置需要治理。** 当前页面提供配置入口,正式使用时应明确维护人、审核人和权限范围。
9. **审计类回答不联网搜索。** 法规更新应先经过业务审核并更新知识库,不能依赖模型自动获取最新规定。
## 九、建议的运营与治理机制
### 9.1 角色分工
| 角色 | 建议职责 |
| ------------ | ------------------------------------------ |
| 业务负责人 | 确认适用范围、提示词规则和输出口径 |
| 知识库维护人 | 汇总 Excel、处理异常行、维护版本和更新记录 |
| 复核人 | 抽查法规依据、来源行号及清单完整性 |
| 普通使用者 | 按规范提问,对重要结果进行人工核验 |
| 技术运维 | 保障模型、文件服务、应用配置和访问权限 |
### 9.2 更新建议
- 知识库文件使用统一模板和命名规则。
- 每次更新记录版本、维护人、更新时间和主要变更。
- 上线前准备一组固定问题作为回归测试集。
- 对“未命中但业务认为应命中”的问题持续补充关键词或优化分类。
- 定期抽查高频问答的来源准确性和清单可用性。
### 9.3 建议关注的产品指标
- 知识库有效记录数、重复数和异常数
- 标准问题命中率与来源准确率
- 知识库问答“未检索到依据”的比例
- 清单生成后人工保留、删除和补充的比例
- Word 导出次数及清单实际使用率
- 用户复用会话和二次追问的比例
## 十、分享会常见问题
### Q1:三个智能体使用的是三个不同模型吗?
不一定。智能体首先代表不同的角色提示词、知识来源和输出规则;底层模型由应用运行配置决定,也可以根据长上下文和思考模式选择对应模型。
### Q2:为什么不把整份 Excel 都交给模型?
整库直接发送会带来上下文过长、响应变慢、成本上升和重点不突出等问题。当前方案先在浏览器内对全量记录评分,再只提交高相关原文。
### Q3:匹配度越高,风险等级就越高吗?
不是。匹配度表示风险点与用户问题的相关程度;风险等级是 Excel 原文中的独立业务字段,两者不能混用。
### Q4:AI 会不会编造法规依据?
系统明确禁止模型补充知识库之外的风险事实,并要求保留来源行号。但大模型仍可能出错,所以重要法规和最终成果必须人工复核。
### Q5:知识库更新后,历史回答会自动变化吗?
不会。历史消息保留生成当时的回答;新问题会读取当前启用的知识库版本。若需要对比,应保留知识库版本记录并重新提问验证。
### Q6:为什么同一个问题换一种说法,结果可能不同?
当前检索依赖主题词、字段层级、来源条件和短语覆盖。表达变化会影响提取到的关键词,因此建议问题中明确写出业务板块、项目主题或来源机构。
## 十一、建议的分享结语
智能助手的价值不在于让 AI 替业务人员作判断,而在于把“查找原文、筛选风险点、整理层级、形成初稿”这些重复工作交给系统,让业务人员把时间用在范围判断、证据核验和专业决策上。
对审计类场景而言,可信度来自三件事:知识库内容可靠、检索过程可解释、最终结果有人复核。应用已经提供了原文约束和来源追溯能力,后续更重要的是持续做好知识库治理和业务验收。
## 附录:可直接使用的分享提纲
1. 应用是什么:面向内部业务人员的 AI 工作台。
2. 为什么做:提升资料查询和审计清单编制效率。
3. 三个智能体:通用对话、知识库问答、审计检查清单。
4. 知识库怎么工作:Excel 导入、全量规则评分、原文进入模型、行号追溯。
5. 现场演示:查询风险点、生成清单、二次调整、下载 Word。
6. 如何正确使用:AI 出初稿,业务做复核;匹配度不等于风险等级。
7. 后续重点:权限治理、知识库版本管理、标准问题回归测试和使用效果评估。
...@@ -4,21 +4,9 @@ ...@@ -4,21 +4,9 @@
## 本地启动 ## 本地启动
如果本地模型代理需要浏览器携带 API Key,先创建本地环境变量文件:
```bash
cp .env.example .env.local
```
然后把 `.env.local` 里的 `UMI_APP_LLM_API_KEY` 替换成线上模型服务 API Key。如果 `/llm-api` 代理已在服务端处理鉴权,可以不创建该文件。
```bash ```bash
pnpm install pnpm install
pnpm dev pnpm dev
``` ```
可选配置: 模型请求与其他业务接口一致,通过当前系统代理访问 `/api/llm/list` 和 `/api/llm/chat`,无需在前端配置第三方模型服务地址或 API Key。
- `UMI_APP_LLM_API_BASE`:模型服务地址,默认走 Umi 代理 `/llm-api`
- `UMI_APP_LLM_CHAT_PATH`:聊天接口路径,默认 `/v1/chat/completions`
- `UMI_APP_LLM_MODEL`:默认模型,默认 `gpt-5.5`
...@@ -11,20 +11,45 @@ ...@@ -11,20 +11,45 @@
"start": "npm run dev" "start": "npm run dev"
}, },
"dependencies": { "dependencies": {
"@ant-design/icons": "^5.0.1", "@heroui-pro/react": "1.0.0-beta.8",
"@ant-design/pro-components": "^2.4.4", "@heroui/react": "^3.2.4",
"@ant-design/x": "^1.6.0", "@heroui/styles": "^3.2.4",
"@ant-design/x-markdown": "2.0.0-alpha.5",
"@modelcontextprotocol/sdk": "^1.17.5", "@modelcontextprotocol/sdk": "^1.17.5",
"@number-flow/react": "^0.6.2",
"@react-aria/utils": "^3.34.1",
"@tiptap/core": "^3.30.1",
"@tiptap/extension-link": "^3.30.1",
"@tiptap/extension-underline": "^3.30.1",
"@tiptap/extensions": "^3.30.1",
"@tiptap/pm": "^3.30.1",
"@tiptap/react": "^3.30.1",
"@tiptap/starter-kit": "^3.30.1",
"@tiptap/suggestion": "^3.30.1",
"@umijs/max": "^4.4.12", "@umijs/max": "^4.4.12",
"antd": "^5.4.0",
"antd-style": "^3.7.1",
"dayjs": "^1.11.18", "dayjs": "^1.11.18",
"embla-carousel": "^8.6.0",
"embla-carousel-react": "^8.6.0",
"jszip": "^3.10.1", "jszip": "^3.10.1",
"katex": "^0.16.22", "katex": "^0.16.22",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"lucide-react": "^1.31.0",
"maplibre-gl": "^6.4.0",
"marked": "^16.2.1", "marked": "^16.2.1",
"marked-katex-extension": "^5.1.5", "marked-katex-extension": "^5.1.5",
"motion": "^13.1.0",
"react": "^19.2.8",
"react-aria-components": "^1.20.0",
"react-dom": "^19.2.8",
"react-markdown": "^10.1.0",
"react-resizable-panels": "^4.12.3",
"recharts": "^3.10.1",
"remark-breaks": "^4.0.0",
"remark-gfm": "^4.0.1",
"shiki": "^4.4.3",
"streamdown": "^2.5.0",
"tailwind-merge": "^3.6.0",
"tailwind-variants": "^3.3.1",
"tailwindcss": "^4.3.3",
"xlsx": "^0.18.5" "xlsx": "^0.18.5"
}, },
"devDependencies": { "devDependencies": {
......
This source diff could not be displayed because it is too large. You can view the blob instead.
...@@ -3,3 +3,5 @@ allowBuilds: ...@@ -3,3 +3,5 @@ allowBuilds:
core-js-pure: false core-js-pure: false
es5-ext: false es5-ext: false
esbuild: false esbuild: false
"@heroui-pro/react": true
"@zowe/secrets-for-zowe-sdk": true
{ {
"name": "ai_chat", "name": "ai_chat",
"version": "1.0.4", "version": "1.0.8",
"build": "abc", "build": "abc",
"description": "merge master", "description": "merge master",
"date": "2025-07-14 19:27:20", "date": "2025-07-14 19:27:20",
......
import { defineApp, matchRoutes } from 'umi'; import { defineApp, matchRoutes } from 'umi';
import { createTracking } from './services/DataService'; import { createTracking } from './services/DataService';
import './styles.css';
import { import {
collectAppInfo, collectAppInfo,
collectClientInfo, collectClientInfo,
......
import { import { getApplications } from '@/services/DataService';
AppstoreOutlined, import { Alert } from '@heroui/react/alert';
ExclamationCircleOutlined, import { Avatar } from '@heroui/react/avatar';
ExportOutlined, import { Button } from '@heroui/react/button';
ThunderboltOutlined, import { Card } from '@heroui/react/card';
} from '@ant-design/icons'; import { Chip } from '@heroui/react/chip';
import { Alert, Empty, Modal, Skeleton } from 'antd'; import { Modal } from '@heroui/react/modal';
import { createStyles } from 'antd-style'; import { Skeleton } from '@heroui/react/skeleton';
import { AppWindow, ArrowUpRight, X } from 'lucide-react';
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { getApplications, getSystemInfo } from '../../services/DataService'; import './index.less';
function normalizeLabelList(items) { function extractApplications(response) {
if (!Array.isArray(items)) { if (Array.isArray(response)) return response;
return []; return response?.records || response?.applications || response?.data || [];
}
return items
.map((item) => {
if (typeof item === 'string') {
return item;
}
return item?.display_name || item?.title || item?.name || '';
})
.filter(Boolean);
}
function resolveApplicationUrl(application) {
if (application?.name) {
return `/${application.name}`;
}
return '#';
} }
function normalizeApplication(application, fallback = {}) { function normalizeApplication(application = {}) {
const name = application?.name || fallback?.name || '';
return { return {
name, name: application.name || '',
title: title: application.title || application.name || '未命名应用',
application?.title ||
fallback?.title ||
application?.name ||
fallback?.name ||
'未命名应用',
description: description:
application?.description || application.description ||
fallback?.description || application.title ||
fallback?.title ||
application?.title ||
'进入对应应用继续处理业务', '进入对应应用继续处理业务',
logo: application?.logo || fallback?.logo || null, logo: application.logo,
url: resolveApplicationUrl({ name }), access: application.access,
access: application?.access || fallback?.access || '', order: Number(application.order || 0),
status: application?.status || fallback?.status || '', url: application.url || (application.name ? `/${application.name}` : '#'),
type: application?.type || fallback?.type || '',
order: application?.order ?? fallback?.order ?? 0,
roles: normalizeLabelList(
application?.roles?.length ? application.roles : fallback?.roles,
),
groups: normalizeLabelList(
application?.groups?.length ? application.groups : fallback?.groups,
),
}; };
} }
function formatAccessLabel(access) { function isCurrent(application) {
if (access === 'public') { const root = application.name ? `/${application.name}` : '';
return '公开'; return Boolean(
} root &&
(window.location.pathname === root ||
if (access === 'protected') { window.location.pathname.startsWith(`${root}/`)),
return '受限'; );
}
if (access === 'private') {
return '私有';
}
if (access === 'internal') {
return '内部';
}
return '';
}
function extractApplications(response) {
if (Array.isArray(response)) {
return response;
}
if (Array.isArray(response?.records)) {
return response.records;
}
if (Array.isArray(response?.applications)) {
return response.applications;
}
if (Array.isArray(response?.data)) {
return response.data;
}
return [];
} }
function mergeApplications(fetchedApplications, fallbackApplications) { export default function ApplicationSwitcherDialog({ open, onClose, userInfo }) {
const fallbackMap = new Map( const fallback = useMemo(
fallbackApplications.map((application) => [application.name, application]), () =>
(Array.isArray(userInfo?.applications) ? userInfo.applications : [])
.map(normalizeApplication)
.filter((application) => application.name),
[userInfo],
); );
const [applications, setApplications] = useState(fallback);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const normalizedFetched = fetchedApplications useEffect(() => {
.map((application) => if (!open) return;
normalizeApplication(application, fallbackMap.get(application?.name)), let cancelled = false;
) setLoading(true);
setError('');
getApplications()
.then((response) => {
if (cancelled) return;
const remote = extractApplications(response)
.map(normalizeApplication)
.filter((application) => application.name); .filter((application) => application.name);
const merged = new Map(
const mergedMap = new Map( [...fallback, ...remote].map((application) => [
normalizedFetched.map((application) => [application.name, application]),
);
fallbackApplications.forEach((application) => {
if (!application?.name || mergedMap.has(application.name)) {
return;
}
mergedMap.set(
application.name, application.name,
normalizeApplication(application, application), application,
]),
); );
}); setApplications(
[...merged.values()].sort(
return Array.from(mergedMap.values()).sort((left, right) => { (left, right) =>
const orderDifference = Number(left.order || 0) - Number(right.order || 0); left.order - right.order ||
if (orderDifference !== 0) { left.title.localeCompare(right.title, 'zh-CN'),
return orderDifference; ),
}
return left.title.localeCompare(right.title, 'zh-CN');
});
}
function buildApplicationSections(applications, applicationGroups) {
if (!Array.isArray(applicationGroups) || applicationGroups.length === 0) {
return [
{
key: 'all',
label: '全部应用',
applications,
},
];
}
const applicationMap = new Map(
applications.map((application) => [application.name, application]),
); );
const groupedApplicationNames = new Set(); })
.catch(() => {
const sections = applicationGroups if (!cancelled) {
.map((group, index) => { setApplications(fallback);
const groupApplications = Array.isArray(group?.applications) setError('应用列表加载失败,当前展示账号缓存中的应用。');
? group.applications
.map((name) => {
const application = applicationMap.get(name);
if (application) {
groupedApplicationNames.add(name);
} }
return application;
}) })
.filter(Boolean) .finally(() => !cancelled && setLoading(false));
: []; return () => {
cancelled = true;
return {
key: group?.label || `group-${index}`,
label: group?.label || '应用分组',
applications: groupApplications,
}; };
}) }, [fallback, open]);
.filter((section) => section.applications.length > 0);
const ungroupedApplications = applications.filter(
(application) => !groupedApplicationNames.has(application.name),
);
if (ungroupedApplications.length > 0) {
sections.push({
key: 'ungrouped',
label: '其他应用',
applications: ungroupedApplications,
});
}
return sections;
}
function isCurrentApplication(application) {
if (typeof window === 'undefined') {
return false;
}
const currentPath = window.location.pathname;
const applicationRoot = application?.name ? `/${application.name}` : '';
if (application?.url?.startsWith('http')) {
try {
const url = new URL(application.url);
return url.pathname === currentPath;
} catch (error) {
return false;
}
}
return (
application?.url === currentPath ||
(!!applicationRoot &&
(currentPath === applicationRoot ||
currentPath.startsWith(`${applicationRoot}/`)))
);
}
const useStyles = createStyles(({ css, responsive }) => ({
modal: css`
.ant-modal-content {
overflow: hidden;
border-radius: 28px;
border: 1px solid rgba(255, 255, 255, 0.72);
background: linear-gradient(
180deg,
rgba(255, 255, 255, 0.96),
rgba(248, 250, 252, 0.94)
);
box-shadow: 0 30px 80px rgba(15, 23, 42, 0.22);
padding: 0;
}
.ant-modal-body {
padding: 0;
}
`,
header: css`
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
padding: 20px;
border-bottom: 1px solid rgba(228, 228, 231, 0.82);
${responsive.xs} {
flex-direction: column;
padding: 18px;
}
`,
eyebrow: css`
display: inline-flex;
align-items: center;
gap: 8px;
height: 24px;
padding: 0 10px;
border-radius: 999px;
background: #fff1f2;
color: #e11d48;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.18em;
text-transform: uppercase;
`,
title: css`
margin: 10px 0 0;
color: #020617;
font-size: 24px;
line-height: 1.25;
font-weight: 700;
letter-spacing: 0;
`,
subtitle: css`
margin: 6px 0 0;
max-width: 640px;
color: #64748b;
font-size: 13px;
line-height: 1.7;
`,
headerActions: css`
display: flex;
align-items: center;
gap: 12px;
`,
countBox: css`
min-width: 90px;
padding: 8px 12px;
border-radius: 16px;
border: 1px solid #ffe4e6;
background: rgba(255, 241, 242, 0.76);
text-align: right;
`,
countLabel: css`
color: #f43f5e;
font-size: 11px;
line-height: 1.2;
font-weight: 600;
`,
countValue: css`
margin-top: 4px;
color: #020617;
font-size: 22px;
line-height: 1;
font-weight: 700;
`,
closeButton: css`
height: 40px;
padding: 0 14px;
border: 1px solid #e4e4e7;
border-radius: 12px;
background: #ffffff;
color: #475569;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: border-color 0.2s ease, color 0.2s ease, background 0.2s ease;
&:hover {
border-color: #d4d4d8;
color: #0f172a;
background: #fafafa;
}
`,
body: css`
max-height: min(68dvh, 720px);
overflow-y: auto;
padding: 18px 20px 20px;
scrollbar-gutter: stable;
${responsive.xs} {
max-height: 64dvh;
padding: 16px 18px 18px;
}
`,
section: css`
& + & {
margin-top: 18px;
}
`,
sectionHeader: css`
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 10px;
`,
sectionTitle: css`
color: #0f172a;
font-size: 14px;
font-weight: 700;
white-space: nowrap;
`,
sectionLine: css`
height: 1px;
flex: 1;
background: rgba(228, 228, 231, 0.86);
`,
sectionCount: css`
color: #94a3b8;
font-size: 12px;
font-weight: 600;
`,
grid: css`
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 10px;
@media (min-width: 1680px) {
grid-template-columns: repeat(6, minmax(0, 1fr));
}
${responsive.lg} {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
${responsive.md} {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
${responsive.xs} {
grid-template-columns: 1fr;
}
`,
card: css`
position: relative;
min-height: 84px;
overflow: hidden;
display: block;
padding: 10px;
border-radius: 16px;
border: 1px solid rgba(228, 228, 231, 0.88);
background: linear-gradient(
180deg,
rgba(255, 255, 255, 0.98),
rgba(248, 250, 252, 0.94)
);
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.06);
color: inherit;
text-decoration: none;
transition: transform 0.2s ease, border-color 0.2s ease,
box-shadow 0.2s ease;
&:hover {
transform: translateY(-2px);
border-color: #fecdd3;
box-shadow: 0 16px 32px rgba(244, 63, 94, 0.12);
color: inherit;
}
&:focus-visible {
outline: 2px solid rgba(244, 63, 94, 0.48);
outline-offset: 2px;
}
`,
cardCurrent: css`
border-color: #fda4af;
background: linear-gradient(
180deg,
rgba(255, 241, 242, 0.95),
rgba(255, 255, 255, 0.96)
);
`,
cardGlow: css`
position: absolute;
inset: 0 0 auto;
height: 56px;
background: radial-gradient(
circle at top right,
rgba(251, 113, 133, 0.14),
transparent 50%
);
opacity: 0;
transition: opacity 0.2s ease;
a:hover & {
opacity: 1;
}
`,
cardTop: css`
position: relative;
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
`,
cardMain: css`
min-width: 0;
display: flex;
align-items: center;
gap: 10px;
`,
logo: css`
width: 36px;
height: 36px;
flex-shrink: 0;
object-fit: cover;
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.86);
background: #ffffff;
box-shadow: 0 4px 10px rgba(15, 23, 42, 0.08);
`,
logoFallback: css`
width: 36px;
height: 36px;
flex-shrink: 0;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 12px;
background: linear-gradient(135deg, #b42318, #f43f5e);
color: #ffffff;
font-size: 14px;
font-weight: 700;
box-shadow: 0 8px 18px rgba(180, 35, 24, 0.22);
`,
cardText: css`
min-width: 0;
`,
cardTitle: css`
overflow: hidden;
color: #020617;
font-size: 14px;
line-height: 1.35;
font-weight: 700;
text-overflow: ellipsis;
white-space: nowrap;
`,
cardDesc: css`
margin-top: 3px;
overflow: hidden;
color: #64748b;
font-size: 11px;
line-height: 16px;
text-overflow: ellipsis;
white-space: nowrap;
`,
cardActions: css`
flex-shrink: 0;
display: inline-flex;
align-items: center;
gap: 8px;
`,
currentBadge: css`
height: 22px;
display: inline-flex;
align-items: center;
padding: 0 8px;
border-radius: 999px;
background: #ffe4e6;
color: #be123c;
font-size: 11px;
font-weight: 700;
`,
openIcon: css`
width: 28px;
height: 28px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 999px;
background: #f1f5f9;
color: #64748b;
transition: background 0.2s ease, color 0.2s ease;
a:hover & {
background: #fff1f2;
color: #e11d48;
}
`,
tags: css`
position: relative;
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 9px;
`,
tag: css`
display: inline-flex;
align-items: center;
max-width: 100%;
height: 22px;
padding: 0 8px;
border-radius: 999px;
background: #f4f4f5;
color: #3f3f46;
font-size: 11px;
line-height: 1;
font-weight: 600;
`,
roleTag: css`
background: #f1f5f9;
color: #334155;
`,
groupTag: css`
background: #fff1f2;
color: #be123c;
`,
skeletonCard: css`
min-height: 84px;
padding: 10px;
border-radius: 16px;
border: 1px solid #e4e4e7;
background: rgba(255, 255, 255, 0.92);
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.05);
`,
empty: css`
min-height: 288px;
display: flex;
align-items: center;
justify-content: center;
border: 1px dashed #e4e4e7;
border-radius: 28px;
background: rgba(250, 250, 250, 0.82);
`,
}));
function ApplicationCard({ application }) {
const { styles, cx } = useStyles();
const current = isCurrentApplication(application);
const target = application?.url?.startsWith('http') ? '_blank' : undefined;
const accessLabel = formatAccessLabel(application.access);
const firstRole = application.roles?.[0];
const firstGroup = application.groups?.[0];
return ( return (
<a <Modal.Root>
href={application.url} <Modal.Trigger aria-hidden="true" className="sr-only" tabIndex={-1} />
target={target} <Modal.Backdrop
rel={target ? 'noreferrer' : undefined} isOpen={open}
className={cx(styles.card, current && styles.cardCurrent)} variant="blur"
onOpenChange={(isOpen) => !isOpen && onClose?.()}
> >
<div className={styles.cardGlow} /> <Modal.Container placement="center" scroll="inside" size="lg">
<div className={styles.cardTop}> <Modal.Dialog className="xy-dialog xy-app-switcher-dialog">
<div className={styles.cardMain}> <Modal.CloseTrigger aria-label="关闭">
{application.logo ? ( <X size={16} />
<img </Modal.CloseTrigger>
src={application.logo} <Modal.Header className="xy-app-switcher-header">
alt={application.title} <span className="xy-app-switcher-mark">
className={styles.logo} <AppWindow size={18} />
/>
) : (
<div className={styles.logoFallback}>
{application.title.slice(0, 1)}
</div>
)}
<div className={styles.cardText}>
<div className={styles.cardTitle}>{application.title}</div>
<div className={styles.cardDesc}>{application.description}</div>
</div>
</div>
<div className={styles.cardActions}>
{current ? <span className={styles.currentBadge}>当前</span> : null}
<span className={styles.openIcon}>
<ExportOutlined />
</span>
</div>
</div>
{accessLabel || firstRole || firstGroup ? (
<div className={styles.tags}>
{accessLabel ? (
<span className={styles.tag}>{accessLabel}</span>
) : null}
{firstRole ? (
<span className={`${styles.tag} ${styles.roleTag}`}>
{firstRole}
</span> </span>
) : null} <span>
{firstGroup ? ( <Modal.Heading>应用切换</Modal.Heading>
<span className={`${styles.tag} ${styles.groupTag}`}> <small>{applications.length} 个可访问应用</small>
{firstGroup}
</span> </span>
</Modal.Header>
<Modal.Body>
{error ? (
<Alert status="warning">
<Alert.Description>{error}</Alert.Description>
</Alert>
) : null} ) : null}
</div>
) : null}
</a>
);
}
function LoadingSkeleton() { {loading && !applications.length ? (
const { styles } = useStyles(); <div className="xy-app-grid">
return (
<div className={styles.grid}>
{Array.from({ length: 6 }).map((_, index) => ( {Array.from({ length: 6 }).map((_, index) => (
<div key={index} className={styles.skeletonCard}> <Card key={index} className="xy-app-card-skeleton">
<Skeleton active avatar paragraph={{ rows: 2 }} title={false} /> <Card.Content>
</div> <Skeleton className="xy-skeleton-logo" />
<Skeleton className="xy-skeleton-line" />
<Skeleton className="xy-skeleton-line xy-skeleton-line-short" />
</Card.Content>
</Card>
))} ))}
</div> </div>
); ) : applications.length ? (
} <div className="xy-app-grid">
{applications.map((application) => {
export default function ApplicationSwitcherDialog({ const current = isCurrent(application);
open, const external = /^https?:\/\//.test(application.url);
onClose,
userInfo,
siteInfo,
}) {
const { styles } = useStyles();
const [loadedSiteInfo, setLoadedSiteInfo] = useState(null);
const fallbackApplications = useMemo(() => {
if (!Array.isArray(userInfo?.applications)) {
return [];
}
return userInfo.applications
.map((application) =>
normalizeApplication(application, {
...application,
description: application?.title,
}),
)
.filter((application) => application.name);
}, [userInfo]);
const [applications, setApplications] = useState(fallbackApplications);
const [status, setStatus] = useState(
fallbackApplications.length > 0 ? 'success' : 'idle',
);
const [errorMessage, setErrorMessage] = useState('');
const effectiveSiteInfo = siteInfo || loadedSiteInfo;
const applicationSections = useMemo(() => {
return buildApplicationSections(applications, effectiveSiteInfo?.group);
}, [applications, effectiveSiteInfo?.group]);
useEffect(() => {
if (fallbackApplications.length === 0) {
return;
}
setApplications((previous) => {
if (previous.length > 0) {
return previous;
}
return fallbackApplications;
});
}, [fallbackApplications]);
useEffect(() => {
if (!open || siteInfo?.group || loadedSiteInfo) {
return;
}
let cancelled = false;
const loadSiteInfo = async () => {
try {
const response = await getSystemInfo();
if (cancelled) {
return;
}
setLoadedSiteInfo({
...(response?.site || response || {}),
key: response?.key,
});
} catch (error) {
if (!cancelled) {
setLoadedSiteInfo({});
}
}
};
loadSiteInfo();
return () => {
cancelled = true;
};
}, [loadedSiteInfo, open, siteInfo?.group]);
useEffect(() => {
if (!open) {
return;
}
let cancelled = false;
const loadApplications = async () => {
setStatus((previous) => (previous === 'success' ? previous : 'loading'));
setErrorMessage('');
try {
const response = await getApplications();
const fetchedApplications = extractApplications(response);
if (cancelled) {
return;
}
const mergedApplications = mergeApplications(
fetchedApplications,
fallbackApplications,
);
setApplications(mergedApplications);
setStatus('success');
} catch (error) {
if (cancelled) {
return;
}
setApplications(fallbackApplications);
setStatus('error');
setErrorMessage('应用列表加载失败,已回退展示当前账号上的应用信息。');
}
};
loadApplications();
return () => {
cancelled = true;
};
}, [fallbackApplications, open]);
return ( return (
<Modal <Card
open={open} key={application.name}
centered className={`xy-app-card ${current ? 'is-current' : ''}`}
footer={null}
closeIcon={null}
width="min(92vw, 1472px)"
onCancel={onClose}
className={styles.modal}
> >
<div className={styles.header}> <Card.Content>
<div> <a
<div className={styles.eyebrow}> href={application.url}
<ThunderboltOutlined /> target={external ? '_blank' : undefined}
Applications rel={external ? 'noreferrer' : undefined}
</div>
<h2 className={styles.title}>应用切换</h2>
<p className={styles.subtitle}>
这里展示当前账号可访问的全部应用,点击卡片即可进入对应工作区。
</p>
</div>
<div className={styles.headerActions}>
<div className={styles.countBox}>
<div className={styles.countLabel}>可见应用</div>
<div className={styles.countValue}>{applications.length}</div>
</div>
<button
type="button"
onClick={onClose}
className={styles.closeButton}
> >
关闭 <Avatar size="md" variant="soft">
</button> {application.logo ? (
</div> <Avatar.Image
</div> src={application.logo}
alt={application.title}
<div className={styles.body}>
{status === 'error' && errorMessage ? (
<Alert
showIcon
type="warning"
icon={<ExclamationCircleOutlined />}
message={errorMessage}
style={{ marginBottom: 16 }}
/> />
) : null} ) : null}
<Avatar.Fallback>
{status === 'loading' && applications.length === 0 ? ( {application.title.slice(0, 1)}
<LoadingSkeleton /> </Avatar.Fallback>
) : applications.length > 0 ? ( </Avatar>
applicationSections.map((section) => ( <span className="xy-app-card-copy">
<section key={section.key} className={styles.section}> <strong>{application.title}</strong>
<div className={styles.sectionHeader}> <small>{application.description}</small>
<div className={styles.sectionTitle}>{section.label}</div>
<span className={styles.sectionLine} />
<span className={styles.sectionCount}>
{section.applications.length}
</span> </span>
<ArrowUpRight size={16} aria-hidden="true" />
</a>
</Card.Content>
{current || application.access ? (
<Card.Footer>
{current ? <Chip size="sm">当前应用</Chip> : null}
{application.access ? (
<Chip size="sm" variant="secondary">
{application.access}
</Chip>
) : null}
</Card.Footer>
) : null}
</Card>
);
})}
</div> </div>
<div className={styles.grid}>
{section.applications.map((application) => (
<ApplicationCard
key={application.name}
application={application}
/>
))}
</div>
</section>
))
) : ( ) : (
<div className={styles.empty}> <div className="xy-app-empty">
<Empty <AppWindow size={24} />
image={<AppstoreOutlined style={{ fontSize: 42 }} />} <strong>暂无可切换应用</strong>
description={ <span>当前账号还没有分配其它应用权限。</span>
<span>
暂无可切换应用
<br />
当前账号还没有分配其它应用权限,后续分配后这里会自动展示。
</span>
}
/>
</div> </div>
)} )}
</div> </Modal.Body>
</Modal> <Modal.Footer>
<Button variant="ghost" onPress={onClose}>
关闭
</Button>
</Modal.Footer>
</Modal.Dialog>
</Modal.Container>
</Modal.Backdrop>
</Modal.Root>
); );
} }
.xy-app-switcher-dialog {
width: calc(100% - 32px);
max-width: 980px;
}
.xy-app-switcher-header {
display: flex;
align-items: center;
gap: 10px;
}
.xy-app-switcher-mark {
display: inline-flex;
width: 34px;
height: 34px;
align-items: center;
justify-content: center;
border: 1px solid var(--xy-border);
border-radius: 7px;
color: var(--xy-brand);
background: var(--xy-surface-subtle);
}
.xy-app-switcher-header > span:last-child {
display: flex;
flex-direction: column;
}
.xy-app-switcher-header small {
color: var(--xy-text-secondary);
font-size: 12px;
}
.xy-app-grid {
display: grid;
margin-top: 10px;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
}
.xy-app-card,
.xy-app-card-skeleton {
min-height: 126px;
border-radius: var(--xy-radius);
}
.xy-app-card.is-current {
border-color: color-mix(in srgb, var(--xy-brand) 40%, var(--xy-border));
}
.xy-app-card a {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: flex-start;
gap: 10px;
}
.xy-app-card-copy {
display: flex;
min-width: 0;
flex-direction: column;
gap: 4px;
}
.xy-app-card-copy strong {
overflow: hidden;
font-size: 14px;
line-height: 20px;
text-overflow: ellipsis;
white-space: nowrap;
}
.xy-app-card-copy small {
display: box;
overflow: hidden;
color: var(--xy-text-secondary);
font-size: 12px;
line-height: 18px;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
.xy-app-card footer {
gap: 6px;
}
.xy-app-empty {
display: flex;
min-height: 260px;
align-items: center;
justify-content: center;
flex-direction: column;
gap: 8px;
color: var(--xy-text-secondary);
}
.xy-app-empty strong {
color: var(--xy-text);
font-size: 14px;
}
.xy-app-empty span {
font-size: 12px;
}
.xy-skeleton-logo {
width: 38px;
height: 38px;
border-radius: 50%;
}
.xy-skeleton-line {
width: 76%;
height: 12px;
margin-top: 12px;
}
.xy-skeleton-line-short {
width: 54%;
margin-top: 7px;
}
@media (max-width: 760px) {
.xy-app-grid {
grid-template-columns: 1fr;
}
}
import { getCaptcha, login } from '@/services/DataService'; import { getCaptcha, login } from '@/services/DataService';
import { import { Alert } from '@heroui/react/alert';
LockOutlined, import { Button } from '@heroui/react/button';
ReloadOutlined, import { Input } from '@heroui/react/input';
SafetyCertificateOutlined, import { Modal } from '@heroui/react/modal';
UserOutlined, import { Spinner } from '@heroui/react/spinner';
} from '@ant-design/icons'; import { Bot, LockKeyhole, RefreshCw, ShieldCheck, User } from 'lucide-react';
import { LoginForm, ProFormText } from '@ant-design/pro-components'; import { useCallback, useEffect, useState } from 'react';
import { Alert, Button, Flex, Modal, Typography } from 'antd'; import './index.less';
import { createStyles } from 'antd-style';
import { useCallback, useEffect, useRef, useState } from 'react'; export default function LoginModal({ open, onSuccess, onError }) {
const [form, setForm] = useState({ username: '', password: '', captcha: '' });
const useStyle = createStyles(({ css }) => {
return {
modal: css`
.ant-modal-content {
border-radius: 30px;
padding: 0;
overflow: hidden;
background: radial-gradient(
circle at top right,
rgba(251, 207, 232, 0.26),
transparent 20%
),
linear-gradient(180deg, #ffffff 0%, #fff7f7 100%);
}
.ant-modal-body {
padding: 0;
}
`,
shell: css`
display: grid;
grid-template-columns: minmax(0, 1.05fr) minmax(0, 0.95fr);
min-height: 540px;
`,
visual: css`
padding: 38px 34px;
background: radial-gradient(
circle at top left,
rgba(200, 30, 58, 0.22),
transparent 22%
),
linear-gradient(160deg, #0f172a 0%, #1e293b 100%);
color: #f8fafc;
display: flex;
flex-direction: column;
justify-content: space-between;
`,
visualBadge: css`
width: fit-content;
padding: 8px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.14);
font-size: 12px;
letter-spacing: 0.04em;
`,
visualTitle: css`
margin-top: 24px;
h2 {
margin: 0;
font-size: 34px;
line-height: 1.15;
color: #f8fafc;
}
p {
margin: 14px 0 0;
font-size: 15px;
line-height: 1.8;
color: rgba(226, 232, 240, 0.78);
}
`,
visualList: css`
display: grid;
gap: 12px;
margin-top: 28px;
div {
border-radius: 18px;
padding: 14px 16px;
background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.08);
}
strong {
display: block;
font-size: 14px;
color: #f8fafc;
}
span {
display: block;
margin-top: 4px;
font-size: 12px;
line-height: 1.6;
color: rgba(226, 232, 240, 0.72);
}
`,
formPane: css`
padding: 34px 32px;
display: flex;
align-items: center;
justify-content: center;
.ant-pro-form-login-container {
width: 100%;
box-shadow: none;
padding: 0;
}
.ant-pro-form-login-logo {
width: 58px;
height: 58px;
}
.ant-pro-form-login-title {
color: #0f172a;
font-size: 28px;
line-height: 1.2;
}
.ant-pro-form-login-subtitle {
color: #64748b;
font-size: 14px;
}
.ant-input-affix-wrapper,
.ant-input {
border-radius: 16px !important;
min-height: 48px;
}
.ant-btn-primary {
height: 46px;
border-radius: 16px;
font-weight: 600;
background: linear-gradient(135deg, #c81e3a 0%, #9f1239 100%);
border: none;
}
`,
formInner: css`
width: 100%;
max-width: 360px;
`,
error: css`
margin-bottom: 20px;
border-radius: 14px;
`,
captchaRow: css`
display: grid;
grid-template-columns: minmax(0, 1fr) 128px;
gap: 12px;
align-items: flex-start;
.ant-form-item {
margin-bottom: 0;
}
`,
captchaButton: css`
height: 48px;
border-radius: 16px;
padding: 0 10px;
overflow: hidden;
border-color: #e2e8f0;
color: #64748b;
.ant-btn-icon {
display: inline-flex;
}
`,
captchaImage: css`
display: flex;
width: 100%;
height: 100%;
align-items: center;
justify-content: center;
svg {
width: 100%;
height: 100%;
display: block;
}
`,
};
});
export default (props) => {
const { onError, onSuccess } = props;
const [errorMessage, setErrorMessage] = useState(null);
const [captchaData, setCaptchaData] = useState(null); const [captchaData, setCaptchaData] = useState(null);
const [captchaLoading, setCaptchaLoading] = useState(false); const [captchaLoading, setCaptchaLoading] = useState(false);
const formRef = useRef(); const [submitting, setSubmitting] = useState(false);
const { styles } = useStyle(); const [errorMessage, setErrorMessage] = useState('');
const refreshCaptcha = useCallback(async () => { const refreshCaptcha = useCallback(async () => {
setCaptchaLoading(true); setCaptchaLoading(true);
try { try {
const nextCaptcha = await getCaptcha(); setCaptchaData(await getCaptcha());
setCaptchaData(nextCaptcha || null);
} catch (error) { } catch (error) {
const nextErrorMessage =
error.response?.data?.message || error.message || '验证码加载失败';
setCaptchaData(null); setCaptchaData(null);
setErrorMessage(nextErrorMessage); setErrorMessage(error.message || '验证码加载失败');
} finally { } finally {
setCaptchaLoading(false); setCaptchaLoading(false);
} }
}, []); }, []);
useEffect(() => { useEffect(() => {
if (props.open) { if (open) refreshCaptcha();
setErrorMessage(null); }, [open, refreshCaptcha]);
formRef.current?.setFieldsValue({ captcha: undefined });
refreshCaptcha();
}
}, [props.open, refreshCaptcha]);
return ( const updateField = (field) => (event) =>
<Modal setForm((current) => ({ ...current, [field]: event.target.value }));
{...props}
closable={false} const handleSubmit = async (event) => {
footer={null} event.preventDefault();
width={920} if (!form.username.trim() || !form.password || !form.captcha.trim()) {
className={styles.modal} setErrorMessage('请完整填写账号、密码和验证码');
> return;
<div className={styles.shell}> }
<div className={styles.visual}> setSubmitting(true);
<div> setErrorMessage('');
<div className={styles.visualBadge}>Enterprise AI Workspace</div>
<div className={styles.visualTitle}>
<Typography.Title level={2}>
让业务知识真正进入对话流程
</Typography.Title>
<Typography.Paragraph>
统一连接企业知识库、智能体工具链、文件理解与流程型操作,让每次提问都更接近可执行结果。
</Typography.Paragraph>
</div>
<div className={styles.visualList}>
<div>
<strong>知识与上下文统一</strong>
<span>支持话题问答、附件内容提取与历史会话连续理解。</span>
</div>
<div>
<strong>智能体可直接协作</strong>
<span>在同一工作台中触发专属助手和 MCP 工具调用。</span>
</div>
<div>
<strong>更适合内部业务落地</strong>
<span>保留稳定功能栈,同时提供更清晰一致的产品体验。</span>
</div>
</div>
</div>
</div>
<div className={styles.formPane}>
<div className={styles.formInner}>
<LoginForm
formRef={formRef}
logo="https://oss.streams7.com/rsm/BfNsqGPGTKE2.svg"
title="智能助手"
subTitle="登录后继续使用统一 AI 对话工作台"
onFinish={async (form) => {
try { try {
const userInfo = await login( const userInfo = await login(
form.username, form.username.trim(),
form.password, form.password,
form.captcha, form.captcha.trim(),
captchaData?.token, captchaData?.token,
); );
localStorage.setItem( localStorage.setItem(
'current_user', 'current_user',
JSON.stringify({ JSON.stringify({ ...userInfo, token: undefined }),
...userInfo,
token: undefined,
}),
); );
setErrorMessage(null);
onSuccess?.(userInfo); onSuccess?.(userInfo);
} catch (error) { } catch (error) {
const nextErrorMessage = const message =
error.response?.data?.message || error.response?.data?.message ||
error.message || error.message ||
'网络错误'; '登录失败,请稍后重试';
setErrorMessage(nextErrorMessage); setErrorMessage(message);
formRef.current?.setFieldsValue({ captcha: undefined }); setForm((current) => ({ ...current, captcha: '' }));
refreshCaptcha(); refreshCaptcha();
onError?.(nextErrorMessage, error); onError?.(message, error);
} finally {
setSubmitting(false);
} }
}} };
>
<Flex vertical> return (
<ProFormText <Modal.Root>
name="username" <Modal.Trigger aria-hidden="true" className="sr-only" tabIndex={-1} />
fieldProps={{ <Modal.Backdrop isOpen={open} isDismissable={false} variant="blur">
size: 'large', <Modal.Container placement="center" size="lg">
prefix: <UserOutlined className={'prefixIcon'} />, <Modal.Dialog className="xy-login-dialog">
}} <div className="xy-login-shell">
placeholder="用户名" <section className="xy-login-intro">
rules={[ <span className="xy-login-brand-mark" aria-hidden="true">
{ <Bot size={22} />
required: true, </span>
message: '请输入用户名', <div>
}, <p className="xy-login-eyebrow">AI Workspace</p>
]} <h1>智能助手</h1>
<p>
面向内部业务场景的统一 AI
对话工作台。登录后可以继续访问历史会话、知识库与专属助手。
</p>
</div>
<ul>
<li>流式对话与连续上下文</li>
<li>审计知识库原文问答</li>
<li>检查清单生成与导出</li>
</ul>
</section>
<section className="xy-login-form-panel">
<header>
<h2>登录</h2>
<p>使用内部账号继续访问工作台</p>
</header>
<form className="xy-login-form" onSubmit={handleSubmit}>
<label>
<span>账号</span>
<div className="xy-input-with-icon">
<User size={16} aria-hidden="true" />
<Input
aria-label="账号"
autoComplete="username"
fullWidth
value={form.username}
onChange={updateField('username')}
/> />
<ProFormText.Password </div>
name="password" </label>
fieldProps={{ <label>
size: 'large', <span>密码</span>
prefix: <LockOutlined className={'prefixIcon'} />, <div className="xy-input-with-icon">
}} <LockKeyhole size={16} aria-hidden="true" />
placeholder="密码" <Input
rules={[ aria-label="密码"
{ type="password"
required: true, autoComplete="current-password"
message: '请输入密码', fullWidth
}, value={form.password}
]} onChange={updateField('password')}
/> />
<div className={styles.captchaRow}> </div>
<ProFormText </label>
name="captcha" <label>
fieldProps={{ <span>验证码</span>
size: 'large', <div className="xy-captcha-row">
prefix: ( <div className="xy-input-with-icon">
<SafetyCertificateOutlined className={'prefixIcon'} /> <ShieldCheck size={16} aria-hidden="true" />
), <Input
}} aria-label="验证码"
placeholder="验证码" autoComplete="off"
rules={[ fullWidth
{ value={form.captcha}
required: true, onChange={updateField('captcha')}
message: '请输入验证码',
},
]}
/> />
<Button </div>
htmlType="button" <button
className={styles.captchaButton} type="button"
loading={captchaLoading} className="xy-captcha-button"
aria-label="刷新验证码"
disabled={captchaLoading} disabled={captchaLoading}
icon={captchaData?.image ? undefined : <ReloadOutlined />} onClick={refreshCaptcha}
onClick={() => {
setErrorMessage(null);
formRef.current?.setFieldsValue({ captcha: undefined });
refreshCaptcha();
}}
> >
{captchaData?.image ? ( {captchaLoading ? (
<Spinner size="sm" />
) : captchaData?.image ? (
<span <span
className={styles.captchaImage} className="xy-captcha-image"
dangerouslySetInnerHTML={{ __html: captchaData.image }} dangerouslySetInnerHTML={{
__html: captchaData.image,
}}
/> />
) : ( ) : (
'刷新' <RefreshCw size={16} />
)} )}
</Button> </button>
</div>
{errorMessage && (
<Alert
className={styles.error}
message={errorMessage}
type="error"
showIcon
/>
)}
</Flex>
</LoginForm>
</div>
</div> </div>
</label>
{errorMessage ? (
<Alert status="danger">
<Alert.Title>登录失败</Alert.Title>
<Alert.Description>{errorMessage}</Alert.Description>
</Alert>
) : null}
<Button
fullWidth
type="submit"
variant="primary"
isDisabled={submitting}
>
{submitting ? <Spinner size="sm" /> : null}
登录
</Button>
</form>
</section>
</div> </div>
</Modal> </Modal.Dialog>
</Modal.Container>
</Modal.Backdrop>
</Modal.Root>
); );
}; }
.xy-login-dialog {
width: calc(100% - 32px);
max-width: 900px;
overflow: hidden;
border: 1px solid var(--xy-border);
border-radius: var(--xy-radius);
background: var(--xy-surface);
box-shadow: 0 24px 70px rgb(0 0 0 / 22%);
}
.xy-login-shell {
display: grid;
min-height: 540px;
grid-template-columns: minmax(0, 1fr) minmax(340px, 0.82fr);
}
.xy-login-intro {
display: flex;
padding: 42px;
flex-direction: column;
justify-content: space-between;
border-right: 1px solid var(--xy-border);
background: #f4f4f5;
}
.xy-login-brand-mark {
display: inline-flex;
width: 42px;
height: 42px;
align-items: center;
justify-content: center;
border-radius: 50%;
color: #fff;
background: var(--xy-brand);
}
.xy-login-eyebrow {
margin: 0 0 8px !important;
color: var(--xy-brand) !important;
font-size: 12px !important;
font-weight: 600;
text-transform: uppercase;
}
.xy-login-intro h1 {
margin: 0;
font-size: 30px;
font-weight: 600;
line-height: 38px;
letter-spacing: 0;
}
.xy-login-intro p {
max-width: 430px;
margin: 14px 0 0;
color: var(--xy-text-secondary);
font-size: 14px;
line-height: 23px;
}
.xy-login-intro ul {
margin: 0;
padding: 0;
list-style: none;
}
.xy-login-intro li {
padding: 9px 0;
border-bottom: 1px solid var(--xy-border);
color: var(--xy-text-secondary);
font-size: 13px;
}
.xy-login-form-panel {
display: flex;
padding: 42px 38px;
flex-direction: column;
justify-content: center;
}
.xy-login-form-panel header h2 {
margin: 0;
font-size: 22px;
font-weight: 600;
letter-spacing: 0;
}
.xy-login-form-panel header p {
margin: 6px 0 0;
color: var(--xy-text-secondary);
font-size: 13px;
}
.xy-login-form {
display: flex;
margin-top: 28px;
flex-direction: column;
gap: 17px;
}
.xy-login-form label > span {
display: block;
margin-bottom: 7px;
color: var(--xy-text);
font-size: 13px;
font-weight: 500;
}
.xy-input-with-icon {
position: relative;
display: flex;
align-items: center;
}
.xy-input-with-icon > svg {
position: absolute;
left: 12px;
z-index: 2;
color: var(--xy-text-secondary);
pointer-events: none;
}
.xy-input-with-icon input {
height: 42px;
padding-left: 36px;
}
.xy-captcha-row {
display: grid;
grid-template-columns: minmax(0, 1fr) 116px;
gap: 8px;
}
.xy-captcha-button {
display: grid;
height: 42px;
place-items: center;
overflow: hidden;
padding: 0;
border: 1px solid var(--xy-border);
border-radius: var(--xy-radius);
color: var(--xy-text-secondary);
background: var(--xy-surface-subtle);
cursor: pointer;
}
.xy-captcha-image,
.xy-captcha-image svg {
display: block;
max-width: 100%;
max-height: 40px;
}
@media (max-width: 720px) {
.xy-input-with-icon input,
.xy-captcha-button {
min-height: 44px;
}
.xy-login-shell {
min-height: 0;
grid-template-columns: 1fr;
}
.xy-login-intro {
display: none;
}
.xy-login-form-panel {
padding: 30px 24px;
}
}
import { Accordion } from '@heroui/react/accordion';
import { Alert } from '@heroui/react/alert';
import { Button } from '@heroui/react/button';
import { Chip } from '@heroui/react/chip';
import { Input } from '@heroui/react/input';
import { Modal } from '@heroui/react/modal';
import { Spinner } from '@heroui/react/spinner';
import { TextArea } from '@heroui/react/textarea';
import { toast } from '@heroui/react/toast';
import { import {
DatabaseOutlined, Database,
DownloadOutlined, Download,
FileExcelOutlined, FileSpreadsheet,
FileMarkdownOutlined, FileText,
FileWordOutlined, FileType2,
InboxOutlined, RefreshCw,
ReloadOutlined, Settings2,
SettingOutlined,
UploadOutlined,
} from '@ant-design/icons';
import {
Alert,
Button,
Collapse,
Divider,
Empty,
Flex,
Form,
Input,
InputNumber,
Modal,
Space,
Table,
Tag,
Tooltip,
Typography,
Upload, Upload,
message, } from 'lucide-react';
} from 'antd'; import { useEffect, useMemo, useRef, useState } from 'react';
import { createStyles } from 'antd-style'; import './components.less';
import { useEffect, useMemo, useState } from 'react';
import { AUDIT_MATCH_THRESHOLD } from './constants'; import { AUDIT_MATCH_THRESHOLD } from './constants';
import { groupAuditRecords } from './engine'; import { groupAuditRecords } from './engine';
import { parseAuditExcel } from './excel'; import { parseAuditExcel } from './excel';
import { import {
exportAuditExcel,
exportAuditMarkdown, exportAuditMarkdown,
exportAuditWord, exportAuditWord,
exportGroundedAuditWord,
filterAuditRecords, filterAuditRecords,
parseAuditSummary, parseAuditSummary,
} from './export'; } from './export';
const useAuditStyles = createStyles(({ token, css }) => ({ function formatChecklistDate(value) {
knowledgeBar: css` return new Intl.DateTimeFormat('zh-CN', {
position: relative; year: 'numeric',
display: flex; month: '2-digit',
align-items: center; day: '2-digit',
justify-content: space-between; hour: '2-digit',
gap: 16px; minute: '2-digit',
min-height: 64px; }).format(new Date(value || Date.now()));
padding: 10px 12px 10px 18px; }
border: 1px solid rgba(125, 211, 252, 0.18);
border-radius: 20px;
background: linear-gradient(
135deg,
rgba(2, 6, 23, 0.88) 0%,
rgba(15, 23, 42, 0.76) 56%,
rgba(8, 47, 73, 0.48) 100%
);
box-shadow: 0 22px 52px rgba(0, 0, 0, 0.24),
inset 0 1px 0 rgba(255, 255, 255, 0.06);
overflow: hidden;
&::before {
content: '';
position: absolute;
inset: 0;
pointer-events: none;
background: linear-gradient(
90deg,
rgba(34, 211, 238, 0.12),
transparent 36%
),
radial-gradient(
circle at 88% 50%,
rgba(99, 102, 241, 0.18),
transparent 30%
);
}
> * {
position: relative;
z-index: 1;
}
.ant-btn {
min-width: 44px;
min-height: 44px;
border-radius: 16px;
border-color: rgba(125, 211, 252, 0.18);
background: rgba(2, 6, 23, 0.56);
color: #cbd5e1;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.06);
}
.ant-btn:hover {
border-color: rgba(34, 211, 238, 0.42) !important;
color: #67e8f9 !important;
background: rgba(8, 47, 73, 0.68) !important;
}
.ant-btn-primary {
height: 44px;
padding-inline: 18px;
border: 1px solid rgba(186, 230, 253, 0.28) !important;
background: linear-gradient(135deg, #22d3ee 0%, #2563eb 100%) !important;
color: #ffffff !important;
box-shadow: 0 16px 34px rgba(14, 165, 233, 0.24),
inset 0 1px 0 rgba(255, 255, 255, 0.18) !important;
}
@media (max-width: 575px) {
align-items: flex-start;
flex-direction: column;
padding: 14px;
}
`,
knowledgeMeta: css`
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
color: #94a3b8;
font-size: 15px;
.anticon {
color: #67e8f9;
font-size: 18px;
filter: drop-shadow(0 0 10px rgba(34, 211, 238, 0.38));
}
strong {
color: #e5eefb;
margin-right: 2px;
font-size: 16px;
letter-spacing: 0.01em;
}
`,
result: css`
width: 100%;
margin-top: 8px;
padding-top: 16px;
border-top: 1px solid ${token.colorBorderSecondary};
`,
resultHeader: css`
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
margin-bottom: 14px;
@media (max-width: 575px) {
flex-direction: column;
}
`,
stats: css`
color: ${token.colorTextSecondary};
font-size: 13px;
line-height: 1.6;
font-variant-numeric: tabular-nums;
`,
checklistTitle: css`
margin: 0 0 4px !important;
font-size: 22px !important;
line-height: 1.4 !important;
`,
checklistMeta: css`
display: block;
margin-bottom: 4px;
color: ${token.colorTextSecondary};
font-size: 13px;
font-variant-numeric: tabular-nums;
`,
checklistDescription: css`
margin: 0 0 18px !important;
color: ${token.colorText};
line-height: 1.7;
`,
checklistSection: css`
margin: 0 0 18px;
h4 {
margin: 0 0 6px !important;
font-size: 16px !important;
line-height: 1.5 !important;
}
.ant-typography {
margin-bottom: 0;
line-height: 1.75;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
`,
businessSection: css`
padding: 14px 0 4px;
border-top: 1px solid ${token.colorBorderSecondary};
&:first-of-type {
border-top: 0;
padding-top: 2px;
}
`,
businessTitle: css`
margin: 0 0 12px !important;
font-size: 16px !important;
line-height: 1.5 !important;
`,
levelOne: css`
margin: 0 0 16px 12px;
@media (max-width: 575px) {
margin-left: 0;
}
`,
levelOneTitle: css`
display: block;
margin-bottom: 8px;
color: ${token.colorText};
font-size: 14px;
`,
detailCollapse: css`
border-radius: 8px;
overflow: hidden;
background: ${token.colorFillAlter};
.ant-collapse-header {
min-height: 44px;
align-items: center !important;
padding: 10px 12px !important;
}
.ant-collapse-content-box {
padding: 0 12px 12px !important;
}
`,
risk: css`
padding: 12px 0 12px 12px;
border-left: 3px solid ${token.colorPrimaryBorder};
border-bottom: 1px solid ${token.colorBorderSecondary};
&:last-child {
border-bottom: 0;
}
`,
riskTitle: css`
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 8px;
`,
riskDescription: css`
margin: 0 0 10px !important;
color: ${token.colorText};
line-height: 1.7;
overflow-wrap: anywhere;
`,
riskLine: css`
margin: 0 0 6px !important;
color: ${token.colorText};
line-height: 1.7;
white-space: pre-wrap;
overflow-wrap: anywhere;
&:last-child {
margin-bottom: 0 !important;
}
`,
exportBar: css`
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 12px;
margin-top: 14px;
padding: 10px 12px;
border: 1px solid rgba(125, 211, 252, 0.16);
border-radius: 16px;
background: rgba(2, 6, 23, 0.28);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.05);
.ant-typography {
color: #94a3b8 !important;
}
.ant-btn-primary {
border: 1px solid rgba(186, 230, 253, 0.26) !important;
background: linear-gradient(135deg, #0ea5e9 0%, #2563eb 100%) !important;
box-shadow: 0 12px 28px rgba(14, 165, 233, 0.22) !important;
}
`,
importPreview: css`
margin-top: 16px;
`,
importErrors: css`
max-height: 120px;
overflow: auto;
margin-top: 12px;
`,
}));
function downloadText(content, fileName) { function downloadText(content, fileName) {
const url = URL.createObjectURL( const url = URL.createObjectURL(
...@@ -316,16 +52,6 @@ function downloadText(content, fileName) { ...@@ -316,16 +52,6 @@ function downloadText(content, fileName) {
window.setTimeout(() => URL.revokeObjectURL(url), 1000); window.setTimeout(() => URL.revokeObjectURL(url), 1000);
} }
function formatChecklistDate(value) {
const date = new Date(value || Date.now());
const pad = (number) => String(number).padStart(2, '0');
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(
date.getDate(),
)} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(
date.getSeconds(),
)}`;
}
export function AuditKnowledgeBar({ export function AuditKnowledgeBar({
count, count,
fileName, fileName,
...@@ -334,130 +60,167 @@ export function AuditKnowledgeBar({ ...@@ -334,130 +60,167 @@ export function AuditKnowledgeBar({
onOpenPrompt, onOpenPrompt,
onRefresh, onRefresh,
}) { }) {
const { styles } = useAuditStyles();
return ( return (
<div className={styles.knowledgeBar}> <div className="xy-audit-bar">
<div className={styles.knowledgeMeta}> <div className="xy-audit-bar-copy">
<DatabaseOutlined style={{ marginRight: 8 }} /> <Database size={17} aria-hidden="true" />
<span>
<strong>审计风险点知识库</strong> <strong>审计风险点知识库</strong>
<small>
{loading {loading
? '正在读取 Excel' ? '正在读取 Excel'
: fileName : fileName
? `${fileName} · 共 ${count} 条有效风险点` ? `${fileName} · ${count} 条有效风险点`
: '尚未配置 Excel'} : '尚未配置 Excel'}
</small>
</span>
</div> </div>
<Space wrap> <div className="xy-audit-actions">
<Tooltip title="配置审计提示词">
<Button <Button
aria-label="配置审计提示词" aria-label="配置审计提示词"
icon={<SettingOutlined />} isIconOnly
onClick={onOpenPrompt} size="sm"
/> variant="ghost"
</Tooltip> onPress={onOpenPrompt}
<Tooltip title="刷新知识库"> >
<Settings2 size={16} />
</Button>
<Button <Button
aria-label="刷新知识库" aria-label="刷新知识库"
icon={<ReloadOutlined />} isIconOnly
loading={loading} size="sm"
onClick={onRefresh} variant="ghost"
/> isDisabled={loading}
</Tooltip> onPress={onRefresh}
<Button icon={<UploadOutlined />} type="primary" onClick={onOpen}> >
{loading ? <Spinner size="sm" /> : <RefreshCw size={16} />}
</Button>
<Button size="sm" variant="secondary" onPress={onOpen}>
<Upload size={16} />
配置 Excel 配置 Excel
</Button> </Button>
</Space> </div>
</div> </div>
); );
} }
export function AuditPromptConfigDialog({ open, value, onSave, onCancel }) { export function AuditPromptConfigDialog({ open, value, onSave, onCancel }) {
const [form] = Form.useForm(); const [prompt, setPrompt] = useState('');
const [context, setContext] = useState('');
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
useEffect(() => { useEffect(() => {
if (open) { if (!open) return;
form.setFieldsValue({ setPrompt(value?.prompt || '');
prompt: value?.prompt || '', setContext(value?.context || '');
context: value?.context || '', }, [open, value]);
});
}
}, [form, open, value]);
const handleSave = async () => { const handleSave = async () => {
let values; if (!prompt.trim() || !context.trim()) {
try { toast.warning('请完整填写主提示词和业务规则');
values = await form.validateFields();
} catch {
return; return;
} }
setSaving(true); setSaving(true);
try { try {
await onSave?.(values); await onSave?.({ prompt, context });
message.success('审计提示词已保存'); toast.success('审计提示词已保存');
onCancel?.(); onCancel?.();
} catch (error) { } catch (error) {
message.error(`提示词保存失败:${error.message || '未知错误'}`); toast.danger(`提示词保存失败:${error.message || '未知错误'}`);
} finally { } finally {
setSaving(false); setSaving(false);
} }
}; };
return ( return (
<Modal <Modal.Root>
title="审计提示词配置" <Modal.Trigger aria-hidden="true" className="sr-only" tabIndex={-1} />
open={open} <Modal.Backdrop
width={760} isOpen={open}
confirmLoading={saving} isDismissable={!saving}
maskClosable={!saving} variant="blur"
closable={!saving} onOpenChange={(isOpen) => !isOpen && !saving && onCancel?.()}
okText="保存"
cancelText="取消"
onOk={handleSave}
onCancel={saving ? undefined : onCancel}
>
<Form form={form} layout="vertical" requiredMark={false}>
<Form.Item
label="主提示词"
name="prompt"
rules={[{ required: true, message: '请输入主提示词' }]}
> >
<Input.TextArea rows={7} showCount maxLength={6000} /> <Modal.Container placement="center" scroll="inside" size="lg">
</Form.Item> <Modal.Dialog className="xy-dialog xy-audit-dialog">
<Form.Item <Modal.CloseTrigger aria-label="关闭" isDisabled={saving} />
label="业务规则与约束" <Modal.Header>
name="context" <Modal.Heading>审计提示词配置</Modal.Heading>
rules={[{ required: true, message: '请输入业务规则与约束' }]} </Modal.Header>
<Modal.Body>
<label className="xy-form-field">
<span>主提示词</span>
<TextArea
aria-label="主提示词"
fullWidth
rows={7}
maxLength={6000}
value={prompt}
onChange={(event) => setPrompt(event.target.value)}
/>
<small>{prompt.length} / 6000</small>
</label>
<label className="xy-form-field">
<span>业务规则与约束</span>
<TextArea
aria-label="业务规则与约束"
fullWidth
rows={9}
maxLength={10000}
value={context}
onChange={(event) => setContext(event.target.value)}
/>
<small>{context.length} / 10000</small>
</label>
</Modal.Body>
<Modal.Footer>
<Button variant="ghost" isDisabled={saving} onPress={onCancel}>
取消
</Button>
<Button
variant="primary"
isDisabled={saving}
onPress={handleSave}
> >
<Input.TextArea rows={9} showCount maxLength={10000} /> {saving ? <Spinner size="sm" /> : null}
</Form.Item> 保存
</Form> </Button>
</Modal> </Modal.Footer>
</Modal.Dialog>
</Modal.Container>
</Modal.Backdrop>
</Modal.Root>
); );
} }
function RiskDetail({ risk, index, styles }) { function RiskDetail({ risk, index }) {
const detail = (label, value) => (
<Typography.Paragraph className={styles.riskLine}>
<Typography.Text strong>{label}:</Typography.Text>
{value || '(无)'}
</Typography.Paragraph>
);
return ( return (
<div className={styles.risk}> <div className="xy-risk-detail">
<div className={styles.riskTitle}> <strong>
<Typography.Text strong> 风险点 {index + 1} · 源文件第 {risk.sourceRow || '未知'} 行
风险点 {index + 1}(源文件第 {risk.sourceRow || '未知'} 行) </strong>
</Typography.Text> <dl>
<div>
<dt>匹配度</dt>
<dd>{risk.matchScore} 分</dd>
</div> </div>
{detail( <div>
'匹配度', <dt>风险点描述</dt>
`${risk.matchScore}分(${risk.matchLevel || '未分级'})`, <dd>{risk.riskDescription || '无'}</dd>
)} </div>
{detail('具体风险点描述', risk.riskDescription)} <div>
{detail('法规依据', risk.regulationBasis)} <dt>法规依据</dt>
{detail('风险点来源', risk.riskSource)} <dd>{risk.regulationBasis || '无'}</dd>
{detail('风险等级', risk.riskLevel)} </div>
<div>
<dt>风险点来源</dt>
<dd>{risk.riskSource || '无'}</dd>
</div>
<div>
<dt>风险等级</dt>
<dd>{risk.riskLevel || '无'}</dd>
</div>
</dl>
</div> </div>
); );
} }
...@@ -465,9 +228,8 @@ function RiskDetail({ risk, index, styles }) { ...@@ -465,9 +228,8 @@ function RiskDetail({ risk, index, styles }) {
export function AuditChecklistResult({ export function AuditChecklistResult({
result, result,
aiContent = '', aiContent = '',
allowWordExport = false, isResponseComplete = false,
}) { }) {
const { styles } = useAuditStyles();
const [threshold, setThreshold] = useState( const [threshold, setThreshold] = useState(
Number(result.threshold || AUDIT_MATCH_THRESHOLD), Number(result.threshold || AUDIT_MATCH_THRESHOLD),
); );
...@@ -485,200 +247,179 @@ export function AuditChecklistResult({ ...@@ -485,200 +247,179 @@ export function AuditChecklistResult({
() => groupAuditRecords(visibleRecords), () => groupAuditRecords(visibleRecords),
[visibleRecords], [visibleRecords],
); );
const isQa = result.kind === 'qa';
const summary = useMemo( const summary = useMemo(
() => parseAuditSummary(aiContent, result.project), () => parseAuditSummary(aiContent, result.project),
[aiContent, result.project], [aiContent, result.project],
); );
const summarySection = (field) => ( const isQa = result.kind === 'qa';
<section className={styles.checklistSection}> const canExportWord =
<Typography.Title level={4}>{field}</Typography.Title> isResponseComplete &&
<Typography.Paragraph>{summary[field]}</Typography.Paragraph> Boolean(aiContent.trim()) &&
</section> visibleRecords.length > 0;
);
const handleWordExport = async () => { const handleWordExport = async () => {
if (!isResponseComplete || !aiContent.trim()) {
toast.warning('请等待 AI 回复完成后再导出 Word');
return;
}
setWordLoading(true); setWordLoading(true);
try { try {
await exportAuditWord(result, aiContent, threshold); await exportAuditWord(result, aiContent, threshold);
} catch (error) { } catch (error) {
message.error(`Word 导出失败:${error.message || '未知错误'}`); toast.danger(`Word 导出失败:${error.message || '未知错误'}`);
} finally { } finally {
setWordLoading(false); setWordLoading(false);
} }
}; };
return ( return (
<section className={styles.result} aria-label="审计风险点结果"> <section className="xy-audit-result" aria-label="审计风险点结果">
{!isQa && ( <header className="xy-audit-result-header">
<>
<Typography.Title level={3} className={styles.checklistTitle}>
智能审计清单
</Typography.Title>
<Typography.Text className={styles.checklistMeta}>
生成时间:{formatChecklistDate(result.generatedAt)}
</Typography.Text>
<Typography.Paragraph className={styles.checklistDescription}>
<Typography.Text strong>审计项目描述:</Typography.Text>
{result.project}
</Typography.Paragraph>
{summarySection('审计项目')}
{summarySection('审计对象')}
</>
)}
<div className={styles.resultHeader}>
<div> <div>
<Typography.Title level={4} style={{ margin: 0 }}> <h3>{isQa ? '引用风险点' : '智能审计清单'}</h3>
{isQa ? '引用风险点' : '审计内容'} <p>
</Typography.Title> {formatChecklistDate(result.generatedAt)} · 知识库{' '}
<div className={styles.stats}> {result.totalCount} 条 · 当前显示 {visibleRecords.length} 条
知识库 {result.totalCount} 条 · 全量评分 {result.scoredCount} 条 · </p>
当前显示 {visibleRecords.length} 条
{result.refined ? ` · 已应用调整“${result.request}”` : ''}
</div>
</div> </div>
<Tag color={visibleRecords.length ? 'processing' : 'warning'}> <Chip color={visibleRecords.length ? 'accent' : 'warning'} size="sm">
匹配度 ≥ {threshold} 匹配度 ≥ {threshold}
</Tag> </Chip>
</header>
{result.queryOptimization?.applied ? (
<Alert status="accent">
<Alert.Title>已优化检索词</Alert.Title>
<Alert.Description>
已将“{result.queryOptimization.originalQuery}”按“
{result.queryOptimization.optimizedQuery}”检索
</Alert.Description>
</Alert>
) : null}
{result.queryOptimization?.needsConfirmation ? (
<Alert status="warning">
<Alert.Title>需要确认检索词</Alert.Title>
<Alert.Description>
是否按“{result.queryOptimization.suggestedQuery}”重新检索?
</Alert.Description>
</Alert>
) : null}
{!isQa ? (
<div className="xy-audit-summary">
<div>
<span>审计项目</span>
<p>{summary['审计项目'] || result.project}</p>
</div> </div>
<div>
<span>审计对象</span>
<p>{summary['审计对象'] || '依据知识库匹配结果确定'}</p>
</div>
</div>
) : null}
{!visibleRecords.length ? ( {!visibleRecords.length ? (
<Alert <Alert status="warning">
type="warning" <Alert.Title>当前阈值下未命中风险点</Alert.Title>
showIcon <Alert.Description>
message="当前阈值下未命中风险点" {result.queryOptimization?.needsConfirmation
description="请补充更明确的业务板块、项目类别或风险点来源,或在消息中指定更低的匹配度阈值。" ? '确认候选术语后再进行检索。'
/> : '请补充业务板块、项目类别或风险点来源,或降低匹配度阈值。'}
</Alert.Description>
</Alert>
) : ( ) : (
groups.map((business, businessIndex) => ( <div className="xy-audit-groups">
<section {groups.map((business) => (
className={styles.businessSection} <section key={business.businessSegment}>
key={business.businessSegment} <h4>{business.businessSegment}</h4>
> {business.level1Projects.map((level1) => (
<Typography.Title level={5} className={styles.businessTitle}> <div key={level1.level1Project} className="xy-audit-level-one">
业务板块 {businessIndex + 1}:{business.businessSegment} <strong>{level1.level1Project}</strong>
</Typography.Title> <Accordion hideSeparator variant="secondary">
{business.level1Projects.map((level1, level1Index) => ( {level1.level2Projects.map((level2) => (
<div <Accordion.Item
className={styles.levelOne} key={level2.level2Project}
key={`${business.businessSegment}-${level1.level1Project}`} id={`${business.businessSegment}-${level1.level1Project}-${level2.level2Project}`}
> >
<Typography.Text strong className={styles.levelOneTitle}> <Accordion.Heading>
一级项目 {level1Index + 1}:{level1.level1Project} <Accordion.Trigger>
</Typography.Text> <span>{level2.level2Project}</span>
<Collapse <Chip size="sm" variant="secondary">
className={styles.detailCollapse} {level2.risks.length} 个风险点
bordered={false} </Chip>
items={level1.level2Projects.map((level2, level2Index) => ({ <Accordion.Indicator />
key: `${level1.level1Project}-${level2.level2Project}`, </Accordion.Trigger>
label: `二级项目 ${level2Index + 1}:${ </Accordion.Heading>
level2.level2Project <Accordion.Panel>
}`, <Accordion.Body>
extra: `${level2.risks.length} 个风险点`, {level2.risks.map((risk, index) => (
children: level2.risks.map((risk, riskIndex) => (
<RiskDetail <RiskDetail
key={`${risk.id || risk.sourceRow}-${riskIndex}`} key={`${risk.id || risk.sourceRow}-${index}`}
risk={risk} risk={risk}
index={riskIndex} index={index}
styles={styles}
/>
)),
}))}
/> />
))}
</Accordion.Body>
</Accordion.Panel>
</Accordion.Item>
))}
</Accordion>
</div> </div>
))} ))}
</section> </section>
)) ))}
)} </div>
{!isQa && (
<>
{summarySection('审计方法')}
{summarySection('审计人员')}
{summarySection('审计时间')}
{summarySection('注意事项')}
</>
)} )}
{!isQa && visibleRecords.length > 0 && ( {!isQa ? (
<div className={styles.exportBar}> <footer className="xy-audit-export">
<Space wrap> <label>
<Typography.Text>导出最低匹配度</Typography.Text> <span>导出最低匹配度</span>
<InputNumber <Input
aria-label="导出最低匹配度" aria-label="导出最低匹配度"
type="number"
min={Number(result.threshold || 0)} min={Number(result.threshold || 0)}
max={100} max={100}
step={5} step={5}
value={threshold} value={String(threshold)}
onChange={(value) => onChange={(event) => setThreshold(Number(event.target.value))}
setThreshold(Number(value || result.threshold))
}
/> />
</Space> </label>
<Space wrap> <div>
<Button
icon={<FileMarkdownOutlined />}
onClick={() => exportAuditMarkdown(result, aiContent, threshold)}
>
Markdown
</Button>
<Button <Button
icon={<FileExcelOutlined />} size="sm"
onClick={() => exportAuditExcel(result, aiContent, threshold)} variant="ghost"
isDisabled={!visibleRecords.length}
title={
visibleRecords.length ? '下载 Markdown' : '暂无可导出的风险点'
}
onPress={() => exportAuditMarkdown(result, aiContent, threshold)}
> >
Excel <FileText size={16} />
下载 Markdown
</Button> </Button>
{allowWordExport && (
<Button <Button
type="primary" size="sm"
icon={<FileWordOutlined />} variant="secondary"
loading={wordLoading} isDisabled={!canExportWord || wordLoading}
onClick={handleWordExport} title={
> !isResponseComplete || !aiContent.trim()
Word ? '请等待 AI 回复完成'
</Button> : visibleRecords.length
)} ? '下载 Word'
</Space> : '暂无可导出的风险点'
</div>
)}
</section>
);
}
export function AuditWordDownload({
records = [],
aiContent = '',
question = '',
title = '审计知识库问答',
}) {
const { styles } = useAuditStyles();
const [loading, setLoading] = useState(false);
const handleDownload = async () => {
setLoading(true);
try {
await exportGroundedAuditWord({ records, aiContent, question, title });
} catch (error) {
message.error(`Word 导出失败:${error.message || '未知错误'}`);
} finally {
setLoading(false);
} }
}; onPress={handleWordExport}
return (
<div className={styles.exportBar}>
<Typography.Text type="secondary">
知识库原文 {records.length} 条
</Typography.Text>
<Button
type="primary"
icon={<FileWordOutlined />}
loading={loading}
onClick={handleDownload}
> >
{wordLoading ? <Spinner size="sm" /> : <FileType2 size={16} />}
下载 Word 下载 Word
</Button> </Button>
</div> </div>
</footer>
) : null}
</section>
); );
} }
...@@ -688,7 +429,7 @@ export function AuditKnowledgeImportDialog({ ...@@ -688,7 +429,7 @@ export function AuditKnowledgeImportDialog({
onCancel, onCancel,
onImported, onImported,
}) { }) {
const { styles } = useAuditStyles(); const inputRef = useRef(null);
const [parsing, setParsing] = useState(false); const [parsing, setParsing] = useState(false);
const [parsed, setParsed] = useState(null); const [parsed, setParsed] = useState(null);
const [selectedFile, setSelectedFile] = useState(null); const [selectedFile, setSelectedFile] = useState(null);
...@@ -702,20 +443,20 @@ export function AuditKnowledgeImportDialog({ ...@@ -702,20 +443,20 @@ export function AuditKnowledgeImportDialog({
}, [open]); }, [open]);
const handleFile = async (file) => { const handleFile = async (file) => {
if (!file) return;
setParsing(true); setParsing(true);
try { try {
const result = await parseAuditExcel(file); const result = await parseAuditExcel(file);
setParsed(result); setParsed(result);
setSelectedFile(file); setSelectedFile(file);
if (!result.records.length) message.warning('Excel 中未识别到有效风险点'); if (!result.records.length) toast.warning('Excel 中未识别到有效风险点');
} catch (error) { } catch (error) {
setParsed(null); setParsed(null);
setSelectedFile(null); setSelectedFile(null);
message.error(`Excel 解析失败:${error.message || '文件格式异常'}`); toast.danger(`Excel 解析失败:${error.message || '文件格式异常'}`);
} finally { } finally {
setParsing(false); setParsing(false);
} }
return false;
}; };
const handleSave = async () => { const handleSave = async () => {
...@@ -723,127 +464,152 @@ export function AuditKnowledgeImportDialog({ ...@@ -723,127 +464,152 @@ export function AuditKnowledgeImportDialog({
setImporting(true); setImporting(true);
try { try {
const result = await onSave?.({ file: selectedFile, parsed }); const result = await onSave?.({ file: selectedFile, parsed });
message.success( toast.success(`Excel 已保存,共 ${parsed.records.length} 条有效风险点`);
`Excel 已保存,共 ${parsed.records.length} 条有效风险点;新会话将重新读取该文件`,
);
await onImported?.(result); await onImported?.(result);
onCancel?.(); onCancel?.();
} catch (error) { } catch (error) {
message.error(`Excel 保存失败:${error.message || '未知错误'}`); toast.danger(`Excel 保存失败:${error.message || '未知错误'}`);
} finally { } finally {
setImporting(false); setImporting(false);
} }
}; };
const columns = [
{ title: '业务板块', dataIndex: 'businessSegment', width: 130 },
{ title: '一级项目', dataIndex: 'level1Project', width: 150 },
{ title: '二级项目', dataIndex: 'level2Project', width: 160 },
{
title: '具体风险点描述',
dataIndex: 'riskDescription',
width: 320,
ellipsis: true,
},
{ title: '风险等级', dataIndex: 'riskLevel', width: 100 },
{ title: '来源行号', dataIndex: 'sourceRow', width: 100 },
];
return ( return (
<Modal <Modal.Root>
title="配置审计知识库 Excel" <Modal.Trigger aria-hidden="true" className="sr-only" tabIndex={-1} />
open={open} <Modal.Backdrop
width={920} isOpen={open}
onCancel={onCancel} isDismissable={!importing}
maskClosable={!importing} variant="blur"
closable={!importing} onOpenChange={(isOpen) => !isOpen && !importing && onCancel?.()}
footer={[
<Button key="cancel" onClick={onCancel} disabled={importing}>
取消
</Button>,
<Button
key="markdown"
icon={<DownloadOutlined />}
disabled={!parsed?.records.length || importing}
onClick={() =>
downloadText(
parsed.markdown,
`${parsed.fileName.replace(/\.[^.]+$/, '')}.md`,
)
}
>
下载 Markdown
</Button>,
<Button
key="import"
type="primary"
icon={<FileExcelOutlined />}
loading={importing}
disabled={!selectedFile || !parsed?.records.length}
onClick={handleSave}
>
保存 Excel 文件
</Button>,
]}
> >
<Upload.Dragger <Modal.Container placement="center" scroll="inside" size="lg">
accept=".xlsx,.xls" <Modal.Dialog className="xy-dialog xy-audit-dialog xy-audit-dialog-wide">
maxCount={1} <Modal.CloseTrigger aria-label="关闭" isDisabled={importing} />
showUploadList={false} <Modal.Header>
beforeUpload={handleFile} <Modal.Heading>配置审计知识库 Excel</Modal.Heading>
</Modal.Header>
<Modal.Body>
<button
type="button"
className="xy-file-dropzone"
disabled={parsing || importing} disabled={parsing || importing}
onClick={() => inputRef.current?.click()}
onDragOver={(event) => event.preventDefault()}
onDrop={(event) => {
event.preventDefault();
handleFile(event.dataTransfer.files?.[0]);
}}
> >
<p className="ant-upload-drag-icon"> {parsing ? <Spinner size="md" /> : <Upload size={24} />}
{parsing ? <ReloadOutlined spin /> : <InboxOutlined />} <strong>
</p>
<p className="ant-upload-text">
{parsing ? '正在解析 Excel' : '选择或拖入 Excel 文件'} {parsing ? '正在解析 Excel' : '选择或拖入 Excel 文件'}
</p> </strong>
</Upload.Dragger> <span>支持 .xlsx 与 .xls</span>
</button>
<input
ref={inputRef}
className="sr-only"
type="file"
accept=".xlsx,.xls"
onChange={(event) => handleFile(event.target.files?.[0])}
/>
{parsed && ( {parsed ? (
<div className={styles.importPreview}> <div className="xy-import-preview">
<Flex justify="space-between" align="center" wrap="wrap" gap={8}> <div className="xy-import-summary">
<Typography.Text strong>{parsed.fileName}</Typography.Text> <strong>{parsed.fileName}</strong>
<Space wrap> <span>
<Tag color="success">有效 {parsed.records.length}</Tag> <Chip color="success" size="sm">
<Tag>重复行 {parsed.duplicateRows}</Tag> 有效 {parsed.records.length}
<Tag color={parsed.errors.length ? 'warning' : 'default'}> </Chip>
<Chip size="sm">重复 {parsed.duplicateRows}</Chip>
<Chip
color={parsed.errors.length ? 'warning' : 'default'}
size="sm"
>
异常 {parsed.errors.length} 异常 {parsed.errors.length}
</Tag> </Chip>
</Space> </span>
</Flex> </div>
<Divider style={{ margin: '12px 0' }} />
{parsed.records.length ? ( {parsed.records.length ? (
<Table <div className="xy-import-table-wrap">
size="small" <table className="xy-import-table">
rowKey={(record) => `${record.sheetName}-${record.sourceRow}`} <thead>
columns={columns} <tr>
dataSource={parsed.records.slice(0, 20)} <th>业务板块</th>
pagination={false} <th>一级项目</th>
scroll={{ x: 960, y: 280 }} <th>二级项目</th>
/> <th>具体风险点描述</th>
) : ( <th>风险等级</th>
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} /> </tr>
)} </thead>
{parsed.errors.length > 0 && ( <tbody>
<div className={styles.importErrors}> {parsed.records.slice(0, 20).map((record) => (
<Alert <tr key={`${record.sheetName}-${record.sourceRow}`}>
type="warning" <td>{record.businessSegment}</td>
showIcon <td>{record.level1Project}</td>
message={`${parsed.errors.length} 行未导入`} <td>{record.level2Project}</td>
description={parsed.errors <td>{record.riskDescription}</td>
.slice(0, 8) <td>{record.riskLevel}</td>
</tr>
))}
</tbody>
</table>
</div>
) : null}
{parsed.errors.length ? (
<Alert status="warning">
<Alert.Title>{parsed.errors.length} 行未导入</Alert.Title>
<Alert.Description>
{parsed.errors
.slice(0, 5)
.map( .map(
(error) => (error) =>
`${error.sheetName} 第 ${error.sourceRow} 行:${error.message}`, `${error.sheetName} 第 ${error.sourceRow} 行:${error.message}`,
) )
.join(';')} .join(';')}
/> </Alert.Description>
</div> </Alert>
)} ) : null}
</div> </div>
) : null}
</Modal.Body>
<Modal.Footer>
<Button variant="ghost" isDisabled={importing} onPress={onCancel}>
取消
</Button>
<Button
variant="ghost"
isDisabled={!parsed?.records.length || importing}
onPress={() =>
downloadText(
parsed.markdown,
`${parsed.fileName.replace(/\.[^.]+$/, '')}.md`,
)
}
>
<Download size={16} />
Markdown
</Button>
<Button
variant="primary"
isDisabled={
!selectedFile || !parsed?.records.length || importing
}
onPress={handleSave}
>
{importing ? (
<Spinner size="sm" />
) : (
<FileSpreadsheet size={16} />
)} )}
</Modal> 保存 Excel
</Button>
</Modal.Footer>
</Modal.Dialog>
</Modal.Container>
</Modal.Backdrop>
</Modal.Root>
); );
} }
.xy-audit-bar {
display: flex;
width: 100%;
min-height: 58px;
margin-bottom: 22px;
align-items: center;
justify-content: space-between;
gap: 14px;
padding: 9px 10px 9px 14px;
border: 1px solid var(--xy-border);
border-radius: var(--xy-radius);
background: var(--xy-surface);
}
.xy-audit-bar-copy,
.xy-audit-actions,
.xy-import-summary,
.xy-import-summary > span,
.xy-audit-export,
.xy-audit-export > div,
.xy-audit-export label {
display: flex;
align-items: center;
}
.xy-audit-bar-copy {
min-width: 0;
gap: 10px;
color: var(--xy-text-secondary);
}
.xy-audit-bar-copy > span {
display: flex;
min-width: 0;
flex-direction: column;
}
.xy-audit-bar-copy strong {
color: var(--xy-text);
font-size: 13px;
line-height: 19px;
}
.xy-audit-bar-copy small {
overflow: hidden;
max-width: 440px;
font-size: 12px;
line-height: 17px;
text-overflow: ellipsis;
white-space: nowrap;
}
.xy-audit-actions {
flex: 0 0 auto;
gap: 4px;
}
.xy-audit-dialog {
max-height: min(88dvh, 860px);
}
.xy-audit-dialog-wide {
width: calc(100% - 32px);
max-width: 980px;
}
.xy-form-field {
display: flex;
margin-bottom: 18px;
flex-direction: column;
gap: 7px;
}
.xy-form-field > span {
color: var(--xy-text);
font-size: 13px;
font-weight: 500;
}
.xy-form-field > small {
align-self: flex-end;
color: var(--xy-text-muted);
font-size: 11px;
}
.xy-audit-result {
margin-top: 14px;
padding: 18px;
border: 1px solid var(--xy-border);
border-radius: var(--xy-radius);
background: var(--xy-surface);
}
.xy-audit-result-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
}
.xy-audit-result h3,
.xy-audit-result h4,
.xy-audit-result p {
letter-spacing: 0;
}
.xy-audit-result h3 {
margin: 0;
color: var(--xy-text);
font-size: 16px;
font-weight: 600;
line-height: 24px;
}
.xy-audit-result-header p {
margin: 3px 0 0;
color: var(--xy-text-secondary);
font-size: 12px;
}
.xy-audit-summary {
display: grid;
margin: 16px 0;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1px;
overflow: hidden;
border: 1px solid var(--xy-border);
border-radius: 7px;
background: var(--xy-border);
}
.xy-audit-summary > div {
padding: 12px;
background: var(--xy-surface-subtle);
}
.xy-audit-summary span {
color: var(--xy-text-secondary);
font-size: 11px;
}
.xy-audit-summary p {
margin: 4px 0 0;
color: var(--xy-text);
font-size: 13px;
line-height: 20px;
}
.xy-audit-groups {
margin-top: 18px;
}
.xy-audit-groups > section + section {
margin-top: 18px;
}
.xy-audit-groups h4 {
margin: 0 0 10px;
font-size: 14px;
font-weight: 600;
}
.xy-audit-level-one > strong {
display: block;
margin: 10px 0 6px;
color: var(--xy-text-secondary);
font-size: 12px;
font-weight: 500;
}
.xy-risk-detail {
padding: 12px;
border: 1px solid var(--xy-border);
border-radius: 7px;
background: var(--xy-surface-subtle);
}
.xy-risk-detail + .xy-risk-detail {
margin-top: 8px;
}
.xy-risk-detail > strong {
font-size: 12px;
}
.xy-risk-detail dl {
margin: 10px 0 0;
}
.xy-risk-detail dl > div {
display: grid;
grid-template-columns: 96px minmax(0, 1fr);
gap: 8px;
padding-block: 5px;
}
.xy-risk-detail dt,
.xy-risk-detail dd {
margin: 0;
font-size: 12px;
line-height: 19px;
}
.xy-risk-detail dt {
color: var(--xy-text-secondary);
}
.xy-audit-export {
margin-top: 16px;
justify-content: space-between;
gap: 12px;
padding-top: 14px;
border-top: 1px solid var(--xy-border);
}
.xy-audit-export label,
.xy-audit-export > div {
gap: 6px;
}
.xy-audit-export label > span {
color: var(--xy-text-secondary);
font-size: 12px;
}
.xy-audit-export input {
width: 72px;
}
.xy-file-dropzone {
display: flex;
width: 100%;
min-height: 150px;
align-items: center;
justify-content: center;
flex-direction: column;
gap: 7px;
border: 1px dashed var(--xy-border-strong);
border-radius: var(--xy-radius);
color: var(--xy-text-secondary);
background: var(--xy-surface-subtle);
cursor: pointer;
}
.xy-file-dropzone:hover {
border-color: var(--xy-brand);
color: var(--xy-brand);
}
.xy-file-dropzone strong {
color: var(--xy-text);
font-size: 14px;
}
.xy-file-dropzone span {
font-size: 12px;
}
.xy-import-preview {
margin-top: 18px;
}
.xy-import-summary {
justify-content: space-between;
gap: 12px;
}
.xy-import-summary > span {
gap: 6px;
}
.xy-import-table-wrap {
max-height: 300px;
margin: 12px 0;
overflow: auto;
border: 1px solid var(--xy-border);
border-radius: var(--xy-radius);
}
.xy-import-table {
width: 100%;
min-width: 760px;
border-collapse: collapse;
font-size: 12px;
}
.xy-import-table th,
.xy-import-table td {
padding: 9px 10px;
border-bottom: 1px solid var(--xy-border);
text-align: left;
vertical-align: top;
}
.xy-import-table th {
position: sticky;
top: 0;
z-index: 1;
color: var(--xy-text-secondary);
background: var(--xy-surface-subtle);
font-weight: 500;
}
@media (max-width: 640px) {
.xy-audit-bar {
align-items: flex-start;
flex-direction: column;
}
.xy-audit-actions {
width: 100%;
justify-content: flex-end;
}
.xy-audit-summary {
grid-template-columns: 1fr;
}
.xy-audit-export {
align-items: flex-start;
flex-direction: column;
}
}
export const AUDIT_KNOWLEDGE_FILE_SUBJECT = 'audit_knowledge_file'; export const AUDIT_KNOWLEDGE_FILE_SUBJECT = 'audit_knowledge_file';
export const AUDIT_MATCH_THRESHOLD = 80; export const AUDIT_MATCH_THRESHOLD = 80;
export const AUDIT_PROMPT_RECORD_LIMIT = 80; export const AUDIT_PROMPT_RECORD_LIMIT = 80;
export const AUDIT_KNOWLEDGE_QA_MATCH_THRESHOLD = 45;
export const AUDIT_KNOWLEDGE_QA_RECORD_LIMIT = 50;
export const AUDIT_FIELD_ALIASES = { export const AUDIT_FIELD_ALIASES = {
riskPointCode: ['risk_point_code', 'riskPointCode', '风险点编号'], riskPointCode: ['risk_point_code', 'riskPointCode', '风险点编号'],
......
...@@ -49,6 +49,11 @@ const REFINE_PATTERNS = [ ...@@ -49,6 +49,11 @@ const REFINE_PATTERNS = [
'最低分', '最低分',
]; ];
const AUDIT_QUERY_CORRECTIONS = [{ pattern: /反洗线/g, replacement: '反洗钱' }];
const QUERY_ACTION_PATTERN =
/我要开展|帮我开展|开展|实施|发起|启动|进行|做一个|帮我做|帮我|我要/;
function firstValue(...values) { function firstValue(...values) {
return values.find( return values.find(
(value) => value !== undefined && value !== null && value !== '', (value) => value !== undefined && value !== null && value !== '',
...@@ -65,6 +70,171 @@ function toText(value) { ...@@ -65,6 +70,171 @@ function toText(value) {
return String(value).trim(); return String(value).trim();
} }
export function normalizeAuditQuery(query = '') {
return AUDIT_QUERY_CORRECTIONS.reduce(
(normalized, { pattern, replacement }) =>
normalized.replace(pattern, replacement),
String(query),
);
}
function compactAuditText(value = '') {
return normalizeAuditQuery(value).replace(
/[\s,。;、,.;::()()!!??\-_/]+/g,
'',
);
}
function auditTermSimilarity(query, term) {
const normalizedQuery = compactAuditText(query);
const normalizedTerm = compactAuditText(term);
if (!normalizedQuery || !normalizedTerm) return 0;
const characters = new Set(normalizedTerm);
const characterCoverage =
Array.from(characters).filter((character) =>
normalizedQuery.includes(character),
).length / characters.size;
const termBigrams = new Set();
for (let index = 0; index < normalizedTerm.length - 1; index += 1) {
termBigrams.add(normalizedTerm.slice(index, index + 2));
}
const bigramCoverage = termBigrams.size
? Array.from(termBigrams).filter((gram) => normalizedQuery.includes(gram))
.length / termBigrams.size
: 0;
return (
Number(normalizedQuery.includes(normalizedTerm)) * 2 +
characterCoverage +
bigramCoverage
);
}
export function buildAuditQueryCandidates(
records = [],
query = '',
limit = 80,
) {
const candidates = new Map();
const addCandidate = (term, type, priority) => {
const normalized = toText(term);
if (normalized.length < 2 || normalized.length > 40) return;
const current = candidates.get(normalized);
if (!current || priority > current.priority) {
candidates.set(normalized, { term: normalized, type, priority });
}
};
Object.entries(AUDIT_DOMAIN_KEYWORDS).forEach(([domain, keywords]) => {
addCandidate(domain, '审计领域', 5);
keywords.forEach((keyword) => addCandidate(keyword, '领域关键词', 1));
});
records.forEach((record) => {
addCandidate(record.businessSegment, '业务板块', 4);
addCandidate(record.level1Project, '一级项目', 3);
addCandidate(record.level2Project, '二级项目', 2);
(record.keywords || []).forEach((keyword) =>
addCandidate(keyword, '知识库关键词', 1),
);
});
const maxCandidates = Math.max(10, Math.min(120, Number(limit) || 80));
return Array.from(candidates.values())
.map((candidate) => ({
...candidate,
similarity: auditTermSimilarity(query, candidate.term),
}))
.sort(
(left, right) =>
right.similarity - left.similarity ||
right.priority - left.priority ||
left.term.length - right.term.length,
)
.slice(0, maxCandidates)
.map(({ priority: _, ...candidate }) => candidate);
}
export function applyAuditQueryOptimization(
query = '',
payload = {},
candidates = [],
) {
const originalQuery = String(query).trim();
const allowedTerms = new Set(candidates.map((candidate) => candidate.term));
const confidence = Math.max(0, Math.min(1, Number(payload?.confidence) || 0));
const corrections = [];
let suggestedQuery = originalQuery;
if (Array.isArray(payload?.corrections)) {
payload.corrections.forEach((correction) => {
const from = toText(correction?.from);
const to = toText(correction?.to);
if (
!from ||
!originalQuery.includes(from) ||
!allowedTerms.has(to) ||
corrections.some((item) => item.from === from && item.to === to)
) {
return;
}
corrections.push({ from, to });
suggestedQuery = suggestedQuery.split(from).join(to);
});
}
const normalizedSuggestion = toText(payload?.normalizedQuery);
const selectedTerms = new Set();
[payload?.auditDomains, payload?.keywords].forEach((values) => {
if (!Array.isArray(values)) return;
values.forEach((value) => {
const term = toText(value);
if (allowedTerms.has(term)) selectedTerms.add(term);
});
});
if (normalizedSuggestion) {
candidates.forEach(({ term }) => {
if (
normalizedSuggestion.includes(term) &&
!originalQuery.includes(term)
) {
selectedTerms.add(term);
}
});
}
corrections.forEach(({ to }) => selectedTerms.add(to));
const validatedTerms = Array.from(selectedTerms).slice(0, 5);
const missingTerms = validatedTerms.filter(
(term) => !suggestedQuery.includes(term),
);
if (missingTerms.length) {
suggestedQuery = `${suggestedQuery} ${missingTerms.join(' ')}`.trim();
}
const changed = suggestedQuery !== originalQuery;
const hasLocalSimilaritySupport =
corrections.some(({ from, to }) => auditTermSimilarity(from, to) >= 0.55) ||
validatedTerms.some(
(term) => auditTermSimilarity(originalQuery, term) >= 0.55,
);
const needsConfirmation =
changed &&
(Boolean(payload?.needsConfirmation) ||
confidence < 0.75 ||
!hasLocalSimilaritySupport);
return {
originalQuery,
suggestedQuery,
optimizedQuery: suggestedQuery,
corrections,
selectedTerms: validatedTerms,
confidence,
hasLocalSimilaritySupport,
needsConfirmation,
applied: changed && !needsConfirmation,
};
}
export function normalizeRecordList(res) { export function normalizeRecordList(res) {
if (Array.isArray(res)) return res; if (Array.isArray(res)) return res;
if (Array.isArray(res?.records)) return res.records; if (Array.isArray(res?.records)) return res.records;
...@@ -256,11 +426,15 @@ export function extractSourceConstraints(query = '') { ...@@ -256,11 +426,15 @@ export function extractSourceConstraints(query = '') {
function extractPrimaryKeywords(query = '') { function extractPrimaryKeywords(query = '') {
const results = new Set(); const results = new Set();
String(query) normalizeAuditQuery(query)
.match(/[\u4e00-\u9fffA-Za-z0-9]{2,}/g) .match(/[\u4e00-\u9fffA-Za-z0-9]{2,}/g)
?.forEach((token) => { ?.forEach((token) => {
token token
.split(/专项|审计|业务|风险|检查|项目|清单|生成|查询|检索|相关/) .split(
new RegExp(
`${QUERY_ACTION_PATTERN.source}|专项|审计|业务|风险|检查|项目|清单|生成|查询|检索|相关`,
),
)
.map((item) => item.trim()) .map((item) => item.trim())
.filter((item) => item.length >= 2 && !AUDIT_STOP_WORDS.has(item)) .filter((item) => item.length >= 2 && !AUDIT_STOP_WORDS.has(item))
.forEach((item) => results.add(item)); .forEach((item) => results.add(item));
...@@ -269,9 +443,11 @@ function extractPrimaryKeywords(query = '') { ...@@ -269,9 +443,11 @@ function extractPrimaryKeywords(query = '') {
} }
export function extractAuditKeywords(query = '', extraKeywords = []) { export function extractAuditKeywords(query = '', extraKeywords = []) {
const keywords = new Set(extractPrimaryKeywords(query)); const normalizedQuery = normalizeAuditQuery(query);
const primaryKeywords = extractPrimaryKeywords(normalizedQuery);
const keywords = new Set(primaryKeywords);
const domains = []; const domains = [];
const cleaned = String(query).replace(/[\s,。;、,.;::()()]+/g, ''); const cleaned = normalizedQuery.replace(/[\s,。;、,.;::()()]+/g, '');
Object.entries(AUDIT_DOMAIN_KEYWORDS).forEach(([domain, values]) => { Object.entries(AUDIT_DOMAIN_KEYWORDS).forEach(([domain, values]) => {
if ( if (
cleaned.includes(domain) || cleaned.includes(domain) ||
...@@ -288,7 +464,7 @@ export function extractAuditKeywords(query = '', extraKeywords = []) { ...@@ -288,7 +464,7 @@ export function extractAuditKeywords(query = '', extraKeywords = []) {
.forEach((keyword) => keywords.add(keyword)); .forEach((keyword) => keywords.add(keyword));
return { return {
domains, domains,
primaryKeywords: extractPrimaryKeywords(query), primaryKeywords,
keywords: Array.from(keywords) keywords: Array.from(keywords)
.filter((keyword) => !AUDIT_STOP_WORDS.has(keyword)) .filter((keyword) => !AUDIT_STOP_WORDS.has(keyword))
.sort((a, b) => b.length - a.length), .sort((a, b) => b.length - a.length),
...@@ -414,6 +590,18 @@ export function scoreAuditRecord( ...@@ -414,6 +590,18 @@ export function scoreAuditRecord(
} }
if (sourceMatched && matchedKeywords.length) score = Math.max(score, 1.08); if (sourceMatched && matchedKeywords.length) score = Math.max(score, 1.08);
const explicitDomainMatched = keywordInfo.domains.find(
(domain) =>
keywordInfo.primaryKeywords.some((keyword) => keyword.includes(domain)) &&
[record.businessSegment, record.level1Project, record.level2Project].some(
(category) => category.includes(domain),
),
);
if (explicitDomainMatched) {
score = Math.max(score, 1.105);
reasons.push(`审计领域:${explicitDomainMatched}`);
}
const matchScore = Math.max( const matchScore = Math.max(
0, 0,
Math.min(100, Math.round((Number(score) / 1.3) * 100)), Math.min(100, Math.round((Number(score) / 1.3) * 100)),
...@@ -535,12 +723,16 @@ export function buildAuditResult( ...@@ -535,12 +723,16 @@ export function buildAuditResult(
) { ) {
const isRefine = isAuditRefineRequest(query, previousResult); const isRefine = isAuditRefineRequest(query, previousResult);
const baseQuery = isRefine ? previousResult.project : query; const baseQuery = isRefine ? previousResult.project : query;
const normalizedBaseQuery = normalizeAuditQuery(baseQuery);
const defaultThreshold = Number.isFinite(Number(options.threshold))
? Math.max(0, Math.min(100, Number(options.threshold)))
: AUDIT_MATCH_THRESHOLD;
const instruction = isRefine const instruction = isRefine
? parseRefineInstruction(query, previousResult.threshold) ? parseRefineInstruction(query, previousResult.threshold)
: { keep: [], remove: [], add: [], threshold: AUDIT_MATCH_THRESHOLD }; : { keep: [], remove: [], add: [], threshold: defaultThreshold };
const scoringQuery = instruction.add.length const scoringQuery = instruction.add.length
? `${baseQuery} ${instruction.add.join(' ')}` ? `${normalizedBaseQuery} ${instruction.add.join(' ')}`
: baseQuery; : normalizedBaseQuery;
let allScored = scoreAllRecords( let allScored = scoreAllRecords(
records, records,
scoringQuery, scoringQuery,
...@@ -556,13 +748,18 @@ export function buildAuditResult( ...@@ -556,13 +748,18 @@ export function buildAuditResult(
recordMatchesTerms(record, instruction.keep), recordMatchesTerms(record, instruction.keep),
); );
} }
const selected = allScored.filter( const matchedRecords = allScored.filter(
(record) => record.matchScore >= instruction.threshold, (record) => record.matchScore >= instruction.threshold,
); );
const recordLimit = Number(options.recordLimit);
const selected =
Number.isInteger(recordLimit) && recordLimit > 0
? matchedRecords.slice(0, recordLimit)
: matchedRecords;
return { return {
version: 1, version: 1,
kind: classifyAuditIntent(isRefine ? baseQuery : query), kind: options.kind || classifyAuditIntent(isRefine ? baseQuery : query),
project: baseQuery, project: normalizedBaseQuery,
request: query, request: query,
refined: Boolean(isRefine), refined: Boolean(isRefine),
instruction, instruction,
...@@ -570,7 +767,9 @@ export function buildAuditResult( ...@@ -570,7 +767,9 @@ export function buildAuditResult(
threshold: instruction.threshold, threshold: instruction.threshold,
totalCount: records.length, totalCount: records.length,
scoredCount: allScored.length, scoredCount: allScored.length,
matchedCount: selected.length, matchedCount: matchedRecords.length,
returnedCount: selected.length,
truncated: matchedRecords.length > selected.length,
records: selected, records: selected,
groups: groupAuditRecords(selected), groups: groupAuditRecords(selected),
generatedAt: new Date().toISOString(), generatedAt: new Date().toISOString(),
...@@ -635,12 +834,30 @@ export function buildAuditKnowledgePrompt(result) { ...@@ -635,12 +834,30 @@ export function buildAuditKnowledgePrompt(result) {
) )
.join('\n\n'); .join('\n\n');
const stats = `知识库共 ${result.totalCount} 条,全量评分 ${result.scoredCount} 条,匹配度 >= ${result.threshold} 分的有 ${result.matchedCount} 条。`; const stats = `知识库共 ${result.totalCount} 条,全量评分 ${result.scoredCount} 条,匹配度 >= ${result.threshold} 分的有 ${result.matchedCount} 条。`;
const optimization = result.queryOptimization;
const optimizationNotice = optimization?.applied
? `检索词已经过 AI 优化:原问题“${optimization.originalQuery}”,优化后“${
optimization.optimizedQuery
}”,置信度 ${Math.round(optimization.confidence * 100)}%。`
: optimization?.needsConfirmation
? `AI 发现可能的术语纠正,但置信度不足:“${optimization.suggestedQuery}”。请询问用户是否按该术语检索,不要直接采用。`
: '';
const noResultInstruction = optimization?.needsConfirmation
? '请只询问用户是否按候选术语检索,未确认前不得生成或补充风险点。'
: '没有达到当前阈值的风险点。请明确说明知识库覆盖不足或建议用户降低匹配度阈值,不得编造风险点。';
const retrievalStats =
result.matchedCount > records.length
? `本次仅提供匹配度最高的 ${records.length} 条原文,另有 ${
result.matchedCount - records.length
} 条命中记录未放入模型上下文。回答不得声称已经穷尽全部命中结果。`
: `本次提供全部 ${records.length} 条命中原文。`;
if (!records.length) { if (!records.length) {
return [ return [
'审计风险点知识库检索结果:', '审计风险点知识库检索结果:',
optimizationNotice,
stats, stats,
'没有达到当前阈值的风险点。请明确说明知识库覆盖不足或建议用户降低匹配度阈值,不得编造风险点。', noResultInstruction,
`用户请求:${result.request}`, `用户请求:${result.request}`,
'', '',
].join('\n'); ].join('\n');
...@@ -649,7 +866,9 @@ export function buildAuditKnowledgePrompt(result) { ...@@ -649,7 +866,9 @@ export function buildAuditKnowledgePrompt(result) {
if (result.kind === 'qa') { if (result.kind === 'qa') {
return [ return [
'审计风险点知识库问答上下文:', '审计风险点知识库问答上下文:',
optimizationNotice,
stats, stats,
retrievalStats,
AUDIT_CHECKLIST_RULES, AUDIT_CHECKLIST_RULES,
context, context,
'', '',
...@@ -660,6 +879,8 @@ export function buildAuditKnowledgePrompt(result) { ...@@ -660,6 +879,8 @@ export function buildAuditKnowledgePrompt(result) {
return [ return [
'审计检查清单知识库上下文:', '审计检查清单知识库上下文:',
optimizationNotice,
`审计项目:${result.project}`,
stats, stats,
AUDIT_CHECKLIST_RULES, AUDIT_CHECKLIST_RULES,
context, context,
......
import JSZip from 'jszip'; import JSZip from 'jszip';
import * as XLSX from 'xlsx';
import { groupAuditRecords } from './engine'; import { groupAuditRecords } from './engine';
const SUMMARY_FIELDS = [ const SUMMARY_FIELDS = [
...@@ -158,50 +157,6 @@ export function exportAuditMarkdown(result, aiContent, threshold) { ...@@ -158,50 +157,6 @@ export function exportAuditMarkdown(result, aiContent, threshold) {
); );
} }
export function exportAuditExcel(result, aiContent, threshold) {
const summary = parseAuditSummary(aiContent, result.project);
const records = filterAuditRecords(result, threshold);
const workbook = XLSX.utils.book_new();
const summarySheet = XLSX.utils.json_to_sheet(
SUMMARY_FIELDS.map((field) => ({
清单要素: field,
内容: summary[field] || '',
})),
);
summarySheet['!cols'] = [{ wch: 14 }, { wch: 90 }];
XLSX.utils.book_append_sheet(workbook, summarySheet, '审计方案');
const riskSheet = XLSX.utils.json_to_sheet(
records.map((record, index) => ({
序号: index + 1,
业务板块: record.businessSegment,
一级项目: record.level1Project,
二级项目: record.level2Project,
匹配度: record.matchScore,
匹配等级: record.matchLevel,
具体风险点描述: record.riskDescription,
法规依据: record.regulationBasis,
风险点来源: record.riskSource,
风险等级: record.riskLevel,
来源行号: record.sourceRow,
})),
);
riskSheet['!cols'] = [
{ wch: 8 },
{ wch: 18 },
{ wch: 22 },
{ wch: 24 },
{ wch: 10 },
{ wch: 10 },
{ wch: 60 },
{ wch: 50 },
{ wch: 22 },
{ wch: 12 },
{ wch: 10 },
];
XLSX.utils.book_append_sheet(workbook, riskSheet, '风险点明细');
XLSX.writeFile(workbook, `${safeFileName(result.project)}.xlsx`);
}
function wordText(value = '') { function wordText(value = '') {
return String(value) return String(value)
.split('\n') .split('\n')
...@@ -454,92 +409,3 @@ export async function exportAuditWord(result, aiContent, threshold) { ...@@ -454,92 +409,3 @@ export async function exportAuditWord(result, aiContent, threshold) {
`${safeFileName(result.project)}.docx`, `${safeFileName(result.project)}.docx`,
); );
} }
function createGroundedAuditWordDocumentXml({
title,
question,
aiContent,
records = [],
}) {
const body = [
wordParagraph(wordRun(title || '审计知识库问答'), {
style: 'Title',
alignment: 'center',
}),
wordParagraph(
wordRun(`生成时间:${formatWordDate()}`, {
size: '18',
color: '808080',
}),
{ alignment: 'right' },
),
wordParagraph(wordRun('用户问题', { bold: true }), {
style: 'Heading1',
}),
wordParagraph(wordRun(question || '(未记录)'), { firstLine: '420' }),
wordParagraph(wordRun('智能体回答', { bold: true }), {
style: 'Heading1',
}),
wordParagraph(wordRun(stripMarkdown(aiContent) || '(无回答内容)'), {
firstLine: '420',
}),
wordParagraph(wordRun('知识库原文依据', { bold: true }), {
style: 'Heading1',
}),
];
if (!records.length) {
body.push(wordParagraph(wordRun('(当前知识库没有可用风险点)')));
return wrapWordDocument(body);
}
records.forEach((record, index) => {
const heading =
record.riskPointCode ||
record.level2Project ||
record.riskDescription ||
`风险点 ${index + 1}`;
const details = [
['风险点编号', record.riskPointCode],
['业务板块', record.businessSegment],
['一级项目', record.level1Project],
['二级项目', record.level2Project],
['具体风险点描述', record.riskDescription],
['法规依据', record.regulationBasis],
['风险点来源', record.riskSource],
['风险等级', record.riskLevel],
['来源文件名', record.sourceFileName],
['来源行号', record.sourceRow],
['文件批次', record.sourceBatch],
['备注', record.remark],
];
body.push(
wordParagraph(wordRun(`${index + 1}. ${heading}`, { bold: true }), {
style: 'Heading2',
}),
...details
.filter(
([, value]) => value !== undefined && value !== null && value !== '',
)
.map(([label, value]) =>
wordParagraph(
[
wordRun(`${label}:`, { bold: true, size: '21' }),
wordRun(String(value), { size: '21' }),
],
{ leftIndent: '283' },
),
),
);
});
return wrapWordDocument(body);
}
export async function exportGroundedAuditWord(options) {
downloadBlob(
await createWordBlob(createGroundedAuditWordDocumentXml(options)),
`${safeFileName(options.title || '审计知识库问答')}.docx`,
);
}
import { Toast } from '@heroui/react/toast';
import { Outlet } from '@umijs/max'; import { Outlet } from '@umijs/max';
import { App, ConfigProvider, theme } from 'antd';
import { createGlobalStyle } from 'antd-style';
const { defaultSeed, defaultAlgorithm } = theme;
const APP_FONT_FAMILY =
"'PingFang SC', 'Microsoft YaHei', 'Helvetica Neue', Arial, sans-serif";
const GlobalStyle = createGlobalStyle`
:root {
--app-shell-bg:
radial-gradient(
circle at 18% 8%,
rgba(34, 211, 238, 0.16),
transparent 28%
),
radial-gradient(
circle at 84% 16%,
rgba(99, 102, 241, 0.2),
transparent 26%
),
radial-gradient(
circle at 74% 86%,
rgba(14, 165, 233, 0.13),
transparent 30%
),
linear-gradient(135deg, #050816 0%, #08111f 46%, #111827 100%);
--app-surface: rgba(15, 23, 42, 0.74);
--app-surface-strong: rgba(15, 23, 42, 0.94);
--app-brand: #22d3ee;
--app-brand-strong: #38bdf8;
--app-sidebar: rgba(2, 6, 23, 0.82);
--app-sidebar-soft: rgba(15, 23, 42, 0.84);
--app-border: rgba(125, 211, 252, 0.16);
--app-shadow: 0 28px 80px rgba(0, 0, 0, 0.34);
--app-shadow-soft: 0 18px 48px rgba(0, 0, 0, 0.28);
--app-radius-xl: 32px;
--app-radius-lg: 24px;
--app-radius-md: 18px;
--app-radius-sm: 14px;
--app-content-width: 960px;
}
html,
body,
#root {
width: 100%;
height: 100%;
min-height: 100%;
overflow: hidden;
}
body {
margin: 0;
background: var(--app-shell-bg);
color: #e5eefb;
font-family: ${APP_FONT_FAMILY};
}
* {
box-sizing: border-box;
}
*::-webkit-scrollbar {
width: 10px;
height: 10px;
}
*::-webkit-scrollbar-thumb {
background: rgba(125, 211, 252, 0.36);
border: 2px solid transparent;
border-radius: 999px;
background-clip: content-box;
}
*::-webkit-scrollbar-track {
background: transparent;
}
a {
color: inherit;
text-decoration: none;
}
.app-glass-card {
background: var(--app-surface);
border: 1px solid var(--app-border);
box-shadow: var(--app-shadow-soft);
backdrop-filter: blur(18px);
}
`;
export default () => {
const themeToken = defaultAlgorithm({
...defaultSeed,
colorPrimary: '#22d3ee',
colorInfo: '#38bdf8',
colorSuccess: '#1d9b62',
colorWarning: '#d97706',
colorError: '#dc2626',
colorBgBase: '#050816',
colorBgContainer: '#0f172a',
colorTextBase: '#e5eefb',
borderRadius: 18,
borderRadiusLG: 24,
borderRadiusSM: 14,
boxShadow:
'0 20px 50px rgba(15, 23, 42, 0.12), 0 6px 18px rgba(15, 23, 42, 0.06)',
boxShadowSecondary:
'0 12px 30px rgba(15, 23, 42, 0.08), 0 4px 12px rgba(15, 23, 42, 0.05)',
controlHeight: 44,
fontSize: 15,
});
export default function Layout() {
return ( return (
<> <div className="xy-app-root">
<GlobalStyle />
<ConfigProvider
theme={{
token: themeToken,
components: {
Layout: {
bodyBg: 'transparent',
siderBg: 'transparent',
},
Button: {
borderRadius: 14,
controlHeight: 42,
primaryShadow: 'none',
},
Input: {
borderRadius: 14,
activeShadow: '0 0 0 3px rgba(200, 30, 58, 0.14)',
},
Modal: {
borderRadiusLG: 24,
},
Collapse: {
headerBg: 'transparent',
contentBg: 'transparent',
},
Drawer: {
colorBgElevated: '#0f172a',
},
Steps: {
colorPrimary: '#c81e3a',
},
},
}}
>
<App>
<Outlet /> <Outlet />
</App> <Toast.Provider maxVisibleToasts={3} placement="top" />
</ConfigProvider> </div>
</>
); );
}; }
import { PageContainer } from '@ant-design/pro-components'; import { Button } from '@heroui/react/button';
import { Access, useAccess } from '@umijs/max'; import { Access, useAccess } from '@umijs/max';
import { Button } from 'antd';
const AccessPage = () => { export default function AccessPage() {
const access = useAccess(); const access = useAccess();
return ( return (
<PageContainer <main style={{ padding: 24 }}>
ghost <h1 style={{ margin: '0 0 16px', fontSize: 20 }}>权限示例</h1>
header={{
title: '权限示例',
}}
>
<Access accessible={access.canSeeAdmin}> <Access accessible={access.canSeeAdmin}>
<Button>只有 Admin 可以看到这个按钮</Button> <Button>只有 Admin 可以看到这个按钮</Button>
</Access> </Access>
</PageContainer> </main>
); );
}; }
export default AccessPage;
This source diff could not be displayed because it is too large. You can view the blob instead.
.xy-chat {
display: grid;
width: 100%;
height: 100%;
min-height: 0;
grid-template-rows: minmax(0, 1fr) auto;
color: var(--xy-text);
background: var(--xy-bg);
}
.xy-chat-conversation {
min-height: 0;
}
.xy-chat-content {
width: 100%;
max-width: calc(var(--xy-content-width) + 48px);
min-height: 100%;
margin: 0 auto;
padding: 28px 24px 32px;
}
.xy-chat-empty,
.xy-conversation-intro {
display: flex;
min-height: 52vh;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
}
.xy-conversation-intro {
min-height: 34vh;
}
.xy-empty-mark,
.xy-agent-icon {
display: inline-flex;
width: 40px;
height: 40px;
align-items: center;
justify-content: center;
border: 1px solid var(--xy-border);
border-radius: 50%;
color: var(--xy-brand);
background: var(--xy-surface);
}
.xy-chat-empty h1,
.xy-conversation-intro h2 {
margin: 14px 0 6px;
color: var(--xy-text);
font-size: 24px;
font-weight: 600;
line-height: 32px;
letter-spacing: 0;
}
.xy-conversation-intro h2 {
font-size: 20px;
line-height: 28px;
}
.xy-chat-empty > p,
.xy-conversation-intro > p {
max-width: 520px;
margin: 0;
color: var(--xy-text-secondary);
font-size: 14px;
line-height: 22px;
}
.xy-agent-suggestions {
width: min(100%, 760px);
margin-top: 28px;
}
.xy-agent-suggestions [data-slot='prompt-suggestion-items'] {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.xy-agent-suggestions [data-slot='prompt-suggestion-item'] {
min-height: 132px;
border: 1px solid var(--xy-border);
border-radius: var(--xy-radius);
background: var(--xy-surface);
box-shadow: 0 1px 3px rgb(0 0 0 / 6%);
cursor: pointer;
text-align: left;
transform: translateZ(0);
transition: background-color 180ms ease-out, border-color 180ms ease-out,
box-shadow 180ms ease-out, transform 180ms ease-out;
}
@media (hover: hover) {
.xy-agent-suggestions [data-slot='prompt-suggestion-item']:hover {
border-color: color-mix(in srgb, var(--xy-brand) 42%, var(--xy-border));
background: color-mix(in srgb, var(--xy-brand) 3%, var(--xy-surface));
box-shadow: 0 10px 26px rgb(0 0 0 / 10%);
transform: translateY(-2px);
}
.xy-agent-suggestions
[data-slot='prompt-suggestion-item']:hover
.xy-agent-icon {
border-color: color-mix(in srgb, var(--xy-brand) 45%, var(--xy-border));
background: color-mix(in srgb, var(--xy-brand) 9%, var(--xy-surface));
}
}
.xy-agent-suggestions [data-slot='prompt-suggestion-item']:active {
box-shadow: 0 2px 8px rgb(0 0 0 / 8%);
transform: translateY(0) scale(0.99);
}
.xy-agent-suggestions [data-slot='prompt-suggestion-item']:focus-visible {
border-color: color-mix(in srgb, var(--xy-brand) 58%, var(--xy-border));
outline: none;
box-shadow: 0 0 0 3px color-mix(in srgb, var(--xy-brand) 16%, transparent),
0 8px 20px rgb(0 0 0 / 8%);
}
.xy-agent-icon {
width: 34px;
height: 34px;
margin-bottom: 12px;
border-radius: 7px;
transition: background-color 180ms ease-out, border-color 180ms ease-out;
}
.xy-chat [data-slot='chat-message-user'],
.xy-chat [data-slot='chat-message-assistant'] {
margin-block: 22px;
}
.xy-chat [data-slot='chat-message-bubble'] {
max-width: min(84%, 620px);
border-radius: var(--xy-radius);
color: var(--xy-text);
background: var(--xy-surface-hover);
}
.xy-chat [data-slot='chat-message-content'] {
color: var(--xy-text);
}
.xy-chat [data-slot='chat-message-body'] {
min-width: 0;
}
.xy-chat .markdown {
color: var(--xy-text);
font-size: 14px;
line-height: 1.75;
}
.xy-chat .markdown pre {
border: 1px solid #27272a;
border-radius: var(--xy-radius);
}
.xy-composer-wrap {
z-index: 20;
width: 100%;
max-width: calc(var(--xy-content-width) + 48px);
margin: 0 auto;
padding: 0 24px 14px;
background: linear-gradient(to bottom, transparent, var(--xy-bg) 24%);
}
.xy-composer {
border: 1px solid var(--xy-border);
border-radius: var(--xy-radius);
background: var(--xy-surface);
box-shadow: 0 8px 24px rgb(0 0 0 / 8%);
transition: border-color 160ms ease, box-shadow 160ms ease;
}
.xy-composer:focus-within {
border-color: color-mix(in srgb, var(--xy-brand) 58%, var(--xy-border));
box-shadow: 0 0 0 3px color-mix(in srgb, var(--xy-brand) 12%, transparent),
0 8px 24px rgb(0 0 0 / 8%);
}
.xy-composer [data-slot='prompt-input-shell'] {
border: 0;
border-radius: inherit;
background: transparent;
}
.xy-composer [data-slot='prompt-input-textarea'] {
min-height: 54px;
border-radius: 0;
color: var(--xy-text);
font-size: 14px;
outline: none !important;
box-shadow: none !important;
}
.xy-composer input[type='file'][aria-hidden='true'] {
display: none;
}
.xy-composer-icon-button {
display: inline-grid;
width: 32px;
height: 32px;
place-items: center;
padding: 0;
border: 0;
border-radius: 50%;
color: var(--xy-text-secondary);
background: var(--xy-surface-subtle);
cursor: pointer;
}
.xy-composer-icon-button:hover {
color: var(--xy-text);
background: var(--xy-surface-hover);
}
.xy-model-select {
min-width: 0;
}
.xy-model-select-trigger {
display: inline-flex;
width: min(180px, 24vw);
height: 32px;
min-width: 112px;
align-items: center;
gap: 6px;
padding: 0 8px;
border: 1px solid var(--xy-border);
border-radius: 6px;
color: var(--xy-text-secondary);
background: var(--xy-surface-subtle);
cursor: pointer;
transition: border-color 160ms ease, color 160ms ease,
background-color 160ms ease;
}
.xy-model-select-trigger [data-slot='select-value'] {
min-width: 0;
flex: 1;
overflow: hidden;
color: var(--xy-text);
font-size: 12px;
font-weight: 500;
text-overflow: ellipsis;
white-space: nowrap;
}
.xy-model-select-trigger:hover {
border-color: var(--xy-border-strong);
color: var(--xy-text);
background: var(--xy-surface-hover);
}
.xy-model-select-trigger:disabled {
cursor: not-allowed;
opacity: 0.5;
}
.xy-model-select-popover {
width: min(280px, calc(100vw - 24px));
max-height: min(360px, 60vh);
overflow-y: auto;
padding: 4px;
border: 1px solid var(--xy-border);
border-radius: var(--xy-radius);
background: var(--xy-surface);
box-shadow: 0 12px 32px rgb(0 0 0 / 14%);
}
.xy-model-select-option {
cursor: pointer;
}
.xy-model-select-option-copy {
display: flex;
min-width: 0;
flex: 1;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.xy-model-select-option-copy strong {
overflow-wrap: anywhere;
color: var(--xy-text);
font-size: 13px;
font-weight: 500;
letter-spacing: 0;
}
.xy-model-select-option-copy small {
flex: none;
color: var(--xy-text-muted);
font-size: 11px;
}
.xy-composer-note {
margin: 6px 0 0;
color: var(--xy-text-muted);
font-size: 11px;
line-height: 16px;
text-align: center;
}
@media (max-width: 768px) {
.xy-chat [data-slot='chat-conversation-scroll-button'],
.xy-composer-icon-button,
.xy-composer [data-slot='prompt-input-action'],
.xy-composer [data-slot='prompt-input-send'] {
width: 44px;
height: 44px;
min-width: 44px;
}
.xy-chat-content {
padding: 18px 14px 24px;
}
.xy-chat-empty {
min-height: 46vh;
}
.xy-chat-empty h1 {
font-size: 21px;
line-height: 28px;
}
.xy-agent-suggestions [data-slot='prompt-suggestion-items'] {
grid-template-columns: 1fr;
}
.xy-agent-suggestions [data-slot='prompt-suggestion-item'] {
min-height: 112px;
}
.xy-chat [data-slot='chat-message-bubble'] {
max-width: 90%;
}
.xy-composer-wrap {
max-width: none;
padding: 0 12px max(10px, env(safe-area-inset-bottom));
}
.xy-agent-chip {
display: none;
}
.xy-model-select-trigger {
width: min(156px, 42vw);
height: 44px;
}
}
import { // Historical invoice conversations now use the shared HeroUI Pro chat surface.
BulbOutlined, export { default } from './index';
CheckCircleOutlined,
CloudUploadOutlined,
CopyOutlined,
DownloadOutlined,
PaperClipOutlined,
PayCircleOutlined,
ReloadOutlined,
} from '@ant-design/icons';
import {
Attachments,
Bubble,
Prompts,
Sender,
XStream,
useXAgent,
useXChat,
} from '@ant-design/x';
import { App, Button, Flex, Space, Spin, Steps, Tag, Typography } from 'antd';
import { createStyles } from 'antd-style';
import 'katex/dist/katex.min.css';
import _ from 'lodash';
import { marked } from 'marked';
import marketKatex from 'marked-katex-extension';
import {
forwardRef,
useEffect,
useImperativeHandle,
useMemo,
useRef,
useState,
} from 'react';
import {
callMcpTool,
createConversation,
extractLlmChunkContent,
extractLlmChunkReasoning,
getApplicationRuntimeConfig,
llmChat,
queryMcps,
resolveLlmModel,
updateConversation,
} from '../../services/DataService';
// 导入附件生成函数
import { generateExcelZip } from '../../utils/excelGenerator.js';
// 导入JSZip用于解压ZIP文件
import JSZip from 'jszip';
marked.use(
marketKatex({
strict: false,
throwOnError: false,
nonStandard: true,
}),
);
const preprocessLaTeX = (content) => {
return content.replace(/\s\$\$\s/gi, '\n\n$$$$\n\n');
};
const sanitizeHtml = (html) => {
if (typeof html !== 'string') {
return '';
}
return html
.replace(/<script[\s\S]*?>[\s\S]*?<\/script>/gi, '')
.replace(/on[a-z]+=(".*?"|'.*?'|[^\s>]+)/gi, '')
.replace(/javascript:/gi, '');
};
const SYSTEM_PROMPT = `你叫Mia,是一个有用的的发票智能助手。如果用户提供了上下文,请根据上下文回答问题,不要试图创造一个答案。
如果你不知道答案,请回答你只能根据上下文的内容回答问题。在识别完文件之后,需要调用mcp工具生成用户可能需要的问题选项。用户确认数量没问题之后,需要生成一个表单供用户填写。接收到的是html的时候,assistent角色回复的消息需要是html格式,只要用户发送了表单信息,那么无论表单信息内容是否为空,都需要调用mcp工具对表单信息进行标准化处理
`;
const useStyle = createStyles(({ token, css, responsive }) => {
return {
chat: css`
flex: 1;
min-height: 0;
display: flex;
flex-direction: row;
gap: 18px;
overflow: hidden;
${responsive.md} {
flex-direction: column;
}
`,
chatWithSidebar: css`
height: 100%;
width: 100%;
display: flex;
flex-direction: column;
gap: 18px;
flex: 1;
order: 1;
`,
stepsSidebar: css`
width: 300px;
min-width: 300px;
height: 100%;
background: linear-gradient(
180deg,
rgba(255, 255, 255, 0.96) 0%,
rgba(248, 250, 252, 0.92) 100%
);
border: 1px solid rgba(148, 163, 184, 0.16);
border-radius: 28px;
padding: 22px 20px;
display: flex;
flex-direction: column;
gap: 16px;
overflow-y: auto;
order: 2;
box-shadow: 0 20px 40px rgba(15, 23, 42, 0.08);
${responsive.md} {
width: 100%;
min-width: 0;
max-height: 280px;
}
`,
sidebarTitle: css`
font-size: 18px;
font-weight: 600;
color: #e5eefb;
display: flex;
align-items: center;
gap: 8px;
`,
chatList: css`
flex: 1;
min-height: 0;
overflow: auto;
padding: 2px 2px 10px;
`,
chatListInner: css`
display: flex;
flex-direction: column;
min-height: 100%;
width: min(100%, var(--app-content-width));
margin: 0 auto;
gap: 16px;
`,
loadingMessage: css`
background-image: linear-gradient(90deg, #3f9c35 0%, #009cde 100%);
background-size: 100% 3px;
background-repeat: no-repeat;
background-position: bottom;
`,
placeholder: css`
justify-content: center;
padding-top: 10px;
`,
sender: css`
width: 100%;
max-width: var(--app-content-width);
margin: 0 auto;
.ant-sender {
border-radius: 24px !important;
border: 1px solid rgba(148, 163, 184, 0.18) !important;
background: rgba(255, 255, 255, 0.9) !important;
box-shadow: 0 18px 40px rgba(15, 23, 42, 0.08) !important;
padding: 10px !important;
}
.ant-sender-header {
margin-bottom: 8px;
border-radius: 18px;
overflow: hidden;
}
`,
speechButton: css`
font-size: 18px;
color: ${token.colorTextSecondary} !important;
`,
senderPrompt: css`
width: 100%;
max-width: var(--app-content-width);
margin: 0 auto;
color: ${token.colorText};
`,
assistantBubble: css`
background: linear-gradient(
180deg,
rgba(15, 23, 42, 0.92) 0%,
rgba(15, 23, 42, 0.74) 100%
) !important;
border: 1px solid rgba(125, 211, 252, 0.18) !important;
border-radius: 22px 22px 22px 8px !important;
padding: 16px 18px !important;
box-shadow: 0 18px 42px rgba(0, 0, 0, 0.28),
inset 0 1px 0 rgba(255, 255, 255, 0.08) !important;
color: #e5eefb !important;
backdrop-filter: blur(18px);
overflow: hidden;
`,
userBubble: css`
background: linear-gradient(135deg, #0ea5e9 0%, #2563eb 100%) !important;
color: #ffffff;
border: 1px solid rgba(186, 230, 253, 0.26) !important;
border-radius: 20px 20px 8px 20px !important;
box-shadow: 0 16px 36px rgba(14, 165, 233, 0.24),
inset 0 1px 0 rgba(255, 255, 255, 0.18) !important;
padding: 13px 17px !important;
font-size: 15px;
line-height: 1.75;
overflow-wrap: anywhere;
`,
senderShell: css`
width: min(100%, calc(var(--app-content-width) + 24px));
margin: 0 auto;
padding: 8px;
border-radius: 30px;
background: rgba(2, 6, 23, 0.32);
border: 1px solid rgba(125, 211, 252, 0.12);
box-shadow: 0 16px 38px rgba(0, 0, 0, 0.22);
`,
bubbleList: css`
.ant-bubble-list {
gap: 22px;
}
.ant-bubble {
width: 100%;
max-width: 100%;
}
.ant-bubble-start,
.ant-bubble-end {
align-items: flex-start !important;
column-gap: 14px !important;
}
.ant-bubble-end {
justify-content: flex-start !important;
}
.ant-bubble-avatar {
width: 40px !important;
height: 40px !important;
margin-top: 3px !important;
flex: 0 0 40px !important;
border-radius: 16px;
background: rgba(15, 23, 42, 0.72);
border: 1px solid rgba(125, 211, 252, 0.22);
box-shadow: 0 12px 28px rgba(0, 0, 0, 0.22),
0 0 18px rgba(34, 211, 238, 0.1);
overflow: hidden;
.ant-avatar,
img {
width: 40px !important;
height: 40px !important;
line-height: 40px !important;
border-radius: 15px !important;
font-size: 13px !important;
font-weight: 700;
}
}
.ant-bubble-content {
max-width: 100% !important;
}
.ant-bubble-content-wrapper {
min-width: 0;
max-width: min(760px, calc(100% - 54px));
}
.ant-bubble-end .ant-bubble-content-wrapper {
display: flex;
flex-direction: column;
align-items: flex-end;
}
.ant-bubble-footer {
width: 100%;
margin-top: 8px !important;
padding-top: 8px;
border-top: 1px solid rgba(125, 211, 252, 0.14);
}
.ant-bubble-end .ant-bubble-footer {
display: flex;
justify-content: flex-end;
}
${responsive.xs} {
.ant-bubble-list {
gap: 14px;
}
.ant-bubble-start,
.ant-bubble-end {
gap: 10px !important;
}
.ant-bubble-avatar {
width: 32px !important;
height: 32px !important;
margin-top: 5px !important;
flex-basis: 32px !important;
.ant-avatar,
img {
width: 32px !important;
height: 32px !important;
line-height: 32px !important;
font-size: 12px !important;
}
}
.ant-bubble-content-wrapper {
max-width: calc(100% - 42px);
}
}
`,
markdown: css`
font-size: 15px;
line-height: 1.8;
color: #e5eefb;
overflow-wrap: anywhere;
> * {
max-width: 100%;
}
p:first-child {
margin-top: 0;
}
p:last-child {
margin-bottom: 0;
}
pre {
overflow-x: auto;
border-radius: 16px;
background: rgba(2, 6, 23, 0.86);
color: #e2e8f0;
padding: 14px 16px;
}
hr {
margin: 16px 0;
border: 0;
border-top: 1px solid rgba(125, 211, 252, 0.16);
}
blockquote {
margin: 12px 0;
padding: 2px 0 2px 14px;
border-left: 3px solid rgba(14, 165, 233, 0.28);
color: #cbd5e1;
}
`,
assistantActions: css`
display: inline-flex;
align-items: center;
gap: 4px !important;
.ant-btn {
width: 32px;
height: 32px;
min-width: 32px;
border-radius: 10px;
color: #64748b;
}
.ant-btn:hover {
background: rgba(14, 165, 233, 0.1) !important;
color: #0284c7 !important;
}
`,
heroCard: css`
padding: 28px;
border-radius: 30px;
background: radial-gradient(
circle at top right,
rgba(125, 211, 252, 0.32),
transparent 24%
),
linear-gradient(
180deg,
rgba(255, 255, 255, 0.98) 0%,
rgba(248, 250, 252, 0.92) 100%
);
border: 1px solid rgba(148, 163, 184, 0.16);
box-shadow: 0 22px 54px rgba(15, 23, 42, 0.08);
`,
promptCard: css`
.ant-prompts-item,
.ant-prompts-sub-item {
border-radius: 18px !important;
border: 1px solid rgba(148, 163, 184, 0.14) !important;
background: rgba(255, 255, 255, 0.8) !important;
}
`,
stepHint: css`
color: #64748b;
font-size: 13px;
line-height: 1.6;
`,
downloadButton: css`
margin-top: auto;
width: 100%;
height: 42px;
`,
sectionTag: css`
width: fit-content;
`,
};
});
const InvoiceAssistant = forwardRef((props, ref) => {
const { conversation, onAddConversation, userInfo } = props;
const { styles } = useStyle();
const { message: messageApi } = App.useApp();
// 发票助手相关状态
const [attachmentsOpen, setAttachmentsOpen] = useState(false);
const [attachedFiles, setAttachedFiles] = useState([]);
const [inputValue, setInputValue] = useState('');
const [currentStep, setCurrentStep] = useState(0);
const projectInfo = useRef({});
const senderRef = useRef(null);
const abortController = useRef(null);
const outputListRef = useRef([]);
const renamedFilesRef = useRef([]);
const processedFilesRef = useRef([]); // 存储处理后的文件列表(包含解压后的文件,不包含ZIP文件)
const chatEndRef = useRef(null);
const chatContainerRef = useRef(null);
// 由智能体思考并填充的步骤列表(进入页面后由助手输出决定)
const [stepsList, setStepsList] = useState([]);
const [thinkPrompts, setThinkPrompts] = useState([]);
const thinkPromptItems = useMemo(() => {
if (!thinkPrompts?.length) {
return [];
}
return [
{
key: 'think-prompts',
label: '推荐回答',
children: thinkPrompts.map((prompt) => ({
key: prompt.key,
title: prompt.title,
description: prompt.description,
icon: <BulbOutlined />,
data: prompt,
})),
},
];
}, [thinkPrompts]);
const didSetStepsFromAIRef = useRef(false);
// 进入页面或收到首条助手消息时,从助手文本动态推导步骤并填充(固定4步)
const extractStepsFromMessages = (message) => {
// 从当前的messagr中尝试提取步骤
let text = '';
text = message.content;
// 提取形如 1. xxx / 1)xxx / 步骤x:xxx / - xxx 的行作为步骤
// 支持更多格式:1.、1)、一.、第一步、Step 1等
const lines = text
.split(/\r?\n/)
.map((l) => l.trim())
.filter(Boolean);
const stepLines = lines.filter((l) => {
return /^(\d+[.)])|^[一二三四五六][.、)]|^步骤[一二三四五六\d]|^[S]tep\s*\d+|^[-•・●◦▪▫]/i.test(
l,
);
});
const parsed = stepLines.map((l) => {
// 移除各种前缀格式
let title = l
.replace(/^(\d+[.)]\s*)/, '') // 1. 或 1)
.replace(/^([一二三四五六][.、)]\s*)/, '') // 一. 或 一)
.replace(/^步骤[一二三四五六\d]+[::.)\s]*/i, '') // 步骤1:或 步骤一:
.replace(/^[S]tep\s*\d+[::.)\s]*/i, '') // Step 1: 或 step 1:
.replace(/^[-•・●◦▪▫]\s*/, '') // 各种列表符号
.trim();
return { title: title || '步骤' };
});
// 只取前6个步骤
const extractedSteps = parsed.slice(0, 6);
const uniqTitles = Array.from(new Set(extractedSteps.map((p) => p.title)));
// 分割为标题和详情的对象数组
const firstStep = uniqTitles[0] || '';
const shouldSplit = firstStep.includes(':');
const formattedSteps = uniqTitles.map((step) => {
if (!shouldSplit) {
return {
title: step.trim(),
detail: '',
};
}
const [title, ...detailParts] = step.split(':');
const detail = detailParts.join(':').trim();
return {
title: title.trim(),
detail,
};
});
// 确保有至少2个步骤,且不超过6个
if (formattedSteps.length >= 2 && formattedSteps.length <= 6) {
setStepsList(
formattedSteps.map((t) => ({
title: t.title,
description: t.detail || '',
})),
);
didSetStepsFromAIRef.current = true;
}
};
useImperativeHandle(ref, () => {
return {
submit: async (message) => {
const files = message.files || [];
setAttachedFiles(files);
await onSubmit({
content: message.content,
files,
});
},
};
});
const transformMessage = (originMessage, chunk) => {
const originContent = originMessage?.content || '';
const originThink = originMessage?.think || '';
let currentContent = '';
let currentThink = '';
const toolCalls = originMessage?.tool_calls || [];
if (!chunk?.data) {
return originMessage;
}
if (chunk.data.includes('DONE')) {
return {
...originMessage,
tool_calls: undefined,
};
}
if (chunk.data.includes('LOADING')) {
return {
loading: true,
content: '',
role: 'assistant',
};
}
try {
let messageData;
try {
messageData = JSON.parse(chunk?.data);
} catch (jsonError) {
console.error('消息数据JSON解析失败:', jsonError);
console.error('原始数据:', chunk?.data);
return originMessage;
}
currentThink = extractLlmChunkReasoning(messageData);
currentContent = extractLlmChunkContent(messageData);
messageData?.choices?.[0]?.delta?.tool_calls?.forEach((call) => {
const index = call.index;
const toolCall = (toolCalls[index] = toolCalls[index] || {
id: null,
index: null,
type: null,
function: {
name: '',
arguments: '',
},
});
toolCall.id = toolCall.id || call.id;
toolCall.index = toolCall.index || index;
toolCall.type = toolCall.type || call.type;
toolCall.function.name += call.function.name || '';
toolCall.function.arguments += call.function.arguments || '';
});
} catch (error) {
console.error(error);
}
// 注释掉这里的打印,避免在 reduce 过程中多次打印
return {
think: `${originThink}${currentThink}`,
content: `${originContent}${currentContent}`,
role: 'assistant',
tool_calls: toolCalls,
tools: toolCalls,
};
};
const getTools = async () => {
const res = await queryMcps({
subject: 'invoice_assitence',
filter: {
name: 'invoice_toolset',
},
});
return res.mcps.flatMap((mcp) =>
mcp.tools.map((tool) => ({
type: 'function',
function: {
name: `${mcp.name}:${tool.definition.name}`,
description: tool.definition.description.trim(),
parameters: tool.definition.params || {},
required: tool.definition.params
? Object.keys(tool.definition.params).filter(
(key) => tool.definition.params[key].required,
)
: [],
},
})),
);
};
const getMessagePrompt = async (message) => {
const fileAnalyzeResults = [];
let files = message.files || [];
let filePrompt = '';
// 只有在有文件时才重置,避免用户确认消息时清空数据
if (files.length > 0) {
outputListRef.current = []; // 重置为空数组
renamedFilesRef.current = []; // 重置重命名文件列表
processedFilesRef.current = []; // 重置处理后的文件列表
projectInfo.current = [];
setCurrentStep(1);
}
// 处理ZIP文件:解压并将解压后的文件添加到文件列表
const processedFiles = [];
let hasZipFile = false;
let hideLoading2 = null; // 将变量提升到外部作用域
for (const file of files) {
// 检查是否是ZIP文件
if (
file.type === 'application/zip' ||
file.type === 'application/x-zip-compressed' ||
file.name?.toLowerCase().endsWith('.zip')
) {
hasZipFile = true;
let hideLoading1 = null;
try {
// 使用更安全的方式调用 messageApi.loading
if (typeof messageApi.loading === 'function') {
hideLoading1 = messageApi.loading('正在解压ZIP文件...', 0);
} else {
messageApi.info('正在解压ZIP文件...');
}
// 下载ZIP文件
const zipResponse = await fetch(file.url);
const zipBlob = await zipResponse.blob();
// 解压ZIP文件
const zip = await JSZip.loadAsync(zipBlob);
const zipFiles = Object.keys(zip.files);
// 关闭第一个加载提示
if (hideLoading1 && typeof hideLoading1 === 'function') {
hideLoading1();
}
if (typeof messageApi.loading === 'function') {
hideLoading2 = messageApi.loading(
`正在处理ZIP文件中的 ${zipFiles.length} 个文件...`,
0,
);
} else {
messageApi.info(`正在处理ZIP文件中的 ${zipFiles.length} 个文件...`);
}
// 遍历ZIP中的每个文件
for (const zipFileName of zipFiles) {
const zipFile = zip.files[zipFileName];
// 跳过目录
if (zipFile.dir) continue;
// 只处理图片和PDF文件
const lowerName = zipFileName.toLowerCase();
if (!lowerName.match(/\.(jpg|jpeg|png|pdf)$/i)) continue;
// 获取文件内容
const fileBlob = await zipFile.async('blob');
// 上传文件到服务器获取URL
const formData = new FormData();
formData.append('file', fileBlob, zipFileName);
const uploadResponse = await fetch('/api/upload', {
method: 'POST',
body: formData,
});
if (uploadResponse.ok) {
const uploadResult = await uploadResponse.json();
// 处理不同的响应格式
let fileUrl =
uploadResult.url ||
uploadResult.data?.url ||
uploadResult.response?.url;
// 如果响应格式是 { response: { url: '...' } }
if (!fileUrl && uploadResult.response) {
fileUrl = uploadResult.response.url;
}
if (fileUrl) {
// 确定文件类型
let fileType = fileBlob.type;
if (!fileType) {
if (lowerName.endsWith('.pdf')) {
fileType = 'application/pdf';
} else if (
lowerName.endsWith('.jpg') ||
lowerName.endsWith('.jpeg')
) {
fileType = 'image/jpeg';
} else if (lowerName.endsWith('.png')) {
fileType = 'image/png';
}
}
processedFiles.push({
name: zipFileName,
url: fileUrl,
type: fileType,
});
} else {
console.error(
`上传文件响应格式错误: ${zipFileName}`,
uploadResult,
);
messageApi.warning(
`处理文件 ${zipFileName} 失败:无法获取文件URL`,
);
}
} else {
const errorText = await uploadResponse.text();
console.error(
`上传文件失败: ${zipFileName}`,
uploadResponse.status,
errorText,
);
messageApi.warning(`上传文件 ${zipFileName} 失败`);
}
}
} catch (error) {
// 关闭加载提示
if (hideLoading1 && typeof hideLoading1 === 'function') {
hideLoading1();
}
if (hideLoading2 && typeof hideLoading2 === 'function') {
hideLoading2();
}
console.error('解压ZIP文件失败:', error);
messageApi.error(`解压ZIP文件失败: ${error.message}`);
}
} else {
// 非ZIP文件,直接添加
processedFiles.push(file);
}
}
// 如果处理了ZIP文件,关闭加载提示并显示成功消息
if (hasZipFile && hideLoading2 && typeof hideLoading2 === 'function') {
hideLoading2(); // 关闭加载提示
const originalFileNames = new Set(
(message.files || []).map((f) => f.name),
);
const extractedCount = processedFiles.filter(
(f) => !originalFileNames.has(f.name),
).length;
if (extractedCount > 0) {
messageApi.success(
`成功从ZIP文件中提取并处理了 ${extractedCount} 个文件`,
);
}
}
// 使用处理后的文件列表(包含解压后的文件)
files = processedFiles.length > 0 ? processedFiles : files;
// 保存处理后的文件列表到 ref,供后续处理使用
processedFilesRef.current = files;
for (const file of files) {
fileAnalyzeResults.push({
name: file.name,
url: file.url,
});
}
if (fileAnalyzeResults.length > 0) {
// 合并所有文件的content内容
const mergedContent = fileAnalyzeResults
.map((item) => item.url)
.join('\n');
filePrompt = `全部发票的url集合:\n${mergedContent}\n\n`;
return `${filePrompt},请根据已经上传的文件的类型判断调用哪一个mcp工具。
在调用mcp工具识别完所有的文件内容后,根据上下文内容,系统回复的消息只需要包括以下信息:
1、告知用户一共上传多少份文件;
2、各类型(增值发票、出租车、普通发票、铁路客票)分别有多少张,识别有误的有多少张,格式错误而导致的无法识别的文件有多少份;
3、提示用户确认数量,以及让用户自行在下载的压缩包内填写无法识别或识别错误的文件的相关信息。`;
}
return `用户回答:${message.content}`;
};
const [agent] = useXAgent({
request: async (
{ message, messages },
{ onUpdate, onSuccess, onError, onStream },
) => {
const chunks = [];
try {
const controller = new AbortController();
onStream(controller);
onUpdate({
data: 'LOADING',
});
setThinkPrompts([]);
const prompt = await getMessagePrompt(message);
const tools = await getTools();
let requestMessages = [];
if (message.files?.length > 0) {
setAttachmentsOpen(false);
requestMessages = [
{
...message,
content: prompt,
},
];
} else {
requestMessages = [
{
role: 'system',
content: SYSTEM_PROMPT,
},
...messages.slice(0, -1),
{
...message,
content: prompt,
},
];
}
const content = JSON.stringify(requestMessages);
const long = content.length > 900000;
const model = await resolveLlmModel({
long,
thinking: false,
});
const enable_search = false;
const enable_thinking = false;
const data = {
model,
stream: true,
messages: requestMessages,
tools: tools.length > 0 ? tools : undefined,
enable_search,
enable_thinking,
};
onUpdate({
data: 'LOADING',
});
if (conversation) {
await updateConversation(conversation.name, {
messages: messages,
});
}
const response = await llmChat(data, controller.signal);
const stream = XStream({
readableStream: response.body,
});
for await (const chunk of stream) {
onUpdate(chunk);
chunks.push(chunk);
}
let currentMessage = chunks.reduce(transformMessage, {});
if (
currentMessage.role === 'assistant' &&
currentMessage.content.includes('Mia')
) {
await extractStepsFromMessages(currentMessage);
}
if (currentMessage.tool_calls?.length > 0) {
const toolMessages = [];
const toolHtmlSnippets = [];
let usedFormNormalization = false;
// 使用处理后的文件列表(包含解压后的文件,不包含ZIP文件)
const files =
processedFilesRef.current.length > 0
? processedFilesRef.current
: message.files || [];
for (const call of currentMessage.tool_calls) {
const [mcpName, toolName] = call.function.name.split(':');
// 安全地解析工具参数
let mcpArguments = {};
try {
const cleanArgs = (call.function.arguments || '').trim();
if (cleanArgs) {
mcpArguments = JSON.parse(cleanArgs);
}
} catch (error) {
console.error('解析工具参数失败:', error);
console.error('原始参数:', call.function.arguments);
mcpArguments = {};
}
// 添加应用配置中的发票工具参数
if (
toolName === '非出租车发票处理工具' ||
toolName === '出租车发票处理工具'
) {
const runtimeConfig = await getApplicationRuntimeConfig();
const invoiceToolConfig = runtimeConfig.invoiceTool || {};
mcpArguments = {
...mcpArguments,
...(invoiceToolConfig.apiKey
? { apikey: invoiceToolConfig.apiKey }
: {}),
...(invoiceToolConfig.baseUrl
? { baseurl: invoiceToolConfig.baseUrl }
: {}),
};
}
let result;
try {
// 尝试调用MCP工具
result = await callMcpTool(mcpName, toolName, mcpArguments);
} catch (error) {
console.error('MCP工具调用失败:', error);
result = {
content: `MCP工具调用失败: ${error.message}`,
error: true,
};
}
// 处理MCP工具返回的内容
let content = '';
if (result && result.content) {
if (Array.isArray(result.content)) {
// 如果是数组,按原逻辑处理
const items = result.content;
content = items
.filter(
(item) => item.type === 'text' || typeof item === 'string',
)
.map((item) => item.text || item)
.join('\n');
} else if (typeof result.content === 'string') {
// 如果是字符串,直接使用
content = result.content;
} else if (typeof result.content === 'object') {
// 如果是对象,尝试转换为字符串
content = JSON.stringify(result.content, null, 2);
} else {
// 其他类型,转换为字符串
content = String(result.content);
}
} else {
// 如果没有content,使用整个result
content = JSON.stringify(result, null, 2);
}
// 安全地解析 JSON 内容
if (toolName === '表单信息标准化') {
projectInfo.current = JSON.parse(content);
usedFormNormalization = true;
}
if (
toolName === '非出租车发票处理工具' ||
toolName === '出租车发票处理工具' ||
toolName === '非发票文件处理工具'
) {
// 如果发生错误,不解析JSON,跳过处理
if (result && result.error) {
console.error(
`${toolName}工具调用失败,跳过JSON解析和文件处理`,
);
// 仍然将错误信息传递给LLM
} else {
try {
const parsedContent = JSON.parse(content);
// 确保 parsedContent 是数组且第一个元素存在
if (
Array.isArray(parsedContent) &&
parsedContent.length > 0 &&
parsedContent[0]
) {
outputListRef.current.push(parsedContent[0]);
// 基于解析结果重命名当前文件:发票号码+发票代码+原扩展名(鲁棒提取)
try {
const firstItem = parsedContent[0];
// 安全地获取 code 和 number,可能不存在(如铁路客票)
const codeVal = firstItem?.code;
const numberVal = firstItem?.number;
// 根据URL获取对应的文件
const fileUrl = mcpArguments.url || '';
// 通过URL匹配文件
const currentFile = files.find((f) => {
const fUrl = f.url || '';
// 完全匹配
return fUrl === fileUrl;
});
if (currentFile || fileUrl) {
// 从URL或文件名中提取扩展名
let ext = '';
const fileName = currentFile?.name || '';
const finalUrl = currentFile?.url || fileUrl;
// 优先从文件名中提取扩展名
if (fileName) {
const dotIdx = fileName.lastIndexOf('.');
ext = dotIdx >= 0 ? fileName.slice(dotIdx) : '';
}
// 如果文件名中没有扩展名,尝试从URL中提取
if (!ext && finalUrl) {
try {
const urlPath = new URL(finalUrl).pathname;
const urlDotIdx = urlPath.lastIndexOf('.');
if (urlDotIdx >= 0) {
const urlExt = urlPath.slice(urlDotIdx);
// 检查是否是有效的文件扩展名(最多6个字符,如 .xlsx)
if (
urlExt.length <= 6 &&
/^\.\w+$/.test(urlExt)
) {
ext = urlExt;
}
}
} catch (e) {
// URL解析失败,尝试简单的字符串匹配
const urlDotIdx = finalUrl.lastIndexOf('.');
if (urlDotIdx >= 0) {
const urlExt = finalUrl
.slice(urlDotIdx)
.split(/[?#]/)[0]; // 移除查询参数和hash
if (
urlExt.length <= 6 &&
/^\.\w+$/.test(urlExt)
) {
ext = urlExt;
}
}
}
}
// 如果 code 或 number 存在,则用于重命名;否则使用原始文件名
if (codeVal || numberVal) {
let newName = '';
// 如果其中一个为20位,直接使用该值作为newName
if (codeVal && String(codeVal).length === 20) {
newName = `${codeVal}${ext}`;
} else if (
numberVal &&
String(numberVal).length === 20
) {
newName = `${numberVal}${ext}`;
} else {
// 否则拼接两个值
const base = `${numberVal || ''}${codeVal || ''}`;
const safeBase = base.replace(
/[^\w\u4e00-\u9fa5-]+/g,
'',
);
newName = safeBase
? `${safeBase}${ext}`
: fileName || `文件_${Date.now()}${ext}`;
}
renamedFilesRef.current.push({
oldName: fileName || '未知文件',
newName,
url: finalUrl,
});
} else {
// 如果没有 code 和 number,使用原始文件名
renamedFilesRef.current.push({
oldName: fileName || '未知文件',
newName: fileName || `文件_${Date.now()}${ext}`,
url: finalUrl,
});
}
} else {
console.warn('未找到匹配的文件,URL:', fileUrl);
}
} catch (e) {
console.warn('文件重命名失败,已跳过:', e);
// 即使重命名失败,也保留原始文件名,通过URL匹配文件
const fileUrl = mcpArguments.url || '';
const currentFile = files.find((f) => {
const fUrl = f.url || '';
return (
fUrl === fileUrl ||
fUrl.includes(fileUrl) ||
fileUrl.includes(fUrl)
);
});
if (currentFile || fileUrl) {
renamedFilesRef.current.push({
oldName: currentFile?.name || '未知文件',
newName: currentFile?.name || '未知文件',
url: currentFile?.url || fileUrl,
});
}
}
} else {
console.warn('解析内容格式不正确,跳过文件重命名');
}
} catch (e) {
console.error(`解析${toolName}返回内容JSON失败:`, e);
console.error('原始内容:', content);
const urlReg = /https?:\/\/[^\s]+/;
const temp_url = content.match(urlReg)?.[0] || '未找到URL';
if (content.includes('日期或金额') && temp_url) {
renamedFilesRef.current.push({
oldName: '' || '未知文件',
newName: `识别有误文件${temp_url.split('/').pop()}`,
url: temp_url,
});
}
}
}
}
if (toolName === '表单生成工具') {
setCurrentStep(2);
}
if (typeof content === 'string') {
const trimmedContent = content.trim();
if (trimmedContent.includes('<form')) {
const sanitized = sanitizeHtml(trimmedContent);
const enhanced = sanitized.replace(
/<button/gi,
'<button data-tool-button="true"',
);
toolHtmlSnippets.push(enhanced);
content = sanitized;
}
}
toolMessages.push({
role: 'tool',
content,
});
}
const toolData = {
...data,
stream: true,
tools: undefined,
enable_thinking: false,
messages: [
data.messages[data.messages.length - 1],
currentMessage,
...toolMessages,
...(usedFormNormalization
? [
{
role: 'assistant',
content:
'请将用户刚刚提供的表单信息进行总结,不必确认表单项是否有实际值,并提示用户点击“下载处理结果”按钮进行下载,以获取报销文件压缩包。',
},
]
: []),
],
};
const toolResponse = await llmChat(toolData, controller.signal);
const toolStream = XStream({
readableStream: toolResponse.body,
});
for await (const chunk of toolStream) {
onUpdate(chunk);
chunks.push(chunk);
}
currentMessage = chunks.reduce(transformMessage, {});
if (toolHtmlSnippets.length > 0) {
const htmlBlock = toolHtmlSnippets.join('<hr />');
currentMessage.content = currentMessage.content
? `${currentMessage.content}\n\n${htmlBlock}`
: htmlBlock;
}
// console.log('currentMessage:', currentMessage);
}
if (!currentMessage.content && !currentMessage.tool_calls?.length) {
currentMessage.content =
'识别完成,但没有可展示的内容。如有文件识别异常,请稍后重试或联系管理员。';
}
// 在助手输出结束后,根据输出内容决定进行到哪一步
if (currentMessage.role === 'assistant') {
const contentStr = String(currentMessage.content || '').toLowerCase();
// 发票识别总结是否完成(包含数量/类型关键词)
const hasInvoiceSummary = contentStr.includes('发票');
// 仅在识别阶段(步骤1或2)并且确实有文件时,生成推荐问句
const hasFiles =
(message.files && message.files.length > 0) ||
attachedFiles.length > 0;
if (hasFiles && hasInvoiceSummary) {
try {
const thinkPromptsResult = await callMcpTool(
'invoice_toolset',
'联想词生成',
{
content: currentMessage.content,
},
);
const firstText = Array.isArray(thinkPromptsResult?.content)
? thinkPromptsResult.content.find((item) => item?.text)?.text
: typeof thinkPromptsResult?.content === 'string'
? thinkPromptsResult.content
: '';
if (firstText) {
let parsed = [];
try {
const json = JSON.parse(firstText);
if (Array.isArray(json)) {
parsed = json;
} else if (json && typeof json === 'object') {
parsed = Object.entries(json).map(([key, value]) => ({
title: key,
content: value,
}));
}
} catch (parseError) {
console.warn('联想词解析失败,使用原始文本', parseError);
parsed = [firstText];
}
const normalized = parsed
.map((item, index) => {
if (!item) {
return null;
}
const key = `think-${Date.now()}-${index}`;
if (typeof item === 'string') {
return {
key,
title: `推荐问题 ${index + 1}`,
description: item,
content: item,
};
}
if (Array.isArray(item)) {
const [title, content] = item;
return {
key,
title: title || `推荐问题 ${index + 1}`,
description: content || '',
content: content || title || '',
};
}
const title =
item.title ||
item.label ||
item.name ||
`推荐问题 ${index + 1}`;
const description =
item.description ||
item.content ||
item.prompt ||
item.value ||
'';
const content =
item.content ||
item.prompt ||
item.value ||
description ||
title;
return {
key,
title,
description,
content,
};
})
.filter(Boolean);
setThinkPrompts(normalized);
} else {
setThinkPrompts([]);
}
} catch (err) {
console.error('联想词生成失败:', err);
setThinkPrompts([]);
}
} else {
setThinkPrompts([]);
}
}
if (
currentMessage.role === 'assistant' &&
currentMessage.content.includes('“下载处理结果”') &&
requestMessages.length > 2
) {
setCurrentStep(3);
}
onUpdate({
data: 'DONE',
});
onSuccess(chunks);
} catch (error) {
if (error.name === 'AbortError') {
onSuccess(chunks);
} else {
onError(error);
}
}
},
});
const agentLoading = agent.isRequesting();
const { onRequest, messages, setMessages } = useXChat({
agent,
requestFallback: (message, { error }) => {
if (error.name === 'AbortError') {
return {
content: 'Request is aborted',
role: 'assistant',
};
}
return {
content: 'Request failed, please try again!',
role: 'assistant',
};
},
transformMessage: (info) =>
transformMessage(info.originMessage, info.chunk),
resolveAbortController: (controller) => {
abortController.current = controller;
},
});
const messageItems = useMemo(() => {
// 过滤掉包含"打招呼"的用户消息(自动发送的欢迎消息)
const visibleMessages = messages?.filter((item) => {
if (!item?.message) {
return false;
}
if (
item.message.role === 'user' &&
item.message.content?.includes('打招呼')
) {
return false;
}
if (item.message.metadata?.hidden) {
return false;
}
return true;
});
return visibleMessages?.map((item, index) => {
const messageItem = {
...item.message,
classNames: {
content: '',
},
loading: item.message?.loading,
};
const contentClasses = [];
if (item.status === 'loading') {
contentClasses.push(styles.loadingMessage);
}
if (item.message.role === 'user') {
messageItem.classNames = {
content: styles.userBubble,
};
messageItem.messageRender = (content) => {
return (
<Flex vertical gap={8}>
<div>{content}</div>
{item.message.files?.map((file, index) => (
<Attachments.FileCard key={index} item={file} />
))}
</Flex>
);
};
}
if (item.message.role === 'assistant') {
const isLastAssistant =
item.message.role === 'assistant' &&
index === visibleMessages.length - 1;
contentClasses.push(styles.assistantBubble);
messageItem.variant = 'borderless';
messageItem.footer = (
<Space className={styles.assistantActions}>
<Button
type="text"
size="small"
icon={<CopyOutlined />}
onClick={() => {
navigator.clipboard.writeText(item.message.content);
messageApi.success('已复制到剪贴板');
}}
/>
{index === messages.length - 1 && (
<Button
type="text"
size="small"
icon={<ReloadOutlined />}
onClick={() => {
const userMessageIndex = messages.findLastIndex(
(m) => m.message.role === 'user',
);
if (userMessageIndex >= 0) {
setMessages((messages) =>
messages.slice(0, userMessageIndex),
);
onSubmit(messages[userMessageIndex]?.message);
}
}}
/>
)}
</Space>
);
messageItem.messageRender = () => {
const content = item.message.content?.trim();
if (!content) {
return (
<Space>
<Spin size="small" />
正在生成回复,请稍候...
</Space>
);
}
return (
<Flex vertical gap={8} style={{ width: '100%' }}>
<div
className={styles.markdown}
dangerouslySetInnerHTML={{
__html: marked.parse(preprocessLaTeX(content)),
}}
></div>
</Flex>
);
};
}
if (contentClasses.length > 0) {
messageItem.classNames = {
...messageItem.classNames,
content: contentClasses.join(' '),
};
}
return messageItem;
});
}, [messages]);
useEffect(() => {
const container = chatContainerRef.current;
if (!container) {
return undefined;
}
const handleClick = (event) => {
const target = event.target.closest(
'button[data-tool-button], button[data-message]',
);
if (!target || !container.contains(target)) {
return;
}
event.preventDefault();
const form = target.closest('form');
if (form && form.id === 'projectForm') {
const formData = new FormData(form);
const formProjectName = (formData.get('projectName') || '')
.toString()
.trim();
const formMembers = (formData.get('projectMembers') || '')
.toString()
.trim();
const formTravel = (formData.get('travelTime') || '').toString().trim();
const formNumber = (formData.get('reimbursementNumber') || '')
.toString()
.trim();
const summaryLines = [
`项目名称:${formProjectName || ''}`,
`项目组人员:${formMembers || ''}`,
`出差期间:${formTravel || ''}`,
`报销单号:${formNumber || ''}`,
];
const summaryContent = summaryLines.join('\n');
if (conversation) {
onRequest({
stream: true,
message: {
role: 'user',
content: summaryContent,
metadata: {
hidden: true,
},
},
});
} else {
onSubmit({
content: summaryContent,
});
}
return;
}
const messageContent =
target.dataset.message ||
target.dataset.submitMessage ||
target.textContent?.trim() ||
'';
if (messageContent) {
let formattedMessage = messageContent.trim();
try {
const parsed = JSON.parse(formattedMessage);
if (parsed && typeof parsed === 'object') {
const entries = Array.isArray(parsed)
? parsed
: Object.entries(parsed).map(([key, value]) => [key, value]);
formattedMessage = entries
.map((entry) => {
if (Array.isArray(entry)) {
const [key, value] = entry;
return `${key}:${value ?? ''}`;
}
if (entry && typeof entry === 'object') {
return Object.entries(entry)
.map(([key, value]) => `${key}:${value ?? ''}`)
.join('\n');
}
return String(entry ?? '').trim();
})
.filter(Boolean)
.join('\n');
}
} catch (error) {
formattedMessage = formattedMessage
.split(/[\n,;]+/)
.map((line) => line.trim())
.filter(Boolean)
.join('\n');
}
onSubmit({
content: formattedMessage,
});
}
};
container.addEventListener('click', handleClick, true);
return () => {
container.removeEventListener('click', handleClick, true);
};
}, [onSubmit]);
useEffect(() => {
if (!conversation) {
return;
}
const frame = requestAnimationFrame(() => {
chatEndRef.current?.scrollIntoView({
behavior: 'smooth',
block: 'end',
});
});
return () => cancelAnimationFrame(frame);
}, [conversation, messages]);
useEffect(() => {
abortController?.current?.abort('cancelled');
const messages = conversation?.metadata?.messages || [];
setMessages(
messages.map((message) => ({
status: 'success',
message: message,
})),
);
// 重置步骤和发票类型
setCurrentStep(0);
projectInfo.current = {};
// 如果是发票助手且没有消息,自动触发AI思考操作流程并生成步骤列表
if (conversation && conversation.metadata?.label === '发票助手') {
if (messages.length === 0) {
onRequest({
content: `请向用户打招呼,并思考并介绍发票识别的操作流程,要求生成4个步骤:
1. 上传发票(可以上传zip、pdf和图片)
2. 识别发票
3. 输入表单信息(项目名称、项目组人员、出差时间、报销单号)
4. 下载zip文件。
请以清晰的步骤列表形式展示,每个步骤单独一行,且描述信息主要是说该步骤需要填写或上传的内容,最后一步的描述信息可以简略一点`,
role: 'user',
});
} else if (messages.length >= 2 && !didSetStepsFromAIRef.current) {
extractStepsFromMessages(messages[1]);
}
}
senderRef.current?.focus();
}, [conversation]);
useEffect(() => {
if (conversation) {
const oldMessage = _.last(conversation.metadata.messages);
const newMessage = _.last(messages);
if (
newMessage?.status === 'success' &&
newMessage?.message?.content !== oldMessage?.content
) {
updateConversation(conversation.name, {
messages: messages.map((m) => m.message),
}).then();
}
}
}, [messages]);
useEffect(() => {
if (currentStep !== 0) {
return;
}
// 检查所有文件是否都上传完成
const hasUploadedFiles =
attachedFiles.length > 0 &&
attachedFiles.every(
(file) => file.status === 'done' && file.response?.url,
);
if (hasUploadedFiles) {
setCurrentStep(1);
}
}, [attachedFiles, currentStep]);
const addConversation = async (data, message) => {
const conversation = await createConversation(data);
conversation.metadata = {
...conversation.metadata,
...data,
};
onAddConversation?.(conversation, message); // 委托给父组件
};
const onSubmit = async ({ content, files = null, agentLabel = null }) => {
const userMessage = {
role: 'user',
content: content || '',
files:
files ||
attachedFiles
.filter((file) => file.status === 'done')
.map((file) => file.response),
};
if (!conversation) {
await addConversation(
{
label: agentLabel || content || '发票助手',
},
userMessage,
);
return;
}
if (agentLoading) {
messageApi.warning('请等待请求完成');
return;
}
setAttachedFiles([]);
onRequest({
stream: true,
message: userMessage,
});
};
const chatList = (
<div className={styles.chatList} ref={chatContainerRef}>
{conversation ? (
<div className={styles.chatListInner}>
<div className={styles.heroCard}>
<Space direction="vertical" size={16}>
<Tag color="cyan" bordered={false} className={styles.sectionTag}>
发票识别工作流
</Tag>
<Typography.Title level={3} style={{ margin: 0 }}>
Mia 发票助手
</Typography.Title>
<Typography.Paragraph
style={{ margin: 0, color: '#64748b', lineHeight: 1.7 }}
>
支持 ZIP、PDF
和图片批量识别,自动联想下一步问题,并在流程完成后生成可下载结果。
</Typography.Paragraph>
</Space>
</div>
<div className={styles.bubbleList}>
<Bubble.List
items={messageItems}
style={{
height: '100%',
}}
roles={{
assistant: {
placement: 'start',
avatar: {
children: 'Mia',
style: { background: '#fde3cf', color: '#9a3412' },
},
},
user: {
placement: 'end',
avatar: userInfo?.avatar
? { src: userInfo.avatar }
: { children: userInfo?.display_name?.[0] || '我' },
},
}}
/>
</div>
{thinkPromptItems.length > 0 && (
<Prompts
items={thinkPromptItems}
style={{ width: '100%' }}
className={styles.promptCard}
onItemClick={(info) => {
const promptContent =
info?.data?.content ||
info?.data?.description ||
info?.data?.title;
if (promptContent) {
onSubmit({
content: promptContent,
});
}
}}
/>
)}
<div ref={chatEndRef} />
</div>
) : (
<div className={`${styles.chatListInner} ${styles.placeholder}`}>
<div className={styles.heroCard}>
<Typography.Title level={3} style={{ margin: 0 }}>
发票助手已就绪
</Typography.Title>
<Typography.Paragraph
style={{ margin: '10px 0 0', color: '#64748b', lineHeight: 1.7 }}
>
上传文件后,Mia 会按步骤帮你完成识别、确认、补充表单和结果下载。
</Typography.Paragraph>
</div>
</div>
)}
</div>
);
const senderHeader = (
<Sender.Header
title="上传文件"
open={attachmentsOpen}
onOpenChange={setAttachmentsOpen}
styles={{ content: { padding: 0 } }}
>
<Attachments
items={attachedFiles}
action="/api/upload"
onChange={(info) => {
setAttachedFiles(info.fileList);
}}
multiple={true}
placeholder={(type) =>
type === 'drop'
? {
title: '拖动文件到这里',
}
: {
icon: <CloudUploadOutlined />,
title: '点击或拖动文件到这里',
description: '支持文件类型: ZIP, PDF, JPG, PNG',
}
}
/>
</Sender.Header>
);
const chatSender = (
<div className={styles.senderShell}>
<Sender
ref={senderRef}
value={inputValue}
header={senderHeader}
onSubmit={() => {
onSubmit({
content: inputValue,
});
setInputValue('');
}}
onChange={setInputValue}
onCancel={() => abortController.current?.abort()}
prefix={
<Button
type="text"
icon={<PaperClipOutlined style={{ fontSize: 18 }} />}
onClick={() => setAttachmentsOpen(!attachmentsOpen)}
/>
}
loading={agentLoading}
className={styles.sender}
allowSpeech
submitType="enter"
actions={(_, info) => {
const { SendButton, LoadingButton, SpeechButton } = info.components;
return (
<Flex gap={4}>
<SpeechButton className={styles.speechButton} />
{agentLoading ? (
<LoadingButton type="default" />
) : (
<SendButton type="primary" />
)}
</Flex>
);
}}
placeholder="请输入消息内容"
/>
</div>
);
// 右侧步骤栏组件
const stepsSidebar = (
<div className={styles.stepsSidebar}>
<div className={styles.sidebarTitle}>
<PayCircleOutlined />
发票助手流程
</div>
<div className={styles.stepHint}>
右侧面板会同步展示当前进度,方便在批量处理发票时快速确认下一步。
</div>
{stepsList.length > 0 && (
<Steps
direction="vertical"
current={currentStep}
items={stepsList.map((step, index) => ({
title: step.title,
description: step.description,
status:
index < currentStep
? 'finish'
: index === currentStep
? 'process'
: 'wait',
icon:
index < currentStep ? (
<CheckCircleOutlined style={{ color: '#52c41a' }} />
) : undefined,
}))}
style={{
'--ant-steps-item-finish-icon-color': '#52c41a',
'--ant-steps-item-finish-line-color': '#52c41a',
}}
/>
)}
{stepsList.length > 0 && currentStep === stepsList.length - 1 && (
<Button
type="primary"
icon={<DownloadOutlined />}
size="small"
onClick={async () => {
const excelData = {
outputList: outputListRef.current,
info: projectInfo.current,
files: renamedFilesRef.current,
};
try {
await generateExcelZip(excelData);
} catch (error) {
console.error('Excel文件生成失败:', error);
messageApi.error('Excel文件生成失败,请重试');
}
}}
className={styles.downloadButton}
>
下载处理结果
</Button>
)}
</div>
);
return (
<div className={styles.chat}>
<div className={styles.chatWithSidebar}>
{chatList}
{chatSender}
</div>
{stepsSidebar}
</div>
);
});
export default InvoiceAssistant;
...@@ -5,1002 +5,544 @@ import { ...@@ -5,1002 +5,544 @@ import {
removeConversation, removeConversation,
updateConversation, updateConversation,
} from '@/services/DataService'; } from '@/services/DataService';
import { import { AppLayout } from '@heroui-pro/react/app-layout';
AppstoreOutlined, import { Navbar } from '@heroui-pro/react/navbar';
BorderlessTableOutlined, import { Sidebar } from '@heroui-pro/react/sidebar';
CommentOutlined, import { AlertDialog } from '@heroui/react/alert-dialog';
DeleteOutlined, import { Avatar } from '@heroui/react/avatar';
EditOutlined, import { Button } from '@heroui/react/button';
MenuFoldOutlined, import { Dropdown } from '@heroui/react/dropdown';
MenuUnfoldOutlined, import { Input } from '@heroui/react/input';
PlusOutlined, import { Modal } from '@heroui/react/modal';
PoweroffOutlined, import { Spinner } from '@heroui/react/spinner';
PushpinOutlined, import { toast } from '@heroui/react/toast';
PushpinTwoTone, import { Tooltip } from '@heroui/react/tooltip';
} from '@ant-design/icons';
import { Conversations } from '@ant-design/x';
import { useQuery } from '@umijs/max'; import { useQuery } from '@umijs/max';
import { import {
Avatar, AppWindow,
Button, Bot,
Drawer, LogOut,
Dropdown, MessageCircle,
Empty, MoreHorizontal,
Input, PanelLeftClose,
Modal, PanelLeftOpen,
Spin, Pencil,
Tooltip, Pin,
message, PinOff,
theme, Plus,
} from 'antd'; Search,
import { createStyles } from 'antd-style'; Trash2,
} from 'lucide-react';
import { useEffect, useMemo, useRef, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import ApplicationSwitcherDialog from '../../components/ApplicationSwitcherDialog'; import ApplicationSwitcherDialog from '../../components/ApplicationSwitcherDialog';
import LoginModal from '../../components/LoginModal'; import LoginModal from '../../components/LoginModal';
import './index.less';
const useStyle = createStyles(({ token, css, responsive }) => { function getConversationLabel(conversation) {
return { return conversation?.metadata?.label || '新会话';
shell: css`
height: 100dvh;
padding: 16px;
display: flex;
overflow: hidden;
background-image: linear-gradient(
rgba(125, 211, 252, 0.045) 1px,
transparent 1px
),
linear-gradient(90deg, rgba(125, 211, 252, 0.04) 1px, transparent 1px);
background-size: 42px 42px;
${responsive.xs} {
padding: 12px;
}
`,
frame: css`
width: 100%;
height: calc(100dvh - 32px);
display: flex;
gap: 16px;
border-radius: 36px;
position: relative;
overflow: hidden;
${responsive.xs} {
height: calc(100dvh - 24px);
border-radius: 26px;
}
`,
sidebar: css`
width: 316px;
flex-shrink: 0;
display: flex;
flex-direction: column;
gap: 12px;
background: radial-gradient(
circle at top left,
rgba(34, 211, 238, 0.15),
transparent 26%
),
linear-gradient(180deg, #020617 0%, #0f172a 58%, #111827 100%);
border: 1px solid rgba(125, 211, 252, 0.13);
box-shadow: 0 24px 52px rgba(0, 0, 0, 0.34);
color: rgba(255, 255, 255, 0.95);
padding: 18px 16px 14px;
border-radius: 34px;
`,
sidebarCollapsed: css`
width: 108px;
padding-inline: 12px;
align-items: center;
`,
sidebarHeader: css`
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
min-height: 52px;
`,
brand: css`
display: flex;
align-items: center;
gap: 12px;
min-width: 0;
`,
brandMark: css`
width: 44px;
height: 44px;
border-radius: 16px;
background: linear-gradient(135deg, #22d3ee 0%, #2563eb 100%);
display: inline-flex;
align-items: center;
justify-content: center;
color: #ffffff;
font-size: 19px;
font-weight: 700;
box-shadow: 0 14px 28px rgba(14, 165, 233, 0.26);
`,
brandText: css`
min-width: 0;
h1 {
margin: 0;
font-size: 18px;
line-height: 1.2;
color: #f8fbff;
letter-spacing: -0.01em;
}
p {
margin: 6px 0 0;
color: rgba(226, 232, 240, 0.86);
font-size: 12px;
line-height: 1.55;
max-width: 180px;
}
`,
collapseButton: css`
width: 40px;
height: 40px;
color: rgba(255, 255, 255, 0.94);
border-radius: 14px;
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.06);
&:hover {
color: #ffffff !important;
background: rgba(255, 255, 255, 0.1) !important;
}
`,
newButton: css`
width: 100%;
height: 48px;
border: none;
border-radius: 18px;
background: linear-gradient(135deg, #22d3ee 0%, #2563eb 100%);
box-shadow: 0 16px 30px rgba(14, 165, 233, 0.28);
color: #ffffff;
font-weight: 600;
font-size: 16px;
&:hover {
color: #ffffff !important;
background: linear-gradient(
135deg,
#67e8f9 0%,
#3b82f6 100%
) !important;
}
`,
conversations: css`
flex: 1;
overflow-y: auto;
padding-right: 2px;
position: relative;
.ant-conversations-list {
margin: 0;
padding-top: 0;
padding-inline-start: 0;
}
.ant-conversations-list .ant-conversations-item {
padding-inline-start: 12px;
}
.ant-conversations-group {
margin-top: 0;
}
.ant-conversations-group-title {
color: rgba(248, 250, 252, 0.96) !important;
font-size: 14px;
line-height: 1.2;
padding: 2px 8px 8px;
font-weight: 700;
letter-spacing: 0;
text-shadow: 0 1px 2px rgba(15, 23, 42, 0.4);
}
.ant-conversations-group-title * {
color: inherit !important;
}
.ant-conversations-item {
margin-bottom: 8px;
border-radius: 18px;
color: #f8fafc !important;
background: linear-gradient(
180deg,
rgba(255, 255, 255, 0.13) 0%,
rgba(255, 255, 255, 0.08) 100%
);
border: 1px solid rgba(255, 255, 255, 0.12);
transition: all 0.2s ease, transform 0.2s ease;
backdrop-filter: blur(10px);
height: 60px;
min-height: 60px;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
}
.ant-conversations-item:hover {
background: linear-gradient(
180deg,
rgba(255, 255, 255, 0.18) 0%,
rgba(255, 255, 255, 0.12) 100%
);
border-color: rgba(125, 211, 252, 0.32);
transform: translateY(-1px);
}
.ant-conversations-item-active {
background: linear-gradient(
135deg,
rgba(34, 211, 238, 0.2) 0%,
rgba(37, 99, 235, 0.18) 100%
);
border-color: rgba(125, 211, 252, 0.42);
box-shadow: inset 0 0 0 1px rgba(125, 211, 252, 0.18),
0 14px 26px rgba(0, 0, 0, 0.24);
}
.ant-conversations-label,
.ant-conversations-icon,
.ant-conversations-menu-icon {
color: inherit !important;
}
.ant-conversations-label {
color: #f8fafc !important;
display: block;
font-size: 16px;
font-weight: 600;
line-height: 1.35;
min-width: 0;
text-shadow: 0 1px 2px rgba(15, 23, 42, 0.45);
}
.ant-conversations-label * {
color: inherit !important;
}
.ant-conversations-icon {
flex: 0 0 22px;
width: 22px;
height: 22px;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 17px;
opacity: 1;
color: rgba(255, 255, 255, 0.96) !important;
}
.ant-conversations-item-active .ant-conversations-label {
color: #ffffff !important;
}
.ant-conversations-item:hover .ant-conversations-label,
.ant-conversations-item:hover .ant-conversations-label *,
.ant-conversations-item-active .ant-conversations-label,
.ant-conversations-item-active .ant-conversations-label * {
color: #ffffff !important;
}
.ant-conversations-item-active .ant-conversations-icon,
.ant-conversations-item-active .ant-conversations-menu-icon {
color: #ffffff !important;
}
.ant-conversations-menu-icon {
color: rgba(255, 255, 255, 0.86) !important;
opacity: 0.74;
padding: 6px;
border-radius: 10px;
transition: color 0.2s ease, background 0.2s ease, opacity 0.2s ease;
}
.ant-conversations-item:hover .ant-conversations-menu-icon {
opacity: 1;
}
.ant-conversations-menu-icon:hover {
color: #ffffff !important;
background: rgba(255, 255, 255, 0.12);
}
`,
emptyHistory: css`
padding-top: 12px;
.ant-empty-description {
color: rgba(226, 232, 240, 0.72);
}
`,
compactNav: css`
flex: 1;
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
padding-top: 8px;
overflow-y: auto;
`,
compactNavItem: css`
width: 100%;
display: flex;
justify-content: center;
`,
compactNavButton: css`
position: relative;
width: 58px;
height: 58px;
border-radius: 18px;
display: inline-flex;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.06);
color: rgba(241, 245, 249, 0.92);
cursor: pointer;
transition: transform 0.2s ease, background 0.2s ease,
border-color 0.2s ease;
&:hover {
transform: translateY(-1px);
background: rgba(255, 255, 255, 0.12);
border-color: rgba(251, 113, 133, 0.24);
}
`,
compactNavButtonActive: css`
background: linear-gradient(
135deg,
rgba(159, 18, 57, 0.52) 0%,
rgba(225, 29, 72, 0.26) 100%
);
border-color: rgba(251, 113, 133, 0.3);
box-shadow: 0 12px 24px rgba(127, 29, 29, 0.16);
`,
compactNavPin: css`
position: absolute;
top: 8px;
right: 8px;
width: 8px;
height: 8px;
border-radius: 999px;
background: #fb7185;
box-shadow: 0 0 0 4px rgba(251, 113, 133, 0.14);
`,
sidebarFooter: css`
padding-top: 12px;
border-top: 1px solid rgba(255, 255, 255, 0.08);
`,
accountCard: css`
display: flex;
align-items: center;
gap: 12px;
padding: 12px;
border-radius: 20px;
background: rgba(255, 255, 255, 0.07);
border: 1px solid rgba(255, 255, 255, 0.06);
cursor: pointer;
transition: background 0.2s ease, border-color 0.2s ease;
&:hover {
background: rgba(255, 255, 255, 0.12);
border-color: rgba(251, 113, 133, 0.24);
}
`,
accountCardCollapsed: css`
flex-direction: column;
padding: 10px 8px;
gap: 8px;
`,
accountIdentity: css`
flex: 1;
min-width: 0;
display: flex;
align-items: center;
gap: 12px;
color: inherit;
cursor: pointer;
`,
accountMeta: css`
flex: 1;
min-width: 0;
strong {
display: block;
color: #f8fafc;
font-size: 14px;
line-height: 1.3;
}
span {
display: block;
color: rgba(226, 232, 240, 0.76);
font-size: 12px;
line-height: 1.3;
margin-top: 4px;
}
`,
applicationSwitcherButton: css`
width: 44px;
height: 44px;
flex-shrink: 0;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 16px;
border: 1px solid rgba(125, 211, 252, 0.34);
background: linear-gradient(
180deg,
rgba(255, 255, 255, 0.1),
rgba(255, 255, 255, 0.06)
);
color: #67e8f9;
cursor: pointer;
box-shadow: 0 12px 22px rgba(14, 165, 233, 0.14);
transition: transform 0.2s ease, border-color 0.2s ease,
background 0.2s ease, color 0.2s ease;
&:hover {
transform: translateY(-1px);
border-color: rgba(125, 211, 252, 0.72);
background: rgba(14, 165, 233, 0.14);
color: #cffafe;
}
&:focus-visible {
outline: 2px solid rgba(34, 211, 238, 0.58);
outline-offset: 2px;
}
`,
contentShell: css`
flex: 1;
min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
position: relative;
background: linear-gradient(
180deg,
rgba(15, 23, 42, 0.72) 0%,
rgba(2, 6, 23, 0.78) 100%
);
border: 1px solid rgba(125, 211, 252, 0.12);
box-shadow: 0 24px 70px rgba(0, 0, 0, 0.32);
backdrop-filter: blur(20px);
border-radius: 32px;
overflow: hidden;
`,
mobileMenuButton: css`
display: none;
${responsive.lg} {
display: inline-flex;
position: absolute;
top: 14px;
right: 14px;
z-index: 4;
}
`,
mobileOnly: css`
display: none;
${responsive.lg} {
display: inline-flex;
}
`,
desktopSidebar: css`
display: flex;
${responsive.lg} {
display: none;
}
`,
body: css`
flex: 1;
min-height: 0;
padding: 0 20px 16px;
${responsive.xs} {
padding: 0 12px 12px;
}
`,
chatStage: css`
height: 100%;
border-radius: 28px;
position: relative;
background: radial-gradient(
circle at 18% 0%,
rgba(34, 211, 238, 0.18),
transparent 24%
),
radial-gradient(
circle at 84% 14%,
rgba(99, 102, 241, 0.16),
transparent 24%
),
linear-gradient(
180deg,
rgba(15, 23, 42, 0.7) 0%,
rgba(2, 6, 23, 0.78) 100%
);
border: 1px solid rgba(125, 211, 252, 0.18);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.08),
0 0 0 1px rgba(255, 255, 255, 0.03), 0 28px 70px rgba(0, 0, 0, 0.26);
overflow: hidden;
&::before {
content: '';
position: absolute;
inset: 0;
pointer-events: none;
background-image: linear-gradient(
rgba(125, 211, 252, 0.04) 1px,
transparent 1px
),
linear-gradient(
90deg,
rgba(125, 211, 252, 0.035) 1px,
transparent 1px
);
background-size: 36px 36px;
mask-image: linear-gradient(
180deg,
rgba(0, 0, 0, 0.88),
rgba(0, 0, 0, 0.16)
);
}
`,
drawerMask: css`
.ant-drawer-header,
.ant-drawer-body {
padding: 0;
}
.ant-drawer-content {
background: transparent;
box-shadow: none;
}
`,
renameInput: css`
margin-top: 12px;
`,
};
});
function buildConversationItems(conversations = []) {
return conversations.map((c) => ({
key: c.name,
label: c.metadata.label || '新会话',
icon:
c.metadata.topic || c.metadata.topicConfig ? (
<BorderlessTableOutlined />
) : (
<CommentOutlined />
),
pinned: c.metadata.pinned,
group: '历史会话',
}));
} }
function buildCompactConversationItems(conversations = []) { function UserAvatar({ userInfo, size = 'sm' }) {
return conversations.slice(0, 6).map((c) => ({ return (
key: c.name, <Avatar size={size}>
label: c.metadata.label || '新会话', {userInfo?.avatar ? (
icon: <Avatar.Image
c.metadata.topic || c.metadata.topicConfig ? ( src={userInfo.avatar}
<BorderlessTableOutlined /> alt={userInfo.display_name || '用户'}
) : ( />
<CommentOutlined /> ) : null}
), <Avatar.Fallback>
pinned: c.metadata.pinned, {userInfo?.display_name?.slice(0, 1) || '用'}
})); </Avatar.Fallback>
</Avatar>
);
} }
export default () => { export default function Home() {
const { token } = theme.useToken(); const [userInfo, setUserInfo] = useState(() => {
const [collapsed, setCollapsed] = useState(false); try {
const [mobileOpen, setMobileOpen] = useState(false); return JSON.parse(localStorage.getItem('current_user') || 'null');
const [isMobile, setIsMobile] = useState(false); } catch {
const [userInfo, setUserInfo] = useState( return null;
localStorage.getItem('current_user') }
? JSON.parse(localStorage.getItem('current_user')) });
: null,
);
const { styles, cx } = useStyle();
const [currentConversation, setCurrentConversation] = useState(null); const [currentConversation, setCurrentConversation] = useState(null);
const [sidebarOpen, setSidebarOpen] = useState(true);
const [searchValue, setSearchValue] = useState('');
const [renamingConversation, setRenamingConversation] = useState(null); const [renamingConversation, setRenamingConversation] = useState(null);
const [renamingConversationLabel, setRenamingConversationLabel] = const [renamingLabel, setRenamingLabel] = useState('');
useState(null); const [deletingConversation, setDeletingConversation] = useState(null);
const [renamingModalOpen, setRenamingModalOpen] = useState(false); const [deleting, setDeleting] = useState(false);
const [applicationSwitcherOpen, setApplicationSwitcherOpen] = useState(false); const [applicationSwitcherOpen, setApplicationSwitcherOpen] = useState(false);
const [loading, setLoading] = useState(false); const [busy, setBusy] = useState(false);
const chatRef = useRef(null); const chatRef = useRef(null);
const { data: conversations = [], refetch: refetchConversations } = useQuery({ const { data: conversations = [], refetch } = useQuery({
queryKey: ['conversations', userInfo], queryKey: ['conversations', userInfo?._id],
enabled: Boolean(userInfo),
queryFn: async () => { queryFn: async () => {
if (!userInfo) { const response = await queryConversations();
return []; return response?.records || [];
}
setLoading(true);
try {
const res = await queryConversations();
return res.records;
} catch (err) {
console.error(err);
if (err.response?.status === 401) {
setUserInfo(null);
message.warning('请重新登录');
} else {
message.error('加载数据失败');
}
} finally {
setLoading(false);
}
}, },
}); });
useEffect(() => { useEffect(() => {
document.title = 'AI 应用'; document.title = '智能助手';
}, []);
useEffect(() => {
if (typeof window === 'undefined') {
return;
}
const media = window.matchMedia('(max-width: 1023px)');
const sync = () => {
const mobile = media.matches;
setIsMobile(mobile);
if (mobile) {
setCollapsed(false);
}
};
sync();
media.addEventListener?.('change', sync);
return () => {
media.removeEventListener?.('change', sync);
};
}, []); }, []);
const conversationItems = useMemo( const filteredConversations = useMemo(() => {
() => buildConversationItems(conversations), const keyword = searchValue.trim().toLocaleLowerCase('zh-CN');
[conversations], if (!keyword) return conversations;
); return conversations.filter((conversation) =>
const compactConversationItems = useMemo( getConversationLabel(conversation)
() => buildCompactConversationItems(conversations), .toLocaleLowerCase('zh-CN')
[conversations], .includes(keyword),
); );
}, [conversations, searchValue]);
const openConversation = async (name) => { const openConversation = async (name) => {
setBusy(true);
try {
const conversation = await getRecord(name); const conversation = await getRecord(name);
setCurrentConversation(conversation); setCurrentConversation(conversation);
setMobileOpen(false); } catch (error) {
toast.danger(error.message || '会话加载失败');
} finally {
setBusy(false);
}
}; };
const conversationMenu = (conversation) => ({ const updatePin = async (conversation) => {
items: [ try {
conversation.pinned await updateConversation(conversation.name, {
? { pinned: !conversation.metadata?.pinned,
key: 'unpin',
label: '已置顶',
type: 'primary',
icon: <PushpinTwoTone />,
onClick: async () => {
await updateConversation(conversation.key, {
pinned: false,
}); });
refetchConversations(); await refetch();
}, } catch (error) {
toast.danger(error.message || '会话更新失败');
} }
: { };
key: 'pin',
label: '置顶', const deleteConversation = async () => {
icon: <PushpinOutlined />, const conversation = deletingConversation;
onClick: async () => { if (!conversation || deleting) return;
await updateConversation(conversation.key, { setDeleting(true);
pinned: true, setBusy(true);
}); try {
refetchConversations(); await removeConversation(conversation.name);
}, if (currentConversation?.name === conversation.name) {
},
{
label: '重命名',
key: 'rename',
icon: <EditOutlined />,
onClick: () => {
setRenamingConversation(conversation);
setRenamingConversationLabel(conversation.label);
setRenamingModalOpen(true);
},
},
{
label: '删除',
key: 'delete',
icon: <DeleteOutlined />,
danger: true,
onClick: async () => {
if (conversation.key === currentConversation?.name) {
setCurrentConversation(null); setCurrentConversation(null);
} }
await refetch();
setDeletingConversation(null);
toast.success('会话已删除');
} catch (error) {
toast.danger(error.message || '会话删除失败');
} finally {
setDeleting(false);
setBusy(false);
}
};
setLoading(true); const renderSidebarBody = () => (
await removeConversation(conversation.key); <>
await refetchConversations(); <Sidebar.Header className="xy-sidebar-header">
setLoading(false); <div className="xy-brand">
}, <span className="xy-brand-mark" aria-hidden="true">
}, <Bot size={17} />
], </span>
}); <span className="xy-brand-copy">
<strong>智能助手</strong>
const sidebarContent = ( <small>AI Workspace</small>
<div className={cx(styles.sidebar, collapsed && styles.sidebarCollapsed)}> </span>
<div className={styles.sidebarHeader}>
{!collapsed && (
<div className={styles.brand}>
<div className={styles.brandMark}>AI</div>
<div className={styles.brandText}>
<h1>智能助手</h1>
<p>面向业务场景的统一 AI 对话工作台</p>
</div>
</div> </div>
)} </Sidebar.Header>
{!isMobile && (
<Button <Sidebar.Content>
type="text" <Sidebar.Group className="xy-sidebar-primary">
icon={collapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />} <Sidebar.Menu aria-label="主要操作">
onClick={() => setCollapsed((value) => !value)} <Sidebar.MenuItem
className={styles.collapseButton} id="new-chat"
isCurrent={!currentConversation}
onAction={() => setCurrentConversation(null)}
>
<Sidebar.MenuIcon>
<Plus size={17} />
</Sidebar.MenuIcon>
<Sidebar.MenuLabel>新建会话</Sidebar.MenuLabel>
</Sidebar.MenuItem>
</Sidebar.Menu>
</Sidebar.Group>
<div className="xy-sidebar-search">
<Search size={15} aria-hidden="true" />
<Input
aria-label="搜索会话"
placeholder="搜索会话"
value={searchValue}
variant="secondary"
onChange={(event) => setSearchValue(event.target.value)}
/> />
)}
</div> </div>
<Button <Sidebar.Separator />
onClick={async () => { <Sidebar.Group className="xy-conversation-group">
setCurrentConversation(null); <Sidebar.GroupLabel>最近会话</Sidebar.GroupLabel>
setMobileOpen(false); {filteredConversations.length ? (
}} <Sidebar.Menu aria-label="最近会话">
icon={<PlusOutlined />} {filteredConversations.map((conversation) => (
className={styles.newButton} <Sidebar.MenuItem
id={conversation.name}
key={conversation.name}
isCurrent={currentConversation?.name === conversation.name}
textValue={getConversationLabel(conversation)}
onAction={() => openConversation(conversation.name)}
> >
{!collapsed ? '新建会话' : null} <Sidebar.MenuIcon>
</Button> <MessageCircle size={16} />
</Sidebar.MenuIcon>
{!collapsed ? ( <Sidebar.MenuLabel>
<> {getConversationLabel(conversation)}
{conversations?.length > 0 ? ( </Sidebar.MenuLabel>
<Conversations {conversation.metadata?.pinned ? (
items={conversationItems} <Sidebar.MenuChip>置顶</Sidebar.MenuChip>
className={styles.conversations} ) : null}
activeKey={currentConversation?.name} <Sidebar.MenuActions>
onActiveChange={openConversation} <Dropdown>
groupable <Dropdown.Trigger
styles={{ aria-label={`管理${getConversationLabel(conversation)}`}
item: { className="xy-conversation-menu-trigger"
padding: '0 8px', >
}, <MoreHorizontal size={16} />
}} </Dropdown.Trigger>
menu={conversationMenu} <Dropdown.Popover placement="bottom end">
/> <Dropdown.Menu
) : ( aria-label="会话操作"
<div className={styles.conversations}> onAction={(key) => {
<Empty if (key === 'pin') updatePin(conversation);
description="还没有历史会话" if (key === 'rename') {
image={Empty.PRESENTED_IMAGE_SIMPLE} setRenamingConversation(conversation);
className={styles.emptyHistory} setRenamingLabel(
/> getConversationLabel(conversation),
</div> );
)}
</>
) : (
<div className={styles.compactNav}>
{compactConversationItems.length > 0 ? (
compactConversationItems.map((item) => (
<Tooltip key={item.key} title={item.label} placement="right">
<div className={styles.compactNavItem}>
<div
className={cx(
styles.compactNavButton,
currentConversation?.name === item.key &&
styles.compactNavButtonActive,
)}
onClick={() => openConversation(item.key)}
role="button"
tabIndex={0}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
openConversation(item.key);
} }
if (key === 'delete')
setDeletingConversation(conversation);
}} }}
> >
{item.icon} <Dropdown.Item id="pin" textValue="置顶">
{item.pinned ? ( {conversation.metadata?.pinned ? (
<span className={styles.compactNavPin} /> <PinOff size={16} />
) : null}
</div>
</div>
</Tooltip>
))
) : ( ) : (
<Tooltip title="还没有历史会话" placement="right"> <Pin size={16} />
<div className={styles.compactNavItem}>
<div className={styles.compactNavButton}>
<CommentOutlined />
</div>
</div>
</Tooltip>
)} )}
<span>
{conversation.metadata?.pinned
? '取消置顶'
: '置顶'}
</span>
</Dropdown.Item>
<Dropdown.Item id="rename" textValue="重命名">
<Pencil size={16} />
<span>重命名</span>
</Dropdown.Item>
<Dropdown.Item
id="delete"
textValue="删除"
className="xy-danger-item"
>
<Trash2 size={16} />
<span>删除</span>
</Dropdown.Item>
</Dropdown.Menu>
</Dropdown.Popover>
</Dropdown>
</Sidebar.MenuActions>
</Sidebar.MenuItem>
))}
</Sidebar.Menu>
) : (
<div className="xy-sidebar-empty">
{searchValue ? '没有匹配的会话' : '还没有历史会话'}
</div> </div>
)} )}
</Sidebar.Group>
<div className={styles.sidebarFooter}> </Sidebar.Content>
{userInfo && (
<div <Sidebar.Footer className="xy-sidebar-footer">
className={cx( {userInfo ? (
styles.accountCard, <Dropdown>
collapsed && styles.accountCardCollapsed, <Dropdown.Trigger className="xy-account-trigger">
)} <UserAvatar userInfo={userInfo} />
> <span className="xy-account-copy">
<Dropdown <strong>{userInfo.display_name || '当前用户'}</strong>
trigger={['click']} <small>{userInfo.email || userInfo.username || '已登录'}</small>
menu={{ </span>
items: [ <MoreHorizontal size={16} />
{ </Dropdown.Trigger>
key: 'logout', <Dropdown.Popover placement="top start">
label: '退出登录', <Dropdown.Menu
icon: <PoweroffOutlined />, aria-label="账户菜单"
onClick: () => { onAction={(key) => {
if (key === 'apps') setApplicationSwitcherOpen(true);
if (key === 'logout') {
localStorage.removeItem('current_user'); localStorage.removeItem('current_user');
setCurrentConversation(null);
setUserInfo(null); setUserInfo(null);
}, }
},
],
}}
>
<div className={styles.accountIdentity}>
<Avatar
size={collapsed ? 40 : 42}
src={userInfo?.avatar}
style={{
background: '#e0f2fe',
color: token.colorPrimary,
flexShrink: 0,
}} }}
> >
{userInfo.display_name?.[0]} <Dropdown.Item id="apps" textValue="应用切换">
</Avatar> <AppWindow size={16} />
{!collapsed && ( <span>应用切换</span>
<div className={styles.accountMeta}> </Dropdown.Item>
<strong>{userInfo.display_name || '当前用户'}</strong> <Dropdown.Item id="logout" textValue="退出登录">
<span>账户设置与登录状态</span> <LogOut size={16} />
</div> <span>退出登录</span>
)} </Dropdown.Item>
</div> </Dropdown.Menu>
</Dropdown.Popover>
</Dropdown> </Dropdown>
) : null}
</Sidebar.Footer>
</>
);
<Tooltip title="应用切换" placement={collapsed ? 'right' : 'top'}> const sidebarContent = (
<button <>
type="button" <Sidebar.Root className="xy-sidebar">{renderSidebarBody()}</Sidebar.Root>
aria-label="应用切换" <Sidebar.Mobile className="xy-sidebar-mobile">
className={styles.applicationSwitcherButton} <Modal.Heading className="sr-only">会话列表</Modal.Heading>
onClick={() => setApplicationSwitcherOpen(true)} {renderSidebarBody()}
> </Sidebar.Mobile>
<AppstoreOutlined /> </>
</button>
</Tooltip>
</div>
)}
</div>
</div>
); );
return ( const title = currentConversation
<Spin spinning={loading} tip="正在同步工作台"> ? getConversationLabel(currentConversation)
<div className={styles.shell}> : '新会话';
<div className={styles.frame}>
<div className={styles.desktopSidebar}>{sidebarContent}</div>
<Drawer return (
placement="left" <div className="xy-home">
width={332} <AppLayout
open={mobileOpen} className="xy-app-layout"
closeIcon={null} defaultSidebarOpen
onClose={() => setMobileOpen(false)} sidebar={sidebarContent}
className={styles.drawerMask} sidebarCollapsible="offcanvas"
sidebarOpen={sidebarOpen}
scrollMode="content"
onSidebarOpenChange={setSidebarOpen}
navbar={
<Navbar className="xy-navbar" height="64px" maxWidth="full">
<Navbar.Header>
<Tooltip delay={400}>
<Tooltip.Trigger
render={(triggerProps) => (
<Sidebar.Trigger
{...triggerProps}
aria-expanded={sidebarOpen}
aria-label={sidebarOpen ? '收起会话列表' : '展开会话列表'}
className="xy-desktop-sidebar-toggle"
> >
{sidebarContent} {sidebarOpen ? (
</Drawer> <PanelLeftClose size={18} />
) : (
<div className={styles.contentShell}> <PanelLeftOpen size={18} />
)}
</Sidebar.Trigger>
)}
/>
<Tooltip.Content placement="bottom">
{sidebarOpen ? '收起会话列表' : '展开会话列表'}
</Tooltip.Content>
</Tooltip>
<AppLayout.MenuToggle
aria-label="打开会话列表"
className="xy-mobile-sidebar-toggle"
title="打开会话列表"
/>
<Navbar.Brand className="xy-chat-heading">
<span>
<strong>{title}</strong>
<small>
{currentConversation ? '刚刚更新' : '选择助手开始工作'}
</small>
</span>
</Navbar.Brand>
<Navbar.Spacer />
<Navbar.Content>
<Button <Button
type="default" aria-label="应用切换"
icon={<MenuUnfoldOutlined />} isIconOnly
onClick={() => setMobileOpen(true)} size="sm"
className={styles.mobileMenuButton} variant="ghost"
onPress={() => setApplicationSwitcherOpen(true)}
> >
菜单 <AppWindow size={17} />
</Button> </Button>
</Navbar.Content>
<div className={styles.body}> </Navbar.Header>
<div className={styles.chatStage}> </Navbar>
}
>
<div className="xy-chat-workspace">
{busy ? (
<div className="xy-workspace-loading" role="status">
<Spinner size="sm" />
<span>正在同步会话</span>
</div>
) : null}
<Chat <Chat
ref={chatRef} ref={chatRef}
userInfo={userInfo} userInfo={userInfo}
key={currentConversation?.name} key={currentConversation?.name || 'new'}
conversation={currentConversation} conversation={currentConversation}
onAddConversation={async (conversation, message) => { onAddConversation={async (conversation, message) => {
await refetchConversations(); await refetch();
setCurrentConversation(conversation); setCurrentConversation(conversation);
setMobileOpen(false);
if (message) { if (message) {
setTimeout(() => { window.setTimeout(() => chatRef.current?.submit(message), 50);
chatRef.current?.submit(message);
}, 50);
} }
}} }}
/> />
</div> </div>
</div> </AppLayout>
</div>
</div> <Modal.Root>
</div> <Modal.Trigger aria-hidden="true" className="sr-only" tabIndex={-1} />
<Modal.Backdrop
<Modal isOpen={Boolean(renamingConversation)}
title="重命名会话" variant="blur"
icon={null} onOpenChange={(open) => !open && setRenamingConversation(null)}
okText="确定" >
cancelText="取消" <Modal.Container placement="center" size="sm">
width={380} <Modal.Dialog className="xy-dialog">
open={renamingModalOpen} <Modal.CloseTrigger aria-label="关闭" />
onOk={async () => { <Modal.Header>
if (renamingConversation) { <Modal.Heading>重命名会话</Modal.Heading>
await updateConversation(renamingConversation.key, { </Modal.Header>
label: renamingConversationLabel, <Modal.Body>
<label className="xy-field-label" htmlFor="rename-conversation">
会话名称
</label>
<Input
id="rename-conversation"
autoFocus
fullWidth
value={renamingLabel}
onChange={(event) => setRenamingLabel(event.target.value)}
/>
</Modal.Body>
<Modal.Footer>
<Button
variant="ghost"
onPress={() => setRenamingConversation(null)}
>
取消
</Button>
<Button
variant="primary"
isDisabled={!renamingLabel.trim()}
onPress={async () => {
try {
await updateConversation(renamingConversation.name, {
label: renamingLabel.trim(),
}); });
await refetch();
await refetchConversations(); if (
setRenamingModalOpen(false); currentConversation?.name === renamingConversation.name
) {
setCurrentConversation((value) => ({
...value,
metadata: {
...value.metadata,
label: renamingLabel.trim(),
},
}));
}
setRenamingConversation(null);
toast.success('会话名称已更新');
} catch (error) {
toast.danger(error.message || '重命名失败');
} }
}}
onCancel={() => {
setRenamingConversationLabel(null);
setRenamingModalOpen(false);
}} }}
> >
<Input 保存
size="large" </Button>
placeholder="输入会话名称" </Modal.Footer>
value={renamingConversationLabel} </Modal.Dialog>
onChange={(e) => { </Modal.Container>
setRenamingConversationLabel(e.target.value); </Modal.Backdrop>
}} </Modal.Root>
className={styles.renameInput}
<AlertDialog.Root>
<AlertDialog.Trigger
aria-hidden="true"
className="sr-only"
tabIndex={-1}
/> />
</Modal> <AlertDialog.Backdrop
isOpen={Boolean(deletingConversation)}
variant="blur"
onOpenChange={(open) => {
if (!open && !deleting) setDeletingConversation(null);
}}
>
<AlertDialog.Container placement="center" size="sm">
<AlertDialog.Dialog className="xy-dialog">
<AlertDialog.Header>
<AlertDialog.Icon status="danger">
<Trash2 size={20} />
</AlertDialog.Icon>
<AlertDialog.Heading>删除会话?</AlertDialog.Heading>
</AlertDialog.Header>
<AlertDialog.Body>
<p className="xy-delete-copy">
确认删除“{getConversationLabel(deletingConversation)}
”?删除后无法恢复。
</p>
</AlertDialog.Body>
<AlertDialog.Footer>
<Button
variant="ghost"
isDisabled={deleting}
onPress={() => setDeletingConversation(null)}
>
取消
</Button>
<Button
variant="danger"
isDisabled={deleting}
onPress={deleteConversation}
>
{deleting ? (
<>
<Spinner size="sm" />
删除中
</>
) : (
'删除'
)}
</Button>
</AlertDialog.Footer>
</AlertDialog.Dialog>
</AlertDialog.Container>
</AlertDialog.Backdrop>
</AlertDialog.Root>
<ApplicationSwitcherDialog <ApplicationSwitcherDialog
open={applicationSwitcherOpen} open={applicationSwitcherOpen}
onClose={() => setApplicationSwitcherOpen(false)} onClose={() => setApplicationSwitcherOpen(false)}
userInfo={userInfo} userInfo={userInfo}
/> />
<LoginModal <LoginModal
open={!userInfo} open={!userInfo}
onSuccess={(currentUser) => { onSuccess={(currentUser) => {
setUserInfo(currentUser); setUserInfo(currentUser);
message.success('登录成功'); toast.success('登录成功');
}} }}
onError={(errorMessage) => message.error(errorMessage)} onError={(message) => toast.danger(message)}
/> />
</Spin> </div>
); );
}; }
.container { .xy-home,
padding-top: 80px; .xy-app-layout {
width: 100%;
height: 100dvh;
min-height: 0;
background: var(--xy-bg);
}
.xy-app-layout [data-slot='sidebar-provider'] {
height: 100%;
}
.xy-app-layout [data-slot='app-layout-body'] {
min-width: 0;
background: var(--xy-bg);
}
.xy-app-layout [data-slot='app-layout-main'] {
min-height: 0;
overflow: hidden;
}
.xy-sidebar {
width: var(--xy-sidebar-width);
border-right: 1px solid var(--xy-border);
background: #f4f4f5;
}
.xy-sidebar-header {
min-height: 64px;
padding: 14px 16px 10px;
}
.xy-brand {
display: flex;
min-width: 0;
align-items: center;
gap: 10px;
}
.xy-brand-mark {
display: inline-flex;
width: 32px;
height: 32px;
flex: 0 0 32px;
align-items: center;
justify-content: center;
border: 1px solid var(--xy-border);
border-radius: 50%;
color: #fff;
background: var(--xy-brand);
}
.xy-brand-copy,
.xy-account-copy,
.xy-chat-heading > span {
display: flex;
min-width: 0;
flex-direction: column;
}
.xy-brand-copy strong,
.xy-account-copy strong,
.xy-chat-heading strong {
overflow: hidden;
color: var(--xy-text);
font-size: 14px;
font-weight: 600;
line-height: 20px;
letter-spacing: 0;
text-overflow: ellipsis;
white-space: nowrap;
}
.xy-brand-copy small,
.xy-account-copy small,
.xy-chat-heading small {
overflow: hidden;
color: var(--xy-text-secondary);
font-size: 12px;
line-height: 16px;
letter-spacing: 0;
text-overflow: ellipsis;
white-space: nowrap;
}
.xy-sidebar-primary {
padding-bottom: 4px;
}
.xy-sidebar-search {
display: flex;
height: 36px;
margin: 2px 12px 10px;
align-items: center;
gap: 6px;
padding: 0 10px;
border: 1px solid var(--xy-border);
border-radius: var(--xy-radius);
color: var(--xy-text-secondary);
background: var(--xy-surface);
}
.xy-sidebar-search .input {
min-width: 0;
padding: 0;
border: 0;
box-shadow: none;
background: transparent;
}
.xy-conversation-group {
min-height: 0;
flex: 1;
padding-top: 8px;
}
.xy-conversation-group [data-slot='sidebar-menu-item'],
.xy-conversation-group [data-slot='sidebar-menu-item-content'],
.xy-conversation-group [data-slot='sidebar-menu-label'] {
min-width: 0;
}
.xy-conversation-group [data-slot='sidebar-menu-actions'] {
display: flex;
width: 28px;
min-width: 28px;
align-items: center;
justify-content: center;
opacity: 0;
visibility: hidden;
pointer-events: none;
transition: opacity 150ms ease-out, visibility 150ms ease-out;
}
.xy-conversation-group
[data-slot='sidebar-menu-item']:is(
:hover,
:focus-within,
[data-current='true'],
[data-hovered='true']
)
[data-slot='sidebar-menu-actions'],
.xy-conversation-group
[data-slot='sidebar-menu-actions']:has([aria-expanded='true']) {
opacity: 1;
visibility: visible;
pointer-events: auto;
}
.xy-conversation-menu-trigger {
display: inline-grid;
width: 28px;
height: 28px;
place-items: center;
padding: 0;
border: 0;
border-radius: 6px;
color: var(--xy-text-secondary);
background: transparent;
cursor: pointer;
}
.xy-conversation-menu-trigger:hover,
.xy-conversation-menu-trigger[data-hovered='true'] {
color: var(--xy-text);
background: var(--xy-surface-hover);
}
.xy-sidebar-empty {
padding: 20px 14px;
color: var(--xy-text-secondary);
font-size: 13px;
text-align: center;
}
.xy-sidebar-footer {
padding: 10px 12px 12px;
border-top: 1px solid var(--xy-border);
}
.xy-account-trigger {
display: flex;
width: 100%;
min-height: 48px;
align-items: center;
gap: 9px;
padding: 6px 8px;
border: 0;
border-radius: var(--xy-radius);
color: var(--xy-text);
background: transparent;
cursor: pointer;
text-align: left;
}
.xy-account-trigger:hover,
.xy-account-trigger[data-hovered='true'] {
background: var(--xy-surface-hover);
}
.xy-account-copy {
flex: 1;
}
.xy-navbar {
border-bottom: 1px solid var(--xy-border);
background: color-mix(in srgb, var(--xy-bg) 92%, transparent);
backdrop-filter: blur(12px);
}
.xy-navbar [data-slot='navbar-header'] {
gap: 10px;
padding: 0 18px;
}
.xy-desktop-sidebar-toggle {
width: 44px;
height: 44px;
min-width: 44px;
margin-inline-start: -8px;
}
.xy-mobile-sidebar-toggle {
display: none;
}
.xy-chat-heading {
min-width: 0;
}
.xy-chat-heading strong {
max-width: min(52vw, 520px);
font-size: 15px;
}
.xy-chat-workspace {
position: relative;
width: 100%;
height: 100%;
min-height: 0;
}
.xy-workspace-loading {
position: absolute;
top: 12px;
left: 50%;
z-index: 30;
display: flex;
align-items: center;
gap: 8px;
padding: 7px 12px;
border: 1px solid var(--xy-border);
border-radius: 999px;
color: var(--xy-text-secondary);
background: var(--xy-surface);
box-shadow: 0 6px 18px rgb(0 0 0 / 8%);
font-size: 12px;
transform: translateX(-50%);
}
.xy-dialog {
border: 1px solid var(--xy-border);
border-radius: var(--xy-radius);
background: var(--xy-surface);
box-shadow: 0 20px 50px rgb(0 0 0 / 18%);
}
.xy-field-label {
display: block;
margin-bottom: 7px;
color: var(--xy-text);
font-size: 13px;
font-weight: 500;
}
.xy-danger-item {
color: var(--xy-danger);
}
.xy-delete-copy {
margin: 0;
color: var(--xy-text-secondary);
line-height: 1.6;
overflow-wrap: anywhere;
}
@media (max-width: 768px) {
.xy-desktop-sidebar-toggle {
display: none;
}
.xy-mobile-sidebar-toggle {
display: inline-flex;
}
.xy-conversation-group [data-slot='sidebar-menu-actions'] {
width: 44px;
min-width: 44px;
}
.xy-conversation-menu-trigger {
width: 44px;
height: 44px;
}
.xy-dialog [data-slot='modal-close-trigger'],
.xy-login-dialog [data-slot='modal-close-trigger'] {
width: 44px;
height: 44px;
}
.xy-dialog [data-slot='button'],
.xy-login-dialog [data-slot='button'] {
min-height: 44px;
}
.xy-navbar [data-slot='app-layout-menu-toggle'],
.xy-navbar [data-slot='button'] {
width: 44px;
height: 44px;
min-width: 44px;
}
.xy-navbar [data-slot='navbar-header'] {
padding: 0 12px;
}
.xy-chat-heading strong {
max-width: 58vw;
}
} }
...@@ -11,34 +11,6 @@ const APPLICATION_NAME = ...@@ -11,34 +11,6 @@ const APPLICATION_NAME =
let applicationConfigPromise = null; let applicationConfigPromise = null;
function joinUrl(baseUrl, path) {
return `${baseUrl.replace(/\/+$/, '')}/${path.replace(/^\/+/, '')}`;
}
const DEFAULT_LLM_CHAT_PATH = '/v1/chat/completions';
const SAME_ORIGIN_LLM_PROXY = '/llm-api';
const PROXIED_LLM_HOSTS = new Set(['edge.lingsuan.org', 'lingsuan.org']);
function resolveConfiguredLlmUrl(url, path) {
if (!url) return null;
if (path) return joinUrl(url, path);
if (
/\/(?:chat\/completions|responses|completions)\/?(?:[?#].*)?$/i.test(url)
) {
return url;
}
return joinUrl(url, DEFAULT_LLM_CHAT_PATH);
}
function routeLlmUrlThroughSameOriginProxy(url) {
if (!url || !/^https?:\/\//i.test(url)) return url;
const parsed = new URL(url);
if (!PROXIED_LLM_HOSTS.has(parsed.hostname.toLowerCase())) return url;
return `${SAME_ORIGIN_LLM_PROXY}${parsed.pathname}${parsed.search}`;
}
function firstDefined(...values) { function firstDefined(...values) {
return values.find((value) => value !== undefined && value !== null); return values.find((value) => value !== undefined && value !== null);
} }
...@@ -68,72 +40,6 @@ function normalizeObjectConfig(value) { ...@@ -68,72 +40,6 @@ function normalizeObjectConfig(value) {
return {}; return {};
} }
const LLM_CONTROL_CONFIG_KEYS = new Set([
'url',
'endpoint',
'baseUrl',
'base_url',
'llmUrl',
'llm_url',
'llmBaseUrl',
'llm_base_url',
'path',
'chatPath',
'chat_path',
'llmPath',
'llm_path',
'llmChatPath',
'llm_chat_path',
'model',
'models',
'llmModel',
'llm_model',
'defaultModel',
'default_model',
'longModel',
'long_model',
'thinkingModel',
'thinking_model',
'thinkingLongModel',
'thinking_long_model',
'longThinkingModel',
'long_thinking_model',
'apiKey',
'api_key',
'apikey',
'llmApiKey',
'llm_api_key',
'authorization',
'headers',
'request',
'params',
'body',
'llm',
'invoiceTool',
'invoice_tool',
'invoiceToolApiKey',
'invoice_tool_api_key',
'invoiceToolBaseUrl',
'invoice_tool_base_url',
]);
function pickRequestParams(...configs) {
return configs.reduce((params, config) => {
const normalizedConfig = normalizeObjectConfig(config);
Object.entries(normalizedConfig).forEach(([key, value]) => {
if (
!LLM_CONTROL_CONFIG_KEYS.has(key) &&
value !== undefined &&
value !== null
) {
params[key] = value;
}
});
return params;
}, {});
}
function firstNonEmptyObject(...values) { function firstNonEmptyObject(...values) {
return values.find( return values.find(
(value) => Object.keys(normalizeObjectConfig(value)).length, (value) => Object.keys(normalizeObjectConfig(value)).length,
...@@ -187,37 +93,6 @@ function normalizeApplicationConfig(application = {}) { ...@@ -187,37 +93,6 @@ function normalizeApplicationConfig(application = {}) {
return { return {
raw: layoutsConfig, raw: layoutsConfig,
llm: { llm: {
url: firstNonEmptyString(
layoutsConfig.url,
layoutsConfig.endpoint,
layoutsConfig.baseUrl,
layoutsConfig.base_url,
layoutsConfig.llmUrl,
layoutsConfig.llm_url,
layoutsConfig.llmBaseUrl,
layoutsConfig.llm_base_url,
llmConfig.url,
llmConfig.endpoint,
llmConfig.baseUrl,
llmConfig.base_url,
settingsConfig.url,
settingsConfig.endpoint,
),
path: firstNonEmptyString(
layoutsConfig.path,
layoutsConfig.chatPath,
layoutsConfig.chat_path,
layoutsConfig.llmPath,
layoutsConfig.llm_path,
layoutsConfig.llmChatPath,
layoutsConfig.llm_chat_path,
llmConfig.path,
llmConfig.chatPath,
llmConfig.chat_path,
settingsConfig.path,
settingsConfig.chatPath,
settingsConfig.chat_path,
),
model: firstNonEmptyString( model: firstNonEmptyString(
modelConfig.default, modelConfig.default,
layoutsConfig.llmModel, layoutsConfig.llmModel,
...@@ -276,36 +151,6 @@ function normalizeApplicationConfig(application = {}) { ...@@ -276,36 +151,6 @@ function normalizeApplicationConfig(application = {}) {
settingsConfig.long_thinking_model, settingsConfig.long_thinking_model,
), ),
}, },
apiKey: firstNonEmptyString(
layoutsConfig.apiKey,
layoutsConfig.api_key,
layoutsConfig.apikey,
layoutsConfig.llmApiKey,
layoutsConfig.llm_api_key,
llmConfig.apiKey,
llmConfig.api_key,
llmConfig.apikey,
settingsConfig.apiKey,
settingsConfig.api_key,
settingsConfig.apikey,
process.env.UMI_APP_LLM_API_KEY,
process.env.VITE_LLM_API_KEY,
),
authorization: firstNonEmptyString(
layoutsConfig.authorization,
llmConfig.authorization,
settingsConfig.authorization,
),
headers: firstDefined(
layoutsConfig.headers,
llmConfig.headers,
settingsConfig.headers,
),
requestParams: pickRequestParams(
settingsConfig,
llmConfig,
layoutsConfig,
),
}, },
invoiceTool: { invoiceTool: {
apiKey: firstNonEmptyString( apiKey: firstNonEmptyString(
...@@ -340,31 +185,47 @@ export async function getApplicationRuntimeConfig({ force = false } = {}) { ...@@ -340,31 +185,47 @@ export async function getApplicationRuntimeConfig({ force = false } = {}) {
return applicationConfigPromise; return applicationConfigPromise;
} }
function resolveLlmUrl(llmConfig = {}) { function normalizeLlmList(payload) {
const envBaseUrl = firstNonEmptyString( const candidates = [
process.env.UMI_APP_LLM_API_BASE, payload,
process.env.VITE_LLM_API_BASE, payload?.data,
); payload?.models,
const envPath = firstNonEmptyString( payload?.records,
process.env.UMI_APP_LLM_CHAT_PATH, payload?.result,
process.env.VITE_LLM_CHAT_PATH, payload?.result?.data,
); ];
const configUrl = llmConfig.url; const items = candidates.find(Array.isArray) || [];
const configPath = llmConfig.path; const models = items
.map((item) => {
if (configUrl) { if (typeof item === 'string' && item.trim()) {
return routeLlmUrlThroughSameOriginProxy( return { id: item.trim(), label: item.trim() };
resolveConfiguredLlmUrl(configUrl, configPath), }
); if (!item || typeof item !== 'object') return null;
}
const id = firstNonEmptyString(
if (envBaseUrl) { item.id,
return routeLlmUrlThroughSameOriginProxy( item.model,
resolveConfiguredLlmUrl(envBaseUrl, envPath), item.name,
item.value,
item.key,
); );
} if (!id) return null;
return {
...item,
id,
label:
firstNonEmptyString(
item.display_name,
item.displayName,
item.label,
item.title,
item.name,
) || id,
};
})
.filter(Boolean);
return null; return [...new Map(models.map((model) => [model.id, model])).values()];
} }
export function extractLlmChunkContent(value) { export function extractLlmChunkContent(value) {
...@@ -658,49 +519,20 @@ export async function callMcpTool(mcpName, toolName, data = {}) { ...@@ -658,49 +519,20 @@ export async function callMcpTool(mcpName, toolName, data = {}) {
} }
export async function llmChat(data, signal = undefined) { export async function llmChat(data, signal = undefined) {
const runtimeConfig = await getApplicationRuntimeConfig(); const model = data.model || (await resolveLlmModel());
const llmConfig = runtimeConfig.llm || {};
const url = resolveLlmUrl(llmConfig);
const model = data.model || llmConfig.model;
if (!url) {
throw new Error('应用配置缺少模型请求地址:请在 layouts.config.url 中配置');
}
if (!model) { if (!model) {
throw new Error('应用配置缺少模型名称:请在 layouts.config.model 中配置'); throw new Error('请选择模型后再发送消息');
} }
const headers = new Headers({ const response = await fetch('/api/llm/chat', {
method: 'POST',
headers: {
Accept: 'text/event-stream', Accept: 'text/event-stream',
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}); },
const configuredHeaders = normalizeObjectConfig(llmConfig.headers);
if (Object.keys(configuredHeaders).length) {
Object.entries(configuredHeaders).forEach(([key, value]) => {
if (value !== undefined && value !== null) {
headers.set(key, String(value));
}
});
}
if (llmConfig.authorization && !headers.has('Authorization')) {
headers.set('Authorization', llmConfig.authorization);
}
if (llmConfig.apiKey && !headers.has('Authorization')) {
headers.set('Authorization', `Bearer ${llmConfig.apiKey}`);
}
const response = await fetch(url, {
method: 'POST',
headers,
signal, signal,
body: JSON.stringify({ body: JSON.stringify({
...(llmConfig.requestParams && typeof llmConfig.requestParams === 'object'
? llmConfig.requestParams
: {}),
...data, ...data,
model, model,
stream: true, stream: true,
...@@ -715,6 +547,23 @@ export async function llmChat(data, signal = undefined) { ...@@ -715,6 +547,23 @@ export async function llmChat(data, signal = undefined) {
return response; return response;
} }
export async function llmList(signal = undefined) {
const payload = await request('/api/llm/list', {
method: 'get',
signal,
});
const models = normalizeLlmList(payload);
if (!models.length) {
throw new Error('模型列表为空');
}
const defaultModel = await resolveLlmModel().catch(() => null);
return models.map((model) => ({
...model,
isDefault: model.id === defaultModel,
}));
}
export async function resolveLlmModel(options = {}) { export async function resolveLlmModel(options = {}) {
const runtimeConfig = await getApplicationRuntimeConfig(); const runtimeConfig = await getApplicationRuntimeConfig();
const models = runtimeConfig.llm?.models || {}; const models = runtimeConfig.llm?.models || {};
......
@import 'tailwindcss';
@import '../node_modules/@heroui/styles/dist/heroui.min.css';
@import '@heroui-pro/react/css';
:root {
color-scheme: light;
--xy-bg: #f7f7f8;
--xy-surface: #fff;
--xy-surface-subtle: #f2f2f3;
--xy-surface-hover: #e9e9eb;
--xy-text: #18181b;
--xy-text-secondary: #71717a;
--xy-text-muted: #a1a1aa;
--xy-border: #e4e4e7;
--xy-border-strong: #d4d4d8;
--xy-brand: #2574e8;
--xy-brand-hover: #1d64cf;
--xy-danger: #dc2626;
--xy-success: #16803c;
--xy-warning: #a16207;
--xy-sidebar-width: 240px;
--xy-content-width: 760px;
--xy-radius: 8px;
font-family: Inter, ui-sans-serif, system-ui, -apple-system,
BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif;
}
html,
body,
#root,
.xy-app-root {
width: 100%;
height: 100%;
min-height: 100%;
margin: 0;
}
body {
overflow: hidden;
color: var(--xy-text);
background: var(--xy-bg);
font-family: inherit;
-webkit-font-smoothing: antialiased;
text-rendering: optimizelegibility;
}
*,
*::before,
*::after {
box-sizing: border-box;
}
button,
input,
textarea,
select {
font: inherit;
}
button,
[role='button'] {
touch-action: manipulation;
}
a {
color: inherit;
text-decoration: none;
}
::selection {
color: var(--xy-text);
background: #dbeafe;
}
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: #d4d4d8;
border: 2px solid transparent;
border-radius: 999px;
background-clip: padding-box;
}
:focus-visible {
outline: 2px solid color-mix(in srgb, var(--xy-brand) 65%, transparent);
outline-offset: 2px;
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
scroll-behavior: auto !important;
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment