Compare commits

..

112 Commits

Author SHA1 Message Date
Syngnat
d9cbbc6c31 Release/0.6.4 2026-03-28 11:24:23 +08:00
Syngnat
09d013f27d 🐛 fix(app): 为稳定期首次连接增加瞬时网络重试保护 (#309)
## 问题背景
在 app 启动后等待 20s 以上,再手动触发数据库连接时,遇到瞬时网络错误(如 `no route to
host`)会立即失败,用户体感为“没有重试”。

相关讨论与上下文参考:
- https://github.com/Syngnat/GoNavi/pull/294

## 问题描述
此前重试保护逻辑主要以“应用启动窗口(20s)”为边界:
- 启动窗口内:瞬时网络失败会自动重试
- 启动窗口外:即使是瞬时网络失败也不重试
这导致“用户首次手动连接发生在稳定期”时,行为与预期不一致。

## 本地复现关键日志(节选)
```log
2026/03/27 15:21:04.792462 [INFO] 应用启动完成(首次连接保护窗口=20s,最多重试=4 次)
2026/03/27 15:22:29.196794 [INFO] 获取数据库连接:... 启动阶段=稳定期(age=1m24.405s)
2026/03/27 15:22:29.208920 [ERROR] 建立数据库连接失败:... connect: no route to host
2026/03/27 15:22:29.212453 [ERROR] DBGetDatabases 获取连接失败:... connect: no route to host


2026/03/27 16:07:45.463959 [INFO] 获取数据库连接:... 启动阶段=稳定期(age=21s)
2026/03/27 16:07:45.470744 [ERROR] 建立数据库连接失败:... connect: no route to host
2026/03/27 16:07:45.473604 [WARN] 检测到瞬时网络失败,准备重试连接:... 尝试=1/4 延迟=800ms
2026/03/27 16:07:46.277658 [INFO] 获取数据库连接:... 启动阶段=稳定期(age=21.814s)
2026/03/27 16:07:46.281761 [INFO] 创建数据库驱动实例:... 尝试=2/4
2026/03/27 16:07:46.284741 [ERROR] 建立数据库连接失败:... connect: no route to host
2026/03/27 16:20:59.298636 [INFO] 应用启动完成(首次连接保护窗口=20s,最多重试=4 次)
2026/03/27 16:23:26.180978 [INFO] 获取数据库连接:... 启动阶段=稳定期(age=2m26.883s)
2026/03/27 16:23:26.201478 [INFO] 数据库连接成功并写入缓存:...
```

## 变更内容
- 调整连接重试判定逻辑:
  - 启动窗口内:保持原有重试预算(最多 4 次)
  - 启动窗口外:若为瞬时网络错误,补充一次保护重试(总计 2 次尝试)
  - 非瞬时错误(如认证失败)在稳定期不重试
- 日志文案泛化,避免“仅启动期”误导:
  - 数据库连接在重试后成功
  - 检测到瞬时网络失败,准备重试连接
## 测试与验证
### 新增/更新单元测试覆盖以下场景:
- 启动期瞬时错误重试并成功
- 稳定期瞬时错误重试一次并成功
- 稳定期瞬时错误持续失败时,仅重试一次后停止
- 稳定期非瞬时错误不重试
- 稳定期重试路径输出重试提示日志
- 启动期瞬时错误失败时使用完整重试预算
### 本地执行:
- go test ./internal/app -run StartupRetry -count=1
- go test ./internal/app -count=1
### 影响范围
- 连接建立重试策略(internal/app/app.go)
- 启动重试相关测试(internal/app/app_startup_connect_retry_test.go)
## 风险与回滚
- 风险:稳定期瞬时网络错误会增加一次重试等待(约 800ms)
- 回滚:可回退本 PR 即恢复“仅启动窗口重试”的旧策略
2026-03-27 17:30:14 +08:00
Syngnat
09aa526570 🐛 fix(ai/provider/chat-ui): 修复千问 Coding Plan 预设与 Claude CLI 报错
- 统一千问 Coding Plan 到 claude-cli 链路
- 修正旧配置识别与模型列表逻辑
- 透传 Claude CLI 鉴权失败和错误事件
- 移除误杀正常回复的启动定时器
2026-03-27 17:02:51 +08:00
DurianPankek
5844cd7c01 🐛 fix(app): 为稳定期首次连接增加瞬时网络重试保护 2026-03-27 16:27:46 +08:00
Syngnat
4f74c44147 🐛 fix(ai/provider/chat-ui): 修复AI供应商兼容性并优化聊天提示交互
- 修复通义千问百炼 Anthropic 兼容鉴权头与健康检查请求
- 拆分通义千问百炼通用与 Coding Plan 双入口,调整预设回填与模型策略
- 修复火山 Coding Plan 模型列表过滤逻辑,避免混入无关模型
- 统一 OpenAI 兼容供应商路径与模型列表处理,补充相关服务层测试
- 优化 AI 设置供应商卡片布局,统一高度并收紧文本展示
- 将聊天区模型校验提示改为输入框上方的内联提示卡,补充前端回归测试
2026-03-27 14:29:03 +08:00
Syngnat
a5fdfefa2d 🐛 fix(ai/volcengine): 修复火山引擎兼容路径并拆分双预设
- OpenAI 兼容 URL 归一化改为保留已有 v3 和 v4 版本段,避免火山与智谱地址被错误补 /v1
- 对误填 /chat/completions 和 /models 的地址先回退到 base URL,再拼接目标端点
- 模型列表与连通性检测复用统一端点解析逻辑,修复火山 Coding Plan 等兼容服务请求
- AI 设置页拆分火山方舟与火山 Coding Plan 两个预设,并按完整路径精确匹配回显
- 修正模型下拉默认值行为,未选模型时保持占位态,避免误用动态列表首项
- 补充 provider 与 service 回归测试,并新增需求追踪文档
2026-03-27 12:04:55 +08:00
Syngnat
37ac13b94e 🐛 fix(ai/wails-binding): 修复生命周期绑定生成类型错误
- 收敛 App 与 AI Service 的内部生命周期方法,避免被 Wails 误导出到前端
- 将启动初始化改为包级生命周期接线,保持主程序启动流程不变
- 隐藏内部清理方法,移除生成绑定中的无效 context/time 类型声明
- 同步更新 frontend/wailsjs 绑定文件,清理 Service 与 App 的错误导出
- 调整相关测试调用,确保内部方法重命名后行为一致
2026-03-27 11:42:57 +08:00
Syngnat
d4d685b076 feat(ci/ai): 新增 dev 分支自动构建工作流并增强 Claude CLI Windows 兼容性
- CI 新增:添加 dev-build.yml,push dev 分支自动触发全平台构建并发布 Pre-release
- CI 清理:删除已废弃的 test-build-all-platforms.yml 和 test-macos-build.yml
- Claude CLI:新增 Windows 环境自动探测 Git Bash 路径(ProgramFiles/LocalAppData 多候选)
- Claude CLI:setEnv 改为可返回 error,环境变量操作提纯为 buildClaudeCLIEnv 纯函数
- Claude CLI:新增 upsertEnv/fileExists/joinWindowsPath 等工具函数
2026-03-27 10:49:39 +08:00
Syngnat
9f6d524e3d 🐛 fix(ai/provider): 修复 Kimi 与 MiniMax 供应商兼容路由
- 调整 Kimi 预设为 Anthropic 兼容入口并修正 Moonshot 域名回显
- 修复 Anthropic 请求地址归一化,确保聊天请求正确落到 /v1/messages
- 修正 Kimi 模型列表与测试连接路由,固定使用 Moonshot /v1/models
- 修正 MiniMax 默认模型与兼容模型集合,避免请求不存在的 /anthropic/v1/models
- 为 MiniMax 健康检查改用最小化 messages 请求,并兼容旧模型名配置
- 补充 Kimi 与 MiniMax 供应商回归测试,更新需求追踪文档
2026-03-26 22:26:25 +08:00
Syngnat
a89289f1cc Merge branch 'dev' into feature/ai-integration-ygf-20260323 2026-03-26 20:29:20 +08:00
Syngnat
b958ff6481 🐛 fix(ai/query-editor/mac-window): 修复模型兼容性并优化即时执行与窗口交互
- AI 兼容性:为 Anthropic Provider 补齐 tools/tool_use/tool_result 协议转换,支持工具调用与流式工具结果解析
- 降级策略:OpenAI 兼容接口在 tools 请求返回 400/422/404 时自动回退为纯文本模式
- 配置修复:调整 MiniMax 预设为 Anthropic 兼容端点并更新默认模型列表
- 状态隔离:AI 聊天面板停止将动态模型列表写回供应商配置,避免污染静态 models 数据
- 编辑器修复:QueryEditor 在 runImmediately 场景下避免重复追加 SQL,改为直接选中并执行
- 交互优化:修复 macOS 原生窗口控制切换与标题栏点击行为,避免窗口按钮状态异常
2026-03-26 17:57:29 +08:00
Syngnat
98e9e5686d feat(ai): 发布全新 AI Copilot 助手面板与工作区智能打通
- 核心架构:新增独立 AI 会话中枢,集成主流大模型生态(含私有部署中继版)的无感衔接发问
- 智能诊断:打破信息孤岛,大模型可通过关联工作区实时数据表 DDL 和错误栈,充当专属 DBA 排错及代码编写
- 视觉与多模态:支持极简发图读图交互体验,智能补全模型所需的缺省预警 Prompt,并兼容不规范中转端点图文并茂
- UI 与性能:重构聊天浮层挂靠逻辑与渲染阻断,应对长时间巨量问答引发的卡段内存泄漏,会话自动保存归档
2026-03-26 16:02:08 +08:00
Syngnat
93446e060e 🐛 fix(table-designer): 修复索引编辑丢失与勾选异常 (#302)
## 问题描述


问题1:设计表中修改索引时,当前实现采用“先删除旧索引,再创建新索引”的流程。当新索引创建过程中出现异常时,旧索引已经被删除,最终会导致原有索引丢失。见https://github.com/Syngnat/GoNavi/issues/300
问题2:自测时发现

索引列表还存在选择交互异常:

- 只有一条索引被选中时,checkbox 偶发无法取消
- “修改”按钮会因选中状态异常而偶发不可用
- checkbox 与整行点击在某些情况下表现不一致


## 问题原因
### 1. 索引编辑失败后丢失原索引
索引修改流程是拆成两条 DDL 顺序执行:
1. 删除旧索引
2. 创建新索引
执行层没有事务保护,也没有失败补偿逻辑。
因此当第 1 步成功、第 2 步失败时,原索引会被直接删掉。

### 2. 索引勾选状态异常
索引表存在两套同时修改选中状态的交互:
- checkbox 自己维护一套 toggle
- 整行点击也维护一套 toggle
两套逻辑共同修改 `selectedIndexKeys`,会导致事件命中时出现互相抵消,从而出现:
- checkbox 偶发点不动
- 单选状态不稳定
- “修改”按钮偶发不可用

## 修复方案
### 1. 增加索引编辑失败恢复机制
- 抽出统一的 DDL 顺序执行逻辑,明确拿到失败语句位置
- 修改索引时,若旧索引删除成功但新索引创建失败,则自动尝试按旧定义恢复原索引
- 若无法恢复,则给出明确错误提示
- 同时增加“无实际变更”判断,避免无意义执行破坏性 DDL
### 2. 统一索引选择交互入口
- 将索引选中状态收敛到单一的 `toggleIndexSelection` 入口
- checkbox 区域改为只走同一套状态切换逻辑
- 阻断 checkbox 区域事件冒泡,避免和整行点击双重触发
- 消除重复选中与单选取消不稳定问题
### 3. 补充单元测试
新增针对索引相关 helper 的单元测试,覆盖:
- 索引行到编辑表单的归一化
- 无变更编辑识别
- 选择切换不重复
- 单选场景下反复点击可稳定选中/取消
- 仅在“删除旧索引成功、创建新索引失败”时触发恢复判断

## 验证效果
### 已验证
- 修改索引时,若新索引创建失败,会尝试恢复原索引
- 单条索引选中后,可稳定通过 checkbox 取消选中
- 多选/取消后,单选状态仍然稳定
- “修改”按钮随单选状态稳定启用/禁用

### 单元测试
执行命令:

```bash
npm test -- src/components/tableDesignerIndexUtils.test.ts
```

## 回归执行结果:

### 问题1
- 索引bug#300_上报问题现象
<img width="1119" height="433" alt="索引bug#300_上报问题现象"
src="https://github.com/user-attachments/assets/61831c2f-5840-4d0d-ab71-d6c82d0db63e"
/>

- 索引bug#300_修复效果截图
<img width="1500" height="460" alt="索引bug#300_修复效果截图"
src="https://github.com/user-attachments/assets/277fd339-9bc4-4cfb-9e0f-d2365e334cdd"
/>

### 问题2
- 索引修改前端事件问题现象截图,有时看着是正常的,实则是两套前端事件冲突

<img width="324" height="283" alt="索引修改前端事件问题"
src="https://github.com/user-attachments/assets/849c362c-4ce3-46b6-9a33-f7348be9c581"
/>

<img width="491" height="348" alt="索引修改前端事件问题2_有时看着是正常的"
src="https://github.com/user-attachments/assets/855a1ed7-1365-44cc-a2f9-6993c3d761e0"
/>

<img width="707" height="406" alt="索引修改前端事件问题3_checkbox事件冲突"
src="https://github.com/user-attachments/assets/3c5fc75f-9eb2-470e-8b0c-976b8eaf5a94"
/>

- 索引修改前端事件问题修复效果
<img width="2308" height="792" alt="索引修改前端事件问题修复效果"
src="https://github.com/user-attachments/assets/f22e8145-58fd-4ba1-9d29-e81a879af64d"
/>

### 影响范围说明
本次修改影响设计表中的“索引”页签交互与索引编辑执行流程,主要包括:
- 索引修改
- 索引单选/多选
- “修改”按钮启用状态
- 索引失败后的恢复处理
不影响:
- 普通表结构保存流程
- 外键维护逻辑
- 触发器维护逻辑
- 非索引相关页面交互

### 风险说明
- 索引恢复依赖旧索引定义能正确还原为创建 SQL
- 当前修复已覆盖前端交互和失败补偿逻辑,但不同数据库方言下仍建议结合实际库型回归验证一次索引修改流程
2026-03-26 12:16:45 +08:00
DurianPankek
ecc8ff1197 🐛 fix(table-designer): 修复索引编辑丢失与勾选异常 2026-03-23 17:32:11 +08:00
Syngnat
82369b4070 Merge branch 'dev' into pr-294 2026-03-22 20:58:21 +08:00
Syngnat
1bda751ada feat(ai-chat): 全面升级AI聊天面板并优化交互体验
- 消息管理:新增聊天气泡的重试、编辑与单条删除功能及相对应的持久化状态函数
- 快捷操作:支持长文一键滑动到底端,并在代码块内增加SQL一键送入编辑器的快捷执行机制
- 视觉优化:深化AI回复背景沉浸感,重绘AI洞察按钮并移除设置面板所有的冗余紫色调
- 设置调优:放宽模型初始必填限制,新增内置系统提示词(Builtin Prompt)全览面板
2026-03-22 20:54:29 +08:00
DurianPankek
7bc358d612 🐛 fix(connect): 修复首次启动数据库连接偶发失败 2026-03-21 16:17:29 +08:00
Syngnat
36a57f9601 feat(shortcut): 将 macOS 全屏切换快捷键注册到快捷键管理面板
- 新增 toggleMacFullscreen action 到 shortcuts.ts
- 新增 platformOnly 字段支持按平台过滤快捷键显示
- 默认绑定 Ctrl+Meta+F,仅 macOS 下显示
- 移除 App.tsx 中的硬编码全屏快捷键判断,统一走 shortcuts 系统
2026-03-20 21:44:12 +08:00
Syngnat
e85c561f1e feat(mac-window): 支持切换 macOS 原生窗口控制与原生全屏行为 (#288)
## 背景
当前 GoNavi 使用自定义无边框标题栏与右上角窗口按钮,在 macOS 下与系统原生窗口交互习惯存在明显差异:
- 缺少左上角原生红黄绿窗口控制按钮
- 绿色按钮不具备 macOS 原生全屏 / Space 语义
- 标题栏交互和系统应用不够一致

这个 PR 为 macOS 增加了可切换的原生窗口控制模式,在尽量不影响现有跨平台行为的前提下,补齐 macOS 用户更熟悉的窗口体验。

## 变更内容
- 在 `主题 -> 外观参数` 中新增 `使用 macOS 原生窗口控制` 开关
- 启用后:
  - 显示 macOS 左上角原生红黄绿按钮
  - 隐藏现有右上角自定义窗口按钮
  - 为标题栏内容预留原生按钮安全区域
  - 优先使用 macOS 原生全屏行为
  - 支持 `Control + Command + F` 切换全屏
- 修复原生全屏下按 `Esc` 会意外退出全屏的问题
- 补充窗口行为、边界条件和相关工具函数单元测试

## 影响范围
- 仅影响 macOS 下启用该开关时的窗口样式与交互
- Windows/Linux 默认行为不变
- Windows 构建已验证通过

## 验证结果
已完成以下验证:
- [x] `npm run test`
- [x] `npm run build`
- [x] `go test ./...`
- [x] macOS 手工验证通过
- [x] Windows 构建验证通过

### macOS 手工验证项
- [x] 设置页可见 `使用 macOS 原生窗口控制`
- [x] 开关关闭时,保留当前自定义标题栏与右上角按钮
- [x] 开关开启时,右上角自定义按钮隐藏
- [x] 开启后显示左上角原生红黄绿按钮
- [x] 绿色按钮进入原生全屏
- [x] 原生全屏进入独立 Space
- [x] `Control + Command + F` 可切换全屏
- [x] 原生全屏下按 `Esc` 不再意外退出全屏
- [x] 浅色 / 深色主题下显示正常
- [x] 模糊与透明效果在普通窗口和全屏下均可正常工作
- [x] 最小化行为正常

## 截图 / 演示
### 历史窗口样式
- `MAC_历史版本窗口.png`
<img width="1920" height="1080" alt="MAC_历史版本窗口"
src="https://github.com/user-attachments/assets/4bd9176f-9d7e-43d1-9e1a-c7a6bfc0e28c"
/>

### 设置项与菜单
- `MAC_菜单控制.png`
<img width="1278" height="909" alt="MAC_菜单控制"
src="https://github.com/user-attachments/assets/520da1b5-af59-4f1a-ba5d-36abdc03ef60"
/>

- `MAC_菜单控制_Dark.png`
<img width="1119" height="861" alt="MAC_菜单控制_Dark"
src="https://github.com/user-attachments/assets/b21af50e-b583-4895-b316-cc21b7498a51"
/>

- `MAC_恢复默认.png`
<img width="1526" height="922" alt="MAC_恢复默认"
src="https://github.com/user-attachments/assets/0129f69d-b2ca-45eb-847a-6b6cb37ca576"
/>

### 原生窗口控制效果
- `MAC_窗口组件原生控制.png`
<img width="1236" height="849" alt="MAC_窗口组件原生控制"
src="https://github.com/user-attachments/assets/003dba09-d0a8-4999-8241-f2d1db078d1b"
/>

- `MAC_窗口组件原生控制2.png`
<img width="1920" height="834" alt="MAC_窗口组件原生控制2"
src="https://github.com/user-attachments/assets/241c94a6-955f-41f8-9b1d-b9a40d0a5251"
/>

- `MAC_切换后变化.png`
<img width="1920" height="1080" alt="MAC_切换后变化"
src="https://github.com/user-attachments/assets/52d8977b-9c64-4413-85d9-f94bdcdc0e53"
/>

### 全屏、快捷键与 Space 行为
- `MAC_快捷键.png`
<img width="1227" height="846" alt="MAC_快捷键"
src="https://github.com/user-attachments/assets/2972cee3-3621-42f1-bd05-1e24eaded5ef"
/>

- `MAC_支持SPACE切换.png`
<img width="353" height="251" alt="MAC_支持SPACE切换"
src="https://github.com/user-attachments/assets/044974a6-64c4-4d0c-8ba9-3445af77f8e4"
/>

- `MAC_最大化.png`
<img width="1920" height="1079" alt="MAC_最大化"
src="https://github.com/user-attachments/assets/dbdf4cd4-0abd-4142-9c81-08c8c23af73b"
/>

### 模糊与透明效果
- `MAC_模糊与透明.png`
<img width="1267" height="954" alt="MAC_模糊与透明"
src="https://github.com/user-attachments/assets/f5a3a377-805e-4d5f-a3f0-fa588d77d9f7"
/>

- `MAC_模糊与透明_全屏.png`
<img width="1920" height="1080" alt="MAC_模糊与透明_全屏"
src="https://github.com/user-attachments/assets/e20642ba-b828-47d0-a154-c23a7b15643d"
/>

### 其他窗口行为
- `MAC_窗口最小化.png`
<img width="276" height="129" alt="MAC_窗口最小化"
src="https://github.com/user-attachments/assets/d7f758a0-072e-4c47-95e6-9539075f1d71"
/>

- `MAC_设置启动全屏-重新打开.png`
<img width="1920" height="1080" alt="MAC_设置启动全屏-重新打开"
src="https://github.com/user-attachments/assets/b033d102-5062-46cb-9c41-c6fe330df007"
/>

### Windows 回归验证
- `WINDOWS_菜单.png`
<img width="1920" height="1040" alt="WINDOWS_菜单"
src="https://github.com/user-attachments/assets/3a295470-c1c6-42f5-a265-2cd38e9224cf"
/>


- `WINDOWS_全屏.png`
<img width="1920" height="1040" alt="WINDOWS_全屏"
src="https://github.com/user-attachments/assets/b254dc81-0c42-4024-9f91-3e029bfe29a0"
/>

## 说明
- 本次实现优先保证 macOS 原生窗口交互一致性,而不是模拟系统按钮视觉
- 当前方案对非 macOS 平台保持兼容
- 如果窗口样式在切换当次未完全刷新,重启应用后可获得稳定表现
2026-03-20 21:18:43 +08:00
DurianPankek
2677364d0e feat(mac-window): 支持切换 macOS 原生窗口控制与原生全屏行为 2026-03-20 18:23:16 +08:00
Syngnat
6ec1072d2e Release/0.6.3 2026-03-20 16:53:11 +08:00
Syngnat
da28207168 🐛 fix(ci): 修复 Release 构建资产丢失,checkout 隔离到独立目录
- checkout path 改为 repo-for-changelog 避免 git 操作干扰 release-assets
- Generate Changelog 步骤进入 checkout 子目录执行 git log
2026-03-20 16:51:42 +08:00
Syngnat
9f2f8b33e8 Release/0.6.3 2026-03-20 16:24:26 +08:00
Syngnat
87cfbee6d3 🐛 fix(ci): 修复 Release 构建资产丢失问题
- Checkout 步骤添加 clean: false 防止 git clean 删除已下载的 release-assets
2026-03-20 16:23:13 +08:00
Syngnat
d984a15508 release/0.6.3 2026-03-20 16:08:38 +08:00
Syngnat
0100b771b0 🔧 ci(release): 优化 Release Notes 自动生成,按 commit 前缀分类展示详细变更
- 替换 generate_release_notes 为 git log 提取 commit message
- 按 emoji 前缀分 6 组:新功能、🐛修复、性能、♻️重构、🌐国际化、🔧其他
- 底部附加 compare 链接,空分类自动跳过
2026-03-20 16:07:25 +08:00
Syngnat
1758d6f918 feat(table-designer): 补全设计表字段类型列表,按数据库方言分组
- 新增 DB_TYPE_OPTIONS 按 MySQL/PostgreSQL/SQL Server/SQLite/Oracle 分组
- MySQL:补充数值(float/double/smallint/mediumint)、字符串(tinytext/mediumtext/longtext)、
  二进制(blob/tinyblob/mediumblob/longblob)、其他(enum/set/bit/year)
- PostgreSQL:补充 serial/boolean/timestamptz/jsonb/uuid/inet 等
- SQL Server:补充 float/real/money/nvarchar/datetime2/uniqueidentifier 等
- AutoComplete options 从固定 COMMON_TYPES 改为 DB_TYPE_OPTIONS[getDbType()] 动态获取
- refs #281
2026-03-20 16:00:35 +08:00
Syngnat
b86cfcacaa 🌐 fix(editor): 加载 monaco-editor 中文 NLS 语言包修复右键菜单英文显示
- 在 main.tsx 中 import 'monaco-editor/esm/nls.messages.zh-cn'
- NLS 必须在 monaco-editor 主包之前导入才能生效
- 覆盖所有 Monaco Editor 实例的内置菜单(Cut→剪切、Copy→复制等)
- refs #269
2026-03-20 15:52:38 +08:00
Syngnat
7d543e06c6 🐛 fix(export): 导出数据日期时间格式化为本地时区 yyyy-MM-dd HH:mm:ss
- formatExportCellText default 分支增加字符串日期时间解析与格式化
- normalizeExportJSONValue 新增 time.Time 和字符串日期时间处理
- 覆盖 CSV/JSON/XLSX/HTML/Markdown 全部导出格式
- refs #270
2026-03-20 15:44:53 +08:00
Syngnat
17e4e3ad1c feat(data-grid): 新增底部数据预览面板支持长数据字段完整查看与编辑
- 工具栏新增「数据预览」切换按钮,点击展开/收起底部面板
- 单击单元格自动更新面板内容,完整展示长文本和 JSON 数据
- 面板使用 Monaco Editor,JSON 数据自动语法高亮
- 编辑模式下支持直接修改并保存,只读模式下 Editor 设为 readOnly
- 支持 JSON 一键格式化功能
- 通过 ref 追踪面板状态避免 mergedColumns 过度重渲染
- refs #271
2026-03-20 15:37:17 +08:00
Syngnat
84579b83c9 feat(TableDesigner): 优化设计表滚动体验并支持索引批量删除
- 滚动优化:修复 Tab 切换时 ResizeObserver 高度归零导致表格异常滚动
- 零高度守卫:移除 activeKey 依赖,跳过 display:none 时的零高度观测
- 触发器统一:触发器 Tab 补充 scroll={{ y: tableHeight }} 与索引/外键保持一致
- 批量删除:handleDeleteIndex 支持多选索引批量生成 DROP SQL 合并执行
- 交互优化:删除确认弹窗展示选中索引数量和名称列表
- 状态清理:批量删除成功后自动清空 selectedIndexKeys
- refs #273
2026-03-20 15:21:47 +08:00
Syngnat
7ddef7096b 🐛 fix(editor): 修复DDL视图stickyScroll首行冻结透明背景导致字符重叠
- 根因定位:TableDesigner和TriggerViewer中局部主题定义覆盖了全局配置
- 全局主题新增editorStickyScroll.background不透明背景色
- 移除TableDesigner.tsx中重复的透明主题定义(26行)
- 移除TriggerViewer.tsx中重复的透明主题定义(26行)
- 清理未使用的loader import
- refs #274
2026-03-20 15:00:00 +08:00
Syngnat
557178f182 🐛 fix(query-editor): 修复SQL查询结果行数限制和显示问题
- 移除每次执行SQL重复弹出"结果集已自动限制"的warning提示
- 用户手写LIMIT时尊重原始结果,不再被前端maxRows截断
- 结果集tab标签显示精确行数,去掉"1000+"的+号后缀
- refs #275
2026-03-20 14:50:18 +08:00
Syngnat
a1b546ddd9 feat(data-grid): 日期时间类型字段集成DatePicker选择器
- 类型识别:根据列元数据自动识别datetime/date/time/year类型
- inline编辑:日期时间列双击弹出DatePicker替代纯文本Input
- 行编辑器:日期时间字段使用DatePicker组件
- 交互优化:datetime类型需点"确定"按钮才保存,date/time/year即选即保存
- 取消支持:datetime选择器点击外部自动取消编辑,不保存
- 值转换:编辑时字符串↔dayjs自动转换,无效日期回退为文本输入
- refs #276
2026-03-20 14:35:45 +08:00
Syngnat
da5e879409 🐛 fix(data-grid): 修复复制为INSERT/CSV/Markdown字段乱序及特殊字符未转义
- INSERT:使用 columnNames 保持 DDL 字段顺序,值中单引号转义为 ''
- CSV:使用 columnNames 保持字段顺序,值中双引号转义为 "",增加表头行
- Markdown:使用 columnNames 保持字段顺序,转义管道符和换行,增加表头行和分隔行
- refs #277
2026-03-20 14:10:23 +08:00
Syngnat
8935ad2905 🐛 fix(query-editor): 修复多Tab场景下SQL智能提示读取错误数据库上下文
- 根因:completion provider 只注册一次,闭包捕获首个 Tab 的组件 ref,切换 Tab 后仍读取旧上下文
- 修复:新增模块级共享变量,所有 QueryEditor 实例在成为活跃 Tab 时同步状态
- 共享变量:currentDb、connectionId、tables、allColumns、visibleDbs、columnsCache
- provider 闭包改为读取共享变量,确保始终使用当前活跃 Tab 的数据库上下文
- metadata 加载和数据库列表获取后同步更新共享变量
- refs #278
2026-03-20 13:59:38 +08:00
Syngnat
cd5a0e85e8 feat(data-grid): 筛选面板新增多字段排序功能并支持启用禁用
- 排序扩展:SortInfo 类型从单字段扩展为数组,SQL 和 MongoDB 均支持多字段 ORDER BY
- 筛选面板:新增排序配置区域,支持动态添加/删除多个排序字段及启用/禁用
- 表头联动:启用 Ant Design 多列排序模式,表头排序图标与筛选面板双向同步
- 增量更新:表头点击排序时在现有排序数组中增量更新,不覆盖其他字段
- 循环优化:表头排序从"升序→降序→取消"改为"升序↔降序"切换
- 布局优化:操作按钮栏增加分隔符分组,排序区域与按钮间增加视觉分隔
- refs #279
2026-03-20 13:22:10 +08:00
Syngnat
ccb9f09452 🐛 fix(store): 修复保存查询后再次打开产生重复Tab
- 新增语义去重:addTab对query类型按savedQueryId匹配已有Tab
- 匹配条件覆盖savedQueryId相同或Tab id等于savedQueryId两种场景
- 命中已有Tab时复用并激活,避免重复创建
- refs #280
2026-03-20 12:59:24 +08:00
Syngnat
5afd80c559 feat(about/update): 优化macOS下载更新交互与关于弹窗按钮布局
- 自动打开目录:macOS下载完成后根据用户是否点了"隐藏到后台"决定是否自动打开下载目录
- 文件校验兜底:打开安装目录失败时清除已下载状态,允许重新下载
- 缓存同步修复:checkForUpdates以后端downloaded字段为准,清除过期的本地ref缓存
- 关于弹窗重构:已下载状态直接显示"打开安装目录"主操作按钮,无需经下载进度中转
- 按钮互斥优化:下载中隐藏"下载更新"和"本次不再提示",显示"下载进度"
- 按钮排版调整:主操作按钮置右侧高亮,各状态下按钮层次分明
2026-03-20 12:55:16 +08:00
Syngnat
1b36f60821 🐛 fix(query-editor/data-grid): 修复只读查询结果右键菜单失效及提交事务后数据丢失
- 右键菜单修复:移除 handleContextMenu 的 editable 守卫,只读模式也能弹出右键菜单
- 非编辑单元格绑定:EditableCell 非编辑模式增加 onContextMenu 包装,确保右键事件触发
- mergedColumns 统一:所有列通过 onCell 绑定 onContextMenu,不再跳过非 editable 列
- 表名正则增强:支持多行 SQL 和 schema.table 写法,复杂 SELECT 也能提取表名获得编辑能力
- 精准重查询:新增 handleReloadResult 函数,提交事务后只用当前结果集 SQL 重查,避免整个编辑器 SQL 二次处理导致数据丢失
- refs #267
2026-03-20 12:11:09 +08:00
Syngnat
eaa76d8f04 feat(connection): 新增数据库连接图标功能并修复达梦数据库列表为空
- 图标组件:新增 DatabaseIcons.tsx,10 种品牌 SVG logo + 7 种彩色文字标签覆盖全部数据源
- 品牌资源:下载 MySQL/PG/Redis/MongoDB/ClickHouse/SQLite/MariaDB/Doris/Sphinx/DuckDB 的 SVG(CC0 许可)
- 类型扩展:SavedConnection 新增 iconType/iconColor 支持自定义图标和颜色
- 外观配置:ConnectionModal 新增"外观"配置区(图标选择器 + 14 色选择器 + 预览面板)
- 数据源选择:Step1 数据源类型卡片全部统一为品牌图标
- 达梦修复:dameng_metadata.go 增加原生 SQL 查询和诊断日志,改善数据库列表获取
- refs #114
- refs #266
2026-03-20 11:19:08 +08:00
Syngnat
0f717706b0 🐛 fix(TableOverview/DataGrid): 修复表概览重复打开Tab及隐藏列修改失效
- Tab去重:表概览 buildTableStatusSQL 对 postgres/kingbase/vastbase/highgo/sqlserver 返回 schema.table 格式表名,与侧边栏一致
- Tab ID统一:移除 openTable 中多余的 table- 前缀,使 Tab ID 格式匹配
- 语义去重:addTab 新增 connectionId+dbName+tableName 语义匹配作为安全网
- 数据修复:handleCommit 和 applyRowEditor 将 displayColumnNames 改为 columnNames,确保隐藏列修改被正确提交
- refs #264
- refs #265
2026-03-20 10:33:51 +08:00
Syngnat
bfa918cb9d Release/v0.6.2 (#263) 2026-03-19 21:18:01 +08:00
Syngnat
8950081a6c 🐛 fix(QueryEditor): 修复多 Tab 导致 SQL 自动补全项重复的问题
- registerCompletionItemProvider 为 monaco.languages 全局 API,多 Tab 实例重复注册导致补全项成倍重复
- 添加模块级标志 sqlCompletionRegistered 确保全局只注册一次
- Provider 内部通过 ref 读取当前上下文,单次注册不影响多 Tab 的上下文感知
- refs #261
2026-03-19 21:14:11 +08:00
Syngnat
3bf8758418 ️ perf(App/handleNewQuery): 缓存 handleNewQuery 并消除 Tab ID 碰撞风险
- 将 handleNewQuery 改为 useCallback,减少 useEffect 中事件监听器的无效重绑定
- Tab ID 生成方式改为 Date.now() + 随机后缀,与项目既有模式一致
2026-03-19 20:46:46 +08:00
Syngnat
561d3810da 🐛 fix(data-grid): 修复窄表场景表头与数据列错位 2026-03-19 18:16:51 +08:00
Syngnat
18cb66b893 🐛 fix(query-editor/data-grid): 修复UPDATE影响行数为0及虚拟表Shift+滚轮横向滚动失效
- 后端修复:DBQueryMulti 包含写操作时跳过原生 QueryMulti,走逐条 Exec 路径获取 RowsAffected
- 结果展示:UPDATE/INSERT/DELETE 结果改为简洁的执行成功提示,不再展示 DataGrid 全套操作按钮
- Tab标签:写操作结果集标签改为「结果 N ✓」替代原来的行数计数
- 横向滚动:修复虚拟表守卫检查选择器不匹配(.rc-virtual-list-holder → .ant-table-tbody-virtual-holder)
- 事件处理:使用 event.isTrusted 区分合成事件,通过 applyVirtualHorizontalOffset 驱动 rc-virtual-list
- 目标检查:isTableDataAreaTarget 改为黑名单模式,兼容 rc-virtual-list 包裹元素
2026-03-19 17:13:38 +08:00
Syngnat
ab61e703b1 🐛 fix(data-grid): 修复空数据表Shift+滚轮横向滚动失效
- 目标匹配:isTableDataAreaTarget 新增 .ant-table-placeholder 选择器覆盖空表占位元素
- 虚拟回退:虚拟模式下 rc-virtual-list-holder 不存在时,回退到手动滚动表头并同步外部滚动条
- 精准匹配:仅添加 .ant-table-placeholder,避免 .ant-table-header 导致有数据表头体滚动不同步
2026-03-19 14:32:12 +08:00
Syngnat
7933b4c315 feat(window): 实现窗口尺寸位置与侧边栏宽度持久化记忆
- 窗口状态:新增 windowState 记录全屏/最大化/普通状态,关闭后重开自动恢复
- 窗口尺寸:普通窗口模式下每2秒自动保存宽高和坐标位置
- 侧边栏宽度:sidebarWidth 从 useState 迁移至 zustand store 持久化
- 状态恢复:启动时根据保存的状态决定全屏/最大化/恢复具体尺寸位置
- 数据校验:新增 sanitizeWindowBounds/sanitizeWindowState/sanitizeSidebarWidth 校验函数
- 兼容处理:startupFullscreen 设置优先级高于自动记忆的窗口状态
- refs #259
2026-03-19 12:26:44 +08:00
Syngnat
4e73f6d8b5 release/0.6.1 2026-03-19 11:59:25 +08:00
Syngnat
c99f857d0a feat(TableOverview): 新增表平铺视图概览功能
- 新建 TableOverview 组件:卡片网格展示表名、注释、行数、数据大小、引擎
- 数据获取:通过 DBQuery 发 SHOW TABLE STATUS 等 SQL 适配多数据库方言
- 交互功能:搜索过滤、按名称/行数/大小排序、双击打开DataGrid、Tooltip悬浮全名
- 右键菜单:与 Sidebar 完全一致(新建查询/设计表/复制结构/备份/重命名/删除/导出)
- 入口集成:双击侧边栏"表(N)"分组节点打开概览Tab,注册table-overview类型
- UI细节:统计指标固定列宽对齐,卡片hover高亮边框
2026-03-19 11:58:12 +08:00
Syngnat
2c3f4a1032 ♻️ refactor(TableDesigner): 重构选择交互为手动Checkbox并优化渲染性能
- 交互重构:移除rowSelection依赖,改用手动Checkbox列避免Ant Design内部对齐差异
- 列隔离:Checkbox和Sort列脱离resizableColumns,不经过ResizableTitle处理
- 对齐修复:.ant-input padding-left归零,消除borderless Input导致的th/td文字偏移
- 性能优化:resizableColumns/sortColumn等用useMemo稳定引用,Tab切换startTransition降级
- 动画加速:ink-bar添加will-change:transform独立合成层,过渡缩短至0.15s
2026-03-19 11:33:30 +08:00
Syngnat
72de16995a feat(TableDesigner): 索引表支持列宽拖拽和Checkbox多选全选
- 拖拽调整:索引表header支持拖拽调整列宽,带三角形角标与DataGrid一致
- 多选重构:索引选择从Radio单选改为Checkbox多选,支持全选/取消全选/半选指示
- 选择列固定:Checkbox列固定48px宽度,不参与拖拽resize,header和body对齐一致
- 按钮逻辑:编辑按钮要求恰好选中1个索引,删除按钮要求选中≥1个索引
- 样式优化:索引表header禁用文字选中和光标效果,保持干净交互体验
2026-03-19 10:25:06 +08:00
Syngnat
0adc8411fa 🐛 fix(data-grid/table-designer/about): 修复空表横向滚动、索引编辑回显及关于弹窗按钮间距
- 空表滚动:虚拟模式下空数据表缺少 virtual-holder 元素时,回退到直接滚动表头实现横向滚动
- 索引回显:修复修改索引后再次编辑时被删除的字段仍然显示的问题,selectedIndex 随 groupedIndexes 同步更新
- 按钮间距:关于弹窗 footer 增加 flex-wrap 和 gap,解决关闭按钮与上方操作按钮行重叠
- refs #258
2026-03-19 08:59:49 +08:00
Syngnat
2f4e20a34a release/0.6.0 2026-03-18 21:27:31 +08:00
杨国锋
8efa7e2de6 🔧 fix(App/handleNewQuery): 修复新建查询默认数据库选择错误
- 验证当前 tab 的 connectionId 仍存在于 connections 列表中才复用上下文
- activeContext 作为次优先回退,同样验证 connectionId 有效性
- 避免关闭连接 A 后新建查询仍默认选中数据库 A 的问题
- refs #241
2026-03-18 21:24:45 +08:00
杨国锋
ecee206304 feat(Sidebar/FindInDatabaseModal): 新增全局数据库搜索功能
- 数据库右键菜单新增「在数据库中搜索」入口
- 逐表搜索文本列,支持包含/精确匹配两种模式
- 智能过滤非文本列(int/blob/date 等自动跳过)
- 兼容 MySQL LIMIT / SQL Server TOP / Oracle FETCH FIRST
- 结果以汇总表格展示,支持展开查看匹配行详情
- refs #240
2026-03-18 21:16:23 +08:00
杨国锋
299dceb01c 🔧 fix(QueryEditor): 修复最大返回行数对 SQL Server 等数据源不生效的问题
- 启用 applyAutoLimit 在 SQL 层面自动注入行数限制
- SQL Server 使用 TOP N,Oracle/Dameng 使用 FETCH FIRST N ROWS ONLY
- 已有 LIMIT/TOP/FETCH/ROWNUM 时自动跳过,不重复注入
- 移除相关 DEBT 标记
- refs #236
2026-03-18 21:02:54 +08:00
杨国锋
5cad761bdd feat(QueryEditor): 增加 SQL 内置函数自动补全提示
- 新增约 120 个常用函数(聚合/字符串/日期/JSON/窗口等分类)
- 以 Function 图标区分,选中自动插入括号
- 适用于所有支持的数据源类型
- refs #248
2026-03-18 20:53:50 +08:00
杨国锋
b8728170ec 🐛 fix(CreateDatabase): 修复 Oracle 新建数据库时因缺少 Service Name 报错
- 前端 Oracle/达梦连接保留原始 database 字段而非清空
- 后端添加 Oracle/达梦不支持此入口创建的友好提示
- refs #223
2026-03-18 20:45:07 +08:00
杨国锋
4ce4cdaad8 🐛 fix(TableDesigner): 修复 MySQL 索引编辑保存时多语句执行失败
- executeSchemaSql 将拼接的 DDL 按分号换行拆分后逐条执行
2026-03-18 20:32:00 +08:00
杨国锋
cc7ef12029 🐛 fix(TableDesigner): 修复深色主题下 SQL 变更确认弹窗文字不可见
- 将 <pre> 的硬编码浅色背景/边框替换为 darkMode 适配的颜色值
- refs #251
2026-03-18 20:23:38 +08:00
杨国锋
5b6403f266 🐛 fix(update): 修复 Win10 自动更新时文件被占用导致替换失败
- 冷却期:进程退出后增加 3 秒等待,确保 Win10 内核释放 exe 文件句柄
- 替换策略:新增 rename-before-replace 机制,先重命名旧文件再复制新文件
- 退避重试:替换固定 1 秒间隔为指数退避(1s→2s→3s→5s),总等待约 36 秒
- 残留清理:替换成功后删除 .old 残留文件
- 测试覆盖:新增 TestBuildWindowsScriptWin10Fixes 验证全部修复点
2026-03-18 20:16:09 +08:00
Syngnat
caceb2868d 🐛 fix(data-grid): 修复右键菜单被窗口裁剪和全选checkbox未对齐
- 单元格右键菜单增加视口边界检测,底部/右侧空间不足时自动偏移
- 菜单容器添加 maxHeight + overflowY auto,确保所有选项可滚动访问
- 修复表头选择列 TH 无 class(虚拟模式),用 :first-child 统一 padding 和对齐
- 行右键菜单 Dropdown 挂载到 document.body 并启用 autoAdjustOverflow
2026-03-18 18:01:29 +08:00
Syngnat
e7b9ff4a10 ♻️ refactor(data-grid): 优化右键菜单定位算法与工具栏按钮优先级
- 单元格菜单 position:fixed 增加 viewport 边界碰撞检测与动态 maxHeight
- 行菜单 Dropdown 通过 getPopupContainer 脱离容器 overflow 限制
- 工具栏按钮按使用频率重排:刷新 → 筛选 → [编辑区] → 导入/导出
2026-03-18 17:43:10 +08:00
Syngnat
dfabd77615 🐛 fix(ci): 修复 Chocolatey UPX 包不可用导致 Windows 构建失败 2026-03-18 17:30:33 +08:00
Syngnat
76f65cb96c 🐛 fix(ci): 修复 Chocolatey UPX 包不可用导致 Windows 构建失败
- Chocolatey NuGet 仓库无法解析 upx 包,触发 NuGetResolverInputException
- 改为 Invoke-WebRequest 从 GitHub Releases 下载 upx-4.2.4-win64.zip
- 使用 GITHUB_PATH 环境文件注入 UPX 路径,后续步骤可直接调用
- 消除对 Chocolatey 包注册表的外部依赖,提高 CI 稳定性
2026-03-18 17:29:24 +08:00
Syngnat
4a2dda8aa2 合并拉取请求 #254
release/0.5.9
2026-03-18 17:21:55 +08:00
Syngnat
8bdc6e8086 ️ perf(data-grid): memoize CSS模板与样式变量,优化render热路径
- P0:gridCssText useMemo化,依赖[themeStyles, gridId, tableBodyBottomPadding]
- P1:80+行主题变量收敛到themeStyles useMemo,依赖[darkMode, opacity, blur]
- P2:CELL_ELLIPSIS_STYLE/VIRTUAL_CELL_WRAPPER_STYLE提升为模块级常量
- P5:合并getBoundingClientRect为单次调用,减少强制重排
- P6:useRef替代displayColumnNames依赖,切断normalizeGridFilterConditions级联
2026-03-18 17:20:53 +08:00
Syngnat
1eb2f6dffe 🐛 fix(data-grid): 修复虚拟表格横向滚动时标头与数据列错位
- 废弃 marginLeft hack,改用合成 WheelEvent 驱动 rc-virtual-list 内部滚动状态
- 虚拟模式 wheel 事件交由 rc-virtual-list 原生处理,rc-table 自动同步 header
- 外部滚动条同步改为 WheelEvent + rAF 异步链路
- refs #249
2026-03-18 16:13:40 +08:00
Syngnat
5c5e1fc68f 🐛 fix(redis,mongodb): 修复Redis集群数据显示不全和MongoDB指定数据库连接失败
- Redis集群ScanKeys改用ForEachMaster逐master节点扫描合并去重
- MongoDB authSource未显式设置时始终默认admin而非Database值
- 同步修复mongodb_impl.go和mongodb_impl_v1.go
- refs #246
2026-03-18 15:51:19 +08:00
Syngnat
fb70f1420c feat(sql-file): 支持大 SQL 文件后端流式执行,解决 WebView2 崩溃
- 新增流式 SQL 拆分器 sql_split_stream.go(逐行状态机)
- OpenSQLFile 超过 50MB 返回文件路径而非内容
- 新增 ExecuteSQLFile 后端流式读取+拆分+逐条执行+事件推送进度
- 新增 CancelSQLFileExecution 支持中途取消
- 前端增加 SQL 文件执行进度 Modal(进度条/计数/取消/结果展示)
- refs #238
2026-03-18 15:33:37 +08:00
Syngnat
d75596921c feat(sidebar): 增加已保存查询删除功能并扩展运行外部SQL文件入口
- 已存查询节点增加右键菜单:打开查询 / 删除查询(含确认弹窗)
- 连接节点右键菜单增加"运行外部SQL文件"入口
- 侧栏工具栏增加"运行外部SQL文件"按钮,使用当前活跃连接上下文
- 统一所有 SQL 文件相关文案为"运行外部SQL文件"
- 表头字段备注过长溢出修复(overflow: hidden)
- 列宽调整与拖拽排序隔离(onPointerDown stopPropagation + isResizingRef guard)
- refs #247
2026-03-18 15:01:56 +08:00
Syngnat
d251594fd9 🐛 fix(data-grid): 修复字段备注过长时溢出重叠到相邻列的问题
- 为 .gonavi-sortable-header-cell 添加 overflow: hidden 限制 th 溢出
- 为 .sortable-header-cell-drag-handle 添加 overflow: hidden 限制内容溢出
- 配合已有 text-overflow: ellipsis 实现长文本截断显示
- 完整备注仍可通过 Tooltip 悬浮查看
- refs #239
2026-03-18 14:47:40 +08:00
Syngnat
7598bf372b 🐛 fix(data-grid): 修复拖拽调整列宽时意外触发列排序拖拽的问题
- ResizableTitle 的 resize handle 增加 onPointerDown stopPropagation
- 阻止 pointerdown 事件冒泡到 @dnd-kit 的 PointerSensor
- handleDragEnd 增加 isResizingRef 防御性检查,双重保险
- 确保列宽调整与列排序两个操作完全隔离
2026-03-18 14:43:26 +08:00
Syngnat
64021ffd2a 🐛 fix(batch-truncate/query): 修复批量清空表安全隐患并优化多语句执行错误反馈
- 安全加固:TruncateTables 增加审计日志(Warnf 级别)和参数校验(上限 200 张)
- 容错增强:批量清空部分失败时返回已执行 SQL 列表并提示已清空表不可恢复
- 错误优化:DBQueryMulti 逐条执行失败时附带语句序号和已成功条数
- 性能优化:splitSQLStatements 从 string 拼接改为 strings.Builder,消除 O(n²) 分配
- 转义修复:splitSQLStatements 支持 SQL 标准转义单引号 '' 防止误拆分
- 前端修复:handleBatchClear 统一取消判断字符串为 '已取消' 并移除冗余变量声明
- refs #244
2026-03-18 14:32:11 +08:00
Syngnat
fbd785400f 批量表操作支持清空数据 (#253)
批量表操作支持清空数据. 
已支持数据库 mysql / mongodb
2026-03-18 14:16:24 +08:00
wjm
b573fd95cc 接口漏提了 2026-03-18 11:18:51 +08:00
wjm
a097d96380 批量表操作支持清空数据. mysql / mongodb 2026-03-18 11:05:44 +08:00
杨国锋
6ee0fea110 feat(multi-query): 适配 MariaDB/SQLServer/DiROS 多结果集并增加回退提示
- MariaDB: DSN 添加 multiStatements=true,实现 QueryMulti/QueryMultiContext
- SQL Server: 实现 QueryMulti/QueryMultiContext(go-mssqldb 原生支持批处理)
- DiROS: DSN 添加 multiStatements=true(继承 MySQLDB 的方法)
- Sphinx: 自动继承 MySQLDB 多结果集支持,无需额外改动
- 不支持原生多语句的数据源执行多条 SQL 时,前端展示 info 提示
- refs #235
2026-03-17 22:53:24 +08:00
杨国锋
e6b822c967 ♻️ refactor(QueryEditor): 清理未使用变量及IDE警告
- 移除未使用的 DBQuery 导入和 currentQueryId 解构
- 简化正则表达式 [\w] 为 \w(4处)
- 移除变量初始值冗余和 nextKey 中间变量
- 为异步调用添加 void 前缀消除 Promise 忽略警告
- 为暂未使用的 getLeadingKeyword/applyAutoLimit 添加 DEBT 标记
2026-03-17 22:39:55 +08:00
杨国锋
0ab10d2e80 feat(query): 支持多条SQL语句执行返回多结果集
- 新增 ResultSetData 结构体承载单个结果集数据
- 新增 MultiResultQuerier/MultiResultQuerierContext 可选接口
- 新增 scanMultiRows 函数利用 NextResultSet() 遍历所有结果集
- MySQL 驱动 DSN 开启 multiStatements=true 并实现多结果集接口
- 新增 DBQueryMulti Wails 方法,支持驱动原生多结果集及自动回退逐条执行
- 新增 Go 版 SQL 拆分函数 splitSQLStatements 及 10 个单元测试
- 前端 QueryEditor handleRun 改为一次性调用 DBQueryMulti
- MongoDB 保持独立的逐条执行路径不受影响
- refs #235
2026-03-17 22:21:49 +08:00
杨国锋
064cdc34be ♻️ refactor(全局): 统一错误消息中文化,补充 godoc 与测试,修复横向滚动和 Vite 代理
错误消息中文化:
- 19 个驱动实现文件中 connection not open / table name required 等英文消息替换为中文
- methods_file.go / methods_db.go / methods_driver.go 英文消息中文化
- 前端 App/Sidebar/DataGrid/ConnectionModal/DriverManagerModal 同步替换 "Cancelled" → "已取消"

文档与测试:
- database.go Database/BatchApplier 接口、types.go 12 个类型、driver_support.go 导出函数补充中文 godoc
- 新增 logger_test.go(ErrorChain 5 个用例)和 methods_db_conn_test.go(连接管理 7 个用例)

Bug 修复:
- DataGrid: 将 liveTargets 空检查移至非虚拟路径,修复外部横向滚动条拖动时内容不跟随
- vite.config.ts: server.host 指定 127.0.0.1,修复 IPv6 回环被拦截导致 Wails 代理 502

基础设施:
- .gitignore 新增 .gemini/ 规则,tmpclaude-* 改为 **/tmpclaude-* 覆盖子目录
2026-03-17 21:44:50 +08:00
Syngnat
c62f4b7d3c ♻️ refactor(query-editor/sidebar): 优化查询新建保存链路与 SQL 补全排序体验
- 表右键“新建查询”默认填充 SQL 模板为 SELECT * FROM <table>;
- Query Tab 增加 savedQueryId,首次保存后将“新建查询”转为已保存查询形态
- 保存按钮改为快速保存:已保存查询直接覆盖保存,不再弹命名对话框
- 首次保存(无保存身份)时保留命名弹窗,保存后同步更新当前 tab 标题与 query
- 已保存查询从侧栏打开时携带 savedQueryId,保持保存链路一致
- SQL 自动补全增加前缀过滤与上下文分组排序,关键字在非表名上下文下优先展示
- refs #232
2026-03-13 17:20:33 +08:00
Syngnat
304a4926d2 🔧 fix(query-editor/sidebar): 修复全选错位并完善侧栏拖拽自适应
- 修复新建查询页 Ctrl/Cmd+A 命中空白区域的问题,改为强制触发 Monaco 全选
- 限制全选拦截仅作用于当前活动查询页,避免影响其他编辑区
- 修复 sidebarWidth 声明时序导致的运行时 ReferenceError
- 侧栏拖窄时工具按钮按宽度分档自适应(4列→2列→图标模式)
- 新建查询/新建连接在窄宽度下改为单列,避免图文重叠
- 补充按钮防溢出样式,保证窄宽度可读与可点击
2026-03-13 16:22:33 +08:00
Syngnat
d1d3fa26f1 🔧 fix(frontend/ci): 移除前端测试对 node:assert 的类型依赖 (#234) 2026-03-13 15:37:16 +08:00
Syngnat
cabf84a041 🔧 fix(frontend/ci): 移除前端测试对 node:assert 的类型依赖
- 修复 darwin/arm64 构建中 tsc 无法解析 node:assert 的 TS2307 报错
- 将 dataGridLayout.test.ts 中的 node:assert 替换为本地 assertEqual
- 将 redisViewerWorkbenchTheme.test.ts 中的 node:assert 替换为本地断言函数
- 将 overlayWorkbenchTheme.test.ts 中的 node:assert 替换为本地断言函数
- 保持原有断言语义不变,避免引入新的运行时依赖
- 本地验证 npm --prefix frontend run build 通过
2026-03-13 15:36:09 +08:00
Syngnat
fc8e62b997 release/0.5.8 (#233) 2026-03-13 15:29:53 +08:00
Syngnat
9b02720169 🔧 fix(data-grid): 修复虚拟表格滚动条遮挡并统一横向同步链路
- 修复数据视图横向滚动条遮挡最后一行内容的问题
- 为虚拟表格接入外部横向滚动条,移除内部重复横向滚动轨道
- 统一拖拽滚动条与鼠标滑轮的横向同步逻辑,修复内容移动但滚动条不跟随
- 调整横向滚动条底部停靠间距,避免继续压住表格内容
- 提升纵向滚动条 thumb 对比度并增加弱轨道底色,改善深色主题下可见性
- 新增 DataGrid 布局计算辅助函数与最小测试用例
- refs #220
2026-03-13 15:27:18 +08:00
Syngnat
eb36dcc5a2 🔧 fix(redis/ui): 统一 Redis 工作台交互样式并修复 Tree 节点异常高亮
- Redis 页面重构为工作台样式,统一左右面板、工具条和详情区层级
- 接入 light/dark/透明模式主题参数,修复 Redis 页面与全局主题不一致问题
- 新增文件夹递归勾选、全选全部、分组全选/取消全选能力
- 支持 Redis Key 右键菜单重命名并同步更新树节点、选中态和详情面板
- 修复 type=none 时读取失败问题,过期或已删除 Key 自动提示并移出列表
- 接管 Redis Tree 展开箭头渲染,修复 switcher 命中区错位和悬浮白线问题
- 统一工具、代理、主题、关于、筛选、新建组和新建连接等弹层主题
- refs #231
2026-03-13 14:51:20 +08:00
Syngnat
1a3f137438 🔧 fix(db/kingbase): 统一 search_path 构建并修复双引号重复转义
- 新增 buildKingbaseSearchPathCommon,统一 search_path 规范化与拼装逻辑
- schema 名称先做 normalize + 去重,避免已带引号值被二次转义为 ""schema""
- getSearchPathStr 改为收集原始 schema 后走公共构建流程
- optional-driver-agent 复用同一构建函数,消除两套实现偏差
- 对 public 做大小写归一,确保 search_path 输出稳定
- 新增 TestBuildKingbaseSearchPathCommon 覆盖 quoted/escaped/dedupe 场景
2026-03-13 11:22:35 +08:00
杨国锋
5f94cd3911 🔧 fix(tab-manager): 修复切换Tab导致表数据编辑与筛选状态丢失
- 移除非活动业务Tab内容置空逻辑,避免DataViewer/DataGrid卸载重建
- 设置 destroyInactiveTabPane=false,确保切换Tab不销毁页面
- 在 DataViewer 统一快照持久化并增加卸载兜底写回
- 保持切换Tab不自动刷新,仅手动刷新或显式状态变化触发加载
- refs #218
2026-03-12 23:25:32 +08:00
杨国锋
bb257c35bc feat(data-grid): 新增同表多列跨行复制粘贴能力
- 在单元格编辑模式新增复制缓冲区,保存源行与多列值
- 新增“复制选区列值”操作,仅允许同一行多列选区复制
- 新增“粘贴到选中行”操作,按同名列批量写入并自动排除源行
- 复用 addedRows/modifiedRows 变更路径,保持提交事务与回滚逻辑一致
- 单元格右键菜单增加“粘贴已复制列(同名列)”入口
- 切换连接/库/表时自动清空复制缓冲区,避免跨上下文误粘贴
- refs #217
2026-03-12 23:14:52 +08:00
Syngnat
b0eb93bfa3 Release/0.5.7 (#230)
🔧 fix(ci/release-winget): 修复 Node20 弃用告警并强制启用 Node24 运行时

- 在 release-winget workflow 增加 FORCE_JAVASCRIPT_ACTIONS_TO_NODE24=true
- 与现有 release/test workflow 的 Node24 配置保持一致
- 避免 actions/checkout、setup-go、setup-node 触发 Node20 弃用告警

🔧 fix(window): 修复Windows启动全屏锁死并补齐标题栏退出全屏逻辑
2026-03-12 19:46:40 +08:00
杨国锋
11b8e0f12a Merge branch 'dev' into release/0.5.7 2026-03-12 19:39:42 +08:00
杨国锋
1dabac1a65 🔧 fix(window): 修复Windows启动全屏锁死并补齐标题栏退出全屏逻辑 2026-03-12 19:38:54 +08:00
杨国锋
e013288967 🔧 fix(ci/release-winget): 修复 Node20 弃用告警并强制启用 Node24 运行时
- 在 release-winget workflow 增加 FORCE_JAVASCRIPT_ACTIONS_TO_NODE24=true
- 与现有 release/test workflow 的 Node24 配置保持一致
- 避免 actions/checkout、setup-go、setup-node 触发 Node20 弃用告警
2026-03-12 19:23:46 +08:00
Syngnat
8c5fee1c7a * 🔧 fix(release/macos): 移除 macOS 打包链路的 UPX 压缩逻辑 2026-03-12 19:08:05 +08:00
杨国锋
ec05f518a9 Merge remote-tracking branch 'origin/main' into release/0.5.7
# Conflicts:
#	.github/workflows/release.yml
#	.github/workflows/test-build-all-platforms.yml
#	build-release.sh
2026-03-12 19:06:48 +08:00
杨国锋
2c9aa640fd Merge branch 'dev' into release/0.5.7 2026-03-12 19:04:20 +08:00
杨国锋
d467322ebe 🔧 fix(release/macos): 移除 macOS 打包链路的 UPX 压缩逻辑
- 删除 release 与手动测试工作流中的 macOS UPX 安装与压缩步骤
- build-release.sh 不再对 macOS arm64/amd64 主程序执行 UPX
- 保留 Windows 与 Linux 的 UPX 压缩策略
2026-03-12 19:00:21 +08:00
Syngnat
9f7cc58fad Release/0.5.7 (#227)
* 🎨 style(DataGrid): 清理冗余代码与静态分析告警

- 类型重构:通过修正 React Context 的函数签名解决了 void 类型的链式调用错误
- 代码精简:利用 Nullish Coalescing (??) 优化组件配置项降级逻辑,剥离无意义的隐式 undefined 赋值
- 工具链适配:适配 IDE 拼写检查与 Promise strict rules,确保全文件零警

* 🔧 fix(db/kingbase_impl): 修复标识符无条件加双引号导致SQL语法报错

- quoteKingbaseIdent 改为条件引用,仅对大写字母、保留字、特殊字符的标识符添加双引号
- 新增 kingbaseIdentNeedsQuote 判断标识符是否需要引用
- 新增 isKingbaseReservedWord 检测常见SQL保留字
- 补充 TestQuoteKingbaseIdent、TestKingbaseIdentNeedsQuote 单测覆盖各场景
- refs #176

* 🔧 fix(release,db/kingbase_impl): 修复金仓默认 schema 并静默生成 DMG

- Kingbase:在 current_schema() 为 public 时探测候选 schema,并通过 DSN search_path 重连,兼容未限定 schema 的查询
- 候选优先级:数据库名/用户名同名 schema(存在性校验),否则仅在“唯一用户 schema 有表”场景兜底
- 避免连接污染:每次 Connect 重置探测结果,重连成功后替换连接并关闭旧连接
- 打包脚本:create-dmg 增加 --sandbox-safe,避免构建时自动弹出/打开挂载窗口
- 产物格式:强制 --format UDZO,并将 rw.*.dmg/UDRW 中间产物转换为可分发 DMG
- 校验门禁:增加 hdiutil verify,失败时保留 .app 便于排查,同时修正卷图标探测并补 ad-hoc 签名

* 🐛 fix(connection/redis): 修复 Redis URI 用户名处理导致认证失败

- Redis URI 解析回填 user 字段,兼容 redis://user:pass@... 与 redis://:pass@...
- 生成 URI 时按需输出 user/password,避免丢失用户名信息
- Redis 类型默认用户名置空,并在构建配置时清理历史默认 root
- 避免 go-redis 触发 ACL AUTH(user, pass) 导致 WRONGPASS
- refs #212

* 🔧 fix(release,ssh): 修复 SSH 误判连接成功并纠正 DMG 打包结构

- SSH 缓存 key 纳入认证指纹(password/keyPath),避免改错凭证仍复用旧连接/端口转发
- MySQL/MariaDB/Doris:SSH 隧道建立失败直接返回错误,不再回退直连导致测试误判成功
- 新增最小单测覆盖 SSH cache key 与 UseSSH 异常路径
- build-release.sh:create-dmg 使用 staging 目录作为 source,避免 DMG 根目录变成 Contents
- refs #213

* fix: KingBase 连接后自动设置 search_path,修复自定义 schema 下表查询报 relation does not exist 的问题 (#215)

* 🔧 fix(driver/kingbase,mongodb): 修复外置驱动事务引用与连接测试链路问题

- 金仓外置驱动链路增加表名与变更字段归一化,修复 ApplyChanges 场景下双引号转义异常导致的 SQL 语法错误
- 新增金仓公共标识符工具并复用到 kingbase_impl 与 optional_driver_agent_impl,统一处理多重转义、schema.table 拆分与引用规范
- 金仓代理连接后自动探测并设置 search_path,降低查询时必须手写 schema 前缀的概率
- MongoDB 连接参数改为显式 host/hosts 优先,避免被 URI 中 localhost 覆盖;代理链路保留目标地址不再改写为本地地址
- 连接测试增加前后端超时收敛与日志增强,避免长时间转圈;连接错误文案在未启用 TLS 时移除误导性的“SSL”前缀
- 统一日志级别为 INFO/WARN/ERROR,默认日志目录收敛到 ~/.GoNavi/Logs,并补充驱动构建脚本 build-driver-agents.sh

* 🔧 fix(release/sidebar): 统一跨平台UPX压缩并修复PG函数列表查询兼容性

- 构建脚本新增通用 UPX 压缩函数,覆盖 macOS、Linux、Windows 产物
- 本地打包改为强制压缩策略:未安装 upx、压缩失败或校验失败直接终止
- macOS 打包在签名前压缩 .app 主程序并执行 upx -t 校验
- Linux 打包在生成 tar.gz 前压缩可执行文件并执行 upx -t 校验
- GitHub Release 与测试构建流程补齐 macOS/Linux/Windows 的 upx 安装与压缩步骤
- PostgreSQL/PG-like 函数元数据查询增加多路兼容 SQL,修复函数列表不显示问题
- refs #221
- refs #222

* 🔧 fix(release/ci): 修复跨平台UPX兼容并处理Windows ARM64打包失败

- CI 工作流统一启用 Node24 JavaScript 运行时,消除 Node20 退役告警干扰
- macOS 打包阶段为 UPX 增加 --force-macos,修复 Mach-O 压缩失败
- Windows 打包按架构分流:arm64 跳过 UPX 并保留原始 EXE,amd64 继续强制压缩
- Windows 压缩流程新增 $LASTEXITCODE 显式校验,避免命令失败被误判为成功
- 本地 build-release.sh 同步 macOS/Windows 的 UPX 兼容策略与错误处理逻辑

---------

Co-authored-by: Syngnat <yangguofeng919@gmail.com>
Co-authored-by: 凌封 <49424247+fengin@users.noreply.github.com>
2026-03-12 17:58:05 +08:00
Syngnat
97bf891df3 Merge remote-tracking branch 'origin/main' into release/0.5.7
# Conflicts:
#	.github/workflows/release.yml
#	.github/workflows/test-build-all-platforms.yml
#	build-release.sh
2026-03-12 17:55:17 +08:00
Syngnat
72a9692200 Merge branch 'dev' into release/0.5.7 2026-03-12 17:54:26 +08:00
Syngnat
e26a456eae 🔧 fix(release/ci): 修复跨平台UPX兼容并处理Windows ARM64打包失败
- CI 工作流统一启用 Node24 JavaScript 运行时,消除 Node20 退役告警干扰
- macOS 打包阶段为 UPX 增加 --force-macos,修复 Mach-O 压缩失败
- Windows 打包按架构分流:arm64 跳过 UPX 并保留原始 EXE,amd64 继续强制压缩
- Windows 压缩流程新增 $LASTEXITCODE 显式校验,避免命令失败被误判为成功
- 本地 build-release.sh 同步 macOS/Windows 的 UPX 兼容策略与错误处理逻辑
2026-03-12 17:54:09 +08:00
Syngnat
eaa45f17fd Release/0.5.7 (#226)
* 🎨 style(DataGrid): 清理冗余代码与静态分析告警

- 类型重构:通过修正 React Context 的函数签名解决了 void 类型的链式调用错误
- 代码精简:利用 Nullish Coalescing (??) 优化组件配置项降级逻辑,剥离无意义的隐式 undefined 赋值
- 工具链适配:适配 IDE 拼写检查与 Promise strict rules,确保全文件零警

* 🔧 fix(db/kingbase_impl): 修复标识符无条件加双引号导致SQL语法报错

- quoteKingbaseIdent 改为条件引用,仅对大写字母、保留字、特殊字符的标识符添加双引号
- 新增 kingbaseIdentNeedsQuote 判断标识符是否需要引用
- 新增 isKingbaseReservedWord 检测常见SQL保留字
- 补充 TestQuoteKingbaseIdent、TestKingbaseIdentNeedsQuote 单测覆盖各场景
- refs #176

* 🔧 fix(release,db/kingbase_impl): 修复金仓默认 schema 并静默生成 DMG

- Kingbase:在 current_schema() 为 public 时探测候选 schema,并通过 DSN search_path 重连,兼容未限定 schema 的查询
- 候选优先级:数据库名/用户名同名 schema(存在性校验),否则仅在“唯一用户 schema 有表”场景兜底
- 避免连接污染:每次 Connect 重置探测结果,重连成功后替换连接并关闭旧连接
- 打包脚本:create-dmg 增加 --sandbox-safe,避免构建时自动弹出/打开挂载窗口
- 产物格式:强制 --format UDZO,并将 rw.*.dmg/UDRW 中间产物转换为可分发 DMG
- 校验门禁:增加 hdiutil verify,失败时保留 .app 便于排查,同时修正卷图标探测并补 ad-hoc 签名

* 🐛 fix(connection/redis): 修复 Redis URI 用户名处理导致认证失败

- Redis URI 解析回填 user 字段,兼容 redis://user:pass@... 与 redis://:pass@...
- 生成 URI 时按需输出 user/password,避免丢失用户名信息
- Redis 类型默认用户名置空,并在构建配置时清理历史默认 root
- 避免 go-redis 触发 ACL AUTH(user, pass) 导致 WRONGPASS
- refs #212

* 🔧 fix(release,ssh): 修复 SSH 误判连接成功并纠正 DMG 打包结构

- SSH 缓存 key 纳入认证指纹(password/keyPath),避免改错凭证仍复用旧连接/端口转发
- MySQL/MariaDB/Doris:SSH 隧道建立失败直接返回错误,不再回退直连导致测试误判成功
- 新增最小单测覆盖 SSH cache key 与 UseSSH 异常路径
- build-release.sh:create-dmg 使用 staging 目录作为 source,避免 DMG 根目录变成 Contents
- refs #213

* fix: KingBase 连接后自动设置 search_path,修复自定义 schema 下表查询报 relation does not exist 的问题 (#215)

* 🔧 fix(driver/kingbase,mongodb): 修复外置驱动事务引用与连接测试链路问题

- 金仓外置驱动链路增加表名与变更字段归一化,修复 ApplyChanges 场景下双引号转义异常导致的 SQL 语法错误
- 新增金仓公共标识符工具并复用到 kingbase_impl 与 optional_driver_agent_impl,统一处理多重转义、schema.table 拆分与引用规范
- 金仓代理连接后自动探测并设置 search_path,降低查询时必须手写 schema 前缀的概率
- MongoDB 连接参数改为显式 host/hosts 优先,避免被 URI 中 localhost 覆盖;代理链路保留目标地址不再改写为本地地址
- 连接测试增加前后端超时收敛与日志增强,避免长时间转圈;连接错误文案在未启用 TLS 时移除误导性的“SSL”前缀
- 统一日志级别为 INFO/WARN/ERROR,默认日志目录收敛到 ~/.GoNavi/Logs,并补充驱动构建脚本 build-driver-agents.sh

* 🔧 fix(release/sidebar): 统一跨平台UPX压缩并修复PG函数列表查询兼容性

- 构建脚本新增通用 UPX 压缩函数,覆盖 macOS、Linux、Windows 产物
- 本地打包改为强制压缩策略:未安装 upx、压缩失败或校验失败直接终止
- macOS 打包在签名前压缩 .app 主程序并执行 upx -t 校验
- Linux 打包在生成 tar.gz 前压缩可执行文件并执行 upx -t 校验
- GitHub Release 与测试构建流程补齐 macOS/Linux/Windows 的 upx 安装与压缩步骤
- PostgreSQL/PG-like 函数元数据查询增加多路兼容 SQL,修复函数列表不显示问题
- refs #221
- refs #222

---------

Co-authored-by: Syngnat <yangguofeng919@gmail.com>
Co-authored-by: 凌封 <49424247+fengin@users.noreply.github.com>
2026-03-12 17:40:35 +08:00
Syngnat
f101a59d32 Merge remote-tracking branch 'origin/main' into release/0.5.7
# Conflicts:
#	frontend/src/App.tsx
#	frontend/src/components/ConnectionModal.tsx
#	frontend/src/components/DataGrid.tsx
2026-03-12 17:34:07 +08:00
Syngnat
501ad9e9a3 Merge branch 'fix/ssh-issue-20260310-ygf' into dev
# Conflicts:
#	internal/db/kingbase_impl.go
2026-03-12 17:30:48 +08:00
凌封
eef973b7fc fix: KingBase 连接后自动设置 search_path,修复自定义 schema 下表查询报 relation does not exist 的问题 (#215) 2026-03-12 10:04:49 +08:00
Syngnat
6ad690cffc release/0.5.6 (#210)
* 🐛 fix(data-viewer): 修复ClickHouse尾部分页异常并增强DuckDB复杂类型兼容

- DataViewer 新增 ClickHouse 反向分页策略,修复最后页与倒数页查询失败
- DuckDB 查询失败时按列类型生成安全 SELECT,复杂类型转 VARCHAR 重试
- 分页状态统一使用 currentPage 回填,避免页码与总数推导不一致
- 增强查询异常日志与重试路径,降低大表场景卡顿与误报

*  feat(frontend-driver): 驱动管理支持快速搜索并优化信息展示

- 新增搜索框,支持按 DuckDB/ClickHouse 等关键字快速定位驱动
- 显示“匹配 x / y”统计与无结果提示
- 优化头部区域排版,提升透明/暗色场景下的视觉对齐

* 🔧 fix(connection-modal): 修复多数据源URI导入解析并校正Oracle服务名校验

- 新增单主机URI解析映射,兼容 postgres/postgresql、sqlserver、redis、tdengine、dameng(dm)、kingbase、highgo、vastbase、clickhouse、oracle
- 抽取 parseSingleHostUri 复用逻辑,统一 host/port/user/password/database 回填行为
- Oracle 连接新增服务名必填校验,移除“服务名为空回退用户名”的隐式逻辑
- 连接弹窗补充 Oracle 服务名输入项与 URI 示例

* 🐛 fix(query-export): 修复查询结果导出卡住并统一按数据源能力控制导出路径

- 查询结果页导出增加稳定兜底,异常时确保 loading 关闭避免持续转圈
- DataGrid 导出逻辑按数据源能力分流,优先走后端 ExportQuery 并保留结果集导出降级
- QueryEditor 传递结果导出 SQL,保证查询结果导出范围与当前结果一致
- 后端补充 ExportData/ExportQuery 关键日志,提升导出链路可观测性

* 🐛 fix(precision): 修复查询链路与分页统计的大整数精度丢失

- 代理响应数据解码改为 UseNumber,避免默认 float64 吞精度
- 统一归一化 json.Number 与超界整数,超出 JS 安全范围转字符串
- 修复 DataViewer 总数解析,超大值不再误转 Number 参与分页
- refs #142

* 🐛 fix(driver-manager): 修复驱动管理网络告警重复并强化代理引导

- 新增下载链路域名探测,区分“GitHub可达但驱动下载链路不可达”
- 网络不可达场景仅保留红色强提醒,移除重复二级告警
- 强提醒增加“打开全局代理设置”入口,优先引导使用 GoNavi 全局代理
- 统一网络检测与目录说明提示图标尺寸,修复加载期视觉不一致
- refs #141

* ♻️ refactor(frontend-interaction): 统一标签拖拽与暗色主题交互实现

- 重构Tab拖拽排序实现,统一为可配置拖拽引擎
- 规范拖拽与点击事件边界,提升交互一致性
- 统一多组件暗色透明样式策略,减少硬编码色值
- 提升Redis/表格/连接面板在透明模式下的观感一致性
- refs #144

* ♻️ refactor(update-state): 重构在线更新状态流并按版本统一进度展示

- 重构更新检查与下载状态同步流程,减少前后端状态分叉
- 进度展示严格绑定 latestVersion,避免跨版本状态串用
- 优化 about 打开场景的静默检查状态回填逻辑
- 统一下载弹窗关闭/后台隐藏行为
- 保持现有安装流程并补齐目录打开能力

* 🎨 style(sidebar-log): 将SQL执行日志入口调整为悬浮胶囊样式

- 移除侧栏底部整条日志入口容器
- 新增悬浮按钮阴影/边框/透明背景并适配明暗主题
- 为树区域预留底部空间避免入口遮挡内容

*  feat(redis-cluster): 支持集群模式逻辑多库隔离与 0-15 库切换

- 前端恢复 Redis 集群场景下 db0-db15 的数据库选择与展示
- 后端新增集群逻辑库命名空间前缀映射,统一 key/pattern 读写隔离
- 覆盖扫描、读取、写入、删除、重命名等核心操作的键映射规则
- 集群命令通道支持 SELECT 逻辑切库与 FLUSHDB 逻辑库清空
- refs #145

*  feat(DataGrid): 大数据表虚拟滚动性能优化及UI一致性修复

- 启用动态虚拟滚动(数据量≥500行自动切换),解决万行数据表卡顿问题
- 虚拟模式下EditableCell改用div渲染,CSS选择器从元素级改为类级适配虚拟DOM
- 修复虚拟模式双水平滚动条:样式化rc-virtual-list内置滚动条为胶囊外观,禁用自定义外部滚动条
- 为rc-virtual-list水平滚动条添加鼠标滚轮支持(MutationObserver + marginLeft驱动)
- 修复白色主题透明模式下列名悬浮Tooltip对比度不足的问题
- 新增白色主题全局滚动条样式适配透明模式(App.css)
- App.tsx主题token与组件样式优化
- refs #147

* 🔧 chore(app): 清理 App.tsx 类型告警并收敛前端壳层实现

- 清除未使用代码和冗余状态
- 替换弃用 API 以消除 IDE 提示
- 显式处理浮动 Promise 避免告警
- 保持现有更新检查和代理设置行为不变

* 🔧 fix(ci): 修复 Windows AMD64 下 DuckDB 驱动构建链路

- 将 DuckDB 工具链准备切换为优先使用 MSYS2
- 增加 gcc 和 g++ 存在性校验与版本验证
- 在 MSYS2 异常时回退 Chocolatey 安装 MinGW
- 保持 Windows ARM64 跳过 DuckDB 构建与平台支持一致

* 🔧 fix(ci): 修复 Windows AMD64 下 DuckDB 驱动构建链路

- 将 DuckDB 工具链准备切换为优先使用 MSYS2
- 增加 gcc 和 g++ 存在性校验与版本验证
- 在 MSYS2 异常时回退 Chocolatey 安装 MinGW
- 保持 Windows ARM64 跳过 DuckDB 构建与平台支持一致

* 🔧 fix(ci): 修复 Windows AMD64 下 DuckDB 驱动构建工具链

- 将 DuckDB 编译链从 MINGW64 切换为 MSYS2 UCRT64
- 修正 Windows AMD64 的 gcc 和 g++ 探测路径
- 增加 DuckDB 编译器版本校验步骤

* 📝 docs(contributing): 补充中英文贡献指南并统一 README 入口

- 新增英文版 CONTRIBUTING.md 作为正式贡献文档
- 新增中文版 CONTRIBUTING.zh-CN.md 作为中文贡献说明
- 调整 README 和 README.zh-CN 的贡献入口指向对应语言文档

* - feat(connection,metadata,kingbase): 增强多数据源连接能力并修复金仓/达梦/Oracle/ClickHouse兼容性问题 (#188) (#190)

* feat(http-tunnel): 支持独立 HTTP 隧道连接并覆盖多数据源

refs #168

* fix(kingbase-data-grid): 修复金仓打开表卡顿并降低对象渲染开销

refs #178

* fix(kingbase-transaction): 修复金仓事务提交重复引号导致语法错误

refs #176

* fix(driver-agent): 修复老版本 Win10 升级后金仓驱动代理启动失败

refs #177

* chore(ci): 新增手动触发的 macOS 测试构建工作流

* chore(ci): 允许测试工作流在当前分支自动触发

* fix(query-editor): 修复 SQL 编辑中光标随机跳到末尾 refs #185

* feat(data-sync): 增加差异 SQL 预览能力便于审核 refs #174

* fix(clickhouse-connect): 自动识别并回退 HTTP/Native 协议连接 refs #181

* fix(oracle-metadata): 修复视图与函数加载按 schema 过滤异常 refs #155

* fix(dameng-databases): 修复显示全部库时数据库列表不完整 refs #154

* fix(connection,db-list): 统一处理空列表返回并修复达梦连接测试报错 refs #157

Co-authored-by: 辣条 <69459608+tianqijiuyun-latiao@users.noreply.github.com>

*  feat(release-notes): 支持自动生成 Release 更新说明并区分配置文件命名

* 🔁 chore(sync): 回灌 main 到 dev (#192)

* - feat(connection,metadata,kingbase): 增强多数据源连接能力并修复金仓/达梦/Oracle/ClickHouse兼容性问题 (#188)

* feat(http-tunnel): 支持独立 HTTP 隧道连接并覆盖多数据源

refs #168

* fix(kingbase-data-grid): 修复金仓打开表卡顿并降低对象渲染开销

refs #178

* fix(kingbase-transaction): 修复金仓事务提交重复引号导致语法错误

refs #176

* fix(driver-agent): 修复老版本 Win10 升级后金仓驱动代理启动失败

refs #177

* chore(ci): 新增手动触发的 macOS 测试构建工作流

* chore(ci): 允许测试工作流在当前分支自动触发

* fix(query-editor): 修复 SQL 编辑中光标随机跳到末尾 refs #185

* feat(data-sync): 增加差异 SQL 预览能力便于审核 refs #174

* fix(clickhouse-connect): 自动识别并回退 HTTP/Native 协议连接 refs #181

* fix(oracle-metadata): 修复视图与函数加载按 schema 过滤异常 refs #155

* fix(dameng-databases): 修复显示全部库时数据库列表不完整 refs #154

* fix(connection,db-list): 统一处理空列表返回并修复达梦连接测试报错 refs #157

* Release/0.5.3 (#191)

---------

Co-authored-by: 辣条 <69459608+tianqijiuyun-latiao@users.noreply.github.com>
Co-authored-by: Syngnat <92659908+Syngnat@users.noreply.github.com>

* 🐛 fix(branch-sync): 修复 main 回灌 dev 时 mergeable 异步计算导致漏开自动合并

- 增加 mergeable 状态轮询,避免新建同步 PR 后立即返回 UNKNOWN
- 在合并状态未稳定时输出中文告警与执行摘要
- 保持冲突分支、待计算分支与自动合并分支的处理路径清晰

* 🔁 chore(sync): 回灌 main 到 dev (#195)

* - feat(connection,metadata,kingbase): 增强多数据源连接能力并修复金仓/达梦/Oracle/ClickHouse兼容性问题 (#188)

* feat(http-tunnel): 支持独立 HTTP 隧道连接并覆盖多数据源

refs #168

* fix(kingbase-data-grid): 修复金仓打开表卡顿并降低对象渲染开销

refs #178

* fix(kingbase-transaction): 修复金仓事务提交重复引号导致语法错误

refs #176

* fix(driver-agent): 修复老版本 Win10 升级后金仓驱动代理启动失败

refs #177

* chore(ci): 新增手动触发的 macOS 测试构建工作流

* chore(ci): 允许测试工作流在当前分支自动触发

* fix(query-editor): 修复 SQL 编辑中光标随机跳到末尾 refs #185

* feat(data-sync): 增加差异 SQL 预览能力便于审核 refs #174

* fix(clickhouse-connect): 自动识别并回退 HTTP/Native 协议连接 refs #181

* fix(oracle-metadata): 修复视图与函数加载按 schema 过滤异常 refs #155

* fix(dameng-databases): 修复显示全部库时数据库列表不完整 refs #154

* fix(connection,db-list): 统一处理空列表返回并修复达梦连接测试报错 refs #157

* Release/0.5.3 (#191)

* - chore(ci): 新增全平台测试包手动构建工作流 tianqijiuyun-latiao 今天 下午4:26 (#194)

* feat(http-tunnel): 支持独立 HTTP 隧道连接并覆盖多数据源

refs #168

* fix(kingbase-data-grid): 修复金仓打开表卡顿并降低对象渲染开销

refs #178

* fix(kingbase-transaction): 修复金仓事务提交重复引号导致语法错误

refs #176

* fix(driver-agent): 修复老版本 Win10 升级后金仓驱动代理启动失败

refs #177

* chore(ci): 新增手动触发的 macOS 测试构建工作流

* chore(ci): 允许测试工作流在当前分支自动触发

* fix(query-editor): 修复 SQL 编辑中光标随机跳到末尾 refs #185

* feat(data-sync): 增加差异 SQL 预览能力便于审核 refs #174

* fix(clickhouse-connect): 自动识别并回退 HTTP/Native 协议连接 refs #181

* fix(oracle-metadata): 修复视图与函数加载按 schema 过滤异常 refs #155

* fix(dameng-databases): 修复显示全部库时数据库列表不完整 refs #154

* fix(connection,db-list): 统一处理空列表返回并修复达梦连接测试报错 refs #157

* fix(kingbase): 补齐主键识别并优化宽表卡顿 refs #176 refs #178

* fix(query-execution): 支持带前置注释的读查询结果识别

* chore(ci): 新增全平台测试包手动构建工作流

---------

Co-authored-by: 辣条 <69459608+tianqijiuyun-latiao@users.noreply.github.com>
Co-authored-by: Syngnat <92659908+Syngnat@users.noreply.github.com>

* ♻️ refactor(frontend-sync): 优化桌面交互细节并移除 main 回灌 dev 自动化

- 优化新建连接、主题设置、侧边栏工具区与 SQL 日志的界面表现
- 调整分页、筛选、透明模式与弹窗样式,统一整体交互层次
- 收口外观参数生效逻辑并补齐多组件适配
- 删除 sync-main-to-dev 工作流并同步维护者手动回灌说明

* feat: 统一筛选条件逻辑按钮宽度 (#201)

* 🐛 fix(oracle-query): 修复 Oracle 表数据分页 SQL 兼容问题 refs #196 (#202)

*  feat(datasource): 支持 DuckDB Parquet 文件模式并优化弹窗打开链路

- 统一 DuckDB 文件库与 Parquet 文件接入能力
- 补充 URI、文件选择、只读挂载与连接缓存键处理
- 去掉数据源卡片点击前的同步驱动查询,修复打开卡顿

*  feat(datasource): 支持 DuckDB Parquet 文件模式并优化弹窗打开链路

- 统一 DuckDB 文件库与 Parquet 文件接入能力
- 补充 URI、文件选择、只读挂载与连接缓存键处理
- 去掉数据源卡片点击前的同步驱动查询,修复打开卡顿
- refs #166

* 🐛 fix(dameng): 修复达梦连接成功后数据库列表为空问题

- 调整达梦数据库列表获取策略,优先回退查询当前 schema 与当前用户
- 保留可见用户与 owner 聚合逻辑,兼容低权限账号场景
- 补充前端空列表提示与后端单元测试,降低排查成本
- close #203

*  feat(data-sync): 扩展跨库迁移链路并优化数据同步交互

- 统一同库同步与跨库迁移入口,补充模式区分与风险提示
- 扩展 ClickHouse 与 PG-like 双向迁移,并新增 PG-like、ClickHouse、TDengine 到 MongoDB 的迁移路由
- 完善 TDengine 目标端建表规划、回归测试与需求追踪文档
- refs #51

* 🐛 fix(connection): 修复新建连接时标签切换导致表单数据丢失

- 在 SSH 标签页测试连接时,基础信息的 host 回退为默认值 localhost
- 在基础信息标签页保存时,SSH 配置丢失
- 保存结果仅包含当前选中标签页的字段
- refs #208

* 🐛 fix(mongodb): 修复单机模式连接副本集实例时地址被替换为内网地址

- getURI 在 topology=single 时未设置 directConnection=true
- 驱动连接目标地址后自动跟随副本集成员发现,切换到 localhost:27017
- 在 mongodb_impl.go 和 mongodb_impl_v1.go 中添加 directConnection=true
- 仅在 topology 非 replica、无 replicaSet、非 SRV 时生效
- refs #205

* 🐛 fix(DataGrid): 修复虚拟滚动模式下右键菜单失效

- 行级和单元格级右键菜单的启用条件互斥,虚拟滚动模式下两者同时失效
- enableLargeResultOptimizedEditing 关闭了内联编辑但未回退启用行级菜单
- 修改 useContextMenuRow 和 enableRowContextMenu 条件,虚拟模式下启用行级菜单
- 更新 dataContextValue 的 useMemo 依赖数组
- refs #209

* 🐛 fix(sqlserver): 修复 SQL Server 查看表数据时分页语法和标识符引用错误

- quoteIdentPart 缺少 sqlserver 分支,标识符使用双引号而非 [bracket]
- buildPaginatedSelectSQL 增加 mssql 别名兜底,避免 dbType 变体导致走 default 分支
- 修复后标识符使用 [bracket],分页使用 OFFSET FETCH NEXT 语法
- refs #204

*  feat(DataGrid): 统一表格右键菜单交互体验

- 彻底移除功能较少的行级右键菜单 ContextMenuRow,统一使用功能更丰富的单元格右键菜单
- 优化虚拟滚动模式和只读模式下的渲染,支持触发单元格右键菜单
- 菜单展示自适应:在只读或不可修改数据的场景下自动隐藏「设置为 NULL」与「填充到选中行」等编辑项
- refs #209

* 🔧 fix(DataGrid): 默认开启虚拟滚动并修复多选单元格高亮失效问题

- 移除根据数据量和列数动态判断是否开启虚拟滚动的阈值限制,改为在表格视图下默认全量开启,彻底解决卡顿问题
- 修复 `updateCellSelection` 在查找坐标节点时硬编码 `td` 选择器的问题,改为精确匹配 `.ant-table-cell`,兼容虚拟滚动时的 `div` 渲染模式
- 修复因透明窗口特性导致的 `transparent !important` 把高亮样式强行覆盖的问题,拔高了多选状态下背景与边框 CSS 的优先级
- 解决单元格内外多重属性嵌套导致的高亮右侧留白现象,使得高亮框完全贴合表格单元格边缘
- 适配主题色响应(暗黑模式使用黄色深色高亮,白昼模式使用默认蓝色高亮)

---------

Co-authored-by: Syngnat <yangguofeng919@gmail.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: 辣条 <69459608+tianqijiuyun-latiao@users.noreply.github.com>
Co-authored-by: TSS <266256496+Zencok@users.noreply.github.com>
2026-03-10 11:26:02 +08:00
Syngnat
22bd1c4c28 Release/0.5.5 (#207)
* 🐛 fix(data-viewer): 修复ClickHouse尾部分页异常并增强DuckDB复杂类型兼容

- DataViewer 新增 ClickHouse 反向分页策略,修复最后页与倒数页查询失败
- DuckDB 查询失败时按列类型生成安全 SELECT,复杂类型转 VARCHAR 重试
- 分页状态统一使用 currentPage 回填,避免页码与总数推导不一致
- 增强查询异常日志与重试路径,降低大表场景卡顿与误报

*  feat(frontend-driver): 驱动管理支持快速搜索并优化信息展示

- 新增搜索框,支持按 DuckDB/ClickHouse 等关键字快速定位驱动
- 显示“匹配 x / y”统计与无结果提示
- 优化头部区域排版,提升透明/暗色场景下的视觉对齐

* 🔧 fix(connection-modal): 修复多数据源URI导入解析并校正Oracle服务名校验

- 新增单主机URI解析映射,兼容 postgres/postgresql、sqlserver、redis、tdengine、dameng(dm)、kingbase、highgo、vastbase、clickhouse、oracle
- 抽取 parseSingleHostUri 复用逻辑,统一 host/port/user/password/database 回填行为
- Oracle 连接新增服务名必填校验,移除“服务名为空回退用户名”的隐式逻辑
- 连接弹窗补充 Oracle 服务名输入项与 URI 示例

* 🐛 fix(query-export): 修复查询结果导出卡住并统一按数据源能力控制导出路径

- 查询结果页导出增加稳定兜底,异常时确保 loading 关闭避免持续转圈
- DataGrid 导出逻辑按数据源能力分流,优先走后端 ExportQuery 并保留结果集导出降级
- QueryEditor 传递结果导出 SQL,保证查询结果导出范围与当前结果一致
- 后端补充 ExportData/ExportQuery 关键日志,提升导出链路可观测性

* 🐛 fix(precision): 修复查询链路与分页统计的大整数精度丢失

- 代理响应数据解码改为 UseNumber,避免默认 float64 吞精度
- 统一归一化 json.Number 与超界整数,超出 JS 安全范围转字符串
- 修复 DataViewer 总数解析,超大值不再误转 Number 参与分页
- refs #142

* 🐛 fix(driver-manager): 修复驱动管理网络告警重复并强化代理引导

- 新增下载链路域名探测,区分“GitHub可达但驱动下载链路不可达”
- 网络不可达场景仅保留红色强提醒,移除重复二级告警
- 强提醒增加“打开全局代理设置”入口,优先引导使用 GoNavi 全局代理
- 统一网络检测与目录说明提示图标尺寸,修复加载期视觉不一致
- refs #141

* ♻️ refactor(frontend-interaction): 统一标签拖拽与暗色主题交互实现

- 重构Tab拖拽排序实现,统一为可配置拖拽引擎
- 规范拖拽与点击事件边界,提升交互一致性
- 统一多组件暗色透明样式策略,减少硬编码色值
- 提升Redis/表格/连接面板在透明模式下的观感一致性
- refs #144

* ♻️ refactor(update-state): 重构在线更新状态流并按版本统一进度展示

- 重构更新检查与下载状态同步流程,减少前后端状态分叉
- 进度展示严格绑定 latestVersion,避免跨版本状态串用
- 优化 about 打开场景的静默检查状态回填逻辑
- 统一下载弹窗关闭/后台隐藏行为
- 保持现有安装流程并补齐目录打开能力

* 🎨 style(sidebar-log): 将SQL执行日志入口调整为悬浮胶囊样式

- 移除侧栏底部整条日志入口容器
- 新增悬浮按钮阴影/边框/透明背景并适配明暗主题
- 为树区域预留底部空间避免入口遮挡内容

*  feat(redis-cluster): 支持集群模式逻辑多库隔离与 0-15 库切换

- 前端恢复 Redis 集群场景下 db0-db15 的数据库选择与展示
- 后端新增集群逻辑库命名空间前缀映射,统一 key/pattern 读写隔离
- 覆盖扫描、读取、写入、删除、重命名等核心操作的键映射规则
- 集群命令通道支持 SELECT 逻辑切库与 FLUSHDB 逻辑库清空
- refs #145

*  feat(DataGrid): 大数据表虚拟滚动性能优化及UI一致性修复

- 启用动态虚拟滚动(数据量≥500行自动切换),解决万行数据表卡顿问题
- 虚拟模式下EditableCell改用div渲染,CSS选择器从元素级改为类级适配虚拟DOM
- 修复虚拟模式双水平滚动条:样式化rc-virtual-list内置滚动条为胶囊外观,禁用自定义外部滚动条
- 为rc-virtual-list水平滚动条添加鼠标滚轮支持(MutationObserver + marginLeft驱动)
- 修复白色主题透明模式下列名悬浮Tooltip对比度不足的问题
- 新增白色主题全局滚动条样式适配透明模式(App.css)
- App.tsx主题token与组件样式优化
- refs #147

* 🔧 chore(app): 清理 App.tsx 类型告警并收敛前端壳层实现

- 清除未使用代码和冗余状态
- 替换弃用 API 以消除 IDE 提示
- 显式处理浮动 Promise 避免告警
- 保持现有更新检查和代理设置行为不变

* 🔧 fix(ci): 修复 Windows AMD64 下 DuckDB 驱动构建链路

- 将 DuckDB 工具链准备切换为优先使用 MSYS2
- 增加 gcc 和 g++ 存在性校验与版本验证
- 在 MSYS2 异常时回退 Chocolatey 安装 MinGW
- 保持 Windows ARM64 跳过 DuckDB 构建与平台支持一致

* 🔧 fix(ci): 修复 Windows AMD64 下 DuckDB 驱动构建链路

- 将 DuckDB 工具链准备切换为优先使用 MSYS2
- 增加 gcc 和 g++ 存在性校验与版本验证
- 在 MSYS2 异常时回退 Chocolatey 安装 MinGW
- 保持 Windows ARM64 跳过 DuckDB 构建与平台支持一致

* 🔧 fix(ci): 修复 Windows AMD64 下 DuckDB 驱动构建工具链

- 将 DuckDB 编译链从 MINGW64 切换为 MSYS2 UCRT64
- 修正 Windows AMD64 的 gcc 和 g++ 探测路径
- 增加 DuckDB 编译器版本校验步骤

* 📝 docs(contributing): 补充中英文贡献指南并统一 README 入口

- 新增英文版 CONTRIBUTING.md 作为正式贡献文档
- 新增中文版 CONTRIBUTING.zh-CN.md 作为中文贡献说明
- 调整 README 和 README.zh-CN 的贡献入口指向对应语言文档

* - feat(connection,metadata,kingbase): 增强多数据源连接能力并修复金仓/达梦/Oracle/ClickHouse兼容性问题 (#188) (#190)

* feat(http-tunnel): 支持独立 HTTP 隧道连接并覆盖多数据源

refs #168

* fix(kingbase-data-grid): 修复金仓打开表卡顿并降低对象渲染开销

refs #178

* fix(kingbase-transaction): 修复金仓事务提交重复引号导致语法错误

refs #176

* fix(driver-agent): 修复老版本 Win10 升级后金仓驱动代理启动失败

refs #177

* chore(ci): 新增手动触发的 macOS 测试构建工作流

* chore(ci): 允许测试工作流在当前分支自动触发

* fix(query-editor): 修复 SQL 编辑中光标随机跳到末尾 refs #185

* feat(data-sync): 增加差异 SQL 预览能力便于审核 refs #174

* fix(clickhouse-connect): 自动识别并回退 HTTP/Native 协议连接 refs #181

* fix(oracle-metadata): 修复视图与函数加载按 schema 过滤异常 refs #155

* fix(dameng-databases): 修复显示全部库时数据库列表不完整 refs #154

* fix(connection,db-list): 统一处理空列表返回并修复达梦连接测试报错 refs #157

Co-authored-by: 辣条 <69459608+tianqijiuyun-latiao@users.noreply.github.com>

*  feat(release-notes): 支持自动生成 Release 更新说明并区分配置文件命名

* 🔁 chore(sync): 回灌 main 到 dev (#192)

* - feat(connection,metadata,kingbase): 增强多数据源连接能力并修复金仓/达梦/Oracle/ClickHouse兼容性问题 (#188)

* feat(http-tunnel): 支持独立 HTTP 隧道连接并覆盖多数据源

refs #168

* fix(kingbase-data-grid): 修复金仓打开表卡顿并降低对象渲染开销

refs #178

* fix(kingbase-transaction): 修复金仓事务提交重复引号导致语法错误

refs #176

* fix(driver-agent): 修复老版本 Win10 升级后金仓驱动代理启动失败

refs #177

* chore(ci): 新增手动触发的 macOS 测试构建工作流

* chore(ci): 允许测试工作流在当前分支自动触发

* fix(query-editor): 修复 SQL 编辑中光标随机跳到末尾 refs #185

* feat(data-sync): 增加差异 SQL 预览能力便于审核 refs #174

* fix(clickhouse-connect): 自动识别并回退 HTTP/Native 协议连接 refs #181

* fix(oracle-metadata): 修复视图与函数加载按 schema 过滤异常 refs #155

* fix(dameng-databases): 修复显示全部库时数据库列表不完整 refs #154

* fix(connection,db-list): 统一处理空列表返回并修复达梦连接测试报错 refs #157

* Release/0.5.3 (#191)

---------

Co-authored-by: 辣条 <69459608+tianqijiuyun-latiao@users.noreply.github.com>
Co-authored-by: Syngnat <92659908+Syngnat@users.noreply.github.com>

* 🐛 fix(branch-sync): 修复 main 回灌 dev 时 mergeable 异步计算导致漏开自动合并

- 增加 mergeable 状态轮询,避免新建同步 PR 后立即返回 UNKNOWN
- 在合并状态未稳定时输出中文告警与执行摘要
- 保持冲突分支、待计算分支与自动合并分支的处理路径清晰

* 🔁 chore(sync): 回灌 main 到 dev (#195)

* - feat(connection,metadata,kingbase): 增强多数据源连接能力并修复金仓/达梦/Oracle/ClickHouse兼容性问题 (#188)

* feat(http-tunnel): 支持独立 HTTP 隧道连接并覆盖多数据源

refs #168

* fix(kingbase-data-grid): 修复金仓打开表卡顿并降低对象渲染开销

refs #178

* fix(kingbase-transaction): 修复金仓事务提交重复引号导致语法错误

refs #176

* fix(driver-agent): 修复老版本 Win10 升级后金仓驱动代理启动失败

refs #177

* chore(ci): 新增手动触发的 macOS 测试构建工作流

* chore(ci): 允许测试工作流在当前分支自动触发

* fix(query-editor): 修复 SQL 编辑中光标随机跳到末尾 refs #185

* feat(data-sync): 增加差异 SQL 预览能力便于审核 refs #174

* fix(clickhouse-connect): 自动识别并回退 HTTP/Native 协议连接 refs #181

* fix(oracle-metadata): 修复视图与函数加载按 schema 过滤异常 refs #155

* fix(dameng-databases): 修复显示全部库时数据库列表不完整 refs #154

* fix(connection,db-list): 统一处理空列表返回并修复达梦连接测试报错 refs #157

* Release/0.5.3 (#191)

* - chore(ci): 新增全平台测试包手动构建工作流 tianqijiuyun-latiao 今天 下午4:26 (#194)

* feat(http-tunnel): 支持独立 HTTP 隧道连接并覆盖多数据源

refs #168

* fix(kingbase-data-grid): 修复金仓打开表卡顿并降低对象渲染开销

refs #178

* fix(kingbase-transaction): 修复金仓事务提交重复引号导致语法错误

refs #176

* fix(driver-agent): 修复老版本 Win10 升级后金仓驱动代理启动失败

refs #177

* chore(ci): 新增手动触发的 macOS 测试构建工作流

* chore(ci): 允许测试工作流在当前分支自动触发

* fix(query-editor): 修复 SQL 编辑中光标随机跳到末尾 refs #185

* feat(data-sync): 增加差异 SQL 预览能力便于审核 refs #174

* fix(clickhouse-connect): 自动识别并回退 HTTP/Native 协议连接 refs #181

* fix(oracle-metadata): 修复视图与函数加载按 schema 过滤异常 refs #155

* fix(dameng-databases): 修复显示全部库时数据库列表不完整 refs #154

* fix(connection,db-list): 统一处理空列表返回并修复达梦连接测试报错 refs #157

* fix(kingbase): 补齐主键识别并优化宽表卡顿 refs #176 refs #178

* fix(query-execution): 支持带前置注释的读查询结果识别

* chore(ci): 新增全平台测试包手动构建工作流

---------

Co-authored-by: 辣条 <69459608+tianqijiuyun-latiao@users.noreply.github.com>
Co-authored-by: Syngnat <92659908+Syngnat@users.noreply.github.com>

* ♻️ refactor(frontend-sync): 优化桌面交互细节并移除 main 回灌 dev 自动化

- 优化新建连接、主题设置、侧边栏工具区与 SQL 日志的界面表现
- 调整分页、筛选、透明模式与弹窗样式,统一整体交互层次
- 收口外观参数生效逻辑并补齐多组件适配
- 删除 sync-main-to-dev 工作流并同步维护者手动回灌说明

* feat: 统一筛选条件逻辑按钮宽度 (#201)

* 🐛 fix(oracle-query): 修复 Oracle 表数据分页 SQL 兼容问题 refs #196 (#202)

*  feat(datasource): 支持 DuckDB Parquet 文件模式并优化弹窗打开链路

- 统一 DuckDB 文件库与 Parquet 文件接入能力
- 补充 URI、文件选择、只读挂载与连接缓存键处理
- 去掉数据源卡片点击前的同步驱动查询,修复打开卡顿

*  feat(datasource): 支持 DuckDB Parquet 文件模式并优化弹窗打开链路

- 统一 DuckDB 文件库与 Parquet 文件接入能力
- 补充 URI、文件选择、只读挂载与连接缓存键处理
- 去掉数据源卡片点击前的同步驱动查询,修复打开卡顿
- refs #166

* 🐛 fix(dameng): 修复达梦连接成功后数据库列表为空问题

- 调整达梦数据库列表获取策略,优先回退查询当前 schema 与当前用户
- 保留可见用户与 owner 聚合逻辑,兼容低权限账号场景
- 补充前端空列表提示与后端单元测试,降低排查成本
- close #203

*  feat(data-sync): 扩展跨库迁移链路并优化数据同步交互

- 统一同库同步与跨库迁移入口,补充模式区分与风险提示
- 扩展 ClickHouse 与 PG-like 双向迁移,并新增 PG-like、ClickHouse、TDengine 到 MongoDB 的迁移路由
- 完善 TDengine 目标端建表规划、回归测试与需求追踪文档
- refs #51

---------

Co-authored-by: Syngnat <yangguofeng919@gmail.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: 辣条 <69459608+tianqijiuyun-latiao@users.noreply.github.com>
Co-authored-by: TSS <266256496+Zencok@users.noreply.github.com>
2026-03-09 17:36:52 +08:00
Syngnat
89c81823bc Release/0.5.4 (#199)
* 🐛 fix(data-viewer): 修复ClickHouse尾部分页异常并增强DuckDB复杂类型兼容

- DataViewer 新增 ClickHouse 反向分页策略,修复最后页与倒数页查询失败
- DuckDB 查询失败时按列类型生成安全 SELECT,复杂类型转 VARCHAR 重试
- 分页状态统一使用 currentPage 回填,避免页码与总数推导不一致
- 增强查询异常日志与重试路径,降低大表场景卡顿与误报

*  feat(frontend-driver): 驱动管理支持快速搜索并优化信息展示

- 新增搜索框,支持按 DuckDB/ClickHouse 等关键字快速定位驱动
- 显示“匹配 x / y”统计与无结果提示
- 优化头部区域排版,提升透明/暗色场景下的视觉对齐

* 🔧 fix(connection-modal): 修复多数据源URI导入解析并校正Oracle服务名校验

- 新增单主机URI解析映射,兼容 postgres/postgresql、sqlserver、redis、tdengine、dameng(dm)、kingbase、highgo、vastbase、clickhouse、oracle
- 抽取 parseSingleHostUri 复用逻辑,统一 host/port/user/password/database 回填行为
- Oracle 连接新增服务名必填校验,移除“服务名为空回退用户名”的隐式逻辑
- 连接弹窗补充 Oracle 服务名输入项与 URI 示例

* 🐛 fix(query-export): 修复查询结果导出卡住并统一按数据源能力控制导出路径

- 查询结果页导出增加稳定兜底,异常时确保 loading 关闭避免持续转圈
- DataGrid 导出逻辑按数据源能力分流,优先走后端 ExportQuery 并保留结果集导出降级
- QueryEditor 传递结果导出 SQL,保证查询结果导出范围与当前结果一致
- 后端补充 ExportData/ExportQuery 关键日志,提升导出链路可观测性

* 🐛 fix(precision): 修复查询链路与分页统计的大整数精度丢失

- 代理响应数据解码改为 UseNumber,避免默认 float64 吞精度
- 统一归一化 json.Number 与超界整数,超出 JS 安全范围转字符串
- 修复 DataViewer 总数解析,超大值不再误转 Number 参与分页
- refs #142

* 🐛 fix(driver-manager): 修复驱动管理网络告警重复并强化代理引导

- 新增下载链路域名探测,区分“GitHub可达但驱动下载链路不可达”
- 网络不可达场景仅保留红色强提醒,移除重复二级告警
- 强提醒增加“打开全局代理设置”入口,优先引导使用 GoNavi 全局代理
- 统一网络检测与目录说明提示图标尺寸,修复加载期视觉不一致
- refs #141

* ♻️ refactor(frontend-interaction): 统一标签拖拽与暗色主题交互实现

- 重构Tab拖拽排序实现,统一为可配置拖拽引擎
- 规范拖拽与点击事件边界,提升交互一致性
- 统一多组件暗色透明样式策略,减少硬编码色值
- 提升Redis/表格/连接面板在透明模式下的观感一致性
- refs #144

* ♻️ refactor(update-state): 重构在线更新状态流并按版本统一进度展示

- 重构更新检查与下载状态同步流程,减少前后端状态分叉
- 进度展示严格绑定 latestVersion,避免跨版本状态串用
- 优化 about 打开场景的静默检查状态回填逻辑
- 统一下载弹窗关闭/后台隐藏行为
- 保持现有安装流程并补齐目录打开能力

* 🎨 style(sidebar-log): 将SQL执行日志入口调整为悬浮胶囊样式

- 移除侧栏底部整条日志入口容器
- 新增悬浮按钮阴影/边框/透明背景并适配明暗主题
- 为树区域预留底部空间避免入口遮挡内容

*  feat(redis-cluster): 支持集群模式逻辑多库隔离与 0-15 库切换

- 前端恢复 Redis 集群场景下 db0-db15 的数据库选择与展示
- 后端新增集群逻辑库命名空间前缀映射,统一 key/pattern 读写隔离
- 覆盖扫描、读取、写入、删除、重命名等核心操作的键映射规则
- 集群命令通道支持 SELECT 逻辑切库与 FLUSHDB 逻辑库清空
- refs #145

*  feat(DataGrid): 大数据表虚拟滚动性能优化及UI一致性修复

- 启用动态虚拟滚动(数据量≥500行自动切换),解决万行数据表卡顿问题
- 虚拟模式下EditableCell改用div渲染,CSS选择器从元素级改为类级适配虚拟DOM
- 修复虚拟模式双水平滚动条:样式化rc-virtual-list内置滚动条为胶囊外观,禁用自定义外部滚动条
- 为rc-virtual-list水平滚动条添加鼠标滚轮支持(MutationObserver + marginLeft驱动)
- 修复白色主题透明模式下列名悬浮Tooltip对比度不足的问题
- 新增白色主题全局滚动条样式适配透明模式(App.css)
- App.tsx主题token与组件样式优化
- refs #147

* 🔧 chore(app): 清理 App.tsx 类型告警并收敛前端壳层实现

- 清除未使用代码和冗余状态
- 替换弃用 API 以消除 IDE 提示
- 显式处理浮动 Promise 避免告警
- 保持现有更新检查和代理设置行为不变

* 🔧 fix(ci): 修复 Windows AMD64 下 DuckDB 驱动构建链路

- 将 DuckDB 工具链准备切换为优先使用 MSYS2
- 增加 gcc 和 g++ 存在性校验与版本验证
- 在 MSYS2 异常时回退 Chocolatey 安装 MinGW
- 保持 Windows ARM64 跳过 DuckDB 构建与平台支持一致

* 🔧 fix(ci): 修复 Windows AMD64 下 DuckDB 驱动构建链路

- 将 DuckDB 工具链准备切换为优先使用 MSYS2
- 增加 gcc 和 g++ 存在性校验与版本验证
- 在 MSYS2 异常时回退 Chocolatey 安装 MinGW
- 保持 Windows ARM64 跳过 DuckDB 构建与平台支持一致

* 🔧 fix(ci): 修复 Windows AMD64 下 DuckDB 驱动构建工具链

- 将 DuckDB 编译链从 MINGW64 切换为 MSYS2 UCRT64
- 修正 Windows AMD64 的 gcc 和 g++ 探测路径
- 增加 DuckDB 编译器版本校验步骤

* 📝 docs(contributing): 补充中英文贡献指南并统一 README 入口

- 新增英文版 CONTRIBUTING.md 作为正式贡献文档
- 新增中文版 CONTRIBUTING.zh-CN.md 作为中文贡献说明
- 调整 README 和 README.zh-CN 的贡献入口指向对应语言文档

* - feat(connection,metadata,kingbase): 增强多数据源连接能力并修复金仓/达梦/Oracle/ClickHouse兼容性问题 (#188) (#190)

* feat(http-tunnel): 支持独立 HTTP 隧道连接并覆盖多数据源

refs #168

* fix(kingbase-data-grid): 修复金仓打开表卡顿并降低对象渲染开销

refs #178

* fix(kingbase-transaction): 修复金仓事务提交重复引号导致语法错误

refs #176

* fix(driver-agent): 修复老版本 Win10 升级后金仓驱动代理启动失败

refs #177

* chore(ci): 新增手动触发的 macOS 测试构建工作流

* chore(ci): 允许测试工作流在当前分支自动触发

* fix(query-editor): 修复 SQL 编辑中光标随机跳到末尾 refs #185

* feat(data-sync): 增加差异 SQL 预览能力便于审核 refs #174

* fix(clickhouse-connect): 自动识别并回退 HTTP/Native 协议连接 refs #181

* fix(oracle-metadata): 修复视图与函数加载按 schema 过滤异常 refs #155

* fix(dameng-databases): 修复显示全部库时数据库列表不完整 refs #154

* fix(connection,db-list): 统一处理空列表返回并修复达梦连接测试报错 refs #157

Co-authored-by: 辣条 <69459608+tianqijiuyun-latiao@users.noreply.github.com>

*  feat(release-notes): 支持自动生成 Release 更新说明并区分配置文件命名

* 🔁 chore(sync): 回灌 main 到 dev (#192)

* - feat(connection,metadata,kingbase): 增强多数据源连接能力并修复金仓/达梦/Oracle/ClickHouse兼容性问题 (#188)

* feat(http-tunnel): 支持独立 HTTP 隧道连接并覆盖多数据源

refs #168

* fix(kingbase-data-grid): 修复金仓打开表卡顿并降低对象渲染开销

refs #178

* fix(kingbase-transaction): 修复金仓事务提交重复引号导致语法错误

refs #176

* fix(driver-agent): 修复老版本 Win10 升级后金仓驱动代理启动失败

refs #177

* chore(ci): 新增手动触发的 macOS 测试构建工作流

* chore(ci): 允许测试工作流在当前分支自动触发

* fix(query-editor): 修复 SQL 编辑中光标随机跳到末尾 refs #185

* feat(data-sync): 增加差异 SQL 预览能力便于审核 refs #174

* fix(clickhouse-connect): 自动识别并回退 HTTP/Native 协议连接 refs #181

* fix(oracle-metadata): 修复视图与函数加载按 schema 过滤异常 refs #155

* fix(dameng-databases): 修复显示全部库时数据库列表不完整 refs #154

* fix(connection,db-list): 统一处理空列表返回并修复达梦连接测试报错 refs #157

* Release/0.5.3 (#191)

---------

Co-authored-by: 辣条 <69459608+tianqijiuyun-latiao@users.noreply.github.com>
Co-authored-by: Syngnat <92659908+Syngnat@users.noreply.github.com>

* 🐛 fix(branch-sync): 修复 main 回灌 dev 时 mergeable 异步计算导致漏开自动合并

- 增加 mergeable 状态轮询,避免新建同步 PR 后立即返回 UNKNOWN
- 在合并状态未稳定时输出中文告警与执行摘要
- 保持冲突分支、待计算分支与自动合并分支的处理路径清晰

* 🔁 chore(sync): 回灌 main 到 dev (#195)

* - feat(connection,metadata,kingbase): 增强多数据源连接能力并修复金仓/达梦/Oracle/ClickHouse兼容性问题 (#188)

* feat(http-tunnel): 支持独立 HTTP 隧道连接并覆盖多数据源

refs #168

* fix(kingbase-data-grid): 修复金仓打开表卡顿并降低对象渲染开销

refs #178

* fix(kingbase-transaction): 修复金仓事务提交重复引号导致语法错误

refs #176

* fix(driver-agent): 修复老版本 Win10 升级后金仓驱动代理启动失败

refs #177

* chore(ci): 新增手动触发的 macOS 测试构建工作流

* chore(ci): 允许测试工作流在当前分支自动触发

* fix(query-editor): 修复 SQL 编辑中光标随机跳到末尾 refs #185

* feat(data-sync): 增加差异 SQL 预览能力便于审核 refs #174

* fix(clickhouse-connect): 自动识别并回退 HTTP/Native 协议连接 refs #181

* fix(oracle-metadata): 修复视图与函数加载按 schema 过滤异常 refs #155

* fix(dameng-databases): 修复显示全部库时数据库列表不完整 refs #154

* fix(connection,db-list): 统一处理空列表返回并修复达梦连接测试报错 refs #157

* Release/0.5.3 (#191)

* - chore(ci): 新增全平台测试包手动构建工作流 tianqijiuyun-latiao 今天 下午4:26 (#194)

* feat(http-tunnel): 支持独立 HTTP 隧道连接并覆盖多数据源

refs #168

* fix(kingbase-data-grid): 修复金仓打开表卡顿并降低对象渲染开销

refs #178

* fix(kingbase-transaction): 修复金仓事务提交重复引号导致语法错误

refs #176

* fix(driver-agent): 修复老版本 Win10 升级后金仓驱动代理启动失败

refs #177

* chore(ci): 新增手动触发的 macOS 测试构建工作流

* chore(ci): 允许测试工作流在当前分支自动触发

* fix(query-editor): 修复 SQL 编辑中光标随机跳到末尾 refs #185

* feat(data-sync): 增加差异 SQL 预览能力便于审核 refs #174

* fix(clickhouse-connect): 自动识别并回退 HTTP/Native 协议连接 refs #181

* fix(oracle-metadata): 修复视图与函数加载按 schema 过滤异常 refs #155

* fix(dameng-databases): 修复显示全部库时数据库列表不完整 refs #154

* fix(connection,db-list): 统一处理空列表返回并修复达梦连接测试报错 refs #157

* fix(kingbase): 补齐主键识别并优化宽表卡顿 refs #176 refs #178

* fix(query-execution): 支持带前置注释的读查询结果识别

* chore(ci): 新增全平台测试包手动构建工作流

---------

Co-authored-by: 辣条 <69459608+tianqijiuyun-latiao@users.noreply.github.com>
Co-authored-by: Syngnat <92659908+Syngnat@users.noreply.github.com>

* ♻️ refactor(frontend-sync): 优化桌面交互细节并移除 main 回灌 dev 自动化

- 优化新建连接、主题设置、侧边栏工具区与 SQL 日志的界面表现
- 调整分页、筛选、透明模式与弹窗样式,统一整体交互层次
- 收口外观参数生效逻辑并补齐多组件适配
- 删除 sync-main-to-dev 工作流并同步维护者手动回灌说明

---------

Co-authored-by: Syngnat <yangguofeng919@gmail.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: 辣条 <69459608+tianqijiuyun-latiao@users.noreply.github.com>
2026-03-07 17:15:30 +08:00
148 changed files with 24184 additions and 2711 deletions

607
.github/workflows/dev-build.yml vendored Normal file
View File

@@ -0,0 +1,607 @@
name: Dev Build
on:
push:
branches:
- dev
permissions:
contents: write
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
jobs:
build:
name: Build ${{ matrix.platform }}
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
include:
- os: macos-latest
platform: darwin/amd64
os_name: MacOS
arch_name: Amd64
build_name: gonavi-build-darwin-amd64
wails_tags: ""
artifact_suffix: ""
build_optional_agents: true
linux_webkit: ""
- os: macos-latest
platform: darwin/arm64
os_name: MacOS
arch_name: Arm64
build_name: gonavi-build-darwin-arm64
wails_tags: ""
artifact_suffix: ""
build_optional_agents: true
linux_webkit: ""
- os: windows-latest
platform: windows/amd64
os_name: Windows
arch_name: Amd64
build_name: gonavi-build-windows-amd64
wails_tags: ""
artifact_suffix: ""
build_optional_agents: true
linux_webkit: ""
- os: windows-latest
platform: windows/arm64
os_name: Windows
arch_name: Arm64
build_name: gonavi-build-windows-arm64
wails_tags: ""
artifact_suffix: ""
build_optional_agents: true
linux_webkit: ""
- os: ubuntu-22.04
platform: linux/amd64
os_name: Linux
arch_name: Amd64
build_name: gonavi-build-linux-amd64
wails_tags: ""
artifact_suffix: ""
build_optional_agents: true
linux_webkit: "4.0"
- os: ubuntu-24.04
platform: linux/amd64
os_name: Linux
arch_name: Amd64
build_name: gonavi-build-linux-amd64-webkit41
wails_tags: "webkit2_41"
artifact_suffix: "-WebKit41"
build_optional_agents: false
linux_webkit: "4.1"
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: '1.24'
check-latest: true
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install UPX (Windows)
if: contains(matrix.platform, 'windows')
shell: pwsh
run: |
$UPX_VERSION = "4.2.4"
$url = "https://github.com/upx/upx/releases/download/v${UPX_VERSION}/upx-${UPX_VERSION}-win64.zip"
$zipPath = "$env:RUNNER_TEMP\upx.zip"
$extractPath = "$env:RUNNER_TEMP\upx"
Write-Host "📥 从 GitHub Releases 下载 UPX v${UPX_VERSION} ..."
Invoke-WebRequest -Uri $url -OutFile $zipPath -UseBasicParsing
Expand-Archive -Path $zipPath -DestinationPath $extractPath -Force
$upxDir = Get-ChildItem -Path $extractPath -Directory | Select-Object -First 1
"$($upxDir.FullName)" | Out-File -FilePath $env:GITHUB_PATH -Append -Encoding utf8
$upxCmd = Join-Path $upxDir.FullName "upx.exe"
if (!(Test-Path $upxCmd)) {
Write-Error "❌ 未检测到 upx无法保证 Windows 产物经过压缩"
exit 1
}
& $upxCmd --version
- name: Install Linux Dependencies
if: contains(matrix.platform, 'linux')
run: |
sudo apt-get update
sudo apt-get install -y libgtk-3-dev
if [ "${{ matrix.linux_webkit }}" = "4.1" ]; then
sudo apt-get install -y libwebkit2gtk-4.1-dev libsoup-3.0-dev
else
sudo apt-get install -y libwebkit2gtk-4.0-dev
fi
sudo apt-get install -y upx-ucl || sudo apt-get install -y upx
upx --version
sudo apt-get install -y libfuse2 || sudo apt-get install -y libfuse2t64 || true
LINUXDEPLOY_URL="https://github.com/linuxdeploy/linuxdeploy/releases/download/continuous/linuxdeploy-x86_64.AppImage"
PLUGIN_URL="https://github.com/linuxdeploy/linuxdeploy-plugin-gtk/releases/download/continuous/linuxdeploy-plugin-gtk-x86_64.AppImage"
echo "📥 下载 linuxdeploy..."
wget --retry-connrefused --waitretry=1 --read-timeout=20 --timeout=15 --tries=3 \
-O /tmp/linuxdeploy "$LINUXDEPLOY_URL" || {
echo "⚠️ linuxdeploy 下载失败AppImage 打包将跳过"
touch /tmp/skip-appimage
}
echo "📥 下载 linuxdeploy-plugin-gtk..."
wget --retry-connrefused --waitretry=1 --read-timeout=20 --timeout=15 --tries=3 \
-O /tmp/linuxdeploy-plugin-gtk "$PLUGIN_URL" || {
echo "⚠️ linuxdeploy-plugin-gtk 下载失败AppImage 打包将跳过"
touch /tmp/skip-appimage
}
if [ ! -f /tmp/skip-appimage ]; then
chmod +x /tmp/linuxdeploy /tmp/linuxdeploy-plugin-gtk
echo "✅ AppImage 工具准备完成"
fi
- name: Install Wails
run: go install -v github.com/wailsapp/wails/v2/cmd/wails@latest
- name: Setup MSYS2 Toolchain For DuckDB (Windows AMD64)
id: msys2_duckdb
if: ${{ matrix.build_optional_agents && matrix.platform == 'windows/amd64' }}
continue-on-error: true
uses: msys2/setup-msys2@v2
with:
msystem: UCRT64
update: true
install: >-
mingw-w64-ucrt-x86_64-gcc
- name: Configure DuckDB CGO Toolchain (Windows AMD64)
if: ${{ matrix.build_optional_agents && matrix.platform == 'windows/amd64' }}
shell: pwsh
run: |
function Find-MingwBin([string[]]$candidates) {
foreach ($bin in $candidates) {
if ([string]::IsNullOrWhiteSpace($bin)) {
continue
}
$gcc = Join-Path $bin 'gcc.exe'
$gxx = Join-Path $bin 'g++.exe'
if ((Test-Path $gcc) -and (Test-Path $gxx)) {
return $bin
}
}
return $null
}
$msys2Outcome = "${{ steps.msys2_duckdb.outcome }}"
$msys2Location = "${{ steps.msys2_duckdb.outputs['msys2-location'] }}"
$candidateBins = @()
if (-not [string]::IsNullOrWhiteSpace($msys2Location)) {
$candidateBins += Join-Path $msys2Location 'ucrt64\bin'
}
$candidateBins += @(
'C:\msys64\ucrt64\bin',
'D:\a\_temp\msys64\ucrt64\bin'
)
$candidateBins = @($candidateBins | Select-Object -Unique)
$mingwBin = Find-MingwBin $candidateBins
if (-not $mingwBin) {
if ($msys2Outcome -ne 'success') {
Write-Warning "⚠️ MSYS2 安装步骤结果为 $msys2Outcome回退到 UCRT64 本机路径探测"
} else {
Write-Warning "⚠️ MSYS2 已执行,但未找到 UCRT64 gcc/g++,回退到本机路径探测"
}
$mingwBin = Find-MingwBin $candidateBins
}
if (-not $mingwBin) {
Write-Error "❌ 未找到可用的 DuckDB UCRT64 编译器。已检查:$($candidateBins -join ', ')"
exit 1
}
$gcc = (Join-Path $mingwBin 'gcc.exe')
$gxx = (Join-Path $mingwBin 'g++.exe')
if (!(Test-Path $gcc) -or !(Test-Path $gxx)) {
Write-Error "❌ DuckDB 编译器缺失gcc=$gcc g++=$gxx"
exit 1
}
"$mingwBin" | Out-File -FilePath $env:GITHUB_PATH -Append -Encoding utf8
"CC=$gcc" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
"CXX=$gxx" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
Write-Host "✅ 已配置 DuckDB cgo 编译器: gcc=$gcc g++=$gxx"
- name: Verify DuckDB CGO Toolchain (Windows AMD64)
if: ${{ matrix.build_optional_agents && matrix.platform == 'windows/amd64' }}
shell: pwsh
run: |
& "$env:CC" --version
& "$env:CXX" --version
# ---- 生成 dev 版本号 ----
- name: Generate Dev Version
id: version
shell: bash
run: |
SHORT_SHA=$(git rev-parse --short HEAD)
DEV_VERSION="dev-${SHORT_SHA}"
echo "version=${DEV_VERSION}" >> "$GITHUB_OUTPUT"
echo "📌 Dev 版本号: ${DEV_VERSION}"
- name: Build
shell: bash
run: |
set -euo pipefail
DEV_VERSION="${{ steps.version.outputs.version }}"
if [ -n "${{ matrix.wails_tags }}" ]; then
wails build -platform ${{ matrix.platform }} -clean -o ${{ matrix.build_name }} -tags "${{ matrix.wails_tags }}" -ldflags "-s -w -X GoNavi-Wails/internal/app.AppVersion=${DEV_VERSION}"
else
wails build -platform ${{ matrix.platform }} -clean -o ${{ matrix.build_name }} -ldflags "-s -w -X GoNavi-Wails/internal/app.AppVersion=${DEV_VERSION}"
fi
- name: Build Optional Driver Agents
if: ${{ matrix.build_optional_agents }}
shell: bash
run: |
set -euo pipefail
TARGET_PLATFORM="${{ matrix.platform }}"
GOOS="${TARGET_PLATFORM%%/*}"
GOARCH="${TARGET_PLATFORM##*/}"
DRIVERS=(mariadb doris sphinx sqlserver sqlite duckdb dameng kingbase highgo vastbase mongodb tdengine clickhouse)
OUTDIR="drivers/${{ matrix.os_name }}"
mkdir -p "$OUTDIR"
for DRIVER in "${DRIVERS[@]}"; do
BUILD_DRIVER="$DRIVER"
if [ "$DRIVER" = "doris" ]; then
BUILD_DRIVER="diros"
fi
if [ "$DRIVER" = "duckdb" ] && [ "$GOOS" = "windows" ] && [ "$GOARCH" != "amd64" ]; then
echo "⚠️ 跳过 DuckDB driver当前平台 ${GOOS}/${GOARCH} 不受支持,仅支持 windows/amd64"
continue
fi
TAG="gonavi_${BUILD_DRIVER}_driver"
OUTPUT="${DRIVER}-driver-agent-${GOOS}-${GOARCH}"
if [ "$GOOS" = "windows" ]; then
OUTPUT="${OUTPUT}.exe"
fi
OUTPUT_PATH="${OUTDIR}/${OUTPUT}"
echo "🔧 构建 ${OUTPUT_PATH} (tag=${TAG})"
if [ "$DRIVER" = "duckdb" ]; then
CGO_ENABLED=1 GOOS="$GOOS" GOARCH="$GOARCH" go build \
-tags "${TAG}" \
-trimpath \
-ldflags "-s -w" \
-o "${OUTPUT_PATH}" \
./cmd/optional-driver-agent
else
CGO_ENABLED=0 GOOS="$GOOS" GOARCH="$GOARCH" go build \
-tags "${TAG}" \
-trimpath \
-ldflags "-s -w" \
-o "${OUTPUT_PATH}" \
./cmd/optional-driver-agent
fi
done
# macOS Packaging
- name: Package macOS DMG
if: contains(matrix.platform, 'darwin')
run: |
brew install create-dmg
VERSION="${{ steps.version.outputs.version }}"
cd build/bin
APP_PATH=$(find . -maxdepth 1 -name "*.app" | head -n 1)
if [ -z "$APP_PATH" ]; then
echo "❌ 未找到 .app 应用包!"
exit 1
fi
APP_NAME=$(basename "$APP_PATH")
APP_BIN=$(find "$APP_PATH/Contents/MacOS" -maxdepth 1 -type f | head -n 1)
if [ -z "$APP_BIN" ]; then
echo "❌ 未找到 macOS 应用主程序!"
exit 1
fi
echo " macOS 产物不执行 UPX 压缩,保留原始主程序。"
echo "🔏 正在进行 Ad-hoc 签名..."
codesign --force --deep --sign - "$APP_NAME"
DMG_NAME="${{ matrix.build_name }}.dmg"
FINAL_NAME="GoNavi-${VERSION}-${{ matrix.os_name }}-${{ matrix.arch_name }}${{ matrix.artifact_suffix }}.dmg"
echo "📦 正在生成 DMG: $DMG_NAME..."
create-dmg \
--volname "GoNavi Dev Build" \
--window-pos 200 120 \
--window-size 800 400 \
--icon-size 100 \
--icon "$APP_NAME" 200 190 \
--hide-extension "$APP_NAME" \
--app-drop-link 600 185 \
"$DMG_NAME" \
"$APP_NAME"
mv "$DMG_NAME" "../../$FINAL_NAME"
# Windows Packaging
- name: Package Windows EXE
if: contains(matrix.platform, 'windows')
shell: pwsh
run: |
Set-Location build/bin
$version = "${{ steps.version.outputs.version }}"
$target = "${{ matrix.build_name }}"
$finalExeName = "GoNavi-$version-${{ matrix.os_name }}-${{ matrix.arch_name }}${{ matrix.artifact_suffix }}.exe"
if (Test-Path "$target.exe") {
$finalExe = "$target.exe"
} elseif (Test-Path "$target") {
Rename-Item -Path "$target" -NewName "$target.exe"
$finalExe = "$target.exe"
} else {
Write-Error "❌ 未找到构建产物 '$target'!"
exit 1
}
$isArm64Target = "${{ matrix.arch_name }}".ToLowerInvariant() -eq "arm64"
if ($isArm64Target) {
Write-Warning "⚠️ UPX 当前不支持 win64/arm64跳过压缩并保留原始 EXE。"
$LASTEXITCODE = 0
} else {
$upxCmd = Get-Command upx -ErrorAction SilentlyContinue
if ($null -eq $upxCmd) {
Write-Error "❌ 未找到 upx无法保证 Windows 产物经过压缩"
exit 1
}
$beforeBytes = (Get-Item -LiteralPath $finalExe).Length
Write-Host "🗜️ 使用 UPX 压缩 $finalExe ..."
& upx --best --lzma --force $finalExe | Out-Host
if ($LASTEXITCODE -ne 0) {
Write-Error "❌ UPX 压缩失败($LASTEXITCODE"
exit 1
}
& upx -t $finalExe | Out-Host
if ($LASTEXITCODE -ne 0) {
Write-Error "❌ UPX 校验失败($LASTEXITCODE"
exit 1
}
$afterBytes = (Get-Item -LiteralPath $finalExe).Length
if ($afterBytes -lt $beforeBytes) {
$savedBytes = $beforeBytes - $afterBytes
Write-Host ("✅ UPX 压缩完成:{0:N2}MB -> {1:N2}MB减少 {2:N2}MB" -f ($beforeBytes / 1MB), ($afterBytes / 1MB), ($savedBytes / 1MB))
} else {
Write-Host (" UPX 压缩完成:{0:N2}MB -> {1:N2}MB" -f ($beforeBytes / 1MB), ($afterBytes / 1MB))
}
}
Write-Host "📦 输出 Windows 可执行文件 $finalExeName..."
Copy-Item -LiteralPath $finalExe -Destination "..\\..\\$finalExeName" -Force
# Linux Packaging
- name: Package Linux
if: contains(matrix.platform, 'linux')
run: |
VERSION="${{ steps.version.outputs.version }}"
cd build/bin
TARGET="${{ matrix.build_name }}"
TAR_NAME="GoNavi-$VERSION-${{ matrix.os_name }}-${{ matrix.arch_name }}${{ matrix.artifact_suffix }}.tar.gz"
APPIMAGE_NAME="GoNavi-$VERSION-${{ matrix.os_name }}-${{ matrix.arch_name }}${{ matrix.artifact_suffix }}.AppImage"
if [ ! -f "$TARGET" ]; then
echo "❌ 未找到构建产物 '$TARGET'!"
exit 1
fi
chmod +x "$TARGET"
BEFORE_BYTES=$(wc -c <"$TARGET" | tr -d '[:space:]')
echo "🗜️ 正在使用 UPX 压缩 Linux 可执行文件: $TARGET ..."
upx --best --lzma --force "$TARGET"
upx -t "$TARGET"
AFTER_BYTES=$(wc -c <"$TARGET" | tr -d '[:space:]')
if [ "$AFTER_BYTES" -lt "$BEFORE_BYTES" ]; then
SAVED_BYTES=$((BEFORE_BYTES - AFTER_BYTES))
awk -v b="$BEFORE_BYTES" -v a="$AFTER_BYTES" -v s="$SAVED_BYTES" 'BEGIN { printf "✅ Linux UPX 压缩完成:%.2fMB -> %.2fMB,减少 %.2fMB\n", b/1024/1024, a/1024/1024, s/1024/1024 }'
else
awk -v b="$BEFORE_BYTES" -v a="$AFTER_BYTES" 'BEGIN { printf " Linux UPX 压缩完成:%.2fMB -> %.2fMB\n", b/1024/1024, a/1024/1024 }'
fi
echo "📦 正在打包 $TAR_NAME..."
tar -czvf "$TAR_NAME" "$TARGET"
mv "$TAR_NAME" ../../
if [ -f /tmp/skip-appimage ]; then
echo "⚠️ 跳过 AppImage 打包"
exit 0
fi
echo "📦 正在生成 AppImage..."
mkdir -p AppDir/usr/bin
mkdir -p AppDir/usr/share/applications
mkdir -p AppDir/usr/share/icons/hicolor/256x256/apps
cp "$TARGET" AppDir/usr/bin/gonavi
printf '%s\n' \
'[Desktop Entry]' \
'Name=GoNavi' \
'Exec=gonavi' \
'Icon=gonavi' \
'Type=Application' \
'Categories=Development;Database;' \
'Comment=Database Management Tool' \
> AppDir/usr/share/applications/gonavi.desktop
cp AppDir/usr/share/applications/gonavi.desktop AppDir/gonavi.desktop
if [ -f "../../build/appicon.png" ]; then
cp "../../build/appicon.png" AppDir/usr/share/icons/hicolor/256x256/apps/gonavi.png
cp "../../build/appicon.png" AppDir/gonavi.png
else
convert -size 256x256 xc:#336791 -fill white -gravity center -pointsize 48 -annotate 0 "GoNavi" AppDir/gonavi.png || \
wget -q "https://via.placeholder.com/256/336791/FFFFFF?text=GoNavi" -O AppDir/gonavi.png || \
touch AppDir/gonavi.png
cp AppDir/gonavi.png AppDir/usr/share/icons/hicolor/256x256/apps/gonavi.png
fi
export DEPLOY_GTK_VERSION=3
/tmp/linuxdeploy --appdir AppDir --plugin gtk --output appimage || {
echo "⚠️ AppImage 生成失败,但 tar.gz 已成功生成"
exit 0
}
mv GoNavi*.AppImage "$APPIMAGE_NAME" 2>/dev/null || {
echo "⚠️ AppImage 重命名失败"
exit 0
}
if [ -f "$APPIMAGE_NAME" ]; then
mv "$APPIMAGE_NAME" ../../
echo "✅ AppImage 生成成功"
fi
- name: Upload Artifact
uses: actions/upload-artifact@v4
with:
name: dev-build-artifacts-${{ strategy.job-index }}
path: |
GoNavi-*.dmg
GoNavi-*.exe
GoNavi-*.tar.gz
GoNavi-*.AppImage
drivers/**
retention-days: 7
# 汇总所有产物并发布为 Pre-release
release:
name: Publish Dev Pre-release
needs: build
runs-on: ubuntu-latest
steps:
- name: Download All Artifacts
uses: actions/download-artifact@v4
with:
path: release-assets
pattern: dev-build-artifacts-*
merge-multiple: true
- name: List Assets
run: ls -R release-assets
- name: Package Driver Agents Bundle
shell: bash
run: |
set -euo pipefail
cd release-assets
if [ ! -d drivers ]; then
echo "⚠️ 未找到 drivers 目录,跳过驱动总包打包"
exit 0
fi
if [ -z "$(find drivers -type f 2>/dev/null | head -n 1)" ]; then
echo "⚠️ drivers 目录为空,跳过驱动总包打包"
rm -rf drivers
exit 0
fi
echo "📦 打包驱动总包GoNavi-DriverAgents.zip"
python3 - <<'PY'
import json
import os
import zipfile
from pathlib import Path
out_name = "GoNavi-DriverAgents.zip"
index_name = "GoNavi-DriverAgents-Index.json"
base = Path("drivers")
out_path = Path(out_name)
index_path = Path(index_name)
if out_path.exists():
out_path.unlink()
if index_path.exists():
index_path.unlink()
size_index = {}
with zipfile.ZipFile(out_path, "w", compression=zipfile.ZIP_DEFLATED) as zf:
for p in base.rglob("*"):
if not p.is_file():
continue
arcname = p.relative_to(base).as_posix()
zf.write(p, arcname)
size_index[p.name] = p.stat().st_size
index_path.write_text(
json.dumps({"assets": size_index}, ensure_ascii=False, indent=2),
encoding="utf-8",
)
print(f"created {out_name} size={out_path.stat().st_size} bytes")
print(f"created {index_name} entries={len(size_index)}")
PY
rm -rf drivers
- name: Generate SHA256SUMS
shell: bash
run: |
cd release-assets
FILES=()
while IFS= read -r file; do
if [ -n "$file" ]; then
FILES+=("$file")
fi
done < <(find . -maxdepth 1 -type f ! -name SHA256SUMS -exec basename {} \; | sort)
if [ ${#FILES[@]} -eq 0 ]; then
echo "⚠️ 未找到可签名资产,生成空 SHA256SUMS"
: > SHA256SUMS
else
sha256sum "${FILES[@]}" > SHA256SUMS
fi
- name: Generate Dev Version
id: version
run: |
SHORT_SHA="${GITHUB_SHA:0:7}"
DEV_VERSION="dev-${SHORT_SHA}"
echo "version=${DEV_VERSION}" >> "$GITHUB_OUTPUT"
# 删除旧的 dev pre-release保持只有最新一个
- name: Delete Previous Dev Release
uses: dev-drprasad/delete-tag-and-release@v1.1
continue-on-error: true
with:
tag_name: dev-latest
delete_release: true
github_token: ${{ secrets.GITHUB_TOKEN }}
- name: Create Dev Pre-release
uses: softprops/action-gh-release@v2
with:
tag_name: dev-latest
name: "🧪 Dev Build (${{ steps.version.outputs.version }})"
target_commitish: ${{ github.sha }}
files: release-assets/*
prerelease: true
draft: false
body: |
## 🧪 测试版本 (Dev Build)
**版本**: `${{ steps.version.outputs.version }}`
**分支**: `dev`
**提交**: [`${{ github.sha }}`](${{ github.server_url }}/${{ github.repository }}/commit/${{ github.sha }})
**构建时间**: ${{ github.event.head_commit.timestamp }}
> ⚠️ 这是开发测试版本,仅供内部测试使用,不建议用于生产环境。
> 每次 push 到 `dev` 分支会自动覆盖此 release。
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -10,6 +10,9 @@ on:
description: 'Tag of release you want to publish'
type: string
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
jobs:
publish:
runs-on: windows-latest

View File

@@ -8,6 +8,9 @@ on:
permissions:
contents: write
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
jobs:
# Phase 1: Build in parallel and output artifacts
build:
@@ -88,23 +91,25 @@ jobs:
with:
node-version: '20'
- name: Install UPX (macOS)
if: contains(matrix.platform, 'darwin')
run: |
brew install upx
upx --version
- name: Install UPX (Windows)
if: contains(matrix.platform, 'windows')
shell: pwsh
run: |
choco install upx --no-progress -y
$upxCmd = Get-Command upx -ErrorAction SilentlyContinue
if ($null -eq $upxCmd) {
$UPX_VERSION = "4.2.4"
$url = "https://github.com/upx/upx/releases/download/v${UPX_VERSION}/upx-${UPX_VERSION}-win64.zip"
$zipPath = "$env:RUNNER_TEMP\upx.zip"
$extractPath = "$env:RUNNER_TEMP\upx"
Write-Host "📥 从 GitHub Releases 下载 UPX v${UPX_VERSION} ..."
Invoke-WebRequest -Uri $url -OutFile $zipPath -UseBasicParsing
Expand-Archive -Path $zipPath -DestinationPath $extractPath -Force
$upxDir = Get-ChildItem -Path $extractPath -Directory | Select-Object -First 1
"$($upxDir.FullName)" | Out-File -FilePath $env:GITHUB_PATH -Append -Encoding utf8
$upxCmd = Join-Path $upxDir.FullName "upx.exe"
if (!(Test-Path $upxCmd)) {
Write-Error "❌ 未检测到 upx无法保证 Windows 产物经过压缩"
exit 1
}
& upx --version
& $upxCmd --version
# Linux Dependencies (GTK3, WebKit2GTK required by Wails)
- name: Install Linux Dependencies
@@ -301,20 +306,10 @@ jobs:
APP_BIN=$(find "$APP_PATH/Contents/MacOS" -maxdepth 1 -type f | head -n 1)
if [ -z "$APP_BIN" ]; then
echo "❌ 未找到 macOS 应用主程序,无法进行 UPX 压缩!"
echo "❌ 未找到 macOS 应用主程序!"
exit 1
fi
BEFORE_BYTES=$(wc -c <"$APP_BIN" | tr -d '[:space:]')
echo "🗜️ 正在使用 UPX 压缩 macOS 可执行文件: $APP_BIN ..."
upx --best --lzma --force "$APP_BIN"
upx -t "$APP_BIN"
AFTER_BYTES=$(wc -c <"$APP_BIN" | tr -d '[:space:]')
if [ "$AFTER_BYTES" -lt "$BEFORE_BYTES" ]; then
SAVED_BYTES=$((BEFORE_BYTES - AFTER_BYTES))
awk -v b="$BEFORE_BYTES" -v a="$AFTER_BYTES" -v s="$SAVED_BYTES" 'BEGIN { printf "✅ macOS UPX 压缩完成:%.2fMB -> %.2fMB,减少 %.2fMB\n", b/1024/1024, a/1024/1024, s/1024/1024 }'
else
awk -v b="$BEFORE_BYTES" -v a="$AFTER_BYTES" 'BEGIN { printf " macOS UPX 压缩完成:%.2fMB -> %.2fMB\n", b/1024/1024, a/1024/1024 }'
fi
echo " macOS 产物不执行 UPX 压缩,保留原始主程序。"
echo "🔏 正在进行 Ad-hoc 签名..."
# 注意Ad-hoc + hardened runtime--options runtime在未配置 entitlements 时,
@@ -361,21 +356,35 @@ jobs:
exit 1
}
$upxCmd = Get-Command upx -ErrorAction SilentlyContinue
if ($null -eq $upxCmd) {
Write-Error "❌ 未找到 upx无法保证 Windows 产物经过压缩"
exit 1
}
$beforeBytes = (Get-Item -LiteralPath $finalExe).Length
Write-Host "🗜️ 使用 UPX 压缩 $finalExe ..."
& upx --best --lzma --force $finalExe | Out-Host
& upx -t $finalExe | Out-Host
$afterBytes = (Get-Item -LiteralPath $finalExe).Length
if ($afterBytes -lt $beforeBytes) {
$savedBytes = $beforeBytes - $afterBytes
Write-Host ("✅ UPX 压缩完成:{0:N2}MB -> {1:N2}MB减少 {2:N2}MB" -f ($beforeBytes / 1MB), ($afterBytes / 1MB), ($savedBytes / 1MB))
$isArm64Target = "${{ matrix.arch_name }}".ToLowerInvariant() -eq "arm64"
if ($isArm64Target) {
Write-Warning "⚠️ UPX 当前不支持 win64/arm64跳过压缩并保留原始 EXE。"
$LASTEXITCODE = 0
} else {
Write-Host (" UPX 压缩完成:{0:N2}MB -> {1:N2}MB" -f ($beforeBytes / 1MB), ($afterBytes / 1MB))
$upxCmd = Get-Command upx -ErrorAction SilentlyContinue
if ($null -eq $upxCmd) {
Write-Error "❌ 未找到 upx无法保证 Windows 产物经过压缩"
exit 1
}
$beforeBytes = (Get-Item -LiteralPath $finalExe).Length
Write-Host "🗜️ 使用 UPX 压缩 $finalExe ..."
& upx --best --lzma --force $finalExe | Out-Host
if ($LASTEXITCODE -ne 0) {
Write-Error "❌ UPX 压缩失败($LASTEXITCODE"
exit 1
}
& upx -t $finalExe | Out-Host
if ($LASTEXITCODE -ne 0) {
Write-Error "❌ UPX 校验失败($LASTEXITCODE"
exit 1
}
$afterBytes = (Get-Item -LiteralPath $finalExe).Length
if ($afterBytes -lt $beforeBytes) {
$savedBytes = $beforeBytes - $afterBytes
Write-Host ("✅ UPX 压缩完成:{0:N2}MB -> {1:N2}MB减少 {2:N2}MB" -f ($beforeBytes / 1MB), ($afterBytes / 1MB), ($savedBytes / 1MB))
} else {
Write-Host (" UPX 压缩完成:{0:N2}MB -> {1:N2}MB" -f ($beforeBytes / 1MB), ($afterBytes / 1MB))
}
}
Write-Host "📦 输出 Windows 可执行文件 $finalExeName..."
@@ -604,6 +613,74 @@ jobs:
sha256sum "${FILES[@]}" > SHA256SUMS
fi
- name: Checkout code for changelog
uses: actions/checkout@v4
with:
fetch-depth: 0
path: repo-for-changelog
- name: Generate Changelog
id: changelog
shell: bash
run: |
set -euo pipefail
cd repo-for-changelog
TAG="${{ github.ref_name }}"
# 获取上一个 tag
PREV_TAG=$(git tag --sort=-creatordate | grep -E '^v' | sed -n '2p' || true)
if [ -z "$PREV_TAG" ]; then
echo "⚠️ 未找到上一个 tag使用全部 commit"
RANGE="$TAG"
else
RANGE="${PREV_TAG}..${TAG}"
fi
echo "📋 生成更新日志:$RANGE"
# 提取 commit 消息(排除 merge commit
COMMITS=$(git log "$RANGE" --no-merges --pretty=format:'%s' 2>/dev/null || true)
if [ -z "$COMMITS" ]; then
BODY="暂无提交记录。"
else
CAT_FEAT=""
CAT_FIX=""
CAT_PERF=""
CAT_REFACTOR=""
CAT_I18N=""
CAT_OTHER=""
while IFS= read -r line; do
[ -z "$line" ] && continue
case "$line" in
✨*|*feat*) CAT_FEAT="${CAT_FEAT}\n- ${line}" ;;
🐛*|*fix*) CAT_FIX="${CAT_FIX}\n- ${line}" ;;
⚡*|*perf*) CAT_PERF="${CAT_PERF}\n- ${line}" ;;
♻️*|*refactor*) CAT_REFACTOR="${CAT_REFACTOR}\n- ${line}" ;;
🌐*) CAT_I18N="${CAT_I18N}\n- ${line}" ;;
🔧*|🔨*|*chore*) CAT_OTHER="${CAT_OTHER}\n- ${line}" ;;
*) CAT_OTHER="${CAT_OTHER}\n- ${line}" ;;
esac
done <<< "$COMMITS"
BODY=""
[ -n "$CAT_FEAT" ] && BODY="${BODY}## ✨ 新功能\n${CAT_FEAT}\n\n"
[ -n "$CAT_FIX" ] && BODY="${BODY}## 🐛 问题修复\n${CAT_FIX}\n\n"
[ -n "$CAT_PERF" ] && BODY="${BODY}## ⚡ 性能优化\n${CAT_PERF}\n\n"
[ -n "$CAT_REFACTOR" ] && BODY="${BODY}## ♻️ 重构\n${CAT_REFACTOR}\n\n"
[ -n "$CAT_I18N" ] && BODY="${BODY}## 🌐 国际化\n${CAT_I18N}\n\n"
[ -n "$CAT_OTHER" ] && BODY="${BODY}## 🔧 其他变更\n${CAT_OTHER}\n\n"
# 附加 compare 链接
if [ -n "$PREV_TAG" ]; then
REPO_URL="${{ github.server_url }}/${{ github.repository }}"
BODY="${BODY}---\n**完整变更**: [${PREV_TAG}...${TAG}](${REPO_URL}/compare/${PREV_TAG}...${TAG})\n"
fi
fi
# 写入到文件避免多行环境变量问题
printf '%b' "$BODY" > /tmp/changelog.md
echo "changelog_file=/tmp/changelog.md" >> "$GITHUB_OUTPUT"
- name: Create Release
uses: softprops/action-gh-release@v2
if: startsWith(github.ref, 'refs/tags/')
@@ -611,6 +688,6 @@ jobs:
files: release-assets/*
draft: true
make_latest: true
generate_release_notes: true
body_path: ${{ steps.changelog.outputs.changelog_file }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -1,403 +0,0 @@
name: Test Build All Platforms (Manual)
on:
workflow_dispatch:
inputs:
build_label:
description: "测试包标识(仅用于文件名)"
required: false
default: "test"
permissions:
contents: read
concurrency:
group: test-build-${{ github.ref }}
cancel-in-progress: false
jobs:
build:
name: Build ${{ matrix.platform }}
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
include:
- os: macos-latest
platform: darwin/amd64
os_name: MacOS
arch_name: Amd64
build_name: gonavi-test-darwin-amd64
wails_tags: ""
artifact_suffix: ""
build_optional_agents: true
linux_webkit: ""
- os: macos-latest
platform: darwin/arm64
os_name: MacOS
arch_name: Arm64
build_name: gonavi-test-darwin-arm64
wails_tags: ""
artifact_suffix: ""
build_optional_agents: true
linux_webkit: ""
- os: windows-latest
platform: windows/amd64
os_name: Windows
arch_name: Amd64
build_name: gonavi-test-windows-amd64
wails_tags: ""
artifact_suffix: ""
build_optional_agents: true
linux_webkit: ""
- os: windows-latest
platform: windows/arm64
os_name: Windows
arch_name: Arm64
build_name: gonavi-test-windows-arm64
wails_tags: ""
artifact_suffix: ""
build_optional_agents: true
linux_webkit: ""
- os: ubuntu-22.04
platform: linux/amd64
os_name: Linux
arch_name: Amd64
build_name: gonavi-test-linux-amd64
wails_tags: ""
artifact_suffix: ""
build_optional_agents: true
linux_webkit: "4.0"
- os: ubuntu-24.04
platform: linux/amd64
os_name: Linux
arch_name: Amd64
build_name: gonavi-test-linux-amd64-webkit41
wails_tags: "webkit2_41"
artifact_suffix: "-WebKit41"
build_optional_agents: false
linux_webkit: "4.1"
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: '1.24'
check-latest: true
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install UPX (macOS)
if: contains(matrix.platform, 'darwin')
run: |
brew install upx
upx --version
- name: Install UPX (Windows)
if: contains(matrix.platform, 'windows')
shell: pwsh
run: |
choco install upx --no-progress -y
$upxCmd = Get-Command upx -ErrorAction SilentlyContinue
if ($null -eq $upxCmd) {
Write-Error "❌ 未检测到 upx无法保证 Windows 测试产物经过压缩"
exit 1
}
& upx --version
- name: Install Linux Dependencies
if: contains(matrix.platform, 'linux')
run: |
sudo apt-get update
sudo apt-get install -y libgtk-3-dev
if [ "${{ matrix.linux_webkit }}" = "4.1" ]; then
sudo apt-get install -y libwebkit2gtk-4.1-dev libsoup-3.0-dev
else
sudo apt-get install -y libwebkit2gtk-4.0-dev
fi
sudo apt-get install -y upx-ucl || sudo apt-get install -y upx
upx --version
sudo apt-get install -y libfuse2 || sudo apt-get install -y libfuse2t64 || true
LINUXDEPLOY_URL="https://github.com/linuxdeploy/linuxdeploy/releases/download/continuous/linuxdeploy-x86_64.AppImage"
PLUGIN_URL="https://github.com/linuxdeploy/linuxdeploy-plugin-gtk/releases/download/continuous/linuxdeploy-plugin-gtk-x86_64.AppImage"
wget --retry-connrefused --waitretry=1 --read-timeout=20 --timeout=15 --tries=3 -O /tmp/linuxdeploy "$LINUXDEPLOY_URL" || {
echo "skip-appimage=true" >> "$GITHUB_ENV"
}
wget --retry-connrefused --waitretry=1 --read-timeout=20 --timeout=15 --tries=3 -O /tmp/linuxdeploy-plugin-gtk "$PLUGIN_URL" || {
echo "skip-appimage=true" >> "$GITHUB_ENV"
}
if [ "${skip-appimage:-false}" != "true" ]; then
chmod +x /tmp/linuxdeploy /tmp/linuxdeploy-plugin-gtk
fi
- name: Install Wails
run: go install github.com/wailsapp/wails/v2/cmd/wails@v2.11.0
- name: Setup MSYS2 Toolchain For DuckDB (Windows AMD64)
id: msys2_duckdb
if: ${{ matrix.build_optional_agents && matrix.platform == 'windows/amd64' }}
continue-on-error: true
uses: msys2/setup-msys2@v2
with:
msystem: UCRT64
update: true
install: >-
mingw-w64-ucrt-x86_64-gcc
- name: Configure DuckDB CGO Toolchain (Windows AMD64)
if: ${{ matrix.build_optional_agents && matrix.platform == 'windows/amd64' }}
shell: pwsh
run: |
function Find-MingwBin([string[]]$candidates) {
foreach ($bin in $candidates) {
if ([string]::IsNullOrWhiteSpace($bin)) {
continue
}
$gcc = Join-Path $bin 'gcc.exe'
$gxx = Join-Path $bin 'g++.exe'
if ((Test-Path $gcc) -and (Test-Path $gxx)) {
return $bin
}
}
return $null
}
$msys2Location = "${{ steps.msys2_duckdb.outputs['msys2-location'] }}"
$candidateBins = @()
if (-not [string]::IsNullOrWhiteSpace($msys2Location)) {
$candidateBins += Join-Path $msys2Location 'ucrt64\bin'
}
$candidateBins += @(
'C:\msys64\ucrt64\bin',
'D:\a\_temp\msys64\ucrt64\bin'
)
$candidateBins = @($candidateBins | Select-Object -Unique)
$mingwBin = Find-MingwBin $candidateBins
if (-not $mingwBin) {
Write-Error "❌ 未找到可用的 DuckDB UCRT64 编译器。"
exit 1
}
$gcc = Join-Path $mingwBin 'gcc.exe'
$gxx = Join-Path $mingwBin 'g++.exe'
"$mingwBin" | Out-File -FilePath $env:GITHUB_PATH -Append -Encoding utf8
"CC=$gcc" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
"CXX=$gxx" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
- name: Build App
shell: bash
run: |
set -euo pipefail
BUILD_LABEL="${{ inputs.build_label }}"
if [ -z "$BUILD_LABEL" ]; then
BUILD_LABEL="test"
fi
APP_VERSION="${BUILD_LABEL}-${GITHUB_RUN_NUMBER}"
if [ -n "${{ matrix.wails_tags }}" ]; then
wails build -platform "${{ matrix.platform }}" -clean -o "${{ matrix.build_name }}" -tags "${{ matrix.wails_tags }}" -ldflags "-s -w -X GoNavi-Wails/internal/app.AppVersion=${APP_VERSION}"
else
wails build -platform "${{ matrix.platform }}" -clean -o "${{ matrix.build_name }}" -ldflags "-s -w -X GoNavi-Wails/internal/app.AppVersion=${APP_VERSION}"
fi
- name: Build Optional Driver Agents
if: ${{ matrix.build_optional_agents }}
shell: bash
run: |
set -euo pipefail
TARGET_PLATFORM="${{ matrix.platform }}"
GOOS="${TARGET_PLATFORM%%/*}"
GOARCH="${TARGET_PLATFORM##*/}"
DRIVERS=(mariadb doris sphinx sqlserver sqlite duckdb dameng kingbase highgo vastbase mongodb tdengine clickhouse)
OUTDIR="drivers/${{ matrix.os_name }}"
mkdir -p "$OUTDIR"
for DRIVER in "${DRIVERS[@]}"; do
BUILD_DRIVER="$DRIVER"
if [ "$DRIVER" = "doris" ]; then
BUILD_DRIVER="diros"
fi
if [ "$DRIVER" = "duckdb" ] && [ "$GOOS" = "windows" ] && [ "$GOARCH" != "amd64" ]; then
echo "跳过 DuckDB driver: ${GOOS}/${GOARCH}"
continue
fi
TAG="gonavi_${BUILD_DRIVER}_driver"
OUTPUT="${DRIVER}-driver-agent-${GOOS}-${GOARCH}"
if [ "$GOOS" = "windows" ]; then
OUTPUT="${OUTPUT}.exe"
fi
OUTPUT_PATH="${OUTDIR}/${OUTPUT}"
if [ "$DRIVER" = "duckdb" ]; then
CGO_ENABLED=1 GOOS="$GOOS" GOARCH="$GOARCH" go build -tags "$TAG" -trimpath -ldflags "-s -w" -o "$OUTPUT_PATH" ./cmd/optional-driver-agent
else
CGO_ENABLED=0 GOOS="$GOOS" GOARCH="$GOARCH" go build -tags "$TAG" -trimpath -ldflags "-s -w" -o "$OUTPUT_PATH" ./cmd/optional-driver-agent
fi
done
- name: Package macOS
if: contains(matrix.platform, 'darwin')
shell: bash
run: |
set -euo pipefail
brew install create-dmg
LABEL="${{ inputs.build_label }}"
if [ -z "$LABEL" ]; then
LABEL="test"
fi
cd build/bin
APP_PATH=$(find . -maxdepth 1 -name "*.app" | head -n 1)
if [ -z "$APP_PATH" ]; then
echo "未找到 .app 应用包"
exit 1
fi
APP_NAME=$(basename "$APP_PATH")
APP_BIN=$(find "$APP_PATH/Contents/MacOS" -maxdepth 1 -type f | head -n 1)
if [ -z "$APP_BIN" ]; then
echo "未找到 macOS 应用主程序,无法进行 UPX 压缩"
exit 1
fi
BEFORE_BYTES=$(wc -c <"$APP_BIN" | tr -d '[:space:]')
echo "🗜️ 使用 UPX 压缩 macOS 可执行文件: $APP_BIN ..."
upx --best --lzma --force "$APP_BIN"
upx -t "$APP_BIN"
AFTER_BYTES=$(wc -c <"$APP_BIN" | tr -d '[:space:]')
if [ "$AFTER_BYTES" -lt "$BEFORE_BYTES" ]; then
SAVED_BYTES=$((BEFORE_BYTES - AFTER_BYTES))
awk -v b="$BEFORE_BYTES" -v a="$AFTER_BYTES" -v s="$SAVED_BYTES" 'BEGIN { printf "✅ macOS UPX 压缩完成:%.2fMB -> %.2fMB,减少 %.2fMB\n", b/1024/1024, a/1024/1024, s/1024/1024 }'
else
awk -v b="$BEFORE_BYTES" -v a="$AFTER_BYTES" 'BEGIN { printf " macOS UPX 压缩完成:%.2fMB -> %.2fMB\n", b/1024/1024, a/1024/1024 }'
fi
codesign --force --deep --sign - "$APP_NAME"
ZIP_NAME="GoNavi-${LABEL}-${{ matrix.os_name }}-${{ matrix.arch_name }}-run${GITHUB_RUN_NUMBER}.zip"
DMG_NAME="GoNavi-${LABEL}-${{ matrix.os_name }}-${{ matrix.arch_name }}-run${GITHUB_RUN_NUMBER}.dmg"
mkdir -p ../../artifacts
ditto -c -k --sequesterRsrc --keepParent "$APP_NAME" "../../artifacts/$ZIP_NAME"
create-dmg \
--volname "GoNavi Test Installer" \
--window-pos 200 120 \
--window-size 800 400 \
--icon-size 100 \
--icon "$APP_NAME" 200 190 \
--hide-extension "$APP_NAME" \
--app-drop-link 600 185 \
"$DMG_NAME" \
"$APP_NAME"
mv "$DMG_NAME" "../../artifacts/$DMG_NAME"
shasum -a 256 "../../artifacts/$ZIP_NAME" > "../../artifacts/$ZIP_NAME.sha256"
shasum -a 256 "../../artifacts/$DMG_NAME" > "../../artifacts/$DMG_NAME.sha256"
- name: Package Windows
if: contains(matrix.platform, 'windows')
shell: pwsh
run: |
$label = "${{ inputs.build_label }}"
if ([string]::IsNullOrWhiteSpace($label)) { $label = 'test' }
Set-Location build/bin
$target = "${{ matrix.build_name }}"
$finalExeName = "GoNavi-$label-${{ matrix.os_name }}-${{ matrix.arch_name }}-run$env:GITHUB_RUN_NUMBER.exe"
if (Test-Path "$target.exe") {
$finalExe = "$target.exe"
} elseif (Test-Path "$target") {
Rename-Item -Path "$target" -NewName "$target.exe"
$finalExe = "$target.exe"
} else {
Write-Error "未找到构建产物 '$target'"
exit 1
}
$upxCmd = Get-Command upx -ErrorAction SilentlyContinue
if ($null -eq $upxCmd) {
Write-Error "❌ 未找到 upx无法保证 Windows 测试产物经过压缩"
exit 1
}
$beforeBytes = (Get-Item -LiteralPath $finalExe).Length
Write-Host "🗜️ 使用 UPX 压缩 $finalExe ..."
& upx --best --lzma --force $finalExe | Out-Host
& upx -t $finalExe | Out-Host
$afterBytes = (Get-Item -LiteralPath $finalExe).Length
if ($afterBytes -lt $beforeBytes) {
$savedBytes = $beforeBytes - $afterBytes
Write-Host ("✅ UPX 压缩完成:{0:N2}MB -> {1:N2}MB减少 {2:N2}MB" -f ($beforeBytes / 1MB), ($afterBytes / 1MB), ($savedBytes / 1MB))
} else {
Write-Host (" UPX 压缩完成:{0:N2}MB -> {1:N2}MB" -f ($beforeBytes / 1MB), ($afterBytes / 1MB))
}
New-Item -ItemType Directory -Force -Path ..\..\artifacts | Out-Null
Copy-Item -LiteralPath $finalExe -Destination "..\..\artifacts\$finalExeName" -Force
Get-FileHash "..\..\artifacts\$finalExeName" -Algorithm SHA256 | ForEach-Object { "{0} *{1}" -f $_.Hash.ToLower(), (Split-Path $_.Path -Leaf) } | Out-File "..\..\artifacts\$finalExeName.sha256" -Encoding ascii
- name: Package Linux
if: contains(matrix.platform, 'linux')
shell: bash
run: |
set -euo pipefail
LABEL="${{ inputs.build_label }}"
if [ -z "$LABEL" ]; then
LABEL="test"
fi
cd build/bin
TARGET="${{ matrix.build_name }}"
TAR_NAME="GoNavi-${LABEL}-${{ matrix.os_name }}-${{ matrix.arch_name }}${{ matrix.artifact_suffix }}-run${GITHUB_RUN_NUMBER}.tar.gz"
APPIMAGE_NAME="GoNavi-${LABEL}-${{ matrix.os_name }}-${{ matrix.arch_name }}${{ matrix.artifact_suffix }}-run${GITHUB_RUN_NUMBER}.AppImage"
mkdir -p ../../artifacts
if [ ! -f "$TARGET" ]; then
echo "未找到构建产物 '$TARGET'"
exit 1
fi
chmod +x "$TARGET"
BEFORE_BYTES=$(wc -c <"$TARGET" | tr -d '[:space:]')
echo "🗜️ 使用 UPX 压缩 Linux 可执行文件: $TARGET ..."
upx --best --lzma --force "$TARGET"
upx -t "$TARGET"
AFTER_BYTES=$(wc -c <"$TARGET" | tr -d '[:space:]')
if [ "$AFTER_BYTES" -lt "$BEFORE_BYTES" ]; then
SAVED_BYTES=$((BEFORE_BYTES - AFTER_BYTES))
awk -v b="$BEFORE_BYTES" -v a="$AFTER_BYTES" -v s="$SAVED_BYTES" 'BEGIN { printf "✅ Linux UPX 压缩完成:%.2fMB -> %.2fMB,减少 %.2fMB\n", b/1024/1024, a/1024/1024, s/1024/1024 }'
else
awk -v b="$BEFORE_BYTES" -v a="$AFTER_BYTES" 'BEGIN { printf " Linux UPX 压缩完成:%.2fMB -> %.2fMB\n", b/1024/1024, a/1024/1024 }'
fi
tar -czvf "../../artifacts/$TAR_NAME" "$TARGET"
sha256sum "../../artifacts/$TAR_NAME" > "../../artifacts/$TAR_NAME.sha256"
if [ "${skip-appimage:-false}" = "true" ]; then
echo "跳过 AppImage 打包"
exit 0
fi
mkdir -p AppDir/usr/bin AppDir/usr/share/applications AppDir/usr/share/icons/hicolor/256x256/apps
cp "$TARGET" AppDir/usr/bin/gonavi
printf '%s\n' '[Desktop Entry]' 'Name=GoNavi' 'Exec=gonavi' 'Icon=gonavi' 'Type=Application' 'Categories=Development;Database;' 'Comment=Database Management Tool' > AppDir/usr/share/applications/gonavi.desktop
cp AppDir/usr/share/applications/gonavi.desktop AppDir/gonavi.desktop
if [ -f "../../build/appicon.png" ]; then
cp "../../build/appicon.png" AppDir/usr/share/icons/hicolor/256x256/apps/gonavi.png
cp "../../build/appicon.png" AppDir/gonavi.png
else
touch AppDir/gonavi.png
cp AppDir/gonavi.png AppDir/usr/share/icons/hicolor/256x256/apps/gonavi.png
fi
export DEPLOY_GTK_VERSION=3
/tmp/linuxdeploy --appdir AppDir --plugin gtk --output appimage || exit 0
mv GoNavi*.AppImage "$APPIMAGE_NAME" 2>/dev/null || exit 0
mv "$APPIMAGE_NAME" "../../artifacts/$APPIMAGE_NAME"
sha256sum "../../artifacts/$APPIMAGE_NAME" > "../../artifacts/$APPIMAGE_NAME.sha256"
- name: Upload Artifact
uses: actions/upload-artifact@v4
with:
name: test-build-${{ matrix.build_name }}-run${{ github.run_number }}
path: |
artifacts/*
drivers/**
if-no-files-found: error
retention-days: 7

View File

@@ -1,91 +0,0 @@
name: Test Build macOS (Manual)
on:
workflow_dispatch:
inputs:
build_label:
description: "测试包标识(仅用于文件名)"
required: false
default: "test"
push:
branches:
- feature/kingbase_opt
paths:
- ".github/workflows/test-macos-build.yml"
permissions:
contents: read
jobs:
build-macos:
name: Build macOS ${{ matrix.arch }}
runs-on: macos-latest
strategy:
fail-fast: false
matrix:
include:
- platform: darwin/amd64
arch: amd64
- platform: darwin/arm64
arch: arm64
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: "1.24.3"
check-latest: true
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: "20"
- name: Install Wails
run: go install github.com/wailsapp/wails/v2/cmd/wails@v2.11.0
- name: Build App
run: |
set -euo pipefail
OUTPUT_NAME="gonavi-test-${{ matrix.arch }}"
BUILD_LABEL="${{ inputs.build_label }}"
if [ -z "$BUILD_LABEL" ]; then
BUILD_LABEL="test"
fi
APP_VERSION="${BUILD_LABEL}-${GITHUB_RUN_NUMBER}"
wails build \
-platform "${{ matrix.platform }}" \
-clean \
-o "$OUTPUT_NAME" \
-ldflags "-s -w -X GoNavi-Wails/internal/app.AppVersion=${APP_VERSION}"
- name: Package Zip
run: |
set -euo pipefail
APP_PATH="build/bin/gonavi-test-${{ matrix.arch }}.app"
if [ ! -d "$APP_PATH" ]; then
APP_PATH=$(find build/bin -maxdepth 1 -name "*.app" | head -n 1 || true)
fi
if [ -z "$APP_PATH" ] || [ ! -d "$APP_PATH" ]; then
echo "未找到 .app 产物"
ls -la build/bin || true
exit 1
fi
LABEL="${{ inputs.build_label }}"
if [ -z "$LABEL" ]; then
LABEL="test"
fi
ZIP_NAME="GoNavi-${LABEL}-macos-${{ matrix.arch }}-run${GITHUB_RUN_NUMBER}.zip"
mkdir -p artifacts
ditto -c -k --sequesterRsrc --keepParent "$APP_PATH" "artifacts/$ZIP_NAME"
shasum -a 256 "artifacts/$ZIP_NAME" > "artifacts/$ZIP_NAME.sha256"
- name: Upload Artifact
uses: actions/upload-artifact@v4
with:
name: gonavi-macos-${{ matrix.arch }}-run${{ github.run_number }}
path: artifacts/*
if-no-files-found: error

4
.gitignore vendored
View File

@@ -17,8 +17,10 @@ dist/
GoNavi-Wails
GoNavi-Wails.exe
.ace-tool/
.superpowers/
.claude/
tmpclaude-*
.gemini/
**/tmpclaude-*
CLAUDE.md
**/CLAUDE.md

View File

@@ -108,9 +108,9 @@ if [ $? -eq 0 ]; then
APP_BIN_PATH=$(find "$DIST_DIR/$APP_DEST_NAME/Contents/MacOS" -maxdepth 1 -type f -print -quit)
if [ -n "$APP_BIN_PATH" ] && [ -f "$APP_BIN_PATH" ]; then
try_compress_binary_with_upx "$APP_BIN_PATH" "macOS arm64 应用主程序"
echo -e "${YELLOW} ⚠️ macOS arm64 不再执行 UPX 压缩,保留原始主程序。${NC}"
else
echo -e "${RED} ❌ 未找到 macOS arm64 主程序文件,无法执行 UPX 压缩${NC}"
echo -e "${RED} ❌ 未找到 macOS arm64 主程序文件。${NC}"
exit 1
fi
@@ -215,9 +215,9 @@ if [ $? -eq 0 ]; then
APP_BIN_PATH=$(find "$DIST_DIR/$APP_DEST_NAME/Contents/MacOS" -maxdepth 1 -type f -print -quit)
if [ -n "$APP_BIN_PATH" ] && [ -f "$APP_BIN_PATH" ]; then
try_compress_binary_with_upx "$APP_BIN_PATH" "macOS amd64 应用主程序"
echo -e "${YELLOW} ⚠️ macOS amd64 不再执行 UPX 压缩,保留原始主程序。${NC}"
else
echo -e "${RED} ❌ 未找到 macOS amd64 主程序文件,无法执行 UPX 压缩${NC}"
echo -e "${RED} ❌ 未找到 macOS amd64 主程序文件。${NC}"
exit 1
fi
@@ -327,7 +327,7 @@ if command -v aarch64-w64-mingw32-gcc &> /dev/null; then
if [ $? -eq 0 ]; then
TARGET_EXE="$DIST_DIR/${APP_NAME}-${VERSION}-windows-arm64.exe"
mv "$BUILD_BIN_DIR/${DEFAULT_BINARY_NAME}.exe" "$TARGET_EXE"
try_compress_binary_with_upx "$TARGET_EXE" "Windows arm64 可执行文件"
echo -e "${YELLOW} ⚠️ 当前 UPX 不支持 win64/arm64跳过 Windows arm64 压缩。${NC}"
echo " ✅ 已生成 ${APP_NAME}-${VERSION}-windows-arm64.exe"
else
echo -e "${RED} ❌ Windows arm64 构建失败。${NC}"

View File

@@ -0,0 +1,182 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>AI UI Brainstorming Prototypes</title>
<!-- React & ReactDOM -->
<script crossorigin src="https://unpkg.com/react@18/umd/react.development.js"></script>
<script crossorigin src="https://unpkg.com/react-dom@18/umd/react-dom.development.js"></script>
<!-- Babel -->
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
<!-- Ant Design -->
<script src="https://unpkg.com/dayjs/dayjs.min.js"></script>
<script src="https://unpkg.com/antd/dist/antd.min.js"></script>
<link rel="stylesheet" href="https://unpkg.com/antd/dist/reset.css" />
<!-- Icons -->
<script src="https://unpkg.com/@ant-design/icons/dist/index.umd.js"></script>
<style>
body { padding: 40px; background: #f0f2f5; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial; }
.prototype-container { display: flex; gap: 40px; }
.prototype-column { flex: 1; max-width: 600px; background: white; border-radius: 12px; box-shadow: 0 4px 12px rgba(0,0,0,0.05); overflow: hidden; }
.prototype-header { padding: 16px 24px; border-bottom: 1px solid #f0f0f0; background: #fafafa; font-weight: bold; }
.prototype-body { padding: 24px; }
/* Default App Theme Colors (Light Mode) */
:root {
--gn-border: rgba(16,24,40,0.08);
--gn-bg: rgba(255,255,255,0.84);
--gn-text: #162033;
--gn-muted: rgba(16,24,40,0.55);
--gn-primary: #1677ff;
--gn-primary-bg: rgba(24,144,255,0.1);
}
/* V1 Styles: Professional List */
.v1-list-item {
display: flex; align-items: center; justify-content: space-between;
padding: 12px 16px; margin-bottom: 8px; border-radius: 8px;
border: 1px solid transparent; cursor: pointer; transition: all 0.2s;
}
.v1-list-item:hover { background: #f5f5f5; }
.v1-list-item.selected {
background: var(--gn-primary-bg); border-color: var(--gn-primary);
}
/* V2 Styles: Refined Cards (ConnectionModal Style) */
.v2-card-grid {
display: grid; grid-template-columns: 1fr 1fr; gap: 12px;
}
.v2-card {
padding: 16px; border-radius: 12px; border: 1px solid var(--gn-border);
cursor: pointer; transition: all 0.2s; background: white;
box-shadow: inset 0 0 0 1px rgba(16,24,40,0.01);
}
.v2-card:hover { border-color: #d9d9d9; background: #fafafa; }
.v2-card.selected {
border-color: var(--gn-primary); box-shadow: 0 0 0 1px var(--gn-primary) inset;
}
.section-title { font-size: 13px; font-weight: 600; color: var(--gn-muted); margin-bottom: 12px; text-transform: uppercase; letter-spacing: 0.5px; }
</style>
</head>
<body>
<div id="root"></div>
<script type="text/babel">
const { useState } = React;
const { Input, Slider, Select, Button, Form, ConfigProvider } = antd;
const { ThunderboltOutlined, CloudOutlined, ExperimentOutlined, AppstoreOutlined, SettingOutlined, LinkOutlined, KeyOutlined } = icons;
const PROVIDERS = [
{ key: 'openai', label: 'OpenAI', icon: <ThunderboltOutlined />, desc: 'GPT-4o / o1' },
{ key: 'deepseek', label: 'DeepSeek', icon: <ThunderboltOutlined />, desc: 'V3 / R1' },
{ key: 'anthropic', label: 'Claude', icon: <ExperimentOutlined />, desc: 'Sonnet 3.5' },
{ key: 'custom', label: '自定义', icon: <AppstoreOutlined />, desc: '通用 API' },
];
const V1ListDesign = () => {
const [selected, setSelected] = useState('openai');
return (
<div className="prototype-column">
<div className="prototype-header">方案一IDE 专业列表风格 (更克制无彩色渐变)</div>
<div className="prototype-body">
<div className="section-title">提供商选择</div>
<div style={{ marginBottom: 24, padding: 8, background: '#fafafa', borderRadius: 10, border: '1px solid #f0f0f0' }}>
{PROVIDERS.map(p => (
<div key={p.key} className={`v1-list-item ${selected === p.key ? 'selected' : ''}`} onClick={() => setSelected(p.key)}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<div style={{
width: 32, height: 32, borderRadius: 6, display: 'grid', placeItems: 'center',
background: selected === p.key ? '#1677ff' : '#e6f4ff',
color: selected === p.key ? '#fff' : '#1677ff', fontSize: 16
}}>
{p.icon}
</div>
<div>
<div style={{ fontWeight: 500, color: 'var(--gn-text)', fontSize: 14 }}>{p.label}</div>
<div style={{ fontSize: 12, color: 'var(--gn-muted)' }}>{p.desc}</div>
</div>
</div>
<div style={{ width: 16, height: 16, borderRadius: '50%', border: `2px solid ${selected === p.key ? 'var(--gn-primary)' : '#d9d9d9'}`, padding: 2 }}>
{selected === p.key && <div style={{ width: '100%', height: '100%', background: 'var(--gn-primary)', borderRadius: '50%' }} />}
</div>
</div>
))}
</div>
<div className="section-title">连接配置 (紧凑表单)</div>
<Form layout="vertical" size="middle">
<Form.Item label="API Endpoint">
<Input placeholder="https://api.openai.com/v1" prefix={<LinkOutlined style={{color: 'var(--gn-muted)'}}/>} />
</Form.Item>
<Form.Item label="API Key">
<Input.Password placeholder="sk-..." prefix={<KeyOutlined style={{color: 'var(--gn-muted)'}}/>} />
</Form.Item>
<Form.Item label="Model Name">
<Input placeholder="gpt-4o" prefix={<AppstoreOutlined style={{color: 'var(--gn-muted)'}}/>} />
</Form.Item>
</Form>
</div>
</div>
);
};
const V2CardDesign = () => {
const [selected, setSelected] = useState('openai');
return (
<div className="prototype-column">
<div className="prototype-header">方案二GoNavi 统一卡片风格 (类似 ConnectionModal)</div>
<div className="prototype-body">
<div className="section-title">选择服务提供商</div>
<div className="v2-card-grid" style={{ marginBottom: 24 }}>
{PROVIDERS.map(p => (
<div key={p.key} className={`v2-card ${selected === p.key ? 'selected' : ''}`} onClick={() => setSelected(p.key)}>
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 12 }}>
<div style={{ color: selected === p.key ? 'var(--gn-primary)' : 'var(--gn-muted)', fontSize: 20, marginTop: 2 }}>
{p.icon}
</div>
<div>
<div style={{ fontWeight: 600, color: 'var(--gn-text)', fontSize: 14 }}>{p.label}</div>
<div style={{ fontSize: 12, color: 'var(--gn-muted)', marginTop: 4 }}>{p.desc}</div>
</div>
</div>
</div>
))}
</div>
<div style={{ padding: 20, borderRadius: 12, border: '1px solid var(--gn-border)', background: '#fafafa' }}>
<div className="section-title" style={{ marginTop: 0 }}>认证与设置</div>
<Form layout="horizontal" labelCol={{ span: 6 }} wrapperCol={{ span: 18 }} size="middle">
<Form.Item label="Endpoint" style={{ marginBottom: 16 }}>
<Input placeholder="https://api..." />
</Form.Item>
<Form.Item label="API Key" style={{ marginBottom: 16 }}>
<Input.Password placeholder="sk-..." />
</Form.Item>
<Form.Item label="模型名称" style={{ marginBottom: 0 }}>
<Input placeholder="例如 gpt-4o" />
</Form.Item>
</Form>
</div>
</div>
</div>
);
};
const App = () => (
<ConfigProvider theme={{ token: { colorPrimary: '#1677ff', borderRadius: 6 } }}>
<div style={{ marginBottom: 24 }}>
<h1 style={{ fontSize: 24, margin: 0 }}>AI 设置 UI 重构探讨</h1>
<p style={{ color: 'var(--gn-muted)' }}>当前设计带有太多渐变和鲜艳色彩"AI 味"以下是遵循 GoNavi 本身设计规范克制专业的两个方案</p>
</div>
<div className="prototype-container">
<V1ListDesign />
<V2CardDesign />
</div>
</ConfigProvider>
);
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(<App />);
</script>
</body>
</html>

File diff suppressed because it is too large Load Diff

View File

@@ -6,7 +6,8 @@
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
"preview": "vite preview",
"test": "vitest run"
},
"dependencies": {
"@ant-design/icons": "^5.2.6",
@@ -15,11 +16,16 @@
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@monaco-editor/react": "^4.6.0",
"@types/react-syntax-highlighter": "^15.5.13",
"antd": "^5.12.0",
"clsx": "^2.1.0",
"mermaid": "^11.13.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-markdown": "^10.1.0",
"react-resizable": "^3.1.3",
"react-syntax-highlighter": "^16.1.1",
"remark-gfm": "^4.0.1",
"sql-formatter": "^15.7.0",
"uuid": "^9.0.1",
"zustand": "^4.4.7"
@@ -31,6 +37,7 @@
"@types/uuid": "^9.0.7",
"@vitejs/plugin-react": "^4.2.1",
"typescript": "^5.2.2",
"vite": "^5.0.8"
"vite": "^5.0.8",
"vitest": "^3.2.4"
}
}
}

View File

@@ -1 +1 @@
5b8157374dae5f9340e31b2d0bd2c00e
dcb87159cf0f1f6f750d1c4870911d3f

View File

@@ -0,0 +1 @@
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>ClickHouse</title><path d="M21.333 10H24v4h-2.667ZM16 1.335h2.667v21.33H16Zm-5.333 0h2.666v21.33h-2.666ZM0 22.665V1.335h2.667v21.33zm5.333-21.33H8v21.33H5.333Z"/></svg>

After

Width:  |  Height:  |  Size: 246 B

View File

@@ -0,0 +1 @@
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Apache Doris</title><path d="M8.666.0001c-.5355-.004-1.068.1072-1.5241.3384-.207.1048-.5749.3802-.8177.6118-1.0278.9803-1.2876 2.5138-.6553 3.8679.205.439.5068.7694 2.8476 3.1166 2.4527 2.4594 2.6352 2.6255 2.8852 2.6258.2446.0003.3647-.099 1.4408-1.19.9367-.9496 1.2306-1.2992 1.4536-1.7286.5966-1.149.6487-2.0513.174-3.014-.2264-.459-.4816-.7514-1.9012-2.176-.9018-.9052-1.7907-1.7496-1.9751-1.8765C10.0488.2005 9.3548.0052 8.666 0ZM3.5518 5.5737c-.2176.0031-.6097.085-.6097.3285v12.0904l.1642.175c.1123.1194.2498.1748.4342.1748.2545 0 .4436-.1738 3.349-3.0786 2.6868-2.6862 3.079-2.909 3.0791-3.305.0002-.3961-.3924-.6194-3.0784-3.306-2.8612-2.8619-3.0968-3.079-3.3384-3.079Zm13.0967.861c-.0481.0184-.112.1636-.1418.3225-.0756.403-.3719 1.109-.6572 1.5663-.1407.2253-2.2392 2.3955-5.049 5.2212-2.7513 2.7667-4.9104 4.9985-5.0468 5.2165-.4552.7275-.5967 1.3905-.4684 2.1964.222 1.3947 1.3263 2.6812 2.5486 2.9693.4667.11 1.618.0927 2.0329-.0305.2084-.062.526-.2112.7055-.3318.5023-.3373 9.341-9.0562 9.6463-9.5154.449-.6753.8356-1.0716.8395-1.9762-.0056-.5935-.1305-1.1138-1.0715-2.306-.5094-.6523-3.2341-3.3723-3.338-3.3324Z"/></svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -0,0 +1 @@
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>DuckDB</title><path d="M12 0C5.363 0 0 5.363 0 12s5.363 12 12 12 12-5.363 12-12S18.637 0 12 0zM9.502 7.03a4.974 4.974 0 0 1 4.97 4.97 4.974 4.974 0 0 1-4.97 4.97A4.974 4.974 0 0 1 4.532 12a4.974 4.974 0 0 1 4.97-4.97zm6.563 3.183h2.351c.98 0 1.787.782 1.787 1.762s-.807 1.789-1.787 1.789h-2.351v-3.551z"/></svg>

After

Width:  |  Height:  |  Size: 389 B

View File

@@ -0,0 +1 @@
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>MariaDB</title><path d="M23.157 4.412c-.676.284-.79.31-1.673.372-.65.045-.757.057-1.212.209-.75.246-1.395.75-2.02 1.59-.296.398-1.249 1.913-1.249 1.988 0 .057-.65.998-.915 1.32-.574.713-1.08 1.079-2.14 1.59-.77.36-1.224.524-4.102 1.477-1.073.353-2.133.738-2.367.864-.852.449-1.515 1.036-2.203 1.938-1.003 1.32-.972 1.313-3.042.947a12.264 12.264 0 00-.675-.063c-.644-.05-1.023.044-1.332.334L0 17.193l.177.088c.094.05.353.234.561.398.215.17.461.347.55.391.088.044.17.088.183.101.012.013-.089.17-.228.353-.435.581-.593.871-.574 1.048.019.164.032.17.43.17.517-.006.826-.056 1.261-.208.65-.233 2.058-.94 2.784-1.4.776-.5 1.717-.998 1.956-1.042.082-.02.354-.07.594-.114.58-.107 1.464-.095 2.587.05.108.013.373.045.6.064.227.025.43.057.454.076.026.012.474.037.998.056.934.026 1.104.007 1.3-.189.126-.133.385-.631.498-.985.209-.643.417-.921.366-.492-.113.966-.322 1.692-.713 2.411-.259.499-.663 1.092-.934 1.395-.322.347-.315.36.088.315.619-.063 1.471-.397 2.096-.82.827-.562 1.647-1.691 2.19-3.03.107-.27.22-.22.183.083-.013.094-.038.315-.057.498l-.031.328.353-.202c.833-.48 1.414-1.262 2.127-2.884.227-.518.877-2.922 1.073-3.976a9.64 9.64 0 01.271-1.042c.127-.429.196-.555.48-.858.183-.19.625-.555.978-.808.72-.505.953-.75 1.187-1.205.208-.417.284-1.13.132-1.357-.132-.202-.284-.196-.763.006Z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -0,0 +1 @@
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>MongoDB</title><path d="M17.193 9.555c-1.264-5.58-4.252-7.414-4.573-8.115-.28-.394-.53-.954-.735-1.44-.036.495-.055.685-.523 1.184-.723.566-4.438 3.682-4.74 10.02-.282 5.912 4.27 9.435 4.888 9.884l.07.05A73.49 73.49 0 0111.91 24h.481c.114-1.032.284-2.056.51-3.07.417-.296.604-.463.85-.693a11.342 11.342 0 003.639-8.464c.01-.814-.103-1.662-.197-2.218zm-5.336 8.195s0-8.291.275-8.29c.213 0 .49 10.695.49 10.695-.381-.045-.765-1.76-.765-2.405z"/></svg>

After

Width:  |  Height:  |  Size: 527 B

View File

@@ -0,0 +1 @@
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>MySQL</title><path d="M16.405 5.501c-.115 0-.193.014-.274.033v.013h.014c.054.104.146.18.214.273.054.107.1.214.154.32l.014-.015c.094-.066.14-.172.14-.333-.04-.047-.046-.094-.08-.14-.04-.067-.126-.1-.18-.153zM5.77 18.695h-.927a50.854 50.854 0 00-.27-4.41h-.008l-1.41 4.41H2.45l-1.4-4.41h-.01a72.892 72.892 0 00-.195 4.41H0c.055-1.966.192-3.81.41-5.53h1.15l1.335 4.064h.008l1.347-4.064h1.095c.242 2.015.384 3.86.428 5.53zm4.017-4.08c-.378 2.045-.876 3.533-1.492 4.46-.482.716-1.01 1.073-1.583 1.073-.153 0-.34-.046-.566-.138v-.494c.11.017.24.026.386.026.268 0 .483-.075.647-.222.197-.18.295-.382.295-.605 0-.155-.077-.47-.23-.944L6.23 14.615h.91l.727 2.36c.164.536.233.91.205 1.123.4-1.064.678-2.227.835-3.483zm12.325 4.08h-2.63v-5.53h.885v4.85h1.745zm-3.32.135l-1.016-.5c.09-.076.177-.158.255-.25.433-.506.648-1.258.648-2.253 0-1.83-.718-2.746-2.155-2.746-.704 0-1.254.232-1.65.697-.43.508-.646 1.256-.646 2.245 0 .972.19 1.686.574 2.14.35.41.877.615 1.583.615.264 0 .506-.033.725-.098l1.325.772.36-.622zM15.5 17.588c-.225-.36-.337-.94-.337-1.736 0-1.393.424-2.09 1.27-2.09.443 0 .77.167.977.5.224.362.336.936.336 1.723 0 1.404-.424 2.108-1.27 2.108-.445 0-.77-.167-.978-.5zm-1.658-.425c0 .47-.172.856-.516 1.156-.344.3-.803.45-1.384.45-.543 0-1.064-.172-1.573-.515l.237-.476c.438.22.833.328 1.19.328.332 0 .593-.073.783-.22a.754.754 0 00.3-.615c0-.33-.23-.61-.648-.845-.388-.213-1.163-.657-1.163-.657-.422-.307-.632-.636-.632-1.177 0-.45.157-.81.47-1.085.315-.278.72-.415 1.22-.415.512 0 .98.136 1.4.41l-.213.476a2.726 2.726 0 00-1.064-.23c-.283 0-.502.068-.654.206a.685.685 0 00-.248.524c0 .328.234.61.666.85.393.215 1.187.67 1.187.67.433.305.648.63.648 1.168zm9.382-5.852c-.535-.014-.95.04-1.297.188-.1.04-.26.04-.274.167.055.053.063.14.11.214.08.134.218.313.346.407.14.11.28.216.427.31.26.16.555.255.81.416.145.094.293.213.44.313.073.05.12.14.214.172v-.02c-.046-.06-.06-.147-.105-.214-.067-.067-.134-.127-.2-.193a3.223 3.223 0 00-.695-.675c-.214-.146-.682-.35-.77-.595l-.013-.014c.146-.013.32-.066.46-.106.227-.06.435-.047.67-.106.106-.027.213-.06.32-.094v-.06c-.12-.12-.21-.283-.334-.395a8.867 8.867 0 00-1.104-.823c-.21-.134-.476-.22-.697-.334-.08-.04-.214-.06-.26-.127-.12-.146-.19-.34-.275-.514a17.69 17.69 0 01-.547-1.163c-.12-.262-.193-.523-.34-.763-.69-1.137-1.437-1.826-2.586-2.5-.247-.14-.543-.2-.856-.274-.167-.008-.334-.02-.5-.027-.11-.047-.216-.174-.31-.235-.38-.24-1.364-.76-1.644-.072-.18.434.267.862.422 1.082.115.153.26.328.34.5.047.116.06.235.107.356.106.294.207.622.347.897.073.14.153.287.247.413.054.073.146.107.167.227-.094.136-.1.334-.154.5-.24.757-.146 1.693.194 2.25.107.166.362.534.703.393.3-.12.234-.5.32-.835.02-.08.007-.133.048-.187v.015c.094.188.188.367.274.555.206.328.566.668.867.895.16.12.287.328.487.402v-.02h-.015c-.043-.058-.1-.086-.154-.133a3.445 3.445 0 01-.35-.4 8.76 8.76 0 01-.747-1.218c-.11-.21-.202-.436-.29-.643-.04-.08-.04-.2-.107-.24-.1.146-.247.273-.32.453-.127.288-.14.642-.188 1.01-.027.007-.014 0-.027.014-.214-.052-.287-.274-.367-.46-.2-.475-.233-1.238-.06-1.785.047-.14.247-.582.167-.716-.042-.127-.174-.2-.247-.303a2.478 2.478 0 01-.24-.427c-.16-.374-.24-.788-.414-1.162-.08-.173-.22-.354-.334-.513-.127-.18-.267-.307-.368-.52-.033-.073-.08-.194-.027-.274.014-.054.042-.075.094-.09.088-.072.335.022.422.062.247.1.455.194.662.334.094.066.195.193.315.226h.14c.214.047.455.014.655.073.355.114.675.28.962.46a5.953 5.953 0 012.085 2.286c.08.154.115.295.188.455.14.33.313.663.455.982.14.315.275.636.476.897.1.14.502.213.682.286.133.06.34.115.46.188.23.14.454.3.67.454.11.076.443.243.463.378z"/></svg>

After

Width:  |  Height:  |  Size: 3.5 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 5.1 KiB

View File

@@ -0,0 +1 @@
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Redis</title><path d="M22.71 13.145c-1.66 2.092-3.452 4.483-7.038 4.483-3.203 0-4.397-2.825-4.48-5.12.701 1.484 2.073 2.685 4.214 2.63 4.117-.133 6.94-3.852 6.94-7.239 0-4.05-3.022-6.972-8.268-6.972-3.752 0-8.4 1.428-11.455 3.685C2.59 6.937 3.885 9.958 4.35 9.626c2.648-1.904 4.748-3.13 6.784-3.744C8.12 9.244.886 17.05 0 18.425c.1 1.261 1.66 4.648 2.424 4.648.232 0 .431-.133.664-.365a100.49 100.49 0 0 0 5.54-6.765c.222 3.104 1.748 6.898 6.014 6.898 3.819 0 7.604-2.756 9.33-8.965.2-.764-.73-1.361-1.261-.73zm-4.349-5.013c0 1.959-1.926 2.922-3.685 2.922-.941 0-1.664-.247-2.235-.568 1.051-1.592 2.092-3.225 3.21-4.973 1.972.334 2.71 1.43 2.71 2.619z"/></svg>

After

Width:  |  Height:  |  Size: 738 B

View File

@@ -0,0 +1 @@
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Sphinx</title><path d="M16.284 19.861c0-.654.177-1.834.393-2.623.499-1.822.774-4.079.497-4.079-.116 0-.959.762-1.873 1.694-3.472 3.54-7.197 5.543-10.312 5.543-1.778 0-2.987-.45-4.154-1.545C.128 18.186 0 17.858 0 16.703c0-1.188.117-1.468.909-2.175.718-.642 1.171-.813 2.157-.813.76.171 1.21.16 1.457.461.251.296.338 1.265.035 1.832-.162.303-.585.491-1.105.491-.49 0-.77-.116-.669-.278.315-.511-.135-.857-.713-.548-.699.374-.711 1.698-.021 2.322.969.878 3.65 1.208 5.262.648 1.743-.605 4.022-2.061 5.841-3.732l1.6-1.469-2.088-.013c-2.186-.012-3.608-.273-8.211-1.506-1.531-.41-3.003-.765-3.271-.789-.304-.026-.503-.274-.487-.656.027-.646.378-1.127.793-1.308.249-.109 1.977-.274 3.809-.761 7.136-1.898 7.569-1.629 12.323-.426 1.553.393 3.351.821 4.147.835 1.227.022 1.493.124 1.74.666.16.351.291.686.291.745 0 .058-.695.424-1.545.813-3.12 1.428-4.104 2.185-3.088 3.635.421.602.412.666-.14 1.052-.323.227-.59.687-.593 1.022-.009.908-.583 2.856-1.417 3.624l-.732.675v-1.189Zm1.594-8.328c1.242-.346 1.994-.738 3.539-1.562-1.272-.372-4.462-.895-4.462-.895-2.354-.472-2.108-.448-2.214.071a3.475 3.475 0 0 1-.45 1.105c-.541.848-2.521 1.026-3.656.483-.356-.171-.714-.821-.709-1.283.007-.65-.362-.801-.598-.714-.191.07-.813.079-2.179.448-4.514 1.217-5.132 1.078-2.189 1.495.353.05 2.223.572 3.136.815 2.239.597 2.658.641 5.556.581 2.015-.042 2.858-.163 4.226-.544ZM.732 6.258c.056-.577.088-.702 1.692-1.025.919-.185 3.185-.785 5.036-1.333 4.254-1.26 5.462-1.263 9.873-.026 1.904.535 4.037.973 4.74.975 1.097.002 1.668.487 1.668.487.505 1.16.412 1.24-1.558 1.24-1.374 0-2.558-.232-4.385-.857-1.389-.476-3.369-.923-4.451-1.004-1.974-.149-1.971-.15-8.072 1.529-1.072.295-2.553.624-3.29.732l-1.342.196.089-.914Z"/></svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@@ -0,0 +1 @@
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>SQLite</title><path d="M21.678.521c-1.032-.92-2.28-.55-3.513.544a8.71 8.71 0 0 0-.547.535c-2.109 2.237-4.066 6.38-4.674 9.544.237.48.422 1.093.544 1.561a13.044 13.044 0 0 1 .164.703s-.019-.071-.096-.296l-.05-.146a1.689 1.689 0 0 0-.033-.08c-.138-.32-.518-.995-.686-1.289-.143.423-.27.818-.376 1.176.484.884.778 2.4.778 2.4s-.025-.099-.147-.442c-.107-.303-.644-1.244-.772-1.464-.217.804-.304 1.346-.226 1.478.152.256.296.698.422 1.186.286 1.1.485 2.44.485 2.44l.017.224a22.41 22.41 0 0 0 .056 2.748c.095 1.146.273 2.13.5 2.657l.155-.084c-.334-1.038-.47-2.399-.41-3.967.09-2.398.642-5.29 1.661-8.304 1.723-4.55 4.113-8.201 6.3-9.945-1.993 1.8-4.692 7.63-5.5 9.788-.904 2.416-1.545 4.684-1.931 6.857.666-2.037 2.821-2.912 2.821-2.912s1.057-1.304 2.292-3.166c-.74.169-1.955.458-2.362.629-.6.251-.762.337-.762.337s1.945-1.184 3.613-1.72C21.695 7.9 24.195 2.767 21.678.521m-18.573.543A1.842 1.842 0 0 0 1.27 2.9v16.608a1.84 1.84 0 0 0 1.835 1.834h9.418a22.953 22.953 0 0 1-.052-2.707c-.006-.062-.011-.141-.016-.2a27.01 27.01 0 0 0-.473-2.378c-.121-.47-.275-.898-.369-1.057-.116-.197-.098-.31-.097-.432 0-.12.015-.245.037-.386a9.98 9.98 0 0 1 .234-1.045l.217-.028c-.017-.035-.014-.065-.031-.097l-.041-.381a32.8 32.8 0 0 1 .382-1.194l.2-.019c-.008-.016-.01-.038-.018-.053l-.043-.316c.63-3.28 2.587-7.443 4.8-9.791.066-.069.133-.128.198-.194Z"/></svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@@ -37,6 +37,91 @@ body, #root {
padding-right: 8px;
}
.redis-viewer-workbench .ant-tree {
background: transparent;
}
.redis-viewer-workbench .ant-tree .ant-tree-list-holder-inner,
.redis-viewer-workbench .ant-tree .ant-tree-list-holder-inner .ant-tree-treenode {
width: 100% !important;
}
.redis-viewer-workbench .ant-tree .ant-tree-node-content-wrapper {
min-height: 36px;
border-radius: 14px;
transition: background-color 0.2s ease, border-color 0.2s ease, color 0.2s ease;
background: transparent !important;
border: none !important;
box-shadow: none !important;
outline: none !important;
flex: 1 1 auto;
min-width: 0;
width: auto !important;
}
.redis-viewer-workbench .ant-tree .ant-tree-node-content-wrapper:hover,
.redis-viewer-workbench .ant-tree .ant-tree-node-content-wrapper:active,
.redis-viewer-workbench .ant-tree .ant-tree-node-content-wrapper:focus,
.redis-viewer-workbench .ant-tree .ant-tree-node-content-wrapper:focus-visible,
.redis-viewer-workbench .ant-tree .ant-tree-node-content-wrapper.ant-tree-node-selected,
.redis-viewer-workbench .ant-tree .ant-tree-node-content-wrapper.ant-tree-node-selected:hover {
background: transparent !important;
border-color: transparent !important;
box-shadow: none !important;
outline: none !important;
}
.redis-viewer-workbench .ant-tree .ant-tree-treenode {
padding: 2px 0;
width: 100%;
border-radius: 14px;
transition: background-color 0.2s ease, border-color 0.2s ease, color 0.2s ease;
border: none;
align-items: center;
position: relative;
z-index: 0;
display: flex !important;
box-sizing: border-box;
}
.redis-viewer-workbench .ant-tree .ant-tree-switcher {
width: 0 !important;
min-width: 0 !important;
margin-inline-end: 0 !important;
padding: 0 !important;
overflow: hidden !important;
background: transparent !important;
}
.redis-viewer-workbench .ant-tree .ant-tree-switcher:hover,
.redis-viewer-workbench .ant-tree .ant-tree-switcher:active,
.redis-viewer-workbench .ant-tree .ant-tree-switcher:focus {
background: transparent !important;
}
.redis-viewer-workbench .redis-tree-expander-button:hover,
.redis-viewer-workbench .redis-tree-expander-button:focus-visible {
background: transparent !important;
outline: none;
}
.redis-viewer-workbench .ant-radio-group .ant-radio-button-wrapper {
border-radius: 10px;
margin-inline-end: 6px;
}
.redis-viewer-workbench .ant-radio-group .ant-radio-button-wrapper:last-child {
margin-inline-end: 0;
}
.redis-viewer-workbench .ant-table {
background: transparent;
}
.redis-viewer-workbench .ant-table-wrapper .ant-table-thead > tr > th {
font-weight: 700;
}
/* Scrollbar styling for dark mode */
body[data-theme='dark'] ::-webkit-scrollbar {
width: 10px;
@@ -97,6 +182,16 @@ body[data-theme='dark'] .ant-tree .ant-tree-node-content-wrapper.ant-tree-node-s
color: rgba(255, 236, 179, 0.98) !important;
}
body[data-theme='dark'] .redis-viewer-workbench .ant-tree .ant-tree-treenode:hover {
background: rgba(255, 255, 255, 0.05) !important;
}
body[data-theme='dark'] .redis-viewer-workbench .ant-tree .ant-tree-treenode.ant-tree-treenode-selected,
body[data-theme='dark'] .redis-viewer-workbench .ant-tree .ant-tree-treenode.ant-tree-treenode-selected:hover {
background: linear-gradient(90deg, rgba(246, 196, 83, 0.22), rgba(246, 196, 83, 0.08)) !important;
border: 1px solid rgba(246, 196, 83, 0.24) !important;
}
body[data-theme='dark'] .ant-checkbox-checked .ant-checkbox-inner {
background-color: #f6c453 !important;
border-color: #f6c453 !important;
@@ -135,6 +230,41 @@ body[data-theme='dark'] .ant-table-tbody .ant-table-row.ant-table-row-selected:h
background: rgba(246, 196, 83, 0.26) !important;
}
body[data-theme='dark'] .redis-viewer-workbench .ant-radio-button-wrapper {
background: rgba(255, 255, 255, 0.04);
border-color: rgba(255, 255, 255, 0.08);
color: rgba(230, 234, 242, 0.9);
}
body[data-theme='dark'] .redis-viewer-workbench .ant-radio-button-wrapper-checked:not(.ant-radio-button-wrapper-disabled) {
background: rgba(246, 196, 83, 0.16);
border-color: rgba(246, 196, 83, 0.3);
color: #f6c453;
}
body[data-theme='light'] .redis-viewer-workbench .ant-tree .ant-tree-treenode:hover {
background: rgba(15, 23, 42, 0.04) !important;
}
body[data-theme='light'] .redis-viewer-workbench .ant-tree .ant-tree-treenode.ant-tree-treenode-selected,
body[data-theme='light'] .redis-viewer-workbench .ant-tree .ant-tree-treenode.ant-tree-treenode-selected:hover {
color: rgba(15, 23, 42, 0.92) !important;
background: linear-gradient(90deg, rgba(22, 119, 255, 0.12), rgba(22, 119, 255, 0.04)) !important;
border: 1px solid rgba(22, 119, 255, 0.18) !important;
}
body[data-theme='light'] .redis-viewer-workbench .ant-radio-button-wrapper {
background: rgba(255, 255, 255, 0.72);
border-color: rgba(15, 23, 42, 0.08);
color: rgba(51, 65, 85, 0.88);
}
body[data-theme='light'] .redis-viewer-workbench .ant-radio-button-wrapper-checked:not(.ant-radio-button-wrapper-disabled) {
background: rgba(22, 119, 255, 0.1);
border-color: rgba(22, 119, 255, 0.22);
color: #1677ff;
}
/* 连接配置弹窗:滚动仅在弹窗 body 内部,不使用外层 wrap 滚动条 */
.connection-modal-wrap {
overflow: hidden !important;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,497 @@
.ai-chat-panel {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
border-left: 1px solid rgba(128, 128, 128, 0.12);
position: relative;
}
/* Resize Handle */
.ai-resize-handle {
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 4px;
cursor: col-resize;
z-index: 10;
transition: background 0.15s ease;
}
.ai-resize-handle:hover,
.ai-resize-handle.active {
background: rgba(22, 119, 255, 0.5);
}
/* Header */
.ai-chat-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
border-bottom: 1px solid rgba(128, 128, 128, 0.1);
flex-shrink: 0;
}
.ai-chat-header-left {
display: flex;
align-items: center;
gap: 10px;
}
.ai-chat-header-left .ai-logo {
width: 28px;
height: 28px;
border-radius: 8px;
display: grid;
place-items: center;
font-size: 16px;
font-weight: 700;
flex-shrink: 0;
}
.ai-chat-header-left .ai-title {
font-size: 14px;
font-weight: 700;
letter-spacing: 0.01em;
}
.ai-chat-header-right {
display: flex;
align-items: center;
gap: 4px;
}
/* Messages Area */
.ai-chat-messages {
flex: 1;
overflow-y: auto;
padding: 16px;
display: flex;
flex-direction: column;
gap: 16px;
}
.ai-chat-messages::-webkit-scrollbar {
width: 5px;
}
.ai-chat-messages::-webkit-scrollbar-track {
background: transparent;
}
.ai-chat-messages::-webkit-scrollbar-thumb {
background: rgba(128, 128, 128, 0.3);
border-radius: 3px;
}
/* Welcome */
.ai-chat-welcome {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 16px;
padding: 40px 20px;
text-align: center;
flex: 1;
}
.ai-chat-welcome .welcome-icon {
width: 56px;
height: 56px;
border-radius: 16px;
display: grid;
place-items: center;
font-size: 28px;
}
.ai-chat-welcome .welcome-title {
font-size: 18px;
font-weight: 700;
margin-bottom: 4px;
}
.ai-chat-welcome .quick-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
justify-content: center;
margin-top: 8px;
}
.ai-chat-welcome .quick-action-btn {
padding: 6px 14px;
border-radius: 20px;
font-size: 12px;
cursor: pointer;
transition: all 0.2s ease;
border: 1px solid;
}
.ai-chat-welcome .quick-action-btn:hover {
background: rgba(99, 102, 241, 0.12) !important;
border-color: rgba(99, 102, 241, 0.3) !important;
color: #818cf8 !important;
}
/* IDE Style Messages */
.ai-ide-message {
padding: 12px 16px;
animation: ai-msg-in 0.2s ease-out;
}
@keyframes ai-msg-in {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.ai-ide-message-header {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
font-weight: 600;
margin-bottom: 8px;
text-transform: uppercase;
letter-spacing: 0.02em;
}
.ai-ide-message-content {
font-size: 13px;
line-height: 1.6;
word-break: break-word;
/* Remove pre-wrap here, as it conflicts with ReactMarkdown's block rendering */
}
/* Markdown Styles Override */
.ai-markdown-content {
white-space: normal;
}
.ai-markdown-content p {
margin: 0 0 10px;
}
.ai-markdown-content p:last-child {
margin-bottom: 0;
}
.ai-markdown-content h1,
.ai-markdown-content h2,
.ai-markdown-content h3,
.ai-markdown-content h4,
.ai-markdown-content h5,
.ai-markdown-content h6 {
margin: 16px 0 8px;
line-height: 1.4;
font-weight: 600;
}
.ai-markdown-content h1:first-child,
.ai-markdown-content h2:first-child,
.ai-markdown-content h3:first-child,
.ai-markdown-content h4:first-child,
.ai-markdown-content h5:first-child,
.ai-markdown-content h6:first-child {
margin-top: 0;
}
.ai-markdown-content pre {
margin: 10px 0;
border-radius: 4px;
padding: 10px;
font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
font-size: 12px;
overflow-x: auto;
border: 1px solid rgba(128, 128, 128, 0.15);
background: rgba(0, 0, 0, 0.2);
}
.ai-markdown-content code {
font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
background: rgba(128, 128, 128, 0.15);
padding: 2px 4px;
border-radius: 3px;
font-size: 0.95em;
}
.ai-markdown-content ul, .ai-markdown-content ol {
margin: 0 0 10px;
padding-left: 20px;
}
.ai-markdown-content li {
margin-bottom: 4px;
}
/* Advanced Typing/Blinker indicator */
.ai-blinking-cursor {
display: inline-block;
width: 6px;
height: 14px;
background-color: currentColor;
border-radius: 1px;
vertical-align: middle;
margin-left: 4px;
animation: blink 1s step-end infinite;
}
@keyframes blink {
0%, 100% { opacity: 1; }
50% { opacity: 0; }
}
@keyframes ai-dot-bounce {
0%, 80%, 100% { transform: scale(0.6); opacity: 0.4; }
40% { transform: scale(1); opacity: 1; }
}
/* History Drawer Styles */
.ai-history-list::-webkit-scrollbar {
width: 4px;
}
.ai-history-list::-webkit-scrollbar-thumb {
background: rgba(128, 128, 128, 0.2);
border-radius: 4px;
}
.ai-history-list:hover::-webkit-scrollbar-thumb {
background: rgba(128, 128, 128, 0.4);
}
.ai-history-item:hover {
background: rgba(128, 128, 128, 0.08) !important;
}
.ai-history-item .ai-history-delete-btn {
opacity: 0;
transition: opacity 0.2s, background 0.2s;
}
.ai-history-item:hover .ai-history-delete-btn,
.ai-history-item.active .ai-history-delete-btn {
opacity: 1;
}
/* Input Area */
.ai-chat-input-area {
padding: 12px 16px 16px;
border-top: 1px solid rgba(128, 128, 128, 0.1);
flex-shrink: 0;
}
/* Textarea scrollbar */
.ai-chat-input-wrapper textarea {
scrollbar-width: thin;
scrollbar-color: rgba(128, 128, 128, 0.3) transparent;
}
.ai-chat-input-wrapper textarea::-webkit-scrollbar {
width: 4px;
}
.ai-chat-input-wrapper textarea::-webkit-scrollbar-track {
background: transparent;
}
.ai-chat-input-wrapper textarea::-webkit-scrollbar-thumb {
background: rgba(128, 128, 128, 0.3);
border-radius: 2px;
}
.ai-chat-input-wrapper {
display: flex;
align-items: flex-end;
gap: 8px;
border-radius: 6px;
border: 1px solid transparent;
border-bottom-color: rgba(128, 128, 128, 0.4);
padding: 6px 10px;
transition: all 0.2s ease;
background: transparent !important;
box-shadow: none !important;
}
.ai-chat-input-wrapper:focus-within {
border-color: var(--ant-primary-color, #1677ff) !important;
background: rgba(128, 128, 128, 0.05) !important;
}
.ai-chat-input-wrapper textarea {
width: 100%;
border: none;
outline: none;
background: transparent;
resize: none;
font-size: 13px;
line-height: 1.5;
min-height: 28px;
max-height: 200px;
padding: 0;
font-family: inherit;
overflow-y: auto;
}
.ai-chat-input-wrapper textarea::placeholder {
opacity: 0.4;
}
.ai-chat-send-btn {
width: 26px;
height: 26px;
border-radius: 4px;
display: grid;
place-items: center;
border: none;
cursor: pointer;
flex-shrink: 0;
transition: transform 0.15s ease, opacity 0.15s ease;
}
.ai-chat-send-btn:hover {
transform: scale(1.06);
}
.ai-chat-send-btn:active {
transform: scale(0.96);
}
.ai-chat-send-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
transform: none;
}
.ai-ide-message:hover .ai-message-actions {
opacity: 1 !important;
}
/* Markdown 额外样式增强: Table & Blockquote */
.ai-markdown-content table {
width: max-content;
min-width: 100%;
border-collapse: collapse;
margin: 12px 0;
font-size: 13px;
}
/* 让消息内容区域成为表格的滚动约束容器 */
.ai-ide-message-content {
max-width: 100%;
overflow-x: hidden;
}
/* 表格滚动容器 - 不限定直接子元素 */
.ai-markdown-content table {
display: block;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
max-width: 100%;
}
.ai-markdown-content table::-webkit-scrollbar {
height: 4px;
}
.ai-markdown-content table::-webkit-scrollbar-thumb {
background: rgba(128, 128, 128, 0.3);
border-radius: 2px;
}
.ai-markdown-content th,
.ai-markdown-content td {
border: 1px solid rgba(125, 125, 125, 0.2);
padding: 6px 12px;
text-align: left;
white-space: nowrap;
}
.ai-markdown-content th {
background: rgba(125, 125, 125, 0.1);
font-weight: 600;
}
.ai-markdown-content blockquote {
margin: 12px 0;
padding: 8px 14px;
border-left: 4px solid rgba(125, 125, 125, 0.4);
background: rgba(125, 125, 125, 0.05);
color: inherit;
opacity: 0.85;
border-radius: 0 6px 6px 0;
font-style: italic;
}
/* 覆盖 code 块容器样式避免和 syntax highlighter 冲突 */
.ai-markdown-content > pre {
background: transparent !important;
padding: 0 !important;
margin: 0 !important;
}
/* ===== 新版 AI 状态流转动画 ===== */
/* 1. 连接脉冲动画 (connecting) */
.ai-wave-pulse {
display: flex;
align-items: center;
gap: 4px;
}
.ai-wave-pulse span {
width: 6px;
height: 6px;
border-radius: 50%;
background-color: currentColor;
animation: wave-pulse-anim 1.2s ease-in-out infinite;
}
.ai-wave-pulse span:nth-child(1) { animation-delay: 0s; }
.ai-wave-pulse span:nth-child(2) { animation-delay: 0.15s; }
.ai-wave-pulse span:nth-child(3) { animation-delay: 0.3s; }
@keyframes wave-pulse-anim {
0%, 100% { transform: translateY(0) scale(0.8); opacity: 0.4; }
50% { transform: translateY(-4px) scale(1.1); opacity: 1; }
}
/* 2. 平滑高度与透明度过渡 (针对 ThinkingBlock 和 面板折叠) */
.ai-expand-transition {
display: grid;
transition: grid-template-rows 0.3s ease-out, opacity 0.3s ease-out;
}
.ai-expand-transition.expanded {
grid-template-rows: 1fr;
opacity: 1;
}
.ai-expand-transition.collapsed {
grid-template-rows: 0fr;
opacity: 0;
}
.ai-expand-transition > div {
overflow: hidden;
}
/* 3. Agent风格旋转Loading环 */
.ai-spinning-ring {
width: 14px;
height: 14px;
border: 2px solid rgba(22, 119, 255, 0.2);
border-top-color: #1677ff;
border-radius: 50%;
animation: ai-spin-anim 0.8s linear infinite;
flex-shrink: 0;
}
@keyframes ai-spin-anim {
to { transform: rotate(360deg); }
}
/* 面板/弹窗内部 toast 定位覆盖:从 fixed视口顶部改为 absolute容器内部顶部 */
.ai-chat-panel .ant-message,
.ai-settings-body .ant-message {
position: absolute !important;
top: 16px !important;
left: 50% !important;
transform: translateX(-50%) !important;
right: auto !important;
width: max-content;
z-index: 100;
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,788 @@
import React, { useState, useEffect, useCallback, useRef } from 'react';
import { Modal, Button, Input, Select, Form, message as antdMessage, Tooltip, Tabs, Space, Popconfirm, Slider } from 'antd';
import { PlusOutlined, DeleteOutlined, EditOutlined, CheckOutlined, ApiOutlined, SafetyCertificateOutlined, RobotOutlined, ThunderboltOutlined, CloudOutlined, ExperimentOutlined, KeyOutlined, LinkOutlined, AppstoreOutlined, ToolOutlined } from '@ant-design/icons';
import type { AIProviderConfig, AIProviderType, AISafetyLevel, AIContextLevel } from '../types';
import {
getProviderFingerprint,
getProviderHostname,
matchQwenPresetKey,
QWEN_BAILIAN_ANTHROPIC_BASE_URL,
QWEN_CODING_PLAN_ANTHROPIC_BASE_URL,
QWEN_CODING_PLAN_MODELS,
resolvePresetBaseURL,
resolvePresetModelSelection,
resolvePresetTransport,
} from '../utils/aiProviderPresets';
import {
PROVIDER_PRESET_CARD_BASE_STYLE,
PROVIDER_PRESET_CARD_CONTENT_STYLE,
PROVIDER_PRESET_CARD_DESCRIPTION_STYLE,
PROVIDER_PRESET_GRID_STYLE,
PROVIDER_PRESET_CARD_TITLE_STYLE,
} from '../utils/aiSettingsPresetLayout';
import type { OverlayWorkbenchTheme } from '../utils/overlayWorkbenchTheme';
interface AISettingsModalProps {
open: boolean;
onClose: () => void;
darkMode: boolean;
overlayTheme: OverlayWorkbenchTheme;
}
// 预设配置:每个预设映射到后端 typeopenai/anthropic/gemini/custom并附带默认 URL 和 Model
interface ProviderPreset {
key: string;
label: string;
icon: React.ReactNode;
desc: string;
color: string;
backendType: AIProviderType;
fixedApiFormat?: string;
defaultBaseUrl: string;
defaultModel: string;
models: string[];
}
const PROVIDER_PRESETS: ProviderPreset[] = [
{ key: 'openai', label: 'OpenAI', icon: <ApiOutlined />, desc: 'GPT-5.4 / 5.3 系列', color: '#10b981', backendType: 'openai', defaultBaseUrl: 'https://api.openai.com/v1', defaultModel: 'gpt-4o', models: [] },
{ key: 'deepseek', label: 'DeepSeek', icon: <ThunderboltOutlined />, desc: 'DeepSeek-V4 / R1', color: '#3b82f6', backendType: 'openai', defaultBaseUrl: 'https://api.deepseek.com/v1', defaultModel: 'deepseek-chat', models: [] },
{ key: 'qwen-bailian', label: '通义千问(百炼通用)', icon: <CloudOutlined />, desc: '百炼 Anthropic 兼容 / 模型从远端拉取', color: '#6366f1', backendType: 'anthropic', defaultBaseUrl: QWEN_BAILIAN_ANTHROPIC_BASE_URL, defaultModel: '', models: [] },
{ key: 'qwen-coding-plan', label: '通义千问Coding Plan', icon: <CloudOutlined />, desc: 'Claude Code CLI 代理链路 / 使用官方支持模型清单', color: '#4f46e5', backendType: 'custom', fixedApiFormat: 'claude-cli', defaultBaseUrl: QWEN_CODING_PLAN_ANTHROPIC_BASE_URL, defaultModel: '', models: QWEN_CODING_PLAN_MODELS },
{ key: 'zhipu', label: '智谱 GLM', icon: <ExperimentOutlined />, desc: 'GLM-5 / GLM-5-Turbo', color: '#0ea5e9', backendType: 'openai', defaultBaseUrl: 'https://open.bigmodel.cn/api/paas/v4', defaultModel: 'glm-4', models: [] },
{ key: 'moonshot', label: 'Kimi', icon: <ExperimentOutlined />, desc: 'Kimi K2.5 (Anthropic 兼容)', color: '#0d9488', backendType: 'anthropic', defaultBaseUrl: 'https://api.moonshot.cn/anthropic', defaultModel: 'moonshot-v1-8k', models: [] },
{ key: 'anthropic', label: 'Claude', icon: <ExperimentOutlined />, desc: 'Claude Opus/Sonnet', color: '#d97706', backendType: 'anthropic', defaultBaseUrl: 'https://api.anthropic.com', defaultModel: 'claude-3-5-sonnet-20241022', models: [] },
{ key: 'gemini', label: 'Gemini', icon: <CloudOutlined />, desc: 'Gemini 3.1 / 2.5 系列', color: '#059669', backendType: 'gemini', defaultBaseUrl: 'https://generativelanguage.googleapis.com', defaultModel: 'gemini-2.5-flash', models: [] },
{ key: 'volcengine-ark', label: '火山方舟', icon: <CloudOutlined />, desc: 'Ark 通用推理 / 豆包模型', color: '#0ea5e9', backendType: 'openai', defaultBaseUrl: 'https://ark.cn-beijing.volces.com/api/v3', defaultModel: '', models: [] },
{ key: 'volcengine-coding', label: '火山 Coding Plan', icon: <CloudOutlined />, desc: 'Ark Code / Coding Plan', color: '#0284c7', backendType: 'openai', defaultBaseUrl: 'https://ark.cn-beijing.volces.com/api/coding/v3', defaultModel: '', models: [] },
{ key: 'minimax', label: 'MiniMax', icon: <ExperimentOutlined />, desc: 'M2.7 / M2.5 系列 (Anthropic 兼容)', color: '#e11d48', backendType: 'anthropic', defaultBaseUrl: 'https://api.minimaxi.com/anthropic', defaultModel: 'MiniMax-M2.7', models: ['MiniMax-M2.7', 'MiniMax-M2.7-highspeed', 'MiniMax-M2.5', 'MiniMax-M2.5-highspeed', 'MiniMax-M2.1', 'MiniMax-M2.1-highspeed', 'MiniMax-M2'] },
{ key: 'ollama', label: 'Ollama', icon: <AppstoreOutlined />, desc: '本地部署开源模型', color: '#78716c', backendType: 'openai', defaultBaseUrl: 'http://localhost:11434/v1', defaultModel: 'llama3', models: [] },
{ key: 'custom', label: '自定义', icon: <AppstoreOutlined />, desc: '自定义 API 端点', color: '#64748b', backendType: 'custom', defaultBaseUrl: '', defaultModel: '', models: [] },
];
const findPreset = (key: string): ProviderPreset => PROVIDER_PRESETS.find(p => p.key === key) || PROVIDER_PRESETS[PROVIDER_PRESETS.length - 1];
const matchProviderPreset = (provider: Pick<AIProviderConfig, 'type' | 'baseUrl'>): ProviderPreset => {
const qwenPresetKey = matchQwenPresetKey(provider);
if (qwenPresetKey) {
return findPreset(qwenPresetKey);
}
const fingerprint = getProviderFingerprint(provider.baseUrl);
const exactPreset = PROVIDER_PRESETS.find(pr =>
pr.backendType === provider.type
&& fingerprint !== ''
&& fingerprint === getProviderFingerprint(pr.defaultBaseUrl)
);
if (exactPreset) {
return exactPreset;
}
const host = getProviderHostname(provider.baseUrl);
if (host.endsWith('moonshot.cn')) {
return findPreset('moonshot');
}
return PROVIDER_PRESETS.find(pr => pr.backendType === provider.type && host !== '' && host === getProviderHostname(pr.defaultBaseUrl))
|| PROVIDER_PRESETS.find(pr => pr.backendType === provider.type)
|| findPreset('custom');
};
const SAFETY_OPTIONS: { label: string; value: AISafetyLevel; desc: string; color: string; icon: string }[] = [
{ label: '只读模式', value: 'readonly', desc: 'AI 仅可执行 SELECT 等查询操作,最安全', color: '#22c55e', icon: '🔒' },
{ label: '读写模式', value: 'readwrite', desc: 'AI 可执行 INSERT/UPDATE/DELETE危险操作需二次确认', color: '#f59e0b', icon: '⚠️' },
{ label: '完全模式', value: 'full', desc: 'AI 可执行所有操作(含 DDL高危操作自动告警', color: '#ef4444', icon: '🔓' },
];
const CONTEXT_OPTIONS: { label: string; value: AIContextLevel; desc: string; icon: string }[] = [
{ label: '仅 Schema', value: 'schema_only', desc: '只传递表/列结构信息给 AI', icon: '📋' },
{ label: '含采样数据', value: 'with_samples', desc: '包含少量采样数据帮助 AI 理解数据特征', icon: '📊' },
{ label: '含查询结果', value: 'with_results', desc: '传递最近的查询结果作为上下文', icon: '📑' },
];
const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMode, overlayTheme }) => {
const [providers, setProviders] = useState<AIProviderConfig[]>([]);
const [activeProviderId, setActiveProviderId] = useState<string>('');
const [safetyLevel, setSafetyLevel] = useState<AISafetyLevel>('readonly');
const [contextLevel, setContextLevel] = useState<AIContextLevel>('schema_only');
const [editingProvider, setEditingProvider] = useState<AIProviderConfig | null>(null);
const [isEditing, setIsEditing] = useState(false);
const [loading, setLoading] = useState(false);
const [testStatus, setTestStatus] = useState<'idle' | 'success' | 'error'>('idle');
const [builtinPrompts, setBuiltinPrompts] = useState<Record<string, string>>({});
const [activeSection, setActiveSection] = useState<'providers' | 'safety' | 'context' | 'prompts' | 'tools'>('providers');
const [form] = Form.useForm();
const modalBodyRef = useRef<HTMLDivElement>(null);
// Modal 内部 toast 通知
const [messageApi, messageContextHolder] = antdMessage.useMessage({ getContainer: () => modalBodyRef.current || document.body });
// 主题色
const cardBg = darkMode ? 'rgba(255,255,255,0.04)' : 'rgba(0,0,0,0.02)';
const cardBorder = darkMode ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.06)';
const cardHoverBg = darkMode ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.03)';
const sectionLabelColor = darkMode ? 'rgba(255,255,255,0.5)' : 'rgba(0,0,0,0.4)';
const inputBg = darkMode ? 'rgba(255,255,255,0.04)' : 'rgba(0,0,0,0.02)';
// Hook 必须在组件顶层调用,不能在条件分支内
const watchedType = Form.useWatch('type', form);
const watchedPresetKey = Form.useWatch('presetKey', form);
const watchedApiFormat = Form.useWatch('apiFormat', form) || 'openai';
const loadConfig = useCallback(async () => {
try {
const Service = (window as any).go?.aiservice?.Service;
if (!Service) { console.warn('[AI] Service not found on window.go'); return; }
const [provRes, safeRes, ctxRes, promptsRes] = await Promise.all([
Service.AIGetProviders?.() || [],
Service.AIGetSafetyLevel?.() || 'readonly',
Service.AIGetContextLevel?.() || 'schema_only',
Service.AIGetBuiltinPrompts?.() || {},
]);
console.log('[AI] AIGetProviders result:', JSON.stringify(provRes), 'isArray:', Array.isArray(provRes));
if (Array.isArray(provRes)) {
setProviders(provRes);
const activeRes = await Service.AIGetActiveProvider?.();
console.log('[AI] AIGetActiveProvider result:', activeRes);
if (activeRes) setActiveProviderId(activeRes);
}
if (safeRes) setSafetyLevel(safeRes);
if (ctxRes) setContextLevel(ctxRes);
if (promptsRes) setBuiltinPrompts(promptsRes);
} catch (e) { console.warn('Failed to load AI config', e); }
}, []);
useEffect(() => { if (open) void loadConfig(); }, [open, loadConfig]);
const handleAddProvider = () => {
const preset = findPreset('openai');
const newProvider: AIProviderConfig = {
id: '', type: preset.backendType, name: '', apiKey: '',
baseUrl: preset.defaultBaseUrl, model: preset.defaultModel,
models: [], maxTokens: 4096, temperature: 0.7,
};
setEditingProvider({ ...newProvider, presetKey: 'openai' } as any);
setIsEditing(true);
setTestStatus('idle');
form.resetFields();
form.setFieldsValue({ ...newProvider, presetKey: 'openai', apiFormat: 'openai' });
};
const handleEditProvider = (p: AIProviderConfig) => {
// 尝试根据 baseUrl 和 type 推断 preset
const matchedPreset = matchProviderPreset(p);
const resolvedTransport = resolvePresetTransport({
presetBackendType: matchedPreset.backendType,
presetFixedApiFormat: matchedPreset.fixedApiFormat,
valuesApiFormat: p.apiFormat,
});
setEditingProvider(p);
setIsEditing(true);
setTestStatus('idle');
form.resetFields();
form.setFieldsValue({
...p,
type: resolvedTransport.type,
models: p.models || [],
presetKey: matchedPreset.key,
apiFormat: resolvedTransport.apiFormat || p.apiFormat || 'openai',
});
};
const handleDeleteProvider = async (id: string) => {
try {
const Service = (window as any).go?.aiservice?.Service;
const wasActive = id === activeProviderId;
await Service?.AIDeleteProvider?.(id);
await loadConfig();
// 合并提示:删除的是当前激活的供应商时,附带自动切换信息
if (wasActive) {
const newProviders: any[] = await Service?.AIGetProviders?.() || [];
if (newProviders.length > 0) {
const newActiveName = newProviders[0]?.name || '下一个供应商';
void messageApi.success(`已删除,自动切换到「${newActiveName}`);
} else {
void messageApi.success('已删除');
}
} else {
void messageApi.success('已删除');
}
window.dispatchEvent(new CustomEvent('gonavi:ai:provider-changed'));
} catch (e: any) { void messageApi.error(e?.message || '删除失败'); }
};
const handleSaveProvider = async () => {
try {
const values = await form.validateFields();
setLoading(true);
const Service = (window as any).go?.aiservice?.Service;
// 构建 payload处理 model/models 逻辑
const preset = findPreset(values.presetKey);
const isCustomLike = values.presetKey === 'custom' || values.presetKey === 'ollama';
const { model: finalModel, models: resolvedModels } = resolvePresetModelSelection({
presetKey: values.presetKey,
presetDefaultModel: preset.defaultModel,
presetModels: preset.models,
valuesModel: values.model,
customModels: values.models,
});
// 内置供应商自动使用 preset label 作为名称
const finalName = isCustomLike ? (values.name || preset.label) : preset.label;
const finalBaseUrl = resolvePresetBaseURL({
presetKey: values.presetKey,
presetDefaultBaseUrl: preset.defaultBaseUrl,
valuesBaseUrl: values.baseUrl,
});
const resolvedTransport = resolvePresetTransport({
presetBackendType: preset.backendType,
presetFixedApiFormat: preset.fixedApiFormat,
valuesApiFormat: values.apiFormat,
});
const payload = {
...editingProvider,
...values,
...resolvedTransport,
name: finalName,
model: finalModel,
models: resolvedModels,
baseUrl: finalBaseUrl,
apiFormat: resolvedTransport.apiFormat,
};
// 后端 AISaveProvider 统一处理新增和更新,返回 void失败抛异常
await Service?.AISaveProvider?.(payload);
void messageApi.success('已保存'); setIsEditing(false); setEditingProvider(null); void loadConfig();
window.dispatchEvent(new CustomEvent('gonavi:ai:provider-changed'));
} catch (e: any) {
if (e?.errorFields) { /* antd form validation error, ignore */ }
else void messageApi.error(e?.message || '保存失败');
} finally { setLoading(false); }
};
const handleSetActive = async (id: string) => {
try {
const Service = (window as any).go?.aiservice?.Service;
await Service?.AISetActiveProvider?.(id);
setActiveProviderId(id); void messageApi.success('已切换');
window.dispatchEvent(new CustomEvent('gonavi:ai:provider-changed'));
} catch (e: any) { void messageApi.error(e?.message || '切换失败'); }
};
const handleSafetyChange = async (level: AISafetyLevel) => {
try {
const Service = (window as any).go?.aiservice?.Service;
await Service?.AISetSafetyLevel?.(level);
setSafetyLevel(level);
} catch (e) { /* ignore */ }
};
const handleContextChange = async (level: AIContextLevel) => {
try {
const Service = (window as any).go?.aiservice?.Service;
await Service?.AISetContextLevel?.(level);
setContextLevel(level);
} catch (e) { /* ignore */ }
};
const handleTestProvider = async () => {
try {
const values = await form.validateFields();
setLoading(true);
setTestStatus('idle');
const Service = (window as any).go?.aiservice?.Service;
const preset = findPreset(values.presetKey || 'openai');
const finalBaseUrl = resolvePresetBaseURL({
presetKey: values.presetKey || 'openai',
presetDefaultBaseUrl: preset.defaultBaseUrl,
valuesBaseUrl: values.baseUrl,
});
const { model: finalModel, models: resolvedModels } = resolvePresetModelSelection({
presetKey: values.presetKey || 'openai',
presetDefaultModel: preset.defaultModel,
presetModels: preset.models,
valuesModel: values.model,
customModels: values.models,
});
const resolvedTransport = resolvePresetTransport({
presetBackendType: preset.backendType,
presetFixedApiFormat: preset.fixedApiFormat,
valuesApiFormat: values.apiFormat,
});
const res = await Service?.AITestProvider?.({
...editingProvider,
...values,
...resolvedTransport,
baseUrl: finalBaseUrl,
model: finalModel,
models: resolvedModels,
maxTokens: Number(values.maxTokens) || 4096,
temperature: Number(values.temperature) ?? 0.7,
apiFormat: resolvedTransport.apiFormat,
});
if (res?.success) { setTestStatus('success'); void messageApi.success('连接成功'); }
else { setTestStatus('error'); void messageApi.error(`测试失败: ${res?.message || '未知错误'}`); }
} catch (e: any) { setTestStatus('error'); void messageApi.error(e?.message || '测试失败'); }
finally { setLoading(false); }
};
const handlePresetChange = (presetKey: string) => {
const preset = findPreset(presetKey);
const resolvedTransport = resolvePresetTransport({
presetBackendType: preset.backendType,
presetFixedApiFormat: preset.fixedApiFormat,
valuesApiFormat: form.getFieldValue('apiFormat'),
});
form.setFieldsValue({
presetKey,
type: resolvedTransport.type,
apiFormat: resolvedTransport.apiFormat || 'openai',
baseUrl: preset.defaultBaseUrl,
model: preset.defaultModel,
});
};
// ---- 字段装饰器样式 ----
const fieldGroupStyle: React.CSSProperties = {
padding: '14px 16px', borderRadius: 12, border: `1px solid ${cardBorder}`,
background: cardBg, marginBottom: 12,
};
const fieldLabelStyle: React.CSSProperties = {
fontSize: 13, fontWeight: 700, textTransform: 'uppercase' as const, letterSpacing: '0.08em',
color: sectionLabelColor, marginBottom: 10, display: 'flex', alignItems: 'center', gap: 6,
};
// ===== Provider 列表 =====
const renderProviderList = () => (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{providers.length === 0 && (
<div style={{
textAlign: 'center', padding: '36px 20px', color: overlayTheme.mutedText, fontSize: 14,
border: `1px dashed ${cardBorder}`, borderRadius: 14, background: cardBg,
}}>
<RobotOutlined style={{ fontSize: 32, marginBottom: 12, opacity: 0.3, display: 'block' }} />
<br />
<span style={{ fontSize: 13, opacity: 0.6 }}>使 AI </span>
</div>
)}
{providers.map(p => {
const matchedPreset = matchProviderPreset(p);
const isActive = p.id === activeProviderId;
return (
<div key={p.id} onClick={() => handleSetActive(p.id)} style={{
padding: '14px 16px', borderRadius: 14, cursor: 'pointer', transition: 'all 0.2s ease',
border: `1.5px solid ${isActive ? overlayTheme.selectedText : cardBorder}`,
background: isActive ? overlayTheme.selectedBg : cardBg,
display: 'flex', alignItems: 'center', gap: 14,
}}>
<div style={{
width: 36, height: 36, borderRadius: 10, display: 'grid', placeItems: 'center',
background: isActive ? overlayTheme.iconBg : (darkMode ? 'rgba(255,255,255,0.04)' : 'rgba(0,0,0,0.02)'),
color: isActive ? overlayTheme.iconColor : overlayTheme.mutedText,
fontSize: 18, flexShrink: 0, transition: 'all 0.2s ease',
}}>
{matchedPreset.icon || <ApiOutlined />}
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontWeight: 700, fontSize: 14, color: overlayTheme.titleText, display: 'flex', alignItems: 'center', gap: 8 }}>
{p.name || p.type}
{isActive && <CheckOutlined style={{ color: overlayTheme.iconColor, fontSize: 13 }} />}
</div>
<div style={{ fontSize: 12, color: overlayTheme.mutedText, marginTop: 4, display: 'flex', alignItems: 'center', gap: 6 }}>
<span>{matchedPreset.label}</span>
<span style={{ opacity: 0.4 }}>·</span>
<span style={{ fontFamily: 'monospace', fontSize: 12 }}>{p.model || '未选择模型'}</span>
</div>
</div>
<Space size={2}>
<Tooltip title="编辑">
<Button type="text" size="small" icon={<EditOutlined />}
onClick={e => { e.stopPropagation(); handleEditProvider(p); }}
style={{ color: overlayTheme.mutedText }} />
</Tooltip>
<Popconfirm title="确认删除?" onConfirm={() => handleDeleteProvider(p.id)}
okButtonProps={{ danger: true }} okText="删除" cancelText="取消">
<Button type="text" size="small" icon={<DeleteOutlined />} danger
onClick={e => e.stopPropagation()} />
</Popconfirm>
</Space>
</div>
);
})}
<Button type="dashed" icon={<PlusOutlined />} onClick={handleAddProvider}
style={{ borderRadius: 12, height: 42, borderColor: darkMode ? 'rgba(255,255,255,0.12)' : undefined }}>
</Button>
</div>
);
// ===== Provider 编辑表单 =====
const renderProviderForm = () => {
const presetKeyFromForm = watchedPresetKey || (editingProvider as any)?.presetKey || 'openai';
return (
<div>
{/* 顶部返回 */}
<div style={{ marginBottom: 16, display: 'flex', alignItems: 'center', gap: 10 }}>
<Button size="small" onClick={() => { setIsEditing(false); setEditingProvider(null); }}
style={{ borderRadius: 8 }}> </Button>
<span style={{ fontWeight: 700, fontSize: 16, color: overlayTheme.titleText }}>
{editingProvider?.id ? '编辑模型供应商' : '添加模型供应商'}
</span>
</div>
<Form form={form} layout="vertical" size="small">
{/* Provider 类型选择 - 卡片式 */}
<div style={fieldGroupStyle}>
<div style={fieldLabelStyle}>
<AppstoreOutlined style={{ fontSize: 14 }} />
</div>
<Form.Item name="presetKey" noStyle>
<div style={PROVIDER_PRESET_GRID_STYLE}>
{PROVIDER_PRESETS.map(pt => (
<div key={pt.key} onClick={() => { form.setFieldValue('presetKey', pt.key); handlePresetChange(pt.key); }}
style={{
...PROVIDER_PRESET_CARD_BASE_STYLE,
border: `1.5px solid ${presetKeyFromForm === pt.key ? overlayTheme.selectedText : 'transparent'}`,
background: presetKeyFromForm === pt.key ? overlayTheme.selectedBg : (darkMode ? 'rgba(255,255,255,0.02)' : 'rgba(255,255,255,0.72)'),
boxShadow: presetKeyFromForm === pt.key ? 'none' : (darkMode ? 'inset 0 0 0 1px rgba(255,255,255,0.028)' : 'inset 0 0 0 1px rgba(16,24,40,0.03)'),
}}>
<div style={{
color: presetKeyFromForm === pt.key ? overlayTheme.iconColor : overlayTheme.mutedText,
fontSize: 18, marginTop: 2, transition: 'all 0.2s ease', flexShrink: 0,
}}>
{pt.icon}
</div>
<div style={PROVIDER_PRESET_CARD_CONTENT_STYLE}>
<div style={{ ...PROVIDER_PRESET_CARD_TITLE_STYLE, fontSize: 13, fontWeight: 700, color: overlayTheme.titleText, lineHeight: 1.3 }}>{pt.label}</div>
<div style={{ ...PROVIDER_PRESET_CARD_DESCRIPTION_STYLE, fontSize: 12, color: overlayTheme.mutedText, lineHeight: 1.4 }}>{pt.desc}</div>
</div>
</div>
))}
</div>
</Form.Item>
<Form.Item name="type" hidden><Input /></Form.Item>
</div>
{/* 基本信息 - 仅自定义/Ollama 显示 */}
{(presetKeyFromForm === 'custom' || presetKeyFromForm === 'ollama') && (
<div style={{ ...fieldGroupStyle, marginTop: 16 }}>
<div style={fieldLabelStyle}>
<RobotOutlined style={{ fontSize: 14 }} />
</div>
<Form.Item label={<span style={{ fontWeight: 500, color: overlayTheme.titleText }}></span>} name="name" rules={[{ required: true, message: '请输入名称' }]} style={{ marginBottom: 16 }}>
<Input placeholder="例如:我的自建 OpenAI / 专属大模型"
size="middle"
style={{ borderRadius: 8, background: inputBg, border: `1px solid ${cardBorder}` }} />
</Form.Item>
{presetKeyFromForm === 'custom' && (
<Form.Item label={<span style={{ fontWeight: 500, color: overlayTheme.titleText }}>API </span>} name="apiFormat" style={{ marginBottom: 16 }}>
<div style={{
display: 'inline-flex', padding: 4, background: darkMode ? 'rgba(0,0,0,0.2)' : 'rgba(0,0,0,0.04)',
borderRadius: 8, gap: 4
}}>
{[{ value: 'openai', label: 'OpenAI' }, { value: 'anthropic', label: 'Anthropic' }, { value: 'gemini', label: 'Gemini' }, { value: 'claude-cli', label: 'Claude CLI' }].map(fmt => (
<div
key={fmt.value}
onClick={() => form.setFieldsValue({ apiFormat: fmt.value })}
style={{
padding: '6px 16px', borderRadius: 6, fontSize: 13, fontWeight: watchedApiFormat === fmt.value ? 600 : 500, cursor: 'pointer',
background: watchedApiFormat === fmt.value ? (darkMode ? '#374151' : '#ffffff') : 'transparent',
color: watchedApiFormat === fmt.value ? overlayTheme.titleText : overlayTheme.mutedText,
boxShadow: watchedApiFormat === fmt.value ? '0 1px 3px rgba(0,0,0,0.1)' : 'none',
transition: 'all 0.2s ease',
}}
>
{fmt.label}
</div>
))}
</div>
</Form.Item>
)}
<Form.Item label={<span style={{ fontWeight: 500, color: overlayTheme.titleText }}></span>} name="models" style={{ marginBottom: 0 }}>
<Select mode="tags" size="middle" placeholder="配置指定的模型ID留空则默认去服务端拉取" style={{ width: '100%' }} />
</Form.Item>
</div>
)}
<Form.Item name="model" hidden><Input /></Form.Item>
<Form.Item name="name" hidden><Input /></Form.Item>
{/* 认证信息 */}
<div style={{ ...fieldGroupStyle, marginTop: 16 }}>
<div style={fieldLabelStyle}>
<KeyOutlined style={{ fontSize: 14 }} /> &
</div>
<Form.Item label={<span style={{ fontWeight: 500, color: overlayTheme.titleText }}>API Key</span>} name="apiKey" rules={[{ required: true, message: '请输入 API Key' }]} style={{ marginBottom: 16 }}>
<Input.Password placeholder="sk-... / 你的 API Key"
size="middle"
style={{ borderRadius: 8, background: inputBg, border: `1px solid ${cardBorder}` }} />
</Form.Item>
{(presetKeyFromForm === 'custom' || presetKeyFromForm === 'ollama') && (
<Form.Item label={<span style={{ fontWeight: 500, color: overlayTheme.titleText }}>API Endpoint (URL)</span>} name="baseUrl" rules={[{ required: true, message: '请输入有效的接口地址' }]} style={{ marginBottom: 0 }}>
<Input placeholder={findPreset(presetKeyFromForm).defaultBaseUrl || 'https://...'}
size="middle"
suffix={<LinkOutlined style={{ color: overlayTheme.mutedText }} />}
style={{ borderRadius: 8, background: inputBg, border: `1px solid ${cardBorder}` }} />
</Form.Item>
)}
</div>
{/* 操作按钮 */}
<div style={{
display: 'flex', gap: 8, justifyContent: 'flex-end', marginTop: 12, paddingTop: 16,
borderTop: `1px solid ${cardBorder}`, paddingBottom: 24,
}}>
<Button onClick={handleTestProvider} loading={loading} style={{ borderRadius: 10 }}
icon={testStatus === 'success' ? <CheckOutlined style={{ color: '#22c55e' }} /> : undefined}>
{testStatus === 'success' ? '连接正常' : testStatus === 'error' ? '重新测试' : '测试连接'}
</Button>
<Button type="primary" onClick={handleSaveProvider} loading={loading}
style={{ borderRadius: 10, fontWeight: 600 }}>
</Button>
</div>
</Form>
</div>
);
};
// ===== 安全控制 =====
const renderSafetySettings = () => (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<div style={{ fontSize: 13, color: overlayTheme.mutedText, marginBottom: 8 }}>
AI SQL
</div>
{SAFETY_OPTIONS.map(opt => {
const active = safetyLevel === opt.value;
return (
<div key={opt.value} onClick={() => handleSafetyChange(opt.value)} style={{
padding: '14px 16px', borderRadius: 14, cursor: 'pointer', transition: 'all 0.2s ease',
border: `1.5px solid ${active ? (opt.color === '#ef4444' ? opt.color : overlayTheme.selectedText) : cardBorder}`,
background: active ? (opt.color === '#ef4444' ? `${opt.color}15` : overlayTheme.selectedBg) : cardBg,
display: 'flex', alignItems: 'flex-start', gap: 14,
}}>
<div style={{
width: 36, height: 36, borderRadius: 10, display: 'grid', placeItems: 'center', fontSize: 18, flexShrink: 0,
background: active ? (opt.color === '#ef4444' ? `${opt.color}25` : overlayTheme.iconBg) : (darkMode ? 'rgba(255,255,255,0.04)' : 'rgba(0,0,0,0.03)'),
color: active ? (opt.color === '#ef4444' ? opt.color : overlayTheme.iconColor) : overlayTheme.mutedText,
transition: 'all 0.2s ease',
}}>
{opt.icon}
</div>
<div style={{ flex: 1 }}>
<div style={{ fontWeight: 700, fontSize: 14, color: overlayTheme.titleText, display: 'flex', alignItems: 'center', gap: 8 }}>
{opt.label}
{active && <CheckOutlined style={{ color: opt.color === '#ef4444' ? opt.color : overlayTheme.iconColor, fontSize: 14 }} />}
</div>
<div style={{ fontSize: 13, color: overlayTheme.mutedText, marginTop: 4, lineHeight: '1.5' }}>{opt.desc}</div>
</div>
</div>
);
})}
</div>
);
// ===== 上下文级别 =====
const renderContextSettings = () => (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<div style={{ fontSize: 13, color: overlayTheme.mutedText, marginBottom: 8 }}>
AI
</div>
{CONTEXT_OPTIONS.map(opt => {
const active = contextLevel === opt.value;
return (
<div key={opt.value} onClick={() => handleContextChange(opt.value)} style={{
padding: '14px 16px', borderRadius: 14, cursor: 'pointer', transition: 'all 0.2s ease',
border: `1.5px solid ${active ? overlayTheme.selectedText : cardBorder}`,
background: active ? overlayTheme.selectedBg : cardBg,
display: 'flex', alignItems: 'flex-start', gap: 14,
}}>
<div style={{
width: 36, height: 36, borderRadius: 10, display: 'grid', placeItems: 'center', fontSize: 18, flexShrink: 0,
background: active ? overlayTheme.iconBg : (darkMode ? 'rgba(255,255,255,0.04)' : 'rgba(0,0,0,0.03)'),
color: active ? overlayTheme.iconColor : overlayTheme.mutedText,
transition: 'all 0.2s ease',
}}>
{opt.icon}
</div>
<div style={{ flex: 1 }}>
<div style={{ fontWeight: 700, fontSize: 14, color: overlayTheme.titleText, display: 'flex', alignItems: 'center', gap: 8 }}>
{opt.label}
{active && <CheckOutlined style={{ color: overlayTheme.iconColor, fontSize: 14 }} />}
</div>
<div style={{ fontSize: 13, color: overlayTheme.mutedText, marginTop: 4, lineHeight: '1.5' }}>{opt.desc}</div>
</div>
</div>
);
})}
</div>
);
const renderBuiltinPrompts = () => (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<div style={{ fontSize: 13, color: overlayTheme.mutedText, marginBottom: 4 }}>
GoNavi AI
</div>
{Object.entries(builtinPrompts).map(([title, promptText]) => (
<div key={title} style={{
padding: '12px', borderRadius: 12, border: `1px solid ${cardBorder}`, background: cardBg,
}}>
<div style={{ fontWeight: 700, fontSize: 14, color: overlayTheme.titleText, marginBottom: 8, display: 'flex', alignItems: 'center', gap: 6 }}>
<RobotOutlined style={{ color: overlayTheme.iconColor }} /> {title}
</div>
<div style={{
background: darkMode ? 'rgba(0,0,0,0.2)' : 'rgba(255,255,255,0.8)',
padding: '10px 12px', borderRadius: 8, fontSize: 13, color: overlayTheme.mutedText,
whiteSpace: 'pre-wrap', fontFamily: 'monospace', lineHeight: 1.5,
userSelect: 'text', border: darkMode ? '1px solid rgba(255,255,255,0.03)' : '1px solid rgba(0,0,0,0.02)'
}}>
{promptText}
</div>
</div>
))}
</div>
);
const BUILTIN_TOOLS_INFO = [
{ name: 'get_connections', icon: '🔗', desc: '获取所有可用的数据库连接', detail: '返回连接 ID、名称、类型 (MySQL/PostgreSQL 等) 和 Host 地址。AI 根据返回信息决定优先探索哪个连接。', params: '无参数' },
{ name: 'get_databases', icon: '🗄️', desc: '获取指定连接下的所有数据库', detail: '传入 connectionId返回该连接下的数据库/Schema 名称列表。', params: 'connectionId: 连接 ID' },
{ name: 'get_tables', icon: '📋', desc: '获取指定数据库下的所有表名', detail: '传入 connectionId 和 dbName返回表名列表。AI 用它来定位用户提到的目标表。', params: 'connectionId, dbName' },
{ name: 'get_columns', icon: '🔍', desc: '获取指定表的字段结构', detail: '传入 connectionId、dbName 和 tableName返回每个字段的名称、类型、是否可空、默认值和注释。AI 在生成 SQL 前必须调用此工具确认真实字段名。', params: 'connectionId, dbName, tableName' },
{ name: 'get_table_ddl', icon: '📝', desc: '获取表的建表语句 (DDL)', detail: '传入 connectionId、dbName 和 tableName返回完整的 CREATE TABLE 语句,包含字段定义、索引、约束等信息。', params: 'connectionId, dbName, tableName' },
{ name: 'execute_sql', icon: '▶️', desc: '执行 SQL 查询并返回结果', detail: '传入 connectionId、dbName 和 sql在目标数据库上执行 SQL 并返回结果(最多 50 行)。受安全级别控制,只读模式下仅允许 SELECT/SHOW/DESCRIBE。', params: 'connectionId, dbName, sql' },
];
const renderBuiltinTools = () => (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<div style={{ fontSize: 13, color: overlayTheme.mutedText, marginBottom: 4 }}>
AI
</div>
<div style={{ fontSize: 12, color: overlayTheme.mutedText, opacity: 0.7, padding: '8px 12px', borderRadius: 8, background: cardBg, border: `1px solid ${cardBorder}` }}>
💡 get_connections get_databases get_tables get_columns SQL
</div>
{BUILTIN_TOOLS_INFO.map(tool => (
<div key={tool.name} style={{
padding: '14px 16px', borderRadius: 14, border: `1px solid ${cardBorder}`, background: cardBg,
transition: 'all 0.2s ease',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 8 }}>
<span style={{ fontSize: 20 }}>{tool.icon}</span>
<div>
<div style={{ fontWeight: 700, fontSize: 14, color: overlayTheme.titleText, fontFamily: 'monospace' }}>
{tool.name}
</div>
<div style={{ fontSize: 13, color: overlayTheme.mutedText, marginTop: 2 }}>{tool.desc}</div>
</div>
</div>
<div style={{
fontSize: 13, color: overlayTheme.mutedText, lineHeight: 1.6, padding: '8px 12px',
background: darkMode ? 'rgba(0,0,0,0.15)' : 'rgba(0,0,0,0.02)', borderRadius: 8,
}}>
{tool.detail}
</div>
<div style={{ marginTop: 8, fontSize: 12, color: overlayTheme.mutedText, opacity: 0.7, display: 'flex', alignItems: 'center', gap: 6 }}>
<ToolOutlined style={{ fontSize: 12 }} />
<span></span>
<code style={{ fontFamily: 'monospace', fontSize: 12, padding: '1px 6px', borderRadius: 4, background: darkMode ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.04)' }}>
{tool.params}
</code>
</div>
</div>
))}
</div>
);
const modalShellStyle = {
background: overlayTheme.shellBg, border: overlayTheme.shellBorder,
boxShadow: overlayTheme.shellShadow, backdropFilter: overlayTheme.shellBackdropFilter,
};
return (
<Modal
title={
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 12 }}>
<div style={{
width: 38, height: 38, borderRadius: 12, display: 'grid', placeItems: 'center',
background: overlayTheme.iconBg, color: overlayTheme.iconColor, fontSize: 18, flexShrink: 0,
}}>
<RobotOutlined />
</div>
<div>
<div style={{ fontSize: 16, fontWeight: 800, color: overlayTheme.titleText }}>AI </div>
<div style={{ marginTop: 3, color: overlayTheme.mutedText, fontSize: 12 }}>
AI
</div>
</div>
</div>
}
open={open}
onCancel={onClose}
footer={null}
width={820}
styles={{
content: modalShellStyle,
header: { background: 'transparent', borderBottom: 'none', paddingBottom: 8 },
body: { paddingTop: 8, height: 620, overflow: 'hidden' },
}}
>
<div ref={modalBodyRef} className="ai-settings-body" style={{ display: 'grid', gridTemplateColumns: '180px minmax(0, 1fr)', gap: 16, padding: '12px 0', height: '100%', minHeight: 0, overflow: 'hidden', alignItems: 'stretch', position: 'relative' }}>
{messageContextHolder}
<div style={{ padding: '0 12px', height: 'fit-content' }}>
<div style={{ marginBottom: 12, fontWeight: 600, color: overlayTheme.titleText }}></div>
<div style={{ display: 'grid', gap: 10 }}>
{[
{ key: 'providers', title: '模型供应商', description: '配置大模型接口与秘钥', icon: <ApiOutlined /> },
{ key: 'safety', title: '安全控制', description: '限制 AI 操作风险级别', icon: <SafetyCertificateOutlined /> },
{ key: 'context', title: '上下文', description: '配置携带的数据架构信息', icon: <RobotOutlined /> },
{ key: 'tools', title: '内置工具', description: '查看 AI 可调用的数据探针', icon: <ToolOutlined /> },
{ key: 'prompts', title: '内置提示词', description: '查看系统预设的底层要求', icon: <ExperimentOutlined /> },
].map((item) => {
const active = activeSection === item.key;
return (
<button
key={item.key}
type="button"
onClick={() => setActiveSection(item.key as typeof activeSection)}
style={{
textAlign: 'left',
padding: '12px 14px',
borderRadius: 12,
border: `1px solid ${active
? (darkMode ? 'rgba(255,214,102,0.3)' : 'rgba(24,144,255,0.24)')
: (darkMode ? 'rgba(255,255,255,0.08)' : 'rgba(16,24,40,0.08)')}`,
background: active
? (darkMode ? 'linear-gradient(180deg, rgba(255,214,102,0.12) 0%, rgba(255,214,102,0.06) 100%)' : 'linear-gradient(180deg, rgba(24,144,255,0.10) 0%, rgba(24,144,255,0.05) 100%)')
: (darkMode ? 'rgba(255,255,255,0.02)' : 'rgba(255,255,255,0.72)'),
color: active ? (darkMode ? '#f5f7ff' : '#162033') : (darkMode ? 'rgba(255,255,255,0.82)' : '#3f4b5e'),
cursor: 'pointer',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<span style={{ fontSize: 16 }}>{item.icon}</span>
<span style={{ fontSize: 14, fontWeight: 700 }}>{item.title}</span>
</div>
<div style={{ marginTop: 6, fontSize: 12, lineHeight: 1.6, color: active ? (darkMode ? 'rgba(255,255,255,0.68)' : 'rgba(22,32,51,0.68)') : 'rgba(128,128,128,0.7)' }}>
{item.description}
</div>
</button>
);
})}
</div>
</div>
<div style={{ minWidth: 0, minHeight: 0, height: '100%', overflowY: 'auto', overflowX: 'hidden', paddingRight: 8, paddingBottom: 28 }}>
{activeSection === 'providers' && (isEditing ? renderProviderForm() : renderProviderList())}
{activeSection === 'safety' && renderSafetySettings()}
{activeSection === 'context' && renderContextSettings()}
{activeSection === 'tools' && renderBuiltinTools()}
{activeSection === 'prompts' && renderBuiltinPrompts()}
</div>
</div>
</Modal>
);
};
export default AISettingsModal;

View File

@@ -1,7 +1,9 @@
import React, { useState, useEffect, useRef, useMemo } from 'react';
import { Modal, Form, Input, InputNumber, Button, message, Checkbox, Divider, Select, Alert, Card, Row, Col, Typography, Collapse, Space, Table, Tag } from 'antd';
import { DatabaseOutlined, ConsoleSqlOutlined, FileTextOutlined, CloudServerOutlined, AppstoreAddOutlined, CloudOutlined, CheckCircleFilled, CloseCircleFilled, LinkOutlined, EditOutlined, AppstoreOutlined } from '@ant-design/icons';
import { DatabaseOutlined, ConsoleSqlOutlined, FileTextOutlined, CloudServerOutlined, AppstoreAddOutlined, CloudOutlined, CheckCircleFilled, CloseCircleFilled, LinkOutlined, EditOutlined, AppstoreOutlined, BgColorsOutlined } from '@ant-design/icons';
import { getDbIcon, getDbDefaultColor, getDbIconLabel, DB_ICON_TYPES, PRESET_ICON_COLORS } from './DatabaseIcons';
import { useStore } from '../store';
import { buildOverlayWorkbenchTheme } from '../utils/overlayWorkbenchTheme';
import { normalizeOpacityForPlatform, resolveAppearanceValues } from '../utils/appearance';
import { DBGetDatabases, GetDriverStatusList, MongoDiscoverMembers, TestConnection, RedisConnect, SelectDatabaseFile, SelectSSHKeyFile } from '../../wailsjs/go/app/App';
import { ConnectionConfig, MongoMemberInfo, SavedConnection } from '../types';
@@ -104,7 +106,9 @@ const ConnectionModal: React.FC<{
const [dbType, setDbType] = useState('mysql');
const [step, setStep] = useState(1); // 1: Select Type, 2: Configure
const [activeGroup, setActiveGroup] = useState(0); // Active category index in step 1
const [activeConfigSection, setActiveConfigSection] = useState<'basic' | 'network'>('basic');
const [activeConfigSection, setActiveConfigSection] = useState<'basic' | 'network' | 'appearance'>('basic');
const [customIconType, setCustomIconType] = useState<string | undefined>(undefined);
const [customIconColor, setCustomIconColor] = useState<string | undefined>(undefined);
const [activeNetworkConfig, setActiveNetworkConfig] = useState<'ssl' | 'ssh' | 'proxy' | 'httpTunnel'>('ssl');
const [testResult, setTestResult] = useState<{ type: 'success' | 'error', message: string } | null>(null);
const [testErrorLogOpen, setTestErrorLogOpen] = useState(false);
@@ -157,6 +161,7 @@ const ConnectionModal: React.FC<{
const step1SidebarDividerColor = darkMode ? STEP1_SIDEBAR_DIVIDER_DARK : STEP1_SIDEBAR_DIVIDER_LIGHT;
const step1SidebarActiveBg = darkMode ? 'rgba(246, 196, 83, 0.20)' : '#e6f4ff';
const step1SidebarActiveColor = darkMode ? '#ffd666' : '#1677ff';
const overlayTheme = useMemo(() => buildOverlayWorkbenchTheme(darkMode), [darkMode]);
const tunnelSectionStyle: React.CSSProperties = {
padding: '12px',
@@ -168,35 +173,33 @@ const ConnectionModal: React.FC<{
const modalShellStyle = useMemo(() => ({
background: darkMode
? 'linear-gradient(180deg, rgba(20,26,38,0.96) 0%, rgba(13,17,26,0.98) 100%)'
: 'linear-gradient(180deg, rgba(255,255,255,0.98) 0%, rgba(246,248,252,0.98) 100%)',
border: darkMode ? '1px solid rgba(255,255,255,0.08)' : '1px solid rgba(16,24,40,0.08)',
boxShadow: darkMode ? '0 24px 56px rgba(0,0,0,0.38)' : '0 18px 42px rgba(15,23,42,0.12)',
backdropFilter: darkMode ? 'blur(18px)' : 'none',
}), [darkMode]);
background: overlayTheme.shellBg,
border: overlayTheme.shellBorder,
boxShadow: overlayTheme.shellShadow,
backdropFilter: overlayTheme.shellBackdropFilter,
}), [overlayTheme]);
const modalInnerSectionStyle = useMemo(() => ({
padding: 14,
borderRadius: 14,
border: darkMode ? '1px solid rgba(255,255,255,0.08)' : '1px solid rgba(16,24,40,0.08)',
background: darkMode ? 'rgba(255,255,255,0.03)' : 'rgba(255,255,255,0.84)',
}), [darkMode]);
border: overlayTheme.sectionBorder,
background: overlayTheme.sectionBg,
}), [overlayTheme]);
const modalMutedTextStyle = useMemo(() => ({
color: darkMode ? 'rgba(255,255,255,0.5)' : 'rgba(16,24,40,0.55)',
color: overlayTheme.mutedText,
fontSize: 12,
lineHeight: 1.6,
}), [darkMode]);
}), [overlayTheme]);
const renderConnectionModalTitle = (icon: React.ReactNode, title: string, description: string) => (
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 12 }}>
<div style={{ width: 36, height: 36, borderRadius: 12, display: 'grid', placeItems: 'center', background: darkMode ? 'rgba(255,214,102,0.12)' : 'rgba(24,144,255,0.1)', color: darkMode ? '#ffd666' : '#1677ff', flexShrink: 0 }}>
<div style={{ width: 36, height: 36, borderRadius: 12, display: 'grid', placeItems: 'center', background: overlayTheme.iconBg, color: overlayTheme.iconColor, flexShrink: 0 }}>
{icon}
</div>
<div style={{ minWidth: 0 }}>
<div style={{ fontSize: 16, fontWeight: 700, color: darkMode ? '#f5f7ff' : '#162033' }}>{title}</div>
<div style={{ marginTop: 4, color: darkMode ? 'rgba(255,255,255,0.5)' : 'rgba(16,24,40,0.55)', fontSize: 12, lineHeight: 1.6 }}>{description}</div>
<div style={{ fontSize: 16, fontWeight: 700, color: overlayTheme.titleText }}>{title}</div>
<div style={{ marginTop: 4, color: overlayTheme.mutedText, fontSize: 12, lineHeight: 1.6 }}>{description}</div>
</div>
</div>
);
@@ -1013,7 +1016,7 @@ const ConnectionModal: React.FC<{
if (selectedPath) {
form.setFieldValue('sshKeyPath', selectedPath);
}
} else if (res?.message !== 'Cancelled') {
} else if (res?.message !== '已取消') {
message.error(`选择私钥文件失败: ${res?.message || '未知错误'}`);
}
} catch (e: any) {
@@ -1037,7 +1040,7 @@ const ConnectionModal: React.FC<{
if (selectedPath) {
form.setFieldValue('host', normalizeFileDbPath(selectedPath));
}
} else if (res?.message !== 'Cancelled') {
} else if (res?.message !== '已取消') {
message.error(`选择数据库文件失败: ${res?.message || '未知错误'}`);
}
} catch (e: any) {
@@ -1061,6 +1064,8 @@ const ConnectionModal: React.FC<{
setRedisDbList([]);
setMongoMembers([]);
setUriFeedback(null);
setCustomIconType(undefined);
setCustomIconColor(undefined);
setTypeSelectWarning(null);
setDriverStatusLoaded(false);
void refreshDriverStatus();
@@ -1146,6 +1151,8 @@ const ConnectionModal: React.FC<{
mongoReplicaPassword: config.mongoReplicaPassword || ''
});
setUseSSL(!!config.useSSL);
setCustomIconType(initialValues.iconType);
setCustomIconColor(initialValues.iconColor);
setUseSSH(config.useSSH || false);
setUseProxy(hasProxy);
setUseHttpTunnel(hasHttpTunnel);
@@ -1212,7 +1219,9 @@ const ConnectionModal: React.FC<{
name: values.name || (isFileDatabaseType(values.type) ? (values.type === 'duckdb' ? 'DuckDB DB' : 'SQLite DB') : (values.type === 'redis' ? `Redis ${displayHost}` : displayHost)),
config: config,
includeDatabases: values.includeDatabases,
includeRedisDatabases: isRedisType ? values.includeRedisDatabases : undefined
includeRedisDatabases: isRedisType ? values.includeRedisDatabases : undefined,
iconType: customIconType,
iconColor: customIconColor,
};
if (initialValues) {
@@ -1735,32 +1744,32 @@ const ConnectionModal: React.FC<{
const dbTypeGroups = [
{ label: '关系型数据库', items: [
{ key: 'mysql', name: 'MySQL', icon: <ConsoleSqlOutlined style={{ fontSize: 24, color: '#00758F' }} /> },
{ key: 'mariadb', name: 'MariaDB', icon: <ConsoleSqlOutlined style={{ fontSize: 24, color: '#003545' }} /> },
{ key: 'diros', name: 'Doris', icon: <ConsoleSqlOutlined style={{ fontSize: 24, color: '#0050b3' }} /> },
{ key: 'sphinx', name: 'Sphinx', icon: <ConsoleSqlOutlined style={{ fontSize: 24, color: '#2F5D62' }} /> },
{ key: 'clickhouse', name: 'ClickHouse', icon: <DatabaseOutlined style={{ fontSize: 24, color: '#FFCC01' }} /> },
{ key: 'postgres', name: 'PostgreSQL', icon: <DatabaseOutlined style={{ fontSize: 24, color: '#336791' }} /> },
{ key: 'sqlserver', name: 'SQL Server', icon: <DatabaseOutlined style={{ fontSize: 24, color: '#CC2927' }} /> },
{ key: 'sqlite', name: 'SQLite', icon: <FileTextOutlined style={{ fontSize: 24, color: '#003B57' }} /> },
{ key: 'duckdb', name: 'DuckDB', icon: <FileTextOutlined style={{ fontSize: 24, color: '#f59e0b' }} /> },
{ key: 'oracle', name: 'Oracle', icon: <DatabaseOutlined style={{ fontSize: 24, color: '#F80000' }} /> },
{ key: 'mysql', name: 'MySQL', icon: getDbIcon('mysql', undefined, 36) },
{ key: 'mariadb', name: 'MariaDB', icon: getDbIcon('mariadb', undefined, 36) },
{ key: 'diros', name: 'Doris', icon: getDbIcon('diros', undefined, 36) },
{ key: 'sphinx', name: 'Sphinx', icon: getDbIcon('sphinx', undefined, 36) },
{ key: 'clickhouse', name: 'ClickHouse', icon: getDbIcon('clickhouse', undefined, 36) },
{ key: 'postgres', name: 'PostgreSQL', icon: getDbIcon('postgres', undefined, 36) },
{ key: 'sqlserver', name: 'SQL Server', icon: getDbIcon('sqlserver', undefined, 36) },
{ key: 'sqlite', name: 'SQLite', icon: getDbIcon('sqlite', undefined, 36) },
{ key: 'duckdb', name: 'DuckDB', icon: getDbIcon('duckdb', undefined, 36) },
{ key: 'oracle', name: 'Oracle', icon: getDbIcon('oracle', undefined, 36) },
]},
{ label: '国产数据库', items: [
{ key: 'dameng', name: 'Dameng (达梦)', icon: <CloudServerOutlined style={{ fontSize: 24, color: '#1890ff' }} /> },
{ key: 'kingbase', name: 'Kingbase (人大金仓)', icon: <DatabaseOutlined style={{ fontSize: 24, color: '#faad14' }} /> },
{ key: 'highgo', name: 'HighGo (瀚高)', icon: <DatabaseOutlined style={{ fontSize: 24, color: '#00a854' }} /> },
{ key: 'vastbase', name: 'Vastbase (海量)', icon: <DatabaseOutlined style={{ fontSize: 24, color: '#1a6dff' }} /> },
{ key: 'dameng', name: 'Dameng (达梦)', icon: getDbIcon('dameng', undefined, 36) },
{ key: 'kingbase', name: 'Kingbase (人大金仓)', icon: getDbIcon('kingbase', undefined, 36) },
{ key: 'highgo', name: 'HighGo (瀚高)', icon: getDbIcon('highgo', undefined, 36) },
{ key: 'vastbase', name: 'Vastbase (海量)', icon: getDbIcon('vastbase', undefined, 36) },
]},
{ label: 'NoSQL', items: [
{ key: 'mongodb', name: 'MongoDB', icon: <CloudServerOutlined style={{ fontSize: 24, color: '#47A248' }} /> },
{ key: 'redis', name: 'Redis', icon: <CloudOutlined style={{ fontSize: 24, color: '#DC382D' }} /> },
{ key: 'mongodb', name: 'MongoDB', icon: getDbIcon('mongodb', undefined, 36) },
{ key: 'redis', name: 'Redis', icon: getDbIcon('redis', undefined, 36) },
]},
{ label: '时序数据库', items: [
{ key: 'tdengine', name: 'TDengine', icon: <DatabaseOutlined style={{ fontSize: 24, color: '#2F54EB' }} /> },
{ key: 'tdengine', name: 'TDengine', icon: getDbIcon('tdengine', undefined, 36) },
]},
{ label: '其他', items: [
{ key: 'custom', name: 'Custom (自定义)', icon: <AppstoreAddOutlined style={{ fontSize: 24, color: '#595959' }} /> },
{ key: 'custom', name: 'Custom (自定义)', icon: getDbIcon('custom', undefined, 36) },
]},
];
@@ -2512,16 +2521,101 @@ const ConnectionModal: React.FC<{
/>
)}
{(() => {
const sectionItems: Array<{ key: 'basic' | 'network'; title: string; description: string; icon: React.ReactNode }> = [
const sectionItems: Array<{ key: 'basic' | 'network' | 'appearance'; title: string; description: string; icon: React.ReactNode }> = [
{ key: 'basic', title: '基础信息', description: '名称、地址、认证、URI 与数据库范围', icon: <DatabaseOutlined /> },
...(!isCustom && !isFileDb ? [{ key: 'network' as const, title: '网络与安全', description: 'SSL、SSH、代理与高级连接', icon: <CloudOutlined /> }] : []),
{ key: 'appearance', title: '外观', description: '自定义图标与颜色', icon: <BgColorsOutlined /> },
];
const resolvedSection = sectionItems.some((item) => item.key === activeConfigSection)
? activeConfigSection
: sectionItems[0]?.key || 'basic';
const effectiveIconType = customIconType || dbType;
const effectiveIconColor = customIconColor || getDbDefaultColor(effectiveIconType);
const appearanceSection = (
<div style={{ display: 'grid', gap: 18 }}>
<div style={{ ...modalInnerSectionStyle, padding: 16 }}>
<div style={{ marginBottom: 12, fontSize: 13, fontWeight: 700, color: darkMode ? '#f5f7ff' : '#162033' }}></div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
{DB_ICON_TYPES.map((iconKey) => {
const isActive = effectiveIconType === iconKey;
return (
<button
key={iconKey}
type="button"
title={getDbIconLabel(iconKey)}
onClick={() => setCustomIconType(iconKey === dbType ? undefined : iconKey)}
style={{
width: 44, height: 44, borderRadius: 10,
display: 'grid', placeItems: 'center',
border: `2px solid ${isActive ? effectiveIconColor : (darkMode ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.06)')}`,
background: isActive
? (darkMode ? 'rgba(255,255,255,0.08)' : 'rgba(24,144,255,0.06)')
: 'transparent',
cursor: 'pointer',
transition: 'all 120ms ease',
}}
>
{getDbIcon(iconKey, isActive ? effectiveIconColor : undefined, 22)}
</button>
);
})}
</div>
<div style={{ marginTop: 6, fontSize: 11, color: darkMode ? 'rgba(255,255,255,0.45)' : 'rgba(0,0,0,0.35)' }}>
{getDbIconLabel(effectiveIconType)}
</div>
</div>
<div style={{ ...modalInnerSectionStyle, padding: 16 }}>
<div style={{ marginBottom: 12, fontSize: 13, fontWeight: 700, color: darkMode ? '#f5f7ff' : '#162033' }}></div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, alignItems: 'center' }}>
{PRESET_ICON_COLORS.map((presetColor) => {
const isActive = effectiveIconColor === presetColor;
return (
<button
key={presetColor}
type="button"
onClick={() => setCustomIconColor(presetColor === getDbDefaultColor(effectiveIconType) ? undefined : presetColor)}
style={{
width: 28, height: 28, borderRadius: 8,
background: presetColor,
border: isActive ? `2.5px solid ${darkMode ? '#fff' : '#162033'}` : '2px solid transparent',
cursor: 'pointer',
transition: 'all 120ms ease',
boxShadow: isActive ? `0 0 0 2px ${presetColor}40` : 'none',
}}
/>
);
})}
<input
type="color"
value={effectiveIconColor}
onChange={(e) => setCustomIconColor(e.target.value === getDbDefaultColor(effectiveIconType) ? undefined : e.target.value)}
title="自定义颜色"
style={{ width: 28, height: 28, border: 'none', padding: 0, cursor: 'pointer', borderRadius: 6, background: 'transparent' }}
/>
</div>
</div>
<div style={{ ...modalInnerSectionStyle, padding: 16, display: 'flex', alignItems: 'center', gap: 14 }}>
<div style={{ fontSize: 13, fontWeight: 700, color: darkMode ? '#f5f7ff' : '#162033' }}></div>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
{getDbIcon(effectiveIconType, effectiveIconColor, 24)}
<span style={{ fontSize: 14, color: darkMode ? '#e0e0e0' : '#333' }}>{form.getFieldValue('name') || '连接名称'}</span>
</div>
{(customIconType || customIconColor) && (
<Button size="small" type="link" onClick={() => { setCustomIconType(undefined); setCustomIconColor(undefined); }}>
</Button>
)}
</div>
</div>
);
const currentSectionContent = resolvedSection === 'basic'
? baseInfoSection
: networkSecuritySection;
: resolvedSection === 'appearance'
? appearanceSection
: networkSecuritySection;
if (sectionItems.length <= 1) {
return currentSectionContent;

File diff suppressed because it is too large Load Diff

View File

@@ -4,7 +4,7 @@ import { TabData, ColumnDefinition } from '../types';
import { useStore } from '../store';
import { DBQuery, DBGetColumns } from '../../wailsjs/go/app/App';
import DataGrid, { GONAVI_ROW_KEY } from './DataGrid';
import { buildOrderBySQL, buildPaginatedSelectSQL, buildWhereSQL, quoteIdentPart, quoteQualifiedIdent, withSortBufferTuningSQL, type FilterCondition } from '../utils/sql';
import { buildOrderBySQL, buildPaginatedSelectSQL, buildWhereSQL, hasExplicitSort, quoteIdentPart, quoteQualifiedIdent, withSortBufferTuningSQL, type FilterCondition } from '../utils/sql';
import { buildMongoCountCommand, buildMongoFilter, buildMongoFindCommand, buildMongoSort } from '../utils/mongodb';
import { getDataSourceCapabilities } from '../utils/dataSourceCapabilities';
@@ -157,7 +157,7 @@ type ViewerFilterSnapshot = {
conditions: FilterCondition[];
currentPage: number;
pageSize: number;
sortInfo: { columnKey: string, order: string } | null;
sortInfo: Array<{ columnKey: string, order: string, enabled?: boolean }>;
scrollTop: number;
scrollLeft: number;
};
@@ -185,16 +185,17 @@ const normalizeViewerFilterConditions = (conditions: FilterCondition[] | undefin
const getViewerFilterSnapshot = (tabId: string): ViewerFilterSnapshot => {
const cached = viewerFilterSnapshotsByTab.get(String(tabId || '').trim());
if (!cached) {
return { showFilter: false, conditions: [], currentPage: 1, pageSize: 100, sortInfo: null, scrollTop: 0, scrollLeft: 0 };
return { showFilter: false, conditions: [], currentPage: 1, pageSize: 100, sortInfo: [], scrollTop: 0, scrollLeft: 0 };
}
return {
showFilter: cached.showFilter === true,
conditions: normalizeViewerFilterConditions(cached.conditions),
currentPage: Number.isFinite(Number(cached.currentPage)) && Number(cached.currentPage) > 0 ? Number(cached.currentPage) : 1,
pageSize: Number.isFinite(Number(cached.pageSize)) && Number(cached.pageSize) > 0 ? Number(cached.pageSize) : 100,
sortInfo: cached.sortInfo && cached.sortInfo.columnKey && (cached.sortInfo.order === 'ascend' || cached.sortInfo.order === 'descend')
? { columnKey: String(cached.sortInfo.columnKey), order: cached.sortInfo.order }
: null,
sortInfo: Array.isArray(cached.sortInfo)
? cached.sortInfo.filter(s => s && s.columnKey && (s.order === 'ascend' || s.order === 'descend'))
.map(s => ({ columnKey: String(s.columnKey), order: s.order }))
: (cached.sortInfo && (cached.sortInfo as any).columnKey ? [{ columnKey: String((cached.sortInfo as any).columnKey), order: (cached.sortInfo as any).order }] : []),
scrollTop: Number.isFinite(Number(cached.scrollTop)) ? Number(cached.scrollTop) : 0,
scrollLeft: Number.isFinite(Number(cached.scrollLeft)) ? Number(cached.scrollLeft) : 0,
};
@@ -238,7 +239,7 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
totalCountCancelled: false,
});
const [sortInfo, setSortInfo] = useState<{ columnKey: string, order: string } | null>(initialViewerSnapshot.sortInfo);
const [sortInfo, setSortInfo] = useState<Array<{ columnKey: string, order: string, enabled?: boolean }>>(initialViewerSnapshot.sortInfo);
const [showFilter, setShowFilter] = useState<boolean>(initialViewerSnapshot.showFilter);
const [filterConditions, setFilterConditions] = useState<FilterCondition[]>(initialViewerSnapshot.conditions);
@@ -247,6 +248,20 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
const currentConnCaps = getDataSourceCapabilities(currentConnConfig);
const currentConnType = currentConnCaps.type;
const forceReadOnly = currentConnCaps.forceReadOnlyQueryResult;
const persistViewerSnapshot = useCallback((tabId: string, overrides?: Partial<ViewerFilterSnapshot>) => {
const normalizedTabId = String(tabId || '').trim();
if (!normalizedTabId) return;
viewerFilterSnapshotsByTab.set(normalizedTabId, {
showFilter,
conditions: normalizeViewerFilterConditions(filterConditions),
currentPage: pagination.current,
pageSize: pagination.pageSize,
sortInfo,
scrollTop: scrollSnapshotRef.current.top,
scrollLeft: scrollSnapshotRef.current.left,
...overrides,
});
}, [showFilter, filterConditions, pagination.current, pagination.pageSize, sortInfo]);
useEffect(() => {
const snapshot = getViewerFilterSnapshot(tab.id);
@@ -258,16 +273,14 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
}, [tab.id]);
useEffect(() => {
viewerFilterSnapshotsByTab.set(tab.id, {
showFilter,
conditions: normalizeViewerFilterConditions(filterConditions),
currentPage: pagination.current,
pageSize: pagination.pageSize,
sortInfo,
scrollTop: scrollSnapshotRef.current.top,
scrollLeft: scrollSnapshotRef.current.left,
});
}, [tab.id, showFilter, filterConditions, pagination.current, pagination.pageSize, sortInfo]);
persistViewerSnapshot(tab.id);
}, [tab.id, persistViewerSnapshot]);
useEffect(() => {
return () => {
persistViewerSnapshot(tab.id);
};
}, [tab.id, persistViewerSnapshot]);
useEffect(() => {
const snapshot = getViewerFilterSnapshot(tab.id);
@@ -298,13 +311,11 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
const handleTableScrollSnapshotChange = useCallback((snapshot: ViewerScrollSnapshot) => {
scrollSnapshotRef.current = snapshot;
const cached = getViewerFilterSnapshot(tab.id);
viewerFilterSnapshotsByTab.set(tab.id, {
...cached,
persistViewerSnapshot(tab.id, {
scrollTop: snapshot.top,
scrollLeft: snapshot.left,
});
}, [tab.id]);
}, [tab.id, persistViewerSnapshot]);
const handleDuckDBManualCount = useCallback(async () => {
if (latestDbTypeRef.current !== 'duckdb') {
@@ -501,7 +512,7 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
}
};
const hasSort = !!sortInfo?.columnKey && (sortInfo?.order === 'ascend' || sortInfo?.order === 'descend');
const hasSort = hasExplicitSort(sortInfo);
const isSortMemoryErr = (msg: string) => /error\s*1038|out of sort memory/i.test(String(msg || ''));
let resData = await executeDataQuery(sql, '主查询');
@@ -778,13 +789,21 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
fetchData(pagination.current, pagination.pageSize);
}, [fetchData, pagination.current, pagination.pageSize]);
const handleSort = useCallback((field: string, order: string) => {
// 支持多字段排序field 为 JSON 数组字符串时解析为多字段
try {
const parsed = JSON.parse(field);
if (Array.isArray(parsed)) {
setSortInfo(parsed.filter((s: any) => s && s.columnKey && (s.order === 'ascend' || s.order === 'descend')));
return;
}
} catch { /* 单字段模式 */ }
const normalizedOrder = order === 'ascend' || order === 'descend' ? order : '';
const normalizedField = String(field || '').trim();
if (!normalizedField || !normalizedOrder) {
setSortInfo(null);
setSortInfo([]);
return;
}
setSortInfo({ columnKey: normalizedField, order: normalizedOrder });
setSortInfo([{ columnKey: normalizedField, order: normalizedOrder, enabled: true }]);
}, []);
const handlePageChange = useCallback((page: number, size: number) => fetchData(page, size), [fetchData]);
const handleToggleFilter = useCallback(() => setShowFilter(prev => !prev), []);
@@ -801,8 +820,8 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
let sql = `SELECT * FROM ${quoteQualifiedIdent(dbType, tableName)} ${whereSQL}`;
sql += buildOrderBySQL(dbType, sortInfo, pkColumns);
const normalizedType = dbType.toLowerCase();
const hasExplicitSort = !!sortInfo?.columnKey && (sortInfo?.order === 'ascend' || sortInfo?.order === 'descend');
if (hasExplicitSort && (normalizedType === 'mysql' || normalizedType === 'mariadb')) {
const hasSortForBuffer = hasExplicitSort(sortInfo);
if (hasSortForBuffer && (normalizedType === 'mysql' || normalizedType === 'mariadb')) {
sql = withSortBufferTuningSQL(normalizedType, sql, 32 * 1024 * 1024);
}
return sql;

View File

@@ -0,0 +1,217 @@
import React from 'react';
// ─── 公共接口 ───────────────────────────────────────────────
export interface DbIconProps {
size?: number;
color?: string;
}
// ─── 默认色表 ───────────────────────────────────────────────
const DB_DEFAULT_COLORS: Record<string, string> = {
mysql: '#00758F',
mariadb: '#003545',
postgres: '#336791',
redis: '#DC382D',
mongodb: '#47A248',
kingbase: '#1890FF',
dameng: '#E6002D',
oracle: '#F80000',
sqlserver: '#CC2927',
clickhouse: '#FFBF00',
sqlite: '#003B57',
duckdb: '#FFC107',
vastbase: '#0066CC',
highgo: '#00A86B',
tdengine: '#2962FF',
diros: '#0050B3',
sphinx: '#2F5D62',
custom: '#888888',
};
export const getDbDefaultColor = (type: string): string =>
DB_DEFAULT_COLORS[type?.toLowerCase()] || DB_DEFAULT_COLORS.custom;
// ─── 有品牌 SVG 文件的数据库类型(文件在 /db-icons/ 下) ────
const BRAND_SVG_TYPES = new Set([
'mysql', 'mariadb', 'postgres', 'redis', 'mongodb', 'clickhouse', 'sqlite',
'diros', 'sphinx', 'duckdb',
]);
/** 品牌 SVG 图标:用 <img> 加载 /db-icons/*.svg */
const BrandSvgIcon: React.FC<{ type: string; size: number; color?: string }> = ({ type, size, color }) => {
const bgColor = color || getDbDefaultColor(type);
return (
<span style={{
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
width: size, height: size, borderRadius: size * 0.22,
background: '#fff', border: `1.5px solid ${bgColor}`,
flexShrink: 0, overflow: 'hidden',
}}>
<img
src={`/db-icons/${type}.svg`}
alt={type}
width={size * 0.7}
height={size * 0.7}
style={{ display: 'block' }}
/>
</span>
);
};
// ─── 彩色标签图标fallback ──────────────────────────────
/** 通用彩色标签:填充背景 + 白色粗体缩写 */
const ColorBadge: React.FC<{ size: number; color: string; label: string }> = ({ size, color, label }) => {
const textSize = label.length <= 2 ? size * 0.48 : size * 0.38;
return (
<svg width={size} height={size} viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<rect x="1" y="1" width="22" height="22" rx="5" fill={color}/>
<text
x="12" y="12" dominantBaseline="central" textAnchor="middle"
fontSize={textSize} fontWeight="800" fontFamily="system-ui,-apple-system,sans-serif"
fill="#fff" letterSpacing={label.length > 2 ? -0.5 : 0}
>
{label}
</text>
</svg>
);
};
// ─── 各数据库图标 ───────────────────────────────────────────
// 有品牌 SVG 的数据库
const MySQLIcon: React.FC<DbIconProps> = ({ size = 16, color }) => (
<BrandSvgIcon type="mysql" size={size} color={color} />
);
const MariaDBIcon: React.FC<DbIconProps> = ({ size = 16, color }) => (
<BrandSvgIcon type="mariadb" size={size} color={color} />
);
const PostgresIcon: React.FC<DbIconProps> = ({ size = 16, color }) => (
<BrandSvgIcon type="postgres" size={size} color={color} />
);
const RedisIcon: React.FC<DbIconProps> = ({ size = 16, color }) => (
<BrandSvgIcon type="redis" size={size} color={color} />
);
const MongoDBIcon: React.FC<DbIconProps> = ({ size = 16, color }) => (
<BrandSvgIcon type="mongodb" size={size} color={color} />
);
const ClickHouseIcon: React.FC<DbIconProps> = ({ size = 16, color }) => (
<BrandSvgIcon type="clickhouse" size={size} color={color} />
);
const SQLiteIcon: React.FC<DbIconProps> = ({ size = 16, color }) => (
<BrandSvgIcon type="sqlite" size={size} color={color} />
);
// 无品牌 SVG → 彩色文字标签
const OracleIcon: React.FC<DbIconProps> = ({ size = 16, color }) => (
<ColorBadge size={size} color={color || DB_DEFAULT_COLORS.oracle} label="Or" />
);
const SQLServerIcon: React.FC<DbIconProps> = ({ size = 16, color }) => (
<ColorBadge size={size} color={color || DB_DEFAULT_COLORS.sqlserver} label="SS" />
);
const DorisIcon: React.FC<DbIconProps> = ({ size = 16, color }) => (
<BrandSvgIcon type="diros" size={size} color={color} />
);
const SphinxIcon: React.FC<DbIconProps> = ({ size = 16, color }) => (
<BrandSvgIcon type="sphinx" size={size} color={color} />
);
const DuckDBIcon: React.FC<DbIconProps> = ({ size = 16, color }) => (
<BrandSvgIcon type="duckdb" size={size} color={color} />
);
const KingBaseIcon: React.FC<DbIconProps> = ({ size = 16, color }) => (
<ColorBadge size={size} color={color || DB_DEFAULT_COLORS.kingbase} label="KB" />
);
const DamengIcon: React.FC<DbIconProps> = ({ size = 16, color }) => (
<ColorBadge size={size} color={color || DB_DEFAULT_COLORS.dameng} label="DM" />
);
const VastBaseIcon: React.FC<DbIconProps> = ({ size = 16, color }) => (
<ColorBadge size={size} color={color || DB_DEFAULT_COLORS.vastbase} label="VB" />
);
const HighGoIcon: React.FC<DbIconProps> = ({ size = 16, color }) => (
<ColorBadge size={size} color={color || DB_DEFAULT_COLORS.highgo} label="HG" />
);
const TDengineIcon: React.FC<DbIconProps> = ({ size = 16, color }) => (
<ColorBadge size={size} color={color || DB_DEFAULT_COLORS.tdengine} label="TD" />
);
/** Custom — 齿轮图标 */
const CustomIcon: React.FC<DbIconProps> = ({ size = 16, color }) => {
const c = color || DB_DEFAULT_COLORS.custom;
return (
<svg width={size} height={size} viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<rect x="1" y="1" width="22" height="22" rx="5" fill={c}/>
<circle cx="12" cy="12" r="3.5" stroke="#fff" strokeWidth="1.5" fill="none"/>
<path d="M12 4v2.5M12 17.5V20M4 12h2.5M17.5 12H20M6.34 6.34l1.77 1.77M15.89 15.89l1.77 1.77M6.34 17.66l1.77-1.77M15.89 8.11l1.77-1.77" stroke="#fff" strokeWidth="1.3" strokeLinecap="round"/>
</svg>
);
};
// ─── 图标注册表 ─────────────────────────────────────────────
const DorisIconFallback: React.FC<DbIconProps> = ({ size = 16, color }) => (
<ColorBadge size={size} color={color || DB_DEFAULT_COLORS.diros} label="Do" />
);
const SphinxIconFallback: React.FC<DbIconProps> = ({ size = 16, color }) => (
<ColorBadge size={size} color={color || DB_DEFAULT_COLORS.sphinx} label="Sp" />
);
const DB_ICON_MAP: Record<string, React.FC<DbIconProps>> = {
mysql: MySQLIcon,
mariadb: MariaDBIcon,
diros: DorisIcon,
sphinx: SphinxIcon,
postgres: PostgresIcon,
redis: RedisIcon,
mongodb: MongoDBIcon,
kingbase: KingBaseIcon,
dameng: DamengIcon,
oracle: OracleIcon,
sqlserver: SQLServerIcon,
clickhouse: ClickHouseIcon,
sqlite: SQLiteIcon,
duckdb: DuckDBIcon,
vastbase: VastBaseIcon,
highgo: HighGoIcon,
tdengine: TDengineIcon,
custom: CustomIcon,
};
/** 可选图标类型列表(用于图标选择器 UI */
export const DB_ICON_TYPES: string[] = [
'mysql', 'mariadb', 'postgres', 'redis', 'mongodb',
'oracle', 'sqlserver', 'sqlite', 'duckdb', 'clickhouse',
'kingbase', 'dameng', 'vastbase', 'highgo', 'tdengine', 'custom',
];
/** 该类型是否有品牌 SVG 文件 */
export const hasBrandSvg = (type: string): boolean => BRAND_SVG_TYPES.has(type?.toLowerCase());
/** 获取数据库图标 React 节点 */
export const getDbIcon = (type: string, color?: string, size?: number): React.ReactNode => {
const key = (type || 'custom').toLowerCase();
const Component = DB_ICON_MAP[key] || CustomIcon;
return <Component size={size} color={color} />;
};
/** 获取数据库图标显示名称(中文) */
export const getDbIconLabel = (type: string): string => {
const labels: Record<string, string> = {
mysql: 'MySQL', mariadb: 'MariaDB', postgres: 'PostgreSQL',
redis: 'Redis', mongodb: 'MongoDB', oracle: 'Oracle',
sqlserver: 'SQL Server', clickhouse: 'ClickHouse', sqlite: 'SQLite',
duckdb: 'DuckDB', kingbase: '金仓', dameng: '达梦',
vastbase: 'VastBase', highgo: '瀚高', tdengine: 'TDengine',
custom: '自定义',
};
return labels[type?.toLowerCase()] || type;
};
/** 预设颜色列表 */
export const PRESET_ICON_COLORS: string[] = [
'#336791', '#00758F', '#DC382D', '#47A248', '#F80000',
'#CC2927', '#1890FF', '#E6002D', '#FFBF00', '#2962FF',
'#00A86B', '#0066CC', '#FF6B35', '#7C3AED',
];

View File

@@ -847,7 +847,7 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void; onOpenG
const installDriverFromLocalFile = useCallback(async (row: DriverStatusRow) => {
const fileRes = await SelectDriverPackageFile(downloadDir);
if (!fileRes?.success) {
if (String(fileRes?.message || '') !== 'Cancelled') {
if (String(fileRes?.message || '') !== '已取消') {
message.error(fileRes?.message || '选择本地驱动包文件失败');
}
return;
@@ -863,7 +863,7 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void; onOpenG
const installDriversFromDirectory = useCallback(async () => {
const directoryRes = await SelectDriverPackageDirectory(downloadDir);
if (!directoryRes?.success) {
if (String(directoryRes?.message || '') !== 'Cancelled') {
if (String(directoryRes?.message || '') !== '已取消') {
message.error(directoryRes?.message || '选择本地驱动包目录失败');
}
return;

View File

@@ -0,0 +1,462 @@
import React, { useState, useRef, useCallback, useMemo } from 'react';
import { Modal, Input, Button, Table, Progress, Space, Tag, message, Tooltip, Select, Empty } from 'antd';
import { SearchOutlined, StopOutlined, EyeOutlined, DatabaseOutlined } from '@ant-design/icons';
import { DBQuery, DBGetTables, DBGetAllColumns } from '../../wailsjs/go/app/App';
import { quoteIdentPart, escapeLiteral } from '../utils/sql';
import { useStore } from '../store';
import { buildOverlayWorkbenchTheme } from '../utils/overlayWorkbenchTheme';
interface FindInDatabaseModalProps {
open: boolean;
onClose: () => void;
connectionId: string;
dbName: string;
}
interface SearchResultItem {
tableName: string;
matchedColumns: string[];
matchCount: number;
rows: Record<string, any>[];
columns: string[];
}
/** 判断数据库列类型是否为文本类型(只搜索文本字段) */
const isTextColumnType = (colType: string): boolean => {
const t = (colType || '').toLowerCase().trim();
// 显式排除非文本类型
if (/^(int|bigint|smallint|tinyint|mediumint|float|double|decimal|numeric|real|money|smallmoney|bit|boolean|bool)/.test(t)) return false;
if (/^(date|time|datetime|timestamp|year|interval)/.test(t)) return false;
if (/^(blob|binary|varbinary|image|bytea|raw|long raw)/.test(t)) return false;
if (/^(geometry|geography|point|line|polygon|spatial)/.test(t)) return false;
if (/^(json|jsonb|xml|uuid|uniqueidentifier)/.test(t)) return false;
if (/^(serial|bigserial|smallserial|autoincrement|identity)/.test(t)) return false;
// 文本类型正匹配
if (/^(varchar|char|nvarchar|nchar|text|ntext|tinytext|mediumtext|longtext|string|clob|nclob|character)/.test(t)) return true;
if (t === 'sysname' || t === 'sql_variant') return true;
// 未知类型默认尝试搜索
return true;
};
/** 根据 dbType 构建限制返回行数的 SELECT SQL */
const buildLimitedSelectSQL = (dbType: string, baseSql: string, limit: number): string => {
const normalizedType = (dbType || '').toLowerCase();
switch (normalizedType) {
case 'sqlserver':
case 'mssql':
return baseSql.replace(/^SELECT\b/i, `SELECT TOP ${limit}`);
case 'oracle':
case 'dameng':
return `${baseSql} FETCH FIRST ${limit} ROWS ONLY`;
default:
return `${baseSql} LIMIT ${limit}`;
}
};
const MAX_MATCH_ROWS_PER_TABLE = 100;
const FindInDatabaseModal: React.FC<FindInDatabaseModalProps> = ({ open, onClose, connectionId, dbName }) => {
const [keyword, setKeyword] = useState('');
const [matchMode, setMatchMode] = useState<'contains' | 'exact'>('contains');
const [searching, setSearching] = useState(false);
const [results, setResults] = useState<SearchResultItem[]>([]);
const [progress, setProgress] = useState({ current: 0, total: 0, tableName: '' });
const [expandedTable, setExpandedTable] = useState<string | null>(null);
const cancelledRef = useRef(false);
const connections = useStore(state => state.connections);
const theme = useStore(state => state.theme);
const conn = useMemo(() => connections.find(c => c.id === connectionId), [connections, connectionId]);
const dbType = useMemo(() => (conn?.config?.type || 'mysql').toLowerCase(), [conn]);
const wt = useMemo(() => {
const isDark = theme === 'dark';
return buildOverlayWorkbenchTheme(isDark);
}, [theme]);
const buildConfig = useCallback(() => {
if (!conn) return null;
return {
...conn.config,
port: Number(conn.config.port),
password: conn.config.password || "",
database: conn.config.database || "",
useSSH: conn.config.useSSH || false,
ssh: conn.config.ssh || { host: "", port: 22, user: "", password: "", keyPath: "" }
};
}, [conn]);
const handleSearch = useCallback(async () => {
const searchKeyword = keyword.trim();
if (!searchKeyword) {
message.warning('请输入搜索关键字');
return;
}
const config = buildConfig();
if (!config) {
message.error('未找到连接配置');
return;
}
setSearching(true);
setResults([]);
setExpandedTable(null);
cancelledRef.current = false;
try {
// 1. 获取所有表
const tablesRes = await DBGetTables(config as any, dbName);
if (!tablesRes.success) {
message.error('获取表列表失败: ' + tablesRes.message);
setSearching(false);
return;
}
const tableRows: any[] = Array.isArray(tablesRes.data) ? tablesRes.data : [];
const tableNames = tableRows.map((row: any) => Object.values(row)[0] as string).filter(Boolean);
if (tableNames.length === 0) {
message.info('当前数据库没有表');
setSearching(false);
return;
}
setProgress({ current: 0, total: tableNames.length, tableName: '' });
// 2. 获取所有列信息(返回 any[],含 tableName/name/type 字段)
const allColsRes = await DBGetAllColumns(config as any, dbName);
const allColumns: any[] = (allColsRes?.success && Array.isArray(allColsRes.data)) ? allColsRes.data : [];
// 按表名分组
const columnsByTable: Record<string, Array<{ name: string; type: string }>> = {};
allColumns.forEach((col: any) => {
const tbl = col.tableName || '';
if (!columnsByTable[tbl]) columnsByTable[tbl] = [];
columnsByTable[tbl].push({ name: col.name, type: col.type || '' });
});
const searchResults: SearchResultItem[] = [];
const escapedKeyword = escapeLiteral(searchKeyword);
// 3. 逐表搜索
for (let i = 0; i < tableNames.length; i++) {
if (cancelledRef.current) break;
const tableName = tableNames[i];
setProgress({ current: i + 1, total: tableNames.length, tableName });
// 获取该表的文本列
const tableCols = columnsByTable[tableName] || [];
const textCols = tableCols.filter(c => isTextColumnType(c.type));
if (textCols.length === 0) continue;
// 构建 WHERE 子句
const castType = (dbType === 'sqlserver' || dbType === 'mssql') ? 'NVARCHAR(MAX)' : 'CHAR';
const whereConditions = textCols.map(c => {
const quotedCol = quoteIdentPart(dbType, c.name);
if (matchMode === 'exact') {
return `CAST(${quotedCol} AS ${castType}) = '${escapedKeyword}'`;
}
return `CAST(${quotedCol} AS ${castType}) LIKE '%${escapedKeyword}%'`;
});
const quotedTable = quoteIdentPart(dbType, tableName);
const baseSql = `SELECT * FROM ${quotedTable} WHERE ${whereConditions.join(' OR ')}`;
const sql = buildLimitedSelectSQL(dbType, baseSql, MAX_MATCH_ROWS_PER_TABLE);
try {
const res = await DBQuery(config as any, dbName, sql);
if (res.success && Array.isArray(res.data) && res.data.length > 0) {
// 检查哪些列实际匹配了
const matchedCols = new Set<string>();
const lowerKeyword = searchKeyword.toLowerCase();
res.data.forEach((row: any) => {
textCols.forEach(c => {
const val = row[c.name];
if (val != null) {
const strVal = String(val).toLowerCase();
if (matchMode === 'exact' ? strVal === lowerKeyword : strVal.includes(lowerKeyword)) {
matchedCols.add(c.name);
}
}
});
});
if (matchedCols.size > 0) {
const columns = Object.keys(res.data[0]);
searchResults.push({
tableName,
matchedColumns: Array.from(matchedCols),
matchCount: res.data.length,
rows: res.data,
columns,
});
setResults([...searchResults]);
}
}
} catch {
// 单表查询失败不中断整体搜索
}
}
if (!cancelledRef.current) {
setResults([...searchResults]);
if (searchResults.length === 0) {
message.info('未找到匹配的数据');
}
}
} catch (e: any) {
message.error('搜索出错: ' + (e?.message || String(e)));
} finally {
setSearching(false);
}
}, [keyword, matchMode, dbName, dbType, buildConfig]);
const handleCancel = useCallback(() => {
cancelledRef.current = true;
}, []);
const handleClose = useCallback(() => {
cancelledRef.current = true;
setResults([]);
setExpandedTable(null);
setProgress({ current: 0, total: 0, tableName: '' });
onClose();
}, [onClose]);
// 汇总表的列定义
const summaryColumns = useMemo(() => [
{
title: '表名',
dataIndex: 'tableName',
key: 'tableName',
width: 220,
render: (text: string) => (
<span style={{ fontWeight: 500, color: wt.titleText }}>
<DatabaseOutlined style={{ marginRight: 6, color: wt.iconColor }} />
{text}
</span>
),
},
{
title: '匹配列',
dataIndex: 'matchedColumns',
key: 'matchedColumns',
render: (cols: string[]) => (
<Space size={4} wrap>
{cols.map(col => (
<Tag key={col} color="blue" style={{ margin: 0, fontSize: 12 }}>{col}</Tag>
))}
</Space>
),
},
{
title: '命中行数',
dataIndex: 'matchCount',
key: 'matchCount',
width: 100,
align: 'center' as const,
render: (count: number) => (
<Tag color={count >= MAX_MATCH_ROWS_PER_TABLE ? 'orange' : 'green'}>
{count >= MAX_MATCH_ROWS_PER_TABLE ? `${count}` : count}
</Tag>
),
},
{
title: '操作',
key: 'action',
width: 80,
align: 'center' as const,
render: (_: any, record: SearchResultItem) => (
<Tooltip title={expandedTable === record.tableName ? '收起详情' : '查看详情'}>
<Button
type="text"
size="small"
icon={<EyeOutlined />}
onClick={(e) => { e.stopPropagation(); setExpandedTable(prev => prev === record.tableName ? null : record.tableName); }}
style={{ color: wt.iconColor }}
/>
</Tooltip>
),
},
], [wt, expandedTable]);
// 展开的详情行 - 动态列
const expandedResult = useMemo(() => {
if (!expandedTable) return null;
return results.find(r => r.tableName === expandedTable);
}, [expandedTable, results]);
const detailColumns = useMemo(() => {
if (!expandedResult) return [];
const lowerKeyword = keyword.trim().toLowerCase();
return expandedResult.columns.map(col => ({
title: col,
dataIndex: col,
key: col,
width: 180,
ellipsis: true,
render: (value: any) => {
const strVal = value != null ? String(value) : '';
const isMatch = expandedResult.matchedColumns.includes(col) &&
strVal.toLowerCase().includes(lowerKeyword);
return (
<Tooltip title={strVal} placement="topLeft">
<span style={isMatch ? { background: 'rgba(255, 193, 7, 0.3)', padding: '1px 3px', borderRadius: 3 } : undefined}>
{strVal || <span style={{ color: wt.mutedText }}>NULL</span>}
</span>
</Tooltip>
);
},
}));
}, [expandedResult, keyword, wt]);
const percent = progress.total > 0 ? Math.round((progress.current / progress.total) * 100) : 0;
return (
<Modal
title={
<span style={{ color: wt.titleText, fontWeight: 600 }}>
<SearchOutlined style={{ marginRight: 8, color: wt.iconColor }} />
{dbName}
</span>
}
open={open}
onCancel={handleClose}
footer={null}
width={960}
styles={{
content: {
background: wt.shellBg,
borderRadius: 16,
border: wt.shellBorder,
boxShadow: wt.shellShadow,
backdropFilter: wt.shellBackdropFilter,
WebkitBackdropFilter: wt.shellBackdropFilter,
},
header: { background: 'transparent', borderBottom: 'none', paddingBottom: 8 },
body: { paddingTop: 8 },
}}
destroyOnClose
>
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
{/* 搜索栏 */}
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<Input
placeholder="输入要搜索的字符串..."
value={keyword}
onChange={e => setKeyword(e.target.value)}
onPressEnter={!searching ? handleSearch : undefined}
style={{ flex: 1 }}
disabled={searching}
autoFocus
/>
<Select
value={matchMode}
onChange={v => setMatchMode(v)}
disabled={searching}
style={{ width: 110 }}
options={[
{ label: '包含', value: 'contains' },
{ label: '精确匹配', value: 'exact' },
]}
/>
{searching ? (
<Button icon={<StopOutlined />} danger onClick={handleCancel}>
</Button>
) : (
<Button type="primary" icon={<SearchOutlined />} onClick={handleSearch} disabled={!keyword.trim()}>
</Button>
)}
</div>
{/* 进度条 */}
{searching && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
<Progress
percent={percent}
size="small"
status="active"
strokeColor={wt.iconColor}
/>
<span style={{ fontSize: 12, color: wt.mutedText }}>
{progress.tableName}... ({progress.current}/{progress.total})
</span>
</div>
)}
{/* 结果汇总表 */}
{results.length > 0 && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<div style={{ fontSize: 13, color: wt.mutedText, fontWeight: 500 }}>
{results.length}
{searching && '(搜索进行中...'}
</div>
<Table
dataSource={results}
columns={summaryColumns}
rowKey="tableName"
size="small"
pagination={false}
style={{ borderRadius: 8, overflow: 'hidden' }}
scroll={{ y: expandedTable ? 200 : 400 }}
onRow={(record) => ({
style: {
cursor: 'pointer',
background: expandedTable === record.tableName ? wt.hoverBg : undefined,
},
onClick: () => setExpandedTable(prev => prev === record.tableName ? null : record.tableName),
})}
/>
</div>
)}
{/* 详情展开 */}
{expandedResult && (
<div style={{
border: wt.sectionBorder,
borderRadius: 8,
background: wt.sectionBg,
overflow: 'hidden',
}}>
<div style={{
padding: '8px 12px',
borderBottom: wt.sectionBorder,
fontSize: 13,
fontWeight: 500,
color: wt.titleText,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
}}>
<span>
<DatabaseOutlined style={{ marginRight: 6 }} />
{expandedResult.tableName}
</span>
<Tag color="blue">{expandedResult.rows.length} </Tag>
</div>
<Table
dataSource={expandedResult.rows.map((row, i) => ({ ...row, __rowIdx: i }))}
columns={detailColumns}
rowKey="__rowIdx"
size="small"
pagination={{ pageSize: 20, size: 'small', showSizeChanger: false }}
scroll={{ x: Math.max(800, expandedResult.columns.length * 180) }}
style={{ fontSize: 12 }}
/>
</div>
)}
{/* 无结果且搜索完成 */}
{!searching && results.length === 0 && progress.total > 0 && (
<Empty description="未找到匹配的数据" style={{ margin: '24px 0' }} />
)}
</div>
</Modal>
);
};
export default FindInDatabaseModal;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,5 @@
import React, { useEffect, useState, useMemo, useRef } from 'react';
import { Tree, message, Dropdown, MenuProps, Input, Button, Modal, Form, Badge, Checkbox, Space, Select, Popover, Tooltip } from 'antd';
import { Tree, message, Dropdown, MenuProps, Input, Button, Modal, Form, Badge, Checkbox, Space, Select, Popover, Tooltip, Progress } from 'antd';
import {
DatabaseOutlined,
TableOutlined,
@@ -32,10 +32,14 @@ import { Tree, message, Dropdown, MenuProps, Input, Button, Modal, Form, Badge,
CheckOutlined,
FilterOutlined
} from '@ant-design/icons';
import { useStore } from '../store';
import { useStore } from '../store';
import { buildOverlayWorkbenchTheme } from '../utils/overlayWorkbenchTheme';
import { SavedConnection } from '../types';
import { DBGetDatabases, DBGetTables, DBQuery, DBShowCreateTable, ExportTable, OpenSQLFile, CreateDatabase, RenameDatabase, DropDatabase, RenameTable, DropTable, DropView, DropFunction, RenameView } from '../../wailsjs/go/app/App';
import { getDbIcon } from './DatabaseIcons';
import { DBGetDatabases, DBGetTables, DBQuery, DBShowCreateTable, ExportTable, OpenSQLFile, ExecuteSQLFile, CancelSQLFileExecution, CreateDatabase, RenameDatabase, DropDatabase, RenameTable, DropTable, DropView, DropFunction, RenameView } from '../../wailsjs/go/app/App';
import { EventsOn } from '../../wailsjs/runtime/runtime';
import { normalizeOpacityForPlatform, resolveAppearanceValues } from '../utils/appearance';
import FindInDatabaseModal from './FindInDatabaseModal';
const { Search } = Input;
@@ -88,6 +92,7 @@ const SEARCH_SCOPE_ICON_MAP: Record<SearchScope, React.ReactNode> = {
const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }> = ({ onEditConnection }) => {
const connections = useStore(state => state.connections);
const savedQueries = useStore(state => state.savedQueries);
const deleteQuery = useStore(state => state.deleteQuery);
const addConnection = useStore(state => state.addConnection);
const addTab = useStore(state => state.addTab);
const setActiveContext = useStore(state => state.setActiveContext);
@@ -106,6 +111,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
const tableSortPreference = useStore(state => state.tableSortPreference);
const recordTableAccess = useStore(state => state.recordTableAccess);
const setTableSortPreference = useStore(state => state.setTableSortPreference);
const addSqlLog = useStore(state => state.addSqlLog);
const darkMode = theme === 'dark';
const resolvedAppearance = resolveAppearanceValues(appearance);
const opacity = normalizeOpacityForPlatform(resolvedAppearance.opacity);
@@ -121,41 +127,40 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
return `rgba(${r}, ${g}, ${b}, ${opacity})`;
};
const bgMain = getBg('#141414');
const overlayTheme = useMemo(() => buildOverlayWorkbenchTheme(darkMode), [darkMode]);
const modalPanelStyle = useMemo(() => ({
background: darkMode
? 'linear-gradient(180deg, rgba(20,26,38,0.96) 0%, rgba(13,17,26,0.98) 100%)'
: 'linear-gradient(180deg, rgba(255,255,255,0.98) 0%, rgba(246,248,252,0.98) 100%)',
border: darkMode ? '1px solid rgba(255,255,255,0.08)' : '1px solid rgba(16,24,40,0.08)',
boxShadow: darkMode ? '0 20px 48px rgba(0,0,0,0.38)' : '0 18px 42px rgba(15,23,42,0.12)',
backdropFilter: darkMode ? 'blur(18px)' : 'none',
}), [darkMode]);
background: overlayTheme.shellBg,
border: overlayTheme.shellBorder,
boxShadow: overlayTheme.shellShadow,
backdropFilter: overlayTheme.shellBackdropFilter,
}), [overlayTheme]);
const modalSectionStyle = useMemo(() => ({
padding: 14,
borderRadius: 14,
border: darkMode ? '1px solid rgba(255,255,255,0.08)' : '1px solid rgba(16,24,40,0.08)',
background: darkMode ? 'rgba(255,255,255,0.03)' : 'rgba(255,255,255,0.84)',
}), [darkMode]);
border: overlayTheme.sectionBorder,
background: overlayTheme.sectionBg,
}), [overlayTheme]);
const modalScrollSectionStyle = useMemo(() => ({
maxHeight: 400,
overflow: 'auto' as const,
border: darkMode ? '1px solid rgba(255,255,255,0.08)' : '1px solid rgba(16,24,40,0.08)',
border: overlayTheme.sectionBorder,
borderRadius: 14,
padding: 12,
background: darkMode ? 'rgba(255,255,255,0.03)' : 'rgba(255,255,255,0.8)',
}), [darkMode]);
background: overlayTheme.sectionBg,
}), [overlayTheme]);
const modalHintTextStyle = useMemo(() => ({
color: darkMode ? 'rgba(255,255,255,0.5)' : 'rgba(16,24,40,0.55)',
color: overlayTheme.mutedText,
fontSize: 12,
lineHeight: 1.6,
}), [darkMode]);
}), [overlayTheme]);
const renderSidebarModalTitle = (icon: React.ReactNode, title: string, description: string) => (
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 12 }}>
<div style={{ width: 34, height: 34, borderRadius: 12, display: 'grid', placeItems: 'center', background: darkMode ? 'rgba(255,214,102,0.12)' : 'rgba(24,144,255,0.1)', color: darkMode ? '#ffd666' : '#1677ff', flexShrink: 0 }}>
<div style={{ width: 34, height: 34, borderRadius: 12, display: 'grid', placeItems: 'center', background: overlayTheme.iconBg, color: overlayTheme.iconColor, flexShrink: 0 }}>
{icon}
</div>
<div style={{ minWidth: 0 }}>
<div style={{ fontSize: 16, fontWeight: 700, color: darkMode ? '#f5f7ff' : '#162033' }}>{title}</div>
<div style={{ marginTop: 4, color: darkMode ? 'rgba(255,255,255,0.5)' : 'rgba(16,24,40,0.55)', fontSize: 12, lineHeight: 1.6 }}>{description}</div>
<div style={{ fontSize: 16, fontWeight: 700, color: overlayTheme.titleText }}>{title}</div>
<div style={{ marginTop: 4, color: overlayTheme.mutedText, fontSize: 12, lineHeight: 1.6 }}>{description}</div>
</div>
</div>
);
@@ -279,6 +284,9 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
const [batchConnContext, setBatchConnContext] = useState<any>(null);
const [selectedDbConnection, setSelectedDbConnection] = useState<string>('');
// Find in Database Modal
const [findInDbContext, setFindInDbContext] = useState<{ open: boolean; connectionId: string; dbName: string }>({ open: false, connectionId: '', dbName: '' });
useEffect(() => {
// Refresh queries for expanded databases
const findNode = (nodes: TreeNode[], k: React.Key): TreeNode | null => {
@@ -322,7 +330,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
return {
title: conn.name,
key: conn.id,
icon: conn.config.type === 'redis' ? <CloudOutlined style={{ color: '#DC382D' }} /> : <HddOutlined />,
icon: getDbIcon(conn.iconType || conn.config.type, conn.iconColor, 22),
type: 'connection',
dataRef: conn,
isLeaf: false,
@@ -1424,7 +1432,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
if (type === 'connection') {
setActiveContext({ connectionId: key, dbName: '' });
} else if (type === 'database') {
setActiveContext({ connectionId: dataRef.id, dbName: title });
setActiveContext({ connectionId: dataRef.id, dbName: dataRef.dbName });
} else if (type === 'table') {
setActiveContext({ connectionId: dataRef.id, dbName: dataRef.dbName });
} else if (type === 'view' || type === 'db-trigger' || type === 'routine') {
@@ -1447,6 +1455,26 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
};
const onDoubleClick = (e: any, node: any) => {
// 保证用户直接双击节点未触发 onClick/onSelect 时也能强行拿到选中状态
const { type, dataRef, key: nodeKey } = node;
if (type === 'connection') setActiveContext({ connectionId: nodeKey, dbName: '' });
else if (type === 'database') setActiveContext({ connectionId: dataRef.id, dbName: dataRef.dbName });
else if (type === 'table' || type === 'view' || type === 'db-trigger' || type === 'routine') setActiveContext({ connectionId: dataRef.id, dbName: dataRef.dbName });
else if (type === 'saved-query') setActiveContext({ connectionId: dataRef.connectionId, dbName: dataRef.dbName });
else if (type === 'redis-db') setActiveContext({ connectionId: dataRef.id, dbName: `db${dataRef.redisDB}` });
if (node.type === 'object-group' && node.dataRef?.groupKey === 'tables') {
const { id, dbName, schemaName } = node.dataRef;
addTab({
id: `table-overview-${id}-${dbName}${schemaName ? `-${schemaName}` : ''}`,
title: `表概览 - ${dbName}${schemaName ? ` (${schemaName})` : ''}`,
type: 'table-overview' as any,
connectionId: id,
dbName,
schemaName,
} as any);
return;
}
if (node.type === 'table') {
const { tableName, dbName, id } = node.dataRef;
// 记录表访问
@@ -1479,7 +1507,8 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
type: 'query',
connectionId: q.connectionId,
dbName: q.dbName,
query: q.sql
query: q.sql,
savedQueryId: q.id,
});
return;
} else if (node.type === 'redis-db') {
@@ -1560,7 +1589,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
hide();
if (res.success) {
message.success('导出成功');
} else if (res.message !== 'Cancelled') {
} else if (res.message !== '已取消') {
message.error('导出失败: ' + res.message);
}
};
@@ -1583,7 +1612,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
hide();
if (res.success) {
message.success('导出成功');
} else if (res.message !== 'Cancelled') {
} else if (res.message !== '已取消') {
message.error('导出失败: ' + res.message);
}
} catch (e: any) {
@@ -1610,7 +1639,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
hide();
if (res.success) {
message.success('导出成功');
} else if (res.message !== 'Cancelled') {
} else if (res.message !== '已取消') {
message.error('导出失败: ' + res.message);
}
} catch (e: any) {
@@ -1799,7 +1828,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
} else {
message.success('导出成功');
}
} else if (res.message !== 'Cancelled') {
} else if (res.message !== '已取消') {
message.error('导出失败: ' + res.message);
}
} catch (e: any) {
@@ -1808,6 +1837,94 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
}
};
const handleBatchClear = async () => {
const selectedObjects = batchTables.filter(t => checkedTableKeys.includes(t.key));
if (selectedObjects.length === 0) {
message.warning('请至少选择一个对象');
return;
}
const { conn, dbName } = batchDbContext;
const objectNames = selectedObjects.map(t => t.objectName);
const ok = await new Promise<boolean>((resolve) => {
Modal.confirm({
title: '确认清空选中表',
content: `清空选中表会永久删除表中所有数据,操作不可逆,是否继续?\r\n\r\n连接: ${conn.name}\n数据库: ${dbName}`,
okText: '继续',
cancelText: '取消',
onOk: () => resolve(true),
onCancel: () => resolve(false),
});
});
if (!ok) return;
setIsBatchModalOpen(false);
const hide = message.loading(`正在清空选中表 (${objectNames.length})...`, 0);
const startTime = Date.now();
try {
const app = (window as any).go.app.App;
const res = await app.TruncateTables(normalizeConnConfig(conn.config), dbName, objectNames);
hide();
const duration = Date.now() - startTime;
if (res.success) {
message.success('清空成功');
// 构造 SQL 日志
let logSql = `/* Truncate Tables (${objectNames.length} tables) */\n`;
if (res.data && res.data.executedSQLs && Array.isArray(res.data.executedSQLs)) {
logSql += res.data.executedSQLs.join(';\n') + ';';
} else {
logSql += objectNames.map(name => name).join('; ');
}
addSqlLog({
id: Date.now().toString(),
timestamp: Date.now(),
sql: logSql,
status: 'success',
duration,
message: res.message,
dbName,
affectedRows: res.data?.count || 0
});
} else if (res.message !== '已取消') {
message.error('清空失败: ' + res.message);
// 记录失败的日志
let logSql = `/* Truncate Tables (${objectNames.length} tables) - FAILED */\n`;
if (res.data && res.data.executedSQLs && Array.isArray(res.data.executedSQLs)) {
logSql += res.data.executedSQLs.join(';\n') + ';';
} else {
logSql += objectNames.map(name => name).join('; ');
}
addSqlLog({
id: Date.now().toString(),
timestamp: Date.now(),
sql: logSql,
status: 'error',
duration,
message: res.message,
dbName
});
}
} catch (e: any) {
const duration = Date.now() - startTime;
hide();
const errMsg = e?.message || String(e);
message.error('清空失败: ' + errMsg);
// 记录异常的日志
let logSql = `/* Truncate Tables (${objectNames.length} tables) - ERROR */\n`;
logSql += objectNames.map(name => name).join('; ');
addSqlLog({
id: Date.now().toString(),
timestamp: Date.now(),
sql: logSql,
status: 'error',
duration,
message: errMsg,
dbName
});
}
};
const handleCheckAll = (checked: boolean) => {
if (batchSelectionScope === 'all') {
setCheckedTableKeys(checked ? allBatchObjectKeys : []);
@@ -1939,7 +2056,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
hide();
if (res.success) {
message.success(`${db.dbName} 导出成功`);
} else if (res.message !== 'Cancelled') {
} else if (res.message !== '已取消') {
message.error(`${db.dbName} 导出失败: ` + res.message);
break;
} else {
@@ -1968,23 +2085,127 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
};
const handleRunSQLFile = async (node: any) => {
const res = await (window as any).go.app.App.OpenSQLFile();
const res = await OpenSQLFile();
if (res.success) {
const sqlContent = res.data;
const data = res.data;
// 大文件:后端返回文件路径,走流式执行
if (data && typeof data === 'object' && data.isLargeFile) {
const connId = node.type === 'connection' ? node.key : node.dataRef?.id;
const dbName = node.dataRef?.dbName || '';
const conn = connections.find(c => c.id === connId);
if (!conn) {
message.error('未找到对应的连接配置');
return;
}
startSQLFileExecution(conn.config, dbName, data.filePath, data.fileSizeMB);
return;
}
// 小文件:加载到编辑器
const sqlContent = data;
const { dbName, id } = node.dataRef;
addTab({
id: `query-${Date.now()}`,
title: `Import SQL`,
title: `运行外部SQL文件`,
type: 'query',
connectionId: node.type === 'connection' ? node.key : node.dataRef.id,
dbName: dbName,
query: sqlContent
});
} else if (res.message !== "Cancelled") {
message.error("读取文件失败: " + res.message);
} else if (res.message !== '已取消') {
message.error('读取文件失败: ' + res.message);
}
};
const handleOpenSQLFileFromToolbar = async () => {
const ctx = useStore.getState().activeContext;
if (!ctx?.connectionId) {
message.warning('请先选择一个连接或数据库');
return;
}
const res = await OpenSQLFile();
if (res.success) {
const data = res.data;
// 大文件:后端流式执行
if (data && typeof data === 'object' && data.isLargeFile) {
const conn = connections.find(c => c.id === ctx.connectionId);
if (!conn) {
message.error('未找到对应的连接配置');
return;
}
startSQLFileExecution(conn.config, ctx.dbName || '', data.filePath, data.fileSizeMB);
return;
}
// 小文件
addTab({
id: `query-${Date.now()}`,
title: `运行外部SQL文件`,
type: 'query',
connectionId: ctx.connectionId,
dbName: ctx.dbName || undefined,
query: data
});
} else if (res.message !== '已取消') {
message.error('读取文件失败: ' + res.message);
}
};
// SQL 文件流式执行状态
const [sqlFileExecState, setSqlFileExecState] = useState<{
open: boolean;
jobId: string;
fileSizeMB: string;
status: 'running' | 'done' | 'cancelled' | 'error';
executed: number;
failed: number;
total: number;
percent: number;
currentSQL: string;
resultMessage: string;
}>({
open: false, jobId: '', fileSizeMB: '', status: 'running',
executed: 0, failed: 0, total: 0, percent: 0, currentSQL: '', resultMessage: ''
});
const startSQLFileExecution = (config: any, dbName: string, filePath: string, fileSizeMB: string) => {
const jobId = `sqlfile-${Date.now()}`;
setSqlFileExecState({
open: true, jobId, fileSizeMB, status: 'running',
executed: 0, failed: 0, total: 0, percent: 0, currentSQL: '', resultMessage: ''
});
// 监听进度事件
const offProgress = EventsOn('sqlfile:progress', (event: any) => {
if (!event || event.jobId !== jobId) return;
setSqlFileExecState(prev => ({
...prev,
status: event.status || prev.status,
executed: typeof event.executed === 'number' ? event.executed : prev.executed,
failed: typeof event.failed === 'number' ? event.failed : prev.failed,
total: typeof event.total === 'number' ? event.total : prev.total,
percent: typeof event.percent === 'number' ? Math.min(100, event.percent) : prev.percent,
currentSQL: typeof event.currentSQL === 'string' ? event.currentSQL : prev.currentSQL,
}));
});
// 异步执行
ExecuteSQLFile(config, dbName, filePath, jobId).then(res => {
offProgress();
setSqlFileExecState(prev => ({
...prev,
status: res.success ? 'done' : (prev.status === 'cancelled' ? 'cancelled' : 'error'),
percent: 100,
resultMessage: res.message || '',
}));
}).catch(err => {
offProgress();
setSqlFileExecState(prev => ({
...prev,
status: 'error',
resultMessage: String(err?.message || err),
}));
});
};
const handleCreateDatabase = async () => {
try {
const values = await createDbForm.validateFields();
@@ -1993,7 +2214,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
...conn.config,
port: Number(conn.config.port),
password: conn.config.password || "",
database: "", // No db selected
database: (conn.config.type === 'oracle' || conn.config.type === 'dameng') ? (conn.config.database || "") : "",
useSSH: conn.config.useSSH || false,
ssh: conn.config.ssh || { host: "", port: 22, user: "", password: "", keyPath: "" }
};
@@ -2535,12 +2756,10 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
const searchScopePopoverContent = useMemo(() => {
const smartSelected = searchScopes.includes('smart');
const scopedOptions = SEARCH_SCOPE_OPTIONS.filter((option) => option.value !== 'smart');
const borderColor = darkMode ? 'rgba(255,255,255,0.08)' : 'rgba(16,24,40,0.08)';
const mutedTextColor = darkMode ? 'rgba(255,255,255,0.5)' : 'rgba(16,24,40,0.55)';
const titleColor = darkMode ? 'rgba(255,255,255,0.92)' : '#162033';
const panelBg = darkMode
? 'linear-gradient(180deg, rgba(17,24,39,0.96) 0%, rgba(10,15,26,0.98) 100%)'
: 'linear-gradient(180deg, rgba(255,255,255,0.98) 0%, rgba(246,248,252,0.98) 100%)';
const borderColor = overlayTheme.sectionBorder.replace('1px solid ', '');
const mutedTextColor = overlayTheme.mutedText;
const titleColor = overlayTheme.titleText;
const panelBg = overlayTheme.shellBg;
const smartBg = smartSelected
? (darkMode ? 'linear-gradient(135deg, rgba(255,214,102,0.22) 0%, rgba(255,179,71,0.16) 100%)' : 'linear-gradient(135deg, rgba(255,214,102,0.26) 0%, rgba(255,244,204,0.92) 100%)')
: (darkMode ? 'rgba(255,255,255,0.03)' : 'rgba(255,255,255,0.72)');
@@ -2591,7 +2810,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
</div>
</label>
<div style={{ height: 1, background: borderColor, opacity: 0.9 }} />
<div style={{ height: 1, background: overlayTheme.divider, opacity: 0.9 }} />
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12 }}>
<div style={{ fontSize: 12, fontWeight: 700, letterSpacing: 0.3, color: mutedTextColor, textTransform: 'uppercase' }}></div>
@@ -2628,7 +2847,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
</div>
</div>
);
}, [darkMode, searchScopes]);
}, [darkMode, overlayTheme, searchScopes]);
const parseHostOnlyToken = (value: unknown): string[] => {
const raw = String(value || '').trim();
@@ -2983,6 +3202,12 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
});
}
},
{
key: 'open-sql-file',
label: '运行外部SQL文件',
icon: <FileAddOutlined />,
onClick: () => handleRunSQLFile(node)
},
{ type: 'divider' },
{
key: 'edit',
@@ -3169,7 +3394,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
},
{
key: 'run-sql',
label: '运行 SQL 文件...',
label: '运行外部SQL文件',
icon: <FileAddOutlined />,
onClick: () => handleRunSQLFile(node)
}
@@ -3261,13 +3486,15 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
label: '新建查询',
icon: <ConsoleSqlOutlined />,
onClick: () => {
const tableName = String(node.dataRef?.tableName || '').trim();
const queryTemplate = tableName ? `SELECT * FROM ${tableName};` : 'SELECT * FROM ';
addTab({
id: `query-${Date.now()}`,
title: `新建查询`,
type: 'query',
connectionId: node.dataRef.id,
dbName: node.dataRef.dbName,
query: ''
query: queryTemplate
});
}
},
@@ -3324,6 +3551,56 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
}
];
}
// 已存查询节点的右键菜单
if (node.type === 'saved-query') {
const q = node.dataRef;
return [
{
key: 'open-query',
label: '打开查询',
icon: <ConsoleSqlOutlined />,
onClick: () => {
addTab({
id: q.id,
title: q.name,
type: 'query',
connectionId: q.connectionId,
dbName: q.dbName,
query: q.sql,
savedQueryId: q.id,
});
}
},
{ type: 'divider' },
{
key: 'delete-query',
label: '删除查询',
icon: <DeleteOutlined />,
danger: true,
onClick: () => {
Modal.confirm({
title: '确认删除',
content: `确定要删除已保存的查询 "${q.name}" 吗?此操作不可恢复。`,
okButtonProps: { danger: true },
onOk: () => {
deleteQuery(q.id);
// 从树中移除节点
setTreeData(origin => {
const removeNode = (list: TreeNode[]): TreeNode[] =>
list
.filter(n => n.key !== node.key)
.map(n => n.children ? { ...n, children: removeNode(n.children) } : n);
return removeNode(origin);
});
message.success('查询已删除');
}
});
}
}
];
}
return [];
};
@@ -3335,7 +3612,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
}
const statusBadge = node.type === 'connection' || node.type === 'database' ? (
<Badge status={status} style={{ marginRight: 8 }} />
<Badge status={status} style={{ marginLeft: 4, marginRight: 8 }} />
) : null;
const displayTitle = String(node.title ?? '');
@@ -3433,109 +3710,87 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<div style={{ padding: '4px 10px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<Search
ref={searchInputRef}
placeholder="搜索..."
onChange={onSearch}
size="small"
style={{ flex: 1, minWidth: 0 }}
/>
<Popover
content={searchScopePopoverContent}
trigger="click"
placement="bottomRight"
open={isSearchScopePopoverOpen}
onOpenChange={setIsSearchScopePopoverOpen}
styles={{ body: { padding: 0, borderRadius: 18, overflow: 'hidden' } }}
>
<Tooltip title={`搜索范围:${searchScopeSummary}`}>
<Button
size="small"
style={{
minWidth: 86,
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
gap: 6,
paddingInline: 10,
borderRadius: 10,
borderColor: darkMode ? 'rgba(255,255,255,0.12)' : 'rgba(16,24,40,0.12)',
background: darkMode ? bgMain : 'rgba(255,255,255,0.92)',
color: darkMode ? 'rgba(255,255,255,0.88)' : '#162033',
boxShadow: isSearchScopePopoverOpen
? (darkMode ? '0 0 0 1px rgba(255,214,102,0.22) inset' : '0 0 0 1px rgba(24,144,255,0.24) inset')
: 'none',
backdropFilter: darkMode ? 'blur(10px)' : 'none',
flexShrink: 0,
}}
>
<span style={{ display: 'inline-flex', alignItems: 'center', color: searchScopes.includes('smart') ? '#ffd666' : (darkMode ? 'rgba(255,255,255,0.72)' : 'rgba(22,32,51,0.72)') }}>
<FilterOutlined />
</span>
<span style={{ fontWeight: 700, color: darkMode ? 'rgba(255,255,255,0.88)' : '#162033' }}></span>
<span
<div style={{ padding: '8px 14px', borderBottom: `1px solid ${darkMode ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.04)'}` }}>
<Input
ref={searchInputRef}
placeholder="搜索..."
onChange={onSearch}
size="small"
prefix={<SearchOutlined style={{ color: darkMode ? 'rgba(255,255,255,0.35)' : 'rgba(0,0,0,0.35)', marginRight: 4 }} />}
style={{
borderRadius: 6,
border: 'none',
background: darkMode ? 'rgba(0,0,0,0.25)' : 'rgba(0,0,0,0.03)',
boxShadow: 'none',
padding: '4px 8px',
color: darkMode ? 'rgba(255,255,255,0.85)' : 'rgba(0,0,0,0.85)',
}}
suffix={
<Popover
content={searchScopePopoverContent}
trigger="click"
placement="bottomRight"
open={isSearchScopePopoverOpen}
onOpenChange={setIsSearchScopePopoverOpen}
styles={{ body: { padding: 0, borderRadius: 16, overflow: 'hidden' } }}
>
<Tooltip title={`搜索范围:${searchScopeSummary}`}>
<div
style={{
minWidth: 18,
height: 18,
padding: '0 5px',
borderRadius: 999,
display: 'inline-flex',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: 11,
fontWeight: 700,
lineHeight: 1,
background: searchScopes.includes('smart')
? (darkMode ? 'rgba(255,214,102,0.16)' : 'rgba(24,144,255,0.12)')
: (darkMode ? 'rgba(118,169,250,0.18)' : 'rgba(24,144,255,0.12)'),
gap: 4,
cursor: 'pointer',
padding: '2px 6px',
borderRadius: 4,
background: isSearchScopePopoverOpen
? (darkMode ? 'rgba(255,255,255,0.12)' : 'rgba(0,0,0,0.06)')
: 'transparent',
transition: 'background 0.2s',
color: searchScopes.includes('smart')
? (darkMode ? '#ffd666' : '#1677ff')
: (darkMode ? '#91caff' : '#1677ff'),
: (darkMode ? 'rgba(255,255,255,0.45)' : 'rgba(0,0,0,0.45)'),
}}
onMouseEnter={(e) => {
if (!isSearchScopePopoverOpen) {
e.currentTarget.style.background = darkMode ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.04)';
e.currentTarget.style.color = darkMode ? 'rgba(255,255,255,0.85)' : 'rgba(0,0,0,0.65)';
}
}}
onMouseLeave={(e) => {
if (!isSearchScopePopoverOpen) {
e.currentTarget.style.background = 'transparent';
e.currentTarget.style.color = searchScopes.includes('smart')
? (darkMode ? '#ffd666' : '#1677ff')
: (darkMode ? 'rgba(255,255,255,0.45)' : 'rgba(0,0,0,0.45)');
}
}}
>
{searchScopes.includes('smart') ? '智' : searchScopes.length}
</span>
<span style={{ display: 'inline-flex', alignItems: 'center', color: darkMode ? 'rgba(255,255,255,0.48)' : 'rgba(22,32,51,0.4)', fontSize: 12 }}>
<DownOutlined />
</span>
</Button>
</Tooltip>
</Popover>
</div>
<FilterOutlined style={{ fontSize: 13 }} />
<span style={{ fontSize: 12, fontWeight: 500 }}>
{searchScopes.includes('smart') ? '智' : searchScopes.length}
</span>
</div>
</Tooltip>
</Popover>
}
/>
</div>
{/* Toolbar */}
<div style={{ padding: '4px 10px', borderBottom: 'none', display: 'flex', flexWrap: 'wrap', gap: 4 }}>
<Button
size="small"
icon={<FolderOpenOutlined />}
onClick={() => {
setRenameViewTarget(null); // Create mode
createTagForm.resetFields();
setIsCreateTagModalOpen(true);
}}
style={{ flex: '1 1 auto' }}
>
</Button>
<Button
size="small"
icon={<CheckSquareOutlined />}
onClick={() => openBatchOperationModal()}
style={{ flex: '1 1 auto' }}
>
</Button>
<Button
size="small"
icon={<CheckSquareOutlined />}
onClick={() => openBatchDatabaseModal()}
style={{ flex: '1 1 auto' }}
>
</Button>
<div style={{ padding: '6px 16px', display: 'flex', gap: 8, justifyContent: 'space-between', borderTop: `1px solid ${darkMode ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.04)'}`, borderBottom: `1px solid ${darkMode ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.04)'}`, background: darkMode ? 'rgba(0,0,0,0.2)' : 'rgba(0,0,0,0.015)' }}>
<Tooltip title="新建组">
<Button size="small" type="text" icon={<FolderOpenOutlined />} onClick={() => { setRenameViewTarget(null); createTagForm.resetFields(); setIsCreateTagModalOpen(true); }} style={{ color: darkMode ? 'rgba(255,255,255,0.65)' : 'rgba(0,0,0,0.65)' }} />
</Tooltip>
<Tooltip title="批量操作表">
<Button size="small" type="text" icon={<TableOutlined />} onClick={() => openBatchOperationModal()} style={{ color: darkMode ? 'rgba(255,255,255,0.65)' : 'rgba(0,0,0,0.65)' }} />
</Tooltip>
<Tooltip title="批量操作库">
<Button size="small" type="text" icon={<DatabaseOutlined />} onClick={() => openBatchDatabaseModal()} style={{ color: darkMode ? 'rgba(255,255,255,0.65)' : 'rgba(0,0,0,0.65)' }} />
</Tooltip>
<Tooltip title="运行外部SQL文件">
<Button size="small" type="text" icon={<FileAddOutlined />} onClick={handleOpenSQLFileFromToolbar} style={{ color: darkMode ? 'rgba(255,255,255,0.65)' : 'rgba(0,0,0,0.65)' }} />
</Tooltip>
</div>
<div ref={treeContainerRef} style={{ flex: 1, overflow: 'hidden', minHeight: 0 }}>
@@ -3716,6 +3971,15 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
</Button>
<Space size={8} wrap style={{ marginLeft: 'auto' }}>
<Button
key="clear"
danger
icon={<DeleteOutlined />}
onClick={() => handleBatchClear()}
disabled={checkedTableKeys.length === 0}
>
</Button>
<Button
key="export-schema"
icon={<ExportOutlined />}
@@ -3989,6 +4253,66 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
</>
)}
</Modal>
{/* SQL 文件流式执行进度 Modal */}
<Modal
title="运行外部SQL文件"
open={sqlFileExecState.open}
centered
closable={sqlFileExecState.status !== 'running'}
maskClosable={false}
footer={sqlFileExecState.status === 'running' ? [
<Button key="cancel" danger onClick={() => {
CancelSQLFileExecution(sqlFileExecState.jobId);
setSqlFileExecState(prev => ({ ...prev, status: 'cancelled' }));
}}>
</Button>
] : [
<Button key="close" type="primary" onClick={() => setSqlFileExecState(prev => ({ ...prev, open: false }))}>
</Button>
]}
onCancel={() => {
if (sqlFileExecState.status !== 'running') {
setSqlFileExecState(prev => ({ ...prev, open: false }));
}
}}
styles={{ content: modalPanelStyle, header: { background: 'transparent', borderBottom: 'none' }, body: { paddingTop: 8 }, footer: { background: 'transparent', borderTop: 'none' } }}
>
<div style={{ marginBottom: 16 }}>
<Progress
percent={Math.round(sqlFileExecState.percent)}
status={sqlFileExecState.status === 'error' ? 'exception' : sqlFileExecState.status === 'done' ? 'success' : 'active'}
strokeColor={sqlFileExecState.status === 'cancelled' ? '#faad14' : undefined}
/>
</div>
<div style={{ fontSize: 13, lineHeight: '22px', marginBottom: 8 }}>
<div><strong>{sqlFileExecState.fileSizeMB} MB</strong></div>
<div><strong>{
sqlFileExecState.status === 'running' ? '执行中...' :
sqlFileExecState.status === 'done' ? '✅ 完成' :
sqlFileExecState.status === 'cancelled' ? '⚠️ 已取消' : '❌ 出错'
}</strong></div>
<div><strong style={{ color: '#52c41a' }}>{sqlFileExecState.executed}</strong> | <strong style={{ color: sqlFileExecState.failed > 0 ? '#ff4d4f' : undefined }}>{sqlFileExecState.failed}</strong> </div>
</div>
{sqlFileExecState.currentSQL && sqlFileExecState.status === 'running' && (
<div style={{ fontSize: 12, color: 'rgba(128,128,128,0.8)', background: 'rgba(128,128,128,0.06)', borderRadius: 6, padding: '6px 10px', marginTop: 8, fontFamily: 'monospace', wordBreak: 'break-all', maxHeight: 60, overflow: 'hidden' }}>
{sqlFileExecState.currentSQL}
</div>
)}
{sqlFileExecState.resultMessage && sqlFileExecState.status !== 'running' && (
<div style={{ fontSize: 12, marginTop: 12, maxHeight: 200, overflow: 'auto', whiteSpace: 'pre-wrap', background: 'rgba(128,128,128,0.06)', borderRadius: 6, padding: '8px 12px' }}>
{sqlFileExecState.resultMessage}
</div>
)}
</Modal>
<FindInDatabaseModal
open={findInDbContext.open}
onClose={() => setFindInDbContext({ open: false, connectionId: '', dbName: '' })}
connectionId={findInDbContext.connectionId}
dbName={findInDbContext.dbName}
/>
</div>
);
};

View File

@@ -14,6 +14,7 @@ import RedisViewer from './RedisViewer';
import RedisCommandEditor from './RedisCommandEditor';
import TriggerViewer from './TriggerViewer';
import DefinitionViewer from './DefinitionViewer';
import TableOverview from './TableOverview';
import type { TabData } from '../types';
const detectConnectionEnvLabel = (connectionName: string): string | null => {
@@ -28,7 +29,7 @@ const detectConnectionEnvLabel = (connectionName: string): string | null => {
};
const buildTabDisplayTitle = (tab: TabData, connectionName: string | undefined): string => {
if (tab.type !== 'table' && tab.type !== 'design') return tab.title;
if (tab.type !== 'table' && tab.type !== 'design' && tab.type !== 'table-overview') return tab.title;
if (!connectionName) return tab.title;
const prefix = detectConnectionEnvLabel(connectionName) || connectionName;
return `[${prefix}] ${tab.title}`;
@@ -88,6 +89,7 @@ const TabManager: React.FC = () => {
const theme = useStore(state => state.theme);
const activeTabId = useStore(state => state.activeTabId);
const setActiveTab = useStore(state => state.setActiveTab);
const addTab = useStore(state => state.addTab);
const closeTab = useStore(state => state.closeTab);
const closeOtherTabs = useStore(state => state.closeOtherTabs);
const closeTabsToLeft = useStore(state => state.closeTabsToLeft);
@@ -133,6 +135,59 @@ const TabManager: React.FC = () => {
setDraggingTabId(null);
};
React.useEffect(() => {
const handleGlobalInsertSql = (e: any) => {
const { sql, runImmediately, connectionId: eventConnId, dbName: eventDbName } = e.detail;
if (!sql) return;
const activeTab = tabs.find(t => t.id === activeTabId);
// 🔧 runImmediately点击"执行")始终新建独立 tab避免追加到已有 tab 导致 SQL 重复
if (runImmediately) {
const newTabId = 'tab-' + Date.now();
const resolvedConnId = eventConnId || activeTab?.connectionId || (connections.length > 0 ? connections[0].id : '');
const resolvedDbName = eventConnId ? (eventDbName || '') : (activeTab?.dbName || '');
addTab({
id: newTabId,
type: 'query',
title: '新建查询',
query: sql,
connectionId: resolvedConnId,
dbName: resolvedDbName
});
setActiveTab(newTabId);
setTimeout(() => {
window.dispatchEvent(new CustomEvent('gonavi:insert-sql-to-tab', {
detail: { tabId: newTabId, sql, runImmediately: true, connectionId: resolvedConnId, dbName: resolvedDbName }
}));
}, 300);
return;
}
// 插入模式:追加到已有 tab 或新建 tab
if (activeTab && activeTab.type === 'query') {
window.dispatchEvent(new CustomEvent('gonavi:insert-sql-to-tab', {
detail: { tabId: activeTab.id, sql, runImmediately: false, connectionId: eventConnId, dbName: eventDbName }
}));
} else {
const newTabId = 'tab-' + Date.now();
const resolvedConnId = eventConnId || activeTab?.connectionId || (connections.length > 0 ? connections[0].id : '');
const resolvedDbName = eventConnId ? (eventDbName || '') : (activeTab?.dbName || '');
addTab({
id: newTabId,
type: 'query',
title: '新建查询',
query: sql,
connectionId: resolvedConnId,
dbName: resolvedDbName
});
setActiveTab(newTabId);
}
};
window.addEventListener('gonavi:insert-sql', handleGlobalInsertSql);
return () => window.removeEventListener('gonavi:insert-sql', handleGlobalInsertSql);
}, [tabs, activeTabId, addTab, setActiveTab, connections]);
const tabIds = useMemo(() => tabs.map((tab) => tab.id), [tabs]);
const renderTabBar: TabsProps['renderTabBar'] = (tabBarProps, DefaultTabBar) => (
@@ -144,12 +199,8 @@ const TabManager: React.FC = () => {
const items = useMemo(() => tabs.map((tab, index) => {
const connectionName = connections.find((conn) => conn.id === tab.connectionId)?.name;
const displayTitle = buildTabDisplayTitle(tab, connectionName);
const keepMountedWhenInactive = tab.type === 'query' || tab.type === 'redis-command';
const shouldRenderContent = activeTabId === tab.id || keepMountedWhenInactive;
let content;
if (!shouldRenderContent) {
content = null;
} else if (tab.type === 'query') {
if (tab.type === 'query') {
content = <QueryEditor tab={tab} />;
} else if (tab.type === 'table') {
content = <DataViewer tab={tab} />;
@@ -163,6 +214,8 @@ const TabManager: React.FC = () => {
content = <TriggerViewer tab={tab} />;
} else if (tab.type === 'view-def' || tab.type === 'routine-def') {
content = <DefinitionViewer tab={tab} />;
} else if (tab.type === 'table-overview') {
content = <TableOverview tab={tab} />;
}
const menuItems: MenuProps['items'] = [
@@ -203,7 +256,7 @@ const TabManager: React.FC = () => {
key: tab.id,
children: content,
};
}), [tabs, connections, activeTabId, closeOtherTabs, closeTabsToLeft, closeTabsToRight, closeAllTabs]);
}), [tabs, connections, closeOtherTabs, closeTabsToLeft, closeTabsToRight, closeAllTabs]);
return (
<>
@@ -297,6 +350,7 @@ const TabManager: React.FC = () => {
<Tabs
className="main-tabs"
type="editable-card"
destroyInactiveTabPane={false}
onChange={(newActiveKey) => {
if (Date.now() < suppressClickUntilRef.current) return;
onChange(newActiveKey);

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,460 @@
import React, { useState, useEffect, useMemo, useCallback } from 'react';
import { Input, Spin, Empty, Dropdown, message, Tooltip, Modal } from 'antd';
import { TableOutlined, SearchOutlined, ReloadOutlined, SortAscendingOutlined, DatabaseOutlined, ConsoleSqlOutlined, EditOutlined, CopyOutlined, SaveOutlined, DeleteOutlined, ExportOutlined } from '@ant-design/icons';
import { useStore } from '../store';
import { DBQuery, DBShowCreateTable, ExportTable, DropTable, RenameTable } from '../../wailsjs/go/app/App';
import type { TabData } from '../types';
interface TableOverviewProps {
tab: TabData;
}
interface TableStatRow {
name: string;
comment: string;
rows: number;
dataSize: number;
indexSize: number;
engine: string;
createTime: string;
updateTime: string;
}
type SortField = 'name' | 'rows' | 'dataSize';
type SortOrder = 'asc' | 'desc';
const formatSize = (bytes: number): string => {
if (!bytes || bytes <= 0) return '—';
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
};
const formatRows = (count: number): string => {
if (count === undefined || count === null || count < 0) return '—';
if (count >= 1_000_000) return `${(count / 1_000_000).toFixed(1)}M`;
if (count >= 1_000) return `${(count / 1_000).toFixed(1)}K`;
return String(count);
};
const getMetadataDialect = (connType: string, driver?: string): string => {
const type = (connType || '').trim().toLowerCase();
if (type === 'custom') {
const d = (driver || '').trim().toLowerCase();
if (d === 'diros' || d === 'doris') return 'mysql';
return d;
}
if (type === 'mariadb' || type === 'diros' || type === 'sphinx') return 'mysql';
if (type === 'dameng') return 'dm';
return type;
};
const buildTableStatusSQL = (dialect: string, dbName: string, schemaName?: string): string => {
const escapeLiteral = (s: string) => s.replace(/'/g, "''");
switch (dialect) {
case 'mysql':
return `SHOW TABLE STATUS FROM \`${dbName.replace(/`/g, '``')}\``;
case 'postgres':
case 'kingbase':
case 'vastbase':
case 'highgo': {
const schema = schemaName || 'public';
return `
SELECT
n.nspname || '.' || c.relname AS table_name,
obj_description(c.oid, 'pg_class') AS table_comment,
c.reltuples::bigint AS table_rows,
pg_total_relation_size(c.oid) AS data_length,
pg_indexes_size(c.oid) AS index_length
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE c.relkind = 'r'
AND n.nspname = '${escapeLiteral(schema)}'
ORDER BY c.relname`;
}
case 'sqlserver': {
const safeDB = `[${dbName.replace(/]/g, ']]')}]`;
return `
SELECT
s.name + '.' + t.name AS table_name,
ep.value AS table_comment,
SUM(p.rows) AS table_rows,
SUM(a.total_pages) * 8 * 1024 AS data_length,
SUM(a.used_pages) * 8 * 1024 AS index_length
FROM ${safeDB}.sys.tables t
JOIN ${safeDB}.sys.schemas s ON t.schema_id = s.schema_id
LEFT JOIN ${safeDB}.sys.extended_properties ep ON ep.major_id = t.object_id AND ep.minor_id = 0 AND ep.name = 'MS_Description'
LEFT JOIN ${safeDB}.sys.partitions p ON t.object_id = p.object_id AND p.index_id IN (0, 1)
LEFT JOIN ${safeDB}.sys.allocation_units a ON p.partition_id = a.container_id
WHERE t.type = 'U'
GROUP BY s.name, t.name, ep.value
ORDER BY s.name, t.name`;
}
case 'clickhouse':
return `SELECT name AS table_name, comment AS table_comment, total_rows AS table_rows, total_bytes AS data_length, 0 AS index_length FROM system.tables WHERE database = '${escapeLiteral(dbName)}' AND engine NOT IN ('View', 'MaterializedView') ORDER BY name`;
case 'dm':
case 'oracle': {
const owner = (schemaName || dbName).toUpperCase();
return `SELECT table_name, comments AS table_comment, num_rows AS table_rows, 0 AS data_length, 0 AS index_length FROM all_tab_comments JOIN all_tables USING (table_name, owner) WHERE owner = '${escapeLiteral(owner)}' ORDER BY table_name`;
}
default:
return `SELECT table_name, '' AS table_comment, 0 AS table_rows, 0 AS data_length, 0 AS index_length FROM information_schema.tables WHERE table_schema = '${escapeLiteral(dbName)}' AND table_type = 'BASE TABLE' ORDER BY table_name`;
}
};
const parseTableStats = (dialect: string, rows: Record<string, any>[]): TableStatRow[] => {
return rows.map((row) => {
const get = (keys: string[]): any => {
for (const k of keys) {
for (const rk of Object.keys(row)) {
if (rk.toLowerCase() === k.toLowerCase() && row[rk] !== null && row[rk] !== undefined) return row[rk];
}
}
return undefined;
};
const strVal = (keys: string[]) => String(get(keys) ?? '').trim();
const numVal = (keys: string[]) => {
const v = get(keys);
if (v === null || v === undefined || v === '') return 0;
const n = Number(v);
return isNaN(n) ? 0 : Math.max(0, Math.round(n));
};
return {
name: strVal(['Name', 'table_name', 'tablename', 'TABLE_NAME']),
comment: strVal(['Comment', 'table_comment', 'TABLE_COMMENT', 'comments']),
rows: numVal(['Rows', 'table_rows', 'TABLE_ROWS', 'num_rows', 'reltuples', 'total_rows']),
dataSize: numVal(['Data_length', 'data_length', 'DATA_LENGTH', 'total_bytes']),
indexSize: numVal(['Index_length', 'index_length', 'INDEX_LENGTH']),
engine: strVal(['Engine', 'engine']),
createTime: strVal(['Create_time', 'create_time']),
updateTime: strVal(['Update_time', 'update_time']),
};
}).filter(t => t.name);
};
const TableOverview: React.FC<TableOverviewProps> = ({ tab }) => {
const connections = useStore(state => state.connections);
const theme = useStore(state => state.theme);
const addTab = useStore(state => state.addTab);
const setActiveContext = useStore(state => state.setActiveContext);
const darkMode = theme === 'dark';
const [tables, setTables] = useState<TableStatRow[]>([]);
const [loading, setLoading] = useState(true);
const [searchText, setSearchText] = useState('');
const [sortField, setSortField] = useState<SortField>('name');
const [sortOrder, setSortOrder] = useState<SortOrder>('asc');
const connection = useMemo(() => connections.find(c => c.id === tab.connectionId), [connections, tab.connectionId]);
const loadData = useCallback(async () => {
if (!connection) return;
setLoading(true);
try {
const config = {
...connection.config,
port: Number(connection.config.port),
password: connection.config.password || '',
database: connection.config.database || '',
useSSH: connection.config.useSSH || false,
ssh: connection.config.ssh || { host: '', port: 22, user: '', password: '', keyPath: '' },
};
const dialect = getMetadataDialect(connection.config.type, (connection.config as any)?.driver);
const sql = buildTableStatusSQL(dialect, tab.dbName || '', (tab as any).schemaName);
const res = await DBQuery(config as any, tab.dbName || '', sql);
if (res.success && Array.isArray(res.data)) {
setTables(parseTableStats(dialect, res.data));
} else {
message.error('获取表信息失败: ' + (res.message || '未知错误'));
}
} catch (e: any) {
message.error('获取表信息失败: ' + (e?.message || String(e)));
} finally {
setLoading(false);
}
}, [connection, tab.dbName]);
useEffect(() => { loadData(); }, [loadData]);
const sortedFiltered = useMemo(() => {
let list = [...tables];
if (searchText.trim()) {
const kw = searchText.trim().toLowerCase();
list = list.filter(t => t.name.toLowerCase().includes(kw) || t.comment.toLowerCase().includes(kw));
}
list.sort((a, b) => {
let cmp = 0;
if (sortField === 'name') cmp = a.name.toLowerCase().localeCompare(b.name.toLowerCase());
else if (sortField === 'rows') cmp = a.rows - b.rows;
else if (sortField === 'dataSize') cmp = a.dataSize - b.dataSize;
return sortOrder === 'asc' ? cmp : -cmp;
});
return list;
}, [tables, searchText, sortField, sortOrder]);
const openTable = useCallback((tableName: string) => {
if (!connection) return;
setActiveContext({ connectionId: connection.id, dbName: tab.dbName || '' });
addTab({
id: `${connection.id}-${tab.dbName}-${tableName}`,
title: tableName,
type: 'table',
connectionId: connection.id,
dbName: tab.dbName,
tableName,
});
}, [connection, tab.dbName, addTab, setActiveContext]);
const openDesign = useCallback((tableName: string) => {
if (!connection) return;
setActiveContext({ connectionId: connection.id, dbName: tab.dbName || '' });
addTab({
id: `design-${connection.id}-${tab.dbName}-${tableName}`,
title: `设计表 (${tableName})`,
type: 'design',
connectionId: connection.id,
dbName: tab.dbName,
tableName,
initialTab: 'columns',
readOnly: false,
});
}, [connection, tab.dbName, addTab, setActiveContext]);
const buildConfig = useCallback(() => {
if (!connection) return null;
return {
...connection.config,
port: Number(connection.config.port),
password: connection.config.password || '',
database: connection.config.database || '',
useSSH: connection.config.useSSH || false,
ssh: connection.config.ssh || { host: '', port: 22, user: '', password: '', keyPath: '' },
};
}, [connection]);
const handleCopyStructure = useCallback(async (tableName: string) => {
const config = buildConfig();
if (!config) return;
const res = await DBShowCreateTable(config as any, tab.dbName || '', tableName);
if (res.success) {
navigator.clipboard.writeText(res.data as string);
message.success('表结构已复制到剪贴板');
} else {
message.error(res.message);
}
}, [buildConfig, tab.dbName]);
const handleExport = useCallback(async (tableName: string, format: string) => {
const config = buildConfig();
if (!config) return;
const hide = message.loading(`正在导出 ${tableName}${format.toUpperCase()}...`, 0);
const res = await ExportTable(config as any, tab.dbName || '', tableName, format);
hide();
if (res.success) {
message.success('导出成功');
} else if (res.message !== '已取消') {
message.error('导出失败: ' + res.message);
}
}, [buildConfig, tab.dbName]);
const handleDeleteTable = useCallback((tableName: string) => {
const config = buildConfig();
if (!config) return;
Modal.confirm({
title: '确认删除表',
content: `确定删除表 "${tableName}" 吗?该操作不可恢复。`,
okButtonProps: { danger: true },
onOk: async () => {
const res = await DropTable(config as any, tab.dbName || '', tableName);
if (res.success) {
message.success('表删除成功');
loadData();
} else {
message.error('删除失败: ' + res.message);
}
},
});
}, [buildConfig, tab.dbName, loadData]);
const handleRenameTable = useCallback((tableName: string) => {
const config = buildConfig();
if (!config) return;
let newName = tableName;
Modal.confirm({
title: '重命名表',
content: (
<Input
defaultValue={tableName}
onChange={e => { newName = e.target.value; }}
placeholder="输入新表名"
autoFocus
style={{ marginTop: 8 }}
/>
),
onOk: async () => {
const trimmed = newName.trim();
if (!trimmed) { message.error('表名不能为空'); return Promise.reject(); }
if (trimmed === tableName) { message.warning('新旧表名相同'); return; }
const res = await RenameTable(config as any, tab.dbName || '', tableName, trimmed);
if (res.success) {
message.success('表重命名成功');
loadData();
} else {
message.error('重命名失败: ' + res.message);
}
},
});
}, [buildConfig, tab.dbName, loadData]);
// --- Theme ---
const cardBg = darkMode ? 'rgba(255,255,255,0.04)' : 'rgba(0,0,0,0.02)';
const cardHoverBg = darkMode ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.04)';
const cardBorder = darkMode ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.06)';
const textPrimary = darkMode ? 'rgba(255,255,255,0.88)' : 'rgba(0,0,0,0.88)';
const textSecondary = darkMode ? 'rgba(255,255,255,0.55)' : 'rgba(0,0,0,0.55)';
const textMuted = darkMode ? 'rgba(255,255,255,0.35)' : 'rgba(0,0,0,0.35)';
const accentColor = '#1677ff';
const containerBg = darkMode ? 'rgba(0,0,0,0.15)' : 'rgba(0,0,0,0.01)';
const toggleSort = (field: SortField) => {
if (sortField === field) {
setSortOrder(o => o === 'asc' ? 'desc' : 'asc');
} else {
setSortField(field);
setSortOrder(field === 'name' ? 'asc' : 'desc');
}
};
const sortMenuItems = [
{ key: 'name', label: `按名称${sortField === 'name' ? (sortOrder === 'asc' ? ' ↑' : ' ↓') : ''}`, onClick: () => toggleSort('name') },
{ key: 'rows', label: `按行数${sortField === 'rows' ? (sortOrder === 'asc' ? ' ↑' : ' ↓') : ''}`, onClick: () => toggleSort('rows') },
{ key: 'dataSize', label: `按大小${sortField === 'dataSize' ? (sortOrder === 'asc' ? ' ↑' : ' ↓') : ''}`, onClick: () => toggleSort('dataSize') },
];
const totalRows = tables.reduce((s, t) => s + t.rows, 0);
const totalSize = tables.reduce((s, t) => s + t.dataSize + t.indexSize, 0);
if (loading) {
return (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100%', background: containerBg }}>
<Spin size="large" tip="加载表信息..." />
</div>
);
}
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', background: containerBg, overflow: 'hidden' }}>
{/* Toolbar */}
<div style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '12px 16px', flexShrink: 0 }}>
<DatabaseOutlined style={{ fontSize: 16, color: accentColor }} />
<span style={{ fontSize: 14, fontWeight: 600, color: textPrimary }}>{tab.dbName}</span>
<span style={{ fontSize: 12, color: textMuted }}>
{tables.length} · {formatRows(totalRows)} · {formatSize(totalSize)}
</span>
<div style={{ flex: 1 }} />
<Input
placeholder="搜索表名或注释..."
prefix={<SearchOutlined style={{ color: textMuted }} />}
value={searchText}
onChange={e => setSearchText(e.target.value)}
allowClear
style={{ width: 240 }}
size="small"
/>
<Dropdown menu={{ items: sortMenuItems }} trigger={['click']}>
<Tooltip title="排序"><SortAscendingOutlined style={{ fontSize: 16, color: textSecondary, cursor: 'pointer' }} /></Tooltip>
</Dropdown>
<Tooltip title="刷新"><ReloadOutlined onClick={loadData} style={{ fontSize: 16, color: textSecondary, cursor: 'pointer' }} /></Tooltip>
</div>
{/* Cards Grid */}
<div style={{ flex: 1, overflow: 'auto', padding: '0 16px 16px 16px' }}>
{sortedFiltered.length === 0 ? (
<Empty description={searchText ? '无匹配结果' : '暂无表'} style={{ marginTop: 80 }} />
) : (
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(260px, 1fr))',
gap: 12,
}}>
{sortedFiltered.map(t => (
<Dropdown
key={t.name}
trigger={['contextMenu']}
menu={{
items: [
{ key: 'new-query', label: '新建查询', icon: <ConsoleSqlOutlined />, onClick: () => {
setActiveContext({ connectionId: tab.connectionId, dbName: tab.dbName || '' });
addTab({
id: `query-${Date.now()}`,
title: '新建查询',
type: 'query',
connectionId: tab.connectionId,
dbName: tab.dbName,
query: `SELECT * FROM ${t.name};`,
});
}},
{ type: 'divider' },
{ key: 'design-table', label: '设计表', icon: <EditOutlined />, onClick: () => openDesign(t.name) },
{ key: 'copy-structure', label: '复制表结构', icon: <CopyOutlined />, onClick: () => handleCopyStructure(t.name) },
{ key: 'backup-table', label: '备份表 (SQL)', icon: <SaveOutlined />, onClick: () => handleExport(t.name, 'sql') },
{ key: 'rename-table', label: '重命名表', icon: <EditOutlined />, onClick: () => handleRenameTable(t.name) },
{ key: 'drop-table', label: '删除表', icon: <DeleteOutlined />, danger: true, onClick: () => handleDeleteTable(t.name) },
{ type: 'divider' },
{ key: 'export', label: '导出表数据', icon: <ExportOutlined />, children: [
{ key: 'export-csv', label: '导出 CSV', onClick: () => handleExport(t.name, 'csv') },
{ key: 'export-xlsx', label: '导出 Excel (XLSX)', onClick: () => handleExport(t.name, 'xlsx') },
{ key: 'export-json', label: '导出 JSON', onClick: () => handleExport(t.name, 'json') },
{ key: 'export-md', label: '导出 Markdown', onClick: () => handleExport(t.name, 'md') },
{ key: 'export-html', label: '导出 HTML', onClick: () => handleExport(t.name, 'html') },
]},
],
}}
>
<div
onDoubleClick={() => openTable(t.name)}
style={{
background: cardBg,
border: `1px solid ${cardBorder}`,
borderRadius: 10,
padding: '14px 16px',
cursor: 'pointer',
transition: 'all 0.15s ease',
userSelect: 'none',
}}
onMouseEnter={e => { (e.currentTarget as HTMLDivElement).style.background = cardHoverBg; (e.currentTarget as HTMLDivElement).style.borderColor = accentColor; }}
onMouseLeave={e => { (e.currentTarget as HTMLDivElement).style.background = cardBg; (e.currentTarget as HTMLDivElement).style.borderColor = cardBorder; }}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
<TableOutlined style={{ fontSize: 14, color: accentColor }} />
<Tooltip title={t.name} mouseEnterDelay={0.4}>
<span style={{ fontSize: 13, fontWeight: 600, color: textPrimary, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1, display: 'block' }}>
{t.name}
</span>
</Tooltip>
</div>
{t.comment && (
<Tooltip title={t.comment} mouseEnterDelay={0.4}>
<div style={{ fontSize: 12, color: textSecondary, marginBottom: 10, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{t.comment}
</div>
</Tooltip>
)}
<div style={{ display: 'flex', gap: 16, fontSize: 12, color: textMuted }}>
<span title="行数" style={{ minWidth: 52 }}>📊 {formatRows(t.rows)}</span>
<span title="数据大小" style={{ minWidth: 72 }}>💾 {formatSize(t.dataSize)}</span>
{t.engine && <span title="引擎" style={{ marginLeft: 'auto', opacity: 0.7 }}>{t.engine}</span>}
</div>
</div>
</Dropdown>
))}
</div>
)}
</div>
</div>
);
};
export default TableOverview;

View File

@@ -1,5 +1,5 @@
import React, { useState, useEffect } from 'react';
import Editor, { loader } from '@monaco-editor/react';
import Editor from '@monaco-editor/react';
import { Spin, Alert } from 'antd';
import { TabData } from '../types';
import { useStore } from '../store';
@@ -18,31 +18,7 @@ const TriggerViewer: React.FC<TriggerViewerProps> = ({ tab }) => {
const theme = useStore(state => state.theme);
const darkMode = theme === 'dark';
// 初始化透明 Monaco Editor 主题
useEffect(() => {
loader.init().then(monaco => {
monaco.editor.defineTheme('transparent-dark', {
base: 'vs-dark',
inherit: true,
rules: [],
colors: {
'editor.background': '#00000000',
'editor.lineHighlightBackground': '#ffffff10',
'editorGutter.background': '#00000000',
}
});
monaco.editor.defineTheme('transparent-light', {
base: 'vs',
inherit: true,
rules: [],
colors: {
'editor.background': '#00000000',
'editor.lineHighlightBackground': '#00000010',
'editorGutter.background': '#00000000',
}
});
});
}, []);
// 透明 Monaco Editor 主题已在 main.tsx 全局注册(含 stickyScroll 不透明背景)
const escapeSQLLiteral = (raw: string): string => String(raw || '').replace(/'/g, "''");
const quoteSqlServerIdentifier = (raw: string): string => `[${String(raw || '').replace(/]/g, ']]')}]`;

View File

@@ -0,0 +1,76 @@
import React from 'react';
import { Button, Tooltip } from 'antd';
import { HistoryOutlined, RobotOutlined, ClearOutlined, SettingOutlined, CloseOutlined, ExportOutlined } from '@ant-design/icons';
import type { OverlayWorkbenchTheme } from '../../utils/overlayWorkbenchTheme';
import type { AIChatMessage } from '../../types';
interface AIChatHeaderProps {
darkMode: boolean;
mutedColor: string;
textColor: string;
overlayTheme: OverlayWorkbenchTheme;
onHistoryClick: () => void;
onClear: () => void;
onSettingsClick: () => void;
onClose: () => void;
messages?: AIChatMessage[];
sessionTitle?: string;
}
const exportToMarkdown = (messages: AIChatMessage[], title: string) => {
const lines: string[] = [`# ${title}`, '', `> 导出时间:${new Date().toLocaleString()}`, ''];
messages.forEach(msg => {
const role = msg.role === 'user' ? '👤 You' : '🤖 GoNavi AI';
lines.push(`## ${role}`);
lines.push('');
lines.push(msg.content);
lines.push('');
lines.push('---');
lines.push('');
});
const blob = new Blob([lines.join('\n')], { type: 'text/markdown;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${title.replace(/[/\\?%*:|"<>]/g, '-')}.md`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
};
export const AIChatHeader: React.FC<AIChatHeaderProps> = ({
darkMode, mutedColor, textColor, overlayTheme,
onHistoryClick, onClear, onSettingsClick, onClose,
messages = [], sessionTitle = '新对话'
}) => {
return (
<div className="ai-chat-header" style={{ borderBottom: 'none', padding: '10px 16px', background: darkMode ? 'rgba(255,255,255,0.02)' : 'rgba(0,0,0,0.01)' }}>
<div className="ai-chat-header-left" style={{ gap: 8 }}>
<Tooltip title="历史会话">
<Button type="text" size="small" icon={<HistoryOutlined />} onClick={onHistoryClick} style={{ color: mutedColor }} />
</Tooltip>
<div className="ai-logo" style={{ background: overlayTheme.iconBg, color: overlayTheme.iconColor, display: 'flex', alignItems: 'center', justifyContent: 'center', width: 20, height: 20, borderRadius: 6, fontSize: 12 }}>
<RobotOutlined />
</div>
<span className="ai-title" style={{ color: textColor, fontSize: 13, fontWeight: 600 }}>GoNavi AI</span>
</div>
<div className="ai-chat-header-right">
{messages.length > 0 && (
<Tooltip title="导出为 Markdown">
<Button type="text" size="small" icon={<ExportOutlined />} onClick={() => exportToMarkdown(messages, sessionTitle)} style={{ color: mutedColor }} />
</Tooltip>
)}
<Tooltip title="新对话 (清空当前)">
<Button type="text" size="small" icon={<ClearOutlined />} onClick={onClear} style={{ color: mutedColor }} />
</Tooltip>
<Tooltip title="AI 设置">
<Button type="text" size="small" icon={<SettingOutlined />} onClick={onSettingsClick} style={{ color: mutedColor }} />
</Tooltip>
<Tooltip title="关闭面板">
<Button type="text" size="small" icon={<CloseOutlined />} onClick={onClose} style={{ color: mutedColor }} />
</Tooltip>
</div>
</div>
);
};

View File

@@ -0,0 +1,61 @@
import React from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { describe, expect, it, vi } from 'vitest';
import { AIChatInput } from './AIChatInput';
import { buildOverlayWorkbenchTheme } from '../../utils/overlayWorkbenchTheme';
vi.mock('../../store', () => ({
useStore: (selector: (state: any) => any) => selector({
aiContexts: {},
addAIContext: vi.fn(),
removeAIContext: vi.fn(),
}),
}));
vi.mock('../../../wailsjs/go/app/App', () => ({
DBGetTables: vi.fn(),
DBShowCreateTable: vi.fn(),
DBGetDatabases: vi.fn(),
}));
describe('AIChatInput notice layout', () => {
it('renders the composer notice above the input editor', () => {
const markup = renderToStaticMarkup(
<AIChatInput
input=""
setInput={() => {}}
draftImages={[]}
setDraftImages={() => {}}
sending={false}
onSend={() => {}}
onStop={() => {}}
handleKeyDown={() => {}}
activeConnName=""
activeContext={null}
activeProvider={{ model: '', models: [] }}
dynamicModels={[]}
loadingModels={false}
composerNotice={{
tone: 'error',
title: '模型列表加载失败',
description: '请检查供应商入口和 API Key。',
}}
onModelChange={() => {}}
onFetchModels={() => {}}
textareaRef={React.createRef<HTMLTextAreaElement>()}
darkMode={false}
textColor="#162033"
mutedColor="rgba(16,24,40,0.55)"
overlayTheme={buildOverlayWorkbenchTheme(false)}
/>
);
const noticeIndex = markup.indexOf('data-ai-chat-composer-notice="true"');
const inputIndex = markup.indexOf('data-ai-chat-composer-input="true"');
expect(noticeIndex).toBeGreaterThanOrEqual(0);
expect(inputIndex).toBeGreaterThanOrEqual(0);
expect(noticeIndex).toBeLessThan(inputIndex);
});
});

View File

@@ -0,0 +1,632 @@
import React from 'react';
import { Input, Select, AutoComplete, Tooltip, Modal, Checkbox, Spin, message, Button, Tag } from 'antd';
import { DatabaseOutlined, SendOutlined, TableOutlined, SearchOutlined, PictureOutlined, ExclamationCircleFilled } from '@ant-design/icons';
import { useStore } from '../../store';
import { DBGetTables, DBShowCreateTable, DBGetDatabases } from '../../../wailsjs/go/app/App';
import type { OverlayWorkbenchTheme } from '../../utils/overlayWorkbenchTheme';
import type { AIComposerNotice } from '../../utils/aiComposerNotice';
interface AIChatInputProps {
input: string;
setInput: (val: string) => void;
draftImages: string[];
setDraftImages: React.Dispatch<React.SetStateAction<string[]>>;
sending: boolean;
onSend: () => void;
onStop: () => void;
handleKeyDown: (e: React.KeyboardEvent) => void;
activeConnName: string;
activeContext: any;
activeProvider: any;
dynamicModels: string[];
loadingModels: boolean;
composerNotice?: AIComposerNotice | null;
onModelChange: (val: string) => void;
onFetchModels: () => void;
textareaRef: React.RefObject<HTMLTextAreaElement>;
darkMode: boolean;
textColor: string;
mutedColor: string;
overlayTheme: OverlayWorkbenchTheme;
contextUsageChars?: number;
maxContextChars?: number;
}
export const AIChatInput: React.FC<AIChatInputProps> = ({
input, setInput, draftImages, setDraftImages, sending, onSend, onStop, handleKeyDown,
activeConnName, activeContext, activeProvider, dynamicModels, loadingModels,
composerNotice,
onModelChange, onFetchModels, textareaRef, darkMode, textColor, mutedColor, overlayTheme,
contextUsageChars, maxContextChars
}) => {
const [contextOpen, setContextOpen] = React.useState(false);
const [contextLoading, setContextLoading] = React.useState(false);
const [contextTables, setContextTables] = React.useState<{name: string}[]>([]);
const [selectedTableKeys, setSelectedTableKeys] = React.useState<string[]>([]);
const [searchText, setSearchText] = React.useState('');
const [appendingContext, setAppendingContext] = React.useState(false);
const fileInputRef = React.useRef<HTMLInputElement>(null);
const handleImageUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files || []);
files.forEach(file => {
if (file.type.indexOf('image') !== -1) {
const reader = new FileReader();
reader.onload = (event) => {
if (event.target?.result) {
setDraftImages(prev => [...prev, event.target!.result as string]);
}
};
reader.readAsDataURL(file);
}
});
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
};
const [dbList, setDbList] = React.useState<string[]>([]);
const [selectedDbName, setSelectedDbName] = React.useState<string>('');
const filteredTables = contextTables.filter(t => t.name.toLowerCase().includes(searchText.toLowerCase()));
const [contextExpanded, setContextExpanded] = React.useState(false);
const composerNoticePalette = React.useMemo(() => {
if (composerNotice?.tone === 'error') {
return darkMode
? {
background: 'rgba(255,120,117,0.12)',
borderColor: 'rgba(255,120,117,0.24)',
iconColor: '#ff7875',
}
: {
background: 'rgba(255,77,79,0.08)',
borderColor: 'rgba(255,77,79,0.16)',
iconColor: '#ff4d4f',
};
}
return darkMode
? {
background: 'rgba(250,173,20,0.12)',
borderColor: 'rgba(250,173,20,0.22)',
iconColor: '#ffd666',
}
: {
background: 'rgba(250,173,20,0.08)',
borderColor: 'rgba(250,173,20,0.18)',
iconColor: '#d48806',
};
}, [composerNotice, darkMode]);
// Slash commands
const [showSlashMenu, setShowSlashMenu] = React.useState(false);
const [slashFilter, setSlashFilter] = React.useState('');
const slashCommands = React.useMemo(() => [
{ cmd: '/query', label: '🔍 自然语言查询', desc: '用中文描述你想查什么', prompt: '帮我写一条 SQL 查询:' },
{ cmd: '/sql', label: '📝 生成 SQL', desc: '描述需求自动生成语句', prompt: '请根据以下需求生成 SQL' },
{ cmd: '/explain', label: '💡 解释 SQL', desc: '解释选中 SQL 的逻辑', prompt: '请解释以下 SQL 的执行逻辑和每一步的作用:\n```sql\n\n```' },
{ cmd: '/optimize', label: '⚡ 优化分析', desc: '分析 SQL 性能瓶颈', prompt: '请分析以下 SQL 的性能问题,并给出优化后的版本:\n```sql\n\n```' },
{ cmd: '/schema', label: '🏗️ 表设计评审', desc: '评审表结构设计质量', prompt: '请全面评审当前关联表的设计,包括字段类型、范式、索引策略等方面的改进建议:' },
{ cmd: '/index', label: '📊 索引建议', desc: '推荐最优索引方案', prompt: '请基于当前表结构和常见查询场景,推荐最优的索引方案并给出建表语句:' },
{ cmd: '/diff', label: '🔄 表对比', desc: '对比两表差异生成变更', prompt: '请对比以下两张表的结构差异,并生成从旧版本迁移到新版本的 ALTER 语句:' },
{ cmd: '/mock', label: '🎲 造测试数据', desc: '生成 INSERT 测试数据', prompt: '请为当前关联的表生成 10 条符合业务语义的测试数据 INSERT 语句:' },
], []);
const filteredSlashCmds = slashCommands.filter(c => c.cmd.startsWith(slashFilter.toLowerCase()));
const aiContexts = useStore(state => state.aiContexts);
const addAIContext = useStore(state => state.addAIContext);
const removeAIContext = useStore(state => state.removeAIContext);
const connectionKey = activeContext?.connectionId ? `${activeContext.connectionId}:${activeContext.dbName || ''}` : 'default';
const activeContextItems = aiContexts[connectionKey] || [];
const fetchTablesForDb = async (dbName: string, connConfig: any) => {
setContextLoading(true);
setSelectedDbName(dbName);
try {
const res = await DBGetTables(connConfig, dbName);
if (res.success && Array.isArray(res.data)) {
setContextTables(res.data.map(r => ({ name: Object.values(r)[0] as string })));
} else {
message.error('获取表格失败: ' + res.message);
setContextTables([]);
}
} catch (e: any) {
message.error(e.message);
setContextTables([]);
} finally {
setContextLoading(false);
}
};
const handleOpenContext = async () => {
if (!activeContext?.connectionId) {
message.warning('请先在左侧选择一个数据库作为所聊上下文');
return;
}
const conn = useStore.getState().connections.find(c => c.id === activeContext.connectionId);
if (!conn) return;
setContextOpen(true);
setContextLoading(true);
setSearchText('');
// Store dbName::tableName composite keys
setSelectedTableKeys(activeContextItems.map(c => `${c.dbName}::${c.tableName}`));
try {
// Fetch databases
const dbRes = await DBGetDatabases(conn.config as any);
if (dbRes.success && Array.isArray(dbRes.data)) {
const databases = dbRes.data.map((r: any) => Object.values(r)[0] as string);
setDbList(databases);
}
// Fetch tables for the active contextual database
const initDbName = activeContext.dbName || '';
setSelectedDbName(initDbName);
const tablesRes = await DBGetTables(conn.config as any, initDbName);
if (tablesRes.success && Array.isArray(tablesRes.data)) {
setContextTables(tablesRes.data.map((r: any) => ({ name: Object.values(r)[0] as string })));
} else {
setContextTables([]);
}
} catch (e: any) {
message.error(e.message);
} finally {
setContextLoading(false);
}
};
const handleAppendContext = async () => {
const conn = useStore.getState().connections.find(c => c.id === activeContext.connectionId);
if (!conn) return;
setAppendingContext(true);
try {
let addedCount = 0;
let removedCount = 0;
for (const cx of activeContextItems) {
const key = `${cx.dbName}::${cx.tableName}`;
if (!selectedTableKeys.includes(key)) {
removeAIContext(connectionKey, cx.dbName, cx.tableName);
removedCount++;
}
}
for (const key of selectedTableKeys) {
const [dbName, tableName] = key.split('::');
if (!dbName || !tableName) continue;
if (activeContextItems.find(c => c.dbName === dbName && c.tableName === tableName)) {
continue;
}
const res = await DBShowCreateTable(conn.config as any, dbName, tableName);
let createSql = '';
if (res.success && res.data) {
if (typeof res.data === 'string') {
createSql = res.data;
} else if (Array.isArray(res.data) && res.data.length > 0) {
const row = res.data[0];
createSql = (Object.values(row).find(v => typeof v === 'string' && (v.toUpperCase().includes('CREATE TABLE') || v.toUpperCase().includes('CREATE'))) || Object.values(row)[1] || Object.values(row)[0]) as string;
}
} else {
message.error(`获取表 ${dbName}.${tableName} 结构失败: ` + (res.message || '未知错误'));
}
if (createSql) {
addAIContext(connectionKey, {
dbName: dbName,
tableName: tableName,
ddl: createSql
});
addedCount++;
}
}
if (addedCount > 0 || removedCount > 0) {
if (addedCount > 0 && removedCount === 0) {
message.success(`已添加 ${addedCount} 张表的结构到上下文`);
} else if (removedCount > 0 && addedCount === 0) {
message.success(`已从上下文移除 ${removedCount} 张表的结构`);
} else {
message.success(`上下文已同步更新:新增 ${addedCount},移除 ${removedCount}`);
}
if (addedCount > 0) setContextExpanded(true);
} else {
message.info('选中的表未发生变化');
}
setContextOpen(false);
} catch (e: any) {
message.error(e.message);
} finally {
setAppendingContext(false);
}
};
return (
<div className="ai-chat-input-area" style={{ borderTop: 'none', padding: '12px 16px 20px' }}>
<div className="ai-chat-input-wrapper" style={{
borderColor: 'transparent',
background: 'transparent',
display: 'flex',
flexDirection: 'column',
alignItems: 'stretch',
gap: 8,
padding: '8px 4px 8px'
}}>
<div className="ai-chat-input-preview-area" style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
{activeContextItems.length > 0 && (
<Tag
onClick={() => setContextExpanded(!contextExpanded)}
style={{ background: darkMode ? 'rgba(24, 144, 255, 0.15)' : 'rgba(24, 144, 255, 0.08)', border: 'none', color: '#1890ff', borderRadius: 12, padding: '4px 10px', display: 'flex', alignItems: 'center', gap: 4, margin: 0, cursor: 'pointer', transition: 'all 0.3s' }}
>
<span style={{ fontSize: 13, fontWeight: 500, display: 'flex', alignItems: 'center', gap: 6 }}>
<DatabaseOutlined /> ({activeContextItems.length}) {contextExpanded ? '▴' : '▾'}
</span>
</Tag>
)}
{contextExpanded && activeContextItems.map((ctx, idx) => (
<Tag
key={`ctx-${idx}`}
closable
onClose={(e) => { e.preventDefault(); removeAIContext(connectionKey, ctx.dbName, ctx.tableName); }}
style={{ background: darkMode ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.04)', border: 'none', color: textColor, borderRadius: 12, padding: '4px 10px', display: 'flex', alignItems: 'center', gap: 4, margin: 0 }}
>
<span style={{ fontSize: 13 }}>🗄 {ctx.tableName}</span>
</Tag>
))}
{draftImages.map((b64, i) => (
<div key={i} style={{ position: 'relative', width: 60, height: 60, borderRadius: 6, overflow: 'hidden', border: overlayTheme.shellBorder }}>
<img src={b64} style={{ width: '100%', height: '100%', objectFit: 'cover' }} alt={`Draft ${i}`} />
<div
onClick={() => setDraftImages(prev => prev.filter((_, idx) => idx !== i))}
style={{ position: 'absolute', top: 2, right: 2, background: 'rgba(0,0,0,0.5)', color: '#fff', borderRadius: '50%', width: 16, height: 16, display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', fontSize: 10 }}
>
</div>
</div>
))}
</div>
{composerNotice && (
<div
data-ai-chat-composer-notice="true"
style={{
display: 'flex',
alignItems: 'flex-start',
gap: 8,
padding: '8px 10px',
borderRadius: 12,
background: composerNoticePalette.background,
border: `1px solid ${composerNoticePalette.borderColor}`,
}}
>
<ExclamationCircleFilled style={{ color: composerNoticePalette.iconColor, fontSize: 14, marginTop: 1, flexShrink: 0 }} />
<div style={{ minWidth: 0 }}>
<div style={{ fontSize: 12, fontWeight: 600, color: textColor, lineHeight: 1.4 }}>
{composerNotice.title}
</div>
<div style={{ fontSize: 11, color: mutedColor, lineHeight: 1.5, marginTop: 2, wordBreak: 'break-word' }}>
{composerNotice.description}
</div>
</div>
</div>
)}
<div data-ai-chat-composer-input="true" style={{ position: 'relative' }}>
{showSlashMenu && filteredSlashCmds.length > 0 && (
<div style={{
position: 'absolute', bottom: '100%', left: 0, right: 0, marginBottom: 4,
background: darkMode ? '#2a2a2a' : '#fff',
border: `1px solid ${darkMode ? 'rgba(255,255,255,0.12)' : 'rgba(0,0,0,0.1)'}`,
borderRadius: 8, boxShadow: '0 4px 16px rgba(0,0,0,0.15)', zIndex: 100,
maxHeight: 220, overflowY: 'auto', padding: 4
}}>
{filteredSlashCmds.map(cmd => (
<div
key={cmd.cmd}
style={{
padding: '8px 12px', borderRadius: 6, cursor: 'pointer',
display: 'flex', alignItems: 'center', gap: 10,
transition: 'background 0.15s'
}}
onMouseEnter={e => e.currentTarget.style.background = darkMode ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.04)'}
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
onClick={() => {
setInput(cmd.prompt);
setShowSlashMenu(false);
setSlashFilter('');
textareaRef.current?.focus();
}}
>
<span style={{ fontSize: 14, fontWeight: 600, color: textColor, minWidth: 80 }}>{cmd.cmd}</span>
<span style={{ fontSize: 13, fontWeight: 500, color: textColor }}>{cmd.label}</span>
<span style={{ fontSize: 11, color: mutedColor, marginLeft: 'auto' }}>{cmd.desc}</span>
</div>
))}
</div>
)}
<Input.TextArea
onPaste={(e) => {
const items = e.clipboardData?.items;
if (!items) return;
for (let i = 0; i < items.length; i++) {
if (items[i].type.indexOf('image') !== -1) {
e.preventDefault();
const blob = items[i].getAsFile();
if (blob) {
const reader = new FileReader();
reader.onload = (event) => {
if (event.target?.result) {
setDraftImages(prev => [...prev, event.target!.result as string]);
}
};
reader.readAsDataURL(blob);
}
}
}
}}
ref={textareaRef as any}
value={input}
onChange={(e) => {
const val = e.target.value;
setInput(val);
// Slash command detection
if (val.startsWith('/')) {
setSlashFilter(val.split(/\s/)[0]);
setShowSlashMenu(true);
} else {
setShowSlashMenu(false);
setSlashFilter('');
}
}}
onKeyDown={handleKeyDown as any}
placeholder="输入消息... (Enter 发送Shift+Enter 换行,/ 快捷命令)"
variant="borderless"
autoSize={{ minRows: 1, maxRows: 8 }}
style={{ color: textColor, width: '100%', padding: 0, resize: 'none' }}
/>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', width: '100%' }}>
<div style={{ display: 'flex', gap: 6, alignItems: 'center', flexWrap: 'wrap' }}>
{activeConnName && (
<Tooltip title="当前数据查询上下文">
<div style={{
display: 'flex', alignItems: 'center', gap: 4,
fontSize: 11, padding: '2px 8px', borderRadius: 12,
background: darkMode ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.04)',
color: overlayTheme.mutedText, cursor: 'default'
}}>
<DatabaseOutlined style={{ fontSize: 10 }} />
<span style={{ maxWidth: 240, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{activeConnName}{activeContext?.dbName ? ` / ${activeContext.dbName}` : ''}
</span>
</div>
</Tooltip>
)}
{activeProvider && (
<Select
size="small"
variant="filled"
value={activeProvider.model || undefined}
onChange={onModelChange}
onDropdownVisibleChange={(open) => {
if (open && dynamicModels.length === 0 && (activeProvider.models || []).length === 0) {
onFetchModels();
}
}}
loading={loadingModels}
options={(dynamicModels.length > 0 ? dynamicModels : (activeProvider.models || [])).map((m: string) => ({ label: m, value: m }))}
style={{ width: 130, fontSize: 11, background: 'transparent' }}
dropdownStyle={{ minWidth: 200 }}
showSearch
placeholder="选择模型"
/>
)}
{contextUsageChars !== undefined && maxContextChars !== undefined && (
<Tooltip title={`当前会话记忆已用字符。达到限制(${(maxContextChars/1000).toFixed(0)}k时将触发自动压缩。`}>
<div style={{
display: 'flex', alignItems: 'center', gap: 4,
fontSize: 10, padding: '2px 6px', borderRadius: 12, border: '1px solid transparent',
background: contextUsageChars > maxContextChars * 0.8 ? (darkMode ? 'rgba(250, 173, 20, 0.1)' : 'rgba(250, 173, 20, 0.08)') : (darkMode ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.04)'),
borderColor: contextUsageChars > maxContextChars * 0.8 ? 'rgba(250, 173, 20, 0.3)' : 'transparent',
color: contextUsageChars > maxContextChars * 0.8 ? '#faad14' : overlayTheme.mutedText, cursor: 'default',
transition: 'all 0.3s'
}}>
<span>🧠 {(contextUsageChars / 1000).toFixed(1)}k / {(maxContextChars / 1000).toFixed(0)}k</span>
</div>
</Tooltip>
)}
</div>
<div style={{ display: 'flex', gap: 6, alignItems: 'center', flexShrink: 0 }}>
<input
type="file"
accept="image/*"
multiple
ref={fileInputRef}
style={{ display: 'none' }}
onChange={handleImageUpload}
/>
<Tooltip title="上传图片/截图">
<Button
type="text"
icon={<PictureOutlined style={{ fontSize: 16 }} />}
onClick={() => fileInputRef.current?.click()}
style={{ color: overlayTheme.mutedText, border: 'none', background: 'transparent', padding: '0 4px', height: 26 }}
onMouseEnter={e => e.currentTarget.style.color = textColor}
onMouseLeave={e => e.currentTarget.style.color = overlayTheme.mutedText}
/>
</Tooltip>
<Tooltip title="关联附带数据库表上下文">
<Button
type="text"
icon={<TableOutlined style={{ fontSize: 16 }} />}
onClick={handleOpenContext}
style={{ color: overlayTheme.mutedText, border: 'none', background: 'transparent', padding: '0 4px', height: 26 }}
onMouseEnter={e => e.currentTarget.style.color = textColor}
onMouseLeave={e => e.currentTarget.style.color = overlayTheme.mutedText}
/>
</Tooltip>
{sending ? (
<button
className="ai-chat-send-btn ai-chat-stop-btn"
onClick={onStop}
title="停止生成"
style={{
background: 'rgba(255,77,79,0.1)',
color: '#ff4d4f', border: '1px solid rgba(255,77,79,0.2)',
width: 26, height: 26, borderRadius: 6, padding: 0,
display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', flexShrink: 0
}}
>
<div style={{ width: 10, height: 10, background: 'currentColor', borderRadius: 2 }} />
</button>
) : (
<button
className="ai-chat-send-btn"
onClick={() => onSend()}
disabled={!input.trim() && draftImages.length === 0}
title="发送"
style={{
background: (input.trim() || draftImages.length > 0) ? overlayTheme.iconBg : (darkMode ? 'rgba(255,255,255,0.04)' : 'rgba(0,0,0,0.04)'),
color: (input.trim() || draftImages.length > 0) ? overlayTheme.iconColor : mutedColor,
width: 26, height: 26, borderRadius: 6, border: 'none', padding: 0,
display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: (input.trim() || draftImages.length > 0) ? 'pointer' : 'not-allowed', flexShrink: 0
}}
>
<SendOutlined />
</button>
)}
</div>
</div>
</div>
<Modal
title={<span style={{ color: textColor }}></span>}
open={contextOpen}
onCancel={() => setContextOpen(false)}
onOk={handleAppendContext}
confirmLoading={appendingContext}
okText="同步所选表至上下文"
cancelText="取消"
centered
styles={{
content: { background: darkMode ? '#1e1e1e' : '#ffffff', border: overlayTheme.shellBorder },
header: { background: darkMode ? '#1e1e1e' : '#ffffff', borderBottom: overlayTheme.shellBorder },
body: { padding: '20px 24px' }
}}
>
<Spin spinning={contextLoading}>
<div style={{ marginBottom: 16, display: 'flex', gap: 12 }}>
{dbList.length > 0 && (
<Select
value={selectedDbName}
onChange={val => {
const c = useStore.getState().connections.find(conn => conn.id === activeContext?.connectionId);
if (c) fetchTablesForDb(val, c.config);
}}
options={dbList.map(d => ({ label: d, value: d }))}
style={{ width: 160, flexShrink: 0 }}
placeholder="切换数据库"
showSearch
/>
)}
<Input
placeholder="在当前库搜索表名..."
prefix={<SearchOutlined style={{ color: overlayTheme.mutedText }} />}
value={searchText}
onChange={e => setSearchText(e.target.value)}
style={{ background: darkMode ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.04)', border: 'none', flexGrow: 1 }}
/>
</div>
{filteredTables.length > 0 ? (
<div style={{ display: 'flex', flexDirection: 'column' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', borderBottom: `1px solid ${darkMode ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.1)'}`, paddingBottom: 12, marginBottom: 8 }}>
<Checkbox
indeterminate={
filteredTables.length > 0 &&
filteredTables.some(t => selectedTableKeys.includes(`${selectedDbName}::${t.name}`)) &&
!filteredTables.every(t => selectedTableKeys.includes(`${selectedDbName}::${t.name}`))
}
checked={filteredTables.length > 0 && filteredTables.every(t => selectedTableKeys.includes(`${selectedDbName}::${t.name}`))}
onChange={(e) => {
if (e.target.checked) {
const newSelected = new Set([...selectedTableKeys, ...filteredTables.map(t => `${selectedDbName}::${t.name}`)]);
setSelectedTableKeys(Array.from(newSelected));
} else {
const filteredKeys = filteredTables.map(t => `${selectedDbName}::${t.name}`);
setSelectedTableKeys(selectedTableKeys.filter(key => !filteredKeys.includes(key)));
}
}}
style={{ color: textColor, fontWeight: 'bold' }}
>
({filteredTables.length})
</Checkbox>
<Button
type="link"
size="small"
style={{ padding: 0, height: 'auto', fontSize: 13 }}
onClick={() => {
const filteredKeys = filteredTables.map(t => `${selectedDbName}::${t.name}`);
const remainingSelected = selectedTableKeys.filter(key => !filteredKeys.includes(key));
const toAdd = filteredKeys.filter(key => !selectedTableKeys.includes(key));
setSelectedTableKeys([...remainingSelected, ...toAdd]);
}}
>
</Button>
</div>
<div style={{ maxHeight: 300, overflowY: 'auto', margin: '0 -24px', padding: '0 24px' }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
{filteredTables.map(t => {
const key = `${selectedDbName}::${t.name}`;
const isSelected = selectedTableKeys.includes(key);
return (
<div
key={key}
style={{
padding: '6px 10px',
borderRadius: 6,
transition: 'background 0.2s',
cursor: 'pointer'
}}
onMouseEnter={e => e.currentTarget.style.background = darkMode ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.03)'}
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
onClick={(e) => {
// If click originated from the checkbox input itself, let its onChange handle it to avoid duplicate toggle
if ((e.target as HTMLElement).tagName.toLowerCase() === 'input') return;
if (isSelected) {
setSelectedTableKeys(selectedTableKeys.filter(k => k !== key));
} else {
setSelectedTableKeys([...selectedTableKeys, key]);
}
}}
>
<Checkbox
checked={isSelected}
onChange={(e) => {
if (e.target.checked) setSelectedTableKeys([...selectedTableKeys, key]);
else setSelectedTableKeys(selectedTableKeys.filter(k => k !== key));
}}
style={{ color: textColor, width: '100%' }}
>
<span style={{ fontSize: 13, userSelect: 'none' }}>{t.name}</span>
</Checkbox>
</div>
);
})}
</div>
</div>
</div>
) : (
<div style={{ padding: '40px 0', textAlign: 'center', color: overlayTheme.mutedText }}>
'{searchText}'
</div>
)}
</Spin>
</Modal>
</div>
);
};

View File

@@ -0,0 +1,64 @@
import React from 'react';
import { RobotOutlined } from '@ant-design/icons';
import type { OverlayWorkbenchTheme } from '../../utils/overlayWorkbenchTheme';
interface AIChatWelcomeProps {
overlayTheme: OverlayWorkbenchTheme;
quickActionBg: string;
quickActionBorder: string;
textColor: string;
mutedColor: string;
onQuickAction: (prompt: string, autoSend?: boolean) => void;
contextTableNames?: string[];
}
export const AIChatWelcome: React.FC<AIChatWelcomeProps> = ({
overlayTheme, quickActionBg, quickActionBorder, textColor, mutedColor, onQuickAction, contextTableNames = []
}) => {
const hasContext = contextTableNames.length > 0;
const tableList = contextTableNames.join('、');
const quickActions = hasContext
? [
{ label: '📝 生成 SQL', prompt: `请根据以下表结构生成一条常用查询语句:${tableList}` },
{ label: '🔍 解释表结构', prompt: `请详细解释以下表的设计意图和字段含义:${tableList}` },
{ label: '⚡ 优化建议', prompt: `请分析以下表的结构设计,给出索引优化和查询性能优化建议:${tableList}` },
{ label: '🏗️ Schema 分析', prompt: `请对以下表进行全面的 Schema 分析,包括数据类型选择、范式评估和改进建议:${tableList}` },
]
: [
{ label: '📝 生成 SQL', prompt: '请根据当前数据库表结构生成一条查询语句:' },
{ label: '🔍 解释 SQL', prompt: '请解释以下 SQL 语句的执行逻辑:\n```sql\n\n```' },
{ label: '⚡ 优化建议', prompt: '请分析以下 SQL 语句的性能并给出优化建议:\n```sql\n\n```' },
{ label: '🏗️ Schema 分析', prompt: '请分析当前数据库的表结构并给出优化建议。' },
];
return (
<div className="ai-chat-welcome" style={{ padding: '30px 20px', alignItems: 'flex-start', textAlign: 'left' }}>
<div style={{ color: overlayTheme.titleText, fontSize: 16, fontWeight: 600, marginBottom: 8 }}>
<RobotOutlined style={{ marginRight: 8, color: overlayTheme.iconColor }} />
GoNavi AI
</div>
<div className="welcome-desc" style={{ color: mutedColor, fontSize: 13, lineHeight: 1.6, marginBottom: 20 }}>
{hasContext
? `已自动关联 ${contextTableNames.length} 张表结构,点击下方按钮快速开始分析。`
: '我是你的智能数据库助手。我可以帮你生成 SQL 查询、分析表结构、解释执行逻辑以及优化数据库性能。'}
</div>
<div className="quick-actions">
{quickActions.map(action => (
<div
key={action.label}
className="quick-action-btn"
style={{
background: quickActionBg,
borderColor: quickActionBorder,
color: textColor,
}}
onClick={() => onQuickAction(action.prompt)}
>
{action.label}
</div>
))}
</div>
</div>
);
};

View File

@@ -0,0 +1,127 @@
import React, { useState } from 'react';
import { Drawer, Button, Tooltip, Input } from 'antd';
import { MenuFoldOutlined, PlusOutlined, DeleteOutlined, SearchOutlined } from '@ant-design/icons';
import { useStore } from '../../store';
interface AIHistoryDrawerProps {
open: boolean;
onClose: () => void;
bgColor?: string;
darkMode: boolean;
textColor: string;
mutedColor: string;
borderColor: string;
onCreateNew: () => void;
sessionId: string;
}
export const AIHistoryDrawer: React.FC<AIHistoryDrawerProps> = ({
open, onClose, bgColor, darkMode, textColor, mutedColor, borderColor, onCreateNew, sessionId
}) => {
const aiChatSessions = useStore(state => state.aiChatSessions);
const setAIActiveSessionId = useStore(state => state.setAIActiveSessionId);
const deleteAISession = useStore(state => state.deleteAISession);
// 阶段4: 历史记录搜索
const [searchText, setSearchText] = useState('');
const filteredSessions = aiChatSessions.filter(s =>
!searchText || (s.title && s.title.toLowerCase().includes(searchText.toLowerCase()))
);
return (
<Drawer
placement="left"
closable={false}
onClose={onClose}
open={open}
getContainer={false}
style={{ position: 'absolute', background: bgColor || (darkMode ? '#1e1e1e' : '#f8f9fa') }}
width={260}
bodyStyle={{ padding: 0, display: 'flex', flexDirection: 'column' }}
>
{/* 侧拉面板头部 */}
<div style={{ padding: '16px 16px 12px', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<span style={{ fontSize: 14, fontWeight: 600, color: textColor }}></span>
<Tooltip title="收起">
<Button type="text" size="small" icon={<MenuFoldOutlined />} onClick={onClose} style={{ color: mutedColor }} />
</Tooltip>
</div>
{/* 新建对话按钮 */}
<div style={{ padding: '0 12px 12px' }}>
<Button
type="dashed"
block
icon={<PlusOutlined />}
onClick={() => { onCreateNew(); onClose(); }}
style={{ borderColor: borderColor, color: textColor, background: 'transparent' }}
>
</Button>
</div>
{/* 列表搜索 */}
<div style={{ padding: '0 12px 12px' }}>
<Input
placeholder="搜索历史记录..."
prefix={<SearchOutlined style={{ color: mutedColor }} />}
value={searchText}
onChange={e => setSearchText(e.target.value)}
variant="filled"
size="small"
style={{ background: darkMode ? 'rgba(255,255,255,0.04)' : 'transparent', color: textColor }}
/>
</div>
{/* 列表容器 */}
<div style={{ flex: 1, overflowY: 'auto', padding: '0 10px 16px' }} className="ai-history-list">
{filteredSessions.length === 0 ? (
<div style={{ padding: '30px 0', textAlign: 'center', color: mutedColor, fontSize: 12 }}></div>
) : (
filteredSessions.map(session => (
<div
key={session.id}
className={`ai-history-item ${sessionId === session.id ? 'active' : ''}`}
onClick={() => { setAIActiveSessionId(session.id); onClose(); }}
style={{
padding: '10px 12px',
borderRadius: 6,
marginBottom: 4,
cursor: 'pointer',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
background: sessionId === session.id ? (darkMode ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.04)') : 'transparent',
transition: 'background 0.2s',
}}
>
<div style={{ overflow: 'hidden', flex: 1, paddingRight: 8 }}>
<div style={{ fontSize: 13, color: textColor, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', fontWeight: sessionId === session.id ? 600 : 'normal' }}>
{session.title || '新对话'}
</div>
<div style={{ fontSize: 11, color: mutedColor, marginTop: 4 }}>
{new Date(session.updatedAt).toLocaleString(undefined, { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' })}
</div>
</div>
<Tooltip title="删除">
<Button
className="ai-history-delete-btn"
type="text"
size="small"
danger
icon={<DeleteOutlined />}
onClick={(e) => {
e.stopPropagation();
deleteAISession(session.id);
}}
style={{ display: sessionId === session.id ? 'inline-flex' : undefined }}
/>
</Tooltip>
</div>
))
)}
</div>
</Drawer>
);
};

View File

@@ -0,0 +1,735 @@
import React, { useState, useEffect, useRef } from 'react';
import { Tooltip, message } from 'antd';
import { UserOutlined, RobotOutlined, EditOutlined, ReloadOutlined, DeleteOutlined, CheckOutlined, CopyOutlined, PlayCircleOutlined, ApiOutlined, LoadingOutlined, CaretRightOutlined, CaretDownOutlined } from '@ant-design/icons';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import mermaid from 'mermaid';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import { vscDarkPlus, vs } from 'react-syntax-highlighter/dist/esm/styles/prism';
import { AIChatMessage, AIToolCall } from '../../types';
import type { OverlayWorkbenchTheme } from '../../utils/overlayWorkbenchTheme';
// 🔧 性能优化:将 ReactMarkdown 包装为 Memo 组件并提取固定的 plugins
const remarkPlugins = [remarkGfm];
const MemoizedMarkdown = React.memo(({
content,
darkMode,
overlayTheme,
activeConnectionConfig,
activeConnectionId,
activeDbName
}: {
content: string;
darkMode: boolean;
overlayTheme: OverlayWorkbenchTheme;
activeConnectionConfig?: any;
activeConnectionId?: string;
activeDbName?: string;
}) => {
// 缓存 components 对象,避免每次渲染都生成新的函数引用击穿内部子组件的 memo
const components = React.useMemo(() => ({
code({ node, inline, className, children, ...props }: any) {
const match = /language-(\w+)/.exec(className || '');
if (!inline && match && match[1] === 'mermaid') {
return <MermaidRenderer chart={String(children).replace(/\n$/, '')} darkMode={darkMode} />;
}
return !inline && match ? (
<AIBlockHashRender match={match} darkMode={darkMode} overlayTheme={overlayTheme} children={children} activeConnectionConfig={activeConnectionConfig} activeConnectionId={activeConnectionId} activeDbName={activeDbName} />
) : (
<code className={className} {...props}>
{children}
</code>
);
}
}), [darkMode, overlayTheme, activeConnectionConfig, activeConnectionId, activeDbName]);
return (
<ReactMarkdown remarkPlugins={remarkPlugins} components={components}>
{content}
</ReactMarkdown>
);
});
interface AIMessageBubbleProps {
msg: AIChatMessage;
darkMode: boolean;
overlayTheme: OverlayWorkbenchTheme;
textColor: string;
onEdit: (msg: AIChatMessage) => void;
onRetry: (msg: AIChatMessage) => void;
onDelete: (id: string) => void;
activeConnectionId?: string;
activeConnectionConfig?: any;
activeDbName?: string;
allMessages?: AIChatMessage[];
}
const AIToolResultItem: React.FC<{ resultMsg: AIChatMessage, darkMode: boolean, overlayTheme: OverlayWorkbenchTheme }> = ({ resultMsg, darkMode, overlayTheme }) => {
const [toolExpanded, setToolExpanded] = useState(false);
const charCount = resultMsg.content ? resultMsg.content.length : 0;
return (
<div style={{
background: darkMode ? 'rgba(0,0,0,0.1)' : 'rgba(0,0,0,0.02)',
borderRadius: 6,
padding: '6px 10px',
border: `1px solid ${darkMode ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.05)'}`,
marginTop: 8,
width: '100%'
}}>
<div
style={{ display: 'flex', alignItems: 'center', cursor: 'pointer', gap: 6, fontSize: 12, color: overlayTheme.mutedText }}
onClick={() => setToolExpanded(!toolExpanded)}
>
{toolExpanded ? <CaretDownOutlined /> : <CaretRightOutlined />}
<ApiOutlined style={{ color: '#1677ff' }} />
<span> (<span style={{ fontFamily: 'monospace', color: overlayTheme.iconColor }}>{resultMsg.tool_name || 'unknown'}</span>)</span>
<span style={{ fontSize: 11, marginLeft: 8, opacity: 0.6 }}>{charCount > 0 ? `${charCount} 个字符` : '无数据'}</span>
</div>
{toolExpanded && (
<div style={{ marginTop: 8, fontSize: 12, color: overlayTheme.mutedText, fontFamily: 'monospace', whiteSpace: 'pre-wrap', wordBreak: 'break-all', maxHeight: 300, overflowY: 'auto', background: darkMode ? 'rgba(0,0,0,0.2)' : 'rgba(0,0,0,0.03)', padding: 8, borderRadius: 6 }}>
{resultMsg.content}
</div>
)}
</div>
);
};
const MermaidRenderer = ({ chart, darkMode }: { chart: string, darkMode: boolean }) => {
const containerRef = React.useRef<HTMLDivElement>(null);
React.useEffect(() => {
if (containerRef.current) {
try {
mermaid.initialize({ startOnLoad: false, theme: darkMode ? 'dark' : 'default' });
const id = `mermaid-${Math.random().toString(36).substring(2)}`;
(async () => {
const result: any = await mermaid.render(id, chart);
if (containerRef.current) {
containerRef.current.innerHTML = result.svg || result;
}
})().catch((e: any) => {
if (containerRef.current) {
containerRef.current.innerHTML = `<div style="color:#ef4444; padding:12px; background:rgba(239,68,68,0.1); border-radius:6px; font-size:12px">Mermaid 解析失败: ${e.message}</div>`;
}
});
} catch (e: any) {
if (containerRef.current) {
containerRef.current.innerHTML = `<div style="color:#ef4444; padding:12px; background:rgba(239,68,68,0.1); border-radius:6px; font-size:12px">Mermaid 渲染异常: ${e.message}</div>`;
}
}
}
}, [chart, darkMode]);
return <div ref={containerRef} className="ai-mermaid-container" style={{ margin: '16px 0', display: 'flex', justifyContent: 'flex-start', overflowX: 'auto' }} />;
};
const CodeCopyBtn = ({ text }: { text: string }) => {
const [copied, setCopied] = useState(false);
return (
<span
className="ai-code-copy-btn"
onClick={() => {
navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}}
style={{
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
opacity: copied ? 1 : 0.6,
transition: 'opacity 0.2s',
}}
onMouseEnter={(e) => { e.currentTarget.style.opacity = '1'; }}
onMouseLeave={(e) => { e.currentTarget.style.opacity = copied ? '1' : '0.6'; }}
>
{copied ? <CheckOutlined style={{ color: '#52c41a' }} /> : <CopyOutlined />}
<span style={{ marginLeft: 4 }}>{copied ? '已复制' : '复制代码'}</span>
</span>
);
};
const CodeRunBtn = ({ text, connectionId, dbName }: { text: string; connectionId?: string; dbName?: string }) => {
// 解析 SQL 顶部的 @context 注释,格式:-- @context connectionId=xxx dbName=yyy
const contextMatch = text.match(/^--\s*@context\s+connectionId=(\S+)\s+dbName=(\S+)/m);
const resolvedConnId = contextMatch?.[1] || connectionId;
const resolvedDbName = contextMatch?.[2] || dbName;
// 发送给查询编辑器时去掉 @context 注释行
const cleanSql = text.replace(/^--\s*@context\s+.*\n?/gm, '').trim();
const sqlDetail = (runImmediately: boolean) => ({ sql: cleanSql, runImmediately, connectionId: resolvedConnId, dbName: resolvedDbName });
const handleExecute = async () => {
try {
const Service = (window as any).go?.aiservice?.Service;
if (Service?.AICheckSQL) {
const result = await Service.AICheckSQL(text);
if (!result.allowed) {
message.error(`🔒 安全策略拦截:当前安全级别不允许执行 ${result.operationType} 类型的 SQL。请在 AI 设置中调整安全级别。`);
return;
}
if (result.requiresConfirm) {
const { Modal } = await import('antd');
Modal.confirm({
title: '⚠️ 安全确认',
content: result.warningMessage || `此 SQL 为 ${result.operationType} 操作,确定要执行吗?`,
okText: '确认执行',
cancelText: '取消',
okButtonProps: { danger: true },
onOk: () => {
window.dispatchEvent(new CustomEvent('gonavi:insert-sql', { detail: sqlDetail(true) }));
},
});
return;
}
}
// Safety check passed or not available, execute directly
window.dispatchEvent(new CustomEvent('gonavi:insert-sql', { detail: sqlDetail(true) }));
} catch (e) {
// If safety check fails, still allow manual execution
window.dispatchEvent(new CustomEvent('gonavi:insert-sql', { detail: sqlDetail(true) }));
}
};
return (
<div style={{ display: 'flex', gap: 10, alignItems: 'center' }}>
<Tooltip title="将该段 SQL 注入查询工作区(可快捷修改或执行)">
<span
className="ai-code-run-btn"
onClick={() => {
window.dispatchEvent(new CustomEvent('gonavi:insert-sql', { detail: sqlDetail(false) }));
}}
style={{
cursor: 'pointer', display: 'flex', alignItems: 'center',
opacity: 0.6, transition: 'opacity 0.2s', padding: '0 4px', color: '#10b981'
}}
onMouseEnter={(e) => { e.currentTarget.style.opacity = '1'; }}
onMouseLeave={(e) => { e.currentTarget.style.opacity = '0.6'; }}
>
<PlayCircleOutlined />
<span style={{ marginLeft: 4 }}></span>
</span>
</Tooltip>
<Tooltip title="立即执行(受 AI 安全策略管控)">
<span
className="ai-code-run-btn"
onClick={handleExecute}
style={{
cursor: 'pointer', display: 'flex', alignItems: 'center',
opacity: 0.6, transition: 'opacity 0.2s', padding: '0 4px', color: '#1677ff'
}}
onMouseEnter={(e) => { e.currentTarget.style.opacity = '1'; }}
onMouseLeave={(e) => { e.currentTarget.style.opacity = '0.6'; }}
>
<PlayCircleOutlined />
<span style={{ marginLeft: 4 }}></span>
</span>
</Tooltip>
</div>
);
};
// 阶段2: 代码块体验升级 (折叠展开、行号显示、内联SQL预览)
const AIBlockHashRender = ({ match, darkMode, overlayTheme, children, activeConnectionConfig, activeConnectionId, activeDbName }: any) => {
const codeText = String(children).replace(/\n$/, '');
// 将 @context 注释行从显示文本中剔除,用户无需看到内部元数据
const displayText = codeText.replace(/^--\s*@context\s+.*\n?/gm, '').trim();
const [expanded, setExpanded] = useState(false);
const [previewData, setPreviewData] = useState<any[] | null>(null);
const [previewCols, setPreviewCols] = useState<string[]>([]);
const [previewLoading, setPreviewLoading] = useState(false);
const [previewError, setPreviewError] = useState('');
const [previewExpanded, setPreviewExpanded] = useState(false);
const MAX_HEIGHT = 300;
const isLongCode = displayText.split('\n').length > 15;
const isSql = match[1] === 'sql';
const isSelectQuery = isSql && /^\s*(SELECT|SHOW|DESCRIBE|DESC|EXPLAIN)\b/i.test(displayText.trim());
const handleInlineExecute = async () => {
if (!activeConnectionConfig || previewLoading) return;
setPreviewLoading(true);
setPreviewError('');
setPreviewData(null);
try {
const { DBQuery } = await import('../../../wailsjs/go/app/App');
const res = await DBQuery(activeConnectionConfig, activeDbName || '', displayText + ' LIMIT 50');
if (res.success && Array.isArray(res.data)) {
const rows = res.data as any[];
const cols = rows.length > 0 ? Object.keys(rows[0]) : [];
setPreviewCols(cols);
setPreviewData(rows.slice(0, 20));
setPreviewExpanded(true);
} else {
setPreviewError(res.message || '查询无结果');
}
} catch (err: any) {
setPreviewError(err?.message || '执行失败');
} finally {
setPreviewLoading(false);
}
};
return (
<div className="ai-code-block-container" style={{ margin: '12px 0', border: overlayTheme.sectionBorder, borderRadius: 6, overflow: 'hidden' }}>
<div className="ai-code-header" style={{
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
padding: '6px 12px', background: darkMode ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.04)',
fontSize: 12, color: overlayTheme.mutedText
}}>
<span style={{ fontFamily: 'monospace' }}>{match[1]}</span>
<div style={{ display: 'flex', gap: 12, alignItems: 'center' }}>
{isSql && <CodeRunBtn text={codeText} connectionId={activeConnectionId} dbName={activeDbName} />}
{isSelectQuery && activeConnectionConfig && (
<Tooltip title="在聊天内预览查询结果最多20行">
<span
onClick={handleInlineExecute}
style={{
cursor: previewLoading ? 'wait' : 'pointer', display: 'flex', alignItems: 'center',
opacity: previewLoading ? 1 : 0.6, transition: 'opacity 0.2s', padding: '0 4px', color: '#faad14'
}}
onMouseEnter={(e) => { if (!previewLoading) e.currentTarget.style.opacity = '1'; }}
onMouseLeave={(e) => { if (!previewLoading) e.currentTarget.style.opacity = '0.6'; }}
>
{previewLoading ? '⏳' : '👁'}
<span style={{ marginLeft: 4 }}>{previewLoading ? '执行中...' : '预览'}</span>
</span>
</Tooltip>
)}
<CodeCopyBtn text={displayText} />
</div>
</div>
<div style={{ position: 'relative' }}>
<SyntaxHighlighter
style={darkMode ? vscDarkPlus as any : vs as any}
language={match[1]}
PreTag="div"
showLineNumbers={true}
customStyle={{
margin: 0,
borderRadius: 0,
background: darkMode ? 'rgba(0,0,0,0.25)' : 'rgba(0,0,0,0.02)',
maxHeight: expanded ? 'none' : (isLongCode ? MAX_HEIGHT : 'none'),
overflowY: expanded ? 'auto' : 'hidden',
fontSize: '14px',
lineHeight: 1.6
}}
codeTagProps={{
style: {
fontSize: '14px',
fontFamily: 'Menlo, Monaco, Consolas, "Courier New", monospace'
}
}}
>
{displayText}
</SyntaxHighlighter>
{!expanded && isLongCode && (
<div
style={{
position: 'absolute',
bottom: 0, left: 0, right: 0,
height: 60,
background: `linear-gradient(to bottom, transparent, ${darkMode ? 'rgba(0,0,0,0.8)' : 'rgba(255,255,255,0.9)'})`,
display: 'flex', alignItems: 'flex-end', justifyContent: 'center',
paddingBottom: 8, cursor: 'pointer'
}}
onClick={() => setExpanded(true)}
>
<span style={{ fontSize: 12, color: overlayTheme.iconColor, background: darkMode ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.05)', padding: '2px 8px', borderRadius: 12 }}>
</span>
</div>
)}
{expanded && isLongCode && (
<div
style={{
display: 'flex', justifyContent: 'center', padding: '6px 0',
background: darkMode ? 'rgba(0,0,0,0.3)' : 'rgba(0,0,0,0.02)', cursor: 'pointer',
borderTop: `1px solid ${darkMode ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.05)'}`
}}
onClick={() => setExpanded(false)}
>
<span style={{ fontSize: 12, color: overlayTheme.iconColor }}></span>
</div>
)}
</div>
{/* Inline SQL Preview Results */}
{previewError && (
<div style={{ padding: '8px 12px', fontSize: 12, color: '#ef4444', background: darkMode ? 'rgba(239,68,68,0.1)' : 'rgba(239,68,68,0.05)', borderTop: `1px solid ${darkMode ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.05)'}` }}>
{previewError}
</div>
)}
{previewExpanded && previewData && previewData.length > 0 && (
<div style={{ borderTop: `1px solid ${darkMode ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.06)'}` }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '4px 12px', background: darkMode ? 'rgba(250,173,20,0.08)' : 'rgba(250,173,20,0.05)' }}>
<span style={{ fontSize: 11, color: overlayTheme.mutedText }}>📊 {previewData.length} × {previewCols.length} </span>
<span style={{ fontSize: 11, color: overlayTheme.mutedText, cursor: 'pointer' }} onClick={() => setPreviewExpanded(false)}> </span>
</div>
<div style={{ overflowX: 'auto', maxHeight: 200, overflowY: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 11, fontFamily: 'monospace' }}>
<thead>
<tr>
{previewCols.map(col => (
<th key={col} style={{ padding: '4px 8px', textAlign: 'left', background: darkMode ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.04)', color: overlayTheme.titleText, fontWeight: 600, whiteSpace: 'nowrap', borderBottom: `1px solid ${darkMode ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.08)'}` }}>
{col}
</th>
))}
</tr>
</thead>
<tbody>
{previewData.map((row, ri) => (
<tr key={ri}>
{previewCols.map(col => (
<td key={col} style={{ padding: '3px 8px', color: overlayTheme.mutedText, whiteSpace: 'nowrap', borderBottom: `1px solid ${darkMode ? 'rgba(255,255,255,0.04)' : 'rgba(0,0,0,0.03)'}`, maxWidth: 200, overflow: 'hidden', textOverflow: 'ellipsis' }}>
{row[col] === null ? <span style={{ color: '#999', fontStyle: 'italic' }}>NULL</span> : String(row[col])}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{!previewExpanded && previewData && previewData.length > 0 && (
<div
style={{ padding: '4px 12px', cursor: 'pointer', fontSize: 11, color: overlayTheme.mutedText, background: darkMode ? 'rgba(250,173,20,0.05)' : 'rgba(250,173,20,0.03)', borderTop: `1px solid ${darkMode ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.03)'}` }}
onClick={() => setPreviewExpanded(true)}
>
📊 {previewData.length}
</div>
)}
</div>
);
};
// 可折叠思考过程组件
const ThinkingBlock: React.FC<{ displayThinking: string; totalLen: number; isTyping: boolean; isGlobalLoading: boolean; darkMode: boolean; overlayTheme: any; hasContent: boolean }> = ({ displayThinking, totalLen, isTyping, isGlobalLoading, darkMode, overlayTheme, hasContent }) => {
// 如果整体在loading且尚未吐出content我们认为真正的思考还在进行如果吐出content了思考框就算告一段落
const isActivelyThinking = isGlobalLoading && !hasContent;
const [expanded, setExpanded] = useState(isActivelyThinking);
const contentRef = React.useRef<HTMLDivElement>(null);
React.useEffect(() => { if (isActivelyThinking) setExpanded(true); }, [isActivelyThinking]);
// 断开连接或思考结束时,若已有内容且不再产生新内容则默认收起
React.useEffect(() => {
if (!isGlobalLoading) setExpanded(false);
}, [isGlobalLoading]);
// 自动滚动到思考内容底部
React.useEffect(() => {
if (expanded && isTyping && contentRef.current) {
contentRef.current.scrollTop = contentRef.current.scrollHeight;
}
}, [displayThinking, expanded, isTyping]);
return (
<div style={{
marginBottom: hasContent ? 8 : 0,
borderRadius: 6,
border: `1px solid ${darkMode ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.06)'}`,
overflow: 'hidden',
}}>
<div
onClick={() => setExpanded(e => !e)}
style={{
display: 'flex', alignItems: 'center', gap: 6,
padding: '6px 10px', cursor: 'pointer',
background: darkMode ? 'rgba(255,255,255,0.04)' : 'rgba(0,0,0,0.02)',
fontSize: 12, color: overlayTheme.mutedText, userSelect: 'none',
}}
>
<span style={{ transition: 'transform 0.2s', transform: expanded ? 'rotate(90deg)' : 'rotate(0deg)', fontSize: 10 }}></span>
<span>💭 </span>
{isActivelyThinking && <span style={{ fontSize: 10, color: '#8b5cf6', animation: 'pulse 1.5s ease-in-out infinite' }}>...</span>}
{!isActivelyThinking && <span style={{ fontSize: 10, opacity: 0.5 }}>({displayThinking.length} )</span>}
</div>
<div className={`ai-expand-transition ${expanded ? 'expanded' : 'collapsed'}`}>
<div ref={contentRef} style={{
padding: expanded ? '8px 12px' : '0 12px',
borderLeft: '3px solid #8b5cf6',
margin: '0 8px 8px',
fontSize: 12, lineHeight: 1.7,
color: overlayTheme.mutedText,
fontStyle: 'italic',
whiteSpace: 'pre-wrap', wordBreak: 'break-word',
maxHeight: 400, overflowY: 'auto',
}}>
{displayThinking}
{isTyping && <span className="ai-blinking-cursor" style={{ background: '#8b5cf6', marginLeft: 4, width: 6, height: 12, display: 'inline-block', verticalAlign: 'middle', opacity: 0.8 }} />}
</div>
</div>
</div>
);
};
// 工具调用进度面板聚合展示组件
const AIToolCallingBlock: React.FC<{ tool_calls: AIToolCall[]; loading: boolean; allMessages: AIChatMessage[]; darkMode: boolean; overlayTheme: any; hasContent: boolean }> = ({ tool_calls, loading, allMessages, darkMode, overlayTheme, hasContent }) => {
const totalCalls = tool_calls.length;
const allDone = tool_calls.every(tc => allMessages?.find(m => m.role === 'tool' && m.tool_call_id === tc.id));
const [expanded, setExpanded] = useState(!allDone && loading);
// 断开连接或执行完毕时,若已完成则默认收起
React.useEffect(() => {
if (allDone || !loading) setExpanded(false);
}, [allDone, loading]);
// 显示友好的人类可读动作名
const getHumanActionName = (fname: string) => {
if (fname === 'get_connections') return '获取可用连接信息';
if (fname === 'get_databases') return '扫描数据库列表';
if (fname === 'get_tables') return '分析表结构信息';
return fname;
};
return (
<div style={{
background: darkMode ? 'rgba(0,0,0,0.15)' : 'rgba(0,0,0,0.025)',
borderRadius: 8, fontSize: 12, overflow: 'hidden',
border: `1px solid ${darkMode ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.06)'}`,
marginTop: hasContent ? 12 : 0,
display: 'flex', flexDirection: 'column',
}}>
<div
onClick={() => setExpanded(!expanded)}
style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '8px 12px', cursor: 'pointer', userSelect: 'none',
background: darkMode ? 'rgba(255,255,255,0.02)' : 'rgba(0,0,0,0.01)',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, color: overlayTheme.titleText, fontWeight: 500 }}>
{!allDone && loading ? (
<div className="ai-spinning-ring" />
) : (
<CheckOutlined style={{ color: '#10b981' }} />
)}
<span>{!allDone && loading ? '正在执行数据探针...' : `数据探针执行完毕 (${totalCalls} 项)`}</span>
</div>
<span style={{ transition: 'transform 0.2s', transform: expanded ? 'rotate(90deg)' : 'rotate(0deg)', fontSize: 10, color: overlayTheme.mutedText }}></span>
</div>
<div className={`ai-expand-transition ${expanded ? 'expanded' : 'collapsed'}`}>
<div style={{ padding: expanded ? '4px 12px 12px' : '0 12px' }}>
{tool_calls.map((tc, idx) => {
const resultMsg = allMessages?.find(m => m.role === 'tool' && m.tool_call_id === tc.id);
const isDone = !!resultMsg;
const actionName = getHumanActionName(tc.function.name);
return (
<div key={tc.id} style={{
display: 'flex', flexDirection: 'column', gap: 4,
marginTop: 6, paddingLeft: 8,
borderLeft: `2px solid ${isDone ? '#10b981' : (loading ? '#1677ff' : overlayTheme.shellBorder)}`,
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
{isDone
? <CheckOutlined style={{ color: '#10b981', fontSize: 11 }} />
: (loading ? <div className="ai-spinning-ring" style={{ width: 10, height: 10, borderWidth: 1.5 }} /> : <ApiOutlined style={{ color: overlayTheme.mutedText, fontSize: 11 }} />)
}
<span style={{ color: isDone ? overlayTheme.mutedText : overlayTheme.titleText }}>{actionName}</span>
</div>
{resultMsg && <AIToolResultItem resultMsg={resultMsg} darkMode={darkMode} overlayTheme={overlayTheme} />}
</div>
);
})}
</div>
</div>
</div>
);
};
export const AIMessageBubble: React.FC<AIMessageBubbleProps> = React.memo(({ msg, darkMode, overlayTheme, textColor, onEdit, onRetry, onDelete, activeConnectionId, activeConnectionConfig, activeDbName, allMessages }) => {
const [isCopied, setIsCopied] = useState(false);
const isUser = msg.role === 'user';
// 从 content 中提取 <think>...</think> 标签内容(部分模型如 MiniMax、DeepSeek 会以文本形式返回思考过程)
const { displayContent, parsedThinking } = React.useMemo(() => {
const content = msg.content || '';
// 优先使用后端已结构化的 thinking 字段(如 Claude API 原生 thinking
if (msg.thinking) {
return { displayContent: content, parsedThinking: msg.thinking };
}
// 尝试从 content 中提取 <think>...</think> 标签
const thinkRegex = /<think>([\s\S]*?)(?:<\/think>|$)/g;
let thinkParts: string[] = [];
let cleanContent = content;
let match;
while ((match = thinkRegex.exec(content)) !== null) {
thinkParts.push(match[1].trim());
}
if (thinkParts.length > 0) {
// 移除所有 <think>...</think> 标签(含未闭合的)
cleanContent = content.replace(/<think>[\s\S]*?(?:<\/think>|$)/g, '').trim();
return { displayContent: cleanContent, parsedThinking: thinkParts.join('\n\n') };
}
return { displayContent: content, parsedThinking: '' };
}, [msg.content, msg.thinking]);
const isTypingThinking = !!(msg.loading && msg.phase === 'thinking');
if (msg.role === 'tool') return null;
// 如果是纯空壳的加载状态connecting或还在思考/工具阶段但还没吐出一个字的 content
const isWaitState = msg.phase === 'connecting' ||
(msg.loading && !msg.content && (msg.phase === 'thinking' || msg.phase === 'tool_calling'));
if (isWaitState) {
return (
<div className="ai-ide-message" style={{ borderBottom: 'none', padding: '8px 16px' }}>
<div style={{
background: darkMode ? 'rgba(255,255,255,0.04)' : 'rgba(0,0,0,0.02)',
borderRadius: 12, padding: '14px 16px',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, color: overlayTheme.mutedText }}>
<div className="ai-wave-pulse">
<span /> <span /> <span />
</div>
<span style={{ fontSize: 13, opacity: 0.8 }}>{msg.content || '正在建立连接'}...</span>
</div>
{/* 即使在波纹过渡态,如果有 thinking / tool_calls 也要显示出来,只是把它们压在波纹下面 */}
<div style={{ marginTop: parsedThinking || (msg.tool_calls && msg.tool_calls.length > 0) ? 12 : 0 }}>
{!isUser && parsedThinking && (
<ThinkingBlock
displayThinking={parsedThinking}
totalLen={parsedThinking.length}
isTyping={isTypingThinking}
isGlobalLoading={!!msg.loading}
darkMode={darkMode}
overlayTheme={overlayTheme}
hasContent={false}
/>
)}
{!isUser && msg.tool_calls && msg.tool_calls.length > 0 && (
<AIToolCallingBlock
tool_calls={msg.tool_calls}
loading={!!msg.loading}
allMessages={allMessages || []}
darkMode={darkMode}
overlayTheme={overlayTheme}
hasContent={false}
/>
)}
</div>
</div>
</div>
);
}
return (
<div className="ai-ide-message" style={{ borderBottom: 'none', padding: '8px 16px' }}>
<div style={{
background: isUser ? (darkMode ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.04)') : (darkMode ? 'rgba(255,255,255,0.04)' : 'rgba(0,0,0,0.02)'),
borderRadius: 12,
padding: '14px 16px',
}}>
<div className="ai-ide-message-header" style={{
color: isUser ? overlayTheme.mutedText : overlayTheme.titleText,
marginBottom: isUser ? 6 : 10,
display: 'flex', justifyContent: 'space-between', alignItems: 'center'
}}>
<div>
{isUser
? <><UserOutlined /> <span>You</span></>
: <><RobotOutlined style={{ color: overlayTheme.iconColor }} /> <span>GoNavi AI</span></>}
</div>
{/* 气泡操作栏 */}
<div className="ai-message-actions" style={{ display: 'flex', gap: 8, opacity: 0, transition: 'opacity 0.2s', padding: '0 4px' }}>
<Tooltip title={isCopied ? "已复制" : "复制全文"}>
{isCopied ? (
<CheckOutlined className="ai-action-icon" style={{ color: '#10b981' }} />
) : (
<CopyOutlined className="ai-action-icon" onClick={() => {
navigator.clipboard.writeText(msg.content);
setIsCopied(true);
setTimeout(() => setIsCopied(false), 2000);
}} style={{ cursor: 'pointer', color: overlayTheme.mutedText }} onMouseEnter={e => e.currentTarget.style.color = textColor} onMouseLeave={e => e.currentTarget.style.color = overlayTheme.mutedText} />
)}
</Tooltip>
{isUser ? (
<Tooltip title="编辑此条消息(移除其后所有记录并重新发送)">
<EditOutlined className="ai-action-icon" onClick={() => onEdit(msg)} style={{ cursor: 'pointer', color: overlayTheme.mutedText }} onMouseEnter={e => e.currentTarget.style.color = textColor} onMouseLeave={e => e.currentTarget.style.color = overlayTheme.mutedText} />
</Tooltip>
) : (
<Tooltip title="重新生成(移除此条并触发上次用户输入重发)">
<ReloadOutlined className="ai-action-icon" onClick={() => onRetry(msg)} style={{ cursor: 'pointer', color: overlayTheme.mutedText }} onMouseEnter={e => e.currentTarget.style.color = textColor} onMouseLeave={e => e.currentTarget.style.color = overlayTheme.mutedText} />
</Tooltip>
)}
<Tooltip title="删除单条消息">
<DeleteOutlined className="ai-action-icon" onClick={() => onDelete(msg.id)} style={{ cursor: 'pointer', color: overlayTheme.mutedText }} onMouseEnter={e => e.currentTarget.style.color = '#ef4444'} onMouseLeave={e => e.currentTarget.style.color = overlayTheme.mutedText} />
</Tooltip>
</div>
</div>
<div className="ai-ide-message-content ai-markdown-content" style={{ color: textColor }}>
{msg.images && msg.images.length > 0 && (
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', marginBottom: 12 }}>
{msg.images.map((img, i) => (
<img key={i} src={img} alt={`Attached ${i}`} style={{ maxWidth: 200, maxHeight: 200, borderRadius: 8, objectFit: 'contain', border: overlayTheme.shellBorder }} />
))}
</div>
)}
{/* 可折叠思考过程 */}
{!isUser && parsedThinking && (
<ThinkingBlock
displayThinking={parsedThinking}
totalLen={parsedThinking.length}
isTyping={isTypingThinking}
isGlobalLoading={!!msg.loading}
darkMode={darkMode}
overlayTheme={overlayTheme}
hasContent={!!msg.content}
/>
)}
{isUser ? (
<div style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word', fontSize: 13 }}>{msg.content}</div>
) : (
<MemoizedMarkdown
content={displayContent}
darkMode={darkMode}
overlayTheme={overlayTheme}
activeConnectionConfig={activeConnectionConfig}
activeConnectionId={activeConnectionId}
activeDbName={activeDbName}
/>
)}
{/* 错误原文复制按钮 */}
{!isUser && msg.rawError && (
<div style={{ marginTop: 8 }}>
<button
onClick={() => {
navigator.clipboard.writeText(msg.rawError || '');
const btn = document.getElementById(`raw-err-btn-${msg.id}`);
if (btn) { btn.textContent = '✅ 已复制'; setTimeout(() => { btn.textContent = '📋 复制报错原文'; }, 1500); }
}}
id={`raw-err-btn-${msg.id}`}
style={{
fontSize: 12, padding: '3px 10px', borderRadius: 6, cursor: 'pointer',
border: `1px solid ${darkMode ? 'rgba(255,255,255,0.12)' : 'rgba(0,0,0,0.08)'}`,
background: darkMode ? 'rgba(255,255,255,0.04)' : 'rgba(0,0,0,0.02)',
color: overlayTheme.mutedText, transition: 'all 0.15s ease',
}}
>
📋
</button>
</div>
)}
{/* 工具调用进度展示 */}
{!isUser && msg.tool_calls && msg.tool_calls.length > 0 && (
<AIToolCallingBlock
tool_calls={msg.tool_calls}
loading={!!msg.loading}
allMessages={allMessages || []}
darkMode={darkMode}
overlayTheme={overlayTheme}
hasContent={!!msg.content}
/>
)}
{msg.loading && msg.phase !== 'tool_calling' && msg.content && (
<span className="ai-blinking-cursor" style={{ background: overlayTheme.iconColor }} />
)}
</div>
</div>
</div>
);
});

View File

@@ -0,0 +1,32 @@
import { describe, expect, it } from 'vitest';
import { calculateTableBodyBottomPadding, calculateVirtualTableScrollX } from './dataGridLayout';
describe('dataGridLayout helpers', () => {
it('returns zero bottom padding without horizontal overflow', () => {
expect(calculateTableBodyBottomPadding({
hasHorizontalOverflow: false,
floatingScrollbarHeight: 10,
floatingScrollbarGap: 6,
})).toBe(0);
});
it('adds safe area when horizontal overflow exists', () => {
expect(calculateTableBodyBottomPadding({
hasHorizontalOverflow: true,
floatingScrollbarHeight: 10,
floatingScrollbarGap: 6,
})).toBe(28);
expect(calculateTableBodyBottomPadding({
hasHorizontalOverflow: true,
floatingScrollbarHeight: 14,
floatingScrollbarGap: 4,
})).toBe(30);
});
it('keeps scroll width aligned with viewport or content width', () => {
expect(calculateVirtualTableScrollX({ totalWidth: 646, tableViewportWidth: 1200, isMacLike: false })).toBe(1200);
expect(calculateVirtualTableScrollX({ totalWidth: 646, tableViewportWidth: 0, isMacLike: false })).toBe(646);
expect(calculateVirtualTableScrollX({ totalWidth: 1200, tableViewportWidth: 800, isMacLike: true })).toBe(1202);
});
});

View File

@@ -0,0 +1,48 @@
export interface TableBodyBottomPaddingOptions {
hasHorizontalOverflow: boolean;
floatingScrollbarHeight: number;
floatingScrollbarGap: number;
}
export interface VirtualTableScrollXOptions {
totalWidth: number;
tableViewportWidth: number;
isMacLike: boolean;
}
const MIN_SCROLLBAR_CLEARANCE = 8;
const FLOATING_SCROLLBAR_VISUAL_EXTRA = 4;
export const calculateTableBodyBottomPadding = ({
hasHorizontalOverflow,
floatingScrollbarHeight,
floatingScrollbarGap,
}: TableBodyBottomPaddingOptions): number => {
if (!hasHorizontalOverflow) {
return 0;
}
const safeScrollbarHeight = Math.max(0, Math.ceil(floatingScrollbarHeight));
const safeScrollbarGap = Math.max(0, Math.ceil(floatingScrollbarGap));
return safeScrollbarHeight + FLOATING_SCROLLBAR_VISUAL_EXTRA + safeScrollbarGap + MIN_SCROLLBAR_CLEARANCE;
};
export const calculateVirtualTableScrollX = ({
totalWidth,
tableViewportWidth,
isMacLike,
}: VirtualTableScrollXOptions): number => {
const safeTotalWidth = Math.max(0, Math.ceil(totalWidth));
const safeViewportWidth = Math.max(0, Math.floor(tableViewportWidth));
if (safeViewportWidth > 0 && safeTotalWidth < safeViewportWidth) {
return safeViewportWidth;
}
if (isMacLike && safeViewportWidth > 0 && safeTotalWidth > safeViewportWidth) {
return safeTotalWidth + 2;
}
return safeTotalWidth;
};

View File

@@ -0,0 +1,79 @@
import { describe, expect, it } from 'vitest';
import type { RedisKeyInfo } from '../types';
import {
applyRenamedRedisKeyState,
applyTreeNodeCheck,
buildCheckedTreeNodeState,
buildRedisKeyTree,
isGroupFullyChecked,
} from './redisViewerTree';
const sampleKeys: RedisKeyInfo[] = [
{ key: 'app:user:1', type: 'string', ttl: -1 },
{ key: 'app:user:2', type: 'string', ttl: -1 },
{ key: 'app:order:1', type: 'hash', ttl: 120 },
{ key: 'misc', type: 'set', ttl: -1 },
];
describe('redisViewerTree helpers', () => {
it('builds grouped redis key tree and group selection state', () => {
const tree = buildRedisKeyTree(sampleKeys, true);
const appGroup = tree.treeData.find((node) => node.key === 'group:app');
const userGroup = appGroup?.children?.find((node) => node.key === 'group:app:user');
expect(appGroup).toBeTruthy();
expect(userGroup).toBeTruthy();
expect(appGroup?.descendantRawKeys).toEqual(['app:order:1', 'app:user:1', 'app:user:2']);
const selectedAfterGroupCheck = applyTreeNodeCheck([], appGroup!, true);
expect(selectedAfterGroupCheck).toEqual(['app:order:1', 'app:user:1', 'app:user:2']);
const checkedState = buildCheckedTreeNodeState(selectedAfterGroupCheck, tree);
expect(checkedState.checked).toEqual(['key:app:order:1', 'group:app:order', 'key:app:user:1', 'key:app:user:2', 'group:app:user', 'group:app']);
expect(checkedState.halfChecked).toEqual([]);
expect(isGroupFullyChecked(appGroup!, selectedAfterGroupCheck)).toBe(true);
const selectedAfterGroupUncheck = applyTreeNodeCheck(selectedAfterGroupCheck, appGroup!, false);
expect(selectedAfterGroupUncheck).toEqual([]);
expect(isGroupFullyChecked(appGroup!, selectedAfterGroupUncheck)).toBe(false);
});
it('marks parent groups as half checked for partial selection', () => {
const tree = buildRedisKeyTree(sampleKeys, true);
const appGroup = tree.treeData.find((node) => node.key === 'group:app');
const partialState = buildCheckedTreeNodeState(['app:user:1'], tree);
expect(partialState.halfChecked).toEqual(['group:app:user', 'group:app']);
expect(isGroupFullyChecked(appGroup!, ['app:user:1'])).toBe(false);
});
it('updates selected keys consistently after rename', () => {
const renamedState = applyRenamedRedisKeyState(
{
keys: sampleKeys,
selectedKey: 'app:user:2',
selectedKeys: ['app:user:1', 'app:user:2', 'misc'],
},
'app:user:2',
'app:user:200'
);
expect(renamedState.keys.map((item) => item.key)).toEqual(['app:user:1', 'app:user:200', 'app:order:1', 'misc']);
expect(renamedState.selectedKey).toBe('app:user:200');
expect(renamedState.selectedKeys).toEqual(['app:user:1', 'app:user:200', 'misc']);
const unrelatedRenameState = applyRenamedRedisKeyState(
{
keys: sampleKeys,
selectedKey: 'misc',
selectedKeys: ['app:user:1'],
},
'app:order:1',
'app:order:9'
);
expect(unrelatedRenameState.selectedKey).toBe('misc');
expect(unrelatedRenameState.selectedKeys).toEqual(['app:user:1']);
});
});

View File

@@ -0,0 +1,260 @@
import type { DataNode } from 'antd/es/tree';
import type { RedisKeyInfo } from '../types';
const KEY_GROUP_DELIMITER = ':';
const EMPTY_SEGMENT_LABEL = '(empty)';
type RedisKeyTreeLeaf = {
keyInfo: RedisKeyInfo;
label: string;
};
type RedisKeyTreeGroup = {
name: string;
path: string;
children: Map<string, RedisKeyTreeGroup>;
leaves: RedisKeyTreeLeaf[];
leafCount: number;
};
export type RedisTreeDataNode = DataNode & {
nodeType: 'group' | 'leaf';
groupName?: string;
groupLeafCount?: number;
leafLabel?: string;
rawKey?: string;
keyType?: string;
ttl?: number;
descendantRawKeys?: string[];
};
export type RedisKeyTreeResult = {
treeData: RedisTreeDataNode[];
groupKeys: string[];
};
export type RedisTreeCheckedState = {
checked: string[];
halfChecked: string[];
};
export type RenamedRedisKeyStateInput = {
keys: RedisKeyInfo[];
selectedKey: string | null;
selectedKeys: string[];
};
export type RenamedRedisKeyStateResult = {
keys: RedisKeyInfo[];
selectedKey: string | null;
selectedKeys: string[];
};
const normalizeKeySegment = (segment: string): string => {
return segment === '' ? EMPTY_SEGMENT_LABEL : segment;
};
const createTreeGroup = (name: string, path: string): RedisKeyTreeGroup => {
return { name, path, children: new Map(), leaves: [], leafCount: 0 };
};
const calculateGroupLeafCount = (group: RedisKeyTreeGroup): number => {
let count = group.leaves.length;
group.children.forEach((child) => {
count += calculateGroupLeafCount(child);
});
group.leafCount = count;
return count;
};
export const buildLeafNodeKey = (rawKey: string): string => `key:${rawKey}`;
export const parseRawKeyFromNodeKey = (nodeKey: React.Key): string | null => {
const keyText = String(nodeKey);
if (!keyText.startsWith('key:')) {
return null;
}
return keyText.slice(4);
};
export const buildRedisKeyTree = (
keys: RedisKeyInfo[],
sortLeafNodes: boolean
): RedisKeyTreeResult => {
const root = createTreeGroup('__root__', '__root__');
keys.forEach((keyInfo) => {
const segments = keyInfo.key.split(KEY_GROUP_DELIMITER);
if (segments.length <= 1) {
root.leaves.push({ keyInfo, label: keyInfo.key });
return;
}
const groupSegments = segments.slice(0, -1);
const leafLabel = normalizeKeySegment(segments[segments.length - 1]);
let current = root;
const pathParts: string[] = [];
groupSegments.forEach((segment) => {
const normalized = normalizeKeySegment(segment);
pathParts.push(normalized);
const groupPath = pathParts.join(KEY_GROUP_DELIMITER);
let child = current.children.get(normalized);
if (!child) {
child = createTreeGroup(normalized, groupPath);
current.children.set(normalized, child);
}
current = child;
});
current.leaves.push({ keyInfo, label: leafLabel });
});
calculateGroupLeafCount(root);
const groupKeys: string[] = [];
const toTreeNodes = (group: RedisKeyTreeGroup): RedisTreeDataNode[] => {
const childGroups = Array.from(group.children.values()).sort((a, b) => a.name.localeCompare(b.name));
const childLeaves = sortLeafNodes
? [...group.leaves].sort((a, b) => a.keyInfo.key.localeCompare(b.keyInfo.key))
: group.leaves;
const groupNodes: RedisTreeDataNode[] = childGroups.map((child) => {
const children = toTreeNodes(child);
const descendantRawKeys = children.flatMap((node) => {
if (node.nodeType === 'leaf') {
return node.rawKey ? [node.rawKey] : [];
}
return node.descendantRawKeys || [];
});
const groupNodeKey = `group:${child.path}`;
groupKeys.push(groupNodeKey);
return {
key: groupNodeKey,
title: child.name,
nodeType: 'group',
groupName: child.name,
groupLeafCount: child.leafCount,
selectable: false,
descendantRawKeys,
children,
};
});
const leafNodes: RedisTreeDataNode[] = childLeaves.map((leaf) => {
return {
key: buildLeafNodeKey(leaf.keyInfo.key),
isLeaf: true,
title: leaf.label,
nodeType: 'leaf',
leafLabel: leaf.label,
rawKey: leaf.keyInfo.key,
keyType: leaf.keyInfo.type,
ttl: leaf.keyInfo.ttl,
};
});
return [...groupNodes, ...leafNodes];
};
return {
treeData: toTreeNodes(root),
groupKeys,
};
};
export const applyTreeNodeCheck = (
selectedKeys: string[],
node: RedisTreeDataNode,
checked: boolean
): string[] => {
if (node.nodeType === 'leaf') {
if (!node.rawKey) {
return selectedKeys;
}
if (checked) {
return Array.from(new Set([...selectedKeys, node.rawKey]));
}
return selectedKeys.filter((item) => item !== node.rawKey);
}
const descendantRawKeys = node.descendantRawKeys || [];
if (descendantRawKeys.length === 0) {
return selectedKeys;
}
if (checked) {
return Array.from(new Set([...selectedKeys, ...descendantRawKeys]));
}
const removeSet = new Set(descendantRawKeys);
return selectedKeys.filter((item) => !removeSet.has(item));
};
const walkGroupStates = (
nodes: RedisTreeDataNode[],
selectedKeySet: Set<string>,
checked: string[],
halfChecked: string[]
) => {
nodes.forEach((node) => {
if (node.nodeType === 'leaf') {
if (node.rawKey && selectedKeySet.has(node.rawKey)) {
checked.push(String(node.key));
}
return;
}
walkGroupStates((node.children || []) as RedisTreeDataNode[], selectedKeySet, checked, halfChecked);
const descendantRawKeys = node.descendantRawKeys || [];
if (descendantRawKeys.length === 0) {
return;
}
const selectedCount = descendantRawKeys.filter((rawKey) => selectedKeySet.has(rawKey)).length;
if (selectedCount === descendantRawKeys.length) {
checked.push(String(node.key));
return;
}
if (selectedCount > 0) {
halfChecked.push(String(node.key));
}
});
};
export const buildCheckedTreeNodeState = (
selectedKeys: string[],
keyTree: RedisKeyTreeResult
): RedisTreeCheckedState => {
const selectedKeySet = new Set(selectedKeys);
const checked: string[] = [];
const halfChecked: string[] = [];
walkGroupStates(keyTree.treeData, selectedKeySet, checked, halfChecked);
return { checked, halfChecked };
};
export const isGroupFullyChecked = (
node: RedisTreeDataNode,
selectedKeys: string[]
): boolean => {
if (node.nodeType !== 'group') {
return false;
}
const descendantRawKeys = node.descendantRawKeys || [];
if (descendantRawKeys.length === 0) {
return false;
}
const selectedKeySet = new Set(selectedKeys);
return descendantRawKeys.every((rawKey) => selectedKeySet.has(rawKey));
};
export const applyRenamedRedisKeyState = (
state: RenamedRedisKeyStateInput,
oldKey: string,
newKey: string
): RenamedRedisKeyStateResult => {
return {
keys: state.keys.map((item) => (item.key === oldKey ? { ...item, key: newKey } : item)),
selectedKey: state.selectedKey === oldKey ? newKey : state.selectedKey,
selectedKeys: state.selectedKeys.map((item) => (item === oldKey ? newKey : item)),
};
};

View File

@@ -0,0 +1,28 @@
import { describe, expect, it } from 'vitest';
import { buildRedisWorkbenchTheme } from './redisViewerWorkbenchTheme';
describe('buildRedisWorkbenchTheme', () => {
it('builds dark redis workbench theme', () => {
const darkTheme = buildRedisWorkbenchTheme({ darkMode: true, opacity: 0.72, blur: 14 });
expect(darkTheme.isDark).toBe(true);
expect(darkTheme.panelBg).toMatch(/^rgba\(/);
expect(darkTheme.toolbarPrimaryBg).toMatch(/^linear-gradient\(/);
expect(darkTheme.actionDangerBg).not.toBe(darkTheme.actionSecondaryBg);
expect(darkTheme.treeSelectedBg).not.toBe(darkTheme.treeHoverBg);
expect(darkTheme.appBg).toMatch(/rgba\(15, 15, 17,/);
expect(darkTheme.panelBg).toMatch(/rgba\(24, 24, 28,/);
expect(darkTheme.panelBgStrong).toMatch(/rgba\(31, 31, 36,/);
expect(darkTheme.backdropFilter).toBe('blur(14px)');
});
it('builds light redis workbench theme', () => {
const lightTheme = buildRedisWorkbenchTheme({ darkMode: false, opacity: 1, blur: 0 });
expect(lightTheme.isDark).toBe(false);
expect(lightTheme.panelBg).toMatch(/^rgba\(/);
expect(lightTheme.contentEmptyBg).toMatch(/^linear-gradient\(/);
expect(lightTheme.textPrimary).not.toBe(lightTheme.textSecondary);
expect(lightTheme.statusTagBg).not.toBe(lightTheme.statusTagMutedBg);
expect(lightTheme.backdropFilter).toBe('none');
});
});

View File

@@ -0,0 +1,129 @@
type RedisWorkbenchThemeInput = {
darkMode: boolean;
opacity: number;
blur: number;
};
type RedisWorkbenchTheme = {
isDark: boolean;
appBg: string;
panelBg: string;
panelBgStrong: string;
panelBgSubtle: string;
panelBorder: string;
panelInset: string;
toolbarPrimaryBg: string;
contentEmptyBg: string;
textPrimary: string;
textSecondary: string;
textMuted: string;
accent: string;
accentSoft: string;
accentBorder: string;
actionSecondaryBg: string;
actionSecondaryBorder: string;
actionDangerBg: string;
actionDangerBorder: string;
actionDangerText: string;
statusTagBg: string;
statusTagBorder: string;
statusTagMutedBg: string;
statusTagMutedBorder: string;
treeHoverBg: string;
treeSelectedBg: string;
treeSelectedBorder: string;
divider: string;
shadow: string;
backdropFilter: string;
};
const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value));
export const buildRedisWorkbenchTheme = ({
darkMode,
opacity,
blur,
}: RedisWorkbenchThemeInput): RedisWorkbenchTheme => {
const normalizedOpacity = clamp(opacity, 0.1, 1);
const normalizedBlur = Math.max(0, Math.round(blur));
const isTranslucent = normalizedOpacity < 0.999 || normalizedBlur > 0;
if (darkMode) {
const appTopAlpha = isTranslucent ? Math.max(0.08, Math.min(0.22, normalizedOpacity * 0.16)) : 0.92;
const appBottomAlpha = isTranslucent ? Math.max(0.12, Math.min(0.28, normalizedOpacity * 0.22)) : 0.96;
const panelAlpha = isTranslucent ? Math.max(0.06, Math.min(0.16, normalizedOpacity * 0.1)) : 0.34;
const strongAlpha = isTranslucent ? Math.max(0.1, Math.min(0.22, normalizedOpacity * 0.16)) : 0.42;
const subtleAlpha = isTranslucent ? Math.max(0.03, Math.min(0.08, normalizedOpacity * 0.05)) : 0.08;
return {
isDark: true,
appBg: `linear-gradient(180deg, rgba(15, 15, 17, ${appTopAlpha}) 0%, rgba(11, 11, 13, ${appBottomAlpha}) 100%)`,
panelBg: `rgba(24, 24, 28, ${panelAlpha})`,
panelBgStrong: `rgba(31, 31, 36, ${strongAlpha})`,
panelBgSubtle: `rgba(255, 255, 255, ${subtleAlpha})`,
panelBorder: `1px solid rgba(255, 255, 255, ${isTranslucent ? Math.max(0.12, Math.min(0.24, normalizedOpacity * 0.2)) : 0.08})`,
panelInset: `inset 0 1px 0 rgba(255,255,255,${isTranslucent ? Math.max(0.05, Math.min(0.12, normalizedOpacity * 0.1)) : 0.04})`,
toolbarPrimaryBg: `linear-gradient(135deg, rgba(246,196,83,0.22) 0%, rgba(246,196,83,0.12) 100%)`,
contentEmptyBg: `linear-gradient(180deg, rgba(255,255,255,0.03) 0%, rgba(255,255,255,0.015) 100%)`,
textPrimary: 'rgba(245, 247, 251, 0.96)',
textSecondary: 'rgba(218, 224, 235, 0.82)',
textMuted: 'rgba(168, 177, 194, 0.72)',
accent: '#f6c453',
accentSoft: 'rgba(246, 196, 83, 0.18)',
accentBorder: 'rgba(246, 196, 83, 0.3)',
actionSecondaryBg: 'rgba(255, 255, 255, 0.04)',
actionSecondaryBorder: 'rgba(255, 255, 255, 0.09)',
actionDangerBg: 'rgba(255, 95, 95, 0.12)',
actionDangerBorder: 'rgba(255, 95, 95, 0.28)',
actionDangerText: '#ff8f8f',
statusTagBg: 'rgba(25, 106, 255, 0.16)',
statusTagBorder: 'rgba(25, 106, 255, 0.28)',
statusTagMutedBg: 'rgba(255, 255, 255, 0.04)',
statusTagMutedBorder: 'rgba(255, 255, 255, 0.08)',
treeHoverBg: 'rgba(255, 255, 255, 0.045)',
treeSelectedBg: 'linear-gradient(90deg, rgba(246,196,83,0.2) 0%, rgba(246,196,83,0.08) 100%)',
treeSelectedBorder: 'rgba(246, 196, 83, 0.24)',
divider: 'rgba(255, 255, 255, 0.07)',
shadow: '0 20px 48px rgba(0, 0, 0, 0.26)',
backdropFilter: normalizedBlur > 0 ? `blur(${normalizedBlur}px)` : 'none',
};
}
const appTopAlpha = isTranslucent ? Math.max(0.16, Math.min(0.36, normalizedOpacity * 0.24)) : 0.98;
const appBottomAlpha = isTranslucent ? Math.max(0.22, Math.min(0.44, normalizedOpacity * 0.32)) : 0.96;
const panelAlpha = isTranslucent ? Math.max(0.18, Math.min(0.4, normalizedOpacity * 0.26)) : 0.94;
const strongAlpha = isTranslucent ? Math.max(0.26, Math.min(0.52, normalizedOpacity * 0.34)) : 0.98;
return {
isDark: false,
appBg: `linear-gradient(180deg, rgba(248, 250, 252, ${appTopAlpha}) 0%, rgba(242, 245, 248, ${appBottomAlpha}) 100%)`,
panelBg: `rgba(255, 255, 255, ${panelAlpha})`,
panelBgStrong: `rgba(255, 255, 255, ${strongAlpha})`,
panelBgSubtle: 'rgba(15, 23, 42, 0.03)',
panelBorder: `1px solid rgba(15, 23, 42, ${isTranslucent ? Math.max(0.1, Math.min(0.18, normalizedOpacity * 0.12)) : 0.08})`,
panelInset: `inset 0 1px 0 rgba(255,255,255,${isTranslucent ? 0.38 : 0.72})`,
toolbarPrimaryBg: 'linear-gradient(135deg, rgba(22,119,255,0.12) 0%, rgba(22,119,255,0.06) 100%)',
contentEmptyBg: 'linear-gradient(180deg, rgba(15,23,42,0.02) 0%, rgba(15,23,42,0.01) 100%)',
textPrimary: 'rgba(15, 23, 42, 0.92)',
textSecondary: 'rgba(51, 65, 85, 0.82)',
textMuted: 'rgba(100, 116, 139, 0.76)',
accent: '#1677ff',
accentSoft: 'rgba(22, 119, 255, 0.12)',
accentBorder: 'rgba(22, 119, 255, 0.22)',
actionSecondaryBg: 'rgba(255, 255, 255, 0.72)',
actionSecondaryBorder: 'rgba(15, 23, 42, 0.08)',
actionDangerBg: 'rgba(255, 77, 79, 0.08)',
actionDangerBorder: 'rgba(255, 77, 79, 0.24)',
actionDangerText: '#cf1322',
statusTagBg: 'rgba(22, 119, 255, 0.1)',
statusTagBorder: 'rgba(22, 119, 255, 0.16)',
statusTagMutedBg: 'rgba(15, 23, 42, 0.04)',
statusTagMutedBorder: 'rgba(15, 23, 42, 0.08)',
treeHoverBg: 'rgba(15, 23, 42, 0.035)',
treeSelectedBg: 'linear-gradient(90deg, rgba(22,119,255,0.12) 0%, rgba(22,119,255,0.05) 100%)',
treeSelectedBorder: 'rgba(22, 119, 255, 0.18)',
divider: 'rgba(15, 23, 42, 0.08)',
shadow: '0 22px 52px rgba(15, 23, 42, 0.08)',
backdropFilter: normalizedBlur > 0 ? `blur(${normalizedBlur}px)` : 'none',
};
};
export type { RedisWorkbenchTheme, RedisWorkbenchThemeInput };

View File

@@ -0,0 +1,95 @@
import { describe, expect, it } from 'vitest';
import {
hasIndexFormChanged,
normalizeIndexFormFromRow,
shouldRestoreOriginalIndex,
toggleIndexSelection,
type IndexDisplaySnapshot,
type IndexFormSnapshot,
} from './tableDesignerIndexUtils';
describe('tableDesignerIndexUtils', () => {
it('normalizes index rows for edit form reuse', () => {
const row: IndexDisplaySnapshot = {
key: 'idx_user_name',
name: 'idx_user_name',
indexType: 'btree',
nonUnique: 0,
columnNames: ['name'],
};
expect(normalizeIndexFormFromRow(row, ['NORMAL', 'UNIQUE', 'PRIMARY', 'FULLTEXT', 'SPATIAL'])).toEqual({
name: 'idx_user_name',
columnNames: ['name'],
kind: 'UNIQUE',
indexType: 'BTREE',
});
});
it('detects no-op index edits as unchanged', () => {
const previousForm: IndexFormSnapshot = {
name: 'idx_user_name',
columnNames: ['name'],
kind: 'UNIQUE',
indexType: 'BTREE',
};
const nextForm: IndexFormSnapshot = {
name: 'idx_user_name',
columnNames: ['name'],
kind: 'UNIQUE',
indexType: 'BTREE',
};
expect(hasIndexFormChanged(previousForm, nextForm)).toBe(false);
});
it('marks edits as changed when index columns differ', () => {
const previousForm: IndexFormSnapshot = {
name: 'idx_user_name',
columnNames: ['name'],
kind: 'NORMAL',
indexType: 'DEFAULT',
};
const nextForm: IndexFormSnapshot = {
name: 'idx_user_name',
columnNames: ['name', 'email'],
kind: 'NORMAL',
indexType: 'DEFAULT',
};
expect(hasIndexFormChanged(previousForm, nextForm)).toBe(true);
});
it('toggles selected index keys without duplicates', () => {
expect(toggleIndexSelection([], 'idx_user_name', true)).toEqual(['idx_user_name']);
expect(toggleIndexSelection(['idx_user_name'], 'idx_user_name', true)).toEqual(['idx_user_name']);
expect(toggleIndexSelection(['idx_user_name'], 'idx_user_name')).toEqual([]);
});
it('keeps single-selection toggles stable across repeated clicks', () => {
let selected = toggleIndexSelection([], 'idx_user_name');
expect(selected).toEqual(['idx_user_name']);
selected = toggleIndexSelection(selected, 'idx_user_name');
expect(selected).toEqual([]);
selected = toggleIndexSelection(selected, 'idx_user_name');
expect(selected).toEqual(['idx_user_name']);
selected = toggleIndexSelection(selected, 'idx_user_email');
expect(selected).toEqual(['idx_user_name', 'idx_user_email']);
selected = toggleIndexSelection(selected, 'idx_user_email');
expect(selected).toEqual(['idx_user_name']);
selected = toggleIndexSelection(selected, 'idx_user_name');
expect(selected).toEqual([]);
});
it('only restores original index when create step fails after drop step', () => {
expect(shouldRestoreOriginalIndex({ failedStatementIndex: 1 })).toBe(true);
expect(shouldRestoreOriginalIndex({ failedStatementIndex: 0 })).toBe(false);
expect(shouldRestoreOriginalIndex({})).toBe(false);
});
});

View File

@@ -0,0 +1,78 @@
export type IndexKind = 'NORMAL' | 'UNIQUE' | 'PRIMARY' | 'FULLTEXT' | 'SPATIAL';
export interface IndexDisplaySnapshot {
key: string;
name: string;
indexType: string;
nonUnique: number;
columnNames: string[];
}
export interface IndexFormSnapshot {
name: string;
columnNames: string[];
kind: IndexKind;
indexType: string;
}
export interface SchemaExecutionSnapshot {
failedStatementIndex?: number;
}
export const normalizeIndexFormFromRow = (
row: IndexDisplaySnapshot,
supportedKinds: IndexKind[],
): IndexFormSnapshot => {
const selectedName = String(row.name || '').trim();
const selectedNameUpper = selectedName.toUpperCase();
const selectedTypeUpper = String(row.indexType || '').trim().toUpperCase();
let kind: IndexKind = 'NORMAL';
if (selectedNameUpper === 'PRIMARY') {
kind = 'PRIMARY';
} else if (selectedTypeUpper === 'FULLTEXT') {
kind = 'FULLTEXT';
} else if (selectedTypeUpper === 'SPATIAL') {
kind = 'SPATIAL';
} else if (row.nonUnique === 0) {
kind = 'UNIQUE';
}
if (!supportedKinds.includes(kind)) {
kind = row.nonUnique === 0 ? 'UNIQUE' : 'NORMAL';
}
return {
name: kind === 'PRIMARY' ? 'PRIMARY' : selectedName,
columnNames: [...row.columnNames],
kind,
indexType: kind === 'NORMAL' || kind === 'UNIQUE'
? (selectedTypeUpper || 'DEFAULT')
: 'DEFAULT',
};
};
export const hasIndexFormChanged = (
previousForm: IndexFormSnapshot,
nextForm: IndexFormSnapshot,
): boolean => {
if (previousForm.name !== nextForm.name) return true;
if (previousForm.kind !== nextForm.kind) return true;
if (previousForm.indexType !== nextForm.indexType) return true;
if (previousForm.columnNames.length !== nextForm.columnNames.length) return true;
return previousForm.columnNames.some((col, idx) => col !== nextForm.columnNames[idx]);
};
export const toggleIndexSelection = (
selectedKeys: string[],
key: string,
checked?: boolean,
): string[] => {
const exists = selectedKeys.includes(key);
const nextChecked = checked ?? !exists;
if (nextChecked) {
return exists ? selectedKeys : [...selectedKeys, key];
}
return selectedKeys.filter((item) => item !== key);
};
export const shouldRestoreOriginalIndex = (result: SchemaExecutionSnapshot): boolean => (
(result.failedStatementIndex ?? -1) > 0
);

View File

@@ -5,6 +5,8 @@ import App from './App'
// 全局配置 Monaco Editor 使用本地打包的文件,避免从 CDN (jsdelivr) 加载。
// Windows WebView2 环境下访问外部 CDN 可能失败,导致编辑器一直显示 Loading。
// 中文语言包必须在 monaco-editor 主包之前导入,否则右键菜单等 UI 仍为英文。
import 'monaco-editor/esm/nls.messages.zh-cn'
import { loader } from '@monaco-editor/react'
import * as monaco from 'monaco-editor'
loader.config({ monaco })
@@ -42,11 +44,11 @@ if (typeof window !== 'undefined' && !(window as any).go) {
// 全局注册透明主题,避免每个 Editor 组件 beforeMount 中重复定义
monaco.editor.defineTheme('transparent-dark', {
base: 'vs-dark', inherit: true, rules: [],
colors: { 'editor.background': '#00000000', 'editor.lineHighlightBackground': '#ffffff10', 'editorGutter.background': '#00000000' }
colors: { 'editor.background': '#00000000', 'editor.lineHighlightBackground': '#ffffff10', 'editorGutter.background': '#00000000', 'editorStickyScroll.background': '#1e1e1e', 'editorStickyScrollHover.background': '#2a2a2a' }
})
monaco.editor.defineTheme('transparent-light', {
base: 'vs', inherit: true, rules: [],
colors: { 'editor.background': '#00000000', 'editor.lineHighlightBackground': '#00000010', 'editorGutter.background': '#00000000' }
colors: { 'editor.background': '#00000000', 'editor.lineHighlightBackground': '#00000010', 'editorGutter.background': '#00000000', 'editorStickyScroll.background': '#ffffff', 'editorStickyScrollHover.background': '#f5f5f5' }
})
ReactDOM.createRoot(document.getElementById('root')!).render(

View File

@@ -1,6 +1,6 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import { ConnectionConfig, ProxyConfig, SavedConnection, TabData, SavedQuery, ConnectionTag } from './types';
import { ConnectionConfig, ProxyConfig, SavedConnection, TabData, SavedQuery, ConnectionTag, AIChatMessage, AIContextItem } from './types';
import {
ShortcutAction,
ShortcutBinding,
@@ -10,7 +10,7 @@ import {
sanitizeShortcutOptions,
} from './utils/shortcuts';
const DEFAULT_APPEARANCE = { enabled: true, opacity: 1.0, blur: 0 };
const DEFAULT_APPEARANCE = { enabled: true, opacity: 1.0, blur: 0, useNativeMacWindowControls: false };
const DEFAULT_UI_SCALE = 1.0;
const MIN_UI_SCALE = 0.8;
const MAX_UI_SCALE = 1.25;
@@ -25,7 +25,7 @@ const MAX_HOST_ENTRY_LENGTH = 512;
const MAX_HOST_ENTRIES = 64;
const DEFAULT_TIMEOUT_SECONDS = 30;
const MAX_TIMEOUT_SECONDS = 3600;
const PERSIST_VERSION = 6;
const PERSIST_VERSION = 7;
const DEFAULT_CONNECTION_TYPE = 'mysql';
const DEFAULT_GLOBAL_PROXY: GlobalProxyConfig = {
enabled: false,
@@ -405,7 +405,7 @@ interface AppState {
activeContext: { connectionId: string; dbName: string } | null;
savedQueries: SavedQuery[];
theme: 'light' | 'dark';
appearance: { enabled: boolean; opacity: number; blur: number };
appearance: { enabled: boolean; opacity: number; blur: number; useNativeMacWindowControls: boolean };
uiScale: number;
fontSize: number;
startupFullscreen: boolean;
@@ -420,6 +420,22 @@ interface AppState {
enableColumnOrderMemory: boolean;
tableHiddenColumns: Record<string, string[]>;
enableHiddenColumnMemory: boolean;
windowBounds: { width: number; height: number; x: number; y: number } | null;
windowState: 'normal' | 'fullscreen' | 'maximized';
sidebarWidth: number;
// AI 运行时与持久化状态
aiPanelVisible: boolean;
aiChatHistory: Record<string, AIChatMessage[]>; // sessionId -> messages
replaceAIChatHistory: (sessionId: string, messages: AIChatMessage[]) => void;
aiChatSessions: { id: string; title: string; updatedAt: number }[]; // 历史会话列表
aiActiveSessionId: string | null;
updateAISessionTitle: (sessionId: string, title: string) => void;
aiContexts: Record<string, AIContextItem[]>;
addAIContext: (connectionKey: string, context: AIContextItem) => void;
removeAIContext: (connectionKey: string, dbName: string, tableName: string) => void;
clearAIContexts: (connectionKey: string) => void;
addConnection: (conn: SavedConnection) => void;
updateConnection: (conn: SavedConnection) => void;
@@ -447,7 +463,7 @@ interface AppState {
deleteQuery: (id: string) => void;
setTheme: (theme: 'light' | 'dark') => void;
setAppearance: (appearance: Partial<{ enabled: boolean; opacity: number; blur: number }>) => void;
setAppearance: (appearance: Partial<{ enabled: boolean; opacity: number; blur: number; useNativeMacWindowControls: boolean }>) => void;
setUiScale: (scale: number) => void;
setFontSize: (size: number) => void;
setStartupFullscreen: (enabled: boolean) => void;
@@ -469,6 +485,21 @@ interface AppState {
setTableHiddenColumns: (connectionId: string, dbName: string, tableName: string, hiddenColumns: string[]) => void;
setEnableHiddenColumnMemory: (enabled: boolean) => void;
clearTableHiddenColumns: (connectionId: string, dbName: string, tableName: string) => void;
setWindowBounds: (bounds: { width: number; height: number; x: number; y: number }) => void;
setWindowState: (state: 'normal' | 'fullscreen' | 'maximized') => void;
setSidebarWidth: (width: number) => void;
// AI actions
toggleAIPanel: () => void;
setAIPanelVisible: (visible: boolean) => void;
addAIChatMessage: (sessionId: string, message: AIChatMessage) => void;
updateAIChatMessage: (sessionId: string, messageId: string, updates: Partial<AIChatMessage>) => void;
deleteAIChatMessage: (sessionId: string, messageId: string) => void;
truncateAIChatMessages: (sessionId: string, upToMessageId: string) => void;
clearAIChatHistory: (sessionId: string) => void;
deleteAISession: (sessionId: string) => void;
createNewAISession: () => void;
setAIActiveSessionId: (sessionId: string | null) => void;
}
const sanitizeSavedQueries = (value: unknown): SavedQuery[] => {
@@ -555,9 +586,9 @@ const sanitizeTableHiddenColumns = (value: unknown): Record<string, string[]> =>
};
const sanitizeAppearance = (
appearance: Partial<{ enabled: boolean; opacity: number; blur: number }> | undefined,
appearance: Partial<{ enabled: boolean; opacity: number; blur: number; useNativeMacWindowControls: boolean }> | undefined,
version: number
): { enabled: boolean; opacity: number; blur: number } => {
): { enabled: boolean; opacity: number; blur: number; useNativeMacWindowControls: boolean } => {
if (!appearance || typeof appearance !== 'object') {
return { ...DEFAULT_APPEARANCE };
}
@@ -565,6 +596,9 @@ const sanitizeAppearance = (
enabled: typeof appearance.enabled === 'boolean' ? appearance.enabled : DEFAULT_APPEARANCE.enabled,
opacity: typeof appearance.opacity === 'number' ? appearance.opacity : DEFAULT_APPEARANCE.opacity,
blur: typeof appearance.blur === 'number' ? appearance.blur : DEFAULT_APPEARANCE.blur,
useNativeMacWindowControls: typeof appearance.useNativeMacWindowControls === 'boolean'
? appearance.useNativeMacWindowControls
: DEFAULT_APPEARANCE.useNativeMacWindowControls,
};
if (version < 2 && isLegacyDefaultAppearance(appearance)) {
return { ...DEFAULT_APPEARANCE };
@@ -599,6 +633,29 @@ const sanitizeGlobalProxy = (value: unknown): GlobalProxyConfig => {
};
};
const sanitizeWindowState = (value: unknown): 'normal' | 'fullscreen' | 'maximized' => {
if (value === 'fullscreen' || value === 'maximized') return value;
return 'normal';
};
const sanitizeSidebarWidth = (value: unknown): number => {
const parsed = Number(value);
if (!Number.isFinite(parsed)) return 330;
return Math.max(200, Math.min(600, Math.trunc(parsed)));
};
const sanitizeWindowBounds = (value: unknown): { width: number; height: number; x: number; y: number } | null => {
if (!value || typeof value !== 'object') return null;
const raw = value as Record<string, unknown>;
const width = Number(raw.width);
const height = Number(raw.height);
const x = Number(raw.x);
const y = Number(raw.y);
if (!Number.isFinite(width) || !Number.isFinite(height) || !Number.isFinite(x) || !Number.isFinite(y)) return null;
if (width < 400 || height < 300) return null;
return { width: Math.trunc(width), height: Math.trunc(height), x: Math.trunc(x), y: Math.trunc(y) };
};
const unwrapPersistedAppState = (persistedState: unknown): Record<string, unknown> => {
if (!persistedState || typeof persistedState !== 'object') {
return {};
@@ -610,6 +667,74 @@ const unwrapPersistedAppState = (persistedState: unknown): Record<string, unknow
return raw;
};
// --- AI 会话文件持久化辅助函数 ---
/** 每个 session 独立防抖定时器2秒 */
const _persistTimers: Record<string, ReturnType<typeof setTimeout>> = {};
function _debouncedPersistSession(sessionId: string) {
if (_persistTimers[sessionId]) clearTimeout(_persistTimers[sessionId]);
_persistTimers[sessionId] = setTimeout(() => {
delete _persistTimers[sessionId];
const state = useStore.getState();
const messages = state.aiChatHistory[sessionId];
const sessionMeta = state.aiChatSessions.find(s => s.id === sessionId);
if (!messages && !sessionMeta) return; // session 已被删除,跳过
const title = sessionMeta?.title || '新的对话';
const updatedAt = sessionMeta?.updatedAt || Date.now();
const messagesJSON = JSON.stringify(messages || []);
const Service = (window as any).go?.aiservice?.Service;
Service?.AISaveSession?.(sessionId, title, updatedAt, messagesJSON).catch((e: any) => {
console.error('[AI Session Persist] 持久化失败:', sessionId, e);
});
}, 2000);
}
/** 从后端加载会话列表(仅元数据,不含消息体) */
export async function loadAISessionsFromBackend(): Promise<{ id: string; title: string; updatedAt: number }[]> {
const Service = (window as any).go?.aiservice?.Service;
if (!Service?.AIGetSessions) return [];
try {
const sessions = await Service.AIGetSessions();
if (Array.isArray(sessions)) {
useStore.setState({ aiChatSessions: sessions });
return sessions;
}
} catch (e) {
console.error('[AI Session] 加载会话列表失败:', e);
}
return [];
}
/** 从后端加载指定会话的消息数据到内存 */
export async function loadAISessionFromBackend(sessionId: string): Promise<boolean> {
const state = useStore.getState();
// 如果内存中已有消息,跳过重复加载
if (state.aiChatHistory[sessionId]?.length > 0) return true;
const Service = (window as any).go?.aiservice?.Service;
if (!Service?.AILoadSession) return false;
try {
const result = await Service.AILoadSession(sessionId);
if (result?.success) {
let messages = result.messages;
// messages 可能是 JSON string 或已解析的数组
if (typeof messages === 'string') {
try { messages = JSON.parse(messages); } catch { messages = []; }
}
if (Array.isArray(messages)) {
useStore.setState((prev) => ({
aiChatHistory: { ...prev.aiChatHistory, [sessionId]: messages },
}));
return true;
}
}
} catch (e) {
console.error('[AI Session] 加载会话消息失败:', sessionId, e);
}
return false;
}
export const useStore = create<AppState>()(
persist(
(set) => ({
@@ -635,6 +760,16 @@ export const useStore = create<AppState>()(
enableColumnOrderMemory: true,
tableHiddenColumns: {},
enableHiddenColumnMemory: true,
windowBounds: null,
windowState: 'normal' as const,
sidebarWidth: 330,
// AI 运行状态
aiPanelVisible: false,
aiChatHistory: {},
aiChatSessions: [],
aiActiveSessionId: null,
aiContexts: {},
addConnection: (conn) => set((state) => ({ connections: [...state.connections, conn] })),
updateConnection: (conn) => set((state) => ({
@@ -689,6 +824,33 @@ export const useStore = create<AppState>()(
newTabs[index] = { ...newTabs[index], ...tab };
return { tabs: newTabs, activeTabId: tab.id };
}
// 语义去重:对 table/design 类型按 connectionId+dbName+tableName 匹配已有 Tab
if ((tab.type === 'table' || tab.type === 'design') && tab.tableName && tab.connectionId && tab.dbName) {
const semanticIndex = state.tabs.findIndex(t =>
t.type === tab.type &&
t.connectionId === tab.connectionId &&
t.dbName === tab.dbName &&
t.tableName === tab.tableName
);
if (semanticIndex !== -1) {
const existingTab = state.tabs[semanticIndex];
const newTabs = [...state.tabs];
newTabs[semanticIndex] = { ...existingTab, ...tab, id: existingTab.id };
return { tabs: newTabs, activeTabId: existingTab.id };
}
}
// 语义去重:对 query 类型按 savedQueryId 匹配已有 Tab避免保存后重复打开
if (tab.type === 'query' && tab.savedQueryId) {
const savedQueryIndex = state.tabs.findIndex(t =>
t.type === 'query' && (t.savedQueryId === tab.savedQueryId || t.id === tab.savedQueryId)
);
if (savedQueryIndex !== -1) {
const existingTab = state.tabs[savedQueryIndex];
const newTabs = [...state.tabs];
newTabs[savedQueryIndex] = { ...existingTab, ...tab, id: existingTab.id };
return { tabs: newTabs, activeTabId: existingTab.id };
}
}
return { tabs: [...state.tabs, tab], activeTabId: tab.id };
}),
@@ -875,6 +1037,165 @@ export const useStore = create<AppState>()(
}),
setEnableHiddenColumnMemory: (enabled) => set({ enableHiddenColumnMemory: !!enabled }),
setWindowBounds: (bounds) => set({
windowBounds: {
width: Math.max(400, Math.trunc(bounds.width)),
height: Math.max(300, Math.trunc(bounds.height)),
x: Math.trunc(bounds.x),
y: Math.trunc(bounds.y),
}
}),
setWindowState: (state) => set({ windowState: state }),
setSidebarWidth: (width) => set({ sidebarWidth: Math.max(200, Math.min(600, Math.trunc(width))) }),
// AI actions
toggleAIPanel: () => set((state) => ({ aiPanelVisible: !state.aiPanelVisible })),
setAIPanelVisible: (visible) => set({ aiPanelVisible: visible }),
addAIChatMessage: (sessionId, message) => {
set((state) => {
const history = { ...state.aiChatHistory };
const messages = history[sessionId] || [];
history[sessionId] = [...messages, message];
let newSessions = [...state.aiChatSessions];
const existingSession = newSessions.find(s => s.id === sessionId);
if (!existingSession) {
let title = message.role === 'user' ? message.content : '新的对话';
if (title.length > 20) {
title = title.substring(0, 20) + '...';
}
newSessions.unshift({ id: sessionId, title, updatedAt: Date.now() });
} else {
newSessions = newSessions.filter(s => s.id !== sessionId);
newSessions.unshift({ ...existingSession, updatedAt: Date.now() });
}
return { aiChatHistory: history, aiChatSessions: newSessions };
});
// 异步持久化到文件fire-and-forget防抖由外层控制
_debouncedPersistSession(sessionId);
},
updateAIChatMessage: (sessionId, messageId, updates) => {
set((state) => {
const messages = state.aiChatHistory[sessionId];
if (!messages) return state;
const idx = messages.findIndex(m => m.id === messageId);
if (idx < 0) return state;
const newMessages = [...messages];
newMessages[idx] = { ...newMessages[idx], ...updates };
const history = { ...state.aiChatHistory, [sessionId]: newMessages };
const isContentOnlyUpdate = Object.keys(updates).length === 1 && 'content' in updates;
if (!isContentOnlyUpdate) {
let newSessions = [...state.aiChatSessions];
const existingSession = newSessions.find(s => s.id === sessionId);
if (existingSession) {
newSessions = newSessions.filter(s => s.id !== sessionId);
newSessions.unshift({ ...existingSession, updatedAt: Date.now() });
}
return { aiChatHistory: history, aiChatSessions: newSessions };
}
return { aiChatHistory: history };
});
// 流式打字高频调用,防抖 2 秒后才写磁盘
_debouncedPersistSession(sessionId);
},
deleteAIChatMessage: (sessionId, messageId) => {
set((state) => {
const history = { ...state.aiChatHistory };
if (history[sessionId]) {
history[sessionId] = history[sessionId].filter(m => m.id !== messageId);
}
return { aiChatHistory: history };
});
_debouncedPersistSession(sessionId);
},
truncateAIChatMessages: (sessionId, upToMessageId) => {
set((state) => {
const history = { ...state.aiChatHistory };
const messages = history[sessionId];
if (messages) {
const idx = messages.findIndex(m => m.id === upToMessageId);
if (idx >= 0) {
history[sessionId] = messages.slice(0, idx + 1);
}
}
return { aiChatHistory: history };
});
_debouncedPersistSession(sessionId);
},
clearAIChatHistory: (sessionId) => {
set((state) => {
const history = { ...state.aiChatHistory };
delete history[sessionId];
return { aiChatHistory: history };
});
_debouncedPersistSession(sessionId);
},
replaceAIChatHistory: (sessionId, messages) => {
set((state) => {
const history = { ...state.aiChatHistory };
history[sessionId] = messages;
return { aiChatHistory: history };
});
_debouncedPersistSession(sessionId);
},
deleteAISession: (sessionId) => {
set((state) => {
const history = { ...state.aiChatHistory };
delete history[sessionId];
const newSessions = state.aiChatSessions.filter(s => s.id !== sessionId);
const newActive = state.aiActiveSessionId === sessionId ? null : state.aiActiveSessionId;
return { aiChatHistory: history, aiChatSessions: newSessions, aiActiveSessionId: newActive };
});
// 删除文件
const Service = (window as any).go?.aiservice?.Service;
Service?.AIDeleteSession?.(sessionId).catch(() => {});
},
createNewAISession: () => set(() => {
const newId = `session-${Date.now()}`;
return { aiActiveSessionId: newId };
}),
setAIActiveSessionId: (sessionId) => set({ aiActiveSessionId: sessionId }),
updateAISessionTitle: (sessionId, title) => {
set((state) => {
const newSessions = [...state.aiChatSessions];
const session = newSessions.find(s => s.id === sessionId);
if (session) {
session.title = title;
}
return { aiChatSessions: newSessions };
});
_debouncedPersistSession(sessionId);
},
addAIContext: (connectionKey, context) => set((state) => {
const contexts = state.aiContexts[connectionKey] || [];
if (contexts.find(c => c.dbName === context.dbName && c.tableName === context.tableName)) {
return state;
}
return {
aiContexts: {
...state.aiContexts,
[connectionKey]: [...contexts, context]
}
};
}),
removeAIContext: (connectionKey, dbName, tableName) => set((state) => {
const contexts = state.aiContexts[connectionKey] || [];
return {
aiContexts: {
...state.aiContexts,
[connectionKey]: contexts.filter(c => !(c.dbName === dbName && c.tableName === tableName))
}
};
}),
clearAIContexts: (connectionKey) => set((state) => {
const { [connectionKey]: _, ...rest } = state.aiContexts;
return { aiContexts: rest };
}),
}),
{
name: 'lite-db-storage', // name of the item in the storage (must be unique)
@@ -906,7 +1227,14 @@ export const useStore = create<AppState>()(
nextState.enableColumnOrderMemory = state.enableColumnOrderMemory !== false;
const safeHidden = sanitizeTableHiddenColumns(state.tableHiddenColumns);
nextState.tableHiddenColumns = safeHidden;
nextState.enableHiddenColumnMemory = state.enableHiddenColumnMemory !== false;
nextState.enableHiddenColumnMemory = state.enableHiddenColumnMemory !== false;
nextState.windowBounds = sanitizeWindowBounds(state.windowBounds);
nextState.windowState = sanitizeWindowState(state.windowState);
nextState.sidebarWidth = sanitizeSidebarWidth(state.sidebarWidth);
// 保留原有的 AI 持久化记录,或者为空(版本兼容)
nextState.aiChatHistory = (state.aiChatHistory && typeof state.aiChatHistory === 'object') ? state.aiChatHistory : {};
nextState.aiChatSessions = Array.isArray(state.aiChatSessions) ? state.aiChatSessions : [];
return nextState as AppState;
},
merge: (persistedState, currentState) => {
@@ -928,11 +1256,18 @@ export const useStore = create<AppState>()(
enableColumnOrderMemory: state.enableColumnOrderMemory !== false,
tableHiddenColumns: sanitizeTableHiddenColumns(state.tableHiddenColumns),
enableHiddenColumnMemory: state.enableHiddenColumnMemory !== false,
windowBounds: sanitizeWindowBounds(state.windowBounds),
windowState: sanitizeWindowState(state.windowState),
sidebarWidth: sanitizeSidebarWidth(state.sidebarWidth),
sqlFormatOptions: sanitizeSqlFormatOptions(state.sqlFormatOptions),
queryOptions: sanitizeQueryOptions(state.queryOptions),
shortcutOptions: sanitizeShortcutOptions(state.shortcutOptions),
tableAccessCount: sanitizeTableAccessCount(state.tableAccessCount),
// AI 会话数据不再从 localStorage 恢复,改为从后端文件加载
aiChatHistory: {},
aiChatSessions: [],
};
},
partialize: (state) => ({
@@ -953,7 +1288,12 @@ export const useStore = create<AppState>()(
tableColumnOrders: state.tableColumnOrders,
enableColumnOrderMemory: state.enableColumnOrderMemory,
tableHiddenColumns: state.tableHiddenColumns,
enableHiddenColumnMemory: state.enableHiddenColumnMemory
enableHiddenColumnMemory: state.enableHiddenColumnMemory,
windowBounds: state.windowBounds,
windowState: state.windowState,
sidebarWidth: state.sidebarWidth,
// AI 会话数据已迁移到后端文件持久化(~/.gonavi/sessions/),不再写入 localStorage
}), // Don't persist logs
}
)

View File

@@ -72,6 +72,8 @@ export interface SavedConnection {
config: ConnectionConfig;
includeDatabases?: string[];
includeRedisDatabases?: number[]; // Redis databases to show (0-15)
iconType?: string; // 自定义图标类型(如 'mysql','postgres'),不填则取 config.type
iconColor?: string; // 自定义图标颜色(十六进制),不填则取类型默认色
}
export interface ConnectionTag {
@@ -116,7 +118,7 @@ export interface TriggerDefinition {
export interface TabData {
id: string;
title: string;
type: 'query' | 'table' | 'design' | 'redis-keys' | 'redis-command' | 'trigger' | 'view-def' | 'routine-def';
type: 'query' | 'table' | 'design' | 'redis-keys' | 'redis-command' | 'trigger' | 'view-def' | 'routine-def' | 'table-overview';
connectionId: string;
dbName?: string;
tableName?: string;
@@ -128,6 +130,7 @@ export interface TabData {
viewName?: string; // View name for view definition tabs
routineName?: string; // Routine name for function/procedure definition tabs
routineType?: string; // 'FUNCTION' or 'PROCEDURE'
savedQueryId?: string; // Saved query identity for quick-save behavior
}
export interface DatabaseNode {
@@ -180,3 +183,63 @@ export interface StreamEntry {
id: string;
fields: Record<string, string>;
}
// --- AI Types ---
export type AIProviderType = 'openai' | 'anthropic' | 'gemini' | 'custom';
export type AISafetyLevel = 'readonly' | 'readwrite' | 'full';
export type AIContextLevel = 'schema_only' | 'with_samples' | 'with_results';
export interface AIContextItem {
dbName: string;
tableName: string;
ddl: string;
}
export interface AIProviderConfig {
id: string;
type: AIProviderType;
name: string;
apiKey: string;
baseUrl: string;
model: string;
models?: string[];
apiFormat?: string; // custom 专用: openai | anthropic | gemini | claude-cli
headers?: Record<string, string>;
maxTokens: number;
temperature: number;
}
export interface AIToolCall {
id: string;
type: string;
function: {
name: string;
arguments: string;
};
}
export type ChatPhase = 'idle' | 'connecting' | 'thinking' | 'generating' | 'tool_calling';
export interface AIChatMessage {
id: string;
role: 'user' | 'assistant' | 'system' | 'tool';
phase?: ChatPhase;
content: string;
thinking?: string;
timestamp: number;
loading?: boolean;
images?: string[]; // base64 encoded images with data URI prefix
tool_calls?: AIToolCall[];
tool_call_id?: string;
tool_name?: string; // used for UI display
rawError?: string; // 存储未清洗的原始错误信息,用于用户复制排查
success?: boolean; // 标记探针执行是否成功
}
export interface AISafetyResult {
allowed: boolean;
operationType: 'query' | 'dml' | 'ddl' | 'other';
requiresConfirm: boolean;
warningMessage?: string;
}

View File

@@ -0,0 +1,33 @@
import { describe, expect, it } from 'vitest';
import {
buildModelFetchFailedNotice,
buildMissingModelNotice,
buildMissingProviderNotice,
} from './aiComposerNotice';
describe('ai composer notice helpers', () => {
it('builds a compact notice for missing provider', () => {
expect(buildMissingProviderNotice()).toEqual({
tone: 'warning',
title: '还没有可用供应商',
description: '先在 AI 设置里添加并启用一个模型供应商。',
});
});
it('builds a compact notice for missing model selection', () => {
expect(buildMissingModelNotice()).toEqual({
tone: 'warning',
title: '先选择一个模型',
description: '打开下方模型下拉并选择模型;如果列表为空,请检查供应商入口和 API Key。',
});
});
it('builds a readable inline notice for model fetch failures', () => {
expect(buildModelFetchFailedNotice('当前接口未返回可用模型')).toEqual({
tone: 'error',
title: '模型列表加载失败',
description: '当前接口未返回可用模型',
});
});
});

View File

@@ -0,0 +1,27 @@
export type AIComposerNoticeTone = 'warning' | 'error';
export interface AIComposerNotice {
tone: AIComposerNoticeTone;
title: string;
description: string;
}
const defaultModelFetchFailedDescription = '请检查供应商入口、API Key 或账号权限,然后重新打开模型下拉。';
export const buildMissingProviderNotice = (): AIComposerNotice => ({
tone: 'warning',
title: '还没有可用供应商',
description: '先在 AI 设置里添加并启用一个模型供应商。',
});
export const buildMissingModelNotice = (): AIComposerNotice => ({
tone: 'warning',
title: '先选择一个模型',
description: '打开下方模型下拉并选择模型;如果列表为空,请检查供应商入口和 API Key。',
});
export const buildModelFetchFailedNotice = (error?: string): AIComposerNotice => ({
tone: 'error',
title: '模型列表加载失败',
description: String(error || '').trim() || defaultModelFetchFailedDescription,
});

View File

@@ -0,0 +1,111 @@
import { describe, expect, it } from 'vitest';
import {
matchQwenPresetKey,
QWEN_BAILIAN_MODELS_BASE_URL,
QWEN_CODING_PLAN_ANTHROPIC_BASE_URL,
QWEN_CODING_PLAN_MODELS,
resolvePresetBaseURL,
resolvePresetModelSelection,
resolvePresetTransport,
} from './aiProviderPresets';
describe('ai provider preset helpers', () => {
it('maps legacy Bailian compatible-mode URL back to the Bailian preset', () => {
expect(matchQwenPresetKey({
type: 'openai',
baseUrl: QWEN_BAILIAN_MODELS_BASE_URL,
})).toBe('qwen-bailian');
});
it('maps Coding Plan anthropic URL to the dedicated Coding Plan preset', () => {
expect(matchQwenPresetKey({
type: 'anthropic',
baseUrl: QWEN_CODING_PLAN_ANTHROPIC_BASE_URL,
})).toBe('qwen-coding-plan');
});
it('maps Coding Plan Claude CLI config back to the dedicated Coding Plan preset', () => {
expect(matchQwenPresetKey({
type: 'custom',
apiFormat: 'claude-cli',
baseUrl: QWEN_CODING_PLAN_ANTHROPIC_BASE_URL,
})).toBe('qwen-coding-plan');
});
it('does not keep a baked-in model list for the Coding Plan preset', () => {
expect(QWEN_CODING_PLAN_MODELS).toEqual([
'qwen3.5-plus',
'kimi-k2.5',
'glm-5',
'MiniMax-M2.5',
'qwen3-max-2026-01-23',
'qwen3-coder-next',
'qwen3-coder-plus',
'glm-4.7',
]);
});
it('keeps built-in preset model empty when the preset intentionally requires an explicit selection', () => {
expect(resolvePresetModelSelection({
presetKey: 'qwen-coding-plan',
presetDefaultModel: '',
presetModels: QWEN_CODING_PLAN_MODELS,
valuesModel: '',
customModels: [],
})).toEqual({
model: '',
models: QWEN_CODING_PLAN_MODELS,
});
});
it('still falls back to the first configured model for custom-like presets', () => {
expect(resolvePresetModelSelection({
presetKey: 'custom',
presetDefaultModel: '',
presetModels: [],
valuesModel: '',
customModels: ['foo-model', 'bar-model'],
})).toEqual({
model: 'foo-model',
models: ['foo-model', 'bar-model'],
});
});
it('forces built-in presets back to their standard base URL when saving or testing', () => {
expect(resolvePresetBaseURL({
presetKey: 'qwen-bailian',
presetDefaultBaseUrl: 'https://dashscope.aliyuncs.com/apps/anthropic',
valuesBaseUrl: 'https://dashscope.aliyuncs.com/compatible-mode/v1',
})).toBe('https://dashscope.aliyuncs.com/apps/anthropic');
});
it('keeps the user-entered base URL for custom-like presets', () => {
expect(resolvePresetBaseURL({
presetKey: 'custom',
presetDefaultBaseUrl: '',
valuesBaseUrl: 'https://example-proxy.internal/v1',
})).toBe('https://example-proxy.internal/v1');
});
it('forces qwen coding plan to save as custom plus claude-cli', () => {
expect(resolvePresetTransport({
presetBackendType: 'custom',
presetFixedApiFormat: 'claude-cli',
valuesApiFormat: 'anthropic',
})).toEqual({
type: 'custom',
apiFormat: 'claude-cli',
});
});
it('keeps custom preset transport editable', () => {
expect(resolvePresetTransport({
presetBackendType: 'custom',
valuesApiFormat: 'gemini',
})).toEqual({
type: 'custom',
apiFormat: 'gemini',
});
});
});

View File

@@ -0,0 +1,143 @@
import type { AIProviderConfig, AIProviderType } from '../types';
export const LEGACY_QWEN_BAILIAN_OPENAI_BASE_URL = 'https://dashscope.aliyuncs.com/compatible-mode/v1';
export const LEGACY_QWEN_CODING_PLAN_OPENAI_BASE_URL = 'https://coding.dashscope.aliyuncs.com/v1';
export const QWEN_BAILIAN_ANTHROPIC_BASE_URL = 'https://dashscope.aliyuncs.com/apps/anthropic';
export const QWEN_CODING_PLAN_ANTHROPIC_BASE_URL = 'https://coding.dashscope.aliyuncs.com/apps/anthropic';
export const QWEN_BAILIAN_MODELS_BASE_URL = LEGACY_QWEN_BAILIAN_OPENAI_BASE_URL;
export const QWEN_CODING_PLAN_MODELS = [
'qwen3.5-plus',
'kimi-k2.5',
'glm-5',
'MiniMax-M2.5',
'qwen3-max-2026-01-23',
'qwen3-coder-next',
'qwen3-coder-plus',
'glm-4.7',
];
const CUSTOM_LIKE_PRESET_KEYS = new Set(['custom', 'ollama']);
export interface ResolvePresetModelSelectionInput {
presetKey: string;
presetDefaultModel: string;
presetModels: string[];
valuesModel?: string;
customModels?: string[];
}
export interface ResolvePresetModelSelectionResult {
model: string;
models: string[];
}
export interface ResolvePresetBaseURLInput {
presetKey: string;
presetDefaultBaseUrl: string;
valuesBaseUrl?: string;
}
export interface ResolvePresetTransportInput {
presetBackendType: AIProviderType;
presetFixedApiFormat?: string;
valuesApiFormat?: string;
}
export interface ResolvePresetTransportResult {
type: AIProviderType;
apiFormat?: string;
}
export const getProviderHostname = (raw?: string): string => {
if (!raw) return '';
try {
return new URL(raw).hostname.toLowerCase();
} catch {
return '';
}
};
export const getProviderFingerprint = (raw?: string): string => {
if (!raw) return '';
try {
const url = new URL(raw);
const normalizedPath = url.pathname.replace(/\/+$/, '').toLowerCase();
return `${url.hostname.toLowerCase()}${normalizedPath}`;
} catch {
return '';
}
};
export const matchQwenPresetKey = (provider: Pick<AIProviderConfig, 'type' | 'baseUrl' | 'apiFormat'>): string | null => {
const fingerprint = getProviderFingerprint(provider.baseUrl);
const bailianFingerprints = new Set([
getProviderFingerprint(LEGACY_QWEN_BAILIAN_OPENAI_BASE_URL),
getProviderFingerprint(QWEN_BAILIAN_ANTHROPIC_BASE_URL),
]);
if (fingerprint !== '' && bailianFingerprints.has(fingerprint)) {
return 'qwen-bailian';
}
const codingPlanFingerprints = new Set([
getProviderFingerprint(LEGACY_QWEN_CODING_PLAN_OPENAI_BASE_URL),
getProviderFingerprint(QWEN_CODING_PLAN_ANTHROPIC_BASE_URL),
]);
if (fingerprint !== '' && codingPlanFingerprints.has(fingerprint)) {
return 'qwen-coding-plan';
}
return null;
};
export const resolvePresetModelSelection = ({
presetKey,
presetDefaultModel,
presetModels,
valuesModel,
customModels,
}: ResolvePresetModelSelectionInput): ResolvePresetModelSelectionResult => {
const isCustomLike = CUSTOM_LIKE_PRESET_KEYS.has(presetKey);
const resolvedModels = isCustomLike ? (customModels || []) : presetModels;
const fallbackModel = resolvedModels.length > 0 ? resolvedModels[0] : '';
return {
models: resolvedModels,
model: isCustomLike ? (valuesModel || fallbackModel) : (valuesModel || presetDefaultModel),
};
};
export const resolvePresetBaseURL = ({
presetKey,
presetDefaultBaseUrl,
valuesBaseUrl,
}: ResolvePresetBaseURLInput): string => {
if (CUSTOM_LIKE_PRESET_KEYS.has(presetKey)) {
return valuesBaseUrl || presetDefaultBaseUrl;
}
return presetDefaultBaseUrl;
};
export const resolvePresetTransport = ({
presetBackendType,
presetFixedApiFormat,
valuesApiFormat,
}: ResolvePresetTransportInput): ResolvePresetTransportResult => {
if (presetFixedApiFormat) {
return {
type: presetBackendType,
apiFormat: presetFixedApiFormat,
};
}
if (presetBackendType === 'custom') {
return {
type: presetBackendType,
apiFormat: valuesApiFormat || 'openai',
};
}
return {
type: presetBackendType,
apiFormat: undefined,
};
};

View File

@@ -0,0 +1,56 @@
import { describe, expect, it } from 'vitest';
import {
PROVIDER_PRESET_CARD_BASE_STYLE,
PROVIDER_PRESET_CARD_CONTENT_STYLE,
PROVIDER_PRESET_CARD_DESCRIPTION_STYLE,
PROVIDER_PRESET_GRID_STYLE,
PROVIDER_PRESET_CARD_TITLE_STYLE,
} from './aiSettingsPresetLayout';
describe('ai settings preset layout', () => {
it('uses a fixed grid auto row height so provider bubbles stay visually consistent across rows', () => {
expect(PROVIDER_PRESET_GRID_STYLE).toMatchObject({
display: 'grid',
gridTemplateColumns: 'repeat(3, minmax(0, 1fr))',
gap: 6,
gridAutoRows: '96px',
alignItems: 'stretch',
});
});
it('stretches each provider card to fill the row height', () => {
expect(PROVIDER_PRESET_CARD_BASE_STYLE).toMatchObject({
display: 'flex',
alignItems: 'flex-start',
gap: 10,
height: '100%',
minHeight: '96px',
overflow: 'hidden',
});
});
it('keeps the text column compact instead of pinning the description to the bottom', () => {
expect(PROVIDER_PRESET_CARD_CONTENT_STYLE).toMatchObject({
minWidth: 0,
flex: 1,
display: 'flex',
flexDirection: 'column',
});
expect(PROVIDER_PRESET_CARD_DESCRIPTION_STYLE).toMatchObject({
marginTop: 4,
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
overflow: 'hidden',
});
expect(PROVIDER_PRESET_CARD_TITLE_STYLE).toMatchObject({
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
overflow: 'hidden',
});
});
});

View File

@@ -0,0 +1,47 @@
import type { CSSProperties } from 'react';
export const PROVIDER_PRESET_CARD_HEIGHT = 96;
export const PROVIDER_PRESET_GRID_STYLE: CSSProperties = {
display: 'grid',
gridTemplateColumns: 'repeat(3, minmax(0, 1fr))',
gap: 6,
gridAutoRows: `${PROVIDER_PRESET_CARD_HEIGHT}px`,
alignItems: 'stretch',
};
export const PROVIDER_PRESET_CARD_BASE_STYLE: CSSProperties = {
padding: '12px 14px',
borderRadius: 12,
cursor: 'pointer',
transition: 'all 0.2s ease',
display: 'flex',
alignItems: 'flex-start',
gap: 10,
height: '100%',
minHeight: `${PROVIDER_PRESET_CARD_HEIGHT}px`,
boxSizing: 'border-box',
overflow: 'hidden',
};
export const PROVIDER_PRESET_CARD_CONTENT_STYLE: CSSProperties = {
minWidth: 0,
flex: 1,
display: 'flex',
flexDirection: 'column',
};
export const PROVIDER_PRESET_CARD_DESCRIPTION_STYLE: CSSProperties = {
marginTop: 4,
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
overflow: 'hidden',
};
export const PROVIDER_PRESET_CARD_TITLE_STYLE: CSSProperties = {
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
overflow: 'hidden',
};

View File

@@ -0,0 +1,23 @@
import { describe, expect, it } from 'vitest';
import { blurToFilter, normalizeBlurForPlatform, normalizeOpacityForPlatform, resolveAppearanceValues } from './appearance';
describe('appearance helpers', () => {
it('falls back to opaque non-blurred appearance when disabled', () => {
expect(resolveAppearanceValues({ enabled: false, opacity: 0.3, blur: 12 })).toEqual({ opacity: 1, blur: 0 });
});
it('preserves configured values when appearance is enabled', () => {
expect(resolveAppearanceValues({ enabled: true, opacity: 0.72, blur: 9 })).toEqual({ opacity: 0.72, blur: 9 });
});
it('caps opacity at full opacity upper bound', () => {
expect(normalizeOpacityForPlatform(2)).toBe(1);
});
it('never returns negative blur and formats blur filter correctly', () => {
expect(normalizeBlurForPlatform(-4)).toBe(0);
expect(blurToFilter(0)).toBeUndefined();
expect(blurToFilter(8)).toBe('blur(8px)');
});
});

View File

@@ -0,0 +1,47 @@
import { describe, expect, it } from 'vitest';
import {
getMacNativeTitlebarPaddingLeft,
getMacNativeTitlebarPaddingRight,
shouldHandleMacNativeFullscreenShortcut,
shouldSuppressMacNativeEscapeExit,
} from './macWindow';
describe('macWindow helpers', () => {
it('uses compact padding when native controls are disabled', () => {
expect(getMacNativeTitlebarPaddingLeft(1, false)).toBe(16);
expect(getMacNativeTitlebarPaddingRight(1, false)).toBe(0);
});
it('reserves traffic-light safe area when native controls are enabled', () => {
expect(getMacNativeTitlebarPaddingLeft(1, true)).toBe(96);
expect(getMacNativeTitlebarPaddingRight(1, true)).toBe(16);
});
it('keeps minimum safe area under small ui scales', () => {
expect(getMacNativeTitlebarPaddingLeft(0.5, true)).toBe(88);
expect(getMacNativeTitlebarPaddingRight(0.5, true)).toBe(12);
});
it('matches Control+Command+F only for mac native mode', () => {
expect(shouldHandleMacNativeFullscreenShortcut(true, true, { ctrlKey: true, metaKey: true, altKey: false, key: 'f' })).toBe(true);
expect(shouldHandleMacNativeFullscreenShortcut(true, true, { ctrlKey: true, metaKey: true, altKey: false, key: 'F' })).toBe(true);
});
it('rejects conflicting modifiers and non-target keys', () => {
expect(shouldHandleMacNativeFullscreenShortcut(true, true, { ctrlKey: true, metaKey: true, altKey: true, key: 'f' })).toBe(false);
expect(shouldHandleMacNativeFullscreenShortcut(true, true, { ctrlKey: true, metaKey: false, altKey: false, key: 'f' })).toBe(false);
expect(shouldHandleMacNativeFullscreenShortcut(false, true, { ctrlKey: true, metaKey: true, altKey: false, key: 'f' })).toBe(false);
expect(shouldHandleMacNativeFullscreenShortcut(true, false, { ctrlKey: true, metaKey: true, altKey: false, key: 'f' })).toBe(false);
expect(shouldHandleMacNativeFullscreenShortcut(true, true, { ctrlKey: true, metaKey: true, altKey: false, key: 'g' })).toBe(false);
});
it('suppresses Escape only in mac native fullscreen mode', () => {
expect(shouldSuppressMacNativeEscapeExit(true, true, true, { key: 'Escape', defaultPrevented: false })).toBe(true);
expect(shouldSuppressMacNativeEscapeExit(true, true, false, { key: 'Escape', defaultPrevented: false })).toBe(false);
expect(shouldSuppressMacNativeEscapeExit(true, false, true, { key: 'Escape', defaultPrevented: false })).toBe(false);
expect(shouldSuppressMacNativeEscapeExit(false, true, true, { key: 'Escape', defaultPrevented: false })).toBe(false);
expect(shouldSuppressMacNativeEscapeExit(true, true, true, { key: 'Enter', defaultPrevented: false })).toBe(false);
expect(shouldSuppressMacNativeEscapeExit(true, true, true, { key: 'Escape', defaultPrevented: true })).toBe(false);
});
});

View File

@@ -0,0 +1,42 @@
export const getMacNativeTitlebarPaddingLeft = (uiScale: number, enabled: boolean): number => {
if (!enabled) {
return Math.max(12, Math.round(16 * uiScale));
}
return Math.max(88, Math.round(96 * uiScale));
};
export const getMacNativeTitlebarPaddingRight = (uiScale: number, enabled: boolean): number => {
if (!enabled) {
return 0;
}
return Math.max(12, Math.round(16 * uiScale));
};
export const shouldHandleMacNativeFullscreenShortcut = (
isMacRuntime: boolean,
useNativeMacWindowControls: boolean,
event: Pick<KeyboardEvent, 'ctrlKey' | 'metaKey' | 'altKey' | 'key'>,
): boolean => {
if (!isMacRuntime || !useNativeMacWindowControls) {
return false;
}
if (!event.ctrlKey || !event.metaKey || event.altKey) {
return false;
}
return String(event.key || '').toLowerCase() === 'f';
};
export const shouldSuppressMacNativeEscapeExit = (
isMacRuntime: boolean,
useNativeMacWindowControls: boolean,
isFullscreen: boolean,
event: Pick<KeyboardEvent, 'key' | 'defaultPrevented'>,
): boolean => {
if (!isMacRuntime || !useNativeMacWindowControls || !isFullscreen) {
return false;
}
if (event.defaultPrevented) {
return false;
}
return String(event.key || '') === 'Escape';
};

View File

@@ -1,10 +1,13 @@
import type { FilterCondition } from './sql';
import { parseListValues } from './sql';
type SortInfo = {
type SortInfoItem = {
columnKey?: string;
order?: string;
} | null | undefined;
enabled?: boolean;
};
type SortInfo = SortInfoItem | SortInfoItem[] | null | undefined;
type ShellConvertResult = {
recognized: boolean;
@@ -607,14 +610,24 @@ export const buildMongoSort = (
sortInfo: SortInfo,
fallbackColumns: string[] = [],
): Record<string, 1 | -1> | undefined => {
const sortColumn = String(sortInfo?.columnKey || '').trim();
const sortOrder = String(sortInfo?.order || '');
if (sortColumn && (sortOrder === 'ascend' || sortOrder === 'descend')) {
return { [sortColumn]: sortOrder === 'ascend' ? 1 : -1 };
const items = Array.isArray(sortInfo) ? sortInfo : (sortInfo ? [sortInfo] : []);
const sort: Record<string, 1 | -1> = {};
const seen = new Set<string>();
for (const item of items) {
if (item?.enabled === false) continue;
const col = String(item?.columnKey || '').trim();
const order = String(item?.order || '');
if (col && (order === 'ascend' || order === 'descend')) {
const key = col.toLowerCase();
if (!seen.has(key)) {
seen.add(key);
sort[col] = order === 'ascend' ? 1 : -1;
}
}
}
if (Object.keys(sort).length > 0) return sort;
const uniqueColumns: string[] = [];
const seen = new Set<string>();
(fallbackColumns || []).forEach((col) => {
const key = String(col || '').trim();
if (!key) return;
@@ -625,7 +638,6 @@ export const buildMongoSort = (
});
if (uniqueColumns.length === 0) return undefined;
const sort: Record<string, 1 | -1> = {};
uniqueColumns.forEach((col) => {
sort[col] = 1;
});

View File

@@ -0,0 +1,21 @@
import { describe, expect, it } from 'vitest';
import { buildOverlayWorkbenchTheme } from './overlayWorkbenchTheme';
describe('buildOverlayWorkbenchTheme', () => {
it('builds dark theme tokens', () => {
const darkTheme = buildOverlayWorkbenchTheme(true);
expect(darkTheme.isDark).toBe(true);
expect(darkTheme.shellBg).toMatch(/rgba\(15, 15, 17,/);
expect(darkTheme.sectionBg).toMatch(/rgba\(255,?\s*255,?\s*255,?\s*0\.03\)/);
expect(darkTheme.iconColor).toBe('#ffd666');
});
it('builds light theme tokens', () => {
const lightTheme = buildOverlayWorkbenchTheme(false);
expect(lightTheme.isDark).toBe(false);
expect(lightTheme.shellBg).toMatch(/rgba\(255,255,255,0\.98\)/);
expect(lightTheme.sectionBg).toMatch(/rgba\(255,?\s*255,?\s*255,?\s*0\.84\)/);
expect(lightTheme.iconColor).toBe('#1677ff');
});
});

View File

@@ -0,0 +1,59 @@
type OverlayWorkbenchTheme = {
isDark: boolean;
shellBg: string;
shellBorder: string;
shellShadow: string;
shellBackdropFilter: string;
sectionBg: string;
sectionBorder: string;
mutedText: string;
titleText: string;
iconBg: string;
iconColor: string;
hoverBg: string;
selectedBg: string;
selectedText: string;
divider: string;
};
export const buildOverlayWorkbenchTheme = (darkMode: boolean): OverlayWorkbenchTheme => {
if (darkMode) {
return {
isDark: true,
shellBg: 'linear-gradient(180deg, rgba(15, 15, 17, 0.96) 0%, rgba(11, 11, 13, 0.98) 100%)',
shellBorder: '1px solid rgba(255,255,255,0.08)',
shellShadow: '0 24px 56px rgba(0,0,0,0.34)',
shellBackdropFilter: 'blur(18px)',
sectionBg: 'rgba(255,255,255,0.03)',
sectionBorder: '1px solid rgba(255,255,255,0.08)',
mutedText: 'rgba(255,255,255,0.5)',
titleText: '#f5f7ff',
iconBg: 'rgba(255,214,102,0.12)',
iconColor: '#ffd666',
hoverBg: 'rgba(255,214,102,0.10)',
selectedBg: 'rgba(255,214,102,0.14)',
selectedText: '#ffd666',
divider: 'rgba(255,255,255,0.08)',
};
}
return {
isDark: false,
shellBg: 'linear-gradient(180deg, rgba(255,255,255,0.98) 0%, rgba(246,248,252,0.98) 100%)',
shellBorder: '1px solid rgba(16,24,40,0.08)',
shellShadow: '0 18px 42px rgba(15,23,42,0.12)',
shellBackdropFilter: 'none',
sectionBg: 'rgba(255,255,255,0.84)',
sectionBorder: '1px solid rgba(16,24,40,0.08)',
mutedText: 'rgba(16,24,40,0.55)',
titleText: '#162033',
iconBg: 'rgba(24,144,255,0.1)',
iconColor: '#1677ff',
hoverBg: 'rgba(24,144,255,0.08)',
selectedBg: 'rgba(24,144,255,0.12)',
selectedText: '#1677ff',
divider: 'rgba(16,24,40,0.08)',
};
};
export type { OverlayWorkbenchTheme };

View File

@@ -6,7 +6,8 @@ export type ShortcutAction =
| 'newQueryTab'
| 'toggleLogPanel'
| 'toggleTheme'
| 'openShortcutManager';
| 'openShortcutManager'
| 'toggleMacFullscreen';
export interface ShortcutBinding {
combo: string;
@@ -19,6 +20,7 @@ export interface ShortcutActionMeta {
label: string;
description: string;
allowInEditable?: boolean;
platformOnly?: 'mac';
}
const MODIFIER_ORDER = ['Ctrl', 'Meta', 'Alt', 'Shift'] as const;
@@ -76,6 +78,7 @@ export const SHORTCUT_ACTION_ORDER: ShortcutAction[] = [
'toggleLogPanel',
'toggleTheme',
'openShortcutManager',
'toggleMacFullscreen',
];
export const SHORTCUT_ACTION_META: Record<ShortcutAction, ShortcutActionMeta> = {
@@ -105,6 +108,11 @@ export const SHORTCUT_ACTION_META: Record<ShortcutAction, ShortcutActionMeta> =
description: '打开快捷键设置面板',
allowInEditable: true,
},
toggleMacFullscreen: {
label: '切换原生全屏',
description: 'macOS 原生窗口控制模式下的全屏切换⌃⌘F',
platformOnly: 'mac',
},
};
export const DEFAULT_SHORTCUT_OPTIONS: ShortcutOptions = {
@@ -114,6 +122,7 @@ export const DEFAULT_SHORTCUT_OPTIONS: ShortcutOptions = {
toggleLogPanel: { combo: 'Ctrl+Shift+L', enabled: true },
toggleTheme: { combo: 'Ctrl+Shift+D', enabled: true },
openShortcutManager: { combo: 'Ctrl+,', enabled: true },
toggleMacFullscreen: { combo: 'Ctrl+Meta+F', enabled: true },
};
const normalizeKeyToken = (value: string): string => {

View File

@@ -69,10 +69,13 @@ export const quoteQualifiedIdent = (dbType: string, ident: string) => {
export const escapeLiteral = (val: string) => (val || '').replace(/'/g, "''");
type SortInfo = {
type SortInfoItem = {
columnKey?: string;
order?: string;
} | null | undefined;
enabled?: boolean;
};
type SortInfo = SortInfoItem | SortInfoItem[] | null | undefined;
// 为排序查询按库类型注入 sort_buffer 提升参数(仅影响当前语句)。
// MySQL: 使用 Optimizer Hint `SET_VAR`。
@@ -101,17 +104,50 @@ export const withSortBufferTuningSQL = (
return rawSql;
};
/** 将 SortInfo单字段或多字段标准化为 SortInfoItem 数组 */
const normalizeSortInfoItems = (sortInfo: SortInfo): SortInfoItem[] => {
if (!sortInfo) return [];
if (Array.isArray(sortInfo)) return sortInfo;
return [sortInfo];
};
/** 判断 SortInfo 中是否存在至少一个有效排序 */
export const hasExplicitSort = (sortInfo: SortInfo): boolean => {
const items = normalizeSortInfoItems(sortInfo);
return items.some(item => {
if (item?.enabled === false) return false;
const col = String(item?.columnKey || '').trim();
const order = String(item?.order || '');
return !!col && (order === 'ascend' || order === 'descend');
});
};
export const buildOrderBySQL = (
dbType: string,
sortInfo: SortInfo,
fallbackColumns: string[] = [],
) => {
const dbTypeLower = String(dbType || '').trim().toLowerCase();
const sortColumn = normalizeIdentPart(String(sortInfo?.columnKey || ''));
const sortOrder = String(sortInfo?.order || '');
const direction = sortOrder === 'ascend' ? 'ASC' : sortOrder === 'descend' ? 'DESC' : '';
if (sortColumn && direction) {
return ` ORDER BY ${quoteIdentPart(dbType, sortColumn)} ${direction}`;
const items = normalizeSortInfoItems(sortInfo);
const seen = new Set<string>();
const sortParts: string[] = [];
for (const item of items) {
if (item?.enabled === false) continue;
const sortColumn = normalizeIdentPart(String(item?.columnKey || ''));
const sortOrder = String(item?.order || '');
const direction = sortOrder === 'ascend' ? 'ASC' : sortOrder === 'descend' ? 'DESC' : '';
if (sortColumn && direction) {
const key = sortColumn.toLowerCase();
if (!seen.has(key)) {
seen.add(key);
sortParts.push(`${quoteIdentPart(dbType, sortColumn)} ${direction}`);
}
}
}
if (sortParts.length > 0) {
return ` ORDER BY ${sortParts.join(', ')}`;
}
// MySQL/MariaDB 大表在无显式排序需求时强制 ORDER BY即使按主键可能触发 filesort
@@ -121,7 +157,6 @@ export const buildOrderBySQL = (
return '';
}
const seen = new Set<string>();
const stableColumns = (fallbackColumns || [])
.map((col) => normalizeIdentPart(String(col || '')))
.filter((col) => {

View File

@@ -0,0 +1,26 @@
import { describe, expect, it } from 'vitest';
import { getConnectionWorkbenchState } from './startupReadiness';
describe('startup readiness helpers', () => {
it('blocks sidebar interactions before local store hydration completes', () => {
expect(getConnectionWorkbenchState(false, false)).toEqual({
ready: false,
message: '正在加载本地配置...',
});
});
it('keeps sidebar blocked until initial global proxy sync finishes', () => {
expect(getConnectionWorkbenchState(true, false)).toEqual({
ready: false,
message: '正在同步全局代理配置...',
});
});
it('unblocks sidebar after startup configuration is fully applied', () => {
expect(getConnectionWorkbenchState(true, true)).toEqual({
ready: true,
message: '',
});
});
});

View File

@@ -0,0 +1,26 @@
export interface ConnectionWorkbenchState {
ready: boolean;
message: string;
}
export function getConnectionWorkbenchState(
isStoreHydrated: boolean,
hasAppliedInitialGlobalProxy: boolean
): ConnectionWorkbenchState {
if (!isStoreHydrated) {
return {
ready: false,
message: '正在加载本地配置...',
};
}
if (!hasAppliedInitialGlobalProxy) {
return {
ready: false,
message: '正在同步全局代理配置...',
};
}
return {
ready: true,
message: '',
};
}

2
frontend/vite.config.d.ts vendored Normal file
View File

@@ -0,0 +1,2 @@
declare const _default: import("vite").UserConfig;
export default _default;

15
frontend/vite.config.js Normal file
View File

@@ -0,0 +1,15 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
server: {
host: '127.0.0.1',
port: 5173,
strictPort: true,
},
build: {
outDir: 'dist', // Standard Wails output directory
emptyOutDir: true,
}
});

View File

@@ -5,6 +5,7 @@ import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
server: {
host: '127.0.0.1',
port: 5173,
strictPort: true,
},

43
frontend/wailsjs/go/aiservice/Service.d.ts vendored Executable file
View File

@@ -0,0 +1,43 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
import {ai} from '../models';
export function AIChatCancel(arg1:string):Promise<void>;
export function AIChatSend(arg1:Array<ai.Message>,arg2:Array<ai.Tool>):Promise<Record<string, any>>;
export function AIChatStream(arg1:string,arg2:Array<ai.Message>,arg3:Array<ai.Tool>):Promise<void>;
export function AICheckSQL(arg1:string):Promise<ai.SafetyResult>;
export function AIDeleteProvider(arg1:string):Promise<void>;
export function AIDeleteSession(arg1:string):Promise<void>;
export function AIGetActiveProvider():Promise<string>;
export function AIGetBuiltinPrompts():Promise<Record<string, string>>;
export function AIGetContextLevel():Promise<string>;
export function AIGetProviders():Promise<Array<ai.ProviderConfig>>;
export function AIGetSafetyLevel():Promise<string>;
export function AIGetSessions():Promise<Array<Record<string, any>>>;
export function AIListModels():Promise<Record<string, any>>;
export function AILoadSession(arg1:string):Promise<Record<string, any>>;
export function AISaveProvider(arg1:ai.ProviderConfig):Promise<void>;
export function AISaveSession(arg1:string,arg2:string,arg3:number,arg4:string):Promise<void>;
export function AISetActiveProvider(arg1:string):Promise<void>;
export function AISetContextLevel(arg1:string):Promise<void>;
export function AISetSafetyLevel(arg1:string):Promise<void>;
export function AITestProvider(arg1:ai.ProviderConfig):Promise<Record<string, any>>;

View File

@@ -0,0 +1,83 @@
// @ts-check
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
export function AIChatCancel(arg1) {
return window['go']['aiservice']['Service']['AIChatCancel'](arg1);
}
export function AIChatSend(arg1, arg2) {
return window['go']['aiservice']['Service']['AIChatSend'](arg1, arg2);
}
export function AIChatStream(arg1, arg2, arg3) {
return window['go']['aiservice']['Service']['AIChatStream'](arg1, arg2, arg3);
}
export function AICheckSQL(arg1) {
return window['go']['aiservice']['Service']['AICheckSQL'](arg1);
}
export function AIDeleteProvider(arg1) {
return window['go']['aiservice']['Service']['AIDeleteProvider'](arg1);
}
export function AIDeleteSession(arg1) {
return window['go']['aiservice']['Service']['AIDeleteSession'](arg1);
}
export function AIGetActiveProvider() {
return window['go']['aiservice']['Service']['AIGetActiveProvider']();
}
export function AIGetBuiltinPrompts() {
return window['go']['aiservice']['Service']['AIGetBuiltinPrompts']();
}
export function AIGetContextLevel() {
return window['go']['aiservice']['Service']['AIGetContextLevel']();
}
export function AIGetProviders() {
return window['go']['aiservice']['Service']['AIGetProviders']();
}
export function AIGetSafetyLevel() {
return window['go']['aiservice']['Service']['AIGetSafetyLevel']();
}
export function AIGetSessions() {
return window['go']['aiservice']['Service']['AIGetSessions']();
}
export function AIListModels() {
return window['go']['aiservice']['Service']['AIListModels']();
}
export function AILoadSession(arg1) {
return window['go']['aiservice']['Service']['AILoadSession'](arg1);
}
export function AISaveProvider(arg1) {
return window['go']['aiservice']['Service']['AISaveProvider'](arg1);
}
export function AISaveSession(arg1, arg2, arg3, arg4) {
return window['go']['aiservice']['Service']['AISaveSession'](arg1, arg2, arg3, arg4);
}
export function AISetActiveProvider(arg1) {
return window['go']['aiservice']['Service']['AISetActiveProvider'](arg1);
}
export function AISetContextLevel(arg1) {
return window['go']['aiservice']['Service']['AISetContextLevel'](arg1);
}
export function AISetSafetyLevel(arg1) {
return window['go']['aiservice']['Service']['AISetSafetyLevel'](arg1);
}
export function AITestProvider(arg1) {
return window['go']['aiservice']['Service']['AITestProvider'](arg1);
}

View File

@@ -1,7 +1,6 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
import {connection} from '../models';
import {time} from '../models';
import {sync} from '../models';
import {redis} from '../models';
@@ -9,12 +8,12 @@ export function ApplyChanges(arg1:connection.ConnectionConfig,arg2:string,arg3:s
export function CancelQuery(arg1:string):Promise<connection.QueryResult>;
export function CancelSQLFileExecution(arg1:string):Promise<connection.QueryResult>;
export function CheckDriverNetworkStatus():Promise<connection.QueryResult>;
export function CheckForUpdates():Promise<connection.QueryResult>;
export function CleanupStaleQueries(arg1:time.Duration):Promise<void>;
export function ConfigureDriverRuntimeDirectory(arg1:string):Promise<connection.QueryResult>;
export function ConfigureGlobalProxy(arg1:boolean,arg2:connection.ProxyConfig):Promise<connection.QueryResult>;
@@ -41,6 +40,8 @@ export function DBQuery(arg1:connection.ConnectionConfig,arg2:string,arg3:string
export function DBQueryIsolated(arg1:connection.ConnectionConfig,arg2:string,arg3:string):Promise<connection.QueryResult>;
export function DBQueryMulti(arg1:connection.ConnectionConfig,arg2:string,arg3:string,arg4:string):Promise<connection.QueryResult>;
export function DBQueryWithCancel(arg1:connection.ConnectionConfig,arg2:string,arg3:string,arg4:string):Promise<connection.QueryResult>;
export function DBShowCreateTable(arg1:connection.ConnectionConfig,arg2:string,arg3:string):Promise<connection.QueryResult>;
@@ -63,6 +64,8 @@ export function DropTable(arg1:connection.ConnectionConfig,arg2:string,arg3:stri
export function DropView(arg1:connection.ConnectionConfig,arg2:string,arg3:string):Promise<connection.QueryResult>;
export function ExecuteSQLFile(arg1:connection.ConnectionConfig,arg2:string,arg3:string,arg4:string):Promise<connection.QueryResult>;
export function ExportData(arg1:Array<Record<string, any>>,arg2:Array<string>,arg3:string,arg4:string):Promise<connection.QueryResult>;
export function ExportDatabaseSQL(arg1:connection.ConnectionConfig,arg2:string,arg3:boolean):Promise<connection.QueryResult>;
@@ -131,6 +134,8 @@ export function RedisGetServerInfo(arg1:connection.ConnectionConfig):Promise<con
export function RedisGetValue(arg1:connection.ConnectionConfig,arg2:string):Promise<connection.QueryResult>;
export function RedisKeyExists(arg1:connection.ConnectionConfig,arg2:string):Promise<connection.QueryResult>;
export function RedisListPush(arg1:connection.ConnectionConfig,arg2:string,arg3:Array<string>):Promise<connection.QueryResult>;
export function RedisListSet(arg1:connection.ConnectionConfig,arg2:string,arg3:number,arg4:string):Promise<connection.QueryResult>;
@@ -185,6 +190,10 @@ export function SelectDriverPackageFile(arg1:string):Promise<connection.QueryRes
export function SelectSSHKeyFile(arg1:string):Promise<connection.QueryResult>;
export function SetMacNativeWindowControls(arg1:boolean):Promise<void>;
export function SetWindowTranslucency(arg1:number,arg2:number):Promise<void>;
export function TestConnection(arg1:connection.ConnectionConfig):Promise<connection.QueryResult>;
export function TruncateTables(arg1:connection.ConnectionConfig,arg2:string,arg3:Array<string>):Promise<connection.QueryResult>;

View File

@@ -10,6 +10,10 @@ export function CancelQuery(arg1) {
return window['go']['app']['App']['CancelQuery'](arg1);
}
export function CancelSQLFileExecution(arg1) {
return window['go']['app']['App']['CancelSQLFileExecution'](arg1);
}
export function CheckDriverNetworkStatus() {
return window['go']['app']['App']['CheckDriverNetworkStatus']();
}
@@ -18,10 +22,6 @@ export function CheckForUpdates() {
return window['go']['app']['App']['CheckForUpdates']();
}
export function CleanupStaleQueries(arg1) {
return window['go']['app']['App']['CleanupStaleQueries'](arg1);
}
export function ConfigureDriverRuntimeDirectory(arg1) {
return window['go']['app']['App']['ConfigureDriverRuntimeDirectory'](arg1);
}
@@ -74,6 +74,10 @@ export function DBQueryIsolated(arg1, arg2, arg3) {
return window['go']['app']['App']['DBQueryIsolated'](arg1, arg2, arg3);
}
export function DBQueryMulti(arg1, arg2, arg3, arg4) {
return window['go']['app']['App']['DBQueryMulti'](arg1, arg2, arg3, arg4);
}
export function DBQueryWithCancel(arg1, arg2, arg3, arg4) {
return window['go']['app']['App']['DBQueryWithCancel'](arg1, arg2, arg3, arg4);
}
@@ -118,6 +122,10 @@ export function DropView(arg1, arg2, arg3) {
return window['go']['app']['App']['DropView'](arg1, arg2, arg3);
}
export function ExecuteSQLFile(arg1, arg2, arg3, arg4) {
return window['go']['app']['App']['ExecuteSQLFile'](arg1, arg2, arg3, arg4);
}
export function ExportData(arg1, arg2, arg3, arg4) {
return window['go']['app']['App']['ExportData'](arg1, arg2, arg3, arg4);
}
@@ -254,6 +262,10 @@ export function RedisGetValue(arg1, arg2) {
return window['go']['app']['App']['RedisGetValue'](arg1, arg2);
}
export function RedisKeyExists(arg1, arg2) {
return window['go']['app']['App']['RedisKeyExists'](arg1, arg2);
}
export function RedisListPush(arg1, arg2, arg3) {
return window['go']['app']['App']['RedisListPush'](arg1, arg2, arg3);
}
@@ -362,6 +374,10 @@ export function SelectSSHKeyFile(arg1) {
return window['go']['app']['App']['SelectSSHKeyFile'](arg1);
}
export function SetMacNativeWindowControls(arg1) {
return window['go']['app']['App']['SetMacNativeWindowControls'](arg1);
}
export function SetWindowTranslucency(arg1, arg2) {
return window['go']['app']['App']['SetWindowTranslucency'](arg1, arg2);
}
@@ -369,3 +385,7 @@ export function SetWindowTranslucency(arg1, arg2) {
export function TestConnection(arg1) {
return window['go']['app']['App']['TestConnection'](arg1);
}
export function TruncateTables(arg1, arg2, arg3) {
return window['go']['app']['App']['TruncateTables'](arg1, arg2, arg3);
}

View File

@@ -1,3 +1,180 @@
export namespace ai {
export class ToolCall {
id: string;
type: string;
// Go type: struct { Name string "json:\"name\""; Arguments string "json:\"arguments\"" }
function: any;
static createFrom(source: any = {}) {
return new ToolCall(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.id = source["id"];
this.type = source["type"];
this.function = this.convertValues(source["function"], Object);
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
if (!a) {
return a;
}
if (a.slice && a.map) {
return (a as any[]).map(elem => this.convertValues(elem, classs));
} else if ("object" === typeof a) {
if (asMap) {
for (const key of Object.keys(a)) {
a[key] = new classs(a[key]);
}
return a;
}
return new classs(a);
}
return a;
}
}
export class Message {
role: string;
content: string;
images?: string[];
tool_call_id?: string;
tool_calls?: ToolCall[];
static createFrom(source: any = {}) {
return new Message(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.role = source["role"];
this.content = source["content"];
this.images = source["images"];
this.tool_call_id = source["tool_call_id"];
this.tool_calls = this.convertValues(source["tool_calls"], ToolCall);
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
if (!a) {
return a;
}
if (a.slice && a.map) {
return (a as any[]).map(elem => this.convertValues(elem, classs));
} else if ("object" === typeof a) {
if (asMap) {
for (const key of Object.keys(a)) {
a[key] = new classs(a[key]);
}
return a;
}
return new classs(a);
}
return a;
}
}
export class ProviderConfig {
id: string;
type: string;
name: string;
apiKey: string;
baseUrl: string;
model: string;
models?: string[];
apiFormat?: string;
headers?: Record<string, string>;
maxTokens: number;
temperature: number;
static createFrom(source: any = {}) {
return new ProviderConfig(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.id = source["id"];
this.type = source["type"];
this.name = source["name"];
this.apiKey = source["apiKey"];
this.baseUrl = source["baseUrl"];
this.model = source["model"];
this.models = source["models"];
this.apiFormat = source["apiFormat"];
this.headers = source["headers"];
this.maxTokens = source["maxTokens"];
this.temperature = source["temperature"];
}
}
export class SafetyResult {
allowed: boolean;
operationType: string;
requiresConfirm: boolean;
warningMessage?: string;
static createFrom(source: any = {}) {
return new SafetyResult(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.allowed = source["allowed"];
this.operationType = source["operationType"];
this.requiresConfirm = source["requiresConfirm"];
this.warningMessage = source["warningMessage"];
}
}
export class ToolFunction {
name: string;
description: string;
parameters: any;
static createFrom(source: any = {}) {
return new ToolFunction(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.name = source["name"];
this.description = source["description"];
this.parameters = source["parameters"];
}
}
export class Tool {
type: string;
function: ToolFunction;
static createFrom(source: any = {}) {
return new Tool(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.type = source["type"];
this.function = this.convertValues(source["function"], ToolFunction);
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
if (!a) {
return a;
}
if (a.slice && a.map) {
return (a as any[]).map(elem => this.convertValues(elem, classs));
} else if ("object" === typeof a) {
if (asMap) {
for (const key of Object.keys(a)) {
a[key] = new classs(a[key]);
}
return a;
}
return new classs(a);
}
return a;
}
}
}
export namespace connection {
export class UpdateRow {

View File

@@ -0,0 +1,214 @@
package aicontext
import (
"fmt"
"strings"
)
// PromptTemplate AI 能力类型
type PromptTemplate string
const (
PromptSQLGenerate PromptTemplate = "sql_generate"
PromptSQLExplain PromptTemplate = "sql_explain"
PromptSQLOptimize PromptTemplate = "sql_optimize"
PromptDataAnalyze PromptTemplate = "data_analyze"
PromptSchemaInsight PromptTemplate = "schema_insight"
PromptGeneralChat PromptTemplate = "general_chat"
)
// GetBuiltinPrompts 获取所有内置系统提示词集合,用于前端展示
func GetBuiltinPrompts() map[string]string {
return map[string]string{
"通用聊天助手": buildGeneralChatPrompt(),
"SQL 生成器": buildSQLGeneratePrompt(),
"SQL 解析器": buildSQLExplainPrompt(),
"SQL 优化器": buildSQLOptimizePrompt(),
"数据洞察分析": buildDataAnalyzePrompt(),
"表结构审查": buildSchemaInsightPrompt(),
}
}
// BuildSystemPrompt 根据模板类型和上下文构建 System Prompt
func BuildSystemPrompt(template PromptTemplate, dbCtx *DatabaseContext) string {
var prompt string
switch template {
case PromptSQLGenerate:
prompt = buildSQLGeneratePrompt()
case PromptSQLExplain:
prompt = buildSQLExplainPrompt()
case PromptSQLOptimize:
prompt = buildSQLOptimizePrompt()
case PromptDataAnalyze:
prompt = buildDataAnalyzePrompt()
case PromptSchemaInsight:
prompt = buildSchemaInsightPrompt()
case PromptGeneralChat:
prompt = buildGeneralChatPrompt()
default:
prompt = buildGeneralChatPrompt()
}
if dbCtx != nil {
prompt += "\n\n" + FormatDatabaseContext(dbCtx)
}
return prompt
}
// FormatDatabaseContext 将数据库上下文格式化为 LLM 友好的文本
func FormatDatabaseContext(ctx *DatabaseContext) string {
if ctx == nil || len(ctx.Tables) == 0 {
return ""
}
var b strings.Builder
b.WriteString(fmt.Sprintf("## 当前数据库上下文\n\n数据库类型: %s\n数据库名: %s\n\n",
ctx.DatabaseType, ctx.DatabaseName))
b.WriteString("### 表结构\n\n")
for _, table := range ctx.Tables {
b.WriteString(fmt.Sprintf("#### 表: %s", table.Name))
if table.Comment != "" {
b.WriteString(fmt.Sprintf(" (%s)", table.Comment))
}
if table.RowCount > 0 {
b.WriteString(fmt.Sprintf(" [约 %d 行]", table.RowCount))
}
b.WriteString("\n\n")
b.WriteString("| 列名 | 类型 | 可空 | 主键 | 备注 |\n")
b.WriteString("|------|------|------|------|------|\n")
for _, col := range table.Columns {
nullable := "否"
if col.Nullable {
nullable = "是"
}
pk := ""
if col.PrimaryKey {
pk = "✓"
}
comment := col.Comment
if comment == "" {
comment = "-"
}
b.WriteString(fmt.Sprintf("| %s | %s | %s | %s | %s |\n",
col.Name, col.Type, nullable, pk, comment))
}
b.WriteString("\n")
if len(table.Indexes) > 0 {
b.WriteString("**索引:**\n")
for _, idx := range table.Indexes {
unique := ""
if idx.Unique {
unique = " (唯一)"
}
b.WriteString(fmt.Sprintf("- %s: [%s]%s\n",
idx.Name, strings.Join(idx.Columns, ", "), unique))
}
b.WriteString("\n")
}
if len(table.SampleRows) > 0 {
b.WriteString(fmt.Sprintf("**采样数据 (%d 行):**\n\n", len(table.SampleRows)))
if len(table.SampleRows) > 0 {
// 使用第一行的 key 作为标题
first := table.SampleRows[0]
var keys []string
for k := range first {
keys = append(keys, k)
}
b.WriteString("| " + strings.Join(keys, " | ") + " |\n")
b.WriteString("|" + strings.Repeat("------|", len(keys)) + "\n")
for _, row := range table.SampleRows {
var vals []string
for _, k := range keys {
vals = append(vals, fmt.Sprintf("%v", row[k]))
}
b.WriteString("| " + strings.Join(vals, " | ") + " |\n")
}
b.WriteString("\n")
}
}
}
return b.String()
}
func buildSQLGeneratePrompt() string {
return `你是 GoNavi AI 助手,一位顶级的数据库开发专家和 SQL 查询构建师。根据用户的自然语言需求,生成精准、优雅、高性能的 SQL 查询或 Redis 命令。
严苛输出规则:
1. 首要目标是输出纯粹的代码:始终将代码放在正确语言标识(如 sql 或 bash的 markdown 代码块中。
2. 保持精简:不要添加过多的前置闲聊,直奔主题。
3. 保护生产安全:优先使用参数化查询或安全防范写法避免 SQL 注入。对于未指定条件的 DELETE/UPDATE 语句,必须提出强烈的红线警告!!
4. 性能至上:对大型查询默认添加合理的 LIMIT 限制(如 LIMIT 100在 JOIN 和聚合时优先选择最高效的范式写法。
5. 适度注释:对于存在复杂逻辑嵌套的代码,请在代码块内使用单行注释简要说明思路。`
}
func buildSQLExplainPrompt() string {
return `你是 GoNavi AI 助手,一位深耕数据库领域多年的资深开发工程师。请用专业、条理分明且深入浅出的开发者语言向用户全盘解析 SQL 语句的底层意图与执行逻辑。
解析规范:
1. 宏观逻辑解构:用简短的一句话概括这条 SQL 在业务上想要解决什么问题。
2. 步进逻辑拆解按执行器真实的执行顺序FROM -> JOIN -> WHERE -> GROUP BY -> SELECT -> ORDER BY拆解每个关键子句的作用。
3. 性能排雷点:敏锐指出可能存在的性能陷阱(如隐式类型转换、没有走索引的函数调用、潜在的笛卡尔积/全表扫描等)。
4. 严谨的排版:使用列表呈现关键点,重点词汇加粗,确保长文不累赘。`
}
func buildSQLOptimizePrompt() string {
return `你是 GoNavi AI 助手,一名曾主导过千万级高并发系统的全栈性能工程专家与高级 DBA。请对用户提供的原始 SQL 进行冷酷、精确的诊断并开出性能重构处方。
诊断与处方要求:
1. 性能瓶颈透视:精准点出当前语句死穴(不合理的驱动表、无法利用覆盖索引、多此一举的子查询等)。
2. 重构版本的 SQL如果存在性能提升空间直接向用户展示彻底优化过的高性能写法并确保逻辑等价性。
3. 剖析原因:不仅要告诉用户“怎么改”,更要说清楚执行器“为什么这样会更快”。
4. 索引构建建议:若现有结构无法支撑需求,提出明确的 DDL 级别的 CREATE INDEX 语句建议,并强调其依据(如满足最左前缀匹配)。
5. 优先级评估:在回答的最后标注本次优化建议的紧迫性(高:阻断级/锁表风险;中:吞吐量瓶颈;低:长效微调)。`
}
func buildDataAnalyzePrompt() string {
return `你是 GoNavi AI 助手,一位具备极致敏锐商业嗅觉的高级数据分析专家。你将审视用户通过查询得到的数据样本,从中提炼出蕴含的真金白银般的信息。
洞察目标:
1. 硬统计:总观数据行数、核心数值指标(极值、平均值、聚合中位数等)的冰冷现实。
2. 趋势与异动:如果数据带有时间戳,敏锐捕捉其上升或下降趋势;如果有异类离群值,将其高亮标注。
3. 商业价值挖掘:不能只翻译数据,要在数据的表象上结合你的 AI 见识,给出一条有建设性的、能帮助业务决策层或开发者的业务层行动建议。
4. 展现格式:你的分析应该是“标题 + 浓缩要点”的极简研报形式,杜绝毫无波澜的流水账。`
}
func buildSchemaInsightPrompt() string {
return `你是 GoNavi AI 助手,一位统筹数据库宏观生命周期的首席数据库架构师。在这个环节里,你需要对用户提供的数据库表结构执行最严厉的范式与前瞻性审查。
审查视界:
1. 规范化博弈:是否存在明显的反三范式设计?这种冗余是否有助于性能(适当的反范式),还是纯粹的设计失误?
2. 索引健壮性审查评估主键选择如自增、UUID 的利弊),是否存在冗余索引阻碍写入?以及是否遗漏了高频的联合索引。
3. 物理容量前瞻:审视数据类型分配(如使用过大的 VARCHAR、没必要的 BIGINT 等可能带来的空间挥霍)。
4. 代码级指引:如果存在结构性缺陷,不要只发牢骚,直接给出包含具体优化的 ALTER TABLE 结构修改建议脚本。`
}
func buildGeneralChatPrompt() string {
return `你是 GoNavi AI 助手,一款深度集成在数据库/缓存客户端GoNavi内部的专属智能专家系统。
你的目标是成为开发者、DBA 和数据科学家最得力的超级外脑,提供专业、精准、具有前瞻性的数据端解决方案。
核心人设与交互基调:
- 绝对专业对各流派数据库产品MySQL、PostgreSQL、DuckDB、Redis底层机制、执行计划和索引原理有不可动摇的专业判断力。
- 直击痛点:谢绝套话与无效寒暄,若用户的意图明确,首屏直接给出可以直接粘贴运行的优雅代码。
- 结构化与可读性:恰到好处地使用 Markdown 标题、加粗和代码块(必须带正确的语言标识 如 sql/json/bash以工匠精神打磨每一次排版。
- 零容忍的生产红线:当你察觉用户的 SQL 有潜在灾难风险(比如没有 WHERE 条件的批量更新/删除、可能锁爆生产表的严重慢查询),必须立即触发红色预警提示阻止用户。
你的综合能力版图:
1. 📝 自然语言驱动:翻译人类意图为精准的查询语句。
2. 🔍 底层原理解析:剥丝抽茧分析查询背后的执行逻辑与性能隐患。
3. ⚡ 专家级调优:指出并化解性能瓶颈,给出覆盖全维度的索引调优思路。
4. 📊 数据洞察炼金:不仅聚合数据,更能从结果集中挖掘商业维度的深度规律。
5. 🏗️ 架构先知视界:全局审阅表结构设计局限,提出抗数据膨胀级别的架构演进方案。
互动守则:
- 永远使用专业、具有合作感且充满信心的中文与用户探讨问题。
- 当被要求提供任何数据库代码时,需结合相关数据库引擎的最佳实践。如果不清楚当前方言版本,请以标准实现为主基调并好心指出版别差异(如 MySQL 8 窗口函数 等)。
- 绝不轻易拒绝:如果用户要求写 SQL 但并未显式挂载任何表的详细 DDL请尽最大努力根据对话上下文中带入的【纯表名列表】去推测他要查询哪个表。如果实在无法推断请温柔且专业地向用户解释目前已知的表有哪些并询问到底想查哪张表。`
}

View File

@@ -0,0 +1,42 @@
package aicontext
// DatabaseContext 数据库上下文信息,传递给 AI 辅助上下文理解
type DatabaseContext struct {
DatabaseType string `json:"databaseType"` // mysql, postgres 等
DatabaseName string `json:"databaseName"`
Tables []TableContext `json:"tables"`
}
// TableContext 表的上下文信息
type TableContext struct {
Name string `json:"name"`
Comment string `json:"comment,omitempty"`
Columns []ColumnInfo `json:"columns"`
Indexes []IndexInfo `json:"indexes,omitempty"`
SampleRows []map[string]interface{} `json:"sampleRows,omitempty"`
RowCount int64 `json:"rowCount,omitempty"`
}
// ColumnInfo 列信息
type ColumnInfo struct {
Name string `json:"name"`
Type string `json:"type"`
Nullable bool `json:"nullable"`
PrimaryKey bool `json:"primaryKey"`
Comment string `json:"comment,omitempty"`
}
// IndexInfo 索引信息
type IndexInfo struct {
Name string `json:"name"`
Columns []string `json:"columns"`
Unique bool `json:"unique"`
}
// QueryResultContext 查询结果上下文
type QueryResultContext struct {
SQL string `json:"sql"`
Columns []string `json:"columns"`
Rows []map[string]interface{} `json:"rows"`
RowCount int `json:"rowCount"`
}

View File

@@ -0,0 +1,514 @@
package provider
import (
"bufio"
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"GoNavi-Wails/internal/ai"
)
const (
defaultAnthropicBaseURL = "https://api.anthropic.com"
anthropicAPIVersion = "2023-06-01"
)
func normalizeAnthropicMessagesURL(baseURL string) string {
url := strings.TrimRight(strings.TrimSpace(baseURL), "/")
if url == "" {
url = defaultAnthropicBaseURL
}
if strings.HasSuffix(url, "/messages") {
return url
}
if strings.HasSuffix(url, "/v1") {
return url + "/messages"
}
return url + "/v1/messages"
}
func IsDashScopeAnthropicCompatibleBaseURL(baseURL string) bool {
parsed, err := url.Parse(strings.TrimSpace(baseURL))
if err != nil {
return false
}
host := strings.ToLower(parsed.Hostname())
return host == "dashscope.aliyuncs.com" || host == "coding.dashscope.aliyuncs.com"
}
func ApplyAnthropicAuthHeaders(headers http.Header, baseURL string, apiKey string) {
headers.Set("x-api-key", apiKey)
if IsDashScopeAnthropicCompatibleBaseURL(baseURL) {
headers.Set("Authorization", "Bearer "+apiKey)
headers.Del("anthropic-version")
return
}
headers.Set("anthropic-version", anthropicAPIVersion)
}
// AnthropicProvider 实现 Anthropic Claude API 的 Provider
type AnthropicProvider struct {
config ai.ProviderConfig
baseURL string
client *http.Client
}
// NewAnthropicProvider 创建 Anthropic Provider 实例
func NewAnthropicProvider(config ai.ProviderConfig) (Provider, error) {
baseURL := strings.TrimRight(strings.TrimSpace(config.BaseURL), "/")
if baseURL == "" {
baseURL = defaultAnthropicBaseURL
}
model := strings.TrimSpace(config.Model)
if model == "" {
return nil, fmt.Errorf("模型 ID 不能为空,请在设置中选择或输入模型")
}
maxTokens := config.MaxTokens
if maxTokens <= 0 {
maxTokens = defaultOpenAIMaxTokens
}
temperature := config.Temperature
if temperature <= 0 {
temperature = defaultOpenAITemperature
}
normalized := config
normalized.BaseURL = baseURL
normalized.Model = model
normalized.MaxTokens = maxTokens
normalized.Temperature = temperature
return &AnthropicProvider{
config: normalized,
baseURL: baseURL,
client: &http.Client{Timeout: openAIHTTPTimeout},
}, nil
}
func (p *AnthropicProvider) Name() string {
if strings.TrimSpace(p.config.Name) != "" {
return p.config.Name
}
return "Anthropic"
}
func (p *AnthropicProvider) Validate() error {
if strings.TrimSpace(p.config.APIKey) == "" {
return fmt.Errorf("API Key 不能为空")
}
return nil
}
// --- 请求体类型 ---
type anthropicRequest struct {
Model string `json:"model"`
Messages []anthropicMessage `json:"messages"`
System string `json:"system,omitempty"`
MaxTokens int `json:"max_tokens"`
Temperature float64 `json:"temperature,omitempty"`
Stream bool `json:"stream,omitempty"`
Tools []anthropicTool `json:"tools,omitempty"`
}
// anthropicTool Anthropic 格式的工具定义
type anthropicTool struct {
Name string `json:"name"`
Description string `json:"description,omitempty"`
InputSchema any `json:"input_schema"`
}
type anthropicMessage struct {
Role string `json:"role"`
Content interface{} `json:"content"`
}
// convertToolsToAnthropic 将 OpenAI 格式的 tools 转换为 Anthropic 格式
func convertToolsToAnthropic(tools []ai.Tool) []anthropicTool {
if len(tools) == 0 {
return nil
}
result := make([]anthropicTool, 0, len(tools))
for _, t := range tools {
result = append(result, anthropicTool{
Name: t.Function.Name,
Description: t.Function.Description,
InputSchema: t.Function.Parameters,
})
}
return result
}
func buildAnthropicMessages(reqMessages []ai.Message) []anthropicMessage {
messages := make([]anthropicMessage, 0, len(reqMessages))
for _, m := range reqMessages {
// tool result 消息:转换为 Anthropic 的 tool_result content block
if m.Role == "tool" {
messages = append(messages, anthropicMessage{
Role: "user",
Content: []map[string]interface{}{
{
"type": "tool_result",
"tool_use_id": m.ToolCallID,
"content": m.Content,
},
},
})
continue
}
// assistant 带 tool_calls转换为 Anthropic 的 tool_use content block
if m.Role == "assistant" && len(m.ToolCalls) > 0 {
var contentParts []map[string]interface{}
if m.Content != "" {
contentParts = append(contentParts, map[string]interface{}{
"type": "text",
"text": m.Content,
})
}
for _, tc := range m.ToolCalls {
var input interface{}
if err := json.Unmarshal([]byte(tc.Function.Arguments), &input); err != nil {
input = map[string]interface{}{}
}
contentParts = append(contentParts, map[string]interface{}{
"type": "tool_use",
"id": tc.ID,
"name": tc.Function.Name,
"input": input,
})
}
messages = append(messages, anthropicMessage{Role: "assistant", Content: contentParts})
continue
}
// 图片消息
if len(m.Images) > 0 {
var contentParts []map[string]interface{}
for _, img := range m.Images {
mimeType, rawBase64, err := ParseDataURI(img)
if err == nil {
contentParts = append(contentParts, map[string]interface{}{
"type": "image",
"source": map[string]interface{}{
"type": "base64",
"media_type": mimeType,
"data": rawBase64,
},
})
}
}
text := m.Content
if text == "" {
text = "请描述和分析这张图片。"
}
contentParts = append(contentParts, map[string]interface{}{
"type": "text",
"text": text,
})
messages = append(messages, anthropicMessage{Role: m.Role, Content: contentParts})
} else {
messages = append(messages, anthropicMessage{Role: m.Role, Content: m.Content})
}
}
return messages
}
// --- 响应体类型 ---
type anthropicContentBlock struct {
Type string `json:"type"` // "text" | "tool_use"
Text string `json:"text,omitempty"`
ID string `json:"id,omitempty"` // tool_use
Name string `json:"name,omitempty"` // tool_use
Input json.RawMessage `json:"input,omitempty"` // tool_use
}
type anthropicResponse struct {
Content []anthropicContentBlock `json:"content"`
Usage struct {
InputTokens int `json:"input_tokens"`
OutputTokens int `json:"output_tokens"`
} `json:"usage"`
Error *struct {
Message string `json:"message"`
} `json:"error,omitempty"`
}
// 流式事件类型
type anthropicStreamEvent struct {
Type string `json:"type"`
Index int `json:"index,omitempty"`
ContentBlock *anthropicContentBlock `json:"content_block,omitempty"`
Delta *struct {
Type string `json:"type,omitempty"`
Text string `json:"text,omitempty"`
PartialJSON string `json:"partial_json,omitempty"`
} `json:"delta,omitempty"`
}
// --- Chat 非流式 ---
func (p *AnthropicProvider) Chat(ctx context.Context, req ai.ChatRequest) (*ai.ChatResponse, error) {
if err := p.Validate(); err != nil {
return nil, err
}
systemMsg, messages := extractSystemMessage(req.Messages)
anthropicMsgs := buildAnthropicMessages(messages)
temperature := req.Temperature
if temperature <= 0 {
temperature = p.config.Temperature
}
maxTokens := req.MaxTokens
if maxTokens <= 0 {
maxTokens = p.config.MaxTokens
}
body := anthropicRequest{
Model: p.config.Model,
Messages: anthropicMsgs,
System: systemMsg,
MaxTokens: maxTokens,
Temperature: temperature,
Tools: convertToolsToAnthropic(req.Tools),
}
respBody, err := p.doRequest(ctx, body)
if err != nil {
return nil, err
}
defer respBody.Close()
var result anthropicResponse
if err := json.NewDecoder(respBody).Decode(&result); err != nil {
return nil, fmt.Errorf("解析 Anthropic 响应失败: %w", err)
}
if result.Error != nil && result.Error.Message != "" {
return nil, fmt.Errorf("Anthropic API 错误: %s", result.Error.Message)
}
if len(result.Content) == 0 {
return nil, fmt.Errorf("Anthropic 返回空响应")
}
// 解析响应中的 text 和 tool_use content blocks
var textContent string
var toolCalls []ai.ToolCall
for _, block := range result.Content {
switch block.Type {
case "text":
textContent += block.Text
case "tool_use":
argsStr := "{}"
if len(block.Input) > 0 {
argsStr = string(block.Input)
}
toolCalls = append(toolCalls, ai.ToolCall{
ID: block.ID,
Type: "function",
Function: struct {
Name string `json:"name"`
Arguments string `json:"arguments"`
}{
Name: block.Name,
Arguments: argsStr,
},
})
}
}
return &ai.ChatResponse{
Content: textContent,
ToolCalls: toolCalls,
TokensUsed: ai.TokenUsage{
PromptTokens: result.Usage.InputTokens,
CompletionTokens: result.Usage.OutputTokens,
TotalTokens: result.Usage.InputTokens + result.Usage.OutputTokens,
},
}, nil
}
// --- ChatStream 流式 ---
func (p *AnthropicProvider) ChatStream(ctx context.Context, req ai.ChatRequest, callback func(ai.StreamChunk)) error {
if err := p.Validate(); err != nil {
return err
}
systemMsg, messages := extractSystemMessage(req.Messages)
anthropicMsgs := buildAnthropicMessages(messages)
temperature := req.Temperature
if temperature <= 0 {
temperature = p.config.Temperature
}
maxTokens := req.MaxTokens
if maxTokens <= 0 {
maxTokens = p.config.MaxTokens
}
body := anthropicRequest{
Model: p.config.Model,
Messages: anthropicMsgs,
System: systemMsg,
MaxTokens: maxTokens,
Temperature: temperature,
Stream: true,
Tools: convertToolsToAnthropic(req.Tools),
}
respBody, err := p.doRequest(ctx, body)
if err != nil {
return err
}
defer respBody.Close()
// 跟踪当前活跃的 tool_use blocks
type activeToolUse struct {
id string
name string
argsJSON strings.Builder
}
activeBlocks := make(map[int]*activeToolUse) // index -> block
scanner := bufio.NewScanner(respBody)
for scanner.Scan() {
line := scanner.Text()
if !strings.HasPrefix(line, "data: ") {
continue
}
data := strings.TrimPrefix(line, "data: ")
var event anthropicStreamEvent
if err := json.Unmarshal([]byte(data), &event); err != nil {
continue
}
switch event.Type {
case "content_block_start":
if event.ContentBlock != nil && event.ContentBlock.Type == "tool_use" {
activeBlocks[event.Index] = &activeToolUse{
id: event.ContentBlock.ID,
name: event.ContentBlock.Name,
}
}
case "content_block_delta":
if event.Delta == nil {
continue
}
switch event.Delta.Type {
case "text_delta":
if event.Delta.Text != "" {
callback(ai.StreamChunk{Content: event.Delta.Text})
}
case "input_json_delta":
if block, ok := activeBlocks[event.Index]; ok {
block.argsJSON.WriteString(event.Delta.PartialJSON)
}
}
case "content_block_stop":
if block, ok := activeBlocks[event.Index]; ok {
argsStr := block.argsJSON.String()
if argsStr == "" {
argsStr = "{}"
}
// 产出完整的 tool call
callback(ai.StreamChunk{
ToolCalls: []ai.ToolCall{
{
ID: block.id,
Type: "function",
Function: struct {
Name string `json:"name"`
Arguments string `json:"arguments"`
}{
Name: block.name,
Arguments: argsStr,
},
},
},
})
delete(activeBlocks, event.Index)
}
case "message_stop":
callback(ai.StreamChunk{Done: true})
return nil
}
}
callback(ai.StreamChunk{Done: true})
return scanner.Err()
}
// --- HTTP 请求 ---
func (p *AnthropicProvider) doRequest(ctx context.Context, body interface{}) (io.ReadCloser, error) {
jsonBody, err := json.Marshal(body)
if err != nil {
return nil, fmt.Errorf("序列化请求失败: %w", err)
}
url := normalizeAnthropicMessagesURL(p.baseURL)
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(jsonBody))
if err != nil {
return nil, fmt.Errorf("创建 HTTP 请求失败: %w", err)
}
httpReq.Header.Set("Content-Type", "application/json")
ApplyAnthropicAuthHeaders(httpReq.Header, p.baseURL, p.config.APIKey)
if strings.Contains(string(jsonBody), `"stream":true`) || strings.Contains(string(jsonBody), `"stream": true`) {
httpReq.Header.Set("Accept", "text/event-stream")
httpReq.Header.Set("Cache-Control", "no-cache")
httpReq.Header.Set("Connection", "keep-alive")
}
// 仅官方 API 发 beta 特性头(代理不发,避免触发 Claude Code 验证)
isOfficialAPI := p.baseURL == defaultAnthropicBaseURL || strings.Contains(p.baseURL, "anthropic.com")
if isOfficialAPI {
httpReq.Header.Set("anthropic-beta", "interleaved-thinking-2025-05-14,output-128k-2025-02-19,prompt-caching-2024-07-31")
}
// 自定义 headers用于兼容各类代理服务
for k, v := range p.config.Headers {
httpReq.Header.Set(k, v)
}
resp, err := p.client.Do(httpReq)
if err != nil {
return nil, fmt.Errorf("发送请求到 %s 失败: %w", url, err)
}
if resp.StatusCode != http.StatusOK {
defer resp.Body.Close()
bodyBytes, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("Anthropic API 返回错误 (HTTP %d): %s", resp.StatusCode, string(bodyBytes))
}
return resp.Body, nil
}
// extractSystemMessage 从消息列表中提取 system 消息Anthropic 要求 system 作为独立字段)
func extractSystemMessage(messages []ai.Message) (string, []ai.Message) {
var systemParts []string
var remaining []ai.Message
for _, m := range messages {
if m.Role == "system" {
systemParts = append(systemParts, m.Content)
} else {
remaining = append(remaining, m)
}
}
return strings.Join(systemParts, "\n\n"), remaining
}

View File

@@ -0,0 +1,57 @@
package provider
import (
"net/http"
"testing"
)
func TestNormalizeAnthropicMessagesURL_AppendsMessagesSuffix(t *testing.T) {
url := normalizeAnthropicMessagesURL("https://api.anthropic.com")
if url != "https://api.anthropic.com/v1/messages" {
t.Fatalf("expected normalized anthropic messages url, got %q", url)
}
}
func TestNormalizeAnthropicMessagesURL_UsesMoonshotAnthropicMessagesEndpoint(t *testing.T) {
url := normalizeAnthropicMessagesURL("https://api.moonshot.cn/anthropic")
if url != "https://api.moonshot.cn/anthropic/v1/messages" {
t.Fatalf("expected moonshot anthropic messages url, got %q", url)
}
}
func TestNormalizeAnthropicMessagesURL_PreservesExplicitMessagesPath(t *testing.T) {
url := normalizeAnthropicMessagesURL("https://api.moonshot.cn/anthropic/v1/messages")
if url != "https://api.moonshot.cn/anthropic/v1/messages" {
t.Fatalf("expected explicit messages path to be preserved, got %q", url)
}
}
func TestApplyAnthropicAuthHeaders_UsesOfficialAnthropicHeadersForAnthropicAPI(t *testing.T) {
headers := http.Header{}
ApplyAnthropicAuthHeaders(headers, "https://api.anthropic.com", "sk-test")
if got := headers.Get("x-api-key"); got != "sk-test" {
t.Fatalf("expected x-api-key header, got %q", got)
}
if got := headers.Get("anthropic-version"); got != anthropicAPIVersion {
t.Fatalf("expected anthropic-version header, got %q", got)
}
if got := headers.Get("Authorization"); got != "" {
t.Fatalf("expected no authorization header for official anthropic, got %q", got)
}
}
func TestApplyAnthropicAuthHeaders_UsesBearerForDashScopeCompatibleAnthropic(t *testing.T) {
headers := http.Header{}
ApplyAnthropicAuthHeaders(headers, "https://coding.dashscope.aliyuncs.com/apps/anthropic", "sk-sp-test")
if got := headers.Get("Authorization"); got != "Bearer sk-sp-test" {
t.Fatalf("expected bearer authorization header, got %q", got)
}
if got := headers.Get("x-api-key"); got != "sk-sp-test" {
t.Fatalf("expected x-api-key header, got %q", got)
}
if got := headers.Get("anthropic-version"); got != "" {
t.Fatalf("expected no anthropic-version header for DashScope, got %q", got)
}
}

View File

@@ -0,0 +1,511 @@
package provider
import (
"bufio"
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"os"
"os/exec"
"runtime"
"strings"
"time"
ai "GoNavi-Wails/internal/ai"
)
var claudeLookPath = exec.LookPath
var claudeCommandContext = exec.CommandContext
var claudeCLIRequestTimeout = 90 * time.Second
// ClaudeCLIProvider 通过 Claude Code CLI 发送聊天请求
// 适用于 anyrouter/newapi 等只支持 Claude Code 协议的代理服务
type ClaudeCLIProvider struct {
config ai.ProviderConfig
}
// NewClaudeCLIProvider 创建 ClaudeCLIProvider 实例
func NewClaudeCLIProvider(config ai.ProviderConfig) (Provider, error) {
return &ClaudeCLIProvider{config: config}, nil
}
func (p *ClaudeCLIProvider) Name() string {
return "ClaudeCLI"
}
func (p *ClaudeCLIProvider) Validate() error {
_, err := claudeLookPath("claude")
if err != nil {
return fmt.Errorf("未找到 claude 命令,请先安装 Claude Code CLI: npm install -g @anthropic-ai/claude-code")
}
if _, err := resolveClaudeCodeGitBashPath(os.Environ(), runtime.GOOS, claudeLookPath, fileExists); err != nil {
return err
}
return nil
}
// Chat 非流式聊天:调用 claude -p "prompt" --output-format json
func (p *ClaudeCLIProvider) Chat(ctx context.Context, req ai.ChatRequest) (*ai.ChatResponse, error) {
if err := p.Validate(); err != nil {
return nil, err
}
ctx, cancel := ensureClaudeCLITimeout(ctx, claudeCLIRequestTimeout)
defer cancel()
prompt := buildPrompt(req.Messages)
args := []string{"-p", prompt, "--output-format", "json", "--no-session-persistence"}
if p.config.Model != "" {
args = append(args, "--model", p.config.Model)
}
cmd := claudeCommandContext(ctx, "claude", args...)
if err := p.setEnv(cmd); err != nil {
return nil, err
}
output, err := cmd.Output()
if err != nil {
if isClaudeCLITimeout(ctx, err) {
return nil, fmt.Errorf("claude CLI 执行超时(%s当前 Base URL 或 API Key 可能没有返回有效响应", claudeCLIRequestTimeout)
}
if exitErr, ok := err.(*exec.ExitError); ok {
return nil, fmt.Errorf("claude CLI 执行失败: %s", string(exitErr.Stderr))
}
return nil, fmt.Errorf("claude CLI 执行失败: %w", err)
}
// 解析 JSON 输出
var result cliStreamEvent
if err := json.Unmarshal(output, &result); err != nil {
// 如果 JSON 解析失败,直接返回原始文本
return &ai.ChatResponse{Content: strings.TrimSpace(string(output))}, nil
}
if errMsg, hasError := extractClaudeCLIEventError(result); hasError {
return nil, fmt.Errorf("claude CLI 返回错误: %s", errMsg)
}
return &ai.ChatResponse{Content: result.Result}, nil
}
// ChatStream 流式聊天:调用 claude -p "prompt" --output-format stream-json
func (p *ClaudeCLIProvider) ChatStream(ctx context.Context, req ai.ChatRequest, callback func(ai.StreamChunk)) error {
if err := p.Validate(); err != nil {
return err
}
ctx, cancel := ensureClaudeCLITimeout(ctx, claudeCLIRequestTimeout)
defer cancel()
prompt := buildPrompt(req.Messages)
args := []string{"-p", prompt, "--output-format", "stream-json", "--verbose", "--include-partial-messages", "--no-session-persistence"}
if p.config.Model != "" {
args = append(args, "--model", p.config.Model)
}
fmt.Printf("[ClaudeCLI DEBUG] Running: claude %v\n", args)
cmd := claudeCommandContext(ctx, "claude", args...)
if err := p.setEnv(cmd); err != nil {
return err
}
// 关闭 stdin防止 claude CLI 等待输入
cmd.Stdin = nil
stdout, err := cmd.StdoutPipe()
if err != nil {
return fmt.Errorf("创建 stdout 管道失败: %w", err)
}
// 捕获 stderr
var stderrBuf bytes.Buffer
cmd.Stderr = &stderrBuf
if err := cmd.Start(); err != nil {
return fmt.Errorf("启动 claude CLI 失败: %w", err)
}
fmt.Printf("[ClaudeCLI DEBUG] Process started, PID: %d\n", cmd.Process.Pid)
// 前端已有 loading 动画,无需在 content 中注入"正在思考"
// 逐行读取流式 JSON 输出
scanner := bufio.NewScanner(stdout)
scanner.Buffer(make([]byte, 64*1024), 1024*1024)
for scanner.Scan() {
line := scanner.Text()
if strings.TrimSpace(line) == "" {
continue
}
fmt.Printf("[ClaudeCLI DEBUG] Line: %s\n", line[:min(len(line), 200)])
var event cliStreamEvent
if err := json.Unmarshal([]byte(line), &event); err != nil {
fmt.Printf("[ClaudeCLI DEBUG] Non-JSON line: %s\n", line)
continue
}
switch event.Type {
case "system":
if isClaudeCLISystemRetryEvent(event) {
if errMsg, hasError := extractClaudeCLISystemRetryError(event); hasError {
callback(ai.StreamChunk{Error: errMsg, Done: true})
if cmd.Process != nil {
_ = cmd.Process.Kill()
}
_ = cmd.Wait()
return nil
}
}
case "assistant":
if errMsg, hasError := extractClaudeCLIEventError(event); hasError {
callback(ai.StreamChunk{Error: errMsg, Done: true})
_ = cmd.Wait()
return nil
}
// 助手消息开始或文本内容
if event.Message.Content != nil {
for _, block := range event.Message.Content {
if block.Type == "thinking" && block.Thinking != "" {
callback(ai.StreamChunk{Thinking: block.Thinking})
} else if block.Type == "text" && block.Text != "" {
callback(ai.StreamChunk{Content: block.Text})
}
}
}
case "content_block_delta":
// 增量文本或增量思考
if event.Delta.Type == "thinking_delta" && event.Delta.Thinking != "" {
callback(ai.StreamChunk{Thinking: event.Delta.Thinking})
} else if event.Delta.Text != "" {
callback(ai.StreamChunk{Content: event.Delta.Text})
}
case "result":
if errMsg, hasError := extractClaudeCLIEventError(event); hasError {
callback(ai.StreamChunk{Error: errMsg, Done: true})
_ = cmd.Wait()
return nil
}
// 最终结果事件 — 不发送 contentassistant 事件已包含),只标记完成
callback(ai.StreamChunk{Done: true})
_ = cmd.Wait()
return nil
case "error":
errMsg, _ := extractClaudeCLIEventError(event)
callback(ai.StreamChunk{Error: errMsg, Done: true})
_ = cmd.Wait()
return nil
}
}
waitErr := cmd.Wait()
stderrStr := strings.TrimSpace(stderrBuf.String())
fmt.Printf("[ClaudeCLI DEBUG] Process exited. stderr: %s\n", stderrStr)
if isClaudeCLITimeout(ctx, waitErr) {
callback(ai.StreamChunk{
Error: fmt.Sprintf("claude CLI 执行超时(%s当前 Base URL 或 API Key 可能没有返回有效响应", claudeCLIRequestTimeout),
Done: true,
})
return nil
}
if waitErr != nil {
errMsg := fmt.Sprintf("claude CLI 异常退出: %v", waitErr)
if stderrStr != "" {
errMsg = fmt.Sprintf("claude CLI 异常退出: %s", stderrStr)
}
callback(ai.StreamChunk{Error: errMsg, Done: true})
return nil
}
callback(ai.StreamChunk{Done: true})
return nil
}
func ensureClaudeCLITimeout(ctx context.Context, timeout time.Duration) (context.Context, context.CancelFunc) {
if _, hasDeadline := ctx.Deadline(); hasDeadline || timeout <= 0 {
return ctx, func() {}
}
return context.WithTimeout(ctx, timeout)
}
func isClaudeCLITimeout(ctx context.Context, err error) bool {
if err == nil {
return false
}
return errors.Is(ctx.Err(), context.DeadlineExceeded) || errors.Is(err, context.DeadlineExceeded)
}
// setEnv 设置 Claude CLI 的环境变量
func (p *ClaudeCLIProvider) setEnv(cmd *exec.Cmd) error {
env, err := buildClaudeCLIEnv(p.config, cmd.Environ(), runtime.GOOS, claudeLookPath, fileExists)
if err != nil {
return err
}
cmd.Env = env
return nil
}
func buildClaudeCLIEnv(config ai.ProviderConfig, baseEnv []string, goos string, lookPath func(string) (string, error), exists func(string) bool) ([]string, error) {
env := append([]string(nil), baseEnv...)
if config.BaseURL != "" {
env = upsertEnv(env, "ANTHROPIC_BASE_URL", strings.TrimRight(config.BaseURL, "/"))
}
if config.APIKey != "" {
env = upsertEnv(env, "ANTHROPIC_AUTH_TOKEN", config.APIKey)
env = upsertEnv(env, "ANTHROPIC_API_KEY", config.APIKey)
}
gitBashPath, err := resolveClaudeCodeGitBashPath(env, goos, lookPath, exists)
if err != nil {
return nil, err
}
if gitBashPath != "" {
env = upsertEnv(env, "CLAUDE_CODE_GIT_BASH_PATH", gitBashPath)
}
return env, nil
}
func resolveClaudeCodeGitBashPath(env []string, goos string, lookPath func(string) (string, error), exists func(string) bool) (string, error) {
if goos != "windows" {
return "", nil
}
if configured := strings.TrimSpace(envValue(env, "CLAUDE_CODE_GIT_BASH_PATH")); configured != "" {
if exists(configured) {
return configured, nil
}
return "", fmt.Errorf("Claude Code CLI 在 Windows 下需要 git-bash但 CLAUDE_CODE_GIT_BASH_PATH 指向的 bash.exe 不存在: %s", configured)
}
for _, command := range []string{"bash.exe", "bash"} {
if bashPath, err := lookPath(command); err == nil && exists(bashPath) {
return bashPath, nil
}
}
if gitPath, err := lookPath("git.exe"); err == nil {
gitDir := parentWindowsPath(gitPath)
for _, candidate := range []string{
joinWindowsPath(parentWindowsPath(gitDir), "bin", "bash.exe"),
joinWindowsPath(gitDir, "bash.exe"),
} {
if candidate != "" && exists(candidate) {
return candidate, nil
}
}
}
for _, candidate := range windowsGitBashCandidates(env) {
if exists(candidate) {
return candidate, nil
}
}
return "", fmt.Errorf("Claude Code CLI 在 Windows 下需要 git-bash。请安装 Git for Windowshttps://git-scm.com/downloads/win如果已安装但未加入 PATH请设置环境变量 CLAUDE_CODE_GIT_BASH_PATH 指向 bash.exe例如 C:\\Program Files\\Git\\bin\\bash.exe")
}
func windowsGitBashCandidates(env []string) []string {
candidates := make([]string, 0, 3)
for _, base := range []string{
envValue(env, "ProgramFiles"),
envValue(env, "ProgramFiles(x86)"),
envValue(env, "LocalAppData"),
} {
base = strings.TrimSpace(base)
if base == "" {
continue
}
if strings.EqualFold(base, envValue(env, "LocalAppData")) {
candidates = append(candidates, joinWindowsPath(base, "Programs", "Git", "bin", "bash.exe"))
continue
}
candidates = append(candidates, joinWindowsPath(base, "Git", "bin", "bash.exe"))
}
return candidates
}
func envValue(env []string, key string) string {
prefix := key + "="
for _, entry := range env {
if strings.HasPrefix(entry, prefix) {
return strings.TrimPrefix(entry, prefix)
}
}
return ""
}
func upsertEnv(env []string, key, value string) []string {
prefix := key + "="
for i, entry := range env {
if strings.HasPrefix(entry, prefix) {
env[i] = prefix + value
return env
}
}
return append(env, prefix+value)
}
func fileExists(path string) bool {
info, err := os.Stat(strings.TrimSpace(path))
return err == nil && !info.IsDir()
}
func joinWindowsPath(base string, parts ...string) string {
result := strings.TrimSpace(strings.ReplaceAll(base, "/", `\`))
if result != "" {
result = strings.TrimRight(result, `\`)
}
for _, part := range parts {
part = strings.Trim(strings.ReplaceAll(strings.TrimSpace(part), "/", `\`), `\`)
if part == "" {
continue
}
if result == "" {
result = part
continue
}
result += `\` + part
}
return result
}
func parentWindowsPath(path string) string {
path = strings.TrimRight(strings.ReplaceAll(strings.TrimSpace(path), "/", `\`), `\`)
idx := strings.LastIndex(path, `\`)
if idx <= 0 {
return ""
}
return path[:idx]
}
// buildPrompt 将消息列表拼接为适合 claude -p 的提示文本
func buildPrompt(messages []ai.Message) string {
if len(messages) == 1 {
return messages[0].Content
}
var sb strings.Builder
for _, m := range messages {
switch m.Role {
case "system":
sb.WriteString("[System]\n")
sb.WriteString(m.Content)
sb.WriteString("\n\n")
case "user":
sb.WriteString(m.Content)
sb.WriteString("\n\n")
case "assistant":
sb.WriteString("[Previous Assistant Response]\n")
sb.WriteString(m.Content)
sb.WriteString("\n\n")
}
}
return strings.TrimSpace(sb.String())
}
// cliStreamEvent Claude CLI stream-json 输出的事件结构
type cliStreamEvent struct {
Type string `json:"type"`
Subtype string `json:"subtype,omitempty"`
IsError bool `json:"is_error,omitempty"`
Attempt int `json:"attempt,omitempty"`
MaxRetries int `json:"max_retries,omitempty"`
RetryDelayMS float64 `json:"retry_delay_ms,omitempty"`
ErrorStatus int `json:"error_status,omitempty"`
SessionID string `json:"session_id,omitempty"`
Message struct {
Content []struct {
Type string `json:"type"`
Text string `json:"text"`
Thinking string `json:"thinking"`
} `json:"content"`
} `json:"message,omitempty"`
Delta struct {
Type string `json:"type"`
Text string `json:"text"`
Thinking string `json:"thinking"`
} `json:"delta,omitempty"`
Result string `json:"result,omitempty"`
Error cliStreamEventError `json:"error,omitempty"`
}
type cliStreamEventError struct {
Message string
}
func (e *cliStreamEventError) UnmarshalJSON(data []byte) error {
trimmed := strings.TrimSpace(string(data))
if trimmed == "" || trimmed == "null" {
e.Message = ""
return nil
}
var text string
if err := json.Unmarshal(data, &text); err == nil {
e.Message = strings.TrimSpace(text)
return nil
}
var payload struct {
Message string `json:"message"`
}
if err := json.Unmarshal(data, &payload); err != nil {
return err
}
e.Message = strings.TrimSpace(payload.Message)
return nil
}
func extractClaudeCLIEventError(event cliStreamEvent) (string, bool) {
if event.Type != "error" && !event.IsError {
return "", false
}
if msg := strings.TrimSpace(event.Result); msg != "" {
return msg, true
}
for _, block := range event.Message.Content {
if block.Type == "text" && strings.TrimSpace(block.Text) != "" {
return strings.TrimSpace(block.Text), true
}
}
if msg := strings.TrimSpace(event.Error.Message); msg != "" {
return msg, true
}
return "claude CLI 返回未知错误", true
}
func isClaudeCLISystemRetryEvent(event cliStreamEvent) bool {
return event.Type == "system" && event.Subtype == "api_retry"
}
func extractClaudeCLISystemRetryError(event cliStreamEvent) (string, bool) {
if !isClaudeCLISystemRetryEvent(event) {
return "", false
}
errText := strings.TrimSpace(event.Error.Message)
if event.ErrorStatus != 401 && event.ErrorStatus != 403 && !strings.EqualFold(errText, "authentication_failed") {
return "", false
}
if errText == "" {
errText = "authentication_failed"
}
if event.ErrorStatus > 0 {
return fmt.Sprintf("claude CLI 鉴权失败 (HTTP %d): %s", event.ErrorStatus, errText), true
}
return fmt.Sprintf("claude CLI 鉴权失败: %s", errText), true
}

View File

@@ -0,0 +1,354 @@
package provider
import (
"context"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"time"
"GoNavi-Wails/internal/ai"
)
func TestBuildClaudeCLIEnv_IncludesAnthropicProxyEnv(t *testing.T) {
env, err := buildClaudeCLIEnv(ai.ProviderConfig{
BaseURL: "https://proxy.example.com/",
APIKey: "sk-test",
}, []string{"PATH=/usr/bin"}, "darwin", func(name string) (string, error) {
return "", errors.New("unexpected lookup")
}, func(path string) bool {
return false
})
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if got := envValue(env, "ANTHROPIC_BASE_URL"); got != "https://proxy.example.com" {
t.Fatalf("expected trimmed base url, got %q", got)
}
if got := envValue(env, "ANTHROPIC_API_KEY"); got != "sk-test" {
t.Fatalf("expected api key in env, got %q", got)
}
if got := envValue(env, "ANTHROPIC_AUTH_TOKEN"); got != "sk-test" {
t.Fatalf("expected auth token in env, got %q", got)
}
}
func TestBuildClaudeCLIEnv_UsesDetectedGitBashOnWindows(t *testing.T) {
env, err := buildClaudeCLIEnv(ai.ProviderConfig{}, []string{"ProgramFiles=C:\\Program Files"}, "windows", func(name string) (string, error) {
switch name {
case "bash.exe":
return "", errors.New("not found")
case "bash":
return "", errors.New("not found")
case "git.exe":
return "C:\\Program Files\\Git\\cmd\\git.exe", nil
default:
return "", errors.New("unexpected lookup")
}
}, func(path string) bool {
return path == `C:\Program Files\Git\bin\bash.exe`
})
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if got := envValue(env, "CLAUDE_CODE_GIT_BASH_PATH"); got != `C:\Program Files\Git\bin\bash.exe` {
t.Fatalf("expected detected git bash path, got %q", got)
}
}
func TestBuildClaudeCLIEnv_ReturnsActionableErrorWhenGitBashMissingOnWindows(t *testing.T) {
_, err := buildClaudeCLIEnv(ai.ProviderConfig{}, []string{"ProgramFiles=C:\\Program Files"}, "windows", func(name string) (string, error) {
return "", errors.New("not found")
}, func(path string) bool {
return false
})
if err == nil {
t.Fatal("expected error when git bash is missing on windows")
}
if !strings.Contains(err.Error(), "git-bash") {
t.Fatalf("expected git-bash hint, got %v", err)
}
if !strings.Contains(err.Error(), "CLAUDE_CODE_GIT_BASH_PATH") {
t.Fatalf("expected env var hint, got %v", err)
}
}
func TestClaudeCLIProvider_ChatTimesOutWhenCommandDoesNotFinish(t *testing.T) {
fakeClaude := writeFakeClaudeScript(t, "#!/bin/sh\nsleep 5\n")
restore := overrideClaudeCLIForTest(t, fakeClaude)
defer restore()
originalRequestTimeout := claudeCLIRequestTimeout
claudeCLIRequestTimeout = 200 * time.Millisecond
defer func() {
claudeCLIRequestTimeout = originalRequestTimeout
}()
provider, err := NewClaudeCLIProvider(ai.ProviderConfig{
BaseURL: "https://coding.dashscope.aliyuncs.com/apps/anthropic",
APIKey: "sk-test",
Model: "qwen3.5-plus",
})
if err != nil {
t.Fatalf("unexpected provider error: %v", err)
}
start := time.Now()
_, err = provider.Chat(context.Background(), ai.ChatRequest{
Messages: []ai.Message{{Role: "user", Content: "ping"}},
})
if err == nil {
t.Fatal("expected chat timeout error")
}
if !strings.Contains(err.Error(), "执行超时") {
t.Fatalf("expected timeout error, got %v", err)
}
if time.Since(start) < 200*time.Millisecond {
t.Fatalf("expected timeout path to wait for configured deadline, took %s", time.Since(start))
}
}
func TestClaudeCLIProvider_ChatStreamUsesRequestTimeoutWhenNoMeaningfulResponseArrives(t *testing.T) {
fakeClaude := writeFakeClaudeScript(t, "#!/bin/sh\necho '{\"type\":\"system\",\"subtype\":\"init\"}'\nexec sleep 5\n")
restore := overrideClaudeCLIForTest(t, fakeClaude)
defer restore()
originalRequestTimeout := claudeCLIRequestTimeout
claudeCLIRequestTimeout = 200 * time.Millisecond
defer func() {
claudeCLIRequestTimeout = originalRequestTimeout
}()
provider, err := NewClaudeCLIProvider(ai.ProviderConfig{
BaseURL: "https://coding.dashscope.aliyuncs.com/apps/anthropic",
APIKey: "sk-test",
Model: "qwen3.5-plus",
})
if err != nil {
t.Fatalf("unexpected provider error: %v", err)
}
var chunks []ai.StreamChunk
err = provider.ChatStream(context.Background(), ai.ChatRequest{
Messages: []ai.Message{{Role: "user", Content: "ping"}},
}, func(chunk ai.StreamChunk) {
chunks = append(chunks, chunk)
})
if err != nil {
t.Fatalf("expected stream provider to report timeout via callback, got %v", err)
}
if len(chunks) == 0 {
t.Fatal("expected timeout chunk")
}
lastChunk := chunks[len(chunks)-1]
if !lastChunk.Done {
t.Fatalf("expected timeout chunk to terminate stream, got %#v", lastChunk)
}
if !strings.Contains(lastChunk.Error, "执行超时") {
t.Fatalf("expected request timeout message, got %#v", lastChunk)
}
}
func TestClaudeCLIProvider_ChatStreamAllowsDelayedMeaningfulResponse(t *testing.T) {
fakeClaude := writeFakeClaudeScript(t, "#!/bin/sh\necho '{\"type\":\"system\",\"subtype\":\"init\"}'\nsleep 0.2\necho '{\"type\":\"assistant\",\"message\":{\"content\":[{\"type\":\"text\",\"text\":\"OK\"}]}}'\necho '{\"type\":\"result\",\"subtype\":\"success\",\"is_error\":false,\"result\":\"OK\"}'\n")
restore := overrideClaudeCLIForTest(t, fakeClaude)
defer restore()
originalRequestTimeout := claudeCLIRequestTimeout
claudeCLIRequestTimeout = 1 * time.Second
defer func() {
claudeCLIRequestTimeout = originalRequestTimeout
}()
provider, err := NewClaudeCLIProvider(ai.ProviderConfig{
BaseURL: "https://coding.dashscope.aliyuncs.com/apps/anthropic",
APIKey: "sk-test",
Model: "qwen3.5-plus",
})
if err != nil {
t.Fatalf("unexpected provider error: %v", err)
}
var chunks []ai.StreamChunk
err = provider.ChatStream(context.Background(), ai.ChatRequest{
Messages: []ai.Message{{Role: "user", Content: "ping"}},
}, func(chunk ai.StreamChunk) {
chunks = append(chunks, chunk)
})
if err != nil {
t.Fatalf("expected delayed response to complete via callback, got %v", err)
}
if len(chunks) == 0 {
t.Fatal("expected delayed response chunks")
}
if chunks[0].Content != "OK" {
t.Fatalf("expected delayed content chunk, got %#v", chunks)
}
if !chunks[len(chunks)-1].Done {
t.Fatalf("expected terminal done chunk, got %#v", chunks[len(chunks)-1])
}
}
func TestClaudeCLIProvider_ChatReturnsErrorWhenJSONResponseIsError(t *testing.T) {
fakeClaude := writeFakeClaudeScript(t, "#!/bin/sh\necho '{\"type\":\"result\",\"subtype\":\"success\",\"is_error\":true,\"result\":\"API Error: Unable to connect to API (ECONNRESET)\",\"error\":\"unknown\"}'\n")
restore := overrideClaudeCLIForTest(t, fakeClaude)
defer restore()
provider, err := NewClaudeCLIProvider(ai.ProviderConfig{
BaseURL: "https://coding.dashscope.aliyuncs.com/apps/anthropic",
APIKey: "sk-test",
Model: "qwen3.5-plus",
})
if err != nil {
t.Fatalf("unexpected provider error: %v", err)
}
_, err = provider.Chat(context.Background(), ai.ChatRequest{
Messages: []ai.Message{{Role: "user", Content: "ping"}},
})
if err == nil {
t.Fatal("expected chat error when CLI JSON marks request as failed")
}
if !strings.Contains(err.Error(), "Unable to connect to API") {
t.Fatalf("expected upstream API error, got %v", err)
}
}
func TestClaudeCLIProvider_ChatStreamReportsAssistantErrorEvent(t *testing.T) {
fakeClaude := writeFakeClaudeScript(t, "#!/bin/sh\necho '{\"type\":\"assistant\",\"is_error\":true,\"message\":{\"content\":[{\"type\":\"text\",\"text\":\"API Error: Unable to connect to API (ECONNRESET)\"}]},\"error\":\"unknown\"}'\n")
restore := overrideClaudeCLIForTest(t, fakeClaude)
defer restore()
provider, err := NewClaudeCLIProvider(ai.ProviderConfig{
BaseURL: "https://coding.dashscope.aliyuncs.com/apps/anthropic",
APIKey: "sk-test",
Model: "qwen3.5-plus",
})
if err != nil {
t.Fatalf("unexpected provider error: %v", err)
}
var chunks []ai.StreamChunk
err = provider.ChatStream(context.Background(), ai.ChatRequest{
Messages: []ai.Message{{Role: "user", Content: "ping"}},
}, func(chunk ai.StreamChunk) {
chunks = append(chunks, chunk)
})
if err != nil {
t.Fatalf("expected stream provider to report error via callback, got %v", err)
}
if len(chunks) != 1 {
t.Fatalf("expected a single terminal error chunk, got %#v", chunks)
}
if chunks[0].Content != "" {
t.Fatalf("expected assistant error event to avoid content output, got %#v", chunks[0])
}
if !chunks[0].Done || !strings.Contains(chunks[0].Error, "Unable to connect to API") {
t.Fatalf("expected upstream API error chunk, got %#v", chunks[0])
}
}
func TestClaudeCLIProvider_ChatStreamReportsResultErrorEvent(t *testing.T) {
fakeClaude := writeFakeClaudeScript(t, "#!/bin/sh\necho '{\"type\":\"result\",\"subtype\":\"success\",\"is_error\":true,\"result\":\"API Error: Unable to connect to API (ECONNRESET)\",\"error\":\"unknown\"}'\n")
restore := overrideClaudeCLIForTest(t, fakeClaude)
defer restore()
provider, err := NewClaudeCLIProvider(ai.ProviderConfig{
BaseURL: "https://coding.dashscope.aliyuncs.com/apps/anthropic",
APIKey: "sk-test",
Model: "qwen3.5-plus",
})
if err != nil {
t.Fatalf("unexpected provider error: %v", err)
}
var chunks []ai.StreamChunk
err = provider.ChatStream(context.Background(), ai.ChatRequest{
Messages: []ai.Message{{Role: "user", Content: "ping"}},
}, func(chunk ai.StreamChunk) {
chunks = append(chunks, chunk)
})
if err != nil {
t.Fatalf("expected stream provider to report error via callback, got %v", err)
}
if len(chunks) != 1 {
t.Fatalf("expected a single terminal error chunk, got %#v", chunks)
}
if chunks[0].Content != "" {
t.Fatalf("expected result error event to avoid content output, got %#v", chunks[0])
}
if !chunks[0].Done || !strings.Contains(chunks[0].Error, "Unable to connect to API") {
t.Fatalf("expected upstream API error chunk, got %#v", chunks[0])
}
}
func TestClaudeCLIProvider_ChatStreamReportsApiRetryAuthenticationFailure(t *testing.T) {
fakeClaude := writeFakeClaudeScript(t, "#!/bin/sh\necho '{\"type\":\"system\",\"subtype\":\"api_retry\",\"attempt\":1,\"max_retries\":10,\"retry_delay_ms\":536.11,\"error_status\":401,\"error\":\"authentication_failed\",\"session_id\":\"retry-1\"}'\nexec sleep 5\n")
restore := overrideClaudeCLIForTest(t, fakeClaude)
defer restore()
provider, err := NewClaudeCLIProvider(ai.ProviderConfig{
BaseURL: "https://coding.dashscope.aliyuncs.com/apps/anthropic",
APIKey: "sk-test",
Model: "qwen3.5-plus",
})
if err != nil {
t.Fatalf("unexpected provider error: %v", err)
}
var chunks []ai.StreamChunk
err = provider.ChatStream(context.Background(), ai.ChatRequest{
Messages: []ai.Message{{Role: "user", Content: "ping"}},
}, func(chunk ai.StreamChunk) {
chunks = append(chunks, chunk)
})
if err != nil {
t.Fatalf("expected stream provider to report authentication error via callback, got %v", err)
}
if len(chunks) != 1 {
t.Fatalf("expected a single terminal error chunk, got %#v", chunks)
}
if !chunks[0].Done {
t.Fatalf("expected terminal error chunk, got %#v", chunks[0])
}
if strings.Contains(chunks[0].Error, "未收到模型响应") {
t.Fatalf("expected auth failure instead of startup timeout, got %#v", chunks[0])
}
if !strings.Contains(chunks[0].Error, "401") || !strings.Contains(chunks[0].Error, "authentication_failed") {
t.Fatalf("expected auth retry error details, got %#v", chunks[0])
}
}
func writeFakeClaudeScript(t *testing.T, content string) string {
t.Helper()
dir := t.TempDir()
path := filepath.Join(dir, "claude")
if err := os.WriteFile(path, []byte(content), 0o755); err != nil {
t.Fatalf("failed to write fake claude script: %v", err)
}
return path
}
func overrideClaudeCLIForTest(t *testing.T, fakeClaudePath string) func() {
t.Helper()
originalLookPath := claudeLookPath
claudeLookPath = func(name string) (string, error) {
if name == "claude" {
return fakeClaudePath, nil
}
return originalLookPath(name)
}
originalPath := os.Getenv("PATH")
if err := os.Setenv("PATH", filepath.Dir(fakeClaudePath)+string(os.PathListSeparator)+originalPath); err != nil {
t.Fatalf("failed to override PATH: %v", err)
}
return func() {
claudeLookPath = originalLookPath
_ = os.Setenv("PATH", originalPath)
}
}

View File

@@ -0,0 +1,74 @@
package provider
import (
"context"
"fmt"
"strings"
"GoNavi-Wails/internal/ai"
)
// CustomProvider 自定义 Provider根据 apiFormat 选择底层协议
// 支持 openai / anthropic / gemini 三种 API 格式
type CustomProvider struct {
inner Provider
name string
}
// NewCustomProvider 创建自定义 Provider 实例
func NewCustomProvider(config ai.ProviderConfig) (Provider, error) {
if strings.TrimSpace(config.BaseURL) == "" {
return nil, fmt.Errorf("自定义 Provider 必须指定 Base URL")
}
// 根据 apiFormat 决定使用哪个底层协议,默认 openai
apiFormat := strings.ToLower(strings.TrimSpace(config.APIFormat))
if apiFormat == "" {
apiFormat = "openai"
}
var innerProvider Provider
var err error
switch apiFormat {
case "anthropic":
innerProvider, err = NewAnthropicProvider(config)
case "gemini":
innerProvider, err = NewGeminiProvider(config)
case "claude-cli":
innerProvider, err = NewClaudeCLIProvider(config)
default: // "openai" 及其他
innerProvider, err = NewOpenAIProvider(config)
}
if err != nil {
return nil, err
}
name := strings.TrimSpace(config.Name)
if name == "" {
name = "Custom"
}
return &CustomProvider{
inner: innerProvider,
name: name,
}, nil
}
func (p *CustomProvider) Name() string {
return p.name
}
func (p *CustomProvider) Validate() error {
if strings.TrimSpace(p.inner.(interface{ Name() string }).Name()) == "" {
// 对自定义 ProviderAPI Key 可选(部分本地服务不需要)
}
return nil
}
func (p *CustomProvider) Chat(ctx context.Context, req ai.ChatRequest) (*ai.ChatResponse, error) {
return p.inner.Chat(ctx, req)
}
func (p *CustomProvider) ChatStream(ctx context.Context, req ai.ChatRequest, callback func(ai.StreamChunk)) error {
return p.inner.ChatStream(ctx, req, callback)
}

View File

@@ -0,0 +1,293 @@
package provider
import (
"bufio"
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"GoNavi-Wails/internal/ai"
)
const (
defaultGeminiBaseURL = "https://generativelanguage.googleapis.com"
)
// GeminiProvider 实现 Google Gemini API 的 Provider
type GeminiProvider struct {
config ai.ProviderConfig
baseURL string
client *http.Client
}
// NewGeminiProvider 创建 Gemini Provider 实例
func NewGeminiProvider(config ai.ProviderConfig) (Provider, error) {
baseURL := strings.TrimRight(strings.TrimSpace(config.BaseURL), "/")
if baseURL == "" {
baseURL = defaultGeminiBaseURL
}
model := strings.TrimSpace(config.Model)
if model == "" {
return nil, fmt.Errorf("模型 ID 不能为空,请在设置中选择或输入模型")
}
maxTokens := config.MaxTokens
if maxTokens <= 0 {
maxTokens = defaultOpenAIMaxTokens
}
temperature := config.Temperature
if temperature <= 0 {
temperature = defaultOpenAITemperature
}
normalized := config
normalized.BaseURL = baseURL
normalized.Model = model
normalized.MaxTokens = maxTokens
normalized.Temperature = temperature
return &GeminiProvider{
config: normalized,
baseURL: baseURL,
client: &http.Client{Timeout: openAIHTTPTimeout},
}, nil
}
func (p *GeminiProvider) Name() string {
if strings.TrimSpace(p.config.Name) != "" {
return p.config.Name
}
return "Gemini"
}
func (p *GeminiProvider) Validate() error {
if strings.TrimSpace(p.config.APIKey) == "" {
return fmt.Errorf("API Key 不能为空")
}
return nil
}
type geminiRequest struct {
Contents []geminiContent `json:"contents"`
SystemInstruction *geminiContent `json:"systemInstruction,omitempty"`
GenerationConfig geminiGenConfig `json:"generationConfig,omitempty"`
}
type geminiContent struct {
Role string `json:"role,omitempty"`
Parts []geminiPart `json:"parts"`
}
type geminiPart struct {
Text string `json:"text,omitempty"`
InlineData *geminiBlob `json:"inlineData,omitempty"`
}
type geminiBlob struct {
MimeType string `json:"mimeType"`
Data string `json:"data"`
}
type geminiGenConfig struct {
Temperature float64 `json:"temperature,omitempty"`
MaxOutputTokens int `json:"maxOutputTokens,omitempty"`
}
type geminiResponse struct {
Candidates []struct {
Content struct {
Parts []struct {
Text string `json:"text"`
} `json:"parts"`
} `json:"content"`
} `json:"candidates"`
UsageMetadata *struct {
PromptTokenCount int `json:"promptTokenCount"`
CandidatesTokenCount int `json:"candidatesTokenCount"`
TotalTokenCount int `json:"totalTokenCount"`
} `json:"usageMetadata"`
Error *struct {
Message string `json:"message"`
} `json:"error,omitempty"`
}
func (p *GeminiProvider) Chat(ctx context.Context, req ai.ChatRequest) (*ai.ChatResponse, error) {
if err := p.Validate(); err != nil {
return nil, err
}
geminiReq := p.buildRequest(req)
url := fmt.Sprintf("%s/v1beta/models/%s:generateContent?key=%s",
p.baseURL, p.config.Model, p.config.APIKey)
respBody, err := p.doRequest(ctx, url, geminiReq)
if err != nil {
return nil, err
}
defer respBody.Close()
var result geminiResponse
if err := json.NewDecoder(respBody).Decode(&result); err != nil {
return nil, fmt.Errorf("解析 Gemini 响应失败: %w", err)
}
if result.Error != nil && result.Error.Message != "" {
return nil, fmt.Errorf("Gemini API 错误: %s", result.Error.Message)
}
if len(result.Candidates) == 0 || len(result.Candidates[0].Content.Parts) == 0 {
return nil, fmt.Errorf("Gemini 返回空响应")
}
var tokens ai.TokenUsage
if result.UsageMetadata != nil {
tokens = ai.TokenUsage{
PromptTokens: result.UsageMetadata.PromptTokenCount,
CompletionTokens: result.UsageMetadata.CandidatesTokenCount,
TotalTokens: result.UsageMetadata.TotalTokenCount,
}
}
var textParts []string
for _, part := range result.Candidates[0].Content.Parts {
if part.Text != "" {
textParts = append(textParts, part.Text)
}
}
return &ai.ChatResponse{
Content: strings.Join(textParts, ""),
TokensUsed: tokens,
}, nil
}
func (p *GeminiProvider) ChatStream(ctx context.Context, req ai.ChatRequest, callback func(ai.StreamChunk)) error {
if err := p.Validate(); err != nil {
return err
}
geminiReq := p.buildRequest(req)
url := fmt.Sprintf("%s/v1beta/models/%s:streamGenerateContent?alt=sse&key=%s",
p.baseURL, p.config.Model, p.config.APIKey)
respBody, err := p.doRequest(ctx, url, geminiReq)
if err != nil {
return err
}
defer respBody.Close()
scanner := bufio.NewScanner(respBody)
for scanner.Scan() {
line := scanner.Text()
if !strings.HasPrefix(line, "data: ") {
continue
}
data := strings.TrimPrefix(line, "data: ")
var chunk geminiResponse
if err := json.Unmarshal([]byte(data), &chunk); err != nil {
continue
}
if len(chunk.Candidates) > 0 && len(chunk.Candidates[0].Content.Parts) > 0 {
for _, part := range chunk.Candidates[0].Content.Parts {
if part.Text != "" {
callback(ai.StreamChunk{Content: part.Text})
}
}
}
}
callback(ai.StreamChunk{Done: true})
return scanner.Err()
}
func (p *GeminiProvider) buildRequest(req ai.ChatRequest) geminiRequest {
temperature := req.Temperature
if temperature <= 0 {
temperature = p.config.Temperature
}
var systemInstruction *geminiContent
var contents []geminiContent
for _, m := range req.Messages {
if m.Role == "system" {
systemInstruction = &geminiContent{
Parts: []geminiPart{{Text: m.Content}},
}
continue
}
role := m.Role
if role == "assistant" {
role = "model"
}
var parts []geminiPart
text := m.Content
if text == "" && len(m.Images) > 0 {
text = "请描述和分析这张图片。" // 同样避免 Gemini 认为意图不明确
}
if text != "" {
parts = append(parts, geminiPart{Text: text})
}
for _, img := range m.Images {
mimeType, rawBase64, err := ParseDataURI(img)
if err == nil {
parts = append(parts, geminiPart{
InlineData: &geminiBlob{
MimeType: mimeType,
Data: rawBase64,
},
})
}
}
contents = append(contents, geminiContent{
Role: role,
Parts: parts,
})
}
return geminiRequest{
Contents: contents,
SystemInstruction: systemInstruction,
GenerationConfig: geminiGenConfig{
Temperature: temperature,
},
}
}
func (p *GeminiProvider) doRequest(ctx context.Context, url string, body interface{}) (io.ReadCloser, error) {
jsonBody, err := json.Marshal(body)
if err != nil {
return nil, fmt.Errorf("序列化请求失败: %w", err)
}
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(jsonBody))
if err != nil {
return nil, fmt.Errorf("创建 HTTP 请求失败: %w", err)
}
httpReq.Header.Set("Content-Type", "application/json")
if strings.Contains(url, "alt=sse") {
httpReq.Header.Set("Accept", "text/event-stream")
httpReq.Header.Set("Cache-Control", "no-cache")
httpReq.Header.Set("Connection", "keep-alive")
}
resp, err := p.client.Do(httpReq)
if err != nil {
return nil, fmt.Errorf("发送请求到 Gemini 失败: %w", err)
}
if resp.StatusCode != http.StatusOK {
defer resp.Body.Close()
bodyBytes, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("Gemini API 返回错误 (HTTP %d): %s", resp.StatusCode, string(bodyBytes))
}
return resp.Body, nil
}

View File

@@ -0,0 +1,97 @@
package provider
import (
"fmt"
"net/url"
"regexp"
"strings"
)
var openAICompatibleVersionSuffixPattern = regexp.MustCompile(`(?i)(^|/)v\d+$`)
// ParseDataURI 解析前端传递的 Data URI返回 mimeType 和去掉前缀的 rawBase64
func ParseDataURI(dataURI string) (mimeType, rawBase64 string, err error) {
if !strings.HasPrefix(dataURI, "data:") {
// 如果前端漏了前缀,默认容错当做 jpeg 处理
return "image/jpeg", dataURI, nil
}
parts := strings.SplitN(dataURI, ",", 2)
if len(parts) != 2 {
return "", "", fmt.Errorf("invalid data URI format")
}
meta := strings.TrimPrefix(parts[0], "data:")
metaParts := strings.Split(meta, ";")
mimeType = metaParts[0]
if mimeType == "" {
mimeType = "image/jpeg" // fallback
}
rawBase64 = parts[1]
return mimeType, rawBase64, nil
}
// NormalizeOpenAICompatibleBaseURL 统一归一化 OpenAI 兼容服务的 base URL。
func NormalizeOpenAICompatibleBaseURL(raw string) string {
trimmed := strings.TrimSpace(raw)
if trimmed == "" {
return defaultOpenAIBaseURL
}
parsed, err := url.Parse(trimmed)
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
return normalizeOpenAICompatibleBaseURLString(trimmed)
}
parsed.RawQuery = ""
parsed.Fragment = ""
parsed.Path = normalizeOpenAICompatiblePath(parsed.Path)
return strings.TrimRight(parsed.String(), "/")
}
// ResolveOpenAICompatibleEndpoint 基于归一化 base URL 拼接 OpenAI 兼容接口路径。
func ResolveOpenAICompatibleEndpoint(baseURL string, endpoint string) string {
normalizedBaseURL := NormalizeOpenAICompatibleBaseURL(baseURL)
normalizedEndpoint := strings.TrimLeft(strings.TrimSpace(endpoint), "/")
if normalizedEndpoint == "" {
return normalizedBaseURL
}
return normalizedBaseURL + "/" + normalizedEndpoint
}
func normalizeOpenAICompatibleBaseURLString(raw string) string {
normalized := strings.TrimRight(strings.TrimSpace(raw), "/")
if normalized == "" {
return defaultOpenAIBaseURL
}
lower := strings.ToLower(normalized)
switch {
case strings.HasSuffix(lower, "/chat/completions"):
normalized = normalized[:len(normalized)-len("/chat/completions")]
case strings.HasSuffix(lower, "/models"):
normalized = normalized[:len(normalized)-len("/models")]
}
normalized = strings.TrimRight(normalized, "/")
if openAICompatibleVersionSuffixPattern.MatchString(normalized) {
return normalized
}
return normalized + "/v1"
}
func normalizeOpenAICompatiblePath(path string) string {
normalized := strings.TrimRight(strings.TrimSpace(path), "/")
lower := strings.ToLower(normalized)
switch {
case strings.HasSuffix(lower, "/chat/completions"):
normalized = normalized[:len(normalized)-len("/chat/completions")]
case strings.HasSuffix(lower, "/models"):
normalized = normalized[:len(normalized)-len("/models")]
}
normalized = strings.TrimRight(normalized, "/")
if openAICompatibleVersionSuffixPattern.MatchString(normalized) {
return normalized
}
if normalized == "" {
return "/v1"
}
return normalized + "/v1"
}

View File

@@ -0,0 +1,424 @@
package provider
import (
"bufio"
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
"GoNavi-Wails/internal/ai"
)
const (
defaultOpenAIBaseURL = "https://api.openai.com/v1"
defaultOpenAIMaxTokens = 4096
defaultOpenAITemperature = 0.7
openAIHTTPTimeout = 120 * time.Second
)
// OpenAIProvider 实现 OpenAI / OpenAI 兼容 API 的 Provider
type OpenAIProvider struct {
config ai.ProviderConfig
baseURL string
client *http.Client
}
// NewOpenAIProvider 创建 OpenAI Provider 实例
func NewOpenAIProvider(config ai.ProviderConfig) (Provider, error) {
baseURL := NormalizeOpenAICompatibleBaseURL(config.BaseURL)
model := strings.TrimSpace(config.Model)
if model == "" {
return nil, fmt.Errorf("模型 ID 不能为空,请在设置中选择或输入模型")
}
maxTokens := config.MaxTokens
if maxTokens <= 0 {
maxTokens = defaultOpenAIMaxTokens
}
temperature := config.Temperature
if temperature <= 0 {
temperature = defaultOpenAITemperature
}
normalized := config
normalized.BaseURL = baseURL
normalized.Model = model
normalized.MaxTokens = maxTokens
normalized.Temperature = temperature
return &OpenAIProvider{
config: normalized,
baseURL: baseURL,
client: &http.Client{
Timeout: openAIHTTPTimeout,
},
}, nil
}
func (p *OpenAIProvider) Name() string {
if strings.TrimSpace(p.config.Name) != "" {
return p.config.Name
}
return "OpenAI"
}
func (p *OpenAIProvider) Validate() error {
if strings.TrimSpace(p.config.APIKey) == "" {
return fmt.Errorf("API Key 不能为空")
}
return nil
}
// openAIChatRequest OpenAI API 请求体
type openAIChatRequest struct {
Model string `json:"model"`
Messages []openAIChatMessage `json:"messages"`
Temperature float64 `json:"temperature,omitempty"`
MaxTokens int `json:"max_tokens,omitempty"`
Stream bool `json:"stream,omitempty"`
Tools []ai.Tool `json:"tools,omitempty"`
}
type openAIChatMessage struct {
Role string `json:"role"`
Content interface{} `json:"content,omitempty"`
ToolCalls []ai.ToolCall `json:"tool_calls,omitempty"`
ToolCallID string `json:"tool_call_id,omitempty"`
}
func buildOpenAIMessages(reqMessages []ai.Message, modelName string, baseURL string) []openAIChatMessage {
messages := make([]openAIChatMessage, len(reqMessages))
for i, m := range reqMessages {
if m.Role == "tool" {
messages[i] = openAIChatMessage{Role: m.Role, Content: m.Content, ToolCallID: m.ToolCallID}
continue
}
if len(m.ToolCalls) > 0 {
messages[i] = openAIChatMessage{Role: m.Role, Content: m.Content, ToolCalls: m.ToolCalls}
continue
}
if len(m.Images) > 0 {
var contentParts []map[string]interface{}
text := m.Content
if text == "" {
text = "请描述和分析这张图片。" // 兼容部分模型(如 ZhipuAI/GLM-4V强制要求图片必须伴随有效文本块同时防止强 System Prompt 下模型当成空消息处理
}
contentParts = append(contentParts, map[string]interface{}{
"type": "text",
"text": text,
})
for _, img := range m.Images {
imgURL := img
// 仅当直接请求智谱官方 API 域名时(它原生不接受 data 协议前缀),才截取裸 Base64
if strings.Contains(strings.ToLower(baseURL), "bigmodel") {
if _, raw, err := ParseDataURI(img); err == nil {
imgURL = raw
}
}
contentParts = append(contentParts, map[string]interface{}{
"type": "image_url",
"image_url": map[string]interface{}{
"url": imgURL,
},
})
}
messages[i] = openAIChatMessage{Role: m.Role, Content: contentParts}
} else {
messages[i] = openAIChatMessage{Role: m.Role, Content: m.Content}
}
}
return messages
}
// openAIChatResponse OpenAI API 响应体
type openAIChatResponse struct {
Choices []struct {
Message struct {
Content string `json:"content"`
ToolCalls []ai.ToolCall `json:"tool_calls,omitempty"`
} `json:"message"`
FinishReason string `json:"finish_reason"`
} `json:"choices"`
Usage struct {
PromptTokens int `json:"prompt_tokens"`
CompletionTokens int `json:"completion_tokens"`
TotalTokens int `json:"total_tokens"`
} `json:"usage"`
Error *struct {
Message string `json:"message"`
} `json:"error,omitempty"`
}
// openAIStreamChunk SSE 流式响应片段
type openAIToolCallDelta struct {
Index int `json:"index"`
ID string `json:"id,omitempty"`
Type string `json:"type,omitempty"`
Function *struct {
Name string `json:"name,omitempty"`
Arguments string `json:"arguments,omitempty"`
} `json:"function,omitempty"`
}
type openAIStreamChunk struct {
Choices []struct {
Delta struct {
Content string `json:"content"`
ReasoningContent string `json:"reasoning_content"`
ToolCalls []openAIToolCallDelta `json:"tool_calls,omitempty"`
} `json:"delta"`
FinishReason *string `json:"finish_reason"`
} `json:"choices"`
Error *struct {
Message string `json:"message"`
} `json:"error,omitempty"`
}
func (p *OpenAIProvider) Chat(ctx context.Context, req ai.ChatRequest) (*ai.ChatResponse, error) {
if err := p.Validate(); err != nil {
return nil, err
}
messages := buildOpenAIMessages(req.Messages, p.config.Model, p.baseURL)
temperature := req.Temperature
if temperature <= 0 {
temperature = p.config.Temperature
}
body := openAIChatRequest{
Model: p.config.Model,
Messages: messages,
Temperature: temperature,
Stream: false,
Tools: req.Tools,
}
respBody, err := p.doRequest(ctx, body)
if err != nil {
// 当带 tools 的请求返回 400 时,自动降级为不带 tools 的纯文本请求
if len(req.Tools) > 0 && isHTTP400Error(err) {
fmt.Println("[OpenAI] 模型不支持 Function Calling自动降级为纯文本模式")
body.Tools = nil
respBody, err = p.doRequest(ctx, body)
if err != nil {
return nil, err
}
} else {
return nil, err
}
}
defer respBody.Close()
var result openAIChatResponse
if err := json.NewDecoder(respBody).Decode(&result); err != nil {
return nil, fmt.Errorf("解析 OpenAI 响应失败: %w", err)
}
if result.Error != nil && result.Error.Message != "" {
return nil, fmt.Errorf("OpenAI API 错误: %s", result.Error.Message)
}
if len(result.Choices) == 0 {
return nil, fmt.Errorf("OpenAI 返回空响应")
}
return &ai.ChatResponse{
Content: result.Choices[0].Message.Content,
TokensUsed: ai.TokenUsage{
PromptTokens: result.Usage.PromptTokens,
CompletionTokens: result.Usage.CompletionTokens,
TotalTokens: result.Usage.TotalTokens,
},
ToolCalls: result.Choices[0].Message.ToolCalls,
}, nil
}
func (p *OpenAIProvider) ChatStream(ctx context.Context, req ai.ChatRequest, callback func(ai.StreamChunk)) error {
if err := p.Validate(); err != nil {
return err
}
messages := buildOpenAIMessages(req.Messages, p.config.Model, p.baseURL)
temperature := req.Temperature
if temperature <= 0 {
temperature = p.config.Temperature
}
body := openAIChatRequest{
Model: p.config.Model,
Messages: messages,
Temperature: temperature,
Stream: true,
Tools: req.Tools,
}
respBody, err := p.doRequest(ctx, body)
if err != nil {
// 当带 tools 的请求返回 400 时,自动降级为不带 tools 的纯文本请求
if len(req.Tools) > 0 && isHTTP400Error(err) {
fmt.Println("[OpenAI] 模型不支持 Function Calling自动降级为纯文本模式")
body.Tools = nil
respBody, err = p.doRequest(ctx, body)
if err != nil {
return err
}
} else {
return err
}
}
defer respBody.Close()
receivedContent := false
var activeToolCalls []ai.ToolCall
scanner := bufio.NewScanner(respBody)
// 增大 scanner buffer防止长行被截断
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
for scanner.Scan() {
line := scanner.Text()
if line == "" {
continue
}
if !strings.HasPrefix(line, "data: ") {
// 非 SSE 数据行,可能是错误信息,记录日志
if strings.Contains(line, "error") || strings.Contains(line, "Error") {
callback(ai.StreamChunk{Error: fmt.Sprintf("服务端返回异常: %s", line), Done: true})
return nil
}
continue
}
data := strings.TrimPrefix(line, "data: ")
if data == "[DONE]" {
callback(ai.StreamChunk{Done: true})
return nil
}
var chunk openAIStreamChunk
if err := json.Unmarshal([]byte(data), &chunk); err != nil {
continue // 跳过格式异常的行
}
if chunk.Error != nil && chunk.Error.Message != "" {
callback(ai.StreamChunk{Error: fmt.Sprintf("API 错误: %s", chunk.Error.Message), Done: true})
return nil
}
if len(chunk.Choices) > 0 {
choice := chunk.Choices[0]
// Handle ToolCalls delta
if len(choice.Delta.ToolCalls) > 0 {
receivedContent = true
for _, tcDelta := range choice.Delta.ToolCalls {
// Expand activeToolCalls slice if index is larger
for len(activeToolCalls) <= tcDelta.Index {
activeToolCalls = append(activeToolCalls, ai.ToolCall{Type: "function"})
}
if tcDelta.ID != "" {
activeToolCalls[tcDelta.Index].ID = tcDelta.ID
}
if tcDelta.Function != nil {
if tcDelta.Function.Name != "" {
activeToolCalls[tcDelta.Index].Function.Name += tcDelta.Function.Name
}
if tcDelta.Function.Arguments != "" {
activeToolCalls[tcDelta.Index].Function.Arguments += tcDelta.Function.Arguments
}
}
}
// 实时推送目前已解析的 ToolCalls 状态
callback(ai.StreamChunk{ToolCalls: activeToolCalls})
}
content := choice.Delta.Content
if content != "" {
receivedContent = true
callback(ai.StreamChunk{Content: content})
}
// 支持 DeepSeek/千问等模型的 reasoning_content 字段
if choice.Delta.ReasoningContent != "" {
receivedContent = true
callback(ai.StreamChunk{Thinking: choice.Delta.ReasoningContent})
}
if choice.FinishReason != nil {
if *choice.FinishReason == "tool_calls" {
callback(ai.StreamChunk{ToolCalls: activeToolCalls, Done: true})
return nil
}
callback(ai.StreamChunk{Done: true})
return nil
}
}
}
if err := scanner.Err(); err != nil {
return fmt.Errorf("读取 OpenAI 流式响应失败: %w", err)
}
// 如果流正常结束但没有收到任何内容,可能是 API 响应格式不兼容
if !receivedContent {
callback(ai.StreamChunk{Error: "未收到任何有效响应内容,请检查 API 端点和模型是否正确", Done: true})
return nil
}
callback(ai.StreamChunk{Done: true})
return nil
}
func (p *OpenAIProvider) doRequest(ctx context.Context, body interface{}) (io.ReadCloser, error) {
jsonBody, err := json.Marshal(body)
if err != nil {
return nil, fmt.Errorf("序列化请求失败: %w", err)
}
url := ResolveOpenAICompatibleEndpoint(p.baseURL, "chat/completions")
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(jsonBody))
if err != nil {
return nil, fmt.Errorf("创建 HTTP 请求失败: %w", err)
}
httpReq.Header.Set("Content-Type", "application/json")
httpReq.Header.Set("Authorization", "Bearer "+p.config.APIKey)
// 仅在流式请求时明确声明 SSE防止代理缓冲
if strings.Contains(string(jsonBody), `"stream":true`) || strings.Contains(string(jsonBody), `"stream": true`) {
httpReq.Header.Set("Accept", "text/event-stream")
httpReq.Header.Set("Cache-Control", "no-cache")
httpReq.Header.Set("Connection", "keep-alive")
}
// 自定义 headers用于兼容各类 OpenAI 兼容服务)
for k, v := range p.config.Headers {
httpReq.Header.Set(k, v)
}
resp, err := p.client.Do(httpReq)
if err != nil {
return nil, fmt.Errorf("发送请求到 %s 失败: %w", url, err)
}
if resp.StatusCode != http.StatusOK {
defer resp.Body.Close()
bodyBytes, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("OpenAI API 返回错误 (HTTP %d): %s", resp.StatusCode, string(bodyBytes))
}
return resp.Body, nil
}
// isHTTP400Error 检查错误是否为 HTTP 4xx 客户端错误400/422 等),
// 通常表示模型不支持请求中的某些参数(如 tools/functions
func isHTTP400Error(err error) bool {
if err == nil {
return false
}
msg := err.Error()
return strings.Contains(msg, "(HTTP 400)") ||
strings.Contains(msg, "(HTTP 422)") ||
strings.Contains(msg, "(HTTP 404)")
}

View File

@@ -0,0 +1,167 @@
package provider
import (
"GoNavi-Wails/internal/ai"
"testing"
)
func TestNormalizeOpenAICompatibleBaseURL(t *testing.T) {
tests := []struct {
name string
raw string
want string
}{
{
name: "empty uses default openai base url",
raw: "",
want: "https://api.openai.com/v1",
},
{
name: "domain only appends v1",
raw: "https://api.openai.com",
want: "https://api.openai.com/v1",
},
{
name: "keeps existing v1 suffix",
raw: "https://api.deepseek.com/v1",
want: "https://api.deepseek.com/v1",
},
{
name: "keeps dashscope compatible mode path",
raw: "https://dashscope.aliyuncs.com/compatible-mode/v1",
want: "https://dashscope.aliyuncs.com/compatible-mode/v1",
},
{
name: "keeps zhipu v4 path",
raw: "https://open.bigmodel.cn/api/paas/v4",
want: "https://open.bigmodel.cn/api/paas/v4",
},
{
name: "keeps volcengine ark v3 path",
raw: "https://ark.cn-beijing.volces.com/api/v3",
want: "https://ark.cn-beijing.volces.com/api/v3",
},
{
name: "keeps volcengine coding plan v3 path",
raw: "https://ark.cn-beijing.volces.com/api/coding/v3",
want: "https://ark.cn-beijing.volces.com/api/coding/v3",
},
{
name: "strips chat completions suffix before normalizing",
raw: "https://api.openai.com/v1/chat/completions",
want: "https://api.openai.com/v1",
},
{
name: "strips models suffix before normalizing",
raw: "https://ark.cn-beijing.volces.com/api/coding/v3/models",
want: "https://ark.cn-beijing.volces.com/api/coding/v3",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := NormalizeOpenAICompatibleBaseURL(tt.raw); got != tt.want {
t.Fatalf("expected normalized base url %q, got %q", tt.want, got)
}
})
}
}
func TestResolveOpenAICompatibleEndpoint(t *testing.T) {
got := ResolveOpenAICompatibleEndpoint("https://ark.cn-beijing.volces.com/api/coding/v3/models", "chat/completions")
want := "https://ark.cn-beijing.volces.com/api/coding/v3/chat/completions"
if got != want {
t.Fatalf("expected endpoint %q, got %q", want, got)
}
}
func TestOpenAIProvider_Validate_MissingAPIKey(t *testing.T) {
p, err := NewOpenAIProvider(ai.ProviderConfig{Type: "openai", Model: "gpt-4o"})
if err != nil {
t.Fatalf("unexpected constructor error: %v", err)
}
if err := p.Validate(); err == nil {
t.Fatal("expected validation error for missing API key")
}
}
func TestOpenAIProvider_Validate_Valid(t *testing.T) {
p, err := NewOpenAIProvider(ai.ProviderConfig{
Type: "openai", APIKey: "sk-test-key", Model: "gpt-4o",
})
if err != nil {
t.Fatalf("unexpected constructor error: %v", err)
}
if err := p.Validate(); err != nil {
t.Fatalf("unexpected validation error: %v", err)
}
}
func TestOpenAIProvider_Name_Custom(t *testing.T) {
p, err := NewOpenAIProvider(ai.ProviderConfig{
Type: "openai", Name: "My OpenAI", APIKey: "sk-test", Model: "gpt-4o",
})
if err != nil {
t.Fatalf("unexpected constructor error: %v", err)
}
if p.Name() != "My OpenAI" {
t.Fatalf("expected name 'My OpenAI', got '%s'", p.Name())
}
}
func TestOpenAIProvider_Name_Default(t *testing.T) {
p, err := NewOpenAIProvider(ai.ProviderConfig{
Type: "openai", APIKey: "sk-test", Model: "gpt-4o",
})
if err != nil {
t.Fatalf("unexpected constructor error: %v", err)
}
if p.Name() != "OpenAI" {
t.Fatalf("expected default name 'OpenAI', got '%s'", p.Name())
}
}
func TestOpenAIProvider_DefaultBaseURL(t *testing.T) {
p, _ := NewOpenAIProvider(ai.ProviderConfig{
Type: "openai", APIKey: "sk-test", Model: "gpt-4o",
})
op := p.(*OpenAIProvider)
if op.baseURL != "https://api.openai.com/v1" {
t.Fatalf("expected default base URL, got '%s'", op.baseURL)
}
}
func TestOpenAIProvider_CustomBaseURL(t *testing.T) {
p, err := NewOpenAIProvider(ai.ProviderConfig{
Type: "openai", APIKey: "sk-test", BaseURL: "https://my-proxy.com/v1", Model: "gpt-4o",
})
if err != nil {
t.Fatalf("unexpected constructor error: %v", err)
}
op := p.(*OpenAIProvider)
if op.baseURL != "https://my-proxy.com/v1" {
t.Fatalf("expected custom base URL, got '%s'", op.baseURL)
}
}
func TestOpenAIProvider_RejectsMissingModel(t *testing.T) {
_, err := NewOpenAIProvider(ai.ProviderConfig{
Type: "openai", APIKey: "sk-test",
})
if err == nil {
t.Fatal("expected constructor error for missing model")
}
}
func TestOpenAIProvider_DefaultMaxTokens(t *testing.T) {
p, err := NewOpenAIProvider(ai.ProviderConfig{
Type: "openai", APIKey: "sk-test", Model: "gpt-4o",
})
if err != nil {
t.Fatalf("unexpected constructor error: %v", err)
}
op := p.(*OpenAIProvider)
if op.config.MaxTokens != 4096 {
t.Fatalf("expected default max tokens 4096, got %d", op.config.MaxTokens)
}
}

View File

@@ -0,0 +1,19 @@
package provider
import (
"context"
"GoNavi-Wails/internal/ai"
)
// Provider AI 模型提供者接口
type Provider interface {
// Chat 发送消息并获取完整响应
Chat(ctx context.Context, req ai.ChatRequest) (*ai.ChatResponse, error)
// ChatStream 发送消息并以流式返回
ChatStream(ctx context.Context, req ai.ChatRequest, callback func(ai.StreamChunk)) error
// Name 返回 Provider 名称
Name() string
// Validate 校验配置是否有效
Validate() error
}

View File

@@ -0,0 +1,25 @@
package provider
import (
"fmt"
"strings"
"GoNavi-Wails/internal/ai"
)
// NewProvider 根据配置创建 Provider 实例
func NewProvider(config ai.ProviderConfig) (Provider, error) {
providerType := strings.ToLower(strings.TrimSpace(config.Type))
switch providerType {
case "openai":
return NewOpenAIProvider(config)
case "anthropic":
return NewAnthropicProvider(config)
case "gemini":
return NewGeminiProvider(config)
case "custom":
return NewCustomProvider(config)
default:
return nil, fmt.Errorf("不支持的 AI Provider 类型: %s", config.Type)
}
}

View File

@@ -0,0 +1,101 @@
package safety
import (
"strings"
"unicode"
"GoNavi-Wails/internal/ai"
)
// ClassifySQL 分类 SQL 语句的操作类型
func ClassifySQL(sql string) ai.SQLOperationType {
keyword := leadingSQLKeyword(sql)
switch keyword {
case "select", "with", "show", "describe", "desc", "explain", "pragma", "values":
return ai.SQLOpQuery
case "insert", "update", "delete", "replace", "merge", "upsert":
return ai.SQLOpDML
case "create", "alter", "drop", "truncate", "rename":
return ai.SQLOpDDL
default:
return ai.SQLOpOther
}
}
// IsHighRiskSQL 判断 SQL 是否为高风险语句
func IsHighRiskSQL(sql string) (bool, string) {
keyword := leadingSQLKeyword(sql)
normalized := strings.ToLower(sql)
switch keyword {
case "drop":
return true, "⚠️ 高危操作DROP 语句将永久删除数据库对象"
case "truncate":
return true, "⚠️ 高危操作TRUNCATE 将清空表中所有数据"
case "delete":
if !containsWhereClause(normalized) {
return true, "⚠️ 高危操作DELETE 语句缺少 WHERE 条件,将删除所有数据"
}
case "update":
if !containsWhereClause(normalized) {
return true, "⚠️ 高危操作UPDATE 语句缺少 WHERE 条件,将更新所有记录"
}
}
return false, ""
}
// containsWhereClause 简单判断 SQL 是否包含 WHERE 子句
func containsWhereClause(normalizedSQL string) bool {
return strings.Contains(normalizedSQL, " where ") ||
strings.Contains(normalizedSQL, "\nwhere ") ||
strings.Contains(normalizedSQL, "\twhere ")
}
// leadingSQLKeyword 提取 SQL 语句的首个关键字(跳过注释和空白)
func leadingSQLKeyword(query string) string {
text := strings.TrimSpace(query)
for len(text) > 0 {
trimmed := strings.TrimLeft(text, " \t\r\n")
if trimmed == "" {
return ""
}
text = trimmed
switch {
case strings.HasPrefix(text, "--"):
if idx := strings.IndexByte(text, '\n'); idx >= 0 {
text = text[idx+1:]
continue
}
return ""
case strings.HasPrefix(text, "#"):
if idx := strings.IndexByte(text, '\n'); idx >= 0 {
text = text[idx+1:]
continue
}
return ""
case strings.HasPrefix(text, "/*"):
if idx := strings.Index(text, "*/"); idx >= 0 {
text = text[idx+2:]
continue
}
return ""
}
break
}
if text == "" {
return ""
}
for i, r := range text {
if unicode.IsLetter(r) || unicode.IsDigit(r) || r == '_' {
continue
}
if i == 0 {
return ""
}
return strings.ToLower(text[:i])
}
return strings.ToLower(text)
}

View File

@@ -0,0 +1,145 @@
package safety
import (
"GoNavi-Wails/internal/ai"
"testing"
)
func TestClassifySQL(t *testing.T) {
tests := []struct {
sql string
want ai.SQLOperationType
}{
{"SELECT * FROM users", ai.SQLOpQuery},
{" select id from t", ai.SQLOpQuery},
{"SHOW TABLES", ai.SQLOpQuery},
{"DESCRIBE users", ai.SQLOpQuery},
{"DESC users", ai.SQLOpQuery},
{"EXPLAIN SELECT 1", ai.SQLOpQuery},
{"WITH cte AS (SELECT 1) SELECT * FROM cte", ai.SQLOpQuery},
{"PRAGMA table_info(t)", ai.SQLOpQuery},
{"VALUES (1, 2)", ai.SQLOpQuery},
{"INSERT INTO users VALUES (1)", ai.SQLOpDML},
{"UPDATE users SET name='x'", ai.SQLOpDML},
{"DELETE FROM users WHERE id=1", ai.SQLOpDML},
{"REPLACE INTO users VALUES (1)", ai.SQLOpDML},
{"MERGE INTO t USING s ON t.id=s.id", ai.SQLOpDML},
{"CREATE TABLE t (id INT)", ai.SQLOpDDL},
{"ALTER TABLE t ADD col INT", ai.SQLOpDDL},
{"DROP TABLE t", ai.SQLOpDDL},
{"TRUNCATE TABLE t", ai.SQLOpDDL},
{"RENAME TABLE old TO new", ai.SQLOpDDL},
{"/* comment */ SELECT 1", ai.SQLOpQuery},
{"-- comment\nDELETE FROM t", ai.SQLOpDML},
{"-- line1\n-- line2\nSELECT 1", ai.SQLOpQuery},
{"/* block */ -- line\nUPDATE t SET x=1", ai.SQLOpDML},
{"", ai.SQLOpOther},
{" ", ai.SQLOpOther},
{"-- only comment", ai.SQLOpOther},
}
for _, tt := range tests {
got := ClassifySQL(tt.sql)
if got != tt.want {
t.Errorf("ClassifySQL(%q) = %s, want %s", tt.sql, got, tt.want)
}
}
}
func TestIsHighRiskSQL(t *testing.T) {
tests := []struct {
sql string
highRisk bool
}{
{"DROP TABLE users", true},
{"DROP DATABASE test", true},
{"TRUNCATE TABLE users", true},
{"DELETE FROM users", true}, // 无 WHERE
{"DELETE FROM users WHERE id=1", false}, // 有 WHERE
{"UPDATE users SET name='x'", true}, // 无 WHERE
{"UPDATE users SET name='x' WHERE id=1", false}, // 有 WHERE
{"SELECT * FROM users", false},
{"INSERT INTO users VALUES (1)", false},
}
for _, tt := range tests {
highRisk, _ := IsHighRiskSQL(tt.sql)
if highRisk != tt.highRisk {
t.Errorf("IsHighRiskSQL(%q) = %v, want %v", tt.sql, highRisk, tt.highRisk)
}
}
}
func TestGuard_ReadOnly(t *testing.T) {
g := NewGuard(ai.PermissionReadOnly)
tests := []struct {
sql string
allowed bool
}{
{"SELECT * FROM t", true},
{"INSERT INTO t VALUES (1)", false},
{"UPDATE t SET x=1", false},
{"DELETE FROM t", false},
{"DROP TABLE t", false},
{"CREATE TABLE t (id INT)", false},
}
for _, tt := range tests {
result := g.Check(tt.sql)
if result.Allowed != tt.allowed {
t.Errorf("Guard[readonly].Check(%q).Allowed = %v, want %v", tt.sql, result.Allowed, tt.allowed)
}
}
}
func TestGuard_ReadWrite(t *testing.T) {
g := NewGuard(ai.PermissionReadWrite)
tests := []struct {
sql string
allowed bool
confirm bool
}{
{"SELECT * FROM t", true, false},
{"INSERT INTO t VALUES (1)", true, true},
{"UPDATE t SET x=1", true, true}, // 允许但需确认
{"DELETE FROM t WHERE id=1", true, true}, // 允许但需确认
{"DROP TABLE t", false, true}, // DDL 不允许
{"CREATE TABLE t (id INT)", false, true},
}
for _, tt := range tests {
result := g.Check(tt.sql)
if result.Allowed != tt.allowed {
t.Errorf("Guard[readwrite].Check(%q).Allowed = %v, want %v", tt.sql, result.Allowed, tt.allowed)
}
if result.RequiresConfirm != tt.confirm {
t.Errorf("Guard[readwrite].Check(%q).RequiresConfirm = %v, want %v", tt.sql, result.RequiresConfirm, tt.confirm)
}
}
}
func TestGuard_Full(t *testing.T) {
g := NewGuard(ai.PermissionFull)
tests := []struct {
sql string
allowed bool
}{
{"SELECT * FROM t", true},
{"INSERT INTO t VALUES (1)", true},
{"DROP TABLE t", true},
{"CREATE TABLE t (id INT)", true},
}
for _, tt := range tests {
result := g.Check(tt.sql)
if result.Allowed != tt.allowed {
t.Errorf("Guard[full].Check(%q).Allowed = %v, want %v", tt.sql, result.Allowed, tt.allowed)
}
}
}
func TestGuard_HighRiskWarning(t *testing.T) {
g := NewGuard(ai.PermissionFull)
result := g.Check("DELETE FROM users")
if result.WarningMessage == "" {
t.Error("expected high-risk warning for DELETE without WHERE")
}
if !result.RequiresConfirm {
t.Error("expected RequiresConfirm for high-risk SQL")
}
}

View File

@@ -0,0 +1,71 @@
package safety
import (
"GoNavi-Wails/internal/ai"
)
// Guard AI SQL 安全策略守卫
type Guard struct {
permissionLevel ai.SQLPermissionLevel
}
// NewGuard 创建安全策略守卫
func NewGuard(level ai.SQLPermissionLevel) *Guard {
return &Guard{permissionLevel: level}
}
// SetPermissionLevel 设置权限级别
func (g *Guard) SetPermissionLevel(level ai.SQLPermissionLevel) {
g.permissionLevel = level
}
// GetPermissionLevel 获取当前权限级别
func (g *Guard) GetPermissionLevel() ai.SQLPermissionLevel {
return g.permissionLevel
}
// Check 检查 AI 生成的 SQL 是否在允许范围内
func (g *Guard) Check(sql string) ai.SafetyResult {
opType := ClassifySQL(sql)
allowed := g.isAllowed(opType)
requiresConfirm := g.requiresConfirmation(opType)
warningMessage := ""
if isHighRisk, msg := IsHighRiskSQL(sql); isHighRisk {
warningMessage = msg
requiresConfirm = true
}
return ai.SafetyResult{
Allowed: allowed,
OperationType: opType,
RequiresConfirm: requiresConfirm,
WarningMessage: warningMessage,
}
}
func (g *Guard) isAllowed(opType ai.SQLOperationType) bool {
switch g.permissionLevel {
case ai.PermissionReadOnly:
return opType == ai.SQLOpQuery
case ai.PermissionReadWrite:
return opType == ai.SQLOpQuery || opType == ai.SQLOpDML
case ai.PermissionFull:
return opType == ai.SQLOpQuery || opType == ai.SQLOpDML || opType == ai.SQLOpDDL
default:
return opType == ai.SQLOpQuery
}
}
func (g *Guard) requiresConfirmation(opType ai.SQLOperationType) bool {
switch opType {
case ai.SQLOpQuery:
return false
case ai.SQLOpDML:
return true // DML 始终需要确认
case ai.SQLOpDDL:
return true // DDL 始终需要确认
default:
return true
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,157 @@
package aiservice
import (
"reflect"
"testing"
"GoNavi-Wails/internal/ai"
)
func TestDefaultStaticModelsForProvider_DoesNotReturnBailianStaticModels(t *testing.T) {
models := defaultStaticModelsForProvider(ai.ProviderConfig{
Type: "anthropic",
BaseURL: "https://dashscope.aliyuncs.com/apps/anthropic",
})
if len(models) != 0 {
t.Fatalf("expected Bailian provider to rely on remote model list, got %v", models)
}
}
func TestDefaultStaticModelsForProvider_ReturnsDashScopeCodingPlanSupportedModels(t *testing.T) {
expected := []string{
"qwen3.5-plus",
"kimi-k2.5",
"glm-5",
"MiniMax-M2.5",
"qwen3-max-2026-01-23",
"qwen3-coder-next",
"qwen3-coder-plus",
"glm-4.7",
}
testCases := []ai.ProviderConfig{
{
Type: "anthropic",
BaseURL: "https://coding.dashscope.aliyuncs.com/apps/anthropic",
},
{
Type: "custom",
APIFormat: "claude-cli",
BaseURL: "https://coding.dashscope.aliyuncs.com/apps/anthropic",
},
}
for _, testCase := range testCases {
models := defaultStaticModelsForProvider(testCase)
if !reflect.DeepEqual(models, expected) {
t.Fatalf("expected Coding Plan supported models %v, got %v for config %#v", expected, models, testCase)
}
}
}
func TestNormalizeProviderConfig_DoesNotForceModelForDashScopeProviders(t *testing.T) {
bailian := normalizeProviderConfig(ai.ProviderConfig{
Type: "anthropic",
BaseURL: "https://dashscope.aliyuncs.com/apps/anthropic",
})
if bailian.Model != "" {
t.Fatalf("expected Bailian model to remain empty until explicit selection, got %q", bailian.Model)
}
codingPlan := normalizeProviderConfig(ai.ProviderConfig{
Type: "anthropic",
BaseURL: "https://coding.dashscope.aliyuncs.com/apps/anthropic",
})
if codingPlan.Type != "custom" {
t.Fatalf("expected Coding Plan provider type to normalize to custom, got %q", codingPlan.Type)
}
if codingPlan.APIFormat != "claude-cli" {
t.Fatalf("expected Coding Plan provider api format to normalize to claude-cli, got %q", codingPlan.APIFormat)
}
if codingPlan.Model != "" {
t.Fatalf("expected Coding Plan model to remain empty until explicit selection, got %q", codingPlan.Model)
}
if len(codingPlan.Models) == 0 {
t.Fatal("expected Coding Plan provider to expose official supported models")
}
if codingPlan.Models[0] != "qwen3.5-plus" {
t.Fatalf("expected Coding Plan provider to expose latest supported models, got %v", codingPlan.Models)
}
}
func TestResolveModelsURL_UsesDashScopeCompatibleModelsEndpointForBailianAnthropic(t *testing.T) {
url := resolveModelsURL(ai.ProviderConfig{
Type: "anthropic",
BaseURL: "https://dashscope.aliyuncs.com/apps/anthropic",
})
if url != "https://dashscope.aliyuncs.com/compatible-mode/v1/models" {
t.Fatalf("expected Bailian models endpoint, got %q", url)
}
}
func TestAIListModels_ReturnsStaticModelsForDashScopeCodingPlanWithoutRemoteFetch(t *testing.T) {
originalFetchModelsFunc := fetchModelsFunc
fetchModelsFunc = func(config ai.ProviderConfig) ([]string, error) {
t.Fatalf("expected Coding Plan model list to stay static and skip remote fetch, got config %#v", config)
return nil, nil
}
defer func() {
fetchModelsFunc = originalFetchModelsFunc
}()
service := NewService()
service.providers = []ai.ProviderConfig{
{
ID: "provider-coding-plan",
Type: "anthropic",
BaseURL: "https://coding.dashscope.aliyuncs.com/apps/anthropic",
},
}
service.activeProvider = "provider-coding-plan"
result := service.AIListModels()
if result["success"] != true {
t.Fatalf("expected AIListModels to succeed, got %#v", result)
}
models, ok := result["models"].([]string)
if !ok {
t.Fatalf("expected []string models, got %#v", result["models"])
}
if len(models) == 0 || models[0] != "qwen3.5-plus" {
t.Fatalf("expected official static Coding Plan models, got %#v", models)
}
if source, _ := result["source"].(string); source != "static" {
t.Fatalf("expected static source, got %#v", result["source"])
}
}
func TestAITestProvider_UsesClaudeCLIHealthCheckForDashScopeCodingPlan(t *testing.T) {
originalClaudeCLIHealthCheckFunc := claudeCLIHealthCheckFunc
defer func() {
claudeCLIHealthCheckFunc = originalClaudeCLIHealthCheckFunc
}()
var received ai.ProviderConfig
claudeCLIHealthCheckFunc = func(config ai.ProviderConfig) error {
received = config
return nil
}
service := NewService()
result := service.AITestProvider(ai.ProviderConfig{
Type: "anthropic",
BaseURL: "https://coding.dashscope.aliyuncs.com/apps/anthropic",
APIKey: "sk-test",
})
if result["success"] != true {
t.Fatalf("expected AITestProvider to succeed, got %#v", result)
}
if received.Type != "custom" {
t.Fatalf("expected Coding Plan test to use custom provider type, got %q", received.Type)
}
if received.APIFormat != "claude-cli" {
t.Fatalf("expected Coding Plan test to use claude-cli api format, got %q", received.APIFormat)
}
if received.Model != "qwen3.5-plus" {
t.Fatalf("expected Coding Plan test to default probe model to qwen3.5-plus, got %q", received.Model)
}
}

View File

@@ -0,0 +1,152 @@
package aiservice
import (
"reflect"
"testing"
"GoNavi-Wails/internal/ai"
)
func TestResolveModelsURL_UsesMoonshotOpenAIModelsEndpointForKimiAnthropicBaseURL(t *testing.T) {
url := resolveModelsURL(ai.ProviderConfig{
Type: "anthropic",
BaseURL: "https://api.moonshot.cn/anthropic",
})
if url != "https://api.moonshot.cn/v1/models" {
t.Fatalf("expected moonshot models endpoint, got %q", url)
}
}
func TestResolveModelsURL_UsesAnthropicModelsEndpointForOfficialAnthropic(t *testing.T) {
url := resolveModelsURL(ai.ProviderConfig{
Type: "anthropic",
BaseURL: "https://api.anthropic.com",
})
if url != "https://api.anthropic.com/v1/models" {
t.Fatalf("expected anthropic models endpoint, got %q", url)
}
}
func TestResolveModelsURL_UsesOpenAIModelsEndpointForOpenAICompatibleProvider(t *testing.T) {
url := resolveModelsURL(ai.ProviderConfig{
Type: "openai",
BaseURL: "https://api.openai.com/v1",
})
if url != "https://api.openai.com/v1/models" {
t.Fatalf("expected openai models endpoint, got %q", url)
}
}
func TestResolveModelsURL_UsesVersionedVolcengineCodingPlanPath(t *testing.T) {
url := resolveModelsURL(ai.ProviderConfig{
Type: "openai",
BaseURL: "https://ark.cn-beijing.volces.com/api/coding/v3",
})
if url != "https://ark.cn-beijing.volces.com/api/coding/v3/models" {
t.Fatalf("expected volcengine coding plan models endpoint, got %q", url)
}
}
func TestResolveModelsURL_UsesVersionedZhipuPath(t *testing.T) {
url := resolveModelsURL(ai.ProviderConfig{
Type: "openai",
BaseURL: "https://open.bigmodel.cn/api/paas/v4",
})
if url != "https://open.bigmodel.cn/api/paas/v4/models" {
t.Fatalf("expected zhipu models endpoint, got %q", url)
}
}
func TestNewModelsRequest_StripsChatCompletionsSuffixForOpenAICompatibleProvider(t *testing.T) {
req, err := newModelsRequest(ai.ProviderConfig{
Type: "openai",
BaseURL: "https://ark.cn-beijing.volces.com/api/v3/chat/completions",
APIKey: "sk-test",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if req.URL.String() != "https://ark.cn-beijing.volces.com/api/v3/models" {
t.Fatalf("expected normalized models endpoint, got %q", req.URL.String())
}
if got := req.Header.Get("Authorization"); got != "Bearer sk-test" {
t.Fatalf("expected bearer auth header, got %q", got)
}
}
func TestDefaultStaticModelsForProvider_ReturnsMiniMaxAnthropicModels(t *testing.T) {
models := defaultStaticModelsForProvider(ai.ProviderConfig{
Type: "anthropic",
BaseURL: "https://api.minimaxi.com/anthropic",
})
expected := []string{
"MiniMax-M2.7",
"MiniMax-M2.7-highspeed",
"MiniMax-M2.5",
"MiniMax-M2.5-highspeed",
"MiniMax-M2.1",
"MiniMax-M2.1-highspeed",
"MiniMax-M2",
}
if !reflect.DeepEqual(models, expected) {
t.Fatalf("expected MiniMax static models %v, got %v", expected, models)
}
}
func TestDefaultStaticModelsForProvider_DoesNotReturnDashScopeBailianStaticModels(t *testing.T) {
models := defaultStaticModelsForProvider(ai.ProviderConfig{
Type: "anthropic",
BaseURL: "https://dashscope.aliyuncs.com/apps/anthropic",
})
if len(models) != 0 {
t.Fatalf("expected Bailian provider to fetch models remotely, got %v", models)
}
}
func TestNewProviderHealthCheckRequest_UsesMessagesEndpointForMiniMaxAnthropic(t *testing.T) {
req, err := newProviderHealthCheckRequest(ai.ProviderConfig{
Type: "anthropic",
BaseURL: "https://api.minimaxi.com/anthropic",
Model: "MiniMax-M2.7",
APIKey: "sk-test",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if req.Method != "POST" {
t.Fatalf("expected POST request, got %s", req.Method)
}
if req.URL.String() != "https://api.minimaxi.com/anthropic/v1/messages" {
t.Fatalf("expected MiniMax messages endpoint, got %q", req.URL.String())
}
if got := req.Header.Get("x-api-key"); got != "sk-test" {
t.Fatalf("expected x-api-key header to be set, got %q", got)
}
}
func TestNewProviderHealthCheckRequest_UsesMessagesEndpointForDashScopeAnthropic(t *testing.T) {
req, err := newProviderHealthCheckRequest(ai.ProviderConfig{
Type: "anthropic",
BaseURL: "https://dashscope.aliyuncs.com/apps/anthropic",
Model: "qwen3.5-plus",
APIKey: "sk-test",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if req.Method != "POST" {
t.Fatalf("expected POST request, got %s", req.Method)
}
if req.URL.String() != "https://dashscope.aliyuncs.com/apps/anthropic/v1/messages" {
t.Fatalf("expected DashScope messages endpoint, got %q", req.URL.String())
}
if got := req.Header.Get("x-api-key"); got != "sk-test" {
t.Fatalf("expected x-api-key header to be set, got %q", got)
}
if got := req.Header.Get("Authorization"); got != "Bearer sk-test" {
t.Fatalf("expected bearer authorization header, got %q", got)
}
if got := req.Header.Get("anthropic-version"); got != "" {
t.Fatalf("expected no anthropic-version header for DashScope, got %q", got)
}
}

View File

@@ -0,0 +1,111 @@
package aiservice
import (
"reflect"
"strings"
"testing"
"GoNavi-Wails/internal/ai"
)
func TestIsVolcengineCodingPlanProvider_MatchesCodingPlanBaseURL(t *testing.T) {
if !isVolcengineCodingPlanProvider(ai.ProviderConfig{
Type: "openai",
BaseURL: "https://ark.cn-beijing.volces.com/api/coding/v3",
}) {
t.Fatal("expected volcengine coding plan provider to be detected")
}
}
func TestFilterVolcengineCodingPlanModels_KeepsOnlySupportedFamilies(t *testing.T) {
filtered := filterVolcengineCodingPlanModels([]string{
"Auto",
"qwen3-14b-20250429",
"wan2-1-14b-t2v-250225",
"Doubao-Seed-2.0-Code",
"Doubao-Seed-2.0-pro",
"Doubao-Seed-2.0-lite",
"doubao-seed-code-32k-250615",
"MiniMax-M2.5",
"GLM-4.7",
"DeepSeek-V3.2",
"kimi-k2-turbo-preview",
})
expected := []string{
"Auto",
"Doubao-Seed-2.0-Code",
"Doubao-Seed-2.0-pro",
"Doubao-Seed-2.0-lite",
"doubao-seed-code-32k-250615",
"MiniMax-M2.5",
"GLM-4.7",
"DeepSeek-V3.2",
"kimi-k2-turbo-preview",
}
if !reflect.DeepEqual(filtered, expected) {
t.Fatalf("expected filtered models %v, got %v", expected, filtered)
}
}
func TestFilterVolcengineCodingPlanModels_DoesNotBroadlyMatchAutoKeyword(t *testing.T) {
filtered := filterVolcengineCodingPlanModels([]string{
"Auto",
"automatic-router-preview",
})
expected := []string{"Auto"}
if !reflect.DeepEqual(filtered, expected) {
t.Fatalf("expected only exact Auto model to remain, got %v", filtered)
}
}
func TestFilterFetchedModelsForProvider_DoesNotFilterVolcengineArk(t *testing.T) {
rawModels := []string{
"qwen3-14b-20250429",
"wan2-1-14b-t2v-250225",
}
filtered, err := filterFetchedModelsForProvider(ai.ProviderConfig{
Type: "openai",
BaseURL: "https://ark.cn-beijing.volces.com/api/v3",
}, rawModels)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !reflect.DeepEqual(filtered, rawModels) {
t.Fatalf("expected ark models to stay untouched, got %v", filtered)
}
}
func TestAIListModels_ReturnsFailureWhenVolcengineCodingPlanModelsAreFilteredEmpty(t *testing.T) {
originalFetchModelsFunc := fetchModelsFunc
fetchModelsFunc = func(config ai.ProviderConfig) ([]string, error) {
return []string{
"qwen3-14b-20250429",
"wan2-1-14b-t2v-250225",
}, nil
}
defer func() {
fetchModelsFunc = originalFetchModelsFunc
}()
service := NewService()
service.providers = []ai.ProviderConfig{
{
ID: "provider-coding",
Type: "openai",
BaseURL: "https://ark.cn-beijing.volces.com/api/coding/v3",
},
}
service.activeProvider = "provider-coding"
result := service.AIListModels()
if result["success"] != false {
t.Fatalf("expected AIListModels to fail, got %#v", result)
}
errorMessage, _ := result["error"].(string)
if !strings.Contains(errorMessage, "当前接口未返回可用的火山 Coding Plan 模型") {
t.Fatalf("expected specific coding plan error, got %q", errorMessage)
}
}

Some files were not shown because too many files have changed in this diff Show More