Compare commits

..

30 Commits

Author SHA1 Message Date
Syngnat
f992ad72e6 feat(mongodb): 支持无认证模式连接低版本 MongoDB 实例
- 连接表单:验证方式新增"无认证 (None)"选项,MongoDB 用户名改为非必填
- URI 构建:当 MongoAuthMechanism 为 NONE 时跳过 user/password/authSource/authMechanism
- 兼容优化:无用户名时不再默认设置 authSource=admin,避免驱动对无密码实例发起认证
- 双版本同步:mongodb_impl.go 与 mongodb_impl_v1.go 同步修改
- refs #303
2026-04-01 16:46:27 +08:00
Syngnat
5c0f6f8ff4 🐛 fix(data-grid): 修复数据预览面板日期格式化、JSON切换失效及幽灵变更计数问题
- 日期时间字段预览时通过 normalizeDateTimeString 格式化带时区的 ISO 格式
- 切换单元格时始终更新预览值,用 dataPanelOriginalRef 替代 suppress 机制判断 dirty
- handleCellSave 增加根源级变更检测,与原始 data 逐字段比较后才写入 modifiedRows
- 英文消息 "No changes to commit" 改为中文 "没有可提交的变更"
- refs #301
2026-04-01 16:21:57 +08:00
Syngnat
1eb517f083 ♻️ refactor(table-designer): 按索引类别精确分类索引方法类型选项
- MySQL InnoDB:所有索引类别均为固定方法(BTREE/FULLTEXT/RTREE),移除无意义的"默认"选项
- PostgreSQL:普通索引保留全部方法选项,主键和唯一索引固定为 BTREE
- 新增 getFixedIndexType 辅助函数,切换索引类别时自动设置对应的固定方法类型
- getIndexTypeOptions 接受 kind 参数,按类别动态返回精确的选项列表
- 切换类别时若当前方法不在新选项中,自动重置为合法值
- refs #299
2026-04-01 16:04:22 +08:00
Syngnat
02fa0aef46 🔥 remove(table-designer): 移除 MySQL 索引类型中不支持的 HASH 和 RTREE 选项
- MySQL InnoDB 引擎不支持手动创建 HASH/RTREE 索引,执行后会静默降级为 BTREE
- 从 MYSQL_INDEX_TYPE_OPTIONS 中移除 HASH 和 RTREE,避免用户误选导致修改"不生效"
- MySQL 下仅保留 DEFAULT/BTREE/FULLTEXT/SPATIAL 四种索引类型
- refs #298
2026-04-01 15:54:29 +08:00
Syngnat
f7107a1625 🐛 fix(data-grid): 修复单元格编辑值丢失及日期选择器滚动偏移问题
- 移除 Form.Item 的 preserve={false},修复嵌套字段名下编辑后值变为 undefined 的问题
- 将表单值初始化移至 useEffect([editing]),确保每次编辑时从 record 重新读取并覆盖旧值
- 新增 cellRef 绑定单元格 DOM,用于定位滚动容器
- DatePicker/TimePicker 面板打开时在 ant-table-wrapper 上拦截 wheel 事件,阻止表格滚动导致选择器漂移
- 面板关闭时自动移除 wheel 事件监听,恢复正常滚动
- refs #297
2026-04-01 15:45:50 +08:00
Syngnat
08ab06c038 feat(sidebar/table-overview): 优化侧边栏交互并新增表概览列表视图
- 修复连接刷新后数据库节点无法再次展开的问题,刷新时清除子节点 expandedKeys/loadedKeys/loadingRef
- 表概览由双击改为单击"表(N)"分组节点打开,双击仅触发展开/折叠
- 使用 clickTimerRef 延时防抖区分单击与双击事件,避免双击同时打开表概览
- 表概览新增列表视图模式,展示表名、注释、行数、数据大小、索引大小、引擎等列
- 工具栏新增卡片/列表视图切换按钮,两种视图共享搜索、排序和右键菜单
- refs #296
- refs #324
2026-04-01 15:29:42 +08:00
Syngnat
3402b56fdb 🎨 style(data-grid): 重构筛选面板为 flex 分区布局
- 外层改为 flex column,拆分为可滚动内容区(maxHeight: 200px)和固定操作栏
- "添加排序"从内容区提升到操作栏,条件渲染依赖 onSort 存在性
- "添加条件"使用 primary ghost 按钮增强辨识度
- refs #295
2026-04-01 15:03:02 +08:00
Syngnat
2c2baca69f 🐛 fix(data-grid): 修复日期时间字段二次编辑时日历残留上次选择日期标记
- Form.Item 默认 preserve={true},DatePicker 卸载后表单仍保留旧 dayjs 值
- 再次进入编辑时 DatePicker 读取到残留值,导致日历面板显示上次选择的日期圆圈
- 设置 preserve={false} 确保每次编辑态卸载后清除字段值,消除残留标记
- refs #290
2026-04-01 14:53:44 +08:00
Syngnat
e464c2cce1 🐛 fix(data-grid): 修复日期时间类型字段编辑交互问题并中文化日期选择器
- 修复"此刻"按钮点击后自动提交的问题,改为自定义按钮仅填值、需点击"确定"才保存
- 修复 datetime 编辑态点击外部后不退出的问题,通过 onBlur + pickerOpenRef 兜底
- 全局配置 dayjs 中文 locale,日期选择器月份/星期等文本显示为中文
- 为 time/date/year 类型 picker 添加 onBlur 兜底,确保焦点离开后退出编辑
- save 函数增加 editing 守卫和 catch 兜底,防止重复保存或异常时卡死编辑态
- refs #289
2026-04-01 14:49:28 +08:00
Syngnat
15f72c013d 📝 docs(readme): 新增项目 Star 增长趋势图与动态状态徽章
- 状态徽章:顶部引入 Shields.io 徽章,实时展示当前总 Star 数与全资源累计下载量
- 增长趋势:底部区域新增 Star History 的动态增长曲线图表
- 兼容性修复:将 HTML `<picture>` 语法回退为标准 Markdown 图片格式,解决部分本地开发工具的预览问题
- 国际化同步:中美双语(README.md 与 README.zh-CN.md)同步部署展示更新
2026-04-01 14:04:26 +08:00
Syngnat
c2c8870841 📝 docs(readme): 新增项目 Star 增长趋势图与动态状态徽章
- 状态徽章:顶部引入 Shields.io 徽章,实时展示当前总 Star 数与全资源累计下载量
- 增长趋势:底部区域新增 Star History 的动态增长曲线图表
- 兼容性修复:将 HTML `<picture>` 语法回退为标准 Markdown 图片格式,解决部分本地开发工具的预览问题
- 国际化同步:中美双语(README.md 与 README.zh-CN.md)同步部署展示更新
2026-04-01 14:03:14 +08:00
Syngnat
4f7ac7149a 📝 docs(readme): 新增项目 Star 增长趋势图与动态状态徽章
- 状态徽章:顶部引入 Shields.io 徽章,实时展示当前总 Star 数与全资源累计下载量
- 增长趋势:底部区域新增 Star History 的动态增长曲线图表
- 兼容性修复:将 HTML `<picture>` 语法回退为标准 Markdown 图片格式,解决部分本地开发工具的预览问题
- 国际化同步:中美双语(README.md 与 README.zh-CN.md)同步部署展示更新
2026-04-01 13:52:57 +08:00
tianqijiuyun-latiao
8d8af530a7 Merge remote-tracking branch 'upstream/dev' into feature/20260327_opt
# Conflicts:
#	frontend/src/components/DataGrid.tsx
2026-03-31 12:36:20 +08:00
tianqijiuyun-latiao
29b96719d5 🐛 fix(sql): 修复时间字段复制与导出SQL格式 2026-03-31 12:29:03 +08:00
Syngnat
9c96246320 feat(postgres): PostgreSQL 支持不带 schema 前缀的表名补全与执行
- 后端优化:连接成功后自动查询所有用户 schema 并将 search_path 写入 DSN 重建连接池
- 连接池修复:SET search_path 仅对单个连接生效,改为 DSN 级别配置使所有连接生效
- 表名补全:前端智能匹配 schema.table 中的纯表名部分,输入表名即可触发补全
- 同名表处理:跨 schema 存在同名表时补全自动显示 schema.table 格式以区分
- 列补全增强:FROM/JOIN 引用纯表名时关联列提示和别名列提示均可正确匹配
2026-03-31 12:09:33 +08:00
Syngnat
31644dee6b 🐛 fix(dameng): 修复达梦数据源侧边栏无法展开数据库节点的问题
- 权限适配:取消对 SYSDBA schema 的默认过滤,并增加 `SELECT USER FROM DUAL` 兜底查询
- 树节点容错:Sidebar 当数据库为空时不再阻断加载状态,允许用户重试刷新并增加明确提示
- 类型修正:修复 RedisMonitor 组件 `NodeJS.Timeout` 在 Vite 下的编译报错
- 测试覆盖:补充达梦 SYSDBA 过滤及兜底查询逻辑的单元测试
2026-03-30 19:46:05 +08:00
Syngnat
aa9d8d243a feat(redis/monitor/oracle/data-viewer): 新增 Redis 实例监控并优化 Oracle 大表预览体验
- 新增 RedisMonitor 面板,展示 QPS、内存、CPU、连接数和键数量趋势图
- 引入 recharts 依赖并补齐监控图表所需前端包与锁文件
- Sidebar 新增 Redis 实例监控入口,TabManager 与 TabData 接入 redis-monitor 页签类型
- RedisCommandEditor 支持多行脚本块解析、选区执行、耗时记录与终端化结果展示
- Oracle 表预览移除自动精确 COUNT(*),避免打开大表时额外阻塞
- 无筛选整表预览接入 ALL_TABLES.NUM_ROWS 近似总数展示,并拆分近似总数与近似总页数语义
2026-03-30 16:48:19 +08:00
Syngnat
6e55d63877 📝 docs(readme): 更新AI助手功能描述与界面截图,并添加友情链接
- 核心特性:补充 AI 智能助手的多模型支持、表结构上下文和快捷指令介绍
- 界面更新:移除旧版截图,替换为全新的 AI 对话、模型配置与上下文选择界面截图
- 友情链接:在文档底部补充 linux.do 及 AI全书 链接
- 多语言:同步更新中英文双语版 README 细节内容
2026-03-30 10:43:46 +08:00
tianqijiuyun-latiao
c126c4b731 Merge remote-tracking branch 'upstream/dev' into feature/20260327_opt 2026-03-29 22:34:39 +08:00
tianqijiuyun-latiao
c85de27aac perf(query): 批量写语句走一次性 Exec 减少网络往返,修复大量 INSERT 执行慢问题
- 新增 BatchWriteExecer 可选接口(ExecBatchContext)
- MySQL/MariaDB/Doris/PostgreSQL/SQLite/DuckDB 实现该接口
- DBQueryMulti 检测到纯写操作时走批量路径,500 条 INSERT 从 500 次网络往返降至 1 次
- 混合语句(SELECT + INSERT)及不支持该接口的驱动继续走原有逐条执行路径
2026-03-29 12:17:37 +08:00
Syngnat
eeef0f06ed 🐛 fix(app): 修复供应商预设识别并兼容Wails开发模式资源加载
- 抽离供应商预设匹配逻辑,避免自定义 OpenAI 端点误识别为千问 Coding Plan
- 调整 AI 设置弹窗的预设回填逻辑,并补充预设识别回归测试
- 通过 dev/prod build tag 拆分前端资源装配,避免开发模式依赖 frontend/dist
2026-03-28 17:40:27 +08:00
Syngnat
fcd4d4026c 🔧 chore(gitignore): 移除本地追踪文档并补充忽略规则
- 从版本控制中移除 docs/superpowers 下的计划与设计文档
- 从版本控制中移除 docs/需求追踪 下的本地进度追踪文档
- 补充忽略规则,避免本地需求追踪与 superpowers 文档再次误提交
2026-03-28 17:35:21 +08:00
Syngnat
a7bee7f3b6 feat(ai-entry): 优化AI助手贴边入口交互体验
- 将 AI 助手入口从侧栏工具区迁移为主内容区右侧贴边标签
- 调整打开态贴边标签锚点到面板左外沿,避免遮挡头部操作区
- 重排侧栏顶部工具布局,恢复四项按钮的稳定网格结构
- 新增 aiEntryLayout 布局辅助与回归测试,覆盖打开态附着位置
2026-03-28 16:48:06 +08:00
tianqijiuyun-latiao
ed4a7b96d4 🐛 fix(query): 修复千万级表查询超时、表头备注类型不显示及datetime INSERT格式问题 refs #307
- QueryEditor: SQL编辑器查询 timeout 下限设为 120s,防止大表全量查询被 30s 超时取消
- QueryEditor: 放宽表名提取正则,支持 SELECT col1,col2 FROM table 形式,修复表头备注/类型不显示
- DataGrid: handleCopyInsert 对 datetime 值调用 normalizeDateTimeString,消除 RFC3339 格式中的 T 和时区后缀
2026-03-27 18:39:09 +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
78 changed files with 4876 additions and 519 deletions

2
.gitignore vendored
View File

@@ -21,6 +21,8 @@ GoNavi-Wails.exe
.claude/
.gemini/
**/tmpclaude-*
docs/superpowers/
docs/需求追踪/
CLAUDE.md
**/CLAUDE.md

View File

@@ -5,6 +5,8 @@
[![React Version](https://img.shields.io/badge/React-v18-blue)](https://reactjs.org/)
[![License](https://img.shields.io/badge/License-Apache%202.0-green.svg)](LICENSE)
[![Build Status](https://img.shields.io/github/actions/workflow/status/Syngnat/GoNavi/release.yml?label=Build)](https://github.com/Syngnat/GoNavi/actions)
[![Stars](https://img.shields.io/github/stars/Syngnat/GoNavi?style=social)](https://github.com/Syngnat/GoNavi/stargazers)
[![Downloads](https://img.shields.io/github/downloads/Syngnat/GoNavi/total?color=blue&label=downloads)](https://github.com/Syngnat/GoNavi/releases)
**Language**: English | [简体中文](README.zh-CN.md)
@@ -53,19 +55,24 @@ GoNavi is designed for developers and DBAs who need a unified desktop experience
<h2 align="center">📸 Screenshots</h2>
<div align="center">
<img width="25%" alt="image" src="https://github.com/user-attachments/assets/341cda98-79a5-4198-90f3-1335131ccde0" />
<img width="25%" alt="image" src="https://github.com/user-attachments/assets/224a74e7-65df-4aef-9710-d8e82e3a70c1" />
<img width="25%" alt="image" src="https://github.com/user-attachments/assets/ec522145-5ceb-4481-ae46-a9251c89bdfc" />
<img width="25%" alt="image" src="https://github.com/user-attachments/assets/0eefe07f-2836-44fa-9ddf-a0d2124b90e2" />
<img width="25%" alt="image" src="https://github.com/user-attachments/assets/6765e539-83ea-4cd6-9c9e-f42790fa05b5" />
<img width="25%" alt="image" src="https://github.com/user-attachments/assets/60e3d187-171a-4248-94e0-c6b08736e235" />
<br />
<img width="25%" alt="image" src="https://github.com/user-attachments/assets/330ce49b-45f1-4919-ae14-75f7d47e5f73" />
<img width="14%" alt="image" src="https://github.com/user-attachments/assets/d15fa9e9-5486-423b-a0e9-53b467e45432" />
<img width="25%" alt="image" src="https://github.com/user-attachments/assets/f0c57590-d987-4ecf-89b2-64efad60b6d7" />
<img width="25%" alt="image" src="https://github.com/user-attachments/assets/7a478602-0f08-4b30-8f6a-879f4a60ae32" />
<img width="14%" alt="image" src="https://github.com/user-attachments/assets/6442ca7d-ce9e-46d9-aecd-405ba88f5a5e" />
<img width="25%" alt="image" src="https://github.com/user-attachments/assets/bc17895e-02a4-4cc5-b471-c3803cf25a2b" />
</div>
---
## Key Features
### AI Assistant (New)
- **Multi-provider Support**: OpenAI, Google Gemini, Anthropic Claude, and custom API support.
- **Context-Aware Chat**: Attach table schemas to the AI context for accurate SQL generation and assistance.
- **Slash Commands**: Quick commands for generating SQL, explaining queries, optimizing performance, and reviewing schema designs.
### Performance
- **Smooth interaction under load**: optimized table interaction (including column resize workflow on large datasets).
- **Virtualized rendering**: keeps large result sets responsive.
@@ -207,6 +214,20 @@ For the full workflow, branch model, and maintainer sync rules, see:
External contributors should open pull requests directly against `main`.
## Star History
<a href="https://www.star-history.com/?repos=Syngnat%2FGoNavi&type=date&legend=top-left">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/image?repos=Syngnat/GoNavi&type=date&theme=dark&legend=top-left" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/image?repos=Syngnat/GoNavi&type=date&legend=top-left" />
<img alt="Star History Chart" src="https://api.star-history.com/image?repos=Syngnat/GoNavi&type=date&legend=top-left" />
</picture>
</a>
## Links
- [linux.do](https://linux.do/)
- [AIBook](https://aibook.ren/)
## License
Licensed under [Apache-2.0](LICENSE).

View File

@@ -5,6 +5,8 @@
[![React Version](https://img.shields.io/badge/React-v18-blue)](https://reactjs.org/)
[![License](https://img.shields.io/badge/License-Apache%202.0-green.svg)](LICENSE)
[![Build Status](https://img.shields.io/github/actions/workflow/status/Syngnat/GoNavi/release.yml?label=Build)](https://github.com/Syngnat/GoNavi/actions)
[![Stars](https://img.shields.io/github/stars/Syngnat/GoNavi?style=social)](https://github.com/Syngnat/GoNavi/stargazers)
[![Downloads](https://img.shields.io/github/downloads/Syngnat/GoNavi/total?color=blue&label=downloads)](https://github.com/Syngnat/GoNavi/releases)
**语言**: [English](README.md) | 简体中文
@@ -52,19 +54,24 @@ GoNavi 面向开发者与 DBA核心目标是让数据库操作在桌面端做
<h2 align="center">📸 项目截图</h2>
<div align="center">
<img width="25%" alt="image" src="https://github.com/user-attachments/assets/341cda98-79a5-4198-90f3-1335131ccde0" />
<img width="25%" alt="image" src="https://github.com/user-attachments/assets/224a74e7-65df-4aef-9710-d8e82e3a70c1" />
<img width="25%" alt="image" src="https://github.com/user-attachments/assets/ec522145-5ceb-4481-ae46-a9251c89bdfc" />
<img width="25%" alt="image" src="https://github.com/user-attachments/assets/0eefe07f-2836-44fa-9ddf-a0d2124b90e2" />
<img width="25%" alt="image" src="https://github.com/user-attachments/assets/6765e539-83ea-4cd6-9c9e-f42790fa05b5" />
<img width="25%" alt="image" src="https://github.com/user-attachments/assets/60e3d187-171a-4248-94e0-c6b08736e235" />
<br />
<img width="25%" alt="image" src="https://github.com/user-attachments/assets/330ce49b-45f1-4919-ae14-75f7d47e5f73" />
<img width="14%" alt="image" src="https://github.com/user-attachments/assets/d15fa9e9-5486-423b-a0e9-53b467e45432" />
<img width="25%" alt="image" src="https://github.com/user-attachments/assets/f0c57590-d987-4ecf-89b2-64efad60b6d7" />
<img width="25%" alt="image" src="https://github.com/user-attachments/assets/7a478602-0f08-4b30-8f6a-879f4a60ae32" />
<img width="14%" alt="image" src="https://github.com/user-attachments/assets/6442ca7d-ce9e-46d9-aecd-405ba88f5a5e" />
<img width="25%" alt="image" src="https://github.com/user-attachments/assets/bc17895e-02a4-4cc5-b471-c3803cf25a2b" />
</div>
---
## 核心特性
### AI 智能助手 (New)
- **多模型服务商支持**:内置跨平台接入 OpenAI, Google Gemini, Anthropic Claude同时支持任意自定义兼容 OpenAI 格式的 API。
- **关联表结构上下文**:原生支持将当前数据库表结构直接提取作为上下文发送给 AI让 SQL 生成、分析变得更精准。
- **快捷指令**:内置多种快捷对话指(如一键生成 SQL、解释执行逻辑、分析性能优化、表字段代码评审等
### 性能与交互
- 大数据场景下保持流畅交互(含 DataGrid 列宽拖拽、批量编辑流程优化)。
- 虚拟滚动渲染,降低大结果集卡顿风险。
@@ -190,6 +197,21 @@ sudo apt-get install -y libgtk-3-0 libwebkit2gtk-4.0-37 libjavascriptcoregtk-4.0
外部贡献者统一直接向 `main` 发起 Pull Request。
## Star History (Star 增长趋势)
<a href="https://www.star-history.com/?repos=Syngnat%2FGoNavi&type=date&legend=top-left">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/image?repos=Syngnat/GoNavi&type=date&theme=dark&legend=top-left" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/image?repos=Syngnat/GoNavi&type=date&legend=top-left" />
<img alt="Star History Chart" src="https://api.star-history.com/image?repos=Syngnat/GoNavi&type=date&legend=top-left" />
</picture>
</a>
## 友情链接
- [linux.do](https://linux.do/)
- [AI全书](https://aibook.ren/)
## 开源协议
本项目采用 [Apache-2.0 协议](LICENSE)。

9
assets_dev.go Normal file
View File

@@ -0,0 +1,9 @@
//go:build dev
package main
import "os"
// 开发模式下由 Wails DevServer 提供前端资源,这里只提供一个稳定的占位 FS
// 避免编译时依赖 frontend/dist 被并发重建。
var assets = os.DirFS(".")

13
assets_prod.go Normal file
View File

@@ -0,0 +1,13 @@
//go:build !dev
package main
import (
"embed"
"io/fs"
)
//go:embed all:frontend/dist
var embeddedAssets embed.FS
var assets fs.FS = embeddedAssets

View File

@@ -23,6 +23,7 @@
"react-markdown": "^10.1.0",
"react-resizable": "^3.1.3",
"react-syntax-highlighter": "^16.1.1",
"recharts": "^3.8.1",
"remark-gfm": "^4.0.1",
"sql-formatter": "^15.7.0",
"uuid": "^9.0.1",
@@ -1210,6 +1211,42 @@
"react-dom": ">=16.9.0"
}
},
"node_modules/@reduxjs/toolkit": {
"version": "2.11.2",
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz",
"integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==",
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.0.0",
"@standard-schema/utils": "^0.3.0",
"immer": "^11.0.0",
"redux": "^5.0.1",
"redux-thunk": "^3.1.0",
"reselect": "^5.1.0"
},
"peerDependencies": {
"react": "^16.9.0 || ^17.0.0 || ^18 || ^19",
"react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
},
"peerDependenciesMeta": {
"react": {
"optional": true
},
"react-redux": {
"optional": true
}
}
},
"node_modules/@reduxjs/toolkit/node_modules/immer": {
"version": "11.1.4",
"resolved": "https://registry.npmjs.org/immer/-/immer-11.1.4.tgz",
"integrity": "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/immer"
}
},
"node_modules/@rolldown/pluginutils": {
"version": "1.0.0-beta.27",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
@@ -1567,6 +1604,18 @@
"win32"
]
},
"node_modules/@standard-schema/spec": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
"license": "MIT"
},
"node_modules/@standard-schema/utils": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
"license": "MIT"
},
"node_modules/@types/babel__core": {
"version": "7.20.5",
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
@@ -2001,6 +2050,12 @@
"integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==",
"license": "MIT"
},
"node_modules/@types/use-sync-external-store": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
"license": "MIT"
},
"node_modules/@types/uuid": {
"version": "9.0.8",
"resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-9.0.8.tgz",
@@ -3046,6 +3101,12 @@
}
}
},
"node_modules/decimal.js-light": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
"license": "MIT"
},
"node_modules/decode-named-character-reference": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz",
@@ -3130,6 +3191,16 @@
"dev": true,
"license": "MIT"
},
"node_modules/es-toolkit": {
"version": "1.45.1",
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.45.1.tgz",
"integrity": "sha512-/jhoOj/Fx+A+IIyDNOvO3TItGmlMKhtX8ISAHKE90c4b/k1tqaqEZ+uUqfpU8DMnW5cgNJv606zS55jGvza0Xw==",
"license": "MIT",
"workspaces": [
"docs",
"benchmarks"
]
},
"node_modules/esbuild": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
@@ -3211,6 +3282,12 @@
"@types/estree": "^1.0.0"
}
},
"node_modules/eventemitter3": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
"license": "MIT"
},
"node_modules/expect-type": {
"version": "1.3.0",
"resolved": "https://registry.npmmirror.com/expect-type/-/expect-type-1.3.0.tgz",
@@ -3404,6 +3481,16 @@
"node": ">=0.10.0"
}
},
"node_modules/immer": {
"version": "10.2.0",
"resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz",
"integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/immer"
}
},
"node_modules/inline-style-parser": {
"version": "0.2.7",
"resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz",
@@ -5511,6 +5598,29 @@
"react": ">=18"
}
},
"node_modules/react-redux": {
"version": "9.2.0",
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
"license": "MIT",
"dependencies": {
"@types/use-sync-external-store": "^0.0.6",
"use-sync-external-store": "^1.4.0"
},
"peerDependencies": {
"@types/react": "^18.2.25 || ^19",
"react": "^18.0 || ^19",
"redux": "^5.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"redux": {
"optional": true
}
}
},
"node_modules/react-refresh": {
"version": "0.17.0",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
@@ -5555,6 +5665,51 @@
"react": ">= 0.14.0"
}
},
"node_modules/recharts": {
"version": "3.8.1",
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.8.1.tgz",
"integrity": "sha512-mwzmO1s9sFL0TduUpwndxCUNoXsBw3u3E/0+A+cLcrSfQitSG62L32N69GhqUrrT5qKcAE3pCGVINC6pqkBBQg==",
"license": "MIT",
"workspaces": [
"www"
],
"dependencies": {
"@reduxjs/toolkit": "^1.9.0 || 2.x.x",
"clsx": "^2.1.1",
"decimal.js-light": "^2.5.1",
"es-toolkit": "^1.39.3",
"eventemitter3": "^5.0.1",
"immer": "^10.1.1",
"react-redux": "8.x.x || 9.x.x",
"reselect": "5.1.1",
"tiny-invariant": "^1.3.3",
"use-sync-external-store": "^1.2.2",
"victory-vendor": "^37.0.2"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/redux": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
"license": "MIT"
},
"node_modules/redux-thunk": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz",
"integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
"license": "MIT",
"peerDependencies": {
"redux": "^5.0.0"
}
},
"node_modules/refractor": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/refractor/-/refractor-5.0.0.tgz",
@@ -5637,6 +5792,12 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/reselect": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
"integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==",
"license": "MIT"
},
"node_modules/resize-observer-polyfill": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz",
@@ -5888,6 +6049,12 @@
"node": ">=12.22"
}
},
"node_modules/tiny-invariant": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
"license": "MIT"
},
"node_modules/tinybench": {
"version": "2.9.0",
"resolved": "https://registry.npmmirror.com/tinybench/-/tinybench-2.9.0.tgz",
@@ -6178,6 +6345,28 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/victory-vendor": {
"version": "37.3.6",
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz",
"integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==",
"license": "MIT AND ISC",
"dependencies": {
"@types/d3-array": "^3.0.3",
"@types/d3-ease": "^3.0.0",
"@types/d3-interpolate": "^3.0.1",
"@types/d3-scale": "^4.0.2",
"@types/d3-shape": "^3.1.0",
"@types/d3-time": "^3.0.0",
"@types/d3-timer": "^3.0.0",
"d3-array": "^3.1.6",
"d3-ease": "^3.0.1",
"d3-interpolate": "^3.0.1",
"d3-scale": "^4.0.2",
"d3-shape": "^3.1.0",
"d3-time": "^3.0.0",
"d3-timer": "^3.0.1"
}
},
"node_modules/vite": {
"version": "5.4.21",
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",

View File

@@ -25,6 +25,7 @@
"react-markdown": "^10.1.0",
"react-resizable": "^3.1.3",
"react-syntax-highlighter": "^16.1.1",
"recharts": "^3.8.1",
"remark-gfm": "^4.0.1",
"sql-formatter": "^15.7.0",
"uuid": "^9.0.1",

View File

@@ -1 +1 @@
dcb87159cf0f1f6f750d1c4870911d3f
f697e821b4acd5cf614d63d46453e8a4

View File

@@ -28,6 +28,13 @@ import {
isShortcutMatch,
normalizeShortcutCombo,
} from './utils/shortcuts';
import {
SIDEBAR_UTILITY_ITEM_KEYS,
resolveAIEntryPlacement,
resolveAIEdgeHandleAttachment,
resolveAIEdgeHandleDockStyle,
resolveAIEdgeHandleStyle,
} from './utils/aiEntryLayout';
import { ConfigureGlobalProxy, SetMacNativeWindowControls, SetWindowTranslucency } from '../wailsjs/go/app/App';
import './App.css';
@@ -1125,6 +1132,61 @@ function App() {
const [capturingShortcutAction, setCapturingShortcutAction] = useState<ShortcutAction | null>(null);
const [isProxyModalOpen, setIsProxyModalOpen] = useState(false);
const [isAISettingsOpen, setIsAISettingsOpen] = useState(false);
const aiEntryPlacement = resolveAIEntryPlacement();
const aiEdgeHandleAttachment = resolveAIEdgeHandleAttachment(aiPanelVisible);
const aiEdgeHandleDockStyle = useMemo(
() => resolveAIEdgeHandleDockStyle(aiEdgeHandleAttachment),
[aiEdgeHandleAttachment],
);
const aiEdgeHandleStyle = useMemo(() => (
resolveAIEdgeHandleStyle({
darkMode,
aiPanelVisible,
effectiveUiScale,
})
), [aiPanelVisible, darkMode, effectiveUiScale]);
const sidebarUtilityItems = useMemo(() => {
const itemMap = {
tools: {
key: 'tools',
title: '工具',
icon: <ToolOutlined />,
onClick: () => setIsToolsModalOpen(true),
},
proxy: {
key: 'proxy',
title: '代理',
icon: <GlobalOutlined />,
onClick: () => setIsProxyModalOpen(true),
},
theme: {
key: 'theme',
title: '主题',
icon: <SkinOutlined />,
onClick: () => setIsThemeModalOpen(true),
},
about: {
key: 'about',
title: '关于',
icon: <InfoCircleOutlined />,
onClick: () => setIsAboutOpen(true),
},
} as const;
return SIDEBAR_UTILITY_ITEM_KEYS.map((key) => itemMap[key]);
}, []);
const renderAIEdgeHandle = () => (
<Tooltip title="AI 助手">
<Button
type="text"
icon={<RobotOutlined />}
onClick={toggleAIPanel}
style={aiEdgeHandleStyle}
>
AI
</Button>
</Tooltip>
);
// Log Panel: 最小高度按“工具栏 + 1 条日志行(微增)”限制
@@ -1634,24 +1696,12 @@ function App() {
>
<div style={{ height: '100%', display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
<div style={{ padding: `12px ${sidebarHorizontalPadding}px 8px`, borderBottom: 'none', display: 'flex', alignItems: 'center', flexShrink: 0 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', width: '100%' }}>
<Tooltip title="工具"><Button type="text" icon={<ToolOutlined />} style={utilityButtonStyle} onClick={() => setIsToolsModalOpen(true)} /></Tooltip>
<Tooltip title="代理"><Button type="text" icon={<GlobalOutlined />} style={utilityButtonStyle} onClick={() => setIsProxyModalOpen(true)} /></Tooltip>
<Tooltip title="主题"><Button type="text" icon={<SkinOutlined />} style={utilityButtonStyle} onClick={() => setIsThemeModalOpen(true)} /></Tooltip>
<Tooltip title="关于"><Button type="text" icon={<InfoCircleOutlined />} style={utilityButtonStyle} onClick={() => setIsAboutOpen(true)} /></Tooltip>
<div style={{ width: 1, height: 16, background: 'rgba(128,128,128,0.2)', margin: '0 4px' }} />
<Tooltip title="AI 助手">
<Button
type="text"
icon={<RobotOutlined />}
onClick={toggleAIPanel}
style={{
...utilityButtonStyle,
color: aiPanelVisible ? (darkMode ? '#ffd666' : '#1677ff') : utilityButtonStyle.color,
background: aiPanelVisible ? (darkMode ? 'rgba(255,214,102,0.16)' : 'rgba(24,144,255,0.12)') : 'transparent'
}}
/>
</Tooltip>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, minmax(0, 1fr))', gap: 8, width: '100%' }}>
{sidebarUtilityItems.map((item) => (
<Tooltip key={item.key} title={item.title}>
<Button type="text" icon={item.icon} style={utilityButtonStyle} onClick={item.onClick} />
</Tooltip>
))}
</div>
</div>
<div style={{ padding: `0 ${sidebarHorizontalPadding}px 10px`, borderBottom: 'none', display: 'flex', alignItems: 'center', flexShrink: 0 }}>
@@ -1760,12 +1810,24 @@ function App() {
/>
</Sider>
<Content style={{ background: isLogPanelOpen ? bgContent : 'transparent', overflow: 'hidden', display: 'flex', flexDirection: 'column', minWidth: 0, flex: 1 }}>
<div style={{ flex: 1, minHeight: 0, minWidth: 0, overflow: 'hidden', display: 'flex', flexDirection: 'row' }}>
<div style={{ flex: 1, minHeight: 0, minWidth: 0, overflow: 'hidden', display: 'flex', flexDirection: 'row', position: 'relative' }}>
<div style={{ flex: 1, minHeight: 0, minWidth: 0, overflow: 'hidden', display: 'flex', flexDirection: 'column', background: bgContent, marginBottom: isLogPanelOpen ? 8 : 0, borderRadius: isLogPanelOpen ? windowCornerRadius : 0, clipPath: isLogPanelOpen ? `inset(0 round ${windowCornerRadius}px)` : 'none' }}>
<TabManager />
</div>
{aiEntryPlacement === 'content-edge' && aiEdgeHandleAttachment === 'content-shell' && (
<div style={aiEdgeHandleDockStyle}>
{renderAIEdgeHandle()}
</div>
)}
{aiPanelVisible && (
<AIChatPanel darkMode={darkMode} bgColor={bgContent} onClose={() => setAIPanelVisible(false)} onOpenSettings={() => setIsAISettingsOpen(true)} overlayTheme={overlayTheme} />
<div style={{ position: 'relative', display: 'flex', flexShrink: 0, overflow: 'visible' }}>
{aiEntryPlacement === 'content-edge' && aiEdgeHandleAttachment === 'panel-shell' && (
<div style={aiEdgeHandleDockStyle}>
{renderAIEdgeHandle()}
</div>
)}
<AIChatPanel darkMode={darkMode} bgColor={bgContent} onClose={() => setAIPanelVisible(false)} onOpenSettings={() => setIsAISettingsOpen(true)} overlayTheme={overlayTheme} />
</div>
)}
</div>
{isLogPanelOpen && (

View File

@@ -6,7 +6,6 @@ import { DBGetDatabases, DBGetTables } from '../../wailsjs/go/app/App';
import type { OverlayWorkbenchTheme } from '../utils/overlayWorkbenchTheme';
import { AIChatMessage, AIToolCall } from '../types';
import { DownOutlined } from '@ant-design/icons';
import { message as antdMessage } from 'antd';
import './AIChatPanel.css';
import { AIChatHeader } from './ai/AIChatHeader';
@@ -14,6 +13,12 @@ import { AIChatWelcome } from './ai/AIChatWelcome';
import { AIMessageBubble } from './ai/AIMessageBubble';
import { AIChatInput } from './ai/AIChatInput';
import { AIHistoryDrawer } from './ai/AIHistoryDrawer';
import type { AIComposerNotice } from '../utils/aiComposerNotice';
import {
buildMissingModelNotice,
buildMissingProviderNotice,
buildModelFetchFailedNotice,
} from '../utils/aiComposerNotice';
interface AIChatPanelProps {
width?: number;
@@ -211,6 +216,7 @@ export const AIChatPanel: React.FC<AIChatPanelProps> = ({
const [dynamicModels, setDynamicModels] = useState<string[]>([]);
const [showScrollBottom, setShowScrollBottom] = useState(false);
const [loadingModels, setLoadingModels] = useState(false);
const [composerNotice, setComposerNotice] = useState<AIComposerNotice | null>(null);
const [panelWidth, setPanelWidth] = useState(width);
const [isResizing, setIsResizing] = useState(false);
const [historyOpen, setHistoryOpen] = useState(false);
@@ -224,9 +230,6 @@ export const AIChatPanel: React.FC<AIChatPanelProps> = ({
const panelRef = useRef<HTMLDivElement>(null); // 面板 DOM ref用于拖拽时直接操作宽度
const dragWidthRef = useRef(0); // 拖拽过程中的实时宽度(不触发 React 重渲染)
// 面板内部 toast 通知(不在屏幕顶部,而在面板容器内显示)
const [messageApi, messageContextHolder] = antdMessage.useMessage({ getContainer: () => panelRef.current || document.body });
const aiChatHistory = useStore(state => state.aiChatHistory);
const aiActiveSessionId = useStore(state => state.aiActiveSessionId);
const createNewAISession = useStore(state => state.createNewAISession);
@@ -336,6 +339,7 @@ export const AIChatPanel: React.FC<AIChatPanelProps> = ({
useEffect(() => {
const handler = () => {
setDynamicModels([]);
setComposerNotice(null);
activeProviderIdRef.current = null;
loadActiveProvider();
};
@@ -350,6 +354,7 @@ export const AIChatPanel: React.FC<AIChatPanelProps> = ({
const payload = { ...activeProvider, model: val };
await Service?.AISaveProvider?.(payload);
setActiveProvider(payload);
setComposerNotice(null);
} catch (e) { console.warn('Failed to update provider model', e); }
};
@@ -358,33 +363,45 @@ export const AIChatPanel: React.FC<AIChatPanelProps> = ({
useEffect(() => {
if (activeProvider?.id && activeProvider.id !== activeProviderIdRef.current) {
setDynamicModels([]);
setComposerNotice(null);
activeProviderIdRef.current = activeProvider.id;
}
// 供应商被删除后 activeProvider 变为 null此时也必须清空残留模型
if (!activeProvider) {
setDynamicModels([]);
setComposerNotice(null);
activeProviderIdRef.current = null;
}
}, [activeProvider?.id, activeProvider]);
useEffect(() => {
if (activeProvider?.model && String(activeProvider.model).trim()) {
setComposerNotice(null);
}
}, [activeProvider?.model]);
// dynamicModels 仅在内存中使用,不再写回供应商配置,避免污染静态 models 列表
const fetchDynamicModels = useCallback(async () => {
try {
setLoadingModels(true);
setComposerNotice(null);
const Service = (window as any).go?.aiservice?.Service;
if (!Service) return;
const result = await Service.AIListModels?.();
if (result?.success && Array.isArray(result.models) && result.models.length > 0) {
const sortedModels = [...result.models].sort((a, b) => a.localeCompare(b));
setDynamicModels(sortedModels);
setComposerNotice(null);
} else if (result && !result.success) {
messageApi.warning(result.error || '获取模型列表失败,可手动输入模型名称');
setDynamicModels([]);
setComposerNotice(buildModelFetchFailedNotice(result.error));
}
} catch (e: any) {
console.warn('Failed to fetch models', e);
messageApi.warning('获取模型列表失败: ' + (e?.message || '未知错误'));
setDynamicModels([]);
setComposerNotice(buildModelFetchFailedNotice('获取模型列表失败:' + (e?.message || '未知错误')));
} finally {
setLoadingModels(false);
}
@@ -1030,13 +1047,14 @@ SELECT * FROM users WHERE status = 1;
// 前置校验:必须配置供应商且选择模型后才能发送
if (!activeProvider) {
messageApi.warning('请先在 AI 设置中配置供应商');
setComposerNotice(buildMissingProviderNotice());
return;
}
if (!activeProvider.model || !activeProvider.model.trim()) {
messageApi.warning('请先选择模型 ID点击工具栏的模型下拉框选择');
setComposerNotice(buildMissingModelNotice());
return;
}
setComposerNotice(null);
toolCallRoundRef.current = 0; // 重置工具调用轮次计数
nudgeCountRef.current = 0; // 重置催促计数
@@ -1258,7 +1276,6 @@ SELECT * FROM users WHERE status = 1;
return (
<div ref={panelRef} className="ai-chat-panel" style={{ width: panelWidth, background: bgColor || 'transparent', color: textColor, borderLeft: overlayTheme.shellBorder, position: 'relative' }}>
{messageContextHolder}
<div className={`ai-resize-handle${isResizing ? ' active' : ''}`} onMouseDown={handleResizeStart} />
{isResizing && panelRect.current && createPortal(
@@ -1366,6 +1383,7 @@ SELECT * FROM users WHERE status = 1;
activeProvider={activeProvider}
dynamicModels={dynamicModels}
loadingModels={loadingModels}
composerNotice={composerNotice}
onModelChange={handleModelChange}
onFetchModels={fetchDynamicModels}
textareaRef={textareaRef}

View File

@@ -2,6 +2,22 @@ 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 {
QWEN_BAILIAN_ANTHROPIC_BASE_URL,
QWEN_CODING_PLAN_ANTHROPIC_BASE_URL,
QWEN_CODING_PLAN_MODELS,
resolveProviderPresetKey,
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';
@@ -20,6 +36,7 @@ interface ProviderPreset {
desc: string;
color: string;
backendType: AIProviderType;
fixedApiFormat?: string;
defaultBaseUrl: string;
defaultModel: string;
models: string[];
@@ -28,12 +45,14 @@ interface ProviderPreset {
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', label: '通义千问', icon: <CloudOutlined />, desc: 'Qwen3.5 / Qwen3 系列', color: '#6366f1', backendType: 'openai', defaultBaseUrl: 'https://dashscope.aliyuncs.com/compatible-mode/v1', defaultModel: 'qwen-max', 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', label: '火山引擎', icon: <CloudOutlined />, desc: '火山方舟 / 豆包模型', color: '#0ea5e9', backendType: 'openai', defaultBaseUrl: 'https://ark.cn-beijing.volces.com/api/v3', defaultModel: 'ep-xxxxxx', 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: [] },
@@ -41,23 +60,9 @@ const PROVIDER_PRESETS: ProviderPreset[] = [
const findPreset = (key: string): ProviderPreset => PROVIDER_PRESETS.find(p => p.key === key) || PROVIDER_PRESETS[PROVIDER_PRESETS.length - 1];
const getProviderHostname = (raw?: string): string => {
if (!raw) return '';
try {
return new URL(raw).hostname.toLowerCase();
} catch {
return '';
}
};
const matchProviderPreset = (provider: Pick<AIProviderConfig, 'type' | 'baseUrl'>): ProviderPreset => {
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 matchProviderPreset = (provider: Pick<AIProviderConfig, 'type' | 'baseUrl' | 'apiFormat'>): ProviderPreset => {
const presetKey = resolveProviderPresetKey(provider, PROVIDER_PRESETS, 'custom');
return findPreset(presetKey);
};
const SAFETY_OPTIONS: { label: string; value: AISafetyLevel; desc: string; color: string; icon: string }[] = [
@@ -143,11 +148,22 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMo
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: matchedPreset.backendType, models: p.models || [], presetKey: matchedPreset.key, apiFormat: p.apiFormat || 'openai' });
form.setFieldsValue({
...p,
type: resolvedTransport.type,
models: p.models || [],
presetKey: matchedPreset.key,
apiFormat: resolvedTransport.apiFormat || p.apiFormat || 'openai',
});
};
const handleDeleteProvider = async (id: string) => {
@@ -179,24 +195,38 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMo
const Service = (window as any).go?.aiservice?.Service;
// 构建 payload处理 model/models 逻辑
const isCustomLike = values.presetKey === 'custom' || values.presetKey === 'ollama';
const preset = findPreset(values.presetKey);
const resolvedModels = isCustomLike ? (values.models || []) : preset.models;
const fallbackModel = resolvedModels.length > 0 ? resolvedModels[0] : '';
const finalModel = isCustomLike ? fallbackModel : (values.model || fallbackModel);
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 = values.baseUrl || preset.defaultBaseUrl;
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: values.apiFormat || 'openai',
apiFormat: resolvedTransport.apiFormat,
};
// 后端 AISaveProvider 统一处理新增和更新,返回 void失败抛异常
await Service?.AISaveProvider?.(payload);
@@ -240,8 +270,34 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMo
setTestStatus('idle');
const Service = (window as any).go?.aiservice?.Service;
const preset = findPreset(values.presetKey || 'openai');
const finalBaseUrl = values.baseUrl || preset.defaultBaseUrl;
const res = await Service?.AITestProvider?.({ ...values, baseUrl: finalBaseUrl, maxTokens: Number(values.maxTokens) || 4096, temperature: Number(values.temperature) ?? 0.7 });
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 || '测试失败'); }
@@ -250,9 +306,15 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMo
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: preset.backendType,
type: resolvedTransport.type,
apiFormat: resolvedTransport.apiFormat || 'openai',
baseUrl: preset.defaultBaseUrl,
model: preset.defaultModel,
});
@@ -307,7 +369,7 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMo
<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>
<span style={{ fontFamily: 'monospace', fontSize: 12 }}>{p.model || '未选择模型'}</span>
</div>
</div>
<Space size={2}>
@@ -353,25 +415,24 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMo
<AppstoreOutlined style={{ fontSize: 14 }} />
</div>
<Form.Item name="presetKey" noStyle>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 6 }}>
<div style={PROVIDER_PRESET_GRID_STYLE}>
{PROVIDER_PRESETS.map(pt => (
<div key={pt.key} onClick={() => { form.setFieldValue('presetKey', pt.key); handlePresetChange(pt.key); }}
style={{
padding: '12px 14px', borderRadius: 12, cursor: 'pointer', transition: 'all 0.2s ease',
...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)'),
display: 'flex', alignItems: 'flex-start', gap: 10,
}}>
<div style={{
color: presetKeyFromForm === pt.key ? overlayTheme.iconColor : overlayTheme.mutedText,
fontSize: 18, marginTop: 2, transition: 'all 0.2s ease',
fontSize: 18, marginTop: 2, transition: 'all 0.2s ease', flexShrink: 0,
}}>
{pt.icon}
</div>
<div>
<div style={{ fontSize: 13, fontWeight: 700, color: overlayTheme.titleText, lineHeight: 1.3 }}>{pt.label}</div>
<div style={{ fontSize: 12, color: overlayTheme.mutedText, marginTop: 4, lineHeight: 1.4 }}>{pt.desc}</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>
))}

View File

@@ -2101,7 +2101,7 @@ const ConnectionModal: React.FC<{
<Form.Item
name="user"
label="用户名"
rules={[createUriAwareRequiredRule('请输入用户名')]}
rules={dbType === 'mongodb' ? [] : [createUriAwareRequiredRule('请输入用户名')]}
style={{ marginBottom: 0 }}
>
<Input />
@@ -2115,6 +2115,7 @@ const ConnectionModal: React.FC<{
allowClear
placeholder="自动协商"
options={[
{ value: 'NONE', label: '无认证 (None)' },
{ value: 'SCRAM-SHA-1', label: 'SCRAM-SHA-1' },
{ value: 'SCRAM-SHA-256', label: 'SCRAM-SHA-256' },
{ value: 'MONGODB-AWS', label: 'MONGODB-AWS' },

View File

@@ -32,7 +32,9 @@ import 'react-resizable/css/styles.css';
import { buildOrderBySQL, buildPaginatedSelectSQL, buildWhereSQL, escapeLiteral, hasExplicitSort, quoteIdentPart, quoteQualifiedIdent, withSortBufferTuningSQL, type FilterCondition } from '../utils/sql';
import { isMacLikePlatform, normalizeOpacityForPlatform, resolveAppearanceValues } from '../utils/appearance';
import { getDataSourceCapabilities } from '../utils/dataSourceCapabilities';
import { resolvePaginationPageText, resolvePaginationSummaryText, resolvePaginationTotalForControl } from '../utils/dataGridPagination';
import { calculateTableBodyBottomPadding, calculateVirtualTableScrollX } from './dataGridLayout';
import { buildCopyInsertSQL, normalizeTemporalLiteralText } from './dataGridCopyInsert';
// --- Error Boundary ---
interface DataGridErrorBoundaryState {
@@ -569,32 +571,52 @@ const EditableCell: React.FC<EditableCellProps> = React.memo(({
}) => {
const [editing, setEditing] = useState(false);
const inputRef = useRef<any>(null);
const cellRef = useRef<HTMLElement>(null);
const pickerOpenRef = useRef(false);
const scrollLockRef = useRef<{ el: HTMLElement; handler: (e: WheelEvent) => void } | null>(null);
const form = useContext(EditableContext);
const cellContextMenuContext = useContext(CellContextMenuContext);
/** DatePicker 面板打开时锁定表格滚动,关闭时恢复 */
const lockTableScroll = useCallback((lock: boolean) => {
if (lock) {
// 查找虚拟滚动容器或常规滚动容器
const tableWrapper = cellRef.current?.closest?.('.ant-table-wrapper') as HTMLElement | null;
if (tableWrapper) {
const handler = (e: WheelEvent) => { e.preventDefault(); e.stopPropagation(); };
tableWrapper.addEventListener('wheel', handler, { capture: true, passive: false });
scrollLockRef.current = { el: tableWrapper, handler };
}
} else if (scrollLockRef.current) {
const { el, handler } = scrollLockRef.current;
el.removeEventListener('wheel', handler, { capture: true } as any);
scrollLockRef.current = null;
}
}, []);
useEffect(() => {
if (editing) {
// 每次进入编辑时强制设置表单值(覆盖 form store 中可能残留的旧值)
const raw = record[dataIndex];
const fieldName = getCellFieldName(record, dataIndex);
if (isDateTimeField) {
const dayjsVal = parseToDayjs(raw, pickerType);
setCellFieldValue(form, fieldName, dayjsVal);
} else {
const initialValue = typeof raw === 'string' ? normalizeDateTimeString(raw) : raw;
setCellFieldValue(form, fieldName, initialValue);
}
inputRef.current?.focus();
}
}, [editing]);
const toggleEdit = () => {
setEditing(!editing);
const raw = record[dataIndex];
const fieldName = getCellFieldName(record, dataIndex);
if (isDateTimeField) {
// 日期时间类型: 将字符串值转为 dayjs 对象供 DatePicker 使用
const dayjsVal = parseToDayjs(raw, pickerType);
setCellFieldValue(form, fieldName, dayjsVal);
} else {
const initialValue = typeof raw === 'string' ? normalizeDateTimeString(raw) : raw;
setCellFieldValue(form, fieldName, initialValue);
}
};
const save = async () => {
try {
if (!form) return;
if (!form || !editing) return;
const fieldName = getCellFieldName(record, dataIndex);
await form.validateFields([fieldName]);
let nextValue = form.getFieldValue(fieldName);
@@ -615,6 +637,8 @@ const EditableCell: React.FC<EditableCellProps> = React.memo(({
}
} catch (errInfo) {
console.log('Save failed:', errInfo);
// 日期时间类型保存失败时兜底退出编辑,避免 DatePicker 卡在编辑态
if (isDateTimeField && editing) setEditing(false);
}
};
@@ -640,6 +664,8 @@ const EditableCell: React.FC<EditableCellProps> = React.memo(({
style={{ width: '100%' }}
format={TEMPORAL_FORMATS[pickerType]}
onChange={() => setTimeout(save, 0)}
onOpenChange={lockTableScroll}
onBlur={() => setTimeout(save, 0)}
needConfirm={false}
/>
) : pickerType === 'datetime' ? (
@@ -647,12 +673,31 @@ const EditableCell: React.FC<EditableCellProps> = React.memo(({
ref={inputRef}
style={{ width: '100%' }}
showTime
showNow={false}
format={TEMPORAL_FORMATS[pickerType]}
renderExtraFooter={() => (
<a
style={{ padding: '0 2px' }}
onClick={() => {
// 自定义"此刻":仅将当前时间填入表单字段,面板保持打开。
// 用户需点击"确定"才真正保存,替代内置 showNow 的自动提交行为。
const fieldName = getCellFieldName(record, dataIndex);
setCellFieldValue(form, fieldName, dayjs());
}}
></a>
)}
onOk={() => setTimeout(save, 0)}
onOpenChange={(open) => {
// 面板关闭(点击外部)且非通过"确定"按钮触发时退出编辑,不保存
pickerOpenRef.current = open;
lockTableScroll(open);
// 面板关闭(点击外部)时退出编辑,不保存;仅"确定"按钮onOk触发保存
if (!open) setTimeout(() => { if (editing) toggleEdit(); }, 0);
}}
onBlur={() => {
// 兜底:面板未打开或已关闭时,点击外部通过 blur 退出编辑。
// 延迟检查面板状态,避免点击自定义"此刻"按钮时误退出(此时面板仍打开)。
setTimeout(() => { if (editing && !pickerOpenRef.current) setEditing(false); }, 150);
}}
needConfirm
/>
) : (
@@ -662,6 +707,8 @@ const EditableCell: React.FC<EditableCellProps> = React.memo(({
format={TEMPORAL_FORMATS[pickerType]}
picker={pickerType as any}
onChange={() => setTimeout(save, 0)}
onOpenChange={lockTableScroll}
onBlur={() => setTimeout(save, 0)}
needConfirm={false}
/>
)
@@ -720,6 +767,7 @@ const EditableCell: React.FC<EditableCellProps> = React.memo(({
return (
<Component
ref={cellRef}
{...restProps}
data-row-key={record ? String(record?.[GONAVI_ROW_KEY]) : undefined}
data-col-name={dataIndex || undefined}
@@ -818,6 +866,7 @@ interface DataGridProps {
total: number,
totalKnown?: boolean,
totalApprox?: boolean,
approximateTotal?: number,
totalCountLoading?: boolean,
totalCountCancelled?: boolean,
};
@@ -995,6 +1044,10 @@ const DataGrid: React.FC<DataGridProps> = ({
const selectionColumnWidth = 46;
const currentConnConfig = connections.find(c => c.id === connectionId)?.config;
const dataSourceCaps = getDataSourceCapabilities(currentConnConfig);
const prefersManualTotalCount = dataSourceCaps.preferManualTotalCount;
const supportsApproximateTableCount = dataSourceCaps.supportsApproximateTableCount;
const supportsApproximateTotalPages = dataSourceCaps.supportsApproximateTotalPages;
const dbType = dataSourceCaps.type;
const isDuckDBConnection = dataSourceCaps.type === 'duckdb';
const supportsCopyInsert = dataSourceCaps.supportsCopyInsert;
const supportsSqlQueryExport = dataSourceCaps.supportsSqlQueryExport;
@@ -1120,6 +1173,7 @@ const DataGrid: React.FC<DataGridProps> = ({
const [dataPanelValue, setDataPanelValue] = useState('');
const [dataPanelIsJson, setDataPanelIsJson] = useState(false);
const dataPanelDirtyRef = useRef(false);
const dataPanelOriginalRef = useRef('');
const [rowEditorOpen, setRowEditorOpen] = useState(false);
const [rowEditorRowKey, setRowEditorRowKey] = useState<string>('');
const rowEditorBaseRawRef = useRef<Record<string, any>>({});
@@ -1336,6 +1390,16 @@ const DataGrid: React.FC<DataGridProps> = ({
return next;
}, [columnMetaMap]);
const columnTypeMapByLowerName = useMemo(() => {
const next: Record<string, string> = {};
Object.entries(columnMetaMapByLowerName).forEach(([name, meta]) => {
const type = String(meta?.type || '').trim();
if (!name || !type) return;
next[name] = type;
});
return next;
}, [columnMetaMapByLowerName]);
const normalizeCommitCellValue = useCallback(
(columnName: string, value: any, mode: 'insert' | 'update') => {
if (value === undefined) return undefined;
@@ -1357,7 +1421,7 @@ const DataGrid: React.FC<DataGridProps> = ({
// INSERT 空时间值直接忽略字段让数据库默认值生效UPDATE 空时间值转 NULL。
return mode === 'insert' ? undefined : null;
}
return normalizeDateTimeString(value);
return normalizeTemporalLiteralText(value, meta?.type, true);
}
return value;
@@ -1432,14 +1496,18 @@ const DataGrid: React.FC<DataGridProps> = ({
const updateFocusedCell = useCallback((record: Item, dataIndex: string) => {
if (!record || !dataIndex) return;
const raw = record?.[dataIndex];
const text = toEditableText(raw);
let text = toEditableText(raw);
// 日期时间字段格式化(处理带时区的 ISO 格式如 2026-03-22T00:00:00+08:00
if (typeof raw === 'string') {
text = normalizeDateTimeString(raw);
}
const isJson = looksLikeJsonText(text);
setFocusedCellInfo({ record, dataIndex, title: dataIndex });
// 仅在面板未被用户手动编辑时自动同步值
if (!dataPanelDirtyRef.current) {
setDataPanelValue(text);
setDataPanelIsJson(isJson);
}
// 切换到新单元格时总是更新预览值并重置 dirty 标记
dataPanelOriginalRef.current = text;
setDataPanelValue(text);
setDataPanelIsJson(isJson);
dataPanelDirtyRef.current = false;
}, []);
const handleDataPanelFormatJson = useCallback(() => {
@@ -2836,28 +2904,49 @@ const DataGrid: React.FC<DataGridProps> = ({
}, []);
const handleCellSave = useCallback((row: any) => {
// Optimistic update for display
// In parent-controlled data, we might need parent to update 'data',
// but here we manage 'modifiedRows' locally and overlay it.
// Since 'displayData' is derived from 'data' + 'modifiedRows', we need to update the source if it's in 'data'.
// But 'data' prop is immutable.
// So we update 'modifiedRows'.
// Check if it's an added row
const rowKey = row?.[GONAVI_ROW_KEY];
if (rowKey === undefined) return;
const isAdded = addedRows.some(r => r?.[GONAVI_ROW_KEY] === rowKey);
if (isAdded) {
setAddedRows(prev => prev.map(r => r?.[GONAVI_ROW_KEY] === rowKey ? { ...r, ...row } : r));
} else {
// 查找原始行数据,对比是否真正有值变更
const originalRow = data.find(r => r?.[GONAVI_ROW_KEY] === rowKey);
if (originalRow) {
const changedFields: Record<string, any> = {};
for (const col of Object.keys(row)) {
if (col === GONAVI_ROW_KEY) continue;
if (!isCellValueEqualForDiff(originalRow[col], row[col])) {
changedFields[col] = row[col];
}
}
if (Object.keys(changedFields).length === 0) {
// 没有实际变更,从 modifiedRows 中移除该行(如有)
setModifiedRows(prev => {
const keyStr = rowKeyStr(rowKey);
if (!(keyStr in prev)) return prev;
const next = { ...prev };
delete next[keyStr];
return next;
});
return;
}
}
setModifiedRows(prev => ({ ...prev, [rowKeyStr(rowKey)]: row }));
}
}, [addedRows]);
}, [addedRows, data]);
const handleDataPanelSave = useCallback(() => {
if (!focusedCellInfo) return;
// 与 updateFocusedCell 设置的原始值比较,避免幽灵变更
if (dataPanelValue === dataPanelOriginalRef.current) {
dataPanelDirtyRef.current = false;
void message.info('数据未变更');
return;
}
const nextRow: any = { ...focusedCellInfo.record, [focusedCellInfo.dataIndex]: dataPanelValue };
handleCellSave(nextRow);
dataPanelOriginalRef.current = dataPanelValue;
dataPanelDirtyRef.current = false;
void message.success('已保存');
}, [focusedCellInfo, dataPanelValue, handleCellSave]);
@@ -3425,7 +3514,7 @@ const DataGrid: React.FC<DataGridProps> = ({
});
if (inserts.length === 0 && updates.length === 0 && deletes.length === 0) {
void message.info("No changes to commit");
void message.info("没有可提交的变更");
return;
}
@@ -3501,16 +3590,15 @@ const DataGrid: React.FC<DataGridProps> = ({
// 使用 columnNames 保持表定义的字段顺序,而非 Object.keys() 的不确定顺序
const orderedCols = columnNames.filter(c => c !== GONAVI_ROW_KEY);
const sqlList = records.map((r: any) => {
const values = orderedCols.map(c => {
const v = r[c];
if (v === null || v === undefined) return 'NULL';
const escaped = String(v).replace(/'/g, "''");
return `'${escaped}'`;
return buildCopyInsertSQL({
dbType,
tableName,
orderedCols,
record: r,
columnTypesByLowerName: columnTypeMapByLowerName,
});
const targetTable = tableName || 'table';
return `INSERT INTO \`${targetTable}\` (${orderedCols.map(c => `\`${c}\``).join(', ')}) VALUES (${values.join(', ')});`;
});
copyToClipboard(sqlList.join('\n')); }, [supportsCopyInsert, tableName, columnNames, getTargets, copyToClipboard]);
copyToClipboard(sqlList.join('\n')); }, [supportsCopyInsert, columnNames, getTargets, copyToClipboard, dbType, tableName, columnTypeMapByLowerName]);
const handleCopyJson = useCallback((record: any) => {
const records = getTargets(record);
@@ -4482,37 +4570,20 @@ const DataGrid: React.FC<DataGridProps> = ({
const paginationSummaryText = useMemo(() => {
if (!pagination) return '';
const total = Number.isFinite(pagination.total) ? pagination.total : 0;
const rangeStart = Math.max(0, (pagination.current - 1) * pagination.pageSize + (total > 0 ? 1 : 0));
const hasValidRange = total > 0 && rangeStart > 0;
const rangeEnd = hasValidRange ? Math.min(total, rangeStart + pagination.pageSize - 1) : 0;
const currentCount = hasValidRange ? Math.max(0, rangeEnd - rangeStart + 1) : 0;
if (pagination.totalKnown === false) {
if (isDuckDBConnection) {
if (pagination.totalCountLoading) return `当前 ${currentCount} 条 / 正在统计精确总数…`;
if (pagination.totalApprox && Number.isFinite(total) && total > 0) return `当前 ${currentCount} 条 / 约 ${total}`;
if (pagination.totalCountCancelled) return `当前 ${currentCount} 条 / 已取消统计`;
return `当前 ${currentCount} 条 / 总数未统计`;
}
return `当前 ${currentCount} 条 / 正在统计总数…`;
}
if (isDuckDBConnection && (!Number.isFinite(total) || total <= 0)) {
return '当前 0 条 / 共 0 条';
}
return `当前 ${currentCount} 条 / 共 ${total}`;
}, [pagination, isDuckDBConnection]);
return resolvePaginationSummaryText({
pagination,
prefersManualTotalCount,
supportsApproximateTableCount,
});
}, [pagination, prefersManualTotalCount, supportsApproximateTableCount]);
const paginationPageText = useMemo(() => {
if (!pagination) return '';
const total = Number.isFinite(pagination.total) ? pagination.total : 0;
const canShowTotalPages = pagination.totalKnown !== false || (isDuckDBConnection && pagination.totalApprox && total > 0);
if (!canShowTotalPages || total <= 0) return `${pagination.current}`;
const totalPages = Math.max(1, Math.ceil(total / Math.max(1, pagination.pageSize)));
return `${pagination.current} / ${totalPages}`;
}, [pagination, isDuckDBConnection]);
return resolvePaginationPageText({
pagination,
supportsApproximateTotalPages,
});
}, [pagination, supportsApproximateTotalPages]);
const handlePageSizeChange = useCallback((value: string) => {
if (!pagination || !onPageChange) return;
@@ -4679,7 +4750,7 @@ const DataGrid: React.FC<DataGridProps> = ({
</Tooltip>
</>
{isDuckDBConnection && onRequestTotalCount && (
{prefersManualTotalCount && onRequestTotalCount && (
<>
<div style={{ width: 1, background: toolbarDividerColor, height: 20, margin: '0 8px' }} />
<Tooltip title={pagination?.totalCountLoading ? '取消本次精确总数统计(不会影响当前浏览)' : '按当前筛选统计精确总数'}>
@@ -4779,7 +4850,11 @@ const DataGrid: React.FC<DataGridProps> = ({
padding: `${filterTopPadding}px ${panelPaddingX}px ${panelPaddingY}px ${panelPaddingX}px`,
background: 'transparent',
boxSizing: 'border-box',
display: 'flex',
flexDirection: 'column',
}}>
{/* 筛选条件 + 排序区域:固定最大高度,超出后可滚动,避免条件过多挤压数据表 */}
<div style={{ maxHeight: 200, overflowY: 'auto', overflowX: 'hidden', flex: '0 1 auto' }}>
{filterConditions.map((cond, condIndex) => (
<div key={cond.id} style={{ display: 'flex', gap: 8, marginBottom: 8, alignItems: 'flex-start', opacity: cond.enabled === false ? 0.58 : 1 }}>
<Checkbox
@@ -4922,14 +4997,17 @@ const DataGrid: React.FC<DataGridProps> = ({
}} />
</div>
))}
<Button type="dashed" size="small" icon={<PlusOutlined />} onClick={() => {
const next = [...sortInfo, { columnKey: displayColumnNames.find(c => !sortInfo.some(s => s.columnKey === c)) || displayColumnNames[0] || '', order: 'ascend', enabled: true }];
onSort(JSON.stringify(next), '');
}} disabled={sortInfo.length >= displayColumnNames.length} style={{ marginBottom: 4 }}></Button>
</div>
)}
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', alignItems: 'center', marginTop: (onSort && sortInfo.length > 0) ? 4 : 0, paddingTop: (onSort && sortInfo.length > 0) ? 6 : 0, borderTop: (onSort && sortInfo.length > 0) ? `1px dashed ${panelFrameColor}` : 'none' }}>
<Button type="dashed" onClick={addFilter} size="small" icon={<PlusOutlined />}></Button>
</div>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', alignItems: 'center', flex: '0 0 auto', marginTop: (onSort && sortInfo.length > 0) || filterConditions.length > 0 ? 4 : 0, paddingTop: (onSort && sortInfo.length > 0) || filterConditions.length > 0 ? 6 : 0, borderTop: (onSort && sortInfo.length > 0) || filterConditions.length > 0 ? `1px dashed ${panelFrameColor}` : 'none' }}>
<Button type="primary" ghost onClick={addFilter} size="small" icon={<PlusOutlined />}></Button>
{onSort && (
<Button type="dashed" size="small" icon={<PlusOutlined />} onClick={() => {
const next = [...sortInfo, { columnKey: displayColumnNames.find(c => !sortInfo.some(s => s.columnKey === c)) || displayColumnNames[0] || '', order: 'ascend', enabled: true }];
onSort(JSON.stringify(next), '');
}} disabled={sortInfo.length >= displayColumnNames.length}></Button>
)}
<div style={{ width: 1, height: 16, background: panelFrameColor, margin: '0 2px', flexShrink: 0 }} />
<Button size="small" onClick={() => setFilterConditions(prev => prev.map(c => ({ ...c, enabled: true })))}></Button>
<Button size="small" onClick={() => setFilterConditions(prev => prev.map(c => ({ ...c, enabled: false })))}></Button>
@@ -5289,8 +5367,10 @@ const DataGrid: React.FC<DataGridProps> = ({
theme={darkMode ? 'transparent-dark' : 'transparent-light'}
value={dataPanelValue}
onChange={(val) => {
setDataPanelValue(val || '');
dataPanelDirtyRef.current = true;
const newVal = val || '';
setDataPanelValue(newVal);
// 只有值真正与原始值不同时才标记 dirty
dataPanelDirtyRef.current = newVal !== dataPanelOriginalRef.current;
}}
options={{
minimap: { enabled: false },
@@ -5538,7 +5618,10 @@ const DataGrid: React.FC<DataGridProps> = ({
<Pagination
current={pagination.current}
pageSize={pagination.pageSize}
total={pagination.total}
total={resolvePaginationTotalForControl({
pagination,
supportsApproximateTotalPages,
})}
showSizeChanger={false}
onChange={onPageChange}
showTitle={false}

View File

@@ -6,6 +6,7 @@ import { DBGetDatabases, DBGetTables, DataSync, DataSyncAnalyze, DataSyncPreview
import { SavedConnection } from '../types';
import { EventsOn } from '../../wailsjs/runtime/runtime';
import { normalizeOpacityForPlatform, resolveAppearanceValues } from '../utils/appearance';
import { formatLocalDateTimeLiteral, normalizeTemporalLiteralText } from './dataGridCopyInsert';
const { Title, Text } = Typography;
const { Step } = Steps;
@@ -74,7 +75,10 @@ const toSqlLiteral = (value: any, dbType: string): string => {
return value ? 'TRUE' : 'FALSE';
}
if (value instanceof Date) {
return `'${value.toISOString().replace(/'/g, "''")}'`;
return `'${formatLocalDateTimeLiteral(value).replace(/'/g, "''")}'`;
}
if (typeof value === 'string') {
return `'${value.replace(/'/g, "''")}'`;
}
if (typeof value === 'object') {
try {
@@ -86,6 +90,20 @@ const toSqlLiteral = (value: any, dbType: string): string => {
return `'${String(value).replace(/'/g, "''")}'`;
};
const toTypedSqlLiteral = (value: any, dbType: string, columnType?: string): string => {
if (typeof value === 'string') {
const normalized = normalizeTemporalLiteralText(value, columnType, false);
return toSqlLiteral(normalized, dbType);
}
if (value instanceof Date) {
const normalized = String(columnType || '').trim()
? formatLocalDateTimeLiteral(value)
: value.toISOString();
return toSqlLiteral(normalized, dbType);
}
return toSqlLiteral(value, dbType);
};
const resolveRedisDbIndex = (raw?: string): number => {
const value = Number(String(raw || '').trim());
return Number.isInteger(value) && value >= 0 && value <= 15 ? value : 0;
@@ -100,6 +118,9 @@ const buildSqlPreview = (
if (!previewData || !tableName) return { sqlText: '', statementCount: 0 };
const tableExpr = quoteSqlTable(dbType, tableName);
const pkCol = String(previewData.pkColumn || 'id');
const columnTypesByLowerName = previewData?.columnTypes && typeof previewData.columnTypes === 'object'
? previewData.columnTypes as Record<string, string>
: {};
const statements: string[] = [];
const insertRows = Array.isArray(previewData.inserts) ? previewData.inserts : [];
@@ -118,7 +139,7 @@ const buildSqlPreview = (
const columns = Object.keys(row);
if (columns.length === 0) return;
const colExpr = columns.map((c) => quoteSqlIdent(dbType, c)).join(', ');
const valExpr = columns.map((c) => toSqlLiteral(row[c], dbType)).join(', ');
const valExpr = columns.map((c) => toTypedSqlLiteral(row[c], dbType, columnTypesByLowerName[String(c).toLowerCase()])).join(', ');
statements.push(`INSERT INTO ${tableExpr} (${colExpr}) VALUES (${valExpr});`);
});
}
@@ -134,10 +155,10 @@ const buildSqlPreview = (
const setCols = changedColumns.filter((c: string) => String(c) !== pkCol);
if (setCols.length === 0) return;
const setExpr = setCols
.map((c: string) => `${quoteSqlIdent(dbType, c)} = ${toSqlLiteral(source[c], dbType)}`)
.map((c: string) => `${quoteSqlIdent(dbType, c)} = ${toTypedSqlLiteral(source[c], dbType, columnTypesByLowerName[String(c).toLowerCase()])}`)
.join(', ');
statements.push(
`UPDATE ${tableExpr} SET ${setExpr} WHERE ${quoteSqlIdent(dbType, pkCol)} = ${toSqlLiteral(pk, dbType)};`,
`UPDATE ${tableExpr} SET ${setExpr} WHERE ${quoteSqlIdent(dbType, pkCol)} = ${toTypedSqlLiteral(pk, dbType, columnTypesByLowerName[String(pkCol).toLowerCase()])};`,
);
});
}
@@ -147,7 +168,7 @@ const buildSqlPreview = (
const pk = String(rowWrap?.pk ?? '');
if (selectedDelete.size > 0 && !selectedDelete.has(pk)) return;
statements.push(
`DELETE FROM ${tableExpr} WHERE ${quoteSqlIdent(dbType, pkCol)} = ${toSqlLiteral(pk, dbType)};`,
`DELETE FROM ${tableExpr} WHERE ${quoteSqlIdent(dbType, pkCol)} = ${toTypedSqlLiteral(pk, dbType, columnTypesByLowerName[String(pkCol).toLowerCase()])};`,
);
});
}

View File

@@ -6,7 +6,9 @@ import { DBQuery, DBGetColumns } from '../../wailsjs/go/app/App';
import DataGrid, { GONAVI_ROW_KEY } from './DataGrid';
import { buildOrderBySQL, buildPaginatedSelectSQL, buildWhereSQL, hasExplicitSort, quoteIdentPart, quoteQualifiedIdent, withSortBufferTuningSQL, type FilterCondition } from '../utils/sql';
import { buildMongoCountCommand, buildMongoFilter, buildMongoFindCommand, buildMongoSort } from '../utils/mongodb';
import { buildOracleApproximateTotalSql, parseApproximateTableCountRow, resolveApproximateTableCountStrategy } from '../utils/approximateTableCount';
import { getDataSourceCapabilities } from '../utils/dataSourceCapabilities';
import { resolveDataViewerAutoFetchAction } from '../utils/dataViewerAutoFetch';
type ViewerPaginationState = {
current: number;
@@ -14,6 +16,7 @@ type ViewerPaginationState = {
total: number;
totalKnown: boolean;
totalApprox: boolean;
approximateTotal?: number;
totalCountLoading: boolean;
totalCountCancelled: boolean;
};
@@ -70,30 +73,6 @@ const parseTotalFromCountRow = (row: any): number | null => {
return null;
};
const parseDuckDBApproxTotalRow = (row: any): number | null => {
if (!row || typeof row !== 'object') return null;
const entries = Object.entries(row as Record<string, unknown>);
if (entries.length === 0) return null;
const preferredKeys = ['approx_total', 'estimated_size', 'estimated_rows', 'row_count', 'count', 'total'];
for (const preferred of preferredKeys) {
for (const [key, raw] of entries) {
if (String(key || '').trim().toLowerCase() !== preferred) continue;
const parsed = toNonNegativeFiniteNumber(raw);
if (parsed !== null) return parsed;
}
}
for (const [key, raw] of entries) {
const normalized = String(key || '').trim().toLowerCase();
if (normalized.includes('estimate') || normalized.includes('row') || normalized.includes('count') || normalized.includes('total')) {
const parsed = toNonNegativeFiniteNumber(raw);
if (parsed !== null) return parsed;
}
}
return null;
};
const normalizeDuckDBIdentifier = (raw: string): string => {
const text = String(raw || '').trim();
if (text.length >= 2) {
@@ -201,7 +180,7 @@ const getViewerFilterSnapshot = (tabId: string): ViewerFilterSnapshot => {
};
};
const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isActive = true }) => {
const initialViewerSnapshot = useMemo(() => getViewerFilterSnapshot(tab.id), [tab.id]);
const [data, setData] = useState<any[]>([]);
const [columnNames, setColumnNames] = useState<string[]>([]);
@@ -214,6 +193,8 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
const countKeyRef = useRef<string>('');
const duckdbApproxSeqRef = useRef(0);
const duckdbApproxKeyRef = useRef<string>('');
const oracleApproxSeqRef = useRef(0);
const oracleApproxKeyRef = useRef<string>('');
const manualCountSeqRef = useRef(0);
const manualCountKeyRef = useRef<string>('');
const pkSeqRef = useRef(0);
@@ -228,6 +209,7 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
left: initialViewerSnapshot.scrollLeft,
});
const initialLoadRef = useRef(false);
const skipNextAutoFetchRef = useRef(false);
const [pagination, setPagination] = useState<ViewerPaginationState>({
current: initialViewerSnapshot.currentPage,
@@ -246,8 +228,10 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
const duckdbSafeSelectCacheRef = useRef<Record<string, string>>({});
const currentConnConfig = connections.find(c => c.id === tab.connectionId)?.config;
const currentConnCaps = getDataSourceCapabilities(currentConnConfig);
const currentConnType = currentConnCaps.type;
const forceReadOnly = currentConnCaps.forceReadOnlyQueryResult;
const preferManualTotalCount = currentConnCaps.preferManualTotalCount;
const supportsApproximateTableCount = currentConnCaps.supportsApproximateTableCount;
const supportsApproximateTotalPages = currentConnCaps.supportsApproximateTotalPages;
const persistViewerSnapshot = useCallback((tabId: string, overrides?: Partial<ViewerFilterSnapshot>) => {
const normalizedTabId = String(tabId || '').trim();
if (!normalizedTabId) return;
@@ -288,6 +272,7 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
pkKeyRef.current = '';
countKeyRef.current = '';
duckdbApproxKeyRef.current = '';
oracleApproxKeyRef.current = '';
manualCountKeyRef.current = '';
duckdbSafeSelectCacheRef.current = {};
latestConfigRef.current = null;
@@ -297,6 +282,7 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
latestCountKeyRef.current = '';
scrollSnapshotRef.current = { top: snapshot.scrollTop, left: snapshot.scrollLeft };
initialLoadRef.current = false;
skipNextAutoFetchRef.current = true;
setPagination(prev => ({
...prev,
current: snapshot.currentPage,
@@ -304,6 +290,7 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
total: 0,
totalKnown: false,
totalApprox: false,
approximateTotal: undefined,
totalCountLoading: false,
totalCountCancelled: false,
}));
@@ -317,10 +304,7 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
});
}, [tab.id, persistViewerSnapshot]);
const handleDuckDBManualCount = useCallback(async () => {
if (latestDbTypeRef.current !== 'duckdb') {
return;
}
const handleManualTotalCount = useCallback(async () => {
const config = latestConfigRef.current;
const dbName = latestDbNameRef.current;
const countSql = latestCountSqlRef.current;
@@ -341,7 +325,7 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
const resCount = await DBQuery(countConfig as any, dbName, countSql);
const countDuration = Date.now() - countStart;
addSqlLog({
id: `log-${Date.now()}-duckdb-manual-count`,
id: `log-${Date.now()}-manual-count`,
timestamp: Date.now(),
sql: countSql,
status: resCount?.success ? 'success' : 'error',
@@ -375,6 +359,7 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
total,
totalKnown: true,
totalApprox: false,
approximateTotal: undefined,
totalCountLoading: false,
totalCountCancelled: false,
}));
@@ -386,7 +371,7 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
}
}, [addSqlLog]);
const handleDuckDBCancelManualCount = useCallback(() => {
const handleCancelManualTotalCount = useCallback(() => {
manualCountSeqRef.current++;
setPagination(prev => ({ ...prev, totalCountLoading: false, totalCountCancelled: true }));
}, []);
@@ -438,7 +423,15 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
const totalRows = Number(pagination.total);
const hasFiniteTotal = Number.isFinite(totalRows) && totalRows >= 0;
const totalKnown = pagination.totalKnown && hasFiniteTotal;
const totalPages = hasFiniteTotal ? Math.max(1, Math.ceil(totalRows / size)) : 0;
const approximateTotalRows = Number(pagination.approximateTotal);
const hasApproximateTotalPages =
!totalKnown &&
supportsApproximateTotalPages &&
pagination.totalApprox &&
Number.isFinite(approximateTotalRows) &&
approximateTotalRows > 0;
const effectiveTotalRows = hasApproximateTotalPages ? approximateTotalRows : totalRows;
const totalPages = Number.isFinite(effectiveTotalRows) && effectiveTotalRows > 0 ? Math.max(1, Math.ceil(effectiveTotalRows / size)) : 0;
const currentPage = totalPages > 0 ? Math.min(Math.max(1, page), totalPages) : Math.max(1, page);
const offset = (currentPage - 1) * size;
const isClickHouse = !isMongoDB && dbTypeLower === 'clickhouse';
@@ -632,6 +625,7 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
total: derivedTotal,
totalKnown: true,
totalApprox: false,
approximateTotal: undefined,
totalCountLoading: false,
totalCountCancelled: false,
};
@@ -647,13 +641,20 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
}
}
const keepManualCounting = prev.totalCountLoading && manualCountKeyRef.current === countKey;
if (isDuckDB && prev.totalApprox && duckdbApproxKeyRef.current === countKey && Number.isFinite(prev.total) && prev.total >= minExpectedTotal) {
const hasApproximateTotalForCurrentKey =
prev.totalApprox &&
(duckdbApproxKeyRef.current === countKey || oracleApproxKeyRef.current === countKey) &&
Number.isFinite(prev.approximateTotal) &&
Number(prev.approximateTotal) >= minExpectedTotal;
if (hasApproximateTotalForCurrentKey) {
return {
...prev,
current: currentPage,
pageSize: size,
total: derivedTotal,
totalKnown: false,
totalApprox: true,
approximateTotal: prev.approximateTotal,
totalCountLoading: keepManualCounting,
totalCountCancelled: false,
};
@@ -665,12 +666,13 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
total: derivedTotal,
totalKnown: false,
totalApprox: false,
approximateTotal: undefined,
totalCountLoading: keepManualCounting,
totalCountCancelled: keepManualCounting ? false : prev.totalCountCancelled,
};
});
const shouldRunAsyncCount = !derivedTotalKnown && !isDuckDB;
const shouldRunAsyncCount = !derivedTotalKnown && !preferManualTotalCount;
if (shouldRunAsyncCount) {
if (countKeyRef.current !== countKey) {
countKeyRef.current = countKey;
@@ -695,7 +697,7 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
});
if (countSeqRef.current !== countSeq) return;
if (countKeyRef.current !== countKey) return;
if (latestCountKeyRef.current !== countKey) return;
if (!resCount.success) return;
if (!Array.isArray(resCount.data) || resCount.data.length === 0) return;
@@ -708,6 +710,7 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
total,
totalKnown: true,
totalApprox: false,
approximateTotal: undefined,
totalCountLoading: false,
totalCountCancelled: false,
}));
@@ -720,48 +723,88 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
}
}
if (isDuckDB && !derivedTotalKnown && whereSQL.trim() === '' && duckdbApproxKeyRef.current !== countKey) {
duckdbApproxKeyRef.current = countKey;
const approxSeq = ++duckdbApproxSeqRef.current;
const { schemaName, pureTableName } = resolveDuckDBSchemaAndTable(dbName, tableName);
const escapedSchema = escapeSQLLiteral(schemaName);
const escapedTable = escapeSQLLiteral(pureTableName);
const approxConfig: any = { ...(config as any), timeout: 3 };
const approxSqlCandidates = [
`SELECT estimated_size AS approx_total FROM duckdb_tables() WHERE schema_name='${escapedSchema}' AND table_name='${escapedTable}' LIMIT 1`,
`SELECT estimated_size AS approx_total FROM duckdb_tables() WHERE table_name='${escapedTable}' ORDER BY CASE WHEN schema_name='${escapedSchema}' THEN 0 ELSE 1 END LIMIT 1`,
];
if (!derivedTotalKnown) {
const approximateCountStrategy = supportsApproximateTableCount
? resolveApproximateTableCountStrategy({ dbType: dbTypeLower, whereSQL })
: 'none';
(async () => {
for (const approxSql of approxSqlCandidates) {
try {
const approxRes = await DBQuery(approxConfig as any, dbName, approxSql);
if (duckdbApproxSeqRef.current !== approxSeq) return;
if (countKeyRef.current !== countKey) return;
if (!approxRes?.success || !Array.isArray(approxRes.data) || approxRes.data.length === 0) continue;
if (approximateCountStrategy === 'duckdb-estimated-size' && duckdbApproxKeyRef.current !== countKey) {
duckdbApproxKeyRef.current = countKey;
const approxSeq = ++duckdbApproxSeqRef.current;
const { schemaName, pureTableName } = resolveDuckDBSchemaAndTable(dbName, tableName);
const escapedSchema = escapeSQLLiteral(schemaName);
const escapedTable = escapeSQLLiteral(pureTableName);
const approxConfig: any = { ...(config as any), timeout: 3 };
const approxSqlCandidates = [
`SELECT estimated_size AS approx_total FROM duckdb_tables() WHERE schema_name='${escapedSchema}' AND table_name='${escapedTable}' LIMIT 1`,
`SELECT estimated_size AS approx_total FROM duckdb_tables() WHERE table_name='${escapedTable}' ORDER BY CASE WHEN schema_name='${escapedSchema}' THEN 0 ELSE 1 END LIMIT 1`,
];
const approxTotal = parseDuckDBApproxTotalRow(approxRes.data[0]);
if (approxTotal === null) continue;
if (!Number.isFinite(approxTotal) || approxTotal < minExpectedTotal) continue;
(async () => {
for (const approxSql of approxSqlCandidates) {
try {
const approxRes = await DBQuery(approxConfig as any, dbName, approxSql);
if (duckdbApproxSeqRef.current !== approxSeq) return;
if (latestCountKeyRef.current !== countKey) return;
if (!approxRes?.success || !Array.isArray(approxRes.data) || approxRes.data.length === 0) continue;
const approxTotal = parseApproximateTableCountRow(approxRes.data[0]);
if (approxTotal === null) continue;
if (!Number.isFinite(approxTotal) || approxTotal < minExpectedTotal) continue;
setPagination(prev => {
if (latestCountKeyRef.current !== countKey) return prev;
if (prev.totalKnown) return prev;
return {
...prev,
totalKnown: false,
totalApprox: true,
approximateTotal: approxTotal,
totalCountCancelled: false,
};
});
return;
} catch {
if (duckdbApproxSeqRef.current !== approxSeq) return;
if (latestCountKeyRef.current !== countKey) return;
}
}
})();
}
if (approximateCountStrategy === 'oracle-num-rows' && oracleApproxKeyRef.current !== countKey) {
oracleApproxKeyRef.current = countKey;
const approxSeq = ++oracleApproxSeqRef.current;
const approxConfig: any = { ...(config as any), timeout: 3 };
const approxSql = buildOracleApproximateTotalSql({ dbName, tableName });
DBQuery(approxConfig as any, dbName, approxSql)
.then((approxRes: any) => {
if (oracleApproxSeqRef.current !== approxSeq) return;
if (latestCountKeyRef.current !== countKey) return;
if (!approxRes?.success || !Array.isArray(approxRes.data) || approxRes.data.length === 0) return;
const approxTotal = parseApproximateTableCountRow(approxRes.data[0], ['approx_total', 'num_rows', 'estimated_rows', 'row_count', 'count', 'total']);
if (approxTotal === null) return;
if (!Number.isFinite(approxTotal) || approxTotal < minExpectedTotal) return;
setPagination(prev => {
if (countKeyRef.current !== countKey) return prev;
if (latestCountKeyRef.current !== countKey) return prev;
if (prev.totalKnown) return prev;
return {
...prev,
total: approxTotal,
totalKnown: false,
totalApprox: true,
approximateTotal: approxTotal,
totalCountCancelled: false,
};
});
return;
} catch {
if (duckdbApproxSeqRef.current !== approxSeq) return;
if (countKeyRef.current !== countKey) return;
}
}
})();
})
.catch(() => {
if (oracleApproxSeqRef.current !== approxSeq) return;
if (latestCountKeyRef.current !== countKey) return;
});
}
}
} else {
message.error(String(resData.message || '查询失败'));
@@ -780,7 +823,7 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
});
}
if (fetchSeqRef.current === seq) setLoading(false);
}, [connections, tab, sortInfo, filterConditions, pkColumns, pagination.total, pagination.totalKnown]);
}, [connections, tab, sortInfo, filterConditions, pkColumns, pagination.total, pagination.totalKnown, pagination.totalApprox, pagination.approximateTotal, preferManualTotalCount, supportsApproximateTableCount, supportsApproximateTotalPages]);
// 依赖 pkColumns在无手动排序时可回退到主键稳定排序。
// 主键信息只会在首次加载后更新一次,避免循环查询。
@@ -828,7 +871,15 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
}, [tab.tableName, currentConnConfig?.type, filterConditions, sortInfo, pkColumns]);
useEffect(() => {
if (!initialLoadRef.current) {
const action = resolveDataViewerAutoFetchAction({
skipNextAutoFetch: skipNextAutoFetchRef.current,
hasInitialLoad: initialLoadRef.current,
});
if (action === 'skip') {
skipNextAutoFetchRef.current = false;
return;
}
if (action === 'load-current-page') {
initialLoadRef.current = true;
fetchData(pagination.current, pagination.pageSize);
return;
@@ -851,8 +902,8 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
onSort={handleSort}
onPageChange={handlePageChange}
pagination={pagination}
onRequestTotalCount={currentConnType === 'duckdb' ? handleDuckDBManualCount : undefined}
onCancelTotalCount={currentConnType === 'duckdb' ? handleDuckDBCancelManualCount : undefined}
onRequestTotalCount={preferManualTotalCount ? handleManualTotalCount : undefined}
onCancelTotalCount={preferManualTotalCount ? handleCancelManualTotalCount : undefined}
showFilter={showFilter}
onToggleFilter={handleToggleFilter}
onApplyFilter={handleApplyFilter}

View File

@@ -183,7 +183,7 @@ let sharedAllColumnsData: {dbName: string, tableName: string, name: string, type
let sharedVisibleDbs: string[] = [];
let sharedColumnsCacheData: Record<string, any[]> = {};
const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isActive = true }) => {
const [query, setQuery] = useState(tab.query || 'SELECT * FROM ');
type ResultSet = {
@@ -716,11 +716,16 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
// Prefer preloaded MySQL all-columns cache
let cols: { name: string, type?: string, tableName?: string, dbName?: string }[];
if (sharedAllColumnsData.length > 0) {
const tiTableLower = (tableInfo.tableName || '').toLowerCase();
cols = sharedAllColumnsData
.filter(c =>
(c.dbName || '').toLowerCase() === (tableInfo.dbName || '').toLowerCase() &&
(c.tableName || '').toLowerCase() === (tableInfo.tableName || '').toLowerCase()
)
.filter(c => {
if ((c.dbName || '').toLowerCase() !== (tableInfo.dbName || '').toLowerCase()) return false;
const cTableLower = (c.tableName || '').toLowerCase();
if (cTableLower === tiTableLower) return true;
// schema.table 格式匹配纯表名
const parsed = splitSchemaAndTable(c.tableName || '');
return (parsed.table || '').toLowerCase() === tiTableLower;
})
.map(c => ({ name: c.name, type: c.type, tableName: c.tableName, dbName: c.dbName }));
} else {
const dbCols = await getColumnsByDB(tableInfo.tableName);
@@ -773,7 +778,10 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
.filter(c => {
const fullIdent = `${c.dbName}.${c.tableName}`.toLowerCase();
const shortIdent = (c.tableName || '').toLowerCase();
return (foundTables.has(fullIdent) || foundTables.has(shortIdent)) && startsWithPrefix(c.name || '');
// 对 schema.table 格式,也用纯表名部分匹配(如 public.users → users
const parsed = splitSchemaAndTable(c.tableName || '');
const pureIdent = (parsed.table || '').toLowerCase();
return (foundTables.has(fullIdent) || foundTables.has(shortIdent) || (pureIdent && foundTables.has(pureIdent))) && startsWithPrefix(c.name || '');
})
.map(c => {
// 当前库的表字段优先级更高
@@ -788,24 +796,61 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
};
});
// 表提示:当前库显示表名,其他库显示 db.table 格式
// 表提示:当前库智能处理 schema.table 格式
// 1. 构建纯表名到 schema 列表的映射,检测同名表
const currentDbTables = sharedTablesData.filter(t =>
(t.dbName || '').toLowerCase() === currentDatabase.toLowerCase()
);
const tableNameToSchemas = new Map<string, string[]>();
for (const t of currentDbTables) {
const parsed = splitSchemaAndTable(t.tableName || '');
const pureTable = (parsed.table || t.tableName || '').toLowerCase();
const schemas = tableNameToSchemas.get(pureTable) || [];
schemas.push(parsed.schema || '');
tableNameToSchemas.set(pureTable, schemas);
}
const tableSuggestions = sharedTablesData
.filter(t => {
const isCurrentDb = (t.dbName || '').toLowerCase() === currentDatabase.toLowerCase();
const label = isCurrentDb ? t.tableName : `${t.dbName}.${t.tableName}`;
return startsWithPrefix(label || '');
if (!isCurrentDb) {
// 跨库:用 db.table 格式匹配
return startsWithPrefix(`${t.dbName}.${t.tableName}`);
}
// 当前库:同时用完整名和纯表名匹配
const parsed = splitSchemaAndTable(t.tableName || '');
const pureTable = parsed.table || t.tableName || '';
return startsWithPrefix(t.tableName || '') || startsWithPrefix(pureTable);
})
.map(t => {
const isCurrentDb = (t.dbName || '').toLowerCase() === currentDatabase.toLowerCase();
const label = isCurrentDb ? t.tableName : `${t.dbName}.${t.tableName}`;
const insertText = isCurrentDb ? t.tableName : `${t.dbName}.${t.tableName}`;
if (!isCurrentDb) {
const label = `${t.dbName}.${t.tableName}`;
return {
label,
kind: monaco.languages.CompletionItemKind.Class,
insertText: label,
detail: `Table (${t.dbName})`,
range,
sortText: sortGroups.tableOther + t.tableName,
};
}
// 当前库:检查是否有跨 schema 同名表
const parsed = splitSchemaAndTable(t.tableName || '');
const pureTable = parsed.table || t.tableName || '';
const schemas = tableNameToSchemas.get(pureTable.toLowerCase()) || [];
const hasDuplicate = schemas.length > 1;
// 同名表存在于多个 schema → 显示 schema.table否则只显示纯表名
const label = hasDuplicate ? t.tableName : pureTable;
const insertText = hasDuplicate ? t.tableName : pureTable;
const schemaInfo = parsed.schema ? ` (${parsed.schema})` : '';
return {
label,
kind: monaco.languages.CompletionItemKind.Class,
insertText,
detail: `Table (${t.dbName})`,
detail: `Table${schemaInfo}`,
range,
sortText: isCurrentDb ? sortGroups.tableCurrent + t.tableName : sortGroups.tableOther + t.tableName,
sortText: sortGroups.tableCurrent + pureTable,
};
});
@@ -1586,13 +1631,14 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
return;
}
const config = {
...conn.config,
const config = {
...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: "" }
ssh: conn.config.ssh || { host: "", port: 22, user: "", password: "", keyPath: "" },
timeout: Math.max(Number(conn.config.timeout) || 30, 120),
};
try {
@@ -1842,8 +1888,12 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
let simpleTableName: string | undefined = undefined;
if (rawStatement) {
// 支持多行 SQLSELECT * FROM [schema.]table [WHERE...] [ORDER BY...] [LIMIT...] 等
const tableMatch = rawStatement.match(/^\s*SELECT\s+\*\s+FROM\s+(?:[\w`"]+\.)?[`"]?(\w+)[`"]?\s*(?:$|[\s;])/im);
// 支持多行 SQLSELECT [cols] FROM [schema.]table [WHERE...] [ORDER BY...] [LIMIT...] 等
// JOIN 查询表名歧义,不提取
const hasJoin = /\bJOIN\b/i.test(rawStatement);
const tableMatch = !hasJoin
? rawStatement.match(/^\s*SELECT\s+.+?\s+FROM\s+(?:[\w`"\[\].]+\.)?[`"\[]?(\w+)[`"\]]?\s*(?:$|[\s;])/im)
: null;
if (tableMatch) {
simpleTableName = tableMatch[1];
if (!forceReadOnlyResult) {

View File

@@ -1,4 +1,4 @@
import React, { useState, useCallback, useRef } from 'react';
import React, { useState, useCallback, useRef, useEffect } from 'react';
import { Button, Space, message } from 'antd';
import { PlayCircleOutlined, ClearOutlined } from '@ant-design/icons';
import { useStore } from '../store';
@@ -14,6 +14,67 @@ interface CommandResult {
result: any;
error?: string;
timestamp: number;
durationMs: number;
}
// 智能解析 Redis 脚本块,保护多行引号内的换行符
function parseRedisScriptBlocks(script: string): string[] {
const blocks: string[] = [];
let currentBlock = "";
let inQuote: string | null = null;
let isEscaping = false;
const lines = script.split('\n');
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const trimmed = line.trim();
if (!inQuote && (trimmed === '' || trimmed.startsWith('//') || trimmed.startsWith('#'))) {
continue;
}
for (let j = 0; j < line.length; j++) {
const char = line[j];
if (isEscaping) {
isEscaping = false;
currentBlock += char;
continue;
}
if (char === '\\') {
isEscaping = true;
currentBlock += char;
continue;
}
if (char === '"' || char === "'") {
if (inQuote === char) {
inQuote = null;
} else if (!inQuote) {
inQuote = char;
}
}
currentBlock += char;
}
if (inQuote || (i < lines.length - 1 && currentBlock.trim() !== '')) {
if (!inQuote) {
blocks.push(currentBlock.trim());
currentBlock = "";
} else {
currentBlock += '\n';
}
}
}
if (currentBlock.trim() !== '') {
blocks.push(currentBlock.trim());
}
return blocks.filter(b => b.trim() !== '');
}
const RedisCommandEditor: React.FC<RedisCommandEditorProps> = ({ connectionId, redisDB }) => {
@@ -23,6 +84,13 @@ const RedisCommandEditor: React.FC<RedisCommandEditorProps> = ({ connectionId, r
const [command, setCommand] = useState('');
const [results, setResults] = useState<CommandResult[]>([]);
const [loading, setLoading] = useState(false);
// UI Layout state
const [editorHeight, setEditorHeight] = useState(250);
const dragRef = useRef<{ startY: number; startHeight: number } | null>(null);
const containerRef = useRef<HTMLDivElement>(null);
const resultsEndRef = useRef<HTMLDivElement>(null);
const editorRef = useRef<any>(null);
const getConfig = useCallback(() => {
@@ -37,77 +105,173 @@ const RedisCommandEditor: React.FC<RedisCommandEditorProps> = ({ connectionId, r
};
}, [connection, redisDB]);
const handleEditorMount: OnMount = (editor) => {
const handleEditorMount: OnMount = (editor, monaco) => {
editorRef.current = editor;
// Add keyboard shortcut for execute
editor.addCommand(
// Ctrl/Cmd + Enter
2048 | 3, // KeyMod.CtrlCmd | KeyCode.Enter
monaco.KeyMod.CtrlCmd | monaco.KeyCode.Enter,
() => handleExecute()
);
if (!(window as any).__redisCompletionRegistered) {
(window as any).__redisCompletionRegistered = true;
const redisCommands = [
"APPEND", "AUTH", "BGREWRITEAOF", "BGSAVE", "BITCOUNT", "BITFIELD", "BITOP",
"BITPOS", "BLPOP", "BRPOP", "BRPOPLPUSH", "BZMPOP", "BZPOPMIN", "BZPOPMAX",
"CLIENT", "CLUSTER", "COMMAND", "CONFIG", "DBSIZE", "DEBUG", "DECR", "DECRBY",
"DEL", "DISCARD", "DUMP", "ECHO", "EVAL", "EVALSHA", "EXEC", "EXISTS", "EXPIRE",
"EXPIREAT", "EXPIRETIME", "FLUSHALL", "FLUSHDB", "GEOADD", "GEODIST", "GEOHASH",
"GEOPOS", "GEORADIUS", "GEORADIUSBYMEMBER", "GEOSEARCH", "GEOSEARCHSTORE",
"GET", "GETBIT", "GETDEL", "GETEX", "GETRANGE", "GETSET", "HDEL", "HELLO",
"HEXISTS", "HGET", "HGETALL", "HINCRBY", "HINCRBYFLOAT", "HKEYS", "HLEN",
"HMGET", "HMSET", "HSCAN", "HSET", "HSETNX", "HSTRLEN", "HVALS", "INCR",
"INCRBY", "INCRBYFLOAT", "INFO", "KEYS", "LASTSAVE", "LCS", "LINDEX", "LINSERT",
"LLEN", "LMOVE", "LMPOP", "LPOP", "LPOS", "LPUSH", "LPUSHX", "LRANGE", "LREM",
"LSET", "LTRIM", "MEMORY", "MGET", "MIGRATE", "MODULE", "MONITOR", "MOVE", "MSET",
"MSETNX", "MULTI", "OBJECT", "PERSIST", "PEXPIRE", "PEXPIREAT", "PEXPIRETIME",
"PFADD", "PFCOUNT", "PFMERGE", "PING", "PSETEX", "PSUBSCRIBE", "PTTL", "PUBLISH",
"PUBSUB", "PUNSUBSCRIBE", "QUIT", "RANDOMKEY", "READONLY", "READWRITE", "RENAME",
"RENAMENX", "RESET", "RESTORE", "ROLE", "RPOP", "RPOPLPUSH", "RPUSH", "RPUSHX",
"SADD", "SAVE", "SCAN", "SCARD", "SCRIPT", "SDIFF", "SDIFFSTORE", "SELECT",
"SET", "SETBIT", "SETEX", "SETNX", "SETRANGE", "SHUTDOWN", "SINTER", "SINTERCARD",
"SINTERSTORE", "SISMEMBER", "SLAVEOF", "SLOWLOG", "SMEMBERS", "SMISMEMBER",
"SMOVE", "SORT", "SORT_RO", "SPOP", "SRANDMEMBER", "SREM", "SSCAN", "STRLEN",
"SUBSCRIBE", "SUNION", "SUNIONSTORE", "SWAPDB", "SYNC", "TIME", "TOUCH", "TTL",
"TYPE", "UNLINK", "UNSUBSCRIBE", "UNWATCH", "WAIT", "WATCH", "XACK", "XADD",
"XAUTOCLAIM", "XCLAIM", "XDEL", "XGROUP", "XINFO", "XLEN", "XPENDING", "XRANGE",
"XREAD", "XREADGROUP", "XREVRANGE", "XTRIM", "ZADD", "ZCARD", "ZCOUNT", "ZDIFF",
"ZDIFFSTORE", "ZINCRBY", "ZINTER", "ZINTERCARD", "ZINTERSTORE", "ZLEXCOUNT",
"ZMPOP", "ZMSCORE", "ZPOPMAX", "ZPOPMIN", "ZRANDMEMBER", "ZRANGE", "ZRANGEBYLEX",
"ZRANGEBYSCORE", "ZRANK", "ZREM", "ZREMRANGEBYLEX", "ZREMRANGEBYRANK",
"ZREMRANGEBYSCORE", "ZREVRANGE", "ZREVRANGEBYLEX", "ZREVRANGEBYSCORE", "ZREVRANK",
"ZSCAN", "ZSCORE", "ZUNION", "ZUNIONSTORE"
];
monaco.languages.registerCompletionItemProvider('redis', {
provideCompletionItems: (model: any, position: any) => {
const word = model.getWordUntilPosition(position);
const range = {
startLineNumber: position.lineNumber,
endLineNumber: position.lineNumber,
startColumn: word.startColumn,
endColumn: word.endColumn
};
return {
suggestions: redisCommands.map(cmd => ({
label: cmd,
kind: monaco.languages.CompletionItemKind.Keyword,
insertText: cmd,
range: range,
detail: "Redis Command"
}))
};
}
});
}
};
const handleExecute = async () => {
const config = getConfig();
if (!config) return;
const cmdToExecute = command.trim();
let cmdToExecute = '';
// 1. 获取用户是否有高亮选中的文本
const selection = editorRef.current?.getSelection();
if (selection && !selection.isEmpty()) {
cmdToExecute = editorRef.current?.getModel()?.getValueInRange(selection) || '';
} else {
// 没有选中则取全部文本
cmdToExecute = editorRef.current?.getValue() || '';
}
cmdToExecute = cmdToExecute.trim();
if (!cmdToExecute) {
message.warning('请输入命令');
message.warning('请输入要执行的命令');
return;
}
// Support multiple commands separated by newlines
const commands = cmdToExecute.split('\n').filter(c => c.trim() && !c.trim().startsWith('//') && !c.trim().startsWith('#'));
// 2. 智能解析多行命令
const commands = parseRedisScriptBlocks(cmdToExecute);
if (commands.length === 0) return;
setLoading(true);
const newResults: CommandResult[] = [];
for (const cmd of commands) {
const trimmedCmd = cmd.trim();
if (!trimmedCmd) continue;
const start = Date.now();
try {
const res = await (window as any).go.app.App.RedisExecuteCommand(config, trimmedCmd);
const res = await (window as any).go.app.App.RedisExecuteCommand(config, cmd);
newResults.push({
command: trimmedCmd,
command: cmd,
result: res.success ? res.data : null,
error: res.success ? undefined : res.message,
timestamp: Date.now()
timestamp: Date.now(),
durationMs: Date.now() - start
});
} catch (e: any) {
newResults.push({
command: trimmedCmd,
command: cmd,
result: null,
error: e?.message || String(e),
timestamp: Date.now()
timestamp: Date.now(),
durationMs: Date.now() - start
});
}
}
setResults(prev => [...newResults, ...prev]);
setResults(prev => [...prev, ...newResults]);
setLoading(false);
};
// Auto scroll to bottom when new results arrive
useEffect(() => {
if (resultsEndRef.current) {
resultsEndRef.current.scrollIntoView({ behavior: 'smooth' });
}
}, [results]);
const handleClear = () => {
setResults([]);
};
const formatResult = (result: any): string => {
const formatResult = (result: any): React.ReactNode => {
if (result === null || result === undefined) {
return '(nil)';
return <span style={{ color: '#569cd6' }}>(nil)</span>;
}
if (typeof result === 'string') {
return `"${result}"`;
// 尝试美化 JSON 字符串
try {
const parsed = JSON.parse(result);
if (typeof parsed === 'object' && parsed !== null) {
return (
<div style={{ marginTop: 4, padding: 8, background: 'rgba(0,0,0,0.2)', borderRadius: 4 }}>
{JSON.stringify(parsed, null, 2)}
</div>
);
}
} catch (e) {
// not a valid json, just return string
}
return <span style={{ color: '#ce9178' }}>"{result}"</span>;
}
if (typeof result === 'number') {
return `(integer) ${result}`;
return <span style={{ color: '#b5cea8' }}>(integer) {result}</span>;
}
if (Array.isArray(result)) {
if (result.length === 0) {
return '(empty array)';
}
return result.map((item, index) => `${index + 1}) ${formatResult(item)}`).join('\n');
return (
<div style={{ marginLeft: 8 }}>
{result.map((item, index) => (
<div key={index} style={{ display: 'flex' }}>
<span style={{ color: '#608b4e', marginRight: 8, userSelect: 'none' }}>{index + 1})</span>
<div>{formatResult(item)}</div>
</div>
))}
</div>
);
}
if (typeof result === 'object') {
return JSON.stringify(result, null, 2);
@@ -115,18 +279,56 @@ const RedisCommandEditor: React.FC<RedisCommandEditorProps> = ({ connectionId, r
return String(result);
};
// Resizing logic
const handleDragStart = (e: React.MouseEvent) => {
e.preventDefault();
dragRef.current = { startY: e.clientY, startHeight: editorHeight };
document.addEventListener('mousemove', handleDragMove);
document.addEventListener('mouseup', handleDragEnd);
document.body.style.cursor = 'row-resize';
};
const handleDragMove = useCallback((e: MouseEvent) => {
if (!dragRef.current) return;
const delta = e.clientY - dragRef.current.startY;
let newHeight = dragRef.current.startHeight + delta;
// 限制高度
const minHeight = 100;
const maxHeight = containerRef.current ? containerRef.current.clientHeight - 100 : 800;
if (newHeight < minHeight) newHeight = minHeight;
if (newHeight > maxHeight) newHeight = maxHeight;
setEditorHeight(newHeight);
// 更新编辑器布局
if (editorRef.current) {
editorRef.current.layout();
}
}, []);
const handleDragEnd = useCallback(() => {
dragRef.current = null;
document.removeEventListener('mousemove', handleDragMove);
document.removeEventListener('mouseup', handleDragEnd);
document.body.style.cursor = 'default';
if (editorRef.current) {
editorRef.current.layout();
}
}, [handleDragMove]);
if (!connection) {
return <div style={{ padding: 20 }}></div>;
}
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
{/* Command Input */}
<div style={{ borderBottom: '1px solid #f0f0f0' }}>
<div style={{ padding: '8px 12px', borderBottom: '1px solid #f0f0f0', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div ref={containerRef} style={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden', background: '#fff' }}>
{/* Editor Top Pane */}
<div style={{ height: editorHeight, minHeight: 100, display: 'flex', flexDirection: 'column' }}>
<div style={{ padding: '8px 12px', borderBottom: '1px solid #f0f0f0', display: 'flex', justifyContent: 'space-between', alignItems: 'center', background: '#fdfdfd' }}>
<Space>
<span style={{ fontWeight: 500 }}>Redis </span>
<span style={{ color: '#999', fontSize: 12 }}>db{redisDB}</span>
<span style={{ fontWeight: 600 }}>Redis Console</span>
<span style={{ color: '#888', fontSize: 13, background: '#f0f0f0', padding: '2px 8px', borderRadius: 12 }}>db{redisDB}</span>
</Space>
<Space>
<Button
@@ -135,68 +337,89 @@ const RedisCommandEditor: React.FC<RedisCommandEditorProps> = ({ connectionId, r
onClick={handleExecute}
loading={loading}
>
(Ctrl+Enter)
(Cmd+Enter)
</Button>
<Button icon={<ClearOutlined />} onClick={handleClear}></Button>
</Space>
</div>
<Editor
height="150px"
defaultLanguage="plaintext"
value={command}
onChange={(value) => setCommand(value || '')}
onMount={handleEditorMount}
options={{
minimap: { enabled: false },
lineNumbers: 'on',
fontSize: 14,
wordWrap: 'on',
scrollBeyondLastLine: false,
automaticLayout: true,
tabSize: 2
}}
/>
<div style={{ flex: 1, position: 'relative' }}>
<Editor
defaultLanguage="redis"
language="redis"
value={command}
onChange={(value) => setCommand(value || '')}
onMount={handleEditorMount}
options={{
minimap: { enabled: false },
lineNumbers: 'on',
fontSize: 14,
wordWrap: 'on',
scrollBeyondLastLine: false,
automaticLayout: true,
tabSize: 4,
padding: { top: 10, bottom: 10 }
}}
/>
</div>
</div>
{/* Results */}
<div style={{ flex: 1, overflow: 'auto', background: '#1e1e1e', color: '#d4d4d4', fontFamily: 'monospace' }}>
{results.length === 0 ? (
<div style={{ padding: 20, color: '#666', textAlign: 'center' }}>
Redis Ctrl+Enter
<br />
<span style={{ fontSize: 12 }}></span>
</div>
) : (
results.map((item, index) => (
<div key={item.timestamp + index} style={{ padding: '8px 12px', borderBottom: '1px solid #333' }}>
<div style={{ color: '#569cd6', marginBottom: 4 }}>
&gt; {item.command}
{/* Resizer Handle */}
<div
className="horizontal-resizer"
onMouseDown={handleDragStart}
style={{
height: 8,
cursor: 'row-resize',
background: '#f0f0f0',
borderTop: '1px solid #e0e0e0',
borderBottom: '1px solid #e0e0e0',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
zIndex: 10
}}
>
<div style={{ width: 40, height: 4, background: '#ccc', borderRadius: 2 }} />
</div>
{/* Results Terminal Bottom Pane */}
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
<div style={{ padding: '4px 12px', background: '#252526', display: 'flex', justifyContent: 'space-between', alignItems: 'center', borderBottom: '1px solid #333' }}>
<span style={{ color: '#ccc', fontSize: 12 }}>Execution Output</span>
<Button type="text" size="small" icon={<ClearOutlined />} onClick={handleClear} style={{ color: '#aaa' }}></Button>
</div>
<div style={{ flex: 1, overflow: 'auto', background: '#1e1e1e', color: '#d4d4d4', fontFamily: '"Consolas", "Courier New", monospace', fontSize: 13, padding: 12 }}>
{results.length === 0 ? (
<div style={{ color: '#666', textAlign: 'center', marginTop: 40 }}>
<div></div>
<div style={{ fontSize: 12, marginTop: 12 }}>
Tips: <code></code> <code style={{ color: '#999' }}>Ctrl + Enter</code>
</div>
{item.error ? (
<div style={{ color: '#f14c4c', whiteSpace: 'pre-wrap' }}>
(error) {item.error}
</div>
) : (
<div style={{ color: '#ce9178', whiteSpace: 'pre-wrap' }}>
{formatResult(item.result)}
</div>
)}
</div>
))
)}
</div>
{/* Common Commands Help */}
<div style={{ padding: '8px 12px', borderTop: '1px solid #f0f0f0', background: '#fafafa', fontSize: 12, color: '#666' }}>
:
<span style={{ marginLeft: 8 }}>
<code>KEYS *</code> |
<code style={{ marginLeft: 8 }}>GET key</code> |
<code style={{ marginLeft: 8 }}>SET key value</code> |
<code style={{ marginLeft: 8 }}>HGETALL key</code> |
<code style={{ marginLeft: 8 }}>INFO</code> |
<code style={{ marginLeft: 8 }}>DBSIZE</code>
</span>
) : (
results.map((item, index) => (
<div key={item.timestamp + index} style={{ marginBottom: 16 }}>
<div style={{ color: '#569cd6', marginBottom: 6, fontWeight: 'bold' }}>
<span style={{ color: '#4CAF50', marginRight: 8 }}></span>
{item.command}
<span style={{ color: '#666', fontSize: 11, marginLeft: 12, fontWeight: 'normal' }}>[{item.durationMs}ms]</span>
</div>
<div style={{ paddingLeft: 20 }}>
{item.error ? (
<div style={{ color: '#f14c4c', whiteSpace: 'pre-wrap' }}>
(error) {item.error}
</div>
) : (
<div style={{ whiteSpace: 'pre-wrap' }}>
{formatResult(item.result)}
</div>
)}
</div>
</div>
))
)}
<div ref={resultsEndRef} />
</div>
</div>
</div>
);

View File

@@ -0,0 +1,378 @@
import React, { useState, useEffect, useRef, useMemo } from 'react';
import { Card, Row, Col, Statistic, Select, Button, message, Tag, Typography, Tooltip, Spin } from 'antd';
import { AreaChart, Area, XAxis, YAxis, Tooltip as RechartsTooltip, ResponsiveContainer, CartesianGrid, Legend, LineChart, Line } from 'recharts';
import {
DesktopOutlined,
DashboardOutlined,
ApiOutlined,
HddOutlined,
ReloadOutlined,
PlayCircleOutlined,
PauseCircleOutlined
} from '@ant-design/icons';
import { useStore } from '../store';
import { SavedConnection } from '../types';
import { RedisGetServerInfo } from '../../wailsjs/go/app/App';
const { Title, Text } = Typography;
interface RedisMonitorProps {
connectionId: string;
redisDB: number;
}
// Data point for charts
interface MetricPoint {
time: string;
qps: number;
memory: number; // in MB
memory_rss: number; // in MB
clients: number;
cpuSys: number;
cpuUser: number;
hitRate: number;
keys: number;
}
const MAX_HISTORY_POINTS = 60; // Keep up to 60 data points
const RedisMonitor: React.FC<RedisMonitorProps> = ({ connectionId, redisDB }) => {
const connections = useStore(state => state.connections);
const theme = useStore(state => state.theme);
const darkMode = theme === 'dark';
const [isRunning, setIsRunning] = useState(true);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [history, setHistory] = useState<MetricPoint[]>([]);
const [currentInfo, setCurrentInfo] = useState<Record<string, string>>({});
// Ref to track if component is mounted to prevent state updates after unmount
const mountedRef = useRef(true);
// Interval ref
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
// Previous ops counter to calculate QPS if instantaneous_ops_per_sec is not enough
const prevMetricsRef = useRef({ prevOps: 0, prevTime: 0 });
const connection = connections.find((c: SavedConnection) => c.id === connectionId);
const fetchMetrics = async () => {
if (!connection) return;
try {
const config = { ...connection.config, redisDB } as any;
const res = await RedisGetServerInfo(config);
if (!mountedRef.current) return;
if (!res.success) {
setError(res.message || 'Failed to fetch Redis info');
return;
}
setError(null);
const infoMap = res.data as Record<string, string>;
setCurrentInfo(infoMap);
const now = new Date();
const timeStr = now.toLocaleTimeString([], { hour12: false, second: '2-digit' });
// Parse values
const qps = parseInt(infoMap['instantaneous_ops_per_sec'] || '0', 10);
const memBytes = parseInt(infoMap['used_memory'] || '0', 10);
const memRssBytes = parseInt(infoMap['used_memory_rss'] || '0', 10);
const clients = parseInt(infoMap['connected_clients'] || '0', 10);
const cpuSys = parseFloat(infoMap['used_cpu_sys'] || '0');
const cpuUser = parseFloat(infoMap['used_cpu_user'] || '0');
const hits = parseInt(infoMap['keyspace_hits'] || '0', 10);
const misses = parseInt(infoMap['keyspace_misses'] || '0', 10);
const hitRate = (hits + misses) > 0 ? (hits / (hits + misses)) * 100 : 0;
let keys = 0;
Object.keys(infoMap).forEach(k => {
if (k.startsWith('db')) {
const m = infoMap[k].match(/keys=(\d+)/);
if (m) keys += parseInt(m[1], 10);
}
});
const point: MetricPoint = {
time: timeStr,
qps,
memory: parseFloat((memBytes / 1024 / 1024).toFixed(2)),
memory_rss: parseFloat((memRssBytes / 1024 / 1024).toFixed(2)),
clients,
cpuSys: parseFloat(cpuSys.toFixed(2)),
cpuUser: parseFloat(cpuUser.toFixed(2)),
hitRate: parseFloat(hitRate.toFixed(2)),
keys
};
setHistory(prev => {
const next = [...prev, point];
if (next.length > MAX_HISTORY_POINTS) {
return next.slice(next.length - MAX_HISTORY_POINTS);
}
return next;
});
if (loading) setLoading(false);
} catch (err: any) {
if (mountedRef.current) {
setError(err.message || 'Unknown error');
if (loading) setLoading(false);
}
}
};
useEffect(() => {
mountedRef.current = true;
fetchMetrics(); // initial fetch
return () => {
mountedRef.current = false;
if (intervalRef.current) clearInterval(intervalRef.current);
};
}, []);
useEffect(() => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
}
if (isRunning) {
intervalRef.current = setInterval(fetchMetrics, 2000); // 2 second interval
}
return () => {
if (intervalRef.current) clearInterval(intervalRef.current);
};
}, [isRunning, connectionId, redisDB, connection]);
if (!connection) {
return <div style={{ padding: 20 }}>Connection not found.</div>;
}
// Determine styles for charts based on theme
const chartTextColor = darkMode ? 'rgba(255,255,255,0.65)' : 'rgba(0,0,0,0.65)';
const chartGridColor = darkMode ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.1)';
const cardBgColor = darkMode ? '#1f1f1f' : '#ffffff';
const getFormatMemoryString = (bytes: string) => {
const val = parseInt(bytes || '0', 10);
if (val > 1024*1024*1024) return (val/1024/1024/1024).toFixed(2) + ' GB';
if (val > 1024*1024) return (val/1024/1024).toFixed(2) + ' MB';
if (val > 1024) return (val/1024).toFixed(2) + ' KB';
return val + ' B';
};
const getUptimeString = (seconds: string) => {
const d = parseInt(seconds || '0', 10);
if (d < 60) return `${d}s`;
if (d < 3600) return `${Math.floor(d/60)}m ${d%60}s`;
if (d < 86400) return `${Math.floor(d/3600)}h ${Math.floor((d%3600)/60)}m`;
return `${Math.floor(d/86400)}d ${Math.floor((d%86400)/3600)}h`;
};
return (
<div style={{ height: '100%', overflow: 'auto', padding: '16px 24px', backgroundColor: darkMode ? '#141414' : '#f0f2f5' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 20 }}>
<div>
<Title level={3} style={{ margin: 0, fontWeight: 600 }}>
<DashboardOutlined style={{ marginRight: 8, color: '#1677ff' }} />
Redis
</Title>
<Text type="secondary">
{connection.name}
{currentInfo.redis_version && ` • Redis ${currentInfo.redis_version}`}
{currentInfo.os && `${currentInfo.os}`}
</Text>
</div>
<div style={{ display: 'flex', gap: 12 }}>
{error && <Tag color="error" style={{ height: 32, lineHeight: '30px', fontSize: 13 }}>{error}</Tag>}
{loading && !error && <Spin style={{ alignSelf: 'center', marginRight: 16 }} />}
<Button
type={isRunning ? "default" : "primary"}
icon={isRunning ? <PauseCircleOutlined /> : <PlayCircleOutlined />}
onClick={() => setIsRunning(!isRunning)}
>
{isRunning ? '暂停刷新' : '恢复刷新'}
</Button>
<Button icon={<ReloadOutlined />} onClick={fetchMetrics}>
</Button>
</div>
</div>
<Row gutter={[16, 16]}>
<Col span={6}>
<Card bordered={false} style={{ background: cardBgColor, borderRadius: 8, boxShadow: '0 1px 2px 0 rgba(0,0,0,0.03)' }}>
<Statistic
title={<span style={{ fontWeight: 500 }}><DesktopOutlined /> (Used)</span>}
value={getFormatMemoryString(currentInfo.used_memory || '0')}
valueStyle={{ color: '#eb2f96', fontWeight: 600 }}
suffix={<Text type="secondary" style={{ fontSize: 13, marginLeft: 8 }}>Peak: {getFormatMemoryString(currentInfo.used_memory_peak || '0')}</Text>}
/>
</Card>
</Col>
<Col span={6}>
<Card bordered={false} style={{ background: cardBgColor, borderRadius: 8, boxShadow: '0 1px 2px 0 rgba(0,0,0,0.03)' }}>
<Statistic
title={<span style={{ fontWeight: 500 }}><ApiOutlined /> (Clients)</span>}
value={currentInfo.connected_clients || '0'}
valueStyle={{ color: '#1677ff', fontWeight: 600 }}
suffix={<Text type="secondary" style={{ fontSize: 13, marginLeft: 8 }}>Blocked: {currentInfo.blocked_clients || '0'}</Text>}
/>
</Card>
</Col>
<Col span={6}>
<Card bordered={false} style={{ background: cardBgColor, borderRadius: 8, boxShadow: '0 1px 2px 0 rgba(0,0,0,0.03)' }}>
<Statistic
title={<span style={{ fontWeight: 500 }}><HddOutlined /> (OPS)</span>}
value={currentInfo.instantaneous_ops_per_sec || '0'}
valueStyle={{ color: '#52c41a', fontWeight: 600 }}
suffix={<Text type="secondary" style={{ fontSize: 13, marginLeft: 8 }}>cmds/s</Text>}
/>
</Card>
</Col>
<Col span={6}>
<Card bordered={false} style={{ background: cardBgColor, borderRadius: 8, boxShadow: '0 1px 2px 0 rgba(0,0,0,0.03)' }}>
<Statistic
title={<span style={{ fontWeight: 500 }}> (Uptime)</span>}
value={getUptimeString(currentInfo.uptime_in_seconds || '0')}
valueStyle={{ color: '#fa8c16', fontWeight: 600 }}
suffix={<Text type="secondary" style={{ fontSize: 13, marginLeft: 8 }}>Days: {currentInfo.uptime_in_days || '0'}</Text>}
/>
</Card>
</Col>
</Row>
<Row gutter={[16, 16]} style={{ marginTop: 16 }}>
<Col span={12}>
<Card
bordered={false}
title="请求吞吐量 (QPS)"
style={{ background: cardBgColor, borderRadius: 8, height: 350, boxShadow: '0 1px 2px 0 rgba(0,0,0,0.03)' }}
styles={{ body: { padding: '16px 16px 0 0', height: 290 } }}
>
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={history} margin={{ top: 10, right: 10, left: 10, bottom: 0 }}>
<defs>
<linearGradient id="colorQps" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#52c41a" stopOpacity={0.3}/>
<stop offset="95%" stopColor="#52c41a" stopOpacity={0}/>
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke={chartGridColor} />
<XAxis dataKey="time" tick={{ fill: chartTextColor, fontSize: 12 }} axisLine={false} tickLine={false} minTickGap={20} />
<YAxis tick={{ fill: chartTextColor, fontSize: 12 }} axisLine={false} tickLine={false} />
<RechartsTooltip
contentStyle={{ backgroundColor: cardBgColor, border: `1px solid ${chartGridColor}`, borderRadius: 6 }}
itemStyle={{ fontWeight: 600 }}
/>
<Area type="monotone" dataKey="qps" name="QPS" stroke="#52c41a" strokeWidth={2} fillOpacity={1} fill="url(#colorQps)" isAnimationActive={false} />
</AreaChart>
</ResponsiveContainer>
</Card>
</Col>
<Col span={12}>
<Card
bordered={false}
title="内存开销 (Memory)"
style={{ background: cardBgColor, borderRadius: 8, height: 350, boxShadow: '0 1px 2px 0 rgba(0,0,0,0.03)' }}
styles={{ body: { padding: '16px 16px 0 0', height: 290 } }}
>
<ResponsiveContainer width="100%" height="100%">
<LineChart data={history} margin={{ top: 10, right: 10, left: 10, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke={chartGridColor} />
<XAxis dataKey="time" tick={{ fill: chartTextColor, fontSize: 12 }} axisLine={false} tickLine={false} minTickGap={20} />
<YAxis tick={{ fill: chartTextColor, fontSize: 12 }} axisLine={false} tickLine={false} domain={['auto', 'auto']} />
<RechartsTooltip
contentStyle={{ backgroundColor: cardBgColor, border: `1px solid ${chartGridColor}`, borderRadius: 6 }}
itemStyle={{ fontWeight: 600 }}
formatter={(value: any) => [`${value} MB`]}
/>
<Legend verticalAlign="top" height={36}/>
<Line type="monotone" dataKey="memory" name="Used Memory" stroke="#eb2f96" strokeWidth={2} dot={false} isAnimationActive={false} />
<Line type="monotone" dataKey="memory_rss" name="RSS Memory" stroke="#722ed1" strokeWidth={2} dot={false} isAnimationActive={false} />
</LineChart>
</ResponsiveContainer>
</Card>
</Col>
</Row>
<Row gutter={[16, 16]} style={{ marginTop: 16 }}>
<Col span={12}>
<Card
bordered={false}
title="CPU 使用率 (CPU Usage)"
style={{ background: cardBgColor, borderRadius: 8, height: 300, boxShadow: '0 1px 2px 0 rgba(0,0,0,0.03)' }}
styles={{ body: { padding: '16px 16px 0 0', height: 240 } }}
>
<ResponsiveContainer width="100%" height="100%">
<LineChart data={history} margin={{ top: 10, right: 10, left: 10, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke={chartGridColor} />
<XAxis dataKey="time" tick={{ fill: chartTextColor, fontSize: 12 }} axisLine={false} tickLine={false} minTickGap={20} />
<YAxis tick={{ fill: chartTextColor, fontSize: 12 }} axisLine={false} tickLine={false} />
<RechartsTooltip
contentStyle={{ backgroundColor: cardBgColor, border: `1px solid ${chartGridColor}`, borderRadius: 6 }}
itemStyle={{ fontWeight: 600 }}
formatter={(value: any) => [`${value} s`]}
/>
<Legend verticalAlign="top" height={36}/>
<Line type="monotone" dataKey="cpuSys" name="System" stroke="#cf1322" strokeWidth={2} dot={false} isAnimationActive={false} />
<Line type="monotone" dataKey="cpuUser" name="User" stroke="#1677ff" strokeWidth={2} dot={false} isAnimationActive={false} />
</LineChart>
</ResponsiveContainer>
</Card>
</Col>
<Col span={12}>
<Card
bordered={false}
title="连接信息 (Clients & Keys)"
style={{ background: cardBgColor, borderRadius: 8, height: 300, boxShadow: '0 1px 2px 0 rgba(0,0,0,0.03)' }}
styles={{ body: { padding: '16px 16px 0 0', height: 240 } }}
>
<ResponsiveContainer width="100%" height="100%">
<LineChart data={history} margin={{ top: 10, right: 10, left: 10, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke={chartGridColor} />
<XAxis dataKey="time" tick={{ fill: chartTextColor, fontSize: 12 }} axisLine={false} tickLine={false} minTickGap={20} />
<YAxis yAxisId="left" tick={{ fill: chartTextColor, fontSize: 12 }} axisLine={false} tickLine={false} />
<YAxis yAxisId="right" orientation="right" tick={{ fill: chartTextColor, fontSize: 12 }} axisLine={false} tickLine={false} />
<RechartsTooltip
contentStyle={{ backgroundColor: cardBgColor, border: `1px solid ${chartGridColor}`, borderRadius: 6 }}
itemStyle={{ fontWeight: 600 }}
/>
<Legend verticalAlign="top" height={36}/>
<Line yAxisId="left" type="stepAfter" dataKey="clients" name="Clients" stroke="#1677ff" strokeWidth={2} dot={false} isAnimationActive={false} />
<Line yAxisId="right" type="stepAfter" dataKey="keys" name="Total Keys" stroke="#fa8c16" strokeWidth={2} dot={false} isAnimationActive={false} />
</LineChart>
</ResponsiveContainer>
</Card>
</Col>
</Row>
<div style={{ marginTop: 24 }}>
<Card bordered={false} title="详细服务器参数" style={{ background: cardBgColor, borderRadius: 8 }}>
<div style={{ columnCount: 3, columnGap: 40 }}>
{['redis_version', 'os', 'arch_bits', 'multiplexing_api', 'gcc_version', 'run_id', 'tcp_port', 'uptime_in_days', 'hz', 'lru_clock', 'role', 'maxmemory_human', 'maxmemory_policy', 'mem_fragmentation_ratio', 'keyspace_hits', 'keyspace_misses', 'total_connections_received'].map(key => (
currentInfo[key] ? (
<div key={key} style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 8, borderBottom: `1px dashed ${chartGridColor}` }}>
<Text type="secondary">{key}</Text>
<Text strong>{currentInfo[key]}</Text>
</div>
) : null
))}
</div>
</Card>
</div>
</div>
);
};
export default RedisMonitor;

View File

@@ -30,7 +30,8 @@ import { Tree, message, Dropdown, MenuProps, Input, Button, Modal, Form, Badge,
CodeOutlined,
TagOutlined,
CheckOutlined,
FilterOutlined
FilterOutlined,
DashboardOutlined
} from '@ant-design/icons';
import { useStore } from '../store';
import { buildOverlayWorkbenchTheme } from '../utils/overlayWorkbenchTheme';
@@ -174,6 +175,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
const [selectedKeys, setSelectedKeys] = useState<React.Key[]>([]);
const selectedNodesRef = useRef<any[]>([]);
const loadingNodesRef = useRef<Set<string>>(new Set());
const clickTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const [contextMenu, setContextMenu] = useState<{ x: number, y: number, items: MenuProps['items'] } | null>(null);
// Virtual Scroll State
@@ -1035,13 +1037,21 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
dbs = dbs.filter(db => conn.includeDatabases!.includes(db.title));
}
setTreeData(origin => updateTreeData(origin, node.key, dbs));
if (dbs.length > 0) {
setTreeData(origin => updateTreeData(origin, node.key, dbs));
} else {
// 空列表:清理 loadedKeys 以允许重新加载,不设置 children = []
setLoadedKeys(prev => prev.filter(k => k !== node.key));
message.warning({ content: '未获取到可见数据库/schema请检查账号权限或右键刷新', key: `conn-${conn.id}-dbs` });
}
} else {
setConnectionStates(prev => ({ ...prev, [conn.id]: 'error' }));
setLoadedKeys(prev => prev.filter(k => k !== node.key));
message.error({ content: res.message, key: `conn-${conn.id}-dbs` });
}
} catch (e: any) {
setConnectionStates(prev => ({ ...prev, [conn.id]: 'error' }));
setLoadedKeys(prev => prev.filter(k => k !== node.key));
message.error({ content: '连接失败: ' + (e?.message || String(e)), key: `conn-${conn.id}-dbs` });
} finally {
loadingNodesRef.current.delete(loadKey);
@@ -1447,6 +1457,22 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
else if (type === 'folder-indexes') openDesign(info.node, 'indexes', false);
else if (type === 'folder-fks') openDesign(info.node, 'foreignKeys', false);
else if (type === 'folder-triggers') openDesign(info.node, 'triggers', false);
else if (type === 'object-group' && dataRef?.groupKey === 'tables') {
// 单击延迟打开表概览,双击时会取消此定时器
if (clickTimerRef.current) clearTimeout(clickTimerRef.current);
const { id, dbName: gDbName, schemaName } = dataRef;
clickTimerRef.current = setTimeout(() => {
clickTimerRef.current = null;
addTab({
id: `table-overview-${id}-${gDbName}${schemaName ? `-${schemaName}` : ''}`,
title: `表概览 - ${gDbName}${schemaName ? ` (${schemaName})` : ''}`,
type: 'table-overview' as any,
connectionId: id,
dbName: gDbName,
schemaName,
} as any);
}, 250);
}
};
const onExpand = (newExpandedKeys: React.Key[]) => {
@@ -1455,7 +1481,11 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
};
const onDoubleClick = (e: any, node: any) => {
// 保证用户直接双击节点未触发 onClick/onSelect 时也能强行拿到选中状态
// 双击时取消单击延迟动作(如表概览打开),让双击只触发展开/折叠
if (clickTimerRef.current) {
clearTimeout(clickTimerRef.current);
clickTimerRef.current = null;
}
const { type, dataRef, key: nodeKey } = node;
if (type === 'connection') setActiveContext({ connectionId: nodeKey, dbName: '' });
else if (type === 'database') setActiveContext({ connectionId: dataRef.id, dbName: dataRef.dbName });
@@ -1463,18 +1493,6 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
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;
// 记录表访问
@@ -3081,7 +3099,17 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
key: 'refresh',
label: '刷新',
icon: <ReloadOutlined />,
onClick: () => loadDatabases(node)
onClick: () => {
const connKey = String(node.key);
// 清除子节点的展开/已加载状态,确保刷新后重新展开时能触发 onLoadData
setExpandedKeys(prev => prev.filter(k => !k.toString().startsWith(`${connKey}-`)));
setLoadedKeys(prev => prev.filter(k => !k.toString().startsWith(`${connKey}-`)));
// 清除 loadingNodesRef 中残留的子节点加载标记
Array.from(loadingNodesRef.current).forEach(lk => {
if (lk.startsWith(`tables-${connKey}-`)) loadingNodesRef.current.delete(lk);
});
loadDatabases(node);
}
},
{ type: 'divider' },
{
@@ -3098,6 +3126,20 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
});
}
},
{
key: 'open-monitor',
label: 'Redis 实例监控',
icon: <DashboardOutlined />,
onClick: () => {
addTab({
id: `redis-monitor-${node.key}-${Date.now()}`,
title: `监控: ${node.title}`,
type: 'redis-monitor',
connectionId: node.key,
redisDB: 0
});
}
},
{ type: 'divider' },
{
key: 'edit',
@@ -3184,7 +3226,17 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
key: 'refresh',
label: '刷新',
icon: <ReloadOutlined />,
onClick: () => loadDatabases(node)
onClick: () => {
const connKey = String(node.key);
// 清除子节点的展开/已加载状态,确保刷新后重新展开时能触发 onLoadData
setExpandedKeys(prev => prev.filter(k => !k.toString().startsWith(`${connKey}-`)));
setLoadedKeys(prev => prev.filter(k => !k.toString().startsWith(`${connKey}-`)));
// 清除 loadingNodesRef 中残留的子节点加载标记
Array.from(loadingNodesRef.current).forEach(lk => {
if (lk.startsWith(`tables-${connKey}-`)) loadingNodesRef.current.delete(lk);
});
loadDatabases(node);
}
},
{ type: 'divider' },
{
@@ -3309,6 +3361,20 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
redisDB: redisDB
});
}
},
{
key: 'open-monitor',
label: 'Redis 实例监控',
icon: <DashboardOutlined />,
onClick: () => {
addTab({
id: `redis-monitor-${id}-db${redisDB}-${Date.now()}`,
title: `监控: ${connections.find(c => c.id === id)?.name || id}`,
type: 'redis-monitor',
connectionId: id,
redisDB: redisDB
});
}
}
];
} else if (node.type === 'database') {

View File

@@ -12,6 +12,7 @@ import QueryEditor from './QueryEditor';
import TableDesigner from './TableDesigner';
import RedisViewer from './RedisViewer';
import RedisCommandEditor from './RedisCommandEditor';
import RedisMonitor from './RedisMonitor';
import TriggerViewer from './TriggerViewer';
import DefinitionViewer from './DefinitionViewer';
import TableOverview from './TableOverview';
@@ -199,17 +200,20 @@ 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 tabIsActive = tab.id === activeTabId;
let content;
if (tab.type === 'query') {
content = <QueryEditor tab={tab} />;
content = <QueryEditor tab={tab} isActive={tabIsActive} />;
} else if (tab.type === 'table') {
content = <DataViewer tab={tab} />;
content = <DataViewer tab={tab} isActive={tabIsActive} />;
} else if (tab.type === 'design') {
content = <TableDesigner tab={tab} />;
} else if (tab.type === 'redis-keys') {
content = <RedisViewer connectionId={tab.connectionId} redisDB={tab.redisDB ?? 0} />;
} else if (tab.type === 'redis-command') {
content = <RedisCommandEditor connectionId={tab.connectionId} redisDB={tab.redisDB ?? 0} />;
} else if (tab.type === 'redis-monitor') {
content = <RedisMonitor connectionId={tab.connectionId} redisDB={tab.redisDB ?? 0} />;
} else if (tab.type === 'trigger') {
content = <TriggerViewer tab={tab} />;
} else if (tab.type === 'view-def' || tab.type === 'routine-def') {
@@ -256,7 +260,7 @@ const TabManager: React.FC = () => {
key: tab.id,
children: content,
};
}), [tabs, connections, closeOtherTabs, closeTabsToLeft, closeTabsToRight, closeAllTabs]);
}), [tabs, connections, activeTabId, closeOtherTabs, closeTabsToLeft, closeTabsToRight, closeAllTabs]);
return (
<>

View File

@@ -217,14 +217,6 @@ const COMMON_DEFAULTS = [
{ value: "''" },
];
const MYSQL_INDEX_TYPE_OPTIONS = [
{ label: '默认', value: 'DEFAULT' },
{ label: 'BTREE', value: 'BTREE' },
{ label: 'HASH', value: 'HASH' },
{ label: 'FULLTEXT', value: 'FULLTEXT' },
{ label: 'SPATIAL', value: 'SPATIAL' },
{ label: 'RTREE', value: 'RTREE' },
];
const PGLIKE_INDEX_TYPE_OPTIONS = [
{ label: '默认', value: 'DEFAULT' },
@@ -1441,14 +1433,37 @@ ${selectedTrigger.statement}`;
];
};
const getIndexTypeOptions = () => {
const getIndexTypeOptions = (kind?: IndexKind) => {
const dbType = getDbType();
if (isMysqlLikeDialect(dbType)) return MYSQL_INDEX_TYPE_OPTIONS;
if (isPgLikeDialect(dbType)) return PGLIKE_INDEX_TYPE_OPTIONS;
const k = kind || 'NORMAL';
if (isMysqlLikeDialect(dbType)) {
// MySQL InnoDB: 所有索引均为固定方法类型
if (k === 'FULLTEXT') return [{ label: 'FULLTEXT', value: 'FULLTEXT' }];
if (k === 'SPATIAL') return [{ label: 'RTREE', value: 'RTREE' }];
return [{ label: 'BTREE', value: 'BTREE' }];
}
if (isPgLikeDialect(dbType)) {
if (k === 'PRIMARY' || k === 'UNIQUE') return [{ label: 'BTREE', value: 'BTREE' }];
return PGLIKE_INDEX_TYPE_OPTIONS;
}
if (isSqlServerDialect(dbType)) return SQLSERVER_INDEX_TYPE_OPTIONS;
return [{ label: '默认', value: 'DEFAULT' }];
};
/** 根据索引类别返回固定的索引方法类型,可选类别返回 undefined */
const getFixedIndexType = (kind: IndexKind): string | undefined => {
const dbType = getDbType();
if (isMysqlLikeDialect(dbType)) {
if (kind === 'PRIMARY') return 'BTREE';
if (kind === 'FULLTEXT') return 'FULLTEXT';
if (kind === 'SPATIAL') return 'RTREE';
}
if (isPgLikeDialect(dbType)) {
if (kind === 'PRIMARY') return 'BTREE';
}
return undefined;
};
const buildCreateTableSql = (targetTableName: string, targetColumns: EditableColumn[], targetCharset: string, targetCollation: string) => {
const tableName = `\`${escapeBacktickIdentifier(targetTableName)}\``;
const colDefs = targetColumns.map(curr => {
@@ -2928,20 +2943,34 @@ END;`;
<Select
value={indexForm.kind}
options={getIndexKindOptions()}
onChange={(val: IndexKind) =>
setIndexForm(prev => ({
...prev,
kind: val,
name: val === 'PRIMARY' ? 'PRIMARY' : (prev.name === 'PRIMARY' ? '' : prev.name),
indexType: val === 'NORMAL' || val === 'UNIQUE' ? (prev.indexType || 'DEFAULT') : 'DEFAULT',
}))
}
onChange={(val: IndexKind) => {
const fixedType = getFixedIndexType(val);
if (fixedType) {
// 固定类型PRIMARY/FULLTEXT/SPATIAL直接设置对应的索引方法
setIndexForm(prev => ({
...prev,
kind: val,
name: val === 'PRIMARY' ? 'PRIMARY' : (prev.name === 'PRIMARY' ? '' : prev.name),
indexType: fixedType,
}));
} else {
const nextTypeOptions = getIndexTypeOptions(val);
const currentType = indexForm.indexType || 'DEFAULT';
const isCurrentTypeValid = nextTypeOptions.some(opt => opt.value === currentType);
setIndexForm(prev => ({
...prev,
kind: val,
name: val === 'PRIMARY' ? 'PRIMARY' : (prev.name === 'PRIMARY' ? '' : prev.name),
indexType: isCurrentTypeValid ? currentType : 'DEFAULT',
}));
}
}}
style={{ width: 220 }}
/>
<Select
value={indexForm.indexType}
onChange={(val) => setIndexForm(prev => ({ ...prev, indexType: val }))}
options={getIndexTypeOptions()}
options={getIndexTypeOptions(indexForm.kind)}
style={{ width: 160 }}
disabled={indexForm.kind === 'PRIMARY' || indexForm.kind === 'FULLTEXT' || indexForm.kind === 'SPATIAL'}
/>

View File

@@ -1,6 +1,6 @@
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 { TableOutlined, SearchOutlined, ReloadOutlined, SortAscendingOutlined, DatabaseOutlined, ConsoleSqlOutlined, EditOutlined, CopyOutlined, SaveOutlined, DeleteOutlined, ExportOutlined, AppstoreOutlined, UnorderedListOutlined } from '@ant-design/icons';
import { useStore } from '../store';
import { DBQuery, DBShowCreateTable, ExportTable, DropTable, RenameTable } from '../../wailsjs/go/app/App';
import type { TabData } from '../types';
@@ -22,6 +22,7 @@ interface TableStatRow {
type SortField = 'name' | 'rows' | 'dataSize';
type SortOrder = 'asc' | 'desc';
type ViewMode = 'card' | 'list';
const formatSize = (bytes: number): string => {
if (!bytes || bytes <= 0) return '—';
@@ -146,6 +147,7 @@ const TableOverview: React.FC<TableOverviewProps> = ({ tab }) => {
const [searchText, setSearchText] = useState('');
const [sortField, setSortField] = useState<SortField>('name');
const [sortOrder, setSortOrder] = useState<SortOrder>('asc');
const [viewMode, setViewMode] = useState<ViewMode>('card');
const connection = useMemo(() => connections.find(c => c.id === tab.connectionId), [connections, tab.connectionId]);
@@ -366,14 +368,43 @@ const TableOverview: React.FC<TableOverviewProps> = ({ tab }) => {
<Dropdown menu={{ items: sortMenuItems }} trigger={['click']}>
<Tooltip title="排序"><SortAscendingOutlined style={{ fontSize: 16, color: textSecondary, cursor: 'pointer' }} /></Tooltip>
</Dropdown>
<div style={{ display: 'flex', gap: 2, padding: 2, borderRadius: 6, background: darkMode ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.04)' }}>
<Tooltip title="卡片视图">
<div
onClick={() => setViewMode('card')}
style={{
padding: '3px 7px', borderRadius: 5, cursor: 'pointer', transition: 'all 0.15s',
background: viewMode === 'card' ? (darkMode ? 'rgba(255,255,255,0.12)' : '#fff') : 'transparent',
boxShadow: viewMode === 'card' ? '0 1px 3px rgba(0,0,0,0.1)' : 'none',
color: viewMode === 'card' ? accentColor : textMuted,
}}
>
<AppstoreOutlined style={{ fontSize: 14 }} />
</div>
</Tooltip>
<Tooltip title="列表视图">
<div
onClick={() => setViewMode('list')}
style={{
padding: '3px 7px', borderRadius: 5, cursor: 'pointer', transition: 'all 0.15s',
background: viewMode === 'list' ? (darkMode ? 'rgba(255,255,255,0.12)' : '#fff') : 'transparent',
boxShadow: viewMode === 'list' ? '0 1px 3px rgba(0,0,0,0.1)' : 'none',
color: viewMode === 'list' ? accentColor : textMuted,
}}
>
<UnorderedListOutlined style={{ fontSize: 14 }} />
</div>
</Tooltip>
</div>
<Tooltip title="刷新"><ReloadOutlined onClick={loadData} style={{ fontSize: 16, color: textSecondary, cursor: 'pointer' }} /></Tooltip>
</div>
{/* Cards Grid */}
{/* Content Area */}
<div style={{ flex: 1, overflow: 'auto', padding: '0 16px 16px 16px' }}>
{sortedFiltered.length === 0 ? (
<Empty description={searchText ? '无匹配结果' : '暂无表'} style={{ marginTop: 80 }} />
) : (
) : viewMode === 'card' ? (
/* ========== 卡片视图 ========== */
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(260px, 1fr))',
@@ -451,6 +482,115 @@ const TableOverview: React.FC<TableOverviewProps> = ({ tab }) => {
</Dropdown>
))}
</div>
) : (
/* ========== 列表/表格视图 ========== */
<div style={{ borderRadius: 8, border: `1px solid ${cardBorder}`, overflow: 'hidden' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
<thead>
<tr style={{ background: darkMode ? 'rgba(255,255,255,0.04)' : 'rgba(0,0,0,0.02)' }}>
{[
{ field: 'name' as SortField, label: '表名', width: undefined },
{ field: null, label: '注释', width: undefined },
{ field: 'rows' as SortField, label: '行数', width: 100 },
{ field: 'dataSize' as SortField, label: '数据大小', width: 110 },
{ field: null, label: '索引大小', width: 110 },
{ field: null, label: '引擎', width: 90 },
].map((col, idx) => (
<th
key={idx}
onClick={col.field ? () => toggleSort(col.field!) : undefined}
style={{
padding: '10px 14px',
textAlign: idx >= 2 ? 'right' : 'left',
fontWeight: 600,
color: textSecondary,
borderBottom: `1px solid ${cardBorder}`,
cursor: col.field ? 'pointer' : 'default',
userSelect: 'none',
whiteSpace: 'nowrap',
width: col.width,
}}
>
{col.label}
{col.field && sortField === col.field && (
<span style={{ marginLeft: 4, fontSize: 11 }}>
{sortOrder === 'asc' ? '↑' : '↓'}
</span>
)}
</th>
))}
</tr>
</thead>
<tbody>
{sortedFiltered.map((t, rowIdx) => (
<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') },
]},
],
}}
>
<tr
onDoubleClick={() => openTable(t.name)}
style={{
cursor: 'pointer',
transition: 'background 0.12s',
borderBottom: rowIdx < sortedFiltered.length - 1 ? `1px solid ${cardBorder}` : 'none',
}}
onMouseEnter={e => { (e.currentTarget as HTMLTableRowElement).style.background = cardHoverBg; }}
onMouseLeave={e => { (e.currentTarget as HTMLTableRowElement).style.background = 'transparent'; }}
>
<td style={{ padding: '10px 14px', color: textPrimary, fontWeight: 500 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<TableOutlined style={{ fontSize: 13, color: accentColor, flexShrink: 0 }} />
<Tooltip title={t.name} mouseEnterDelay={0.4}>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{t.name}</span>
</Tooltip>
</div>
</td>
<td style={{ padding: '10px 14px', color: textSecondary, maxWidth: 260, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{t.comment ? (
<Tooltip title={t.comment} mouseEnterDelay={0.4}><span>{t.comment}</span></Tooltip>
) : (
<span style={{ color: textMuted }}></span>
)}
</td>
<td style={{ padding: '10px 14px', textAlign: 'right', color: textSecondary, fontVariantNumeric: 'tabular-nums' }}>{formatRows(t.rows)}</td>
<td style={{ padding: '10px 14px', textAlign: 'right', color: textSecondary, fontVariantNumeric: 'tabular-nums' }}>{formatSize(t.dataSize)}</td>
<td style={{ padding: '10px 14px', textAlign: 'right', color: textSecondary, fontVariantNumeric: 'tabular-nums' }}>{formatSize(t.indexSize)}</td>
<td style={{ padding: '10px 14px', textAlign: 'right', color: textMuted }}>{t.engine || '—'}</td>
</tr>
</Dropdown>
))}
</tbody>
</table>
</div>
)}
</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

@@ -1,9 +1,10 @@
import React from 'react';
import { Input, Select, AutoComplete, Tooltip, Modal, Checkbox, Spin, message, Button, Tag } from 'antd';
import { DatabaseOutlined, SendOutlined, TableOutlined, SearchOutlined, PictureOutlined } from '@ant-design/icons';
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;
@@ -19,6 +20,7 @@ interface AIChatInputProps {
activeProvider: any;
dynamicModels: string[];
loadingModels: boolean;
composerNotice?: AIComposerNotice | null;
onModelChange: (val: string) => void;
onFetchModels: () => void;
textareaRef: React.RefObject<HTMLTextAreaElement>;
@@ -33,6 +35,7 @@ interface AIChatInputProps {
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
}) => {
@@ -67,6 +70,33 @@ export const AIChatInput: React.FC<AIChatInputProps> = ({
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);
@@ -258,7 +288,31 @@ export const AIChatInput: React.FC<AIChatInputProps> = ({
</div>
))}
</div>
<div style={{ position: 'relative' }}>
{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,
@@ -354,9 +408,13 @@ export const AIChatInput: React.FC<AIChatInputProps> = ({
<Select
size="small"
variant="filled"
value={activeProvider.model || (dynamicModels.length > 0 ? dynamicModels[0] : activeProvider.models?.[0])}
value={activeProvider.model || undefined}
onChange={onModelChange}
onDropdownVisibleChange={(open) => { if (open && dynamicModels.length === 0) onFetchModels(); }}
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' }}

View File

@@ -0,0 +1,61 @@
import { describe, expect, it } from 'vitest';
import { buildCopyInsertSQL } from './dataGridCopyInsert';
describe('buildCopyInsertSQL', () => {
it('normalizes PostgreSQL timestamp values for copy-as-insert and uses PostgreSQL identifier quoting', () => {
const sql = buildCopyInsertSQL({
dbType: 'postgres',
tableName: 'public.OrderLog',
orderedCols: ['CreatedAt', 'note'],
record: {
CreatedAt: '2026-01-21T18:32:26+08:00',
note: "O'Brien",
},
columnTypesByLowerName: {
createdat: 'timestamp without time zone',
note: 'text',
},
});
expect(sql).toBe(
`INSERT INTO public."OrderLog" ("CreatedAt", note) VALUES ('2026-01-21 18:32:26', 'O''Brien');`,
);
});
it('keeps timezone offsets for timezone-aware PostgreSQL columns while still removing the T separator', () => {
const sql = buildCopyInsertSQL({
dbType: 'postgres',
tableName: 'public.audit_log',
orderedCols: ['created_at'],
record: {
created_at: '2026-01-21T18:32:26+08:00',
},
columnTypesByLowerName: {
created_at: 'timestamp with time zone',
},
});
expect(sql).toBe(
`INSERT INTO public.audit_log (created_at) VALUES ('2026-01-21 18:32:26+08:00');`,
);
});
it('keeps RFC3339-looking text unchanged for non-temporal columns', () => {
const sql = buildCopyInsertSQL({
dbType: 'postgres',
tableName: 'public.audit_log',
orderedCols: ['payload'],
record: {
payload: '2026-01-21T18:32:26+08:00',
},
columnTypesByLowerName: {
payload: 'text',
},
});
expect(sql).toBe(
`INSERT INTO public.audit_log (payload) VALUES ('2026-01-21T18:32:26+08:00');`,
);
});
});

View File

@@ -0,0 +1,131 @@
import { escapeLiteral, quoteIdentPart, quoteQualifiedIdent } from '../utils/sql';
type BuildCopyInsertSQLParams = {
dbType: string;
tableName?: string;
orderedCols: string[];
record: Record<string, any>;
columnTypesByLowerName?: Record<string, string>;
};
const looksLikeDateTimeText = (val: string): boolean => {
if (!val) return false;
const len = val.length;
if (len < 19 || len > 64) return false;
const charCode0 = val.charCodeAt(0);
if (charCode0 < 48 || charCode0 > 57) return false;
return (
val[4] === '-' &&
val[7] === '-' &&
(val[10] === ' ' || val[10] === 'T') &&
val[13] === ':' &&
val[16] === ':'
);
};
const normalizeDateTimeString = (val: string): string => {
if (!looksLikeDateTimeText(val)) {
return val;
}
if (/^0{4}-0{2}-0{2}/.test(val)) {
return val;
}
const match = val.match(
/^(\d{4}-\d{2}-\d{2})[T ](\d{2}:\d{2}:\d{2})(?:\.\d+)?(?:\s*(?:Z|[+-]\d{2}:?\d{2})(?:\s+[A-Za-z_\/+-]+)?)?$/
);
return match ? `${match[1]} ${match[2]}` : val;
};
const normalizeTimezoneAwareDateTimeString = (val: string): string => {
if (!looksLikeDateTimeText(val)) {
return val;
}
if (/^0{4}-0{2}-0{2}/.test(val)) {
return val;
}
const match = val.match(
/^(\d{4}-\d{2}-\d{2})[T ](\d{2}:\d{2}:\d{2})(?:\.\d+)?(?:\s*(Z|[+-]\d{2}:?\d{2})(?:\s+[A-Za-z_\/+-]+)?)?$/
);
if (!match) {
return val;
}
const suffix = match[3] || '';
return `${match[1]} ${match[2]}${suffix}`;
};
const isTemporalColumnType = (columnType?: string): boolean => {
const raw = String(columnType || '').trim().toLowerCase();
if (!raw) return false;
if (raw.includes('datetime') || raw.includes('timestamp') || raw.includes('timestamptz')) return true;
const base = raw.split(/[ (]/)[0];
return base === 'date' || base === 'time' || base === 'timetz' || base === 'year';
};
const isTimezoneAwareColumnType = (columnType?: string): boolean => {
const raw = String(columnType || '').trim().toLowerCase();
if (!raw) return false;
return (
raw.includes('with time zone') ||
raw.includes('with timezone') ||
raw.includes('datetimeoffset') ||
raw.includes('timestamptz') ||
raw.includes('timetz')
);
};
export const normalizeTemporalLiteralText = (
value: string,
columnType?: string,
normalizeWhenTypeMissing = false,
): string => {
const rawType = String(columnType || '').trim();
if (!rawType) {
return normalizeWhenTypeMissing ? normalizeDateTimeString(value) : value;
}
if (!isTemporalColumnType(rawType)) {
return value;
}
return isTimezoneAwareColumnType(rawType)
? normalizeTimezoneAwareDateTimeString(value)
: normalizeDateTimeString(value);
};
export const formatLocalDateTimeLiteral = (value: Date): string => {
const year = value.getFullYear();
const month = String(value.getMonth() + 1).padStart(2, '0');
const day = String(value.getDate()).padStart(2, '0');
const hour = String(value.getHours()).padStart(2, '0');
const minute = String(value.getMinutes()).padStart(2, '0');
const second = String(value.getSeconds()).padStart(2, '0');
return `${year}-${month}-${day} ${hour}:${minute}:${second}`;
};
export const buildCopyInsertSQL = ({
dbType,
tableName,
orderedCols,
record,
columnTypesByLowerName = {},
}: BuildCopyInsertSQLParams): string => {
const targetTable = quoteQualifiedIdent(dbType, tableName || 'table');
const quotedCols = orderedCols.map((col) => quoteIdentPart(dbType, col));
const values = orderedCols.map((col) => {
const value = record?.[col];
if (value === null || value === undefined) return 'NULL';
const columnType = columnTypesByLowerName[String(col || '').toLowerCase()];
const raw =
typeof value === 'string'
? normalizeTemporalLiteralText(value, columnType, true)
: value instanceof Date
? formatLocalDateTimeLiteral(value)
: String(value);
return `'${escapeLiteral(raw)}'`;
});
return `INSERT INTO ${targetTable} (${quotedCols.join(', ')}) VALUES (${values.join(', ')});`;
};

View File

@@ -3,6 +3,12 @@ import ReactDOM from 'react-dom/client'
import App from './App'
// import './index.css' // Optional global styles
// 全局配置 dayjs 使用中文 locale使 Ant Design 的 DatePicker/TimePicker 等组件
// 的月份、星期等文本显示为中文。必须在 Ant Design 组件渲染前完成配置。
import dayjs from 'dayjs'
import 'dayjs/locale/zh-cn'
dayjs.locale('zh-cn')
// 全局配置 Monaco Editor 使用本地打包的文件,避免从 CDN (jsdelivr) 加载。
// Windows WebView2 环境下访问外部 CDN 可能失败,导致编辑器一直显示 Loading。
// 中文语言包必须在 monaco-editor 主包之前导入,否则右键菜单等 UI 仍为英文。

View File

@@ -118,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' | 'table-overview';
type: 'query' | 'table' | 'design' | 'redis-keys' | 'redis-command' | 'redis-monitor' | 'trigger' | 'view-def' | 'routine-def' | 'table-overview';
connectionId: string;
dbName?: string;
tableName?: string;
@@ -204,7 +204,7 @@ export interface AIProviderConfig {
baseUrl: string;
model: string;
models?: string[];
apiFormat?: string; // custom 专用: openai | anthropic | gemini
apiFormat?: string; // custom 专用: openai | anthropic | gemini | claude-cli
headers?: Record<string, string>;
maxTokens: number;
temperature: number;
@@ -243,4 +243,3 @@ export interface AISafetyResult {
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,71 @@
import { describe, expect, it } from 'vitest';
import {
SIDEBAR_UTILITY_ITEM_KEYS,
resolveAIEntryPlacement,
resolveAIEdgeHandleAttachment,
resolveAIEdgeHandleDockStyle,
resolveAIEdgeHandleStyle,
} from './aiEntryLayout';
describe('ai entry layout', () => {
it('keeps the sidebar utility group free of the AI entry', () => {
expect(SIDEBAR_UTILITY_ITEM_KEYS).toEqual(['tools', 'proxy', 'theme', 'about']);
});
it('anchors the AI entry to the content edge', () => {
expect(resolveAIEntryPlacement()).toBe('content-edge');
});
it('attaches the closed handle to the content shell', () => {
expect(resolveAIEdgeHandleAttachment(false)).toBe('content-shell');
});
it('attaches the open handle to the panel shell', () => {
expect(resolveAIEdgeHandleAttachment(true)).toBe('panel-shell');
});
it('keeps the closed handle docked on the content edge', () => {
expect(resolveAIEdgeHandleDockStyle('content-shell')).toMatchObject({
position: 'absolute',
top: 16,
right: 0,
zIndex: 12,
});
});
it('keeps the open handle outside the panel shell to avoid header overlap', () => {
expect(resolveAIEdgeHandleDockStyle('panel-shell')).toMatchObject({
position: 'absolute',
top: 16,
right: '100%',
zIndex: 12,
});
});
it('uses the attached active appearance when the AI panel is open', () => {
const style = resolveAIEdgeHandleStyle({
darkMode: true,
aiPanelVisible: true,
effectiveUiScale: 1,
});
expect(style.color).toBe('#ffd666');
expect(style.background).toBe('rgba(255,214,102,0.12)');
expect(style.borderRadius).toBe('10px 0 0 10px');
expect(style.borderRight).toBe('none');
expect(style.height).toBe(24);
});
it('uses the subdued attached appearance when the AI panel is closed', () => {
const style = resolveAIEdgeHandleStyle({
darkMode: false,
aiPanelVisible: false,
effectiveUiScale: 1,
});
expect(style.color).toBe('rgba(22,32,51,0.82)');
expect(style.background).toBe('rgba(15,23,42,0.04)');
expect(style.paddingInline).toBe(8);
expect(style.borderRadius).toBe('10px 0 0 10px');
});
});

View File

@@ -0,0 +1,59 @@
import type { CSSProperties } from 'react';
export const SIDEBAR_UTILITY_ITEM_KEYS = ['tools', 'proxy', 'theme', 'about'] as const;
export type AIEntryPlacement = 'content-edge';
export type AIEdgeHandleAttachment = 'content-shell' | 'panel-shell';
export interface ResolveAIEdgeHandleStyleInput {
darkMode: boolean;
aiPanelVisible: boolean;
effectiveUiScale: number;
}
export const resolveAIEntryPlacement = (): AIEntryPlacement => 'content-edge';
export const resolveAIEdgeHandleAttachment = (
aiPanelVisible: boolean,
): AIEdgeHandleAttachment => (aiPanelVisible ? 'panel-shell' : 'content-shell');
export const resolveAIEdgeHandleDockStyle = (
attachment: AIEdgeHandleAttachment,
): CSSProperties => ({
position: 'absolute',
top: 16,
right: attachment === 'panel-shell' ? '100%' : 0,
zIndex: 12,
});
export const resolveAIEdgeHandleStyle = ({
darkMode,
aiPanelVisible,
effectiveUiScale,
}: ResolveAIEdgeHandleStyleInput): CSSProperties => {
const inactiveColor = darkMode ? 'rgba(255,255,255,0.86)' : 'rgba(22,32,51,0.82)';
const inactiveBackground = darkMode ? 'rgba(255,255,255,0.04)' : 'rgba(15,23,42,0.04)';
const inactiveBorder = darkMode ? 'rgba(255,255,255,0.08)' : 'rgba(15,23,42,0.08)';
return {
height: Math.max(24, Math.round(24 * effectiveUiScale)),
paddingInline: Math.max(8, Math.round(8 * effectiveUiScale)),
borderRadius: '10px 0 0 10px',
border: `1px solid ${aiPanelVisible ? (darkMode ? 'rgba(255,214,102,0.22)' : 'rgba(24,144,255,0.18)') : inactiveBorder}`,
borderRight: 'none',
background: aiPanelVisible ? (darkMode ? 'rgba(255,214,102,0.12)' : 'rgba(24,144,255,0.10)') : inactiveBackground,
color: aiPanelVisible ? (darkMode ? '#ffd666' : '#1677ff') : inactiveColor,
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
gap: Math.max(4, Math.round(4 * effectiveUiScale)),
fontSize: Math.max(12, Math.round(12 * effectiveUiScale)),
fontWeight: 600,
lineHeight: 1,
boxShadow: 'none',
backdropFilter: 'none',
WebkitBackdropFilter: 'none',
whiteSpace: 'nowrap',
flexShrink: 0,
};
};

View File

@@ -0,0 +1,185 @@
import { describe, expect, it } from 'vitest';
import type { AIProviderType } from '../types';
import {
LEGACY_QWEN_CODING_PLAN_OPENAI_BASE_URL,
QWEN_BAILIAN_ANTHROPIC_BASE_URL,
QWEN_BAILIAN_MODELS_BASE_URL,
QWEN_CODING_PLAN_ANTHROPIC_BASE_URL,
QWEN_CODING_PLAN_MODELS,
matchQwenPresetKey,
resolvePresetBaseURL,
resolvePresetModelSelection,
resolvePresetTransport,
resolveProviderPresetKey,
} from './aiProviderPresets';
type PresetMatcher = {
key: string;
backendType: AIProviderType;
defaultBaseUrl: string;
fixedApiFormat?: string;
};
const PRESETS: PresetMatcher[] = [
{ key: 'openai', backendType: 'openai', defaultBaseUrl: 'https://api.openai.com/v1' },
{ key: 'qwen-bailian', backendType: 'anthropic', defaultBaseUrl: QWEN_BAILIAN_ANTHROPIC_BASE_URL },
{
key: 'qwen-coding-plan',
backendType: 'custom',
defaultBaseUrl: QWEN_CODING_PLAN_ANTHROPIC_BASE_URL,
fixedApiFormat: 'claude-cli',
},
{ key: 'custom', backendType: 'custom', defaultBaseUrl: '' },
];
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 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('maps legacy Coding Plan OpenAI config back to the dedicated Coding Plan preset', () => {
expect(matchQwenPresetKey({
type: 'openai',
baseUrl: LEGACY_QWEN_CODING_PLAN_OPENAI_BASE_URL,
})).toBe('qwen-coding-plan');
});
it('does not treat a custom OpenAI endpoint as the built-in Coding Plan preset', () => {
expect(matchQwenPresetKey({
type: 'custom',
apiFormat: 'openai',
baseUrl: LEGACY_QWEN_CODING_PLAN_OPENAI_BASE_URL,
})).toBeNull();
});
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',
});
});
});
describe('resolveProviderPresetKey', () => {
it('不会把自定义 OpenAI 端点误识别成千问 Coding Plan', () => {
const key = resolveProviderPresetKey(
{
type: 'custom',
apiFormat: 'openai',
baseUrl: LEGACY_QWEN_CODING_PLAN_OPENAI_BASE_URL,
},
PRESETS,
'custom',
);
expect(key).toBe('custom');
});
it('仍然能识别当前内置的千问 Coding Plan 预设', () => {
const key = resolveProviderPresetKey(
{
type: 'custom',
apiFormat: 'claude-cli',
baseUrl: QWEN_CODING_PLAN_ANTHROPIC_BASE_URL,
},
PRESETS,
'custom',
);
expect(key).toBe('qwen-coding-plan');
});
it('仍然能识别当前内置的千问百炼预设', () => {
const key = resolveProviderPresetKey(
{
type: 'anthropic',
apiFormat: undefined,
baseUrl: QWEN_BAILIAN_ANTHROPIC_BASE_URL,
},
PRESETS,
'custom',
);
expect(key).toBe('qwen-bailian');
});
});

View File

@@ -0,0 +1,216 @@
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 interface ProviderPresetMatcher {
key: string;
backendType: AIProviderType;
defaultBaseUrl: string;
fixedApiFormat?: 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);
if (
fingerprint !== ''
&& fingerprint === getProviderFingerprint(QWEN_BAILIAN_ANTHROPIC_BASE_URL)
&& provider.type === 'anthropic'
) {
return 'qwen-bailian';
}
if (
fingerprint !== ''
&& fingerprint === getProviderFingerprint(LEGACY_QWEN_BAILIAN_OPENAI_BASE_URL)
&& provider.type === 'openai'
) {
return 'qwen-bailian';
}
if (
fingerprint !== ''
&& fingerprint === getProviderFingerprint(QWEN_CODING_PLAN_ANTHROPIC_BASE_URL)
&& provider.type === 'custom'
&& provider.apiFormat === 'claude-cli'
) {
return 'qwen-coding-plan';
}
if (
fingerprint !== ''
&& fingerprint === getProviderFingerprint(LEGACY_QWEN_CODING_PLAN_OPENAI_BASE_URL)
&& provider.type === 'openai'
) {
return 'qwen-coding-plan';
}
return null;
};
export const resolveProviderPresetKey = (
provider: Pick<AIProviderConfig, 'type' | 'baseUrl' | 'apiFormat'>,
presets: ProviderPresetMatcher[],
fallbackKey = 'custom',
): string => {
const qwenPresetKey = matchQwenPresetKey(provider);
if (qwenPresetKey) {
return qwenPresetKey;
}
const fingerprint = getProviderFingerprint(provider.baseUrl);
const exactPreset = presets.find((preset) =>
preset.backendType === provider.type
&& fingerprint !== ''
&& fingerprint === getProviderFingerprint(preset.defaultBaseUrl)
&& (!preset.fixedApiFormat || preset.fixedApiFormat === provider.apiFormat),
);
if (exactPreset) {
return exactPreset.key;
}
// custom 供应商必须保守处理,避免仅凭 host 错误吞掉用户显式保存的自定义配置。
if (provider.type === 'custom') {
return fallbackKey;
}
const host = getProviderHostname(provider.baseUrl);
if (provider.type === 'anthropic' && host.endsWith('moonshot.cn')) {
const moonshotPreset = presets.find((preset) => preset.key === 'moonshot');
if (moonshotPreset) {
return moonshotPreset.key;
}
}
const hostPreset = presets.find((preset) =>
preset.backendType === provider.type
&& host !== ''
&& host === getProviderHostname(preset.defaultBaseUrl)
&& (!preset.fixedApiFormat || preset.fixedApiFormat === provider.apiFormat),
);
if (hostPreset) {
return hostPreset.key;
}
const typePreset = presets.find((preset) => preset.backendType === provider.type && !preset.fixedApiFormat);
return typePreset?.key || fallbackKey;
};
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,28 @@
import { describe, expect, it } from 'vitest';
import {
buildOracleApproximateTotalSql,
parseApproximateTableCountRow,
resolveApproximateTableCountStrategy,
} from './approximateTableCount';
describe('approximateTableCount', () => {
it('uses oracle metadata approximate total only for unfiltered full-table preview', () => {
expect(resolveApproximateTableCountStrategy({ dbType: 'oracle', whereSQL: '' })).toBe('oracle-num-rows');
expect(resolveApproximateTableCountStrategy({ dbType: 'oracle', whereSQL: 'WHERE id = 1' })).toBe('none');
});
it('keeps duckdb approximate count on unfiltered previews', () => {
expect(resolveApproximateTableCountStrategy({ dbType: 'duckdb', whereSQL: '' })).toBe('duckdb-estimated-size');
});
it('builds Oracle approx count SQL from owner and table name', () => {
expect(buildOracleApproximateTotalSql({ dbName: 'HR', tableName: 'HR.EMPLOYEES' })).toContain("owner = 'HR'");
expect(buildOracleApproximateTotalSql({ dbName: 'HR', tableName: 'HR.EMPLOYEES' })).toContain("table_name = 'EMPLOYEES'");
});
it('parses approximate total rows using preferred keys', () => {
expect(parseApproximateTableCountRow({ NUM_ROWS: '1234' }, ['num_rows'])).toBe(1234);
expect(parseApproximateTableCountRow({ approx_total: 5678 }, ['approx_total'])).toBe(5678);
});
});

View File

@@ -0,0 +1,106 @@
export type ApproximateTableCountStrategy = 'none' | 'duckdb-estimated-size' | 'oracle-num-rows';
const MAX_SAFE_BIGINT = BigInt(Number.MAX_SAFE_INTEGER);
const toNonNegativeFiniteNumber = (value: unknown): number | null => {
if (typeof value === 'number') {
return Number.isFinite(value) && value >= 0 && value <= Number.MAX_SAFE_INTEGER ? value : null;
}
if (typeof value === 'bigint') {
return value >= 0n && value <= MAX_SAFE_BIGINT ? Number(value) : null;
}
if (typeof value === 'string') {
const text = value.trim();
if (!text) return null;
if (/^[+-]?\d+$/.test(text)) {
try {
const parsed = BigInt(text);
return parsed >= 0n && parsed <= MAX_SAFE_BIGINT ? Number(parsed) : null;
} catch {
return null;
}
}
const parsed = Number(text);
return Number.isFinite(parsed) && parsed >= 0 && parsed <= Number.MAX_SAFE_INTEGER ? parsed : null;
}
return null;
};
const stripOuterQuotes = (value: string): string => {
const trimmed = String(value || '').trim();
if (trimmed.length < 2) return trimmed;
const first = trimmed[0];
const last = trimmed[trimmed.length - 1];
if ((first === '"' && last === '"') || (first === '`' && last === '`') || (first === '[' && last === ']')) {
return trimmed.slice(1, -1).trim();
}
return trimmed;
};
const escapeSQLLiteral = (value: string): string => String(value || '').replace(/'/g, "''");
const resolveOracleOwnerAndTable = (params: { dbName: string; tableName: string }) => {
const rawTable = String(params.tableName || '').trim();
const parts = rawTable.split('.').map(stripOuterQuotes).filter(Boolean);
const tableName = String(parts[parts.length - 1] || rawTable || '').trim();
const ownerCandidate = parts.length >= 2 ? parts[parts.length - 2] : String(params.dbName || '').trim();
return {
owner: ownerCandidate.toUpperCase(),
tableName: tableName.toUpperCase(),
};
};
export const resolveApproximateTableCountStrategy = (params: {
dbType: string;
whereSQL: string;
}): ApproximateTableCountStrategy => {
const dbType = String(params.dbType || '').trim().toLowerCase();
const whereSQL = String(params.whereSQL || '').trim();
if (whereSQL) return 'none';
if (dbType === 'duckdb') return 'duckdb-estimated-size';
if (dbType === 'oracle') return 'oracle-num-rows';
return 'none';
};
export const buildOracleApproximateTotalSql = (params: { dbName: string; tableName: string }): string => {
const { owner, tableName } = resolveOracleOwnerAndTable(params);
const escapedTable = escapeSQLLiteral(tableName);
if (!owner) {
return `SELECT num_rows AS approx_total FROM user_tables WHERE table_name = '${escapedTable}' AND ROWNUM = 1`;
}
return `SELECT num_rows AS approx_total FROM all_tables WHERE owner = '${escapeSQLLiteral(owner)}' AND table_name = '${escapedTable}' AND ROWNUM = 1`;
};
export const parseApproximateTableCountRow = (
row: unknown,
preferredKeys: string[] = ['approx_total', 'estimated_size', 'estimated_rows', 'row_count', 'num_rows', 'count', 'total'],
): number | null => {
if (!row || typeof row !== 'object') return null;
const entries = Object.entries(row as Record<string, unknown>);
if (entries.length === 0) return null;
for (const preferredKey of preferredKeys) {
const normalizedPreferred = String(preferredKey || '').trim().toLowerCase();
for (const [key, value] of entries) {
if (String(key || '').trim().toLowerCase() !== normalizedPreferred) continue;
const parsed = toNonNegativeFiniteNumber(value);
if (parsed !== null) return parsed;
}
}
for (const [key, value] of entries) {
const normalizedKey = String(key || '').trim().toLowerCase();
if (!normalizedKey.includes('estimate') && !normalizedKey.includes('row') && !normalizedKey.includes('count') && !normalizedKey.includes('total')) {
continue;
}
const parsed = toNonNegativeFiniteNumber(value);
if (parsed !== null) return parsed;
}
for (const [, value] of entries) {
const parsed = toNonNegativeFiniteNumber(value);
if (parsed !== null) return parsed;
}
return null;
};

View File

@@ -0,0 +1,57 @@
import { describe, expect, it } from 'vitest';
import {
resolvePaginationPageText,
resolvePaginationSummaryText,
resolvePaginationTotalForControl,
} from './dataGridPagination';
describe('dataGridPagination', () => {
it('shows Oracle approximate total in summary but not in total-page chip', () => {
const pagination = {
current: 3,
pageSize: 100,
total: 301,
totalKnown: false,
totalApprox: true,
approximateTotal: 1832451,
};
expect(resolvePaginationSummaryText({
pagination,
prefersManualTotalCount: true,
supportsApproximateTableCount: true,
})).toContain('约 1832451 条');
expect(resolvePaginationPageText({
pagination,
supportsApproximateTotalPages: false,
})).toBe('第 3 页');
expect(resolvePaginationTotalForControl({
pagination,
supportsApproximateTotalPages: false,
})).toBe(301);
});
it('still allows DuckDB to use approximate totals for page counts', () => {
const pagination = {
current: 2,
pageSize: 100,
total: 201,
totalKnown: false,
totalApprox: true,
approximateTotal: 1000,
};
expect(resolvePaginationPageText({
pagination,
supportsApproximateTotalPages: true,
})).toBe('第 2 / 10 页');
expect(resolvePaginationTotalForControl({
pagination,
supportsApproximateTotalPages: true,
})).toBe(1000);
});
});

View File

@@ -0,0 +1,92 @@
export type PaginationStateLike = {
current: number;
pageSize: number;
total: number;
totalKnown?: boolean;
totalApprox?: boolean;
approximateTotal?: number;
totalCountLoading?: boolean;
totalCountCancelled?: boolean;
};
const toFiniteNonNegativeNumber = (value: unknown): number | null => {
const parsed = Number(value);
return Number.isFinite(parsed) && parsed >= 0 ? parsed : null;
};
const resolveApproximateTotal = (pagination: PaginationStateLike): number | null => {
if (!pagination.totalApprox) return null;
const approximateTotal = toFiniteNonNegativeNumber(pagination.approximateTotal);
return approximateTotal !== null && approximateTotal > 0 ? approximateTotal : null;
};
const resolveCurrentCount = (pagination: PaginationStateLike): number => {
const total = toFiniteNonNegativeNumber(pagination.total) ?? 0;
const rangeStart = Math.max(0, (pagination.current - 1) * pagination.pageSize + (total > 0 ? 1 : 0));
const hasValidRange = total > 0 && rangeStart > 0;
if (!hasValidRange) return 0;
const rangeEnd = Math.min(total, rangeStart + pagination.pageSize - 1);
return Math.max(0, rangeEnd - rangeStart + 1);
};
export const resolvePaginationSummaryText = (params: {
pagination: PaginationStateLike;
prefersManualTotalCount: boolean;
supportsApproximateTableCount: boolean;
}): string => {
const { pagination, prefersManualTotalCount, supportsApproximateTableCount } = params;
const currentCount = resolveCurrentCount(pagination);
const total = toFiniteNonNegativeNumber(pagination.total) ?? 0;
const approximateTotal = resolveApproximateTotal(pagination);
if (pagination.totalKnown === false) {
if (prefersManualTotalCount) {
if (pagination.totalCountLoading) return `当前 ${currentCount} 条 / 正在统计精确总数…`;
if (supportsApproximateTableCount && approximateTotal !== null) return `当前 ${currentCount} 条 / 约 ${approximateTotal}`;
if (pagination.totalCountCancelled) return `当前 ${currentCount} 条 / 已取消统计`;
return `当前 ${currentCount} 条 / 总数未统计`;
}
return `当前 ${currentCount} 条 / 正在统计总数…`;
}
if (!Number.isFinite(total) || total <= 0) {
return '当前 0 条 / 共 0 条';
}
return `当前 ${currentCount} 条 / 共 ${total}`;
};
export const resolvePaginationPageText = (params: {
pagination: PaginationStateLike;
supportsApproximateTotalPages: boolean;
}): string => {
const { pagination, supportsApproximateTotalPages } = params;
const exactTotal = toFiniteNonNegativeNumber(pagination.total) ?? 0;
const approximateTotal = resolveApproximateTotal(pagination);
const effectiveTotal =
pagination.totalKnown !== false
? exactTotal
: supportsApproximateTotalPages && approximateTotal !== null
? approximateTotal
: 0;
if (effectiveTotal <= 0) return `${pagination.current}`;
const totalPages = Math.max(1, Math.ceil(effectiveTotal / Math.max(1, pagination.pageSize)));
if (pagination.totalKnown === false && !(supportsApproximateTotalPages && approximateTotal !== null)) {
return `${pagination.current}`;
}
return `${pagination.current} / ${totalPages}`;
};
export const resolvePaginationTotalForControl = (params: {
pagination: PaginationStateLike;
supportsApproximateTotalPages: boolean;
}): number => {
const { pagination, supportsApproximateTotalPages } = params;
const exactTotal = toFiniteNonNegativeNumber(pagination.total) ?? 0;
const approximateTotal = resolveApproximateTotal(pagination);
if (pagination.totalKnown !== false) return exactTotal;
if (supportsApproximateTotalPages && approximateTotal !== null) return approximateTotal;
return exactTotal;
};

View File

@@ -0,0 +1,32 @@
import { describe, expect, it } from 'vitest';
import { getDataSourceCapabilities } from './dataSourceCapabilities';
describe('dataSourceCapabilities', () => {
it('treats Oracle table preview totals as manual exact count plus approximate metadata count', () => {
expect(getDataSourceCapabilities({ type: 'oracle' })).toMatchObject({
type: 'oracle',
preferManualTotalCount: true,
supportsApproximateTableCount: true,
supportsApproximateTotalPages: false,
});
});
it('keeps DuckDB manual count and approximate total support', () => {
expect(getDataSourceCapabilities({ type: 'duckdb' })).toMatchObject({
type: 'duckdb',
preferManualTotalCount: true,
supportsApproximateTableCount: true,
supportsApproximateTotalPages: true,
});
});
it('keeps MySQL on automatic total count mode', () => {
expect(getDataSourceCapabilities({ type: 'mysql' })).toMatchObject({
type: 'mysql',
preferManualTotalCount: false,
supportsApproximateTableCount: false,
supportsApproximateTotalPages: false,
});
});
});

View File

@@ -64,6 +64,9 @@ const COPY_INSERT_TYPES = new Set([
const QUERY_EDITOR_DISABLED_TYPES = new Set(['redis']);
const FORCE_READ_ONLY_QUERY_TYPES = new Set(['tdengine', 'clickhouse']);
const MANUAL_TOTAL_COUNT_TYPES = new Set(['duckdb', 'oracle']);
const APPROXIMATE_TABLE_COUNT_TYPES = new Set(['duckdb', 'oracle']);
const APPROXIMATE_TOTAL_PAGE_TYPES = new Set(['duckdb']);
export type DataSourceCapabilities = {
type: string;
@@ -71,6 +74,9 @@ export type DataSourceCapabilities = {
supportsSqlQueryExport: boolean;
supportsCopyInsert: boolean;
forceReadOnlyQueryResult: boolean;
preferManualTotalCount: boolean;
supportsApproximateTableCount: boolean;
supportsApproximateTotalPages: boolean;
};
export const getDataSourceCapabilities = (config: ConnectionLike): DataSourceCapabilities => {
@@ -81,6 +87,8 @@ export const getDataSourceCapabilities = (config: ConnectionLike): DataSourceCap
supportsSqlQueryExport: SQL_QUERY_EXPORT_TYPES.has(type),
supportsCopyInsert: COPY_INSERT_TYPES.has(type),
forceReadOnlyQueryResult: FORCE_READ_ONLY_QUERY_TYPES.has(type),
preferManualTotalCount: MANUAL_TOTAL_COUNT_TYPES.has(type),
supportsApproximateTableCount: APPROXIMATE_TABLE_COUNT_TYPES.has(type),
supportsApproximateTotalPages: APPROXIMATE_TOTAL_PAGE_TYPES.has(type),
};
};

View File

@@ -0,0 +1,26 @@
import { describe, expect, it } from 'vitest';
import { resolveDataViewerAutoFetchAction } from './dataViewerAutoFetch';
describe('resolveDataViewerAutoFetchAction', () => {
it('skips one fetch while tab state is hydrating', () => {
expect(resolveDataViewerAutoFetchAction({
skipNextAutoFetch: true,
hasInitialLoad: false,
})).toBe('skip');
});
it('loads current page on the first real fetch', () => {
expect(resolveDataViewerAutoFetchAction({
skipNextAutoFetch: false,
hasInitialLoad: false,
})).toBe('load-current-page');
});
it('reloads from first page after sort or filter changes', () => {
expect(resolveDataViewerAutoFetchAction({
skipNextAutoFetch: false,
hasInitialLoad: true,
})).toBe('reload-first-page');
});
});

View File

@@ -0,0 +1,16 @@
export type DataViewerAutoFetchAction = 'skip' | 'load-current-page' | 'reload-first-page';
export const resolveDataViewerAutoFetchAction = (params: {
skipNextAutoFetch: boolean;
hasInitialLoad: boolean;
}): DataViewerAutoFetchAction => {
if (params.skipNextAutoFetch) {
return 'skip';
}
if (!params.hasInitialLoad) {
return 'load-current-page';
}
return 'reload-first-page';
};

View File

@@ -1,7 +1,6 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
import {ai} from '../models';
import {context} from '../models';
export function AIChatCancel(arg1:string):Promise<void>;
@@ -42,5 +41,3 @@ export function AISetContextLevel(arg1:string):Promise<void>;
export function AISetSafetyLevel(arg1:string):Promise<void>;
export function AITestProvider(arg1:ai.ProviderConfig):Promise<Record<string, any>>;
export function Startup(arg1:context.Context):Promise<void>;

View File

@@ -81,7 +81,3 @@ export function AISetSafetyLevel(arg1) {
export function AITestProvider(arg1) {
return window['go']['aiservice']['Service']['AITestProvider'](arg1);
}
export function Startup(arg1) {
return window['go']['aiservice']['Service']['Startup'](arg1);
}

View File

@@ -1,10 +1,8 @@
// 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';
import {context} from '../models';
export function ApplyChanges(arg1:connection.ConnectionConfig,arg2:string,arg3:string,arg4:connection.ChangeSet):Promise<connection.QueryResult>;
@@ -16,8 +14,6 @@ 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>;
@@ -198,8 +194,6 @@ export function SetMacNativeWindowControls(arg1:boolean):Promise<void>;
export function SetWindowTranslucency(arg1:number,arg2:number):Promise<void>;
export function Startup(arg1:context.Context):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

@@ -22,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);
}
@@ -386,10 +382,6 @@ export function SetWindowTranslucency(arg1, arg2) {
return window['go']['app']['App']['SetWindowTranslucency'](arg1, arg2);
}
export function Startup(arg1) {
return window['go']['app']['App']['Startup'](arg1);
}
export function TestConnection(arg1) {
return window['go']['app']['App']['TestConnection'](arg1);
}

View File

@@ -8,6 +8,7 @@ import (
"fmt"
"io"
"net/http"
"net/url"
"strings"
"GoNavi-Wails/internal/ai"
@@ -32,6 +33,25 @@ func normalizeAnthropicMessagesURL(baseURL string) string {
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
@@ -446,8 +466,7 @@ func (p *AnthropicProvider) doRequest(ctx context.Context, body interface{}) (io
}
httpReq.Header.Set("Content-Type", "application/json")
httpReq.Header.Set("x-api-key", p.config.APIKey)
httpReq.Header.Set("anthropic-version", anthropicAPIVersion)
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")

View File

@@ -1,6 +1,9 @@
package provider
import "testing"
import (
"net/http"
"testing"
)
func TestNormalizeAnthropicMessagesURL_AppendsMessagesSuffix(t *testing.T) {
url := normalizeAnthropicMessagesURL("https://api.anthropic.com")
@@ -22,3 +25,33 @@ func TestNormalizeAnthropicMessagesURL_PreservesExplicitMessagesPath(t *testing.
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

@@ -5,16 +5,20 @@ import (
"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 协议的代理服务
@@ -48,19 +52,25 @@ func (p *ClaudeCLIProvider) Chat(ctx context.Context, req ai.ChatRequest) (*ai.C
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 := exec.CommandContext(ctx, "claude", args...)
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))
}
@@ -68,13 +78,14 @@ func (p *ClaudeCLIProvider) Chat(ctx context.Context, req ai.ChatRequest) (*ai.C
}
// 解析 JSON 输出
var result struct {
Result string `json:"result"`
}
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
}
@@ -85,6 +96,9 @@ func (p *ClaudeCLIProvider) ChatStream(ctx context.Context, req ai.ChatRequest,
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 != "" {
@@ -93,7 +107,7 @@ func (p *ClaudeCLIProvider) ChatStream(ctx context.Context, req ai.ChatRequest,
fmt.Printf("[ClaudeCLI DEBUG] Running: claude %v\n", args)
cmd := exec.CommandContext(ctx, "claude", args...)
cmd := claudeCommandContext(ctx, "claude", args...)
if err := p.setEnv(cmd); err != nil {
return err
}
@@ -137,7 +151,23 @@ func (p *ClaudeCLIProvider) ChatStream(ctx context.Context, req ai.ChatRequest,
}
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 {
@@ -156,12 +186,18 @@ func (p *ClaudeCLIProvider) ChatStream(ctx context.Context, req ai.ChatRequest,
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":
callback(ai.StreamChunk{Error: event.Error.Message, Done: true})
errMsg, _ := extractClaudeCLIEventError(event)
callback(ai.StreamChunk{Error: errMsg, Done: true})
_ = cmd.Wait()
return nil
}
@@ -171,6 +207,14 @@ func (p *ClaudeCLIProvider) ChatStream(ctx context.Context, req ai.ChatRequest,
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 != "" {
@@ -184,6 +228,20 @@ func (p *ClaudeCLIProvider) ChatStream(ctx context.Context, req ai.ChatRequest,
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)
@@ -200,6 +258,7 @@ func buildClaudeCLIEnv(config ai.ProviderConfig, baseEnv []string, goos string,
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)
}
@@ -354,8 +413,15 @@ func buildPrompt(messages []ai.Message) string {
// cliStreamEvent Claude CLI stream-json 输出的事件结构
type cliStreamEvent struct {
Type string `json:"type"`
Message 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"`
@@ -367,8 +433,79 @@ type cliStreamEvent struct {
Text string `json:"text"`
Thinking string `json:"thinking"`
} `json:"delta,omitempty"`
Result string `json:"result,omitempty"`
Error struct {
Message string `json:"message"`
} `json:"error,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

@@ -1,9 +1,13 @@
package provider
import (
"context"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"time"
"GoNavi-Wails/internal/ai"
)
@@ -26,6 +30,9 @@ func TestBuildClaudeCLIEnv_IncludesAnthropicProxyEnv(t *testing.T) {
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) {
@@ -67,3 +74,281 @@ func TestBuildClaudeCLIEnv_ReturnsActionableErrorWhenGitBashMissingOnWindows(t *
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

@@ -2,9 +2,13 @@ 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:") {
@@ -24,3 +28,70 @@ func ParseDataURI(dataURI string) (mimeType, rawBase64 string, err error) {
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

@@ -30,14 +30,7 @@ type OpenAIProvider struct {
// NewOpenAIProvider 创建 OpenAI Provider 实例
func NewOpenAIProvider(config ai.ProviderConfig) (Provider, error) {
baseURL := strings.TrimRight(strings.TrimSpace(config.BaseURL), "/")
if baseURL == "" {
baseURL = defaultOpenAIBaseURL
}
// 确保 baseURL 包含 /v1 路径(兼容用户只填域名的情况,如 https://anyrouter.top
if !strings.HasSuffix(baseURL, "/v1") && !strings.Contains(baseURL, "/v1/") {
baseURL = baseURL + "/v1"
}
baseURL := NormalizeOpenAICompatibleBaseURL(config.BaseURL)
model := strings.TrimSpace(config.Model)
if model == "" {
return nil, fmt.Errorf("模型 ID 不能为空,请在设置中选择或输入模型")
@@ -315,7 +308,7 @@ func (p *OpenAIProvider) ChatStream(ctx context.Context, req ai.ChatRequest, cal
}
if len(chunk.Choices) > 0 {
choice := chunk.Choices[0]
// Handle ToolCalls delta
if len(choice.Delta.ToolCalls) > 0 {
receivedContent = true
@@ -383,10 +376,7 @@ func (p *OpenAIProvider) doRequest(ctx context.Context, body interface{}) (io.Re
return nil, fmt.Errorf("序列化请求失败: %w", err)
}
url := p.baseURL + "/chat/completions"
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)

View File

@@ -5,6 +5,76 @@ import (
"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 {

View File

@@ -6,6 +6,7 @@ import (
"fmt"
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
@@ -31,7 +32,7 @@ type Service struct {
safetyLevel ai.SQLPermissionLevel
contextLevel ai.ContextLevel
guard *safety.Guard
configDir string // 配置存储目录
configDir string // 配置存储目录
cancelFuncs map[string]context.CancelFunc // 记录每个 session 的 context 取消函数
}
@@ -45,6 +46,55 @@ var miniMaxAnthropicModels = []string{
"MiniMax-M2",
}
var dashScopeCodingPlanModels = []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",
}
const dashScopeCodingPlanAnthropicBaseURL = "https://coding.dashscope.aliyuncs.com/apps/anthropic"
var volcengineCodingPlanAllowedExactModels = []string{
"auto",
}
var volcengineCodingPlanAllowedModelFamilies = []string{
"doubao-seed-2.0-code",
"doubao-seed-2.0-pro",
"doubao-seed-2.0-lite",
"doubao-seed-code",
"minimax-m2.5",
"glm-4.7",
"deepseek-v3.2",
"kimi-k2",
}
const volcengineCodingPlanEmptyModelsError = `当前接口未返回可用的火山 Coding Plan 模型,请检查账号权限或切换到"火山方舟"供应商`
var claudeCLIHealthCheckFunc = func(config ai.ProviderConfig) error {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
cliProvider, err := provider.NewProvider(config)
if err != nil {
return err
}
_, err = cliProvider.Chat(ctx, ai.ChatRequest{
Messages: []ai.Message{
{Role: "user", Content: "ping"},
},
MaxTokens: 1,
Temperature: 0,
})
return err
}
// NewService 创建 AI Service 实例
func NewService() *Service {
return &Service{
@@ -56,8 +106,13 @@ func NewService() *Service {
}
}
// Startup Wails 生命周期回调
func (s *Service) Startup(ctx context.Context) {
// InitializeLifecycle attaches runtime context without exposing lifecycle internals to Wails bindings.
func InitializeLifecycle(s *Service, ctx context.Context) {
s.startup(ctx)
}
// startup Wails 生命周期回调
func (s *Service) startup(ctx context.Context) {
s.ctx = ctx
s.configDir = resolveConfigDir()
s.loadConfig()
@@ -173,6 +228,12 @@ func (s *Service) AITestProvider(config ai.ProviderConfig) map[string]interface{
err = fmt.Errorf("上游服务器内部错误 (HTTP %d)", resp.StatusCode)
}
}
case "claude-cli":
testConfig := config
if strings.TrimSpace(testConfig.Model) == "" && isDashScopeCodingPlanProvider(testConfig) && len(dashScopeCodingPlanModels) > 0 {
testConfig.Model = dashScopeCodingPlanModels[0]
}
err = claudeCLIHealthCheckFunc(testConfig)
default:
if baseURL != "" {
req, _ := http.NewRequest("GET", baseURL, nil)
@@ -219,18 +280,104 @@ func isMoonshotAnthropicProvider(config ai.ProviderConfig) bool {
return strings.Contains(baseURL, "api.moonshot.cn")
}
func parseProviderBaseURL(raw string) (string, string) {
parsed, err := url.Parse(strings.TrimSpace(raw))
if err != nil {
return "", ""
}
return strings.ToLower(parsed.Hostname()), strings.TrimRight(strings.ToLower(parsed.Path), "/")
}
func isDashScopeBailianAnthropicProvider(config ai.ProviderConfig) bool {
if normalizedProviderType(config) != "anthropic" {
return false
}
host, path := parseProviderBaseURL(config.BaseURL)
return host == "dashscope.aliyuncs.com" && strings.HasPrefix(path, "/apps/anthropic")
}
func isDashScopeCodingPlanAnthropicProvider(config ai.ProviderConfig) bool {
if normalizedProviderType(config) != "anthropic" {
return false
}
return isDashScopeCodingPlanProvider(config)
}
func isDashScopeCodingPlanProvider(config ai.ProviderConfig) bool {
host, path := parseProviderBaseURL(config.BaseURL)
return host == "coding.dashscope.aliyuncs.com" && (strings.HasPrefix(path, "/apps/anthropic") || strings.HasPrefix(path, "/v1"))
}
func isVolcengineCodingPlanProvider(config ai.ProviderConfig) bool {
if normalizedProviderType(config) != "openai" {
return false
}
host, path := parseProviderBaseURL(provider.NormalizeOpenAICompatibleBaseURL(config.BaseURL))
return host == "ark.cn-beijing.volces.com" && path == "/api/coding/v3"
}
func filterVolcengineCodingPlanModels(models []string) []string {
filtered := make([]string, 0, len(models))
for _, model := range models {
lowerModel := strings.ToLower(strings.TrimSpace(model))
matched := false
for _, exactModel := range volcengineCodingPlanAllowedExactModels {
if lowerModel == exactModel {
filtered = append(filtered, model)
matched = true
break
}
}
if matched {
continue
}
for _, family := range volcengineCodingPlanAllowedModelFamilies {
if strings.Contains(lowerModel, family) {
filtered = append(filtered, model)
break
}
}
}
return filtered
}
func filterFetchedModelsForProvider(config ai.ProviderConfig, models []string) ([]string, error) {
if !isVolcengineCodingPlanProvider(config) {
return models, nil
}
filtered := filterVolcengineCodingPlanModels(models)
if len(filtered) == 0 {
return nil, fmt.Errorf(volcengineCodingPlanEmptyModelsError)
}
return filtered, nil
}
func defaultStaticModelsForProvider(config ai.ProviderConfig) []string {
if isMiniMaxAnthropicProvider(config) {
return append([]string(nil), miniMaxAnthropicModels...)
}
if isDashScopeCodingPlanProvider(config) {
return append([]string(nil), dashScopeCodingPlanModels...)
}
return nil
}
func normalizeProviderConfig(config ai.ProviderConfig) ai.ProviderConfig {
staticModels := defaultStaticModelsForProvider(config)
if len(staticModels) > 0 && len(config.Models) == 0 {
config.Models = staticModels
switch {
case isDashScopeBailianAnthropicProvider(config):
config.Models = nil
case isDashScopeCodingPlanProvider(config):
config.Type = "custom"
config.APIFormat = "claude-cli"
config.BaseURL = dashScopeCodingPlanAnthropicBaseURL
config.Models = append([]string(nil), dashScopeCodingPlanModels...)
default:
staticModels := defaultStaticModelsForProvider(config)
if len(staticModels) > 0 && len(config.Models) == 0 {
config.Models = staticModels
}
}
model := strings.TrimSpace(config.Model)
if isMiniMaxAnthropicProvider(config) && (model == "" || strings.HasPrefix(strings.ToLower(model), "minimax-text-")) {
config.Model = miniMaxAnthropicModels[0]
@@ -248,6 +395,9 @@ func resolveModelsURL(config ai.ProviderConfig) string {
if isMoonshotAnthropicProvider(config) {
return "https://api.moonshot.cn/v1/models"
}
if isDashScopeBailianAnthropicProvider(config) {
return "https://dashscope.aliyuncs.com/compatible-mode/v1/models"
}
if baseURL == "" {
baseURL = "https://api.anthropic.com"
}
@@ -263,13 +413,7 @@ func resolveModelsURL(config ai.ProviderConfig) string {
case "openai":
fallthrough
default:
if baseURL == "" {
baseURL = "https://api.openai.com/v1"
}
if !strings.HasSuffix(baseURL, "/v1") && !strings.Contains(baseURL, "/v1/") {
baseURL = baseURL + "/v1"
}
return baseURL + "/models"
return provider.ResolveOpenAICompatibleEndpoint(baseURL, "models")
}
}
@@ -283,9 +427,11 @@ func newModelsRequest(config ai.ProviderConfig) (*http.Request, error) {
switch normalizedProviderType(config) {
case "anthropic":
req.Header.Set("x-api-key", config.APIKey)
req.Header.Set("anthropic-version", "2023-06-01")
req.Header.Set("Authorization", "Bearer "+config.APIKey)
if isDashScopeBailianAnthropicProvider(config) {
req.Header.Set("Authorization", "Bearer "+config.APIKey)
} else {
provider.ApplyAnthropicAuthHeaders(req.Header, config.BaseURL, config.APIKey)
}
case "gemini":
// Gemini 使用 query string 传递 key无需额外鉴权头
default:
@@ -315,33 +461,36 @@ func resolveAnthropicMessagesURL(baseURL string) string {
func newProviderHealthCheckRequest(config ai.ProviderConfig) (*http.Request, error) {
config = normalizeProviderConfig(config)
if isMiniMaxAnthropicProvider(config) {
body := map[string]interface{}{
"model": config.Model,
"max_tokens": 1,
"messages": []map[string]string{
{"role": "user", "content": "ping"},
},
}
bodyBytes, err := json.Marshal(body)
if err != nil {
return nil, fmt.Errorf("序列化请求失败: %w", err)
}
req, err := http.NewRequest("POST", resolveAnthropicMessagesURL(config.BaseURL), strings.NewReader(string(bodyBytes)))
if err != nil {
return nil, fmt.Errorf("创建请求失败: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("x-api-key", config.APIKey)
req.Header.Set("anthropic-version", "2023-06-01")
for k, v := range config.Headers {
req.Header.Set(k, v)
}
return req, nil
if isMiniMaxAnthropicProvider(config) || isDashScopeBailianAnthropicProvider(config) || isDashScopeCodingPlanAnthropicProvider(config) {
return newAnthropicMessagesHealthCheckRequest(config)
}
return newModelsRequest(config)
}
func newAnthropicMessagesHealthCheckRequest(config ai.ProviderConfig) (*http.Request, error) {
body := map[string]interface{}{
"model": config.Model,
"max_tokens": 1,
"messages": []map[string]string{
{"role": "user", "content": "ping"},
},
}
bodyBytes, err := json.Marshal(body)
if err != nil {
return nil, fmt.Errorf("序列化请求失败: %w", err)
}
req, err := http.NewRequest("POST", resolveAnthropicMessagesURL(config.BaseURL), strings.NewReader(string(bodyBytes)))
if err != nil {
return nil, fmt.Errorf("创建请求失败: %w", err)
}
req.Header.Set("Content-Type", "application/json")
provider.ApplyAnthropicAuthHeaders(req.Header, config.BaseURL, config.APIKey)
for k, v := range config.Headers {
req.Header.Set(k, v)
}
return req, nil
}
// AISetActiveProvider 设置活动 Provider
func (s *Service) AISetActiveProvider(id string) {
s.mu.Lock()
@@ -380,7 +529,12 @@ func (s *Service) AIListModels() map[string]interface{} {
return map[string]interface{}{"success": false, "models": []string{}, "error": "未找到活跃 Provider"}
}
models, err := fetchModels(config)
config = normalizeProviderConfig(config)
if staticModels := defaultStaticModelsForProvider(config); len(staticModels) > 0 {
return map[string]interface{}{"success": true, "models": staticModels, "source": "static"}
}
models, err := fetchModelsFunc(config)
if err != nil {
// 回退到配置中的静态模型列表
if len(config.Models) > 0 {
@@ -389,10 +543,17 @@ func (s *Service) AIListModels() map[string]interface{} {
return map[string]interface{}{"success": false, "models": []string{}, "error": err.Error()}
}
models, err = filterFetchedModelsForProvider(config, models)
if err != nil {
return map[string]interface{}{"success": false, "models": []string{}, "error": err.Error()}
}
return map[string]interface{}{"success": true, "models": models, "source": "api"}
}
// fetchModels 从供应商 API 获取可用模型列表
var fetchModelsFunc = fetchModels
func fetchModels(config ai.ProviderConfig) ([]string, error) {
providerType := normalizedProviderType(config)
if staticModels := defaultStaticModelsForProvider(config); len(staticModels) > 0 {
@@ -588,8 +749,8 @@ func (s *Service) AIChatSend(messages []ai.Message, tools []ai.Tool) map[string]
}
return map[string]interface{}{
"success": true,
"content": resp.Content,
"success": true,
"content": resp.Content,
"tool_calls": resp.ToolCalls,
"tokensUsed": map[string]int{
"promptTokens": resp.TokensUsed.PromptTokens,

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

@@ -37,6 +37,43 @@ func TestResolveModelsURL_UsesOpenAIModelsEndpointForOpenAICompatibleProvider(t
}
}
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",
@@ -56,6 +93,16 @@ func TestDefaultStaticModelsForProvider_ReturnsMiniMaxAnthropicModels(t *testing
}
}
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",
@@ -76,3 +123,30 @@ func TestNewProviderHealthCheckRequest_UsesMessagesEndpointForMiniMaxAnthropic(t
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)
}
}

View File

@@ -25,7 +25,7 @@ type Tool struct {
// Message 表示一条对话消息
type Message struct {
Role string `json:"role"` // "system" | "user" | "assistant" | "tool"
Role string `json:"role"` // "system" | "user" | "assistant" | "tool"
Content string `json:"content"`
Images []string `json:"images,omitempty"` // base64 encoded images with data:image/png;base64,... prefix
ToolCallID string `json:"tool_call_id,omitempty"` // 当 role 为 "tool" 时必须传递
@@ -66,13 +66,13 @@ type StreamChunk struct {
// ProviderConfig AI Provider 配置
type ProviderConfig struct {
ID string `json:"id"`
Type string `json:"type"` // openai | anthropic | gemini | custom
Type string `json:"type"` // openai | anthropic | gemini | custom
Name string `json:"name"`
APIKey string `json:"apiKey"`
BaseURL string `json:"baseUrl"`
Model string `json:"model"`
Models []string `json:"models,omitempty"`
APIFormat string `json:"apiFormat,omitempty"` // custom 专用: openai | anthropic | gemini
APIFormat string `json:"apiFormat,omitempty"` // custom 专用: openai | anthropic | gemini | claude-cli
Headers map[string]string `json:"headers,omitempty"`
MaxTokens int `json:"maxTokens"`
Temperature float64 `json:"temperature"`

View File

@@ -64,9 +64,14 @@ func NewApp() *App {
}
}
// Startup is called when the app starts. The context is saved
// so we can call the runtime methods
func (a *App) Startup(ctx context.Context) {
// InitializeLifecycle attaches runtime context without exposing lifecycle internals to Wails bindings.
func InitializeLifecycle(a *App, ctx context.Context) {
a.startup(ctx)
}
// startup is called when the app starts. The context is saved
// so we can call the runtime methods.
func (a *App) startup(ctx context.Context) {
a.ctx = ctx
a.startedAt = time.Now()
logger.Init()
@@ -603,7 +608,7 @@ func (a *App) connectDatabaseWithStartupRetry(rawConfig connection.ConnectionCon
if err := dbInst.Connect(connectConfig); err == nil {
if attempt > 1 {
logger.Warnf("数据库连接在启动保护重试后成功:%s 缓存Key=%s 尝试=%d/%d", formatConnSummary(effectiveConfig), cacheKey, attempt, startupConnectRetryAttempts)
logger.Warnf("数据库连接在重试后成功:%s 缓存Key=%s 尝试=%d/%d", formatConnSummary(effectiveConfig), cacheKey, attempt, startupConnectRetryAttempts)
}
return dbInst, effectiveConfig, nil
} else {
@@ -611,10 +616,10 @@ func (a *App) connectDatabaseWithStartupRetry(rawConfig connection.ConnectionCon
wrapped := wrapConnectError(effectiveConfig, err)
lastErr = wrapped
logger.Error(wrapped, "建立数据库连接失败:%s 缓存Key=%s", formatConnSummary(effectiveConfig), cacheKey)
if !a.shouldRetryStartupConnect(err, attempt) {
if !a.shouldRetryConnect(err, attempt) {
return nil, effectiveConfig, wrapped
}
logger.Warnf("检测到启动期瞬时网络失败,准备重试连接:%s 缓存Key=%s 尝试=%d/%d 延迟=%s 原因=%s",
logger.Warnf("检测到瞬时网络失败,准备重试连接:%s 缓存Key=%s 尝试=%d/%d 延迟=%s 原因=%s",
formatConnSummary(effectiveConfig), cacheKey, attempt, startupConnectRetryAttempts, startupConnectRetryDelay, normalizeErrorMessage(err))
time.Sleep(startupConnectRetryDelay)
}
@@ -645,18 +650,21 @@ func (a *App) startupPhaseLabel() string {
return fmt.Sprintf("稳定期(age=%s)", age)
}
func (a *App) shouldRetryStartupConnect(err error, attempt int) bool {
func (a *App) shouldRetryConnect(err error, attempt int) bool {
if attempt >= startupConnectRetryAttempts {
return false
}
if a == nil || a.startedAt.IsZero() {
if !isTransientStartupConnectError(err) {
return false
}
age := time.Since(a.startedAt)
if age < 0 || age > startupConnectRetryWindow {
return false
if a != nil && !a.startedAt.IsZero() {
age := time.Since(a.startedAt)
if age >= 0 && age <= startupConnectRetryWindow {
return true
}
}
return isTransientStartupConnectError(err)
// Outside startup window, still grant one retry for transient network glitches.
return attempt == 1
}
func isTransientStartupConnectError(err error) bool {
@@ -700,8 +708,8 @@ func (a *App) CancelQuery(queryID string) connection.QueryResult {
return connection.QueryResult{Success: false, Message: "查询不存在或已完成"}
}
// CleanupStaleQueries removes queries older than maxAge
func (a *App) CleanupStaleQueries(maxAge time.Duration) {
// cleanupStaleQueries removes queries older than maxAge.
func (a *App) cleanupStaleQueries(maxAge time.Duration) {
a.queryMu.Lock()
defer a.queryMu.Unlock()

View File

@@ -2,11 +2,14 @@ package app
import (
"errors"
"os"
"strings"
"testing"
"time"
"GoNavi-Wails/internal/connection"
"GoNavi-Wails/internal/db"
"GoNavi-Wails/internal/logger"
)
type fakeStartupRetryDB struct {
@@ -106,7 +109,7 @@ func TestConnectDatabaseWithStartupRetry_RetriesTransientFailureAndReappliesGlob
}
}
func TestConnectDatabaseWithStartupRetry_DoesNotRetryOutsideStartupWindow(t *testing.T) {
func TestConnectDatabaseWithStartupRetry_RetriesOnceOutsideStartupWindow(t *testing.T) {
originalNewDatabaseFunc := newDatabaseFunc
originalResolveDialConfigWithProxyFunc := resolveDialConfigWithProxyFunc
defer func() {
@@ -130,12 +133,165 @@ func TestConnectDatabaseWithStartupRetry_DoesNotRetryOutsideStartupWindow(t *tes
a := &App{startedAt: time.Now().Add(-startupConnectRetryWindow - time.Second)}
rawConfig := connection.ConnectionConfig{Type: "postgres", Host: "10.1.131.86", Port: 5432, User: "postgres"}
_, _, err := a.connectDatabaseWithStartupRetry(rawConfig)
if err == nil {
t.Fatal("expected error, got nil")
}
if connectCalls != 2 {
t.Fatalf("expected 2 connect attempts outside startup window, got %d", connectCalls)
}
}
func TestConnectDatabaseWithStartupRetry_DoesNotRetryOutsideStartupWindowForNonTransientError(t *testing.T) {
originalNewDatabaseFunc := newDatabaseFunc
originalResolveDialConfigWithProxyFunc := resolveDialConfigWithProxyFunc
defer func() {
newDatabaseFunc = originalNewDatabaseFunc
resolveDialConfigWithProxyFunc = originalResolveDialConfigWithProxyFunc
}()
connectCalls := 0
newDatabaseFunc = func(dbType string) (db.Database, error) {
return &fakeStartupRetryDB{
connect: func(config connection.ConnectionConfig) error {
connectCalls++
return errors.New("pq: password authentication failed")
},
}, nil
}
resolveDialConfigWithProxyFunc = func(raw connection.ConnectionConfig) (connection.ConnectionConfig, error) {
return raw, nil
}
a := &App{startedAt: time.Now().Add(-startupConnectRetryWindow - time.Second)}
rawConfig := connection.ConnectionConfig{Type: "postgres", Host: "10.1.131.86", Port: 5432, User: "postgres"}
_, _, err := a.connectDatabaseWithStartupRetry(rawConfig)
if err == nil {
t.Fatal("expected error, got nil")
}
if connectCalls != 1 {
t.Fatalf("expected 1 connect attempt outside startup window, got %d", connectCalls)
t.Fatalf("expected 1 connect attempt outside startup window for non-transient error, got %d", connectCalls)
}
}
func TestConnectDatabaseWithStartupRetry_LogsRetryHintOutsideStartupWindow(t *testing.T) {
originalNewDatabaseFunc := newDatabaseFunc
originalResolveDialConfigWithProxyFunc := resolveDialConfigWithProxyFunc
defer func() {
newDatabaseFunc = originalNewDatabaseFunc
resolveDialConfigWithProxyFunc = originalResolveDialConfigWithProxyFunc
}()
logPath := logger.Path()
beforeSize := int64(0)
if fi, err := os.Stat(logPath); err == nil {
beforeSize = fi.Size()
}
connectCalls := 0
newDatabaseFunc = func(dbType string) (db.Database, error) {
return &fakeStartupRetryDB{
connect: func(config connection.ConnectionConfig) error {
connectCalls++
if connectCalls == 1 {
return errors.New("dial tcp 10.1.131.86:5432: connect: no route to host")
}
return nil
},
}, nil
}
resolveDialConfigWithProxyFunc = func(raw connection.ConnectionConfig) (connection.ConnectionConfig, error) {
return raw, nil
}
a := &App{startedAt: time.Now().Add(-startupConnectRetryWindow - time.Second)}
rawConfig := connection.ConnectionConfig{Type: "postgres", Host: "10.1.131.86", Port: 5432, User: "postgres"}
_, _, err := a.connectDatabaseWithStartupRetry(rawConfig)
if err != nil {
t.Fatalf("expected success after retry, got error: %v", err)
}
if connectCalls != 2 {
t.Fatalf("expected 2 connect attempts, got %d", connectCalls)
}
logContent, readErr := os.ReadFile(logPath)
if readErr != nil {
t.Fatalf("read log failed: %v", readErr)
}
if int64(len(logContent)) < beforeSize {
t.Fatalf("expected log file to grow, before=%d after=%d", beforeSize, len(logContent))
}
appended := string(logContent[beforeSize:])
if !strings.Contains(appended, "检测到瞬时网络失败,准备重试连接") {
t.Fatalf("expected retry hint log in appended segment, got: %s", appended)
}
}
func TestConnectDatabaseWithStartupRetry_OutsideStartupWindowTransientFailureStopsAfterOneRetry(t *testing.T) {
originalNewDatabaseFunc := newDatabaseFunc
originalResolveDialConfigWithProxyFunc := resolveDialConfigWithProxyFunc
defer func() {
newDatabaseFunc = originalNewDatabaseFunc
resolveDialConfigWithProxyFunc = originalResolveDialConfigWithProxyFunc
}()
connectCalls := 0
newDatabaseFunc = func(dbType string) (db.Database, error) {
return &fakeStartupRetryDB{
connect: func(config connection.ConnectionConfig) error {
connectCalls++
return errors.New("dial tcp 10.1.131.86:5432: connect: no route to host")
},
}, nil
}
resolveDialConfigWithProxyFunc = func(raw connection.ConnectionConfig) (connection.ConnectionConfig, error) {
return raw, nil
}
a := &App{startedAt: time.Now().Add(-startupConnectRetryWindow - time.Second)}
rawConfig := connection.ConnectionConfig{Type: "postgres", Host: "10.1.131.86", Port: 5432, User: "postgres"}
_, _, err := a.connectDatabaseWithStartupRetry(rawConfig)
if err == nil {
t.Fatal("expected error, got nil")
}
if connectCalls != 2 {
t.Fatalf("expected 2 connect attempts outside startup window for transient error, got %d", connectCalls)
}
}
func TestConnectDatabaseWithStartupRetry_StartupWindowTransientFailureUsesFullRetryBudget(t *testing.T) {
originalNewDatabaseFunc := newDatabaseFunc
originalResolveDialConfigWithProxyFunc := resolveDialConfigWithProxyFunc
defer func() {
newDatabaseFunc = originalNewDatabaseFunc
resolveDialConfigWithProxyFunc = originalResolveDialConfigWithProxyFunc
}()
connectCalls := 0
newDatabaseFunc = func(dbType string) (db.Database, error) {
return &fakeStartupRetryDB{
connect: func(config connection.ConnectionConfig) error {
connectCalls++
return errors.New("dial tcp 10.1.131.86:5432: connect: no route to host")
},
}, nil
}
resolveDialConfigWithProxyFunc = func(raw connection.ConnectionConfig) (connection.ConnectionConfig, error) {
return raw, nil
}
a := &App{startedAt: time.Now()}
rawConfig := connection.ConnectionConfig{Type: "postgres", Host: "10.1.131.86", Port: 5432, User: "postgres"}
_, _, err := a.connectDatabaseWithStartupRetry(rawConfig)
if err == nil {
t.Fatal("expected error, got nil")
}
if connectCalls != startupConnectRetryAttempts {
t.Fatalf("expected %d connect attempts in startup window, got %d", startupConnectRetryAttempts, connectCalls)
}
}

View File

@@ -581,6 +581,48 @@ func (a *App) DBQueryMulti(config connection.ConnectionConfig, dbName string, qu
}
}
// 全部为写操作且驱动支持批量 Exec → 一次性发送,大幅减少网络往返
// 适用于 MySQL/MariaDB/Doris/PostgreSQL/SQLite/DuckDB 等支持多语句 Exec 的驱动
if !allReadOnly {
allWrite := true
for _, stmt := range statements {
if strings.TrimSpace(stmt) != "" && isReadOnlySQLQuery(runConfig.Type, stmt) {
allWrite = false
break
}
}
if allWrite {
if batcher, ok := dbInst.(db.BatchWriteExecer); ok {
affected, batchErr := batcher.ExecBatchContext(ctx, query)
if batchErr != nil && shouldRefreshCachedConnection(batchErr) {
if a.invalidateCachedDatabase(runConfig, batchErr) {
retryInst, retryErr := a.getDatabaseForcePing(runConfig)
if retryErr != nil {
logger.Error(retryErr, "DBQueryMulti 批量写重建连接失败:%s", formatConnSummary(runConfig))
return connection.QueryResult{Success: false, Message: retryErr.Error(), QueryID: queryID}
}
if retryBatcher, ok2 := retryInst.(db.BatchWriteExecer); ok2 {
affected, batchErr = retryBatcher.ExecBatchContext(ctx, query)
}
}
}
if batchErr != nil {
logger.Error(batchErr, "DBQueryMulti 批量写执行失败:%s SQL片段=%q", formatConnSummary(runConfig), sqlSnippet(query))
return connection.QueryResult{Success: false, Message: batchErr.Error(), QueryID: queryID}
}
logger.Infof("DBQueryMulti 批量写执行成功:%s 语句数=%d affectedRows=%d", formatConnSummary(runConfig), len(statements), affected)
return connection.QueryResult{
Success: true,
Data: []connection.ResultSetData{{
Rows: []map[string]interface{}{{"affectedRows": affected}},
Columns: []string{"affectedRows"},
}},
QueryID: queryID,
}
}
}
}
var resultSets []connection.ResultSetData
for idx, stmt := range statements {
stmt = strings.TrimSpace(stmt)

View File

@@ -88,7 +88,7 @@ func TestCleanupStaleQueries(t *testing.T) {
app.queryMu.Unlock()
// Cleanup queries older than 1 hour
app.CleanupStaleQueries(1 * time.Hour)
app.cleanupStaleQueries(1 * time.Hour)
// Verify stale query was removed
app.queryMu.Lock()
@@ -110,7 +110,7 @@ func TestCleanupStaleQueries(t *testing.T) {
defer cancel2()
// Cleanup queries older than 1 hour
app.CleanupStaleQueries(1 * time.Hour)
app.cleanupStaleQueries(1 * time.Hour)
// Verify fresh query still exists
app.queryMu.Lock()

View File

@@ -574,7 +574,7 @@ func isDateTimeColumnType(columnType string) bool {
if typ == "" {
return false
}
return strings.Contains(typ, "datetime") || strings.Contains(typ, "timestamp")
return strings.Contains(typ, "datetime") || strings.Contains(typ, "timestamp") || strings.Contains(typ, "timestamptz")
}
func isTimeOnlyColumnType(columnType string) bool {
@@ -585,7 +585,7 @@ func isTimeOnlyColumnType(columnType string) bool {
if strings.Contains(typ, "datetime") || strings.Contains(typ, "timestamp") {
return false
}
return strings.Contains(typ, "time")
return strings.Contains(typ, "time") || strings.Contains(typ, "timetz")
}
func isDateOnlyColumnType(dbType, columnType string) bool {
@@ -1717,6 +1717,10 @@ func dumpTableSQL(
if err != nil {
return err
}
columnTypeMap := map[string]string{}
if defs, colErr := dbInst.GetColumns(schemaName, pureTableName); colErr == nil {
columnTypeMap = buildImportColumnTypeMap(defs)
}
if len(data) == 0 {
if _, err := w.WriteString("-- (0 rows)\n"); err != nil {
return err
@@ -1733,7 +1737,7 @@ func dumpTableSQL(
for _, row := range data {
values := make([]string, 0, len(columns))
for _, c := range columns {
values = append(values, formatSQLValue(config.Type, row[c]))
values = append(values, formatImportSQLValue(config.Type, columnTypeMap[normalizeColumnName(c)], row[c]))
}
if _, err := w.WriteString(fmt.Sprintf("INSERT INTO %s (%s) VALUES (%s);\n", quotedTable, strings.Join(quotedCols, ", "), strings.Join(values, ", "))); err != nil {
return err

View File

@@ -273,3 +273,17 @@ func TestWriteRowsToFile_HTML_EscapeHeader(t *testing.T) {
t.Fatalf("html 表头未正确转义: %s", content)
}
}
func TestFormatImportSQLValue_NormalizesTimestampWithoutTimezone(t *testing.T) {
got := formatImportSQLValue("postgres", "timestamp without time zone", "2026-01-21T18:32:26+08:00")
if got != "'2026-01-21 18:32:26'" {
t.Fatalf("时间字面量归一化异常want=%q got=%q", "'2026-01-21 18:32:26'", got)
}
}
func TestFormatImportSQLValue_LeavesTextLiteralUntouched(t *testing.T) {
got := formatImportSQLValue("postgres", "text", "2026-01-21T18:32:26+08:00")
if got != "'2026-01-21T18:32:26+08:00'" {
t.Fatalf("文本字段不应被归一化want=%q got=%q", "'2026-01-21T18:32:26+08:00'", got)
}
}

View File

@@ -9,9 +9,9 @@ import (
)
var damengDatabaseQueries = []string{
// 优先使用达梦原生系统表
"SELECT DISTINCT OBJECT_NAME AS DATABASE_NAME FROM SYS.SYSOBJECTS WHERE TYPE$ = 'SCH' AND OBJECT_NAME NOT IN ('SYS','SYSDBA','SYSAUDITOR','SYSSSO','CTISYS','__RECYCLE_USER__') ORDER BY OBJECT_NAME",
"SELECT SCHEMA_NAME AS DATABASE_NAME FROM INFORMATION_SCHEMA.SCHEMATA WHERE SCHEMA_NAME NOT IN ('SYS','SYSDBA','SYSAUDITOR','SYSSSO','CTISYS','INFORMATION_SCHEMA') ORDER BY SCHEMA_NAME",
// 优先使用达梦原生系统表SYSDBA 保留:作为默认管理员 schema大多数用户在此创建业务表
"SELECT DISTINCT OBJECT_NAME AS DATABASE_NAME FROM SYS.SYSOBJECTS WHERE TYPE$ = 'SCH' AND OBJECT_NAME NOT IN ('SYS','SYSAUDITOR','SYSSSO','CTISYS','__RECYCLE_USER__') ORDER BY OBJECT_NAME",
"SELECT SCHEMA_NAME AS DATABASE_NAME FROM INFORMATION_SCHEMA.SCHEMATA WHERE SCHEMA_NAME NOT IN ('SYS','SYSAUDITOR','SYSSSO','CTISYS','INFORMATION_SCHEMA') ORDER BY SCHEMA_NAME",
// Oracle 兼容层
"SELECT SYS_CONTEXT('USERENV', 'CURRENT_SCHEMA') AS DATABASE_NAME FROM DUAL",
"SELECT SYS_CONTEXT('USERENV', 'CURRENT_USER') AS DATABASE_NAME FROM DUAL",
@@ -21,6 +21,8 @@ var damengDatabaseQueries = []string{
"SELECT USERNAME AS DATABASE_NAME FROM SYS.DBA_USERS ORDER BY USERNAME",
"SELECT DISTINCT OWNER AS DATABASE_NAME FROM ALL_OBJECTS ORDER BY OWNER",
"SELECT DISTINCT OWNER AS DATABASE_NAME FROM ALL_TABLES ORDER BY OWNER",
// 最终兜底:获取当前连接用户作为 schema 名称
"SELECT USER AS DATABASE_NAME FROM DUAL",
}
type damengQueryFunc func(query string) ([]map[string]interface{}, []string, error)

View File

@@ -71,3 +71,50 @@ func TestCollectDamengDatabaseNames_ReturnsErrorWhenNoNameResolved(t *testing.T)
t.Fatalf("错误不符合预期: %v", err)
}
}
// TestCollectDamengDatabaseNames_IncludesSYSDBA 验证 SYSDBA达梦默认管理员 schema
// 不会被系统 schema 过滤排除。
func TestCollectDamengDatabaseNames_IncludesSYSDBA(t *testing.T) {
t.Parallel()
got, err := collectDamengDatabaseNames(func(query string) ([]map[string]interface{}, []string, error) {
switch query {
case damengDatabaseQueries[0]:
// 查询 0 返回 SYSDBA之前会被排除修复后应该返回
return []map[string]interface{}{{"DATABASE_NAME": "SYSDBA"}}, nil, nil
default:
return nil, nil, errors.New("permission denied")
}
})
if err != nil {
t.Fatalf("collectDamengDatabaseNames 返回错误: %v", err)
}
want := []string{"SYSDBA"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("SYSDBA 应该包含在结果中, got=%v want=%v", got, want)
}
}
// TestCollectDamengDatabaseNames_FallbackToCurrentUser 验证当所有查询都失败时
// 兜底查询 SELECT USER FROM DUAL 能返回当前用户作为 schema。
func TestCollectDamengDatabaseNames_FallbackToCurrentUser(t *testing.T) {
t.Parallel()
lastQuery := damengDatabaseQueries[len(damengDatabaseQueries)-1]
got, err := collectDamengDatabaseNames(func(query string) ([]map[string]interface{}, []string, error) {
if query == lastQuery {
return []map[string]interface{}{{"DATABASE_NAME": "SYSDBA"}}, nil, nil
}
// 前面所有查询要么返回空要么报错
return []map[string]interface{}{}, nil, nil
})
if err != nil {
t.Fatalf("collectDamengDatabaseNames 返回错误: %v", err)
}
want := []string{"SYSDBA"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("兜底查询应该返回当前用户, got=%v want=%v", got, want)
}
}

View File

@@ -50,6 +50,13 @@ type MultiResultQuerierContext interface {
QueryMultiContext(ctx context.Context, query string) ([]connection.ResultSetData, error)
}
// BatchWriteExecer 是可选接口,支持将多条写语句一次性批量发送执行。
// 驱动的底层连接需支持多语句协议(如 MySQL multiStatements=true、PostgreSQL 原生多语句)。
// 实现此接口可大幅减少批量 INSERT/UPDATE/DELETE 的网络往返次数。
type BatchWriteExecer interface {
ExecBatchContext(ctx context.Context, query string) (int64, error)
}
// BatchApplier 定义了批量变更提交接口。
// 支持批量编辑的驱动实现此接口,用于一次性提交前端 DataGrid 中的增删改操作。
type BatchApplier interface {

View File

@@ -90,6 +90,17 @@ func (d *DuckDB) Query(query string) ([]map[string]interface{}, []string, error)
return scanRows(rows)
}
func (d *DuckDB) ExecBatchContext(ctx context.Context, query string) (int64, error) {
if d.conn == nil {
return 0, fmt.Errorf("连接未打开")
}
res, err := d.conn.ExecContext(ctx, query)
if err != nil {
return 0, err
}
return res.RowsAffected()
}
func (d *DuckDB) ExecContext(ctx context.Context, query string) (int64, error) {
if d.conn == nil {
return 0, fmt.Errorf("连接未打开")

View File

@@ -135,6 +135,17 @@ func (m *MariaDB) Query(query string) ([]map[string]interface{}, []string, error
return scanRows(rows)
}
func (m *MariaDB) ExecBatchContext(ctx context.Context, query string) (int64, error) {
if m.conn == nil {
return 0, fmt.Errorf("连接未打开")
}
res, err := m.conn.ExecContext(ctx, query)
if err != nil {
return 0, err
}
return res.RowsAffected()
}
func (m *MariaDB) ExecContext(ctx context.Context, query string) (int64, error) {
if m.conn == nil {
return 0, fmt.Errorf("连接未打开")

View File

@@ -215,7 +215,9 @@ func (m *MongoDB) getURI(config connection.ConnectionConfig) string {
hostText := strings.Join(seeds, ",")
uri := fmt.Sprintf("%s://%s", scheme, hostText)
if config.User != "" {
noAuth := strings.EqualFold(strings.TrimSpace(config.MongoAuthMechanism), "NONE")
if config.User != "" && !noAuth {
var userinfo *url.Userinfo
if config.Password != "" {
userinfo = url.UserPassword(config.User, config.Password)
@@ -236,11 +238,14 @@ func (m *MongoDB) getURI(config connection.ConnectionConfig) string {
params.Set("connectTimeoutMS", strconv.Itoa(timeout*1000))
params.Set("serverSelectionTimeoutMS", strconv.Itoa(timeout*1000))
authSource := strings.TrimSpace(config.AuthSource)
if authSource == "" {
authSource = "admin"
// 仅在有用户名且非 NONE 认证时设置 authSource
if config.User != "" && !noAuth {
authSource := strings.TrimSpace(config.AuthSource)
if authSource == "" {
authSource = "admin"
}
params.Set("authSource", authSource)
}
params.Set("authSource", authSource)
if replicaSet := strings.TrimSpace(config.ReplicaSet); replicaSet != "" {
params.Set("replicaSet", replicaSet)
@@ -248,7 +253,8 @@ func (m *MongoDB) getURI(config connection.ConnectionConfig) string {
if readPreference := strings.TrimSpace(config.ReadPreference); readPreference != "" {
params.Set("readPreference", readPreference)
}
if authMechanism := strings.TrimSpace(config.MongoAuthMechanism); authMechanism != "" {
// NONE 表示无认证,不设置 authMechanism
if authMechanism := strings.TrimSpace(config.MongoAuthMechanism); authMechanism != "" && !noAuth {
params.Set("authMechanism", authMechanism)
}

View File

@@ -216,7 +216,9 @@ func (m *MongoDBV1) getURI(config connection.ConnectionConfig) string {
hostText := strings.Join(seeds, ",")
uri := fmt.Sprintf("%s://%s", scheme, hostText)
if config.User != "" {
noAuth := strings.EqualFold(strings.TrimSpace(config.MongoAuthMechanism), "NONE")
if config.User != "" && !noAuth {
var userinfo *url.Userinfo
if config.Password != "" {
userinfo = url.UserPassword(config.User, config.Password)
@@ -237,11 +239,14 @@ func (m *MongoDBV1) getURI(config connection.ConnectionConfig) string {
params.Set("connectTimeoutMS", strconv.Itoa(timeout*1000))
params.Set("serverSelectionTimeoutMS", strconv.Itoa(timeout*1000))
authSource := strings.TrimSpace(config.AuthSource)
if authSource == "" {
authSource = "admin"
// 仅在有用户名且非 NONE 认证时设置 authSource
if config.User != "" && !noAuth {
authSource := strings.TrimSpace(config.AuthSource)
if authSource == "" {
authSource = "admin"
}
params.Set("authSource", authSource)
}
params.Set("authSource", authSource)
if replicaSet := strings.TrimSpace(config.ReplicaSet); replicaSet != "" {
params.Set("replicaSet", replicaSet)
@@ -249,7 +254,8 @@ func (m *MongoDBV1) getURI(config connection.ConnectionConfig) string {
if readPreference := strings.TrimSpace(config.ReadPreference); readPreference != "" {
params.Set("readPreference", readPreference)
}
if authMechanism := strings.TrimSpace(config.MongoAuthMechanism); authMechanism != "" {
// NONE 表示无认证,不设置 authMechanism
if authMechanism := strings.TrimSpace(config.MongoAuthMechanism); authMechanism != "" && !noAuth {
params.Set("authMechanism", authMechanism)
}

View File

@@ -329,6 +329,17 @@ func (m *MySQLDB) Query(query string) ([]map[string]interface{}, []string, error
return scanRows(rows)
}
func (m *MySQLDB) ExecBatchContext(ctx context.Context, query string) (int64, error) {
if m.conn == nil {
return 0, fmt.Errorf("连接未打开")
}
res, err := m.conn.ExecContext(ctx, query)
if err != nil {
return 0, err
}
return res.RowsAffected()
}
func (m *MySQLDB) ExecContext(ctx context.Context, query string) (int64, error) {
if m.conn == nil {
return 0, fmt.Errorf("连接未打开")

View File

@@ -166,6 +166,9 @@ func (p *PostgresDB) Connect(config connection.ConnectionConfig) error {
logger.Infof("PostgreSQL 自动选择连接数据库:%s", dbName)
}
// 设置 search_path使所有用户 schema 下的表可以不带 schema 前缀访问
p.ensureSearchPath(dsn)
cleanupOnFailure = false
return nil
}
@@ -233,6 +236,17 @@ func (p *PostgresDB) Query(query string) ([]map[string]interface{}, []string, er
return scanRows(rows)
}
func (p *PostgresDB) ExecBatchContext(ctx context.Context, query string) (int64, error) {
if p.conn == nil {
return 0, fmt.Errorf("连接未打开")
}
res, err := p.conn.ExecContext(ctx, query)
if err != nil {
return 0, err
}
return res.RowsAffected()
}
func (p *PostgresDB) ExecContext(ctx context.Context, query string) (int64, error) {
if p.conn == nil {
return 0, fmt.Errorf("连接未打开")
@@ -600,6 +614,101 @@ ORDER BY table_schema, table_name, ordinal_position`
return cols, nil
}
// ensureSearchPath 查询当前数据库中所有用户 schema通过重建连接池将 search_path 写入 DSN。
// 仅使用 SET search_path 只对连接池中的单个连接生效,后续查询可能拿到未设置的连接。
// 将 search_path 写入 DSN (lib/pq 支持任意 PostgreSQL runtime parameter)
// 使连接池中每个连接建立时自动携带 search_path与金仓行为一致。
func (p *PostgresDB) ensureSearchPath(baseDSN string) {
if p.conn == nil {
return
}
rawSchemas := p.queryUserSchemas()
if len(rawSchemas) == 0 {
return
}
// 构建 search_path SQL 片段(带双引号转义),用于 SET 兜底
searchPathSQL, normalizedSchemas := buildKingbaseSearchPathCommon(rawSchemas)
if strings.TrimSpace(searchPathSQL) == "" {
return
}
// 策略 1将 search_path 写入 DSN重建连接池
// lib/pq 支持在 URL 查参数中设置任意 PostgreSQL runtime parameter
// 如 ?search_path=ce,public每个新连接建立时会自动 SET search_path。
searchPathDSNVal := strings.Join(normalizedSchemas, ",")
u, parseErr := url.Parse(baseDSN)
if parseErr == nil {
q := u.Query()
q.Set("search_path", searchPathDSNVal)
u.RawQuery = q.Encode()
newDSN := u.String()
newDB, err := sql.Open("postgres", newDSN)
if err == nil {
newDB.SetConnMaxLifetime(5 * time.Minute)
oldConn := p.conn
p.conn = newDB
if err := p.Ping(); err == nil {
_ = oldConn.Close()
logger.Infof("PostgreSQL 已通过 DSN 配置 search_path%s", searchPathDSNVal)
return
}
// DSN 方式失败,回滚
_ = newDB.Close()
p.conn = oldConn
logger.Warnf("PostgreSQL DSN search_path 验证失败,回退至 SET 方式")
}
}
// 策略 2 兜底:通过 SET search_path 设置(仅影响单个连接,但聊胜于无)
timeout := p.pingTimeout
if timeout <= 0 {
timeout = 5 * time.Second
}
ctx, cancel := utils.ContextWithTimeout(timeout)
defer cancel()
if _, err := p.conn.ExecContext(ctx, fmt.Sprintf("SET search_path TO %s", searchPathSQL)); err != nil {
logger.Warnf("PostgreSQL 设置 search_path 失败:%v", err)
return
}
logger.Infof("PostgreSQL 已通过 SET 设置 search_path%s", searchPathSQL)
}
// queryUserSchemas 查询当前数据库中所有用户 schema。
func (p *PostgresDB) queryUserSchemas() []string {
if p.conn == nil {
return nil
}
query := `SELECT nspname FROM pg_namespace
WHERE nspname NOT IN ('pg_catalog', 'information_schema')
AND nspname NOT LIKE 'pg_%'
ORDER BY nspname`
rows, err := p.conn.Query(query)
if err != nil {
logger.Warnf("PostgreSQL 查询用户 schema 失败:%v", err)
return nil
}
defer rows.Close()
var schemas []string
for rows.Next() {
var name string
if err := rows.Scan(&name); err != nil {
continue
}
name = strings.TrimSpace(name)
if name != "" {
schemas = append(schemas, name)
}
}
return schemas
}
func (p *PostgresDB) ApplyChanges(tableName string, changes connection.ChangeSet) error {
if p.conn == nil {
return fmt.Errorf("连接未打开")

View File

@@ -222,6 +222,17 @@ func (s *SQLiteDB) Query(query string) ([]map[string]interface{}, []string, erro
return scanRows(rows)
}
func (s *SQLiteDB) ExecBatchContext(ctx context.Context, query string) (int64, error) {
if s.conn == nil {
return 0, fmt.Errorf("连接未打开")
}
res, err := s.conn.ExecContext(ctx, query)
if err != nil {
return 0, err
}
return res.RowsAffected()
}
func (s *SQLiteDB) ExecContext(ctx context.Context, query string) (int64, error) {
if s.conn == nil {
return 0, fmt.Errorf("连接未打开")

View File

@@ -21,6 +21,7 @@ type PreviewUpdateRow struct {
type TableDiffPreview struct {
Table string `json:"table"`
PKColumn string `json:"pkColumn"`
ColumnTypes map[string]string `json:"columnTypes,omitempty"`
TotalInserts int `json:"totalInserts"`
TotalUpdates int `json:"totalUpdates"`
TotalDeletes int `json:"totalDeletes"`
@@ -112,6 +113,7 @@ func (s *SyncEngine) Preview(config SyncConfig, tableName string, limit int) (Ta
out := TableDiffPreview{
Table: tableName,
PKColumn: pkCol,
ColumnTypes: make(map[string]string, len(cols)),
TotalInserts: 0,
TotalUpdates: 0,
TotalDeletes: 0,
@@ -119,6 +121,14 @@ func (s *SyncEngine) Preview(config SyncConfig, tableName string, limit int) (Ta
Updates: make([]PreviewUpdateRow, 0),
Deletes: make([]PreviewRow, 0),
}
for _, col := range cols {
name := strings.ToLower(strings.TrimSpace(col.Name))
typ := strings.TrimSpace(col.Type)
if name == "" || typ == "" {
continue
}
out.ColumnTypes[name] = typ
}
sourcePKSet := make(map[string]struct{}, len(sourceRows))
for _, sRow := range sourceRows {

View File

@@ -2,7 +2,6 @@ package main
import (
"context"
"embed"
aiservice "GoNavi-Wails/internal/ai/service"
"GoNavi-Wails/internal/app"
@@ -15,9 +14,6 @@ import (
"github.com/wailsapp/wails/v2/pkg/options/windows"
)
//go:embed all:frontend/dist
var assets embed.FS
func main() {
// Create an instance of the app structure
application := app.NewApp()
@@ -34,8 +30,8 @@ func main() {
},
BackgroundColour: &options.RGBA{R: 0, G: 0, B: 0, A: 0},
OnStartup: func(ctx context.Context) {
application.Startup(ctx)
aiService.Startup(ctx)
app.InitializeLifecycle(application, ctx)
aiservice.InitializeLifecycle(aiService, ctx)
},
OnShutdown: application.Shutdown,
Bind: []interface{}{