feat: 初始化NewLife Studio项目,完成基础框架与数据管理模块 新增项目整体结构,包含核心库、数据提供层、AI模块、数据管理模块与主应用程序,实现: 1. 基础的MVVM架构与服务定位 2. 数据库连接管理与SQL查询功能 3. AI对话与工具调用基础框架 4. 模块化加载与主窗口UI框架 5. 配套单元测试与项目配置何炳宏 authored at 2026-05-26 12:09:09
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..1b4ccbc
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,4 @@
+.vs/
+bin/
+obj/
+TestResults/
\ No newline at end of file
diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 0000000..3024035
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,339 @@
+# NewLife.Studio — AI 快速上手指南
+
+> **本文档是项目的灵魂**。任何 AI 接手此项目,读完本文档即可理解:项目是什么、为什么做、怎么做、做到哪了、下一步做什么。
+
+---
+
+## 1. 项目身份
+
+- **名称**: NewLife.Studio
+- **定位**: NewLife 团队产品的统一工作室窗口
+- **目标用户**: 使用 NewLife 开源产品的开发者
+- **核心价值**: 一个桌面应用,一站式管理 NewLife 生态所有产品(数据库、MQTT、MQ、Redis、Stardust、Modbus 等),无需在多个工具间切换
+
+---
+
+## 2. 团队背景
+
+NewLife 团队自 2002 年起深耕 .NET 开源生态,积累了大量基础设施产品:
+
+| 产品 | 定位 |
+|------|------|
+| XCode | 大数据中间件,ORM + 分表分库 |
+| NewLife.Redis | Redis 客户端 |
+| NewLife.MQTT | MQTT 消息队列 |
+| NewLife.MQ | 自研消息队列 |
+| Stardust | 星尘监控平台 |
+| NewLife.Modbus | Modbus 工业协议 |
+
+**痛点**: 每个产品各自为战,缺乏统一的管理入口,用户需要在多个工具间切换。
+
+**解决方案**: NewLife.Studio — 一个可插拔模块的桌面应用,每个产品作为一个模块接入。
+
+---
+
+## 3. 架构设计
+
+### 3.1 分层架构
+
+```
+┌─────────────────────────────────────────┐
+│ App (Shell) │
+│ ┌──────┬──────────────────┬──────────┐ │
+│ │NavBar│ ModuleHost │ AIPanel │ │
+│ │ 48px │ (Content) │ 300px │ │
+│ └──────┴──────────────────┴──────────┘ │
+└─────────────────────────────────────────┘
+ │ ▲
+ ▼ │
+┌─────────────────────────────────────────┐
+│ Modules (插件) │
+│ DataStudio MqttStudio MqStudio ... │
+└─────────────────────────────────────────┘
+ │
+ ▼
+┌─────────────────────────────────────────┐
+│ Providers (能力提供者) │
+│ Data AI (更多...) │
+└─────────────────────────────────────────┘
+ │
+ ▼
+┌─────────────────────────────────────────┐
+│ Framework (基础设施) │
+│ Core Store AI │
+└─────────────────────────────────────────┘
+```
+
+### 3.2 依赖方向(严格单向)
+
+```
+App → Modules → Providers → Framework
+```
+
+- Framework 不依赖任何上层
+- Providers 只依赖 Framework
+- Modules 依赖 Framework + Providers
+- App 组装一切
+
+### 3.3 目录结构
+
+```
+NewLife.Studio/
+├── src/
+│ ├── Framework/ # 基础设施层
+│ │ ├── NewLife.Studio.Core/ # 核心接口、服务定位器
+│ │ ├── NewLife.Studio.Store/ # 本地存储(JSON + AES)
+│ │ └── NewLife.Studio.AI/ # AI 提供者 + Tool Calling
+│ ├── Providers/ # 能力提供者层
+│ │ └── NewLife.Studio.Data/ # 数据库访问(多提供者)
+│ ├── App/ # 应用 Shell 层
+│ │ └── NewLife.Studio.App/ # 主窗口、导航、模块加载
+│ └── Modules/ # 功能模块(插件)
+│ └── DataStudio/ # 数据工作室模块(MVP)
+│ ├── DataStudioModule.cs # 模块入口
+│ ├── Views/ # UI 视图
+│ └── ViewModels/ # 视图模型
+├── tests/ # 测试项目(与 src 结构对应)
+│ ├── NewLife.Studio.Core.Tests/
+│ ├── NewLife.Studio.Store.Tests/
+│ ├── NewLife.Studio.Data.Tests/
+│ ├── NewLife.Studio.AI.Tests/
+│ └── NewLife.Studio.Modules.DataStudio.Tests/
+├── NewLife.Studio.slnx # 解决方案(XML 格式)
+└── AGENTS.md # 本文档
+```
+
+---
+
+## 4. 核心接口
+
+### 4.1 IStudioModule — 模块入口
+
+```csharp
+// src/Framework/NewLife.Studio.Core/IStudioModule.cs
+public interface IStudioModule
+{
+ string Id { get; } // 唯一标识,如 "data-studio"
+ string DisplayName { get; } // 显示名称,如 "数据"
+ string Icon { get; } // 图标字符
+ int Order { get; } // 排序(越小越前)
+ Task OnActivateAsync(); // 模块激活时调用
+ Task OnDeactivateAsync(); // 模块停用时调用
+ UserControl GetView(); // 返回模块主视图
+}
+```
+
+### 4.2 ModuleLoader — 模块发现与加载
+
+```csharp
+// src/App/NewLife.Studio.App/Services/ModuleLoader.cs
+// 通过 Assembly.GetExportedTypes() 扫描所有程序集中的 IStudioModule 实现
+// 注意:.NET 延迟加载程序集,需要在 App.axaml.cs 中触发目标程序集加载
+```
+
+### 4.3 StudioServices — 服务定位器
+
+```csharp
+// src/Framework/NewLife.Studio.Core/StudioServices.cs
+public static class StudioServices
+{
+ public static void Initialize(IServiceProvider provider);
+ public static T? GetService<T>();
+ public static T GetRequiredService<T>();
+}
+```
+
+### 4.4 IStoreService — 本地持久化
+
+```csharp
+// src/Framework/NewLife.Studio.Store/IStoreService.cs
+// 管理:连接列表、查询历史、AI 配置、用户偏好
+// 存储:JSON 文件,机密字段 AES 加密
+// 加密:MachineName + UserName 作为熵源
+```
+
+### 4.5 IDataProvider / IDbSession — 数据库访问
+
+```csharp
+// src/Providers/NewLife.Studio.Data/IDataProvider.cs
+// 多提供者模式,当前实现:SQLite
+
+// src/Providers/NewLife.Studio.Data/IDbSession.cs
+// GetTablesAsync() / GetColumnsAsync() / ExecuteQueryAsync()
+```
+
+### 4.6 IAIProvider — AI 对话
+
+```csharp
+// src/Framework/NewLife.Studio.AI/IAIProvider.cs
+// OpenAI-compatible API,支持 Tool Calling
+// AIService 管理对话循环(最多 5 轮 tool calling)
+// 6 个内置工具:connections.list, db.open, schema.tables, schema.table, query.select, query.sample
+```
+
+---
+
+## 5. 当前实现状态
+
+### 5.1 已完成(Tasks 1-8,全部通过)
+
+| 任务 | 内容 | 状态 |
+|------|------|------|
+| Task 1 | Shell 框架(主窗口、导航栏、模块切换) | ✅ |
+| Task 2 | 本地存储(连接管理、查询历史、偏好设置) | ✅ |
+| Task 3 | 数据库访问层(SQLite 实现 + 元数据读取) | ✅ |
+| Task 4 | AI 引擎(OpenAI 兼容 + Tool Calling + 6 内置工具) | ✅ |
+| Task 5 | DataStudio UI(连接面板、表列表、SQL 编辑器、结果网格) | ✅ |
+| Task 6 | 数据导出(CSV / JSON / SQL 导出) | ✅ |
+| Task 7 | AI 面板 UI(聊天界面、Tool Calling 可视化) | ✅ |
+| Task 8 | 打包发布(dotnet publish 配置) | ✅ |
+
+### 5.2 测试覆盖
+
+| 测试项目 | 测试数 | 状态 |
+|----------|--------|------|
+| Core.Tests | 59 | ✅ 全部通过 |
+| Store.Tests | 37 | ✅ 全部通过 |
+| Data.Tests | 28 | ✅ 全部通过 |
+| AI.Tests | 79 | ✅ 全部通过 |
+| DataStudio.Tests | 99 | ✅ 全部通过 |
+| **合计** | **286** | **全部通过** |
+
+### 5.3 技术栈
+
+| 类别 | 选型 | 版本 |
+|------|------|------|
+| 运行时 | .NET | 9.0 |
+| UI 框架 | Avalonia UI | 12.0.3 |
+| MVVM | CommunityToolkit.Mvvm | 8.4.1 |
+| DI | Microsoft.Extensions.DependencyInjection | 9.0 |
+| 测试 | xUnit + Moq | 2.9.3 / 4.20.72 |
+| 覆盖率 | coverlet.collector | 6.0.4 |
+| 数据库 | Microsoft.Data.Sqlite | 9.0 |
+| AI | OpenAI-compatible API | - |
+| 序列化 | System.Text.Json | 9.0 |
+| 方案格式 | .slnx (XML-based) | - |
+
+---
+
+## 6. 关键设计决策
+
+### 6.1 为什么是 DataStudio 而不是 DbStudio?
+- Data 是多数据源的统称(SQL/NoSQL/时序),Studio 表示工作室界面
+- Providers 层可以有 Data、Redis、MQ 等多种 Provider
+- DataStudio 是操作数据的模块,Data Provider 是底层能力
+
+### 6.2 为什么用静态服务定位器而不是纯 DI?
+- 模块的 View 由 Avalonia 通过反射创建,无法使用构造函数注入
+- `StudioServices.GetService<T>()` 作为 DI 的补充
+
+### 6.3 模块加载为什么需要显式触发程序集加载?
+- .NET 延迟加载程序集,未引用的类型不会加载其程序集
+- 在 `App.axaml.cs` 中添加 `typeof(DataStudioModule).FullName` 强制触发
+
+### 6.4 NavBar 为什么用代码创建按钮而不是 XAML 绑定?
+- Avalonia 编译绑定要求 `x:DataType`,但 ItemsControl 动态 ItemTemplate 难以静态指定
+- 代码创建按钮更灵活,便于后续动态扩展
+
+---
+
+## 7. 构建与运行
+
+```bash
+# 还原依赖
+dotnet restore
+
+# 构建
+dotnet build
+
+# 运行
+dotnet run --project src/App/NewLife.Studio.App
+
+# 运行全部测试
+dotnet test
+
+# 运行特定测试项目
+dotnet test tests/NewLife.Studio.Core.Tests
+
+# 带覆盖率
+dotnet test /p:CollectCoverage=true /p:CoverletOutputFormat=cobertura
+
+# 发布
+dotnet publish src/App/NewLife.Studio.App -c Release -r win-x64 --self-contained
+```
+
+---
+
+## 8. 下一步路线图
+
+### 8.1 近期(模块扩展)
+
+| 任务 | 模块 | 内容 |
+|------|------|------|
+| Task 9 | MqttStudio | MQTT 客户端管理、主题订阅、消息收发 |
+| Task 10 | MqStudio | 消息队列管理、消息查看、死信处理 |
+| Task 11 | RedisStudio | Redis 数据浏览、命令执行、集群管理 |
+| Task 12 | StardustConsole | 星尘监控面板、应用列表、追踪查看 |
+| Task 13 | ModbusStudio | Modbus 设备连接、寄存器读写 |
+
+### 8.2 中期(能力增强)
+
+- Data Provider 扩展:MySQL、PostgreSQL、SQL Server
+- AI Tool Calling:扩展内置工具集(代码生成、架构建议)
+- 数据可视化:图表组件(ECharts 集成)
+- 多语言支持:i18n 框架
+
+### 8.3 远期(平台化)
+
+- 插件市场:第三方模块分发
+- 工作区保存:窗口布局、打开的连接等状态持久化
+- 远程协作:共享连接配置、查询模板
+
+---
+
+## 9. 已知限制与注意事项
+
+1. **Data Provider 仅实现 SQLite**: MVP 阶段只支持 SQLite,其他数据库待扩展
+2. **AI 仅支持 OpenAI 兼容 API**: 配置文件中的 `ApiEndpoint` 可指向任何兼容服务
+3. **无身份认证**: 所有数据本地存储,无登录/多用户概念
+4. **单窗口**: 当前不支持多窗口或标签页分离
+5. **跨平台未验证**: 目标支持 Windows/Linux/macOS,但主要在 Windows 上开发和测试
+6. **QuerySafetyFilter 为白名单模式**: 只允许 SELECT/PRAGMA/EXPLAIN,不支持存储过程调用
+
+---
+
+## 10. 开发约定
+
+### 10.1 代码风格
+- C# 默认代码风格(遵循 .editorconfig)
+- 接口以 `I` 开头
+- 异步方法以 `Async` 结尾
+- 私有字段使用 `_camelCase` 命名
+
+### 10.2 测试约定
+- 每个 src 项目对应一个 tests 项目
+- 使用 `internal` 构造函数 + `InternalsVisibleTo` 支持测试隔离
+- StoreService 使用 `internal` 构造函数,测试通过反射或 `InternalsVisibleTo` 访问
+
+### 10.3 项目结构约定
+- Framework 项目: `NewLife.Studio.{Name}` — 纯接口 + 实现,不依赖 UI
+- Providers 项目: `NewLife.Studio.{Name}` — 依赖 Framework,提供具体能力
+- App 项目: `NewLife.Studio.App` — 唯一的一个 App 项目
+- Modules 项目: 按模块分目录,模块名用 PascalCase(如 `DataStudio`)
+
+---
+
+## 11. Spec 文档索引
+
+所有设计文档位于 `.trae/specs/design-studio/`:
+
+| 文档 | 内容 |
+|------|------|
+| `spec.md` | 项目愿景、需求、Why/What/Impact |
+| `plan.md` | 架构图、接口设计、数据流、安全策略 |
+| `tasks.md` | 任务拆解、验收标准 |
+| `checklist.md` | 功能验证清单 |
+
+---
+
+> **本文档是项目的"灵魂文件"**。修改重大架构、新增模块、或任何影响项目全局的决策后,请同步更新此文档。
\ No newline at end of file
diff --git a/docs/checklist.md b/docs/checklist.md
new file mode 100644
index 0000000..d46da3e
--- /dev/null
+++ b/docs/checklist.md
@@ -0,0 +1,13 @@
+- [ ] Studio Shell 可在 Windows/Linux/macOS 启动并展示主窗口骨架(导航栏 + 内容区 + 状态栏)
+- [ ] Module 加载机制正常工作:注册的 Module 在启动时自动发现并展示导航项
+- [ ] 切换 Module 时内容区正确切换,会话状态保持
+- [ ] Data Studio:可创建/编辑/删除/测试 SQLite 连接,重启后连接仍存在
+- [ ] Data Studio:打开连接后对象树可列出表,并能展示列/主键信息
+- [ ] Data Studio:SQL 查询支持执行 SELECT 并展示结果网格与耗时,且有 MaxRows 裁剪
+- [ ] Data Studio:查询历史可记录并可再次执行
+- [ ] Data Studio:结果可导出 CSV 与 JSON,文件内容与列顺序正确
+- [ ] AI 面板可发起对话并完成一次工具调用闭环(列连接→列表→查数→输出分析)
+- [ ] AI 默认只读:非 SELECT 被拒绝,且返回可解释错误
+- [ ] AI 查询与返回数据受 MaxRows 限制,裁剪行为对用户可见
+- [ ] IStudioModule 接口定义清晰,第三方模块可独立开发并接入
+- [ ] 敏感信息(密码、API Key)在本地存储中受保护
\ No newline at end of file
diff --git a/docs/plan.md b/docs/plan.md
new file mode 100644
index 0000000..e22f410
--- /dev/null
+++ b/docs/plan.md
@@ -0,0 +1,532 @@
+# NewLife.Studio — 详细实施计划
+
+## 1. 总体架构
+
+```
+┌──────────────────────────────────────────────────────────────┐
+│ NewLife.Studio.App │
+│ (Avalonia Desktop Shell) │
+├────────────┬────────────────────────────────────┬───────────┤
+│ NavBar │ Content Area │ AI Panel │
+│ ──────── │ ┌───────────────────────────────┐ │ (右侧 │
+│ Data │ │ Active Module View │ │ 可折叠) │
+│ Studio ● │ │ │ │ │
+│ MQTT │ │ ┌─────────┐ ┌─────────────┐ │ │ Chat │
+│ Studio │ │ │ 对象树 │ │ 编辑/结果区 │ │ │ History │
+│ MQ │ │ │ (左侧) │ │ (右侧) │ │ │ │
+│ Studio │ │ │ │ │ │ │ │ Input │
+│ Redis │ │ └─────────┘ └─────────────┘ │ │ │
+│ Studio │ │ │ │ │
+│ ... │ └───────────────────────────────┘ │ │
+├────────────┴────────────────────────────────────┴───────────┤
+│ StatusBar (当前连接/Module状态/后台任务) │
+└──────────────────────────────────────────────────────────────┘
+```
+
+**分层架构:**
+
+```
+src/
+├── Framework/ # 框架层 — 所有模块无条件引用
+│ ├── NewLife.Studio.Core/ # IStudioModule, DTOs, StudioServices
+│ ├── NewLife.Studio.Store/ # 本地持久化 + 加密
+│ └── NewLife.Studio.AI/ # IAIProvider + Tool Calling
+
+├── Providers/ # 协议/基础设施提供者层 — 模块按需引用
+│ ├── NewLife.Studio.Data/ # IDataProvider, IDbSession, SQLite
+│ ├── NewLife.Studio.Mqtt/ # IMqttService (后续)
+│ ├── NewLife.Studio.WebSocket/ # IWebSocketService (后续)
+│ └── NewLife.Studio.Mq/ # IMqService (后续)
+
+├── App/
+│ └── NewLife.Studio.App/ # Shell 壳程序
+
+├── Modules/ # UI 模块 — 实现 IStudioModule
+│ ├── DataStudio/ # MVP 首发
+│ ├── MqttStudio/ # 预留
+│ ├── MqStudio/ # 预留
+│ ├── RedisStudio/ # 预留
+│ └── ...
+```
+
+**依赖方向:** App → Modules → Providers → Framework,永远没有反向引用。Module 之间禁止直接引用。
+
+***
+
+## 2. 解决方案项目结构
+
+```
+NewLife.Studio/
+├── NewLife.Studio.sln
+├── src/
+│ ├── Framework/
+│ │ ├── NewLife.Studio.Core/ # 核心抽象
+│ │ │ ├── IStudioModule.cs # 模块接口
+│ │ │ ├── ModuleInfo.cs # 模块元数据
+│ │ │ ├── StudioServices.cs # 服务定位器
+│ │ │ ├── DTOs/ # 共享 DTO
+│ │ │ │ ├── ConnectionInfo.cs
+│ │ │ │ ├── QueryRequest.cs
+│ │ │ │ ├── QueryResult.cs
+│ │ │ │ ├── TableInfo.cs
+│ │ │ │ ├── ColumnInfo.cs
+│ │ │ │ ├── QueryHistoryEntry.cs
+│ │ │ │ ├── AiProfile.cs
+│ │ │ │ └── AppPreference.cs
+│ │ │ └── Exceptions/
+│ │ │ └── StudioException.cs
+│ │ │
+│ │ ├── NewLife.Studio.Store/ # 本地持久化层
+│ │ │ ├── IStoreService.cs # 存储服务接口
+│ │ │ ├── StoreService.cs # JSON 文件实现
+│ │ │ ├── SecretProtection.cs # 敏感信息保护
+│ │ │ └── Models/
+│ │ │ ├── StoredConnection.cs
+│ │ │ ├── StoredQueryHistory.cs
+│ │ │ ├── StoredAiProfile.cs
+│ │ │ └── StoredAppPreference.cs
+│ │ │
+│ │ └── NewLife.Studio.AI/ # AI 助手层
+│ │ ├── IAIProvider.cs # AI Provider 接口
+│ │ ├── AIProviderFactory.cs
+│ │ ├── Models/
+│ │ │ └── ChatModels.cs # 对话/工具调用模型
+│ │ ├── Providers/
+│ │ │ └── OpenAIProvider.cs
+│ │ ├── ToolCalling/
+│ │ │ ├── ToolRegistry.cs # 工具注册中心
+│ │ │ ├── AIService.cs # 对话管理 + Tool Calling 循环
+│ │ │ └── BuiltInTools/
+│ │ │ └── BuiltInDatabaseTools.cs
+│ │ └── Safety/
+│ │ └── QuerySafetyFilter.cs # SQL 安全检查
+│ │
+│ ├── Providers/
+│ │ └── NewLife.Studio.Data/ # 数据访问抽象层
+│ │ ├── IDataProvider.cs # 数据库 Provider 接口
+│ │ ├── IDbSession.cs # 会话抽象
+│ │ └── Providers/
+│ │ └── SQLite/
+│ │ ├── SQLiteProvider.cs
+│ │ ├── SQLiteSession.cs
+│ │ └── SQLiteMetadataReader.cs
+│ │
+│ ├── App/
+│ │ └── NewLife.Studio.App/ # Avalonia 主项目 (Shell)
+│ │ ├── App.axaml / App.axaml.cs # 应用入口
+│ │ ├── MainWindow.axaml/.cs # 主窗口 (Shell 布局)
+│ │ ├── Program.cs # Main 入口
+│ │ ├── Controls/
+│ │ │ ├── NavBar.axaml/.cs # 左侧导航栏
+│ │ │ ├── StatusBar.axaml/.cs # 底部状态栏
+│ │ │ ├── ModuleHost.axaml/.cs # 模块内容容器
+│ │ │ └── AIPanel.axaml/.cs # AI 助手面板
+│ │ ├── Services/
+│ │ │ └── ModuleLoader.cs # 模块扫描与加载
+│ │ └── ViewModels/
+│ │ └── ViewModelBase.cs
+│ │
+│ └── Modules/
+│ └── DataStudio/ # Data Studio 模块 (MVP)
+│ ├── DataStudioModule.cs # IStudioModule 实现
+│ ├── ViewModels/
+│ │ ├── DataStudioViewModel.cs
+│ │ ├── ConnectionListViewModel.cs
+│ │ ├── ObjectTreeViewModel.cs
+│ │ ├── SqlEditorViewModel.cs
+│ │ └── ResultGridViewModel.cs
+│ └── Views/
+│ ├── DataStudioView.axaml/.cs
+│ ├── ConnectionListView.axaml/.cs
+│ ├── ObjectTreeView.axaml/.cs
+│ ├── SqlEditorView.axaml/.cs
+│ └── ResultGridView.axaml/.cs
+│
+└── tests/
+ ├── NewLife.Studio.Core.Tests/
+ ├── NewLife.Studio.Store.Tests/
+ ├── NewLife.Studio.Data.Tests/
+ ├── NewLife.Studio.AI.Tests/
+ └── NewLife.Studio.Modules.DataStudio.Tests/
+```
+
+***
+
+## 3. 核心接口设计
+
+### 3.1 IStudioModule — 模块注册接口
+
+```csharp
+namespace NewLife.Studio.Core;
+
+/// <summary>Studio 模块标准接口</summary>
+public interface IStudioModule
+{
+ /// <summary>模块唯一标识</summary>
+ string Id { get; }
+
+ /// <summary>显示名称</summary>
+ string DisplayName { get; }
+
+ /// <summary>导航栏图标 (Material/Path icon key)</summary>
+ string Icon { get; }
+
+ /// <summary>导航排序 (越小越靠前)</summary>
+ int Order { get; }
+
+ /// <summary>模块激活时调用</summary>
+ Task OnActivateAsync(CancellationToken ct = default);
+
+ /// <summary>模块停用时调用</summary>
+ Task OnDeactivateAsync(CancellationToken ct = default);
+
+ /// <summary>获取模块主视图 (返回 Avalonia UserControl)</summary>
+ Control GetView();
+}
+```
+
+### 3.2 IDataProvider — 数据库 Provider 接口
+
+```csharp
+namespace NewLife.Studio.Data;
+
+public interface IDataProvider
+{
+ string ProviderName { get; }
+ string[] SupportedSchemes { get; } // e.g. ["sqlite", "sqlite3"]
+ bool CanTestConnection { get; }
+
+ Task<bool> TestConnectionAsync(ConnectionInfo conn, CancellationToken ct = default);
+ Task<IDbSession> OpenSessionAsync(ConnectionInfo conn, CancellationToken ct = default);
+}
+
+public interface IDbSession : IDisposable
+{
+ string SessionId { get; }
+ ConnectionInfo Connection { get; }
+ bool IsOpen { get; }
+
+ Task<TableInfo[]> GetTablesAsync(CancellationToken ct = default);
+ Task<ColumnInfo[]> GetColumnsAsync(string tableName, CancellationToken ct = default);
+ Task<QueryResult> ExecuteQueryAsync(QueryRequest request, CancellationToken ct = default);
+ Task CloseAsync();
+}
+```
+
+### 3.3 IStoreService — 本地存储接口
+
+```csharp
+namespace NewLife.Studio.Store;
+
+public interface IStoreService
+{
+ // 连接管理
+ Task<ConnectionInfo[]> ListConnectionsAsync();
+ Task SaveConnectionAsync(ConnectionInfo conn);
+ Task DeleteConnectionAsync(string id);
+
+ // 查询历史
+ Task AddQueryHistoryAsync(QueryHistoryEntry entry);
+ Task<QueryHistoryEntry[]> GetRecentQueriesAsync(int count = 50);
+
+ // AI 配置
+ Task<AIProfile> GetAIProfileAsync();
+ Task SaveAIProfileAsync(AIProfile profile);
+
+ // 应用偏好
+ Task<AppPreference> GetPreferencesAsync();
+ Task SavePreferencesAsync(AppPreference pref);
+}
+```
+
+### 3.4 IAIProvider — AI Provider 接口
+
+```csharp
+namespace NewLife.Studio.AI;
+
+public interface IAIProvider
+{
+ string ProviderName { get; }
+
+ Task<ChatResponse> ChatAsync(
+ ChatRequest request,
+ CancellationToken ct = default);
+}
+
+public class ChatRequest
+{
+ public List<ChatMessage> Messages { get; set; }
+ public List<ToolDefinition> Tools { get; set; }
+ public string Model { get; set; }
+ public int MaxTokens { get; set; } = 4096;
+}
+
+public class ChatResponse
+{
+ public string Content { get; set; }
+ public List<ToolCall> ToolCalls { get; set; }
+ public long InputTokens { get; set; }
+ public long OutputTokens { get; set; }
+}
+```
+
+***
+
+## 4. 核心数据模型 (DTO)
+
+| DTO | 关键字段 | 说明 |
+| ------------------- | --------------------------------------------------------------- | ----- |
+| `ConnectionInfo` | Id, Name, ConnectionString, ProviderType, LastUsedAt, Group | 连接配置 |
+| `QueryRequest` | Sql, ConnectionId, MaxRows(默认1000), TimeoutSeconds(默认30) | 查询请求 |
+| `QueryResult` | Columns\[], Rows\[]\[], RowCount, ElapsedMs, Truncated, Error | 查询结果 |
+| `TableInfo` | Name, Schema, RowCount(可选) | 表信息 |
+| `ColumnInfo` | Name, DataType, IsNullable, DefaultValue, IsPrimaryKey, Ordinal | 列信息 |
+| `QueryHistoryEntry` | Id, Sql, ConnectionName, ExecutedAt, ElapsedMs, RowCount | 历史记录 |
+| `AIProfile` | ProviderType, Endpoint, ApiKey, Model | AI 配置 |
+| `AppPreference` | MaxRows, DefaultExportPath, Theme, Language | 用户偏好 |
+
+***
+
+## 5. UI 组件树
+
+```
+MainWindow
+├── NavBar (左侧, 48px 宽)
+│ └── ModuleNavItem[] (按 Order 排序)
+│ ├── Icon
+│ ├── DisplayName (ToolTip)
+│ └── IsActive (高亮指示)
+│
+├── ModuleHost (中间, 填充剩余)
+│ └── ActiveView (当前激活 Module 的 GetView() 返回值)
+│ │
+│ │ ★ DataStudio 模块内部布局:
+│ │ ┌──────────────────────────────────────┐
+│ │ │ Toolbar (新建连接/断开/导出) │
+│ │ ├────────────┬─────────────────────────┤
+│ │ │ ObjectTree │ TabControl │
+│ │ │ ───────── │ ├── Query1 (SQL编辑器 │
+│ │ │ ▸ conn-1 │ │ + 结果网格) │
+│ │ │ ▸ tables │ ├── Query2 │
+│ │ │ ▸ views │ └── + 新查询 │
+│ │ │ ▸ conn-2 │ │
+│ │ └────────────┴─────────────────────────┘
+│ └──────────────────────────────────────────
+│
+├── AIPanel (右侧, 可折叠, 300px)
+│ ├── ChatMessageList
+│ │ └── ChatBubble[]
+│ │ ├── Role (User/Assistant/Tool)
+│ │ ├── Content (Markdown)
+│ │ └── ToolCallInfo (展开查看工具调用)
+│ └── ChatInput (多行输入 + 发送按钮)
+│
+└── StatusBar (底部, 24px)
+ ├── ActiveConnection
+ ├── BackgroundTaskIndicator
+ └── ModuleName
+```
+
+***
+
+## 6. 数据流
+
+### 6.1 用户执行 SQL 查询流程
+
+```
+用户输入 SQL → SqlEditorViewModel.ExecuteCommand
+ → QueryRequest { Sql, ConnectionId, MaxRows }
+ → IDbSession.ExecuteQueryAsync()
+ → SQLite 执行 (Microsoft.Data.Sqlite)
+ → QueryResult { Columns[], Rows[][], ElapsedMs, Truncated }
+ → ResultGridViewModel.Result ← QueryResult
+ → UI 绑定刷新 DataGrid
+ → Store.AddQueryHistoryAsync() (异步、不阻塞 UI)
+```
+
+### 6.2 AI 工具调用闭环流程
+
+```
+用户提问 ──────────────────────────────────────────────────────┐
+ │ │
+ ▼ │
+ChatInput → AIService.ChatAsync() │
+ │ │
+ ▼ │
+IAIProvider.ChatAsync(messages + tools) ──► AI 模型 │
+ │ │ │
+ │◄────── ChatResponse { Content / ToolCalls } │
+ │ │
+ ├── 有 ToolCalls? ──YES──► ToolExecutor.Execute(toolCall) │
+ │ │ │
+ │ ▼ │
+ │ 内置工具执行 (安全过滤) │
+ │ │ │
+ │ ▼ │
+ │ ToolResult { Output, Error } │
+ │ │ │
+ │ ▼ │
+ │ 追加 Assistant(tool_call) + │
+ │ Tool(result) 消息到对话 │
+ │ │ │
+ │ └──► 回到 ChatAsync() 继续推理 │
+ │ │
+ └── NO ToolCalls ──► 直接展示 Markdown 回复 │
+```
+
+### 6.3 模块切换流程
+
+```
+用户点击 NavBar 上的 ModuleNavItem
+ → ModuleLoader.SwitchTo(moduleId)
+ → 当前 Module.OnDeactivateAsync() (保存状态, 不关闭连接)
+ → 目标 Module.OnActivateAsync()
+ → ModuleHost.Content ← 目标 Module.GetView()
+ → NavBar 更新激活指示
+ → StatusBar 更新当前模块名
+```
+
+***
+
+## 7. 安全策略
+
+### 7.1 AI SQL 安全过滤
+
+所有通过 AI Tool Calling 执行的 SQL 在 `QuerySafetyFilter` 中检查:
+
+| 规则 | 行为 |
+| -------------------------------------- | -------------------- |
+| 仅允许 SELECT / EXPLAIN / PRAGMA (只读) | 拒绝并返回错误 |
+| INSERT/UPDATE/DELETE/DROP/ALTER/CREATE | 拒绝,提示"AI 模式不支持写操作" |
+| 多语句 (分号分隔) | 拒绝 |
+| 结果行数 > MaxRows | 裁剪并标注 Truncated=true |
+| 查询超时 > TimeoutSeconds | 取消 CancellationToken |
+
+### 7.2 敏感信息保护
+
+| 数据 | 保护方式 |
+| ---------- | --------------------- |
+| 数据库密码 | AES 加密存储 / 系统凭据库 |
+| AI API Key | AES 加密存储 |
+| 连接字符串 | 脱敏存储 (密码部分替换为 \*\*\*) |
+
+***
+
+## 8. 关键技术选型
+
+| 层 | 技术 | 说明 |
+| ----- | ---------------------------------------- | ----------------------------- |
+| UI 框架 | Avalonia 11.x | 跨平台桌面 (Windows/Linux/macOS) |
+| MVVM | CommunityToolkit.Mvvm | 简化 ViewModel 与绑定 |
+| 数据访问 | Microsoft.Data.Sqlite + NewLife.XCode | SQLite MVP; XCode 为多数据库扩展 |
+| AI | OpenAI-compatible API | 支持 OpenAI / Azure / 本地 Ollama |
+| 存储 | System.Text.Json + 本地文件 | JSON 文件存储,AppData 目录 |
+| 加密 | System.Security.Cryptography (AES) | 敏感信息加密 |
+| 日志 | NewLife.Core (XTrace) | 统一日志 |
+| 依赖注入 | Microsoft.Extensions.DependencyInjection | 模块注册与服务管理 |
+
+***
+
+## 9. 实施阶段 (8 个 Task)
+
+### Phase 1: 骨架搭建 (Task 1-2, 约 40%)
+
+```
+Task 1: 解决方案 + Shell + Module 接口
+ ├── dotnet new avalonia.app → NewLife.Studio.App
+ ├── dotnet new classlib → Core / Store / Data / AI / Shared
+ ├── 定义 IStudioModule 接口
+ ├── 实现 Shell 布局 (NavBar + ModuleHost + StatusBar)
+ ├── 实现 ModuleLoader (Assembly 扫描 + 注册)
+ └── 验证: 空 Shell 窗口启动,显示导航栏骨架
+
+Task 2: 本地存储
+ ├── 定义 StoredConnection / QueryHistory / AIProfile / AppPreference
+ ├── 实现 StoreService (JSON 读写)
+ ├── 实现 SecretProtection (AES 占位)
+ └── 验证: 连接信息写入/读取/重启恢复
+```
+
+### Phase 2: Data Studio 核心 (Task 3-4, 约 25%)
+
+```
+Task 3: SQLite Provider
+ ├── 定义 IDataProvider / IDbSession
+ ├── 实现 SQLiteProvider (连接测试 + 打开会话)
+ ├── 实现 SQLiteMetadataReader (表/列/主键信息)
+ └── 验证: 打开 SQLite 文件,读取元数据
+
+Task 4: 查询执行
+ ├── 定义 QueryRequest / QueryResult
+ ├── 实现 SELECT 执行 + 结果封装
+ ├── 实现 MaxRows 裁剪 + 耗时统计
+ ├── 实现历史记录写入
+ └── 验证: 执行 SELECT,返回网格数据
+```
+
+### Phase 3: Data Studio UI (Task 5-6, 约 20%)
+
+```
+Task 5: UI 闭环
+ ├── ConnectionListView (连接 CRUD + 测试按钮)
+ ├── ObjectTreeView (递归树: 连接→表→列)
+ ├── SqlEditorView (多 Tab 编辑器 + 执行按钮)
+ ├── ResultGridView (DataGrid 绑定 + 耗时显示)
+ ├── DataStudioModule 实现 IStudioModule 并注册
+ └── 验证: 完整 UI 交互闭环
+
+Task 6: 导出
+ ├── CSV 导出 (UTF-8 + 逗号分隔)
+ ├── JSON 导出 (数组格式)
+ └── 验证: 结果集导出为 CSV/JSON 文件
+```
+
+### Phase 4: AI 助手 (Task 7, 约 10%)
+
+```
+Task 7: AI Tool Calling
+ ├── IAIProvider 接口 + OpenAI 实现
+ ├── ToolRegistry (6 个内置工具)
+ ├── QuerySafetyFilter (只读 + 行限制)
+ ├── AI 面板 UI (Chat + 工具调用可视化)
+ └── 验证: AI 分析数据库闭环
+```
+
+### Phase 5: 打包验证 (Task 8, 约 5%)
+
+```
+Task 8: 打包与验证
+ ├── 端到端验证 (连接→查询→导出→AI 分析)
+ ├── 跨平台发布配置 (dotnet publish)
+ └── 验证: Windows/Linux/macOS 可运行
+```
+
+***
+
+## 10. 依赖关系图
+
+```
+Task 1 (Shell + Module接口)
+ ├──► Task 2 (Store)
+ │ └──► Task 4 (查询执行) ──► Task 5 (UI) ──► Task 6 (导出)
+ │ │ │ │
+ ├──► Task 3 (SQLite) │ │
+ └──► Task 4 ──────────────────┘ │
+ │
+ Task 7 (AI) ─── requires Task 1,2,3,4 ──────────┤
+ │
+ Task 8 (打包) ─── requires Task 5,6,7 ───────────┘
+```
+
+***
+
+## 11. 风险与缓解
+
+| 风险 | 影响 | 缓解 |
+| ----------------- | ---------- | --------------------------------------- |
+| Avalonia 跨平台渲染不一致 | UI 布局差异 | 早期三平台验证;使用 Grid/StackPanel 避免绝对定位 |
+| SQLite 大文件性能 | 元数据浏览卡顿 | 异步加载 + 虚拟化树;元数据缓存 |
+| AI API 不可用/超时 | AI 助手不可用 | 降级策略:AI 面板显示连接错误,不影响其他功能 |
+| 后续模块接口不足 | 大改 Core 接口 | IStudioModule 保持最小集合;Module 间通过 DI 获取服务 |
+| 跨平台文件路径差异 | 存储/导出路径错误 | 统一使用 `Environment.SpecialFolder` |
+
diff --git a/docs/spec.md b/docs/spec.md
new file mode 100644
index 0000000..fb3c3be
--- /dev/null
+++ b/docs/spec.md
@@ -0,0 +1,227 @@
+# NewLife.Studio(Avalonia)— NewLife 团队全产品工作室窗口 Spec
+
+## 背景:NewLife 开源团队
+
+[NewLife 开发团队](https://github.com/NewLifeX)(又称"新生命团队")成立于 2002 年,是国内历史最悠久的 .NET 开源社区之一。团队以"学无先后达者为师"为理念,长期维护一套覆盖基础组件、数据中间件、通信协议、物联网、分布式平台的全栈 .NET 生态。
+
+截至 2026 年,NewLife 在 NuGet 上发布了 90+ 个包,累计下载量超 580 万次,产品已成功应用于电力、高校、互联网、电信、交通、物流、工控、医疗、文博等行业。
+
+### NewLife 项目矩阵一览
+
+| 分类 | 项目 | 年份 | 说明 |
+|---|---|---|---|
+| **基础组件** | | | |
+| | [NewLife.Core](https://github.com/NewLifeX/X) | 2002 | 核心库:日志、配置、缓存、网络、序列化、APM 性能追踪 |
+| | [NewLife.XCode](https://github.com/NewLifeX/NewLife.XCode) | 2005 | 大数据中间件 / 超级 ORM,单表百亿级,支持 MySQL/SQLite/SqlServer/Oracle/PostgreSQL/TDengine/InfluxDB/达梦/金仓/瀚高/DB2,自动分表、读写分离 |
+| | [NewLife.Net](https://github.com/NewLifeX/NewLife.Net) | 2005 | 网络库,单机千万级吞吐(2266万 tps),单机百万级连接(400万 Tcp) |
+| | [NewLife.Remoting](https://github.com/NewLifeX/NewLife.Remoting) | 2011 | RPC/Http 通信框架,高吞吐,物联网设备低开销易接入 |
+| | [NewLife.Cube](https://github.com/NewLifeX/NewLife.Cube) | 2010 | 魔方快速开发平台,用户权限/SSO/OAuth,单表 100 亿级验证 |
+| | [NewLife.Agent](https://github.com/NewLifeX/NewLife.Agent) | 2008 | 服务管理组件,安装为 Windows 服务 / Linux systemd 守护进程 |
+| | [NewLife.Zero](https://github.com/NewLifeX/NewLife.Zero) | 2020 | Zero 零代脚手架,项目模板:Web/WebApi/Service/Worker |
+| **中间件** | | | |
+| | [NewLife.Redis](https://github.com/NewLifeX/NewLife.Redis) | 2017 | Redis 客户端,微秒级延迟,百万级吞吐,内置消息队列,日均 80 亿+次调用验证 |
+| | [NewLife.RocketMQ](https://github.com/NewLifeX/NewLife.RocketMQ) | 2018 | RocketMQ 纯托管客户端,支持 Apache RocketMQ / 阿里云 / 华为云 / 腾讯云,十亿级验证 |
+| | [NewLife.MQTT](https://github.com/NewLifeX/NewLife.MQTT) | 2019 | MQTT 物联网消息协议,MqttClient / MqttServer,支持阿里云物联网平台 |
+| | [NewLife.MQ](https://github.com/NewLifeX/NewLife.MQ) | 2016 | 轻量级消息队列,无延迟分发,支持消费组和消息去重 |
+| | [NewLife.IoT](https://github.com/NewLifeX/NewLife.IoT) | 2022 | IoT 标准库,定义物联网领域的各种通信协议标准规范 |
+| | [NewLife.Modbus](https://github.com/NewLifeX/NewLife.Modbus) | 2022 | ModbusTcp / ModbusRTU / ModbusASCII 协议实现 |
+| | [NewLife.LoRa](https://github.com/NewLifeX/NewLife.LoRa) | 2016 | LoRa 协议库 |
+| **平台产品** | | | |
+| | [NewLife.Stardust](https://github.com/NewLifeX/NewLife.Stardust) | 2018 | 星尘分布式平台:节点管理/配置中心/注册中心/APM/日志中心/远程发布 |
+| | AntJob | 2019 | 蚂蚁调度,分布式大数据计算平台(实时/离线),万亿级验证 |
+| | FIoT | — | 物联网一体化平台 |
+| | NewLife.ERP | 2021 | 企业 ERP:产品/客户/销售/供应商管理 |
+
+## Why
+
+NewLife 团队拥有 20+ 个核心产品与组件,覆盖数据库、缓存、消息队列、物联网协议、分布式平台等领域。当前缺少一个**统一的跨平台桌面工作室窗口**,让开发者 / 运维人员能够在**一个应用中**集中管理和操作这些产品,例如:
+
+- 通过 XCode 连接多数据库进行查询、建模、代码生成
+- 连接 Redis 实例查看 Key、执行命令、监控性能
+- 管理 MQTT 服务端 / 客户端,收发消息、查看 Topic
+- 管理 MQ / RocketMQ 消息队列,查看消息堆积、消费状态
+- 浏览和管理星尘平台中的节点、应用、配置
+- 其他 NewLife 组件的一站式操作
+
+目前这些操作分散于命令行、各自的管理后台或第三方工具,缺乏统一入口和一致体验。
+
+## What Changes
+
+- **核心定位变更**:从"数据库管理工具"升级为 **NewLife 全产品工作室窗口** —— 以模块化/插件化架构统一承载 NewLife 生态所有产品的管理能力
+- **模块化架构**:Studio 提供主框架(窗口管理、导航、布局、本地存储),各产品以独立 Module/Plugin 形式接入
+- **MVP 首期模块**:数据库管理(Data Studio)作为首发模块,覆盖连接管理、元数据浏览、SQL 查询、导入导出、AI 助手
+- **扩展点预留**:为 MQTT Studio、MQ Studio、Redis Studio、Stardust Console 等后续模块预留标准接口与 UI 扩展区
+- 保留原有技术栈:Avalonia 跨平台 UI + NewLife.Core/NewLife.XCode 为核心依赖
+- 保留原有 AI 助手体系,扩展为可感知当前激活模块(后续迭代)
+
+## Impact
+
+- Affected specs: 新增模块化架构、Data Studio(原数据库管理)、AI 助手、插件机制、跨平台发布;后续扩展 MQTT Studio、MQ Studio、Redis Studio 等
+- Affected code: 新建解决方案;Framework 框架层(Core / Store / AI);Providers 协议提供者层(Data);App Shell 壳层;Modules 模块层(DataStudio);Store(本地配置/加密)
+
+## ADDED Requirements
+
+### Requirement: Modular Studio Architecture
+系统 SHALL 采用模块化/插件化架构,Studio Shell 提供主窗口框架,各产品功能以独立 Module 实现。
+
+#### Scenario: Load Modules at Startup
+- **WHEN** Studio 启动
+- **THEN** Shell 加载已注册的 Module 列表,展示导航项,并激活默认 Module
+
+#### Scenario: Switch Between Modules
+- **WHEN** 用户在导航栏切换 Module(如从 Data Studio 切换到 MQ Studio)
+- **THEN** Shell 切换内容区域,保持当前会话状态,不关闭未激活 Module 的后台连接(由用户显式关闭)
+
+### Requirement: Studio Shell
+系统 SHALL 提供统一的 Studio 主窗口(Avalonia),包含导航栏、内容区、状态栏、通用设置入口。
+
+#### Scenario: App Startup
+- **WHEN** 用户启动应用
+- **THEN** 展示 Studio 主窗口(导航栏/内容区/状态栏),无未处理异常导致退出
+
+### Requirement: Plugin / Module Interface
+系统 SHALL 定义 `IStudioModule` 标准接口,第三方或后续模块可通过实现此接口接入 Studio。
+
+#### Scenario: Register a New Module
+- **WHEN** 开发者新建模块项目,实现 IStudioModule 并注册
+- **THEN** Studio 在下次启动时自动发现并加载该模块,无需修改 Shell 代码
+
+---
+
+## Module 1: Data Studio(数据库管理,MVP 首发)
+
+### Requirement: Connection Management
+系统 SHALL 支持管理多数据库连接(SQLite MVP),包括创建、编辑、删除、测试连接、最近使用记录。
+
+#### Scenario: Create SQLite Connection
+- **WHEN** 用户选择一个 SQLite 数据库文件并保存连接
+- **THEN** 连接出现在连接列表中,可被打开并用于后续操作
+
+#### Scenario: Test Connection
+- **WHEN** 用户点击"测试连接"
+- **THEN** 系统返回成功/失败与错误原因(如文件不存在、权限不足、文件损坏)
+
+### Requirement: Database Session Handling
+系统 SHALL 以"会话"形式管理已打开连接,支持复用并在关闭连接时释放资源。
+
+### Requirement: Metadata Browsing (SQLite MVP)
+系统 SHALL 支持浏览数据库对象元数据,至少包含表、列、主键、索引与外键信息。
+
+#### Scenario: Browse Tables
+- **WHEN** 用户展开连接对象树
+- **THEN** 展示表列表,并可查看单表详情(列名、类型、是否可空、默认值、主键等)
+
+### Requirement: SQL Query (SQLite MVP)
+系统 SHALL 提供 SQL 编辑与执行能力,支持展示结果表格、执行耗时、影响行数、以及历史记录。
+
+#### Scenario: Execute SELECT
+- **WHEN** 用户执行一条 SELECT 语句
+- **THEN** 系统展示结果集(受最大行数限制),并展示执行耗时
+
+### Requirement: Import/Export
+系统 SHALL 支持将查询结果或表数据导出为 CSV 与 JSON;导入能力预留扩展点。
+
+#### Scenario: Export CSV
+- **WHEN** 用户在结果网格上执行"导出 CSV"
+- **THEN** 系统生成 CSV 文件,编码与分隔符可配置(默认 UTF-8 + 逗号)
+
+### Requirement: Extensibility for Multi-DB
+系统 SHALL 采用 Provider/Adapter 架构,便于后续加入 MySQL、SQLServer 等数据库与 XCode 增强能力(实体生成、模型同步)。
+
+---
+
+## Module N: 后续模块扩展点(非 MVP,架构预留)
+
+### 预留模块:MQTT Studio
+操作 MQTT Broker,管理 Topic / Client、收发消息、查看连接状态。基于 NewLife.MQTT。
+
+### 预留模块:MQ Studio
+操作 MQ 消息队列(NewLife.MQ / NewLife.RocketMQ),查看队列状态、消息堆积、消费进度。
+
+### 预留模块:Redis Studio
+连接 Redis 实例,浏览 Key、执行命令、查看内存/命中率等监控指标。基于 NewLife.Redis。
+
+### 预留模块:Stardust Console
+星尘平台控制台视窗,查看节点列表、应用运行状态、配置下发、APM 调用链等。基于 NewLife.Stardust。
+
+### 预留模块:Modbus Studio
+Modbus 调试工具,读写寄存器、扫描设备。基于 NewLife.Modbus / NewLife.IoT。
+
+---
+
+## AI 助手(跨模块共享)
+
+### Requirement: AI Assistant with Tool Calling
+系统 SHALL 内置 AI 助手,支持"模型输出工具调用 → 系统执行工具 → 回填结果 → 模型继续推理"的闭环。AI 助手可感知当前激活的 Module,提供上下文相关的帮助。
+
+#### Scenario: AI Analyzes Database
+- **GIVEN** 当前激活 Data Studio 模块
+- **WHEN** 用户提出"分析今日订单"
+- **THEN** AI 可调用工具:列连接、打开连接、列出表、查看表结构、执行只读查询(带行数限制),最终输出分析汇总
+
+### Requirement: AI Safety Policy
+系统 SHALL 对 AI 调用能力施加安全限制,默认只读并限制数据规模。
+
+#### Scenario: AI Attempts Non-SELECT
+- **WHEN** AI 请求执行 INSERT/UPDATE/DELETE/DDL
+- **THEN** 系统拒绝执行并返回可解释的错误信息
+
+#### Scenario: Row Limit Enforcement
+- **WHEN** AI 或用户执行查询返回大量数据
+- **THEN** 系统按配置裁剪到最大行数,并提示已裁剪
+
+---
+
+## 本地存储与安全
+
+### Requirement: Local Storage & Secret Handling
+系统 SHALL 将连接信息、历史查询、AI 配置、Module 偏好存储在本地;敏感信息(如 API Key、密码)必须被保护(加密或系统凭据库)。
+
+#### Scenario: Persist Connection
+- **WHEN** 用户保存连接信息
+- **THEN** 重启应用后连接仍可用
+
+---
+
+## MODIFIED Requirements
+无(首次引入模块化架构,原数据库管理需求并入 Data Studio 模块)。
+
+## REMOVED Requirements
+无。
+
+## Notes / Scope
+
+### MVP 范围(v0.1)
+- Studio Shell(导航栏 + 内容区框架)
+- Module 接口与加载机制(IStudioModule)
+- Data Studio 模块:SQLite 连接管理、元数据浏览、SQL 查询、CSV/JSON 导出、AI 助手
+- 本地存储(连接、历史、偏好、AI 配置)
+- 跨平台运行(Windows / Linux / macOS)
+
+### 后续迭代(v0.2+)
+- Data Studio:MySQL / SQLServer / PostgreSQL 等多数据库支持、实体代码生成、模型同步
+- MQTT Studio 模块
+- MQ Studio 模块
+- Redis Studio 模块
+- AI 助手跨模块上下文感知
+- 模块市场 / 在线安装
+
+### 技术约束
+- AI Provider 类型(OpenAI 兼容 / Azure / 本地)在实现前需最终确认;本 Spec 要求"可插拔 Provider"
+- 导入 Excel 不纳入 MVP;避免引入不确定依赖
+- 模块间依赖通过 Core 共享类型,禁止模块间直接引用
+
+### 目录结构约定
+```
+src/
+├── Framework/ # 框架层 — 所有模块无条件引用
+│ ├── Core/ # IStudioModule, DTOs, StudioServices
+│ ├── Store/ # 本地持久化 + 加密
+│ └── AI/ # AI Provider + Tool Calling
+├── Providers/ # 协议提供者层 — 模块按需引用
+│ └── Data/ # IDataProvider, IDbSession, SQLite
+├── App/ # Shell 壳程序
+└── Modules/ # UI 模块 — 实现 IStudioModule
+ └── DataStudio/ # 数据库管理模块
+```
+后续新增 MqttStudio / WebSocketStudio 时在 Providers/ 与 Modules/ 下创建对应目录。
\ No newline at end of file
diff --git a/docs/tasks.md b/docs/tasks.md
new file mode 100644
index 0000000..8bfb644
--- /dev/null
+++ b/docs/tasks.md
@@ -0,0 +1,292 @@
+# Tasks
+
+## Phase 1: 骨架搭建 — Task 1 ~ 2
+
+- [ ] Task 1: 初始化解决方案与项目结构(Avalonia Studio Shell + Core + Module 接口)
+ - [ ] `dotnet new avalonia.app` 创建 `src/App/NewLife.Studio.App`,添加 `<TargetFrameworks>net9.0</TargetFrameworks>`
+ - [ ] `dotnet new classlib` 创建 4 个工程(按分层存放):
+ - `src/Framework/NewLife.Studio.Core` — 无 UI 依赖的纯抽象层
+ - `src/Framework/NewLife.Studio.Store` — 本地持久化(依赖 Core)
+ - `src/Providers/NewLife.Studio.Data` — 数据访问抽象(依赖 Core)
+ - `src/Framework/NewLife.Studio.AI` — AI Provider 抽象(依赖 Core)
+ - [ ] 定义 `src/Framework/NewLife.Studio.Core/IStudioModule.cs`:
+ - 属性:`Id`、`DisplayName`、`Icon`、`Order`
+ - 方法:`OnActivateAsync(CancellationToken)`、`OnDeactivateAsync(CancellationToken)`、`GetView()` → 返回 `Avalonia.Controls.Control`
+ - [ ] 引入 NuGet 依赖:
+ - App:`Avalonia.Desktop`、`CommunityToolkit.Mvvm`、`Microsoft.Extensions.DependencyInjection`
+ - Core:`NewLife.Core`(日志 XTrace)
+ - 所有项目统一使用 `System.Text.Json` 做序列化
+ - [ ] 实现 Shell 布局 `src/App/NewLife.Studio.App/MainWindow.axaml`:
+ - `Grid` 三列:`NavBar`(48px) | `ModuleHost`(*) | `AIPanel`(300px, 可折叠)
+ - `Grid` 一行:`Content`(*) | `StatusBar`(24px, 底部)
+ - [ ] 实现 `src/App/NewLife.Studio.App/Controls/NavBar.axaml/.cs`:垂直图标列表,点击切换 Module
+ - [ ] 实现 `src/App/NewLife.Studio.App/Controls/ModuleHost.axaml/.cs`:`ContentControl` 绑定当前激活 Module 视图
+ - [ ] 实现 `src/App/NewLife.Studio.App/Controls/StatusBar.axaml/.cs`:显示当前连接 / 模块名 / 后台任务
+ - [ ] 实现 `src/App/NewLife.Studio.App/Services/ModuleLoader.cs`:
+ - 扫描所有已加载 Assembly 中实现 `IStudioModule` 的类型
+ - 按 `Order` 排序,注入导航项,激活第一个 Module
+ - [ ] 验证:`dotnet run` 启动空 Shell 窗口,显示 NavBar/ModuleHost/StatusBar 骨架,无异常退出
+
+- [ ] Task 2: 本地存储(连接 / 历史 / 偏好 / AI 配置)
+ - [ ] 在 `src/Framework/NewLife.Studio.Core/DTOs/` 定义共享 DTO:
+ - `ConnectionInfo.cs`:Id, Name, ConnectionString, ProviderType, LastUsedAt, Group
+ - `QueryRequest.cs`:Sql, ConnectionId, MaxRows(默认1000), TimeoutSeconds(默认30)
+ - `QueryResult.cs`:Columns[], Rows[][], RowCount, ElapsedMs, Truncated, Error
+ - `TableInfo.cs`:Name, Schema, RowCount
+ - `ColumnInfo.cs`:Name, DataType, IsNullable, DefaultValue, IsPrimaryKey, Ordinal
+ - `QueryHistoryEntry.cs`:Id, Sql, ConnectionName, ExecutedAt, ElapsedMs, RowCount
+ - [ ] 在 `src/Framework/NewLife.Studio.Store/Models/` 定义存储模型:
+ - `StoredConnection.cs`:持久化格式(密码字段加密)
+ - `StoredQueryHistory.cs`:JSON 行存储
+ - `StoredAiProfile.cs`:AiProviderType, Endpoint, ApiKey(加密), Model
+ - `StoredAppPreference.cs`:MaxRows, DefaultExportPath, Theme, Language
+ - [ ] 实现 `src/Framework/NewLife.Studio.Store/IStoreService.cs` 接口:
+ - 连接 CRUD:`ListConnectionsAsync` / `SaveConnectionAsync` / `DeleteConnectionAsync`
+ - 历史:`AddQueryHistoryAsync` / `GetRecentQueriesAsync(count)`
+ - AI:`GetAiProfileAsync` / `SaveAiProfileAsync`
+ - 偏好:`GetPreferencesAsync` / `SavePreferencesAsync`
+ - [ ] 实现 `src/Framework/NewLife.Studio.Store/StoreService.cs`:
+ - 基于 `System.Text.Json` + 本地文件(`Environment.SpecialFolder.LocalApplicationData`)
+ - 按类型分文件:`connections.json` / `history.json` / `ai_profile.json` / `preferences.json`
+ - [ ] 实现 `src/Framework/NewLife.Studio.Store/SecretProtection.cs`:
+ - `Protect(string plain)` / `Unprotect(string cipher)` — AES 加密
+ - 连接保存时自动加密密码、AI Key 字段
+ - [ ] 注册:`services.AddSingleton<IStoreService, StoreService>()`
+ - [ ] 验证:写入连接 → 重启 → 读取仍存在;密码字段在文件中不可明文读取
+
+---
+
+## Phase 2: Data Studio 核心 — Task 3 ~ 4
+
+- [ ] Task 3: Data Studio — SQLite Provider(会话与元数据)
+ - [ ] 定义 `src/Providers/NewLife.Studio.Data/IDataProvider.cs`:
+ - `ProviderName`、`SupportedSchemes[]`、`CanTestConnection`
+ - `TestConnectionAsync(ConnectionInfo, CancellationToken)` → `Task<bool>`
+ - `OpenSessionAsync(ConnectionInfo, CancellationToken)` → `Task<IDbSession>`
+ - [ ] 定义 `src/Providers/NewLife.Studio.Data/IDbSession.cs`:
+ - `SessionId`、`Connection`、`IsOpen`
+ - `GetTablesAsync(CancellationToken)` → `Task<TableInfo[]>`
+ - `GetColumnsAsync(string tableName, CancellationToken)` → `Task<ColumnInfo[]>`
+ - `ExecuteQueryAsync(QueryRequest, CancellationToken)` → `Task<QueryResult>`
+ - `CloseAsync()`
+ - 继承 `IDisposable`
+ - [ ] 实现 `src/Providers/NewLife.Studio.Data/Providers/SQLite/SQLiteProvider.cs`:
+ - `ProviderName = "SQLite"`,`SupportedSchemes = ["sqlite", "sqlite3"]`
+ - `TestConnectionAsync`:打开 `Microsoft.Data.Sqlite.SqliteConnection` → 执行 `SELECT 1` → 关闭
+ - `OpenSessionAsync`:创建并返回 `SQLiteSession` 实例
+ - [ ] 实现 `src/Providers/NewLife.Studio.Data/Providers/SQLite/SQLiteSession.cs`:
+ - 持有 `Microsoft.Data.Sqlite.SqliteConnection`
+ - `CloseAsync` → `connection.Close()` + `Dispose()`
+ - `GetTablesAsync` → 查询 `sqlite_master WHERE type='table' ORDER BY name`
+ - `GetColumnsAsync` → 使用 `PRAGMA table_info('{tableName}')`
+ - `ExecuteQueryAsync` → 委托给 Task 4
+ - [ ] 实现 `src/Providers/NewLife.Studio.Data/Providers/SQLite/SQLiteMetadataReader.cs`:
+ - 解析 `PRAGMA table_info` 结果 → `ColumnInfo[]`
+ - 解析 `PRAGMA index_list` + `PRAGMA index_info` → 索引信息
+ - 解析 `PRAGMA foreign_key_list` → 外键信息(可为空列表)
+ - [ ] 注册:`services.AddSingleton<IDataProvider, SQLiteProvider>()`(后续多 Provider 改为工厂)
+ - [ ] 验证:打开测试 SQLite 文件,列表出所有表,每张表可展开列信息
+
+- [ ] Task 4: Data Studio — 查询执行与结果模型
+ - [ ] 确认 Core DTO `QueryRequest` / `QueryResult` 字段完整(已在 Task 2 定义)
+ - [ ] 在 `SQLiteSession.ExecuteQueryAsync` 中实现:
+ - 创建 `SqliteCommand`,设置 `CommandTimeout = request.TimeoutSeconds`
+ - 使用 `ExecuteReaderAsync` + `CancellationToken` 实现取消
+ - 逐行读取,达到 `MaxRows` 时停止并标记 `Truncated = true`
+ - 记录 `ElapsedMs = Stopwatch` 耗时
+ - [ ] 结果封装为 `QueryResult`:
+ - `Columns` 从 `reader.GetSchemaTable()` 提取
+ - `Rows` 为 `List<object[]>`,每个 cell 使用 `reader.GetValue(i)` 取原始值
+ - 异常时设置 `Error` 字段并返回空结果
+ - [ ] 实现历史记录写入:
+ - 依赖注入 `IStoreService`
+ - 查询成功或失败均异步写入 `QueryHistoryEntry`(不阻塞 UI)
+ - [ ] 验证:执行 `SELECT * FROM some_table` → 返回网格数据,耗时可见,超限行数被裁剪
+
+---
+
+## Phase 3: Data Studio UI — Task 5 ~ 6
+
+- [ ] Task 5: Data Studio — UI(MVP 闭环)
+ - [ ] 创建 Module 项目 `src/Modules/DataStudio/`(作为独立 `classlib`,引用 Avalonia + Core + Store + Data)
+ - [ ] 实现 `DataStudioModule.cs : IStudioModule`:
+ - `Id = "data-studio"`,`DisplayName = "数据管理"`,`Order = 0`
+ - `GetView()` → new `DataStudioView`
+ - [ ] 实现 `DataStudioViewModel`(主 ViewModel,持有子 ViewModel 引用)
+ - [ ] 实现 `src/Modules/DataStudio/ViewModels/ConnectionListViewModel.cs`:
+ - `ObservableCollection<ConnectionInfo> Connections`
+ - `AddConnectionCommand` → 弹出对话框输入 Name / FilePath → 调用 `IStoreService.SaveConnectionAsync`
+ - `EditConnectionCommand` / `DeleteConnectionCommand`
+ - `TestConnectionCommand` → 调用 `IDataProvider.TestConnectionAsync` → 显示成功/失败提示
+ - `OpenConnectionCommand` → 调用 `IDataProvider.OpenSessionAsync` → 传入 ObjectTreeVM
+ - [ ] 实现 `src/Modules/DataStudio/Views/ConnectionListView.axaml/.cs`:
+ - `ListBox` 绑定 `Connections`,每项显示 Name + 最近使用时间
+ - ToolBar 按钮:`+ 新建` `测试` `编辑` `删除`
+ - [ ] 实现 `src/Modules/DataStudio/ViewModels/ObjectTreeViewModel.cs`:
+ - `ObservableCollection<TreeNode> Nodes`(连接→Table→Column 递归树)
+ - `ExpandTableCommand` → 调用 `IDbSession.GetColumnsAsync` → 填充子节点
+ - 选中表时触发 `TableSelected` 事件
+ - [ ] 实现 `src/Modules/DataStudio/Views/ObjectTreeView.axaml/.cs`:
+ - `TreeView` 绑定 `Nodes`,HierarchicalDataTemplate 渲染三层
+ - [ ] 实现 `src/Modules/DataStudio/ViewModels/SqlEditorViewModel.cs`:
+ - `ObservableCollection<QueryTab> Tabs`(每个 Tab 有 Sql 文本框 + Result)
+ - `ExecuteCommand` → 构建 `QueryRequest` → 调用 `IDbSession.ExecuteQueryAsync` → 更新 `Result`
+ - `NewTabCommand` / `CloseTabCommand`
+ - [ ] 实现 `src/Modules/DataStudio/Views/SqlEditorView.axaml/.cs`:
+ - `TabControl` 绑定 `Tabs`,每个 Tab 内含 `TextBox`(SQL 编辑) + `Button`(执行)
+ - [ ] 实现 `src/Modules/DataStudio/ViewModels/ResultGridViewModel.cs`:
+ - `Columns` / `Rows` 绑定到 DataGrid
+ - `ElapsedMs` / `RowCount` / `TruncatedWarning` 显示
+ - `ExportCsvCommand` / `ExportJsonCommand`(委托 Task 6)
+ - [ ] 实现 `src/Modules/DataStudio/Views/ResultGridView.axaml/.cs`:
+ - `DataGrid` 自动列生成(AutoGenerateColumns=True)
+ - 底部显示耗时 + 行数 + 裁剪提示
+ - [ ] 在 App 启动时注册 DataStudioModule:
+ - `ModuleLoader` 扫描到 `DataStudioModule`,添加 NavBar 图标,默认激活
+ - [ ] 验证:完整 UI 交互闭环(新建连接 → 打开 → 浏览表 → 执行 SQL → 看结果)
+
+- [ ] Task 6: Data Studio — 导出(CSV / JSON)
+ - [ ] 在 `ResultGridViewModel` 中实现:
+ - `ExportCsvAsync(string filePath)`:
+ - 使用 `StreamWriter` + UTF-8 BOM
+ - 首行写入列名(逗号分隔),值含逗号/引号时加双引号转义
+ - 逐行写入数据行
+ - `ExportJsonAsync(string filePath)`:
+ - 使用 `System.Text.Json` 序列化
+ - 格式:`[{ "col1": val1, "col2": val2 }, ...]`
+ - [ ] UI 层:
+ - 结果区 ToolBar 添加 `导出 CSV` / `导出 JSON` 按钮
+ - 点击弹出 `SaveFileDialog`(Avalonia `StorageProvider`)选择保存路径
+ - [ ] 验证:查询结果导出为 CSV → 用记事本/Excel 打开 → 列正确无乱码;导出为 JSON → 格式合法
+
+---
+
+## Phase 4: AI 助手 — Task 7
+
+- [ ] Task 7: AI 助手(Provider 可插拔 + Tool Calling MVP)
+ - [ ] 定义 `src/Framework/NewLife.Studio.AI/IAiProvider.cs`:
+ - `ProviderName`
+ - `ChatAsync(ChatRequest, CancellationToken)` → `Task<ChatResponse>`
+ - [ ] 定义 AI 模型 DTO `src/Framework/NewLife.Studio.AI/Models/`:
+ - `ChatMessage.cs`:Role, Content, ToolCalls[], ToolCallId
+ - `ChatRequest.cs`:Messages[], Tools[], Model, MaxTokens
+ - `ChatResponse.cs`:Content, ToolCalls[], InputTokens, OutputTokens
+ - `ToolCall.cs`:Id, Name, Arguments(JSON)
+ - `ToolResult.cs`:ToolCallId, Output, Error
+ - `ToolDefinition.cs`:Name, Description, Parameters(JSON Schema)
+ - [ ] 实现 `src/Framework/NewLife.Studio.AI/AiProviderFactory.cs`:
+ - 根据 `AiProfile.ProviderType`("openai"/"azure"/"ollama")创建对应 `IAiProvider`
+ - [ ] 实现 OpenAI Provider(首个实现):
+ - HttpClient 调用 `https://api.openai.com/v1/chat/completions`
+ - 支持 Tool Calling(tools 参数传递)
+ - 解析响应中的 `tool_calls` 与 `content`
+ - [ ] 实现 `src/Framework/NewLife.Studio.AI/ToolCalling/ToolRegistry.cs`:
+ - `Register(string name, Func<string, Task<string>> handler, ToolDefinition definition)`
+ - `GetAllDefinitions()` → `ToolDefinition[]`
+ - [ ] 实现 `src/Framework/NewLife.Studio.AI/ToolCalling/ToolExecutor.cs`:
+ - `ExecuteAsync(ToolCall call)` → `Task<ToolResult>`
+ - 通过 `ToolRegistry` 查找 handler,执行并返回结果
+ - [ ] 实现 6 个内置工具 `src/Framework/NewLife.Studio.AI/ToolCalling/BuiltInTools/`:
+ | 工具名 | 功能 |
+ |---|---|
+ | `connections.list` | 列出所有已保存的连接(脱敏) |
+ | `db.open` | 打开指定数据库连接,返回会话 ID |
+ | `schema.tables` | 列出当前会话的表 |
+ | `schema.table` | 查看指定表的列详情 |
+ | `query.select` | 执行 SELECT(经安全过滤) |
+ | `query.sample` | 取表的前 N 行样本数据 |
+ - [ ] 实现 `src/Framework/NewLife.Studio.AI/Safety/QuerySafetyFilter.cs`:
+ - 仅允许 `SELECT` / `EXPLAIN` / `PRAGMA` 开头的语句
+ - 拒绝所有写操作(INSERT/UPDATE/DELETE/DROP/ALTER/CREATE)
+ - 拒绝多语句(分号分隔)
+ - 超行限制 + 超时取消
+ - [ ] 实现 Shell 级 AI 面板 `src/App/NewLife.Studio.App/Controls/AIPanel.axaml/.cs`:
+ - `ChatMessageList`:`ItemsControl` 渲染对话气泡(User 右对齐蓝底,Assistant 左对齐灰底,Tool 灰底斜体)
+ - Markdown 渲染(简单实现:支持 ``` 代码块和纯文本)
+ - `ToolCallInfo`:可展开区域,显示工具名 + 参数 + 返回结果
+ - `ChatInput`:多行 `TextBox` + `Button`("发送")
+ - [ ] 实现 `AIService`(跨 Module 共享):
+ - 管理对话历史 `List<ChatMessage>`
+ - 实现 Tool Calling 循环:发送 → 检测 tool_calls → 执行工具 → 追加结果 → 再发送
+ - [ ] 在 AI Profile 未配置时,AI Panel 显示引导提示"请先配置 AI 服务"
+ - [ ] 验证:AI 分析数据库完整闭环(列出表 → 查看结构 → 执行样本查询 → 输出分析汇总)
+
+---
+
+## Phase 5: 打包验证 — Task 8
+
+- [ ] Task 8: 打包与基本验证
+ - [ ] 端到端集成验证:
+ 1. `dotnet run` → Shell 窗口启动 → NavBar 显示"数据管理"图标
+ 2. 新建 SQLite 连接 → 测试连接成功 → 打开连接
+ 3. 对象树展开 Table 列表 → 点击表查看列信息
+ 4. SQL 编辑器输入 `SELECT * FROM table LIMIT 10` → 执行 → 结果网格显示
+ 5. 导出 CSV / JSON → 文件内容正确
+ 6. 打开 AI 面板 → 输入"分析数据库结构" → AI 调用工具 → 输出分析
+ 7. AI 尝试 `INSERT INTO` → 被拒绝 → 显示错误提示
+ 8. 关闭应用 → 重新启动 → 连接/历史/偏好仍存在
+ - [ ] 验证 `IStudioModule` 可扩展性:
+ - 编写一个 Mock Module 实现 `IStudioModule`,编译为独立 dll 放入 App 目录
+ - 启动后 NavBar 出现该模块图标,点击可切换视图
+ - [ ] 跨平台发布配置:
+ - `dotnet publish -c Release -r win-x64 --self-contained` → 生成 Windows 产物
+ - `dotnet publish -c Release -r linux-x64 --self-contained` → 生成 Linux 产物
+ - `dotnet publish -c Release -r osx-x64 --self-contained` → 生成 macOS 产物
+ - 验证:三个平台的发布产物均可启动(解压即用)
+
+---
+
+# Task Dependencies
+
+```
+Task 1 (Shell + Module接口)
+ ├──► Task 2 (Store)
+ │ └──► Task 4 (查询执行)
+ │ └──► Task 5 (UI)
+ │ └──► Task 6 (导出)
+ │
+ └──► Task 3 (SQLite Provider)
+ └──► Task 4 ──► Task 5 ──► Task 6
+ │
+ Task 7 (AI) ────────────┤
+ (依赖 Task 1,2,3,4) │
+ │
+ Task 8 (打包) ───────────┘
+ (依赖 Task 5,6,7)
+```
+
+- Task 2 depends on Task 1
+- Task 3 depends on Task 1
+- Task 4 depends on Task 3 and Task 2
+- Task 5 depends on Task 1, Task 2, Task 3, and Task 4
+- Task 6 depends on Task 4 and Task 5
+- Task 7 depends on Task 1, Task 2, Task 3, and Task 4
+- Task 8 depends on Task 5, Task 6, and Task 7
+
+---
+
+# Module Expansion Roadmap(后续迭代参考)
+
+- Task 9: MQTT Studio 模块
+ - `src/Providers/NewLife.Studio.Mqtt/` — MQTT 客户端封装
+ - `src/Modules/MqttStudio/` — 连接 MQTT Broker(NewLife.MQTT)
+ - Topic 树浏览、消息发布/订阅
+ - 客户端连接状态监控
+- Task 10: MQ Studio 模块
+ - `src/Providers/NewLife.Studio.Mq/` — 消息队列抽象
+ - `src/Modules/MqStudio/` — MQ 队列管理
+ - MQ 队列列表(NewLife.MQ / NewLife.RocketMQ)
+ - 消息查看、消费状态、堆积监控
+- Task 11: Redis Studio 模块
+ - `src/Providers/NewLife.Studio.Redis/` — Redis 客户端封装
+ - `src/Modules/RedisStudio/` — Key 浏览与搜索
+ - Key 浏览与搜索(NewLife.Redis)
+ - 命令执行控制台、内存/命中率监控
+- Task 12: Stardust Console 模块
+ - `src/Modules/StardustConsole/` — 星尘平台管理
+ - 星尘平台节点/应用/配置总览(NewLife.Stardust)
+ - APM 调用链查看、日志搜索
+- Task 13: Modbus Studio 模块
+ - `src/Providers/NewLife.Studio.Modbus/` — Modbus 协议封装
+ - `src/Modules/ModbusStudio/` — Modbus 设备管理
+ - Modbus 设备扫描、寄存器读写(NewLife.Modbus / NewLife.IoT)
\ No newline at end of file
diff --git a/NewLife.Studio.slnx b/NewLife.Studio.slnx
new file mode 100644
index 0000000..71b0ec9
--- /dev/null
+++ b/NewLife.Studio.slnx
@@ -0,0 +1,23 @@
+<Solution>
+ <Folder Name="/src/Framework/">
+ <Project Path="src/Framework/NewLife.Studio.Core/NewLife.Studio.Core.csproj" />
+ <Project Path="src/Framework/NewLife.Studio.Store/NewLife.Studio.Store.csproj" />
+ <Project Path="src/Framework/NewLife.Studio.AI/NewLife.Studio.AI.csproj" />
+ </Folder>
+ <Folder Name="/src/Providers/">
+ <Project Path="src/Providers/NewLife.Studio.Data/NewLife.Studio.Data.csproj" />
+ </Folder>
+ <Folder Name="/src/App/">
+ <Project Path="src/App/NewLife.Studio.App/NewLife.Studio.App.csproj" />
+ </Folder>
+ <Folder Name="/src/Modules/">
+ <Project Path="src/Modules/DataStudio/DataStudio.csproj" />
+ </Folder>
+ <Folder Name="/tests/">
+ <Project Path="tests/NewLife.Studio.AI.Tests/NewLife.Studio.AI.Tests.csproj" />
+ <Project Path="tests/NewLife.Studio.Core.Tests/NewLife.Studio.Core.Tests.csproj" />
+ <Project Path="tests/NewLife.Studio.Data.Tests/NewLife.Studio.Data.Tests.csproj" />
+ <Project Path="tests/NewLife.Studio.Modules.DataStudio.Tests/NewLife.Studio.Modules.DataStudio.Tests.csproj" />
+ <Project Path="tests/NewLife.Studio.Store.Tests/NewLife.Studio.Store.Tests.csproj" />
+ </Folder>
+</Solution>
\ No newline at end of file
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..9c372d7
--- /dev/null
+++ b/README.md
@@ -0,0 +1,225 @@
+# NewLife.Studio
+
+<p align="center">
+ <strong>NewLife 团队全产品工作室窗口</strong>
+</p>
+
+<p align="center">
+ <a href="#"><img src="https://img.shields.io/badge/.NET-9.0-512BD4?logo=dotnet" alt=".NET 9.0"></a>
+ <a href="#"><img src="https://img.shields.io/badge/Avalonia-12.0.3-8B5CF6?logo=avalonia" alt="Avalonia 12.0.3"></a>
+ <a href="#"><img src="https://img.shields.io/badge/tests-286%20passed-success" alt="286 tests passed"></a>
+ <a href="#"><img src="https://img.shields.io/badge/platform-Windows%20%7C%20Linux%20%7C%20macOS-blue" alt="Platform"></a>
+ <a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-green" alt="License"></a>
+</p>
+
+---
+
+## 简介
+
+**NewLife.Studio** 是 NewLife 团队生态的统一桌面管理工具。基于 Avalonia UI 构建,采用可插拔模块架构,将团队旗下所有开源产品(数据库管理、MQTT、消息队列、Redis、Stardust、Modbus 等)整合到单一窗口中,让开发者无需在多个工具间切换。
+
+### 产品矩阵
+
+| 产品 | 定位 | Studio 模块 |
+|------|------|------------|
+| **XCode** | 大数据中间件,ORM + 分表分库 | DataStudio |
+| **NewLife.Redis** | Redis 高性能客户端 | RedisStudio |
+| **NewLife.MQTT** | MQTT 消息队列 | MqttStudio |
+| **NewLife.MQ** | 自研轻量消息队列 | MqStudio |
+| **Stardust** | 星尘分布式监控平台 | StardustConsole |
+| **NewLife.Modbus** | Modbus 工业协议 | ModbusStudio |
+
+---
+
+## 特性
+
+- **模块化架构** — 通过 `IStudioModule` 接口实现插件式模块,零耦合接入
+- **AI 助手集成** — 内置 OpenAI 兼容的 AI 对话面板,支持 Tool Calling(6 个内置数据库工具)
+- **数据库工作室** — SQL 编辑器、表浏览、元数据查看、结果导出(CSV/JSON/SQL)
+- **本地安全存储** — JSON 文件持久化,敏感信息 AES 加密(基于机器指纹)
+- **跨平台** — 基于 Avalonia UI,支持 Windows / Linux / macOS
+- **完善的测试** — 286 个单元测试,5 个测试项目全覆盖
+
+---
+
+## 快速开始
+
+### 环境要求
+
+- [.NET 9.0 SDK](https://dotnet.microsoft.com/download/dotnet/9.0)
+
+### 构建与运行
+
+```bash
+# 克隆仓库
+git clone https://github.com/NewLifeX/NewLife.Studio.git
+cd NewLife.Studio
+
+# 还原依赖
+dotnet restore
+
+# 构建
+dotnet build
+
+# 运行
+dotnet run --project src/App/NewLife.Studio.App
+
+# 运行测试
+dotnet test
+
+# 发布 (Windows x64)
+dotnet publish src/App/NewLife.Studio.App -c Release -r win-x64 --self-contained
+```
+
+---
+
+## 架构
+
+```
+┌──────────────────────────────────────────┐
+│ App (Shell) │
+│ ┌────────┬──────────────────┬─────────┐ │
+│ │ NavBar │ ModuleHost │ AIPanel │ │
+│ │ 48px │ (Content) │ 300px │ │
+│ └────────┴──────────────────┴─────────┘ │
+└──────────────────────────────────────────┘
+ │ ▲
+ ▼ │
+┌──────────────────────────────────────────┐
+│ Modules (插件) │
+│ DataStudio MqttStudio MqStudio ... │
+└──────────────────────────────────────────┘
+ │
+ ▼
+┌──────────────────────────────────────────┐
+│ Providers (能力提供者) │
+│ Data AI (更多...) │
+└──────────────────────────────────────────┘
+ │
+ ▼
+┌──────────────────────────────────────────┐
+│ Framework (基础设施) │
+│ Core Store AI │
+└──────────────────────────────────────────┘
+```
+
+**依赖方向**: `App → Modules → Providers → Framework`(严格单向)
+
+详细架构说明见 [AGENTS.md](AGENTS.md)。
+
+---
+
+## 项目结构
+
+```
+NewLife.Studio/
+├── src/
+│ ├── Framework/ # 基础设施层
+│ │ ├── NewLife.Studio.Core/ # 核心接口、服务定位器
+│ │ ├── NewLife.Studio.Store/ # 本地存储 (JSON + AES)
+│ │ └── NewLife.Studio.AI/ # AI 引擎 + Tool Calling
+│ ├── Providers/ # 能力提供者
+│ │ └── NewLife.Studio.Data/ # 数据库访问层
+│ ├── App/ # 应用 Shell
+│ │ └── NewLife.Studio.App/ # 主窗口、导航、模块加载
+│ └── Modules/ # 功能模块 (插件)
+│ └── DataStudio/ # 数据工作室 (MVP)
+├── tests/ # 测试项目
+│ ├── NewLife.Studio.Core.Tests/ (59 tests)
+│ ├── NewLife.Studio.Store.Tests/ (37 tests)
+│ ├── NewLife.Studio.Data.Tests/ (28 tests)
+│ ├── NewLife.Studio.AI.Tests/ (79 tests)
+│ └── NewLife.Studio.Modules.DataStudio.Tests/ (99 tests)
+├── AGENTS.md # AI 快速上手指南
+└── README.md # 本文件
+```
+
+---
+
+## 技术栈
+
+| 类别 | 技术 | 版本 |
+|------|------|------|
+| 运行时 | .NET | 9.0 |
+| UI 框架 | Avalonia UI | 12.0.3 |
+| MVVM | CommunityToolkit.Mvvm | 8.4.1 |
+| 依赖注入 | Microsoft.Extensions.DependencyInjection | 9.0 |
+| 测试框架 | xUnit + Moq | 2.9.3 / 4.20.72 |
+| 覆盖率 | coverlet.collector | 6.0.4 |
+| 数据库 (MVP) | Microsoft.Data.Sqlite | 9.0 |
+| AI 接口 | OpenAI-compatible API | - |
+| 序列化 | System.Text.Json | 9.0 |
+
+---
+
+## AI 助手
+
+NewLife.Studio 内置 AI 对话面板,支持 OpenAI 兼容 API。AI 可以通过 Tool Calling 直接操作数据库:
+
+| 工具名称 | 功能 |
+|----------|------|
+| `connections.list` | 列出已保存的数据库连接 |
+| `db.open` | 打开指定数据库连接 |
+| `schema.tables` | 获取表列表 |
+| `schema.table` | 获取表结构详情 |
+| `query.select` | 执行只读 SQL 查询 |
+| `query.sample` | 获取表样本数据 |
+
+所有 SQL 查询经过安全过滤,仅允许 `SELECT` / `PRAGMA` / `EXPLAIN` 语句。
+
+---
+
+## 路线图
+
+### 当前阶段 (MVP)
+
+- [x] Shell 框架 (主窗口、导航栏、模块切换)
+- [x] 本地安全存储
+- [x] SQLite 数据库管理 (连接、浏览、查询、导出)
+- [x] AI 助手面板 + Tool Calling
+- [x] 完整测试覆盖 (286 tests)
+
+### 下一阶段 (模块扩展)
+
+- [ ] **MqttStudio** — MQTT 客户端管理、主题订阅、消息收发
+- [ ] **MqStudio** — 消息队列管理、死信处理
+- [ ] **RedisStudio** — Redis 数据浏览、命令执行
+- [ ] **StardustConsole** — 星尘监控面板
+- [ ] **ModbusStudio** — Modbus 设备连接、寄存器读写
+
+### 远期规划
+
+- [ ] 更多 Data Provider (MySQL、PostgreSQL、SQL Server)
+- [ ] 数据可视化 (图表、仪表盘)
+- [ ] 插件市场
+- [ ] 多语言国际化
+
+---
+
+## 贡献
+
+欢迎提交 Issue 和 Pull Request。
+
+### 开发约定
+
+- 每个 `src/` 项目对应 `tests/` 下的同名测试项目
+- 接口以 `I` 开头,异步方法以 `Async` 结尾
+- 新增模块放在 `src/Modules/` 下,实现 `IStudioModule` 接口
+- 新增 Provider 放在 `src/Providers/` 下
+
+更多约定见 [AGENTS.md](AGENTS.md)。
+
+---
+
+## 相关链接
+
+- [NewLife 团队](https://github.com/NewLifeX)
+- [XCode](https://github.com/NewLifeX/X)
+- [NewLife.Redis](https://github.com/NewLifeX/NewLife.Redis)
+- [Avalonia UI](https://avaloniaui.net/)
+
+---
+
+## 许可证
+
+[MIT License](LICENSE)
\ No newline at end of file
diff --git a/src/App/NewLife.Studio.App/App.axaml b/src/App/NewLife.Studio.App/App.axaml
new file mode 100644
index 0000000..1bdfaa9
--- /dev/null
+++ b/src/App/NewLife.Studio.App/App.axaml
@@ -0,0 +1,15 @@
+<Application xmlns="https://github.com/avaloniaui"
+ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
+ x:Class="NewLife.Studio.App.App"
+ xmlns:local="using:NewLife.Studio.App"
+ RequestedThemeVariant="Default">
+ <!-- "Default" ThemeVariant follows system theme variant. "Dark" or "Light" are other available options. -->
+
+ <Application.DataTemplates>
+ <local:ViewLocator/>
+ </Application.DataTemplates>
+
+ <Application.Styles>
+ <FluentTheme />
+ </Application.Styles>
+</Application>
\ No newline at end of file
diff --git a/src/App/NewLife.Studio.App/App.axaml.cs b/src/App/NewLife.Studio.App/App.axaml.cs
new file mode 100644
index 0000000..8174c97
--- /dev/null
+++ b/src/App/NewLife.Studio.App/App.axaml.cs
@@ -0,0 +1,62 @@
+using Avalonia;
+using Avalonia.Controls.ApplicationLifetimes;
+using Avalonia.Data.Core.Plugins;
+using Microsoft.Extensions.DependencyInjection;
+using NewLife.Studio.App.Services;
+using NewLife.Studio.App.Views;
+using NewLife.Studio.Core;
+using NewLife.Studio.Data;
+using NewLife.Studio.Data.Providers.SQLite;
+using NewLife.Studio.Store;
+// 触发 DataStudio 模块程序集加载,确保 ModuleLoader 能扫描到
+using NewLife.Studio.Modules.DataStudio;
+
+namespace NewLife.Studio.App;
+
+public partial class App : Application
+{
+ private ServiceProvider? _serviceProvider;
+
+ public IServiceProvider? GetServiceProvider() => _serviceProvider;
+
+ public override void Initialize()
+ {
+ Avalonia.Markup.Xaml.AvaloniaXamlLoader.Load(this);
+ }
+
+ public override void OnFrameworkInitializationCompleted()
+ {
+ var services = new ServiceCollection();
+ ConfigureServices(services);
+ _serviceProvider = services.BuildServiceProvider();
+ StudioServices.Initialize(_serviceProvider);
+
+ // 强制加载模块程序集
+ _ = typeof(DataStudioModule).FullName;
+
+ if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
+ {
+ var mainWindow = _serviceProvider.GetRequiredService<MainWindow>();
+ desktop.MainWindow = mainWindow;
+
+ mainWindow.InitializeAsync().ContinueWith(t =>
+ {
+ if (t.Exception != null)
+ {
+ NewLife.Log.XTrace.WriteLine($"MainWindow init error: {t.Exception}");
+ }
+ });
+ }
+
+ base.OnFrameworkInitializationCompleted();
+ }
+
+ private static void ConfigureServices(IServiceCollection services)
+ {
+ services.AddSingleton<ModuleLoader>();
+ services.AddSingleton<MainWindow>();
+ services.AddSingleton<IStoreService, StoreService>();
+ services.AddSingleton<IDataProvider, SQLiteProvider>();
+ services.AddSingleton<HttpClient>();
+ }
+}
\ No newline at end of file
diff --git a/src/App/NewLife.Studio.App/app.manifest b/src/App/NewLife.Studio.App/app.manifest
new file mode 100644
index 0000000..c3eaf24
--- /dev/null
+++ b/src/App/NewLife.Studio.App/app.manifest
@@ -0,0 +1,18 @@
+<?xml version="1.0" encoding="utf-8"?>
+<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
+ <!-- This manifest is used on Windows only.
+ Don't remove it as it might cause problems with window transparency and embedded controls.
+ For more details visit https://learn.microsoft.com/en-us/windows/win32/sbscs/application-manifests -->
+ <assemblyIdentity version="1.0.0.0" name="NewLife.Studio.App.Desktop"/>
+
+ <compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
+ <application>
+ <!-- A list of the Windows versions that this application has been tested on
+ and is designed to work with. Uncomment the appropriate elements
+ and Windows will automatically select the most compatible environment. -->
+
+ <!-- Windows 10 -->
+ <supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />
+ </application>
+ </compatibility>
+</assembly>
diff --git a/src/App/NewLife.Studio.App/Assets/avalonia-logo.ico b/src/App/NewLife.Studio.App/Assets/avalonia-logo.ico
new file mode 100644
index 0000000..f7da8bb
Binary files /dev/null and b/src/App/NewLife.Studio.App/Assets/avalonia-logo.ico differ
diff --git a/src/App/NewLife.Studio.App/Controls/AIPanel.axaml b/src/App/NewLife.Studio.App/Controls/AIPanel.axaml
new file mode 100644
index 0000000..786ded5
--- /dev/null
+++ b/src/App/NewLife.Studio.App/Controls/AIPanel.axaml
@@ -0,0 +1,23 @@
+<UserControl xmlns="https://github.com/avaloniaui"
+ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
+ x:Class="NewLife.Studio.App.Controls.AIPanel">
+ <Border Padding="8">
+ <Grid RowDefinitions="*,Auto" x:Name="MainGrid">
+ <!-- 对话历史 -->
+ <ScrollViewer Grid.Row="0">
+ <ItemsControl x:Name="ChatHistory" />
+ </ScrollViewer>
+
+ <!-- 输入区域 -->
+ <Grid Grid.Row="1" ColumnDefinitions="*,Auto" Margin="0,8,0,0">
+ <TextBox Grid.Column="0" x:Name="ChatInput"
+ AcceptsReturn="True" Height="60"
+ PlaceholderText="输入问题..."
+ FontSize="13" />
+ <Button Grid.Column="1" x:Name="SendButton"
+ Content="发送" Width="60" Margin="4,0,0,0"
+ VerticalAlignment="Top" />
+ </Grid>
+ </Grid>
+ </Border>
+</UserControl>
\ No newline at end of file
diff --git a/src/App/NewLife.Studio.App/Controls/AIPanel.axaml.cs b/src/App/NewLife.Studio.App/Controls/AIPanel.axaml.cs
new file mode 100644
index 0000000..71e498a
--- /dev/null
+++ b/src/App/NewLife.Studio.App/Controls/AIPanel.axaml.cs
@@ -0,0 +1,244 @@
+using Avalonia.Controls;
+using Avalonia.Media;
+using Avalonia.Interactivity;
+using NewLife.Studio.AI;
+using NewLife.Studio.AI.ToolCalling;
+using NewLife.Studio.AI.ToolCalling.BuiltInTools;
+using NewLife.Studio.AI.Models;
+using NewLife.Studio.Store;
+using NewLife.Studio.Data;
+using NewLife.Studio.Core;
+using NewLife.Studio.Core.DTOs;
+using NewLife.Log;
+
+namespace NewLife.Studio.App.Controls;
+
+public partial class AIPanel : UserControl
+{
+ private AIService? _aiService;
+ private IStoreService? _storeService;
+ private IDataProvider? _dataProvider;
+ private IDbSession? _activeSession;
+ private bool _configured;
+
+ public AIPanel()
+ {
+ InitializeComponent();
+
+ SendButton.Click += async (_, _) =>
+ {
+ if (_aiService == null || !_configured)
+ {
+ AddMessage("系统", "请先在设置中配置 AI 服务", Colors.Orange);
+ return;
+ }
+
+ var text = ChatInput.Text?.Trim();
+ if (string.IsNullOrEmpty(text)) return;
+
+ ChatInput.Text = "";
+ AddMessage("用户", text, Colors.DodgerBlue);
+
+ try
+ {
+ SendButton.IsEnabled = false;
+ var reply = await _aiService.ChatAsync(text, (tc, tr) =>
+ {
+ AddToolCall(tc, tr);
+ });
+
+ if (!string.IsNullOrEmpty(reply))
+ {
+ AddMessage("AI", reply, Colors.DimGray);
+ }
+ }
+ catch (Exception ex)
+ {
+ AddMessage("错误", ex.Message, Colors.Red);
+ }
+ finally
+ {
+ SendButton.IsEnabled = true;
+ }
+ };
+ }
+
+ public void Initialize(IStoreService storeService, IDataProvider dataProvider)
+ {
+ _storeService = storeService;
+ _dataProvider = dataProvider;
+ _ = LoadAiConfigAsync();
+ }
+
+ private async Task LoadAiConfigAsync()
+ {
+ if (_storeService == null) return;
+
+ var profile = await _storeService.GetAiProfileAsync();
+ if (profile != null && !string.IsNullOrEmpty(profile.ApiKey))
+ {
+ var provider = AIProviderFactory.Create(
+ StudioServices.GetRequiredService<HttpClient>(),
+ profile.ProviderType,
+ profile.Endpoint,
+ profile.ApiKey,
+ profile.Model);
+
+ var registry = new ToolRegistry();
+ RegisterBuiltInTools(registry);
+
+ _aiService = new AIService(provider, registry,
+ "You are a database assistant for NewLife Studio. You can analyze database structures and execute read-only queries. Always explain your analysis in the user's language.");
+
+ _configured = true;
+ AddMessage("系统", "AI 助手已就绪", Colors.Green);
+ XTrace.WriteLine("AIPanel: AI service initialized");
+ }
+ else
+ {
+ AddMessage("系统", "请先配置 AI 服务(设置 -> AI 配置)", Colors.Orange);
+ XTrace.WriteLine("AIPanel: AI not configured");
+ }
+ }
+
+ private void RegisterBuiltInTools(ToolRegistry registry)
+ {
+ BuiltInDatabaseTools.RegisterAll(registry,
+ listConnections: async () =>
+ {
+ if (_storeService == null) return "[]";
+ var conns = await _storeService.ListConnectionsAsync();
+ var names = conns.Select(c => $"{c.Name} ({c.ProviderType})");
+ return string.Join("\n", names);
+ },
+ openDatabase: async (name) =>
+ {
+ if (_dataProvider == null || _storeService == null) return "No data provider";
+ var conns = await _storeService.ListConnectionsAsync();
+ var conn = conns.FirstOrDefault(c => c.Name == name);
+ if (conn == null) return $"Connection '{name}' not found";
+ _activeSession = await _dataProvider.OpenSessionAsync(conn);
+ return $"Opened: {_activeSession.SessionId}";
+ },
+ listTables: async (_) =>
+ {
+ if (_activeSession == null) return "No active session";
+ var tables = await _activeSession.GetTablesAsync();
+ return string.Join("\n", tables.Select(t => $"{t.Name} (rows: {t.RowCount})"));
+ },
+ describeTable: async (table) =>
+ {
+ if (_activeSession == null) return "No active session";
+ var cols = await _activeSession.GetColumnsAsync(table);
+ var lines = cols.Select(c =>
+ $"{c.Name}: {c.DataType}{(c.IsNullable ? " NULL" : " NOT NULL")}{(c.IsPrimaryKey ? " PK" : "")}");
+ return string.Join("\n", lines);
+ },
+ executeSelect: async (sql) =>
+ {
+ if (_activeSession == null) return "No active session";
+ var result = await _activeSession.ExecuteQueryAsync(new QueryRequest
+ {
+ Sql = sql,
+ MaxRows = 100,
+ TimeoutSeconds = 30
+ });
+
+ if (result.Error != null) return $"Error: {result.Error}";
+ var lines = new List<string> { $"Columns: {string.Join(", ", result.Columns.Select(c => c.Name))}" };
+ lines.Add($"Rows: {result.RowCount}, Time: {result.ElapsedMs}ms");
+ foreach (var row in result.Rows.Take(10))
+ {
+ lines.Add(string.Join(" | ", row.Select(v => v?.ToString() ?? "NULL")));
+ }
+ if (result.Truncated) lines.Add("(results truncated)");
+ return string.Join("\n", lines);
+ },
+ sampleData: async (table, limit) =>
+ {
+ if (_activeSession == null) return "No active session";
+ var result = await _activeSession.ExecuteQueryAsync(new QueryRequest
+ {
+ Sql = $"SELECT * FROM {table} LIMIT {limit}",
+ MaxRows = limit,
+ TimeoutSeconds = 30
+ });
+ if (result.Error != null) return $"Error: {result.Error}";
+ return $"Sample from {table} ({result.RowCount} rows, {result.ElapsedMs}ms):\n" +
+ string.Join("\n", result.Rows.Select(r => string.Join(" | ", r.Select(v => v?.ToString() ?? "NULL"))));
+ }
+ );
+ }
+
+ private void AddMessage(string role, string content, Color color)
+ {
+ var border = new Border
+ {
+ Background = new SolidColorBrush(color, 0.1),
+ CornerRadius = new Avalonia.CornerRadius(4),
+ Padding = new Avalonia.Thickness(8),
+ Margin = new Avalonia.Thickness(0, 4)
+ };
+
+ var panel = new StackPanel();
+ panel.Children.Add(new TextBlock
+ {
+ Text = $"[{role}]",
+ FontWeight = FontWeight.Bold,
+ FontSize = 11,
+ Foreground = new SolidColorBrush(color)
+ });
+ panel.Children.Add(new TextBlock
+ {
+ Text = content,
+ TextWrapping = TextWrapping.Wrap,
+ FontSize = 12
+ });
+
+ border.Child = panel;
+ ChatHistory.Items.Add(border);
+ }
+
+ private void AddToolCall(ToolCall tc, ToolResult tr)
+ {
+ var expander = new Expander
+ {
+ Header = $"Tool: {tc.Function.Name}",
+ Margin = new Avalonia.Thickness(0, 2)
+ };
+
+ var content = new StackPanel();
+ content.Children.Add(new TextBlock
+ {
+ Text = $"Args: {tc.Function.Arguments}",
+ FontSize = 10,
+ Foreground = Brushes.Gray,
+ TextWrapping = TextWrapping.Wrap
+ });
+
+ if (tr.Error != null)
+ {
+ content.Children.Add(new TextBlock
+ {
+ Text = $"Error: {tr.Error}",
+ FontSize = 10,
+ Foreground = Brushes.Red,
+ TextWrapping = TextWrapping.Wrap
+ });
+ }
+ else
+ {
+ content.Children.Add(new TextBlock
+ {
+ Text = $"Result: {tr.Output}",
+ FontSize = 10,
+ Foreground = Brushes.DarkGreen,
+ TextWrapping = TextWrapping.Wrap,
+ MaxHeight = 200
+ });
+ }
+
+ expander.Content = new ScrollViewer { Content = content };
+ ChatHistory.Items.Add(expander);
+ }
+}
\ No newline at end of file
diff --git a/src/App/NewLife.Studio.App/Controls/ModuleHost.axaml b/src/App/NewLife.Studio.App/Controls/ModuleHost.axaml
new file mode 100644
index 0000000..c87ca11
--- /dev/null
+++ b/src/App/NewLife.Studio.App/Controls/ModuleHost.axaml
@@ -0,0 +1,5 @@
+<UserControl xmlns="https://github.com/avaloniaui"
+ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
+ x:Class="NewLife.Studio.App.Controls.ModuleHost">
+ <ContentControl x:Name="ContentHost" />
+</UserControl>
\ No newline at end of file
diff --git a/src/App/NewLife.Studio.App/Controls/ModuleHost.axaml.cs b/src/App/NewLife.Studio.App/Controls/ModuleHost.axaml.cs
new file mode 100644
index 0000000..9f5882f
--- /dev/null
+++ b/src/App/NewLife.Studio.App/Controls/ModuleHost.axaml.cs
@@ -0,0 +1,16 @@
+using Avalonia.Controls;
+
+namespace NewLife.Studio.App.Controls;
+
+public partial class ModuleHost : UserControl
+{
+ public ModuleHost()
+ {
+ InitializeComponent();
+ }
+
+ public void SetContent(Control? view)
+ {
+ ContentHost.Content = view;
+ }
+}
\ No newline at end of file
diff --git a/src/App/NewLife.Studio.App/Controls/NavBar.axaml b/src/App/NewLife.Studio.App/Controls/NavBar.axaml
new file mode 100644
index 0000000..e0ff16c
--- /dev/null
+++ b/src/App/NewLife.Studio.App/Controls/NavBar.axaml
@@ -0,0 +1,10 @@
+<UserControl xmlns="https://github.com/avaloniaui"
+ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
+ x:Class="NewLife.Studio.App.Controls.NavBar"
+ Width="48">
+ <Border Background="#F0F0F0" BorderBrush="#E0E0E0" BorderThickness="0,0,1,0">
+ <ScrollViewer>
+ <StackPanel x:Name="ModuleList" Margin="4" Spacing="8" />
+ </ScrollViewer>
+ </Border>
+</UserControl>
\ No newline at end of file
diff --git a/src/App/NewLife.Studio.App/Controls/NavBar.axaml.cs b/src/App/NewLife.Studio.App/Controls/NavBar.axaml.cs
new file mode 100644
index 0000000..6ce1fa0
--- /dev/null
+++ b/src/App/NewLife.Studio.App/Controls/NavBar.axaml.cs
@@ -0,0 +1,60 @@
+using Avalonia.Controls;
+using Avalonia.Media;
+using NewLife.Studio.Core;
+
+namespace NewLife.Studio.App.Controls;
+
+public partial class NavBar : UserControl
+{
+ public event EventHandler<ModuleInfo>? ModuleSelected;
+
+ public NavBar()
+ {
+ InitializeComponent();
+ }
+
+ public void SetModules(IReadOnlyList<ModuleInfo> modules, ModuleInfo? activeModule)
+ {
+ ModuleList.Children.Clear();
+
+ foreach (var m in modules)
+ {
+ var isActive = m == activeModule;
+
+ var button = new Button
+ {
+ Width = 44,
+ Height = 44,
+ Padding = new Avalonia.Thickness(2),
+ Background = isActive
+ ? new SolidColorBrush(Color.Parse("#007ACC"))
+ : Brushes.Transparent,
+ Content = new StackPanel
+ {
+ HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Center,
+ Children =
+ {
+ new TextBlock
+ {
+ Text = m.DisplayName,
+ FontSize = 10,
+ TextAlignment = TextAlignment.Center,
+ TextWrapping = TextWrapping.Wrap,
+ Width = 40,
+ Foreground = isActive ? Brushes.White : Brushes.Black
+ }
+ }
+ },
+ Tag = m
+ };
+
+ var capturedModule = m;
+ button.Click += (_, _) =>
+ {
+ ModuleSelected?.Invoke(this, capturedModule);
+ };
+
+ ModuleList.Children.Add(button);
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/App/NewLife.Studio.App/Controls/StatusBar.axaml b/src/App/NewLife.Studio.App/Controls/StatusBar.axaml
new file mode 100644
index 0000000..7d8f972
--- /dev/null
+++ b/src/App/NewLife.Studio.App/Controls/StatusBar.axaml
@@ -0,0 +1,12 @@
+<UserControl xmlns="https://github.com/avaloniaui"
+ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
+ x:Class="NewLife.Studio.App.Controls.StatusBar"
+ Height="24">
+ <Border Background="#007ACC" Padding="8,2">
+ <StackPanel Orientation="Horizontal" Spacing="16">
+ <TextBlock x:Name="ConnectionText" Foreground="White" FontSize="12" VerticalAlignment="Center" />
+ <TextBlock x:Name="ModuleText" Foreground="White" FontSize="12" VerticalAlignment="Center" />
+ <TextBlock x:Name="TaskText" Foreground="White" FontSize="12" VerticalAlignment="Center" />
+ </StackPanel>
+ </Border>
+</UserControl>
\ No newline at end of file
diff --git a/src/App/NewLife.Studio.App/Controls/StatusBar.axaml.cs b/src/App/NewLife.Studio.App/Controls/StatusBar.axaml.cs
new file mode 100644
index 0000000..c824aff
--- /dev/null
+++ b/src/App/NewLife.Studio.App/Controls/StatusBar.axaml.cs
@@ -0,0 +1,26 @@
+using Avalonia.Controls;
+
+namespace NewLife.Studio.App.Controls;
+
+public partial class StatusBar : UserControl
+{
+ public StatusBar()
+ {
+ InitializeComponent();
+ }
+
+ public void SetConnection(string? text)
+ {
+ ConnectionText.Text = text ?? "";
+ }
+
+ public void SetModule(string? text)
+ {
+ ModuleText.Text = text ?? "";
+ }
+
+ public void SetTask(string? text)
+ {
+ TaskText.Text = text ?? "";
+ }
+}
\ No newline at end of file
diff --git a/src/App/NewLife.Studio.App/NewLife.Studio.App.csproj b/src/App/NewLife.Studio.App/NewLife.Studio.App.csproj
new file mode 100644
index 0000000..4ff5c52
--- /dev/null
+++ b/src/App/NewLife.Studio.App/NewLife.Studio.App.csproj
@@ -0,0 +1,36 @@
+<Project Sdk="Microsoft.NET.Sdk">
+ <PropertyGroup>
+ <OutputType>WinExe</OutputType>
+ <TargetFramework>net9.0</TargetFramework>
+ <Nullable>enable</Nullable>
+ <ImplicitUsings>enable</ImplicitUsings>
+ <ApplicationManifest>app.manifest</ApplicationManifest>
+ <AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
+ <RuntimeIdentifiers>win-x64;linux-x64;osx-x64</RuntimeIdentifiers>
+ </PropertyGroup>
+
+ <ItemGroup>
+ <AvaloniaResource Include="Assets\**" />
+ </ItemGroup>
+
+ <ItemGroup>
+ <PackageReference Include="Avalonia" Version="12.0.3" />
+ <PackageReference Include="Avalonia.Desktop" Version="12.0.3" />
+ <PackageReference Include="Avalonia.Themes.Fluent" Version="12.0.3" />
+ <PackageReference Include="Avalonia.Fonts.Inter" Version="12.0.3" />
+ <PackageReference Include="AvaloniaUI.DiagnosticsSupport" Version="2.2.1">
+ <IncludeAssets Condition="'$(Configuration)' != 'Debug'">None</IncludeAssets>
+ <PrivateAssets Condition="'$(Configuration)' != 'Debug'">All</PrivateAssets>
+ </PackageReference>
+ <PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.1" />
+ <PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="9.0.0" />
+ </ItemGroup>
+
+ <ItemGroup>
+ <ProjectReference Include="..\..\Framework\NewLife.Studio.Core\NewLife.Studio.Core.csproj" />
+ <ProjectReference Include="..\..\Framework\NewLife.Studio.Store\NewLife.Studio.Store.csproj" />
+ <ProjectReference Include="..\..\Providers\NewLife.Studio.Data\NewLife.Studio.Data.csproj" />
+ <ProjectReference Include="..\..\Modules\DataStudio\DataStudio.csproj" />
+ <ProjectReference Include="..\..\Framework\NewLife.Studio.AI\NewLife.Studio.AI.csproj" />
+ </ItemGroup>
+</Project>
diff --git a/src/App/NewLife.Studio.App/Program.cs b/src/App/NewLife.Studio.App/Program.cs
new file mode 100644
index 0000000..7545b93
--- /dev/null
+++ b/src/App/NewLife.Studio.App/Program.cs
@@ -0,0 +1,21 @@
+using Avalonia;
+using System;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace NewLife.Studio.App;
+
+sealed class Program
+{
+ [STAThread]
+ public static void Main(string[] args) => BuildAvaloniaApp()
+ .StartWithClassicDesktopLifetime(args);
+
+ public static AppBuilder BuildAvaloniaApp()
+ => AppBuilder.Configure<App>()
+ .UsePlatformDetect()
+#if DEBUG
+ .WithDeveloperTools()
+#endif
+ .WithInterFont()
+ .LogToTrace();
+}
\ No newline at end of file
diff --git a/src/App/NewLife.Studio.App/Services/ModuleLoader.cs b/src/App/NewLife.Studio.App/Services/ModuleLoader.cs
new file mode 100644
index 0000000..192ea7f
--- /dev/null
+++ b/src/App/NewLife.Studio.App/Services/ModuleLoader.cs
@@ -0,0 +1,84 @@
+using System.Reflection;
+using NewLife.Studio.Core;
+using NewLife.Log;
+
+namespace NewLife.Studio.App.Services;
+
+/// <summary>模块加载器 — 扫描已加载 Assembly 中实现 IStudioModule 的类型</summary>
+public class ModuleLoader
+{
+ private readonly List<ModuleInfo> _modules = [];
+ private ModuleInfo? _activeModule;
+
+ public IReadOnlyList<ModuleInfo> Modules => _modules.AsReadOnly();
+ public ModuleInfo? ActiveModule => _activeModule;
+
+ public event EventHandler<ModuleInfo>? ModuleActivated;
+
+ /// <summary>扫描所有已加载的 Assembly 中实现 IStudioModule 的类型</summary>
+ public void ScanModules()
+ {
+ _modules.Clear();
+
+ foreach (var assembly in AppDomain.CurrentDomain.GetAssemblies())
+ {
+ foreach (var type in assembly.GetTypes())
+ {
+ if (type.IsAbstract || type.IsInterface)
+ continue;
+ if (!typeof(IStudioModule).IsAssignableFrom(type))
+ continue;
+
+ try
+ {
+ if (Activator.CreateInstance(type) is IStudioModule module)
+ {
+ _modules.Add(new ModuleInfo
+ {
+ Id = module.Id,
+ DisplayName = module.DisplayName,
+ Icon = module.Icon,
+ Order = module.Order,
+ Module = module
+ });
+ XTrace.WriteLine($"ModuleLoader: Found module [{module.Id}] {module.DisplayName} in {type.FullName}");
+ }
+ }
+ catch (Exception ex)
+ {
+ XTrace.WriteLine($"ModuleLoader: Failed to load module {type.FullName}: {ex.Message}");
+ }
+ }
+ }
+
+ _modules.Sort((a, b) => a.Order.CompareTo(b.Order));
+ XTrace.WriteLine($"ModuleLoader: Loaded {_modules.Count} modules");
+ }
+
+ /// <summary>激活默认模块(第一个)</summary>
+ public async Task ActivateDefaultAsync(CancellationToken ct = default)
+ {
+ if (_modules.Count > 0)
+ {
+ await ActivateModuleAsync(_modules[0], ct);
+ }
+ }
+
+ /// <summary>切换到指定模块</summary>
+ public async Task ActivateModuleAsync(ModuleInfo moduleInfo, CancellationToken ct = default)
+ {
+ if (_activeModule == moduleInfo)
+ return;
+
+ if (_activeModule != null)
+ {
+ await _activeModule.Module.OnDeactivateAsync(ct);
+ }
+
+ _activeModule = moduleInfo;
+ await moduleInfo.Module.OnActivateAsync(ct);
+
+ ModuleActivated?.Invoke(this, moduleInfo);
+ XTrace.WriteLine($"ModuleLoader: Activated module [{moduleInfo.Id}]");
+ }
+}
\ No newline at end of file
diff --git a/src/App/NewLife.Studio.App/ViewLocator.cs b/src/App/NewLife.Studio.App/ViewLocator.cs
new file mode 100644
index 0000000..78a916f
--- /dev/null
+++ b/src/App/NewLife.Studio.App/ViewLocator.cs
@@ -0,0 +1,37 @@
+using System;
+using System.Diagnostics.CodeAnalysis;
+using Avalonia.Controls;
+using Avalonia.Controls.Templates;
+using NewLife.Studio.App.ViewModels;
+
+namespace NewLife.Studio.App;
+
+/// <summary>
+/// Given a view model, returns the corresponding view if possible.
+/// </summary>
+[RequiresUnreferencedCode(
+ "Default implementation of ViewLocator involves reflection which may be trimmed away.",
+ Url = "https://docs.avaloniaui.net/docs/concepts/view-locator")]
+public class ViewLocator : IDataTemplate
+{
+ public Control? Build(object? param)
+ {
+ if (param is null)
+ return null;
+
+ var name = param.GetType().FullName!.Replace("ViewModel", "View", StringComparison.Ordinal);
+ var type = Type.GetType(name);
+
+ if (type != null)
+ {
+ return (Control)Activator.CreateInstance(type)!;
+ }
+
+ return new TextBlock { Text = "Not Found: " + name };
+ }
+
+ public bool Match(object? data)
+ {
+ return data is ViewModelBase;
+ }
+}
diff --git a/src/App/NewLife.Studio.App/ViewModels/MainWindowViewModel.cs b/src/App/NewLife.Studio.App/ViewModels/MainWindowViewModel.cs
new file mode 100644
index 0000000..4286911
--- /dev/null
+++ b/src/App/NewLife.Studio.App/ViewModels/MainWindowViewModel.cs
@@ -0,0 +1,6 @@
+namespace NewLife.Studio.App.ViewModels;
+
+public partial class MainWindowViewModel : ViewModelBase
+{
+ public string Greeting { get; } = "Welcome to Avalonia!";
+}
diff --git a/src/App/NewLife.Studio.App/ViewModels/ViewModelBase.cs b/src/App/NewLife.Studio.App/ViewModels/ViewModelBase.cs
new file mode 100644
index 0000000..18ac895
--- /dev/null
+++ b/src/App/NewLife.Studio.App/ViewModels/ViewModelBase.cs
@@ -0,0 +1,7 @@
+using CommunityToolkit.Mvvm.ComponentModel;
+
+namespace NewLife.Studio.App.ViewModels;
+
+public abstract class ViewModelBase : ObservableObject
+{
+}
diff --git a/src/App/NewLife.Studio.App/Views/MainWindow.axaml b/src/App/NewLife.Studio.App/Views/MainWindow.axaml
new file mode 100644
index 0000000..dd905e4
--- /dev/null
+++ b/src/App/NewLife.Studio.App/Views/MainWindow.axaml
@@ -0,0 +1,24 @@
+<Window xmlns="https://github.com/avaloniaui"
+ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
+ xmlns:controls="clr-namespace:NewLife.Studio.App.Controls"
+ x:Class="NewLife.Studio.App.Views.MainWindow"
+ Title="NewLife Studio"
+ Width="1280" Height="800"
+ MinWidth="800" MinHeight="500">
+ <Grid RowDefinitions="*,24">
+ <!-- 主区域: NavBar | ModuleHost | AIPanel -->
+ <Grid Grid.Row="0" ColumnDefinitions="48,*,300">
+ <!-- 左侧导航栏 -->
+ <controls:NavBar Grid.Column="0" x:Name="NavBarControl" />
+
+ <!-- 中间模块内容区 -->
+ <controls:ModuleHost Grid.Column="1" x:Name="ModuleHostControl" />
+
+ <!-- 右侧 AI 面板 -->
+ <controls:AIPanel Grid.Column="2" x:Name="AIPanelControl" />
+ </Grid>
+
+ <!-- 底部状态栏 -->
+ <controls:StatusBar Grid.Row="1" x:Name="StatusBarControl" />
+ </Grid>
+</Window>
\ No newline at end of file
diff --git a/src/App/NewLife.Studio.App/Views/MainWindow.axaml.cs b/src/App/NewLife.Studio.App/Views/MainWindow.axaml.cs
new file mode 100644
index 0000000..8602a8e
--- /dev/null
+++ b/src/App/NewLife.Studio.App/Views/MainWindow.axaml.cs
@@ -0,0 +1,57 @@
+using Avalonia.Controls;
+using NewLife.Studio.App.Controls;
+using NewLife.Studio.App.Services;
+using NewLife.Studio.Core;
+using NewLife.Studio.Store;
+using NewLife.Studio.Data;
+
+namespace NewLife.Studio.App.Views;
+
+public partial class MainWindow : Window
+{
+ private readonly ModuleLoader _moduleLoader;
+ private readonly IStoreService _storeService;
+ private readonly IDataProvider _dataProvider;
+
+ public MainWindow() : this(new ModuleLoader(), null!, null!) { }
+
+ public MainWindow(ModuleLoader moduleLoader, IStoreService storeService, IDataProvider dataProvider)
+ {
+ InitializeComponent();
+ _moduleLoader = moduleLoader;
+ _storeService = storeService;
+ _dataProvider = dataProvider;
+
+ _moduleLoader.ModuleActivated += OnModuleActivated;
+ NavBarControl.ModuleSelected += OnNavBarModuleSelected;
+
+ AIPanelControl.Initialize(_storeService, _dataProvider);
+ }
+
+ public async Task InitializeAsync()
+ {
+ _moduleLoader.ScanModules();
+ NavBarControl.SetModules(_moduleLoader.Modules, null);
+ StatusBarControl.SetModule("准备就绪");
+
+ await _moduleLoader.ActivateDefaultAsync();
+
+ if (_moduleLoader.ActiveModule != null)
+ {
+ NavBarControl.SetModules(_moduleLoader.Modules, _moduleLoader.ActiveModule);
+ ModuleHostControl.SetContent(_moduleLoader.ActiveModule.Module.GetView());
+ }
+ }
+
+ private void OnModuleActivated(object? sender, ModuleInfo moduleInfo)
+ {
+ NavBarControl.SetModules(_moduleLoader.Modules, moduleInfo);
+ ModuleHostControl.SetContent(moduleInfo.Module.GetView());
+ StatusBarControl.SetModule(moduleInfo.DisplayName);
+ }
+
+ private async void OnNavBarModuleSelected(object? sender, ModuleInfo moduleInfo)
+ {
+ await _moduleLoader.ActivateModuleAsync(moduleInfo);
+ }
+}
\ No newline at end of file
diff --git a/src/Framework/NewLife.Studio.AI/AIProviderFactory.cs b/src/Framework/NewLife.Studio.AI/AIProviderFactory.cs
new file mode 100644
index 0000000..074c8c2
--- /dev/null
+++ b/src/Framework/NewLife.Studio.AI/AIProviderFactory.cs
@@ -0,0 +1,19 @@
+using NewLife.Studio.AI.Providers;
+
+namespace NewLife.Studio.AI;
+
+/// <summary>AI Provider 工厂</summary>
+public static class AIProviderFactory
+{
+ /// <summary>根据 ProviderType 创建对应的 IAIProvider</summary>
+ public static IAIProvider Create(HttpClient httpClient, string providerType, string endpoint, string apiKey, string model)
+ {
+ return providerType.ToLowerInvariant() switch
+ {
+ "openai" => new OpenAIProvider(httpClient, endpoint, apiKey, model),
+ "azure" => new OpenAIProvider(httpClient, endpoint, apiKey, model), // Azure 也是兼容 API
+ "ollama" => new OpenAIProvider(httpClient, endpoint, apiKey, model), // Ollama 也是兼容 API
+ _ => throw new ArgumentException($"Unknown provider type: {providerType}")
+ };
+ }
+}
\ No newline at end of file
diff --git a/src/Framework/NewLife.Studio.AI/IAIProvider.cs b/src/Framework/NewLife.Studio.AI/IAIProvider.cs
new file mode 100644
index 0000000..4f8ea73
--- /dev/null
+++ b/src/Framework/NewLife.Studio.AI/IAIProvider.cs
@@ -0,0 +1,13 @@
+using NewLife.Studio.AI.Models;
+
+namespace NewLife.Studio.AI;
+
+/// <summary>AI Provider 接口</summary>
+public interface IAIProvider
+{
+ string ProviderName { get; }
+
+ Task<ChatResponse> ChatAsync(
+ ChatRequest request,
+ CancellationToken ct = default);
+}
\ No newline at end of file
diff --git a/src/Framework/NewLife.Studio.AI/Models/ChatModels.cs b/src/Framework/NewLife.Studio.AI/Models/ChatModels.cs
new file mode 100644
index 0000000..2cac3f6
--- /dev/null
+++ b/src/Framework/NewLife.Studio.AI/Models/ChatModels.cs
@@ -0,0 +1,112 @@
+using System.Text.Json.Serialization;
+
+namespace NewLife.Studio.AI.Models;
+
+public class ChatMessage
+{
+ [JsonPropertyName("role")]
+ public string Role { get; set; } = "";
+
+ [JsonPropertyName("content")]
+ public string? Content { get; set; }
+
+ [JsonPropertyName("tool_calls")]
+ public List<ToolCall>? ToolCalls { get; set; }
+
+ [JsonPropertyName("tool_call_id")]
+ public string? ToolCallId { get; set; }
+}
+
+public class ChatRequest
+{
+ [JsonPropertyName("model")]
+ public string Model { get; set; } = "gpt-4o";
+
+ [JsonPropertyName("messages")]
+ public List<ChatMessage> Messages { get; set; } = [];
+
+ [JsonPropertyName("tools")]
+ public List<ToolDefinition>? Tools { get; set; }
+
+ [JsonPropertyName("max_tokens")]
+ public int MaxTokens { get; set; } = 4096;
+
+ [JsonPropertyName("temperature")]
+ public double Temperature { get; set; } = 0.1;
+}
+
+public class ChatResponse
+{
+ [JsonPropertyName("choices")]
+ public List<ChatChoice> Choices { get; set; } = [];
+
+ [JsonPropertyName("usage")]
+ public ChatUsage? Usage { get; set; }
+}
+
+public class ChatChoice
+{
+ [JsonPropertyName("message")]
+ public ChatMessage Message { get; set; } = new();
+
+ [JsonPropertyName("finish_reason")]
+ public string? FinishReason { get; set; }
+}
+
+public class ChatUsage
+{
+ [JsonPropertyName("prompt_tokens")]
+ public int PromptTokens { get; set; }
+
+ [JsonPropertyName("completion_tokens")]
+ public int CompletionTokens { get; set; }
+}
+
+public class ToolCall
+{
+ [JsonPropertyName("id")]
+ public string Id { get; set; } = "";
+
+ [JsonPropertyName("type")]
+ public string Type { get; set; } = "function";
+
+ [JsonPropertyName("function")]
+ public FunctionCall Function { get; set; } = new();
+}
+
+public class FunctionCall
+{
+ [JsonPropertyName("name")]
+ public string Name { get; set; } = "";
+
+ [JsonPropertyName("arguments")]
+ public string Arguments { get; set; } = "";
+}
+
+public class ToolDefinition
+{
+ [JsonPropertyName("type")]
+ public string Type { get; set; } = "function";
+
+ [JsonPropertyName("function")]
+ public FunctionDef Function { get; set; } = new();
+}
+
+public class FunctionDef
+{
+ [JsonPropertyName("name")]
+ public string Name { get; set; } = "";
+
+ [JsonPropertyName("description")]
+ public string Description { get; set; } = "";
+
+ [JsonPropertyName("parameters")]
+ public object? Parameters { get; set; }
+}
+
+public class ToolResult
+{
+ public string ToolCallId { get; set; } = "";
+ public string? Output { get; set; }
+ public string? Error { get; set; }
+}
\ No newline at end of file
diff --git a/src/Framework/NewLife.Studio.AI/NewLife.Studio.AI.csproj b/src/Framework/NewLife.Studio.AI/NewLife.Studio.AI.csproj
new file mode 100644
index 0000000..c6e6ed9
--- /dev/null
+++ b/src/Framework/NewLife.Studio.AI/NewLife.Studio.AI.csproj
@@ -0,0 +1,13 @@
+<Project Sdk="Microsoft.NET.Sdk">
+
+ <PropertyGroup>
+ <TargetFramework>net9.0</TargetFramework>
+ <ImplicitUsings>enable</ImplicitUsings>
+ <Nullable>enable</Nullable>
+ </PropertyGroup>
+
+ <ItemGroup>
+ <ProjectReference Include="..\NewLife.Studio.Core\NewLife.Studio.Core.csproj" />
+ </ItemGroup>
+
+</Project>
diff --git a/src/Framework/NewLife.Studio.AI/Providers/OpenAIProvider.cs b/src/Framework/NewLife.Studio.AI/Providers/OpenAIProvider.cs
new file mode 100644
index 0000000..fdb5d57
--- /dev/null
+++ b/src/Framework/NewLife.Studio.AI/Providers/OpenAIProvider.cs
@@ -0,0 +1,40 @@
+using System.Net.Http.Json;
+using System.Text;
+using System.Text.Json;
+using NewLife.Studio.AI.Models;
+using NewLife.Log;
+
+namespace NewLife.Studio.AI.Providers;
+
+/// <summary>OpenAI-compatible AI Provider</summary>
+public class OpenAIProvider : IAIProvider
+{
+ private readonly HttpClient _httpClient;
+ private readonly string _endpoint;
+ private readonly string _model;
+
+ public string ProviderName => "OpenAI";
+
+ public OpenAIProvider(HttpClient httpClient, string endpoint, string apiKey, string model = "gpt-4o")
+ {
+ _endpoint = endpoint.TrimEnd('/');
+ _model = model;
+ _httpClient = httpClient;
+ _httpClient.DefaultRequestHeaders.Add("Authorization", $"Bearer {apiKey}");
+ }
+
+ public async Task<ChatResponse> ChatAsync(ChatRequest request, CancellationToken ct = default)
+ {
+ request.Model = _model;
+ var url = $"{_endpoint}/chat/completions";
+
+ var json = JsonSerializer.Serialize(request);
+ var content = new StringContent(json, Encoding.UTF8, "application/json");
+
+ var response = await _httpClient.PostAsync(url, content, ct);
+ response.EnsureSuccessStatusCode();
+
+ var result = await response.Content.ReadFromJsonAsync<ChatResponse>(ct);
+ return result ?? new ChatResponse();
+ }
+}
\ No newline at end of file
diff --git a/src/Framework/NewLife.Studio.AI/Safety/QuerySafetyFilter.cs b/src/Framework/NewLife.Studio.AI/Safety/QuerySafetyFilter.cs
new file mode 100644
index 0000000..e4767f1
--- /dev/null
+++ b/src/Framework/NewLife.Studio.AI/Safety/QuerySafetyFilter.cs
@@ -0,0 +1,59 @@
+namespace NewLife.Studio.AI.Safety;
+
+/// <summary>SQL 安全检查 - 仅允许只读查询</summary>
+public static class QuerySafetyFilter
+{
+ private static readonly HashSet<string> AllowedPrefixes = new(StringComparer.OrdinalIgnoreCase)
+ {
+ "SELECT", "EXPLAIN", "PRAGMA", "WITH"
+ };
+
+ private static readonly HashSet<string> BlockedKeywords = new(StringComparer.OrdinalIgnoreCase)
+ {
+ "INSERT", "UPDATE", "DELETE", "DROP", "ALTER", "CREATE",
+ "TRUNCATE", "REPLACE", "MERGE", "GRANT", "REVOKE"
+ };
+
+ /// <summary>检查 SQL 是否安全(只读)</summary>
+ public static (bool IsSafe, string? Reason) Validate(string sql)
+ {
+ var trimmed = sql.Trim();
+
+ // 拒绝多语句
+ if (trimmed.Contains(';'))
+ {
+ return (false, "AI 模式不支持多语句查询");
+ }
+
+ // 检查是否以允许的前缀开头
+ bool hasAllowedPrefix = false;
+ foreach (var prefix in AllowedPrefixes)
+ {
+ if (trimmed.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
+ {
+ hasAllowedPrefix = true;
+ break;
+ }
+ }
+
+ if (!hasAllowedPrefix)
+ {
+ return (false, $"AI 模式仅支持只读查询 (SELECT/EXPLAIN/PRAGMA)");
+ }
+
+ // 检查是否包含被阻止的关键字(在语句中间)
+ var upper = trimmed.ToUpperInvariant();
+ foreach (var keyword in BlockedKeywords)
+ {
+ // 简单检查:关键词前后有空格或位于开头/结尾
+ if (upper.Contains($" {keyword} ") ||
+ upper.StartsWith(keyword + " ") ||
+ upper.EndsWith(" " + keyword))
+ {
+ return (false, $"AI 模式不支持写操作 ({keyword})");
+ }
+ }
+
+ return (true, null);
+ }
+}
\ No newline at end of file
diff --git a/src/Framework/NewLife.Studio.AI/ToolCalling/AIService.cs b/src/Framework/NewLife.Studio.AI/ToolCalling/AIService.cs
new file mode 100644
index 0000000..f4f6e30
--- /dev/null
+++ b/src/Framework/NewLife.Studio.AI/ToolCalling/AIService.cs
@@ -0,0 +1,102 @@
+using System.Text.Json;
+using NewLife.Studio.AI.Models;
+using NewLife.Log;
+
+namespace NewLife.Studio.AI.ToolCalling;
+
+/// <summary>AI 服务 — 管理对话历史和 Tool Calling 循环</summary>
+public class AIService
+{
+ private readonly IAIProvider _provider;
+ private readonly ToolRegistry _toolRegistry;
+ private readonly List<ChatMessage> _history = [];
+ private string? _systemPrompt;
+
+ public AIService(IAIProvider provider, ToolRegistry toolRegistry, string? systemPrompt = null)
+ {
+ _provider = provider;
+ _toolRegistry = toolRegistry;
+ _systemPrompt = systemPrompt;
+
+ if (_systemPrompt != null)
+ {
+ _history.Add(new ChatMessage { Role = "system", Content = _systemPrompt });
+ }
+ }
+
+ public IReadOnlyList<ChatMessage> History => _history.AsReadOnly();
+
+ /// <summary>发送用户消息,执行完整的 Tool Calling 循环</summary>
+ public async Task<string> ChatAsync(string userMessage, Action<ToolCall, ToolResult>? onToolCall = null)
+ {
+ _history.Add(new ChatMessage { Role = "user", Content = userMessage });
+
+ var tools = _toolRegistry.GetAllDefinitions();
+
+ for (int loop = 0; loop < 5; loop++) // 最多 5 轮
+ {
+ var request = new ChatRequest
+ {
+ Messages = [.._history],
+ Tools = tools.Count > 0 ? tools : null
+ };
+
+ var response = await _provider.ChatAsync(request);
+
+ var choice = response.Choices.FirstOrDefault();
+ if (choice == null)
+ break;
+
+ var msg = choice.Message;
+
+ // 检查是否有 tool_calls
+ if (msg.ToolCalls is { Count: > 0 })
+ {
+ // 添加 assistant 消息(带 tool_calls)
+ _history.Add(new ChatMessage
+ {
+ Role = "assistant",
+ Content = msg.Content,
+ ToolCalls = msg.ToolCalls
+ });
+
+ // 执行每个工具调用
+ foreach (var tc in msg.ToolCalls)
+ {
+ XTrace.WriteLine($"AI ToolCall: {tc.Function.Name}({tc.Function.Arguments})");
+ var result = await _toolRegistry.ExecuteAsync(tc);
+ onToolCall?.Invoke(tc, result);
+
+ _history.Add(new ChatMessage
+ {
+ Role = "tool",
+ ToolCallId = tc.Id,
+ Content = result.Error ?? result.Output
+ });
+ }
+
+ continue; // 继续循环,让 AI 处理工具结果
+ }
+
+ // 无 tool_calls,返回内容
+ if (msg.Content != null)
+ {
+ _history.Add(new ChatMessage { Role = "assistant", Content = msg.Content });
+ return msg.Content;
+ }
+
+ break;
+ }
+
+ return "AI 未返回有效响应";
+ }
+
+ public void ClearHistory()
+ {
+ _history.Clear();
+ if (_systemPrompt != null)
+ {
+ _history.Add(new ChatMessage { Role = "system", Content = _systemPrompt });
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/Framework/NewLife.Studio.AI/ToolCalling/BuiltInTools/BuiltInDatabaseTools.cs b/src/Framework/NewLife.Studio.AI/ToolCalling/BuiltInTools/BuiltInDatabaseTools.cs
new file mode 100644
index 0000000..6b5b0c7
--- /dev/null
+++ b/src/Framework/NewLife.Studio.AI/ToolCalling/BuiltInTools/BuiltInDatabaseTools.cs
@@ -0,0 +1,126 @@
+using System.Text.Json;
+using NewLife.Studio.AI.Safety;
+using NewLife.Log;
+
+namespace NewLife.Studio.AI.ToolCalling.BuiltInTools;
+
+/// <summary>内置数据库工具的注册</summary>
+public static class BuiltInDatabaseTools
+{
+ /// <summary>注册所有内置数据库工具到 ToolRegistry</summary>
+ public static void RegisterAll(ToolRegistry registry,
+ Func<Task<string>> listConnections,
+ Func<string, Task<string>> openDatabase,
+ Func<string, Task<string>> listTables,
+ Func<string, Task<string>> describeTable,
+ Func<string, Task<string>> executeSelect,
+ Func<string, int, Task<string>> sampleData)
+ {
+ // connections.list
+ registry.Register(
+ "connections.list",
+ "列出所有已保存的数据库连接(脱敏显示)",
+ new { type = "object", properties = new { } },
+ async _ => await listConnections()
+ );
+
+ // db.open
+ registry.Register(
+ "db.open",
+ "打开指定数据库连接,传入连接名称,返回会话 ID",
+ new
+ {
+ type = "object",
+ properties = new
+ {
+ connection_name = new { type = "string", description = "连接名称" }
+ },
+ required = new[] { "connection_name" }
+ },
+ async args =>
+ {
+ var json = JsonDocument.Parse(args);
+ var name = json.RootElement.GetProperty("connection_name").GetString() ?? "";
+ return await openDatabase(name);
+ }
+ );
+
+ // schema.tables
+ registry.Register(
+ "schema.tables",
+ "列出当前已打开数据库的所有表",
+ new { type = "object", properties = new { } },
+ async _ => await listTables("current")
+ );
+
+ // schema.table
+ registry.Register(
+ "schema.table",
+ "查看指定表的列详情",
+ new
+ {
+ type = "object",
+ properties = new
+ {
+ table_name = new { type = "string", description = "表名" }
+ },
+ required = new[] { "table_name" }
+ },
+ async args =>
+ {
+ var json = JsonDocument.Parse(args);
+ var table = json.RootElement.GetProperty("table_name").GetString() ?? "";
+ return await describeTable(table);
+ }
+ );
+
+ // query.select
+ registry.Register(
+ "query.select",
+ "执行 SELECT 查询(经安全过滤,仅允许只读)",
+ new
+ {
+ type = "object",
+ properties = new
+ {
+ sql = new { type = "string", description = "SELECT 语句" }
+ },
+ required = new[] { "sql" }
+ },
+ async args =>
+ {
+ var json = JsonDocument.Parse(args);
+ var sql = json.RootElement.GetProperty("sql").GetString() ?? "";
+
+ var (isSafe, reason) = QuerySafetyFilter.Validate(sql);
+ if (!isSafe)
+ return $"查询被拒绝: {reason}";
+
+ return await executeSelect(sql);
+ }
+ );
+
+ // query.sample
+ registry.Register(
+ "query.sample",
+ "取表的前 N 行样本数据",
+ new
+ {
+ type = "object",
+ properties = new
+ {
+ table_name = new { type = "string", description = "表名" },
+ limit = new { type = "integer", description = "行数限制,默认 5" }
+ },
+ required = new[] { "table_name" }
+ },
+ async args =>
+ {
+ var json = JsonDocument.Parse(args);
+ var table = json.RootElement.GetProperty("table_name").GetString() ?? "";
+ var limit = json.RootElement.TryGetProperty("limit", out var l) ? l.GetInt32() : 5;
+ return await sampleData(table, limit);
+ }
+ );
+ }
+}
\ No newline at end of file
diff --git a/src/Framework/NewLife.Studio.AI/ToolCalling/ToolRegistry.cs b/src/Framework/NewLife.Studio.AI/ToolCalling/ToolRegistry.cs
new file mode 100644
index 0000000..9e8e08d
--- /dev/null
+++ b/src/Framework/NewLife.Studio.AI/ToolCalling/ToolRegistry.cs
@@ -0,0 +1,71 @@
+using System.Text.Json;
+using NewLife.Studio.AI.Models;
+
+namespace NewLife.Studio.AI.ToolCalling;
+
+/// <summary>工具注册中心</summary>
+public class ToolRegistry
+{
+ private readonly Dictionary<string, ToolHandler> _handlers = new();
+
+ /// <summary>注册工具</summary>
+ public void Register(string name, string description, object? parameters, Func<string, Task<string>> handler)
+ {
+ _handlers[name] = new ToolHandler
+ {
+ Definition = new ToolDefinition
+ {
+ Function = new FunctionDef
+ {
+ Name = name,
+ Description = description,
+ Parameters = parameters
+ }
+ },
+ Handler = handler
+ };
+ }
+
+ /// <summary>获取所有工具定义</summary>
+ public List<ToolDefinition> GetAllDefinitions()
+ {
+ return _handlers.Values.Select(h => h.Definition).ToList();
+ }
+
+ /// <summary>执行工具</summary>
+ public async Task<ToolResult> ExecuteAsync(ToolCall toolCall)
+ {
+ if (!_handlers.TryGetValue(toolCall.Function.Name, out var handler))
+ {
+ return new ToolResult
+ {
+ ToolCallId = toolCall.Id,
+ Error = $"Unknown tool: {toolCall.Function.Name}"
+ };
+ }
+
+ try
+ {
+ var output = await handler.Handler(toolCall.Function.Arguments);
+ return new ToolResult
+ {
+ ToolCallId = toolCall.Id,
+ Output = output
+ };
+ }
+ catch (Exception ex)
+ {
+ return new ToolResult
+ {
+ ToolCallId = toolCall.Id,
+ Error = ex.Message
+ };
+ }
+ }
+
+ private class ToolHandler
+ {
+ public required ToolDefinition Definition { get; init; }
+ public required Func<string, Task<string>> Handler { get; init; }
+ }
+}
\ No newline at end of file
diff --git a/src/Framework/NewLife.Studio.Core/DTOs/AiProfile.cs b/src/Framework/NewLife.Studio.Core/DTOs/AiProfile.cs
new file mode 100644
index 0000000..cc9076e
--- /dev/null
+++ b/src/Framework/NewLife.Studio.Core/DTOs/AiProfile.cs
@@ -0,0 +1,10 @@
+namespace NewLife.Studio.Core.DTOs;
+
+/// <summary>AI 配置</summary>
+public class AiProfile
+{
+ public string ProviderType { get; set; } = "openai";
+ public string Endpoint { get; set; } = "https://api.openai.com/v1";
+ public string ApiKey { get; set; } = "";
+ public string Model { get; set; } = "gpt-4o";
+}
\ No newline at end of file
diff --git a/src/Framework/NewLife.Studio.Core/DTOs/AppPreference.cs b/src/Framework/NewLife.Studio.Core/DTOs/AppPreference.cs
new file mode 100644
index 0000000..4f42032
--- /dev/null
+++ b/src/Framework/NewLife.Studio.Core/DTOs/AppPreference.cs
@@ -0,0 +1,10 @@
+namespace NewLife.Studio.Core.DTOs;
+
+/// <summary>应用偏好</summary>
+public class AppPreference
+{
+ public int MaxRows { get; set; } = 1000;
+ public string DefaultExportPath { get; set; } = "";
+ public string Theme { get; set; } = "Light";
+ public string Language { get; set; } = "zh-CN";
+}
\ No newline at end of file
diff --git a/src/Framework/NewLife.Studio.Core/DTOs/ColumnInfo.cs b/src/Framework/NewLife.Studio.Core/DTOs/ColumnInfo.cs
new file mode 100644
index 0000000..68150ad
--- /dev/null
+++ b/src/Framework/NewLife.Studio.Core/DTOs/ColumnInfo.cs
@@ -0,0 +1,12 @@
+namespace NewLife.Studio.Core.DTOs;
+
+/// <summary>列信息</summary>
+public class ColumnInfo
+{
+ public string Name { get; set; } = "";
+ public string DataType { get; set; } = "";
+ public bool IsNullable { get; set; }
+ public string? DefaultValue { get; set; }
+ public bool IsPrimaryKey { get; set; }
+ public int Ordinal { get; set; }
+}
\ No newline at end of file
diff --git a/src/Framework/NewLife.Studio.Core/DTOs/ConnectionInfo.cs b/src/Framework/NewLife.Studio.Core/DTOs/ConnectionInfo.cs
new file mode 100644
index 0000000..dc6850d
--- /dev/null
+++ b/src/Framework/NewLife.Studio.Core/DTOs/ConnectionInfo.cs
@@ -0,0 +1,12 @@
+namespace NewLife.Studio.Core.DTOs;
+
+/// <summary>连接配置信息</summary>
+public class ConnectionInfo
+{
+ public string Id { get; set; } = Guid.NewGuid().ToString("N");
+ public string Name { get; set; } = "";
+ public string ConnectionString { get; set; } = "";
+ public string ProviderType { get; set; } = "sqlite";
+ public DateTime LastUsedAt { get; set; }
+ public string Group { get; set; } = "";
+}
\ No newline at end of file
diff --git a/src/Framework/NewLife.Studio.Core/DTOs/QueryHistoryEntry.cs b/src/Framework/NewLife.Studio.Core/DTOs/QueryHistoryEntry.cs
new file mode 100644
index 0000000..f8eafb4
--- /dev/null
+++ b/src/Framework/NewLife.Studio.Core/DTOs/QueryHistoryEntry.cs
@@ -0,0 +1,12 @@
+namespace NewLife.Studio.Core.DTOs;
+
+/// <summary>查询历史记录</summary>
+public class QueryHistoryEntry
+{
+ public string Id { get; set; } = Guid.NewGuid().ToString("N");
+ public string Sql { get; set; } = "";
+ public string ConnectionName { get; set; } = "";
+ public DateTime ExecutedAt { get; set; } = DateTime.Now;
+ public long ElapsedMs { get; set; }
+ public int RowCount { get; set; }
+}
\ No newline at end of file
diff --git a/src/Framework/NewLife.Studio.Core/DTOs/QueryRequest.cs b/src/Framework/NewLife.Studio.Core/DTOs/QueryRequest.cs
new file mode 100644
index 0000000..41e6dd5
--- /dev/null
+++ b/src/Framework/NewLife.Studio.Core/DTOs/QueryRequest.cs
@@ -0,0 +1,10 @@
+namespace NewLife.Studio.Core.DTOs;
+
+/// <summary>查询请求</summary>
+public class QueryRequest
+{
+ public string Sql { get; set; } = "";
+ public string ConnectionId { get; set; } = "";
+ public int MaxRows { get; set; } = 1000;
+ public int TimeoutSeconds { get; set; } = 30;
+}
\ No newline at end of file
diff --git a/src/Framework/NewLife.Studio.Core/DTOs/QueryResult.cs b/src/Framework/NewLife.Studio.Core/DTOs/QueryResult.cs
new file mode 100644
index 0000000..b04d0de
--- /dev/null
+++ b/src/Framework/NewLife.Studio.Core/DTOs/QueryResult.cs
@@ -0,0 +1,12 @@
+namespace NewLife.Studio.Core.DTOs;
+
+/// <summary>查询结果</summary>
+public class QueryResult
+{
+ public ColumnInfo[] Columns { get; set; } = [];
+ public List<object?[]> Rows { get; set; } = [];
+ public int RowCount { get; set; }
+ public long ElapsedMs { get; set; }
+ public bool Truncated { get; set; }
+ public string? Error { get; set; }
+}
\ No newline at end of file
diff --git a/src/Framework/NewLife.Studio.Core/DTOs/TableInfo.cs b/src/Framework/NewLife.Studio.Core/DTOs/TableInfo.cs
new file mode 100644
index 0000000..c50153e
--- /dev/null
+++ b/src/Framework/NewLife.Studio.Core/DTOs/TableInfo.cs
@@ -0,0 +1,9 @@
+namespace NewLife.Studio.Core.DTOs;
+
+/// <summary>表信息</summary>
+public class TableInfo
+{
+ public string Name { get; set; } = "";
+ public string Schema { get; set; } = "";
+ public long RowCount { get; set; }
+}
\ No newline at end of file
diff --git a/src/Framework/NewLife.Studio.Core/Exceptions/StudioException.cs b/src/Framework/NewLife.Studio.Core/Exceptions/StudioException.cs
new file mode 100644
index 0000000..250e243
--- /dev/null
+++ b/src/Framework/NewLife.Studio.Core/Exceptions/StudioException.cs
@@ -0,0 +1,8 @@
+namespace NewLife.Studio.Core;
+
+/// <summary>Studio 异常基类</summary>
+public class StudioException : Exception
+{
+ public StudioException(string message) : base(message) { }
+ public StudioException(string message, Exception inner) : base(message, inner) { }
+}
\ No newline at end of file
diff --git a/src/Framework/NewLife.Studio.Core/IStudioModule.cs b/src/Framework/NewLife.Studio.Core/IStudioModule.cs
new file mode 100644
index 0000000..c20a0b2
--- /dev/null
+++ b/src/Framework/NewLife.Studio.Core/IStudioModule.cs
@@ -0,0 +1,28 @@
+using Avalonia.Controls;
+
+namespace NewLife.Studio.Core;
+
+/// <summary>Studio 模块标准接口</summary>
+public interface IStudioModule
+{
+ /// <summary>模块唯一标识</summary>
+ string Id { get; }
+
+ /// <summary>显示名称</summary>
+ string DisplayName { get; }
+
+ /// <summary>导航栏图标</summary>
+ string Icon { get; }
+
+ /// <summary>导航排序(越小越靠前)</summary>
+ int Order { get; }
+
+ /// <summary>模块激活时调用</summary>
+ Task OnActivateAsync(CancellationToken ct = default);
+
+ /// <summary>模块停用时调用</summary>
+ Task OnDeactivateAsync(CancellationToken ct = default);
+
+ /// <summary>获取模块主视图</summary>
+ Control GetView();
+}
\ No newline at end of file
diff --git a/src/Framework/NewLife.Studio.Core/ModuleInfo.cs b/src/Framework/NewLife.Studio.Core/ModuleInfo.cs
new file mode 100644
index 0000000..be971fb
--- /dev/null
+++ b/src/Framework/NewLife.Studio.Core/ModuleInfo.cs
@@ -0,0 +1,11 @@
+namespace NewLife.Studio.Core;
+
+/// <summary>模块元数据</summary>
+public class ModuleInfo
+{
+ public required string Id { get; init; }
+ public required string DisplayName { get; init; }
+ public required string Icon { get; init; }
+ public int Order { get; init; }
+ public required IStudioModule Module { get; init; }
+}
\ No newline at end of file
diff --git a/src/Framework/NewLife.Studio.Core/NewLife.Studio.Core.csproj b/src/Framework/NewLife.Studio.Core/NewLife.Studio.Core.csproj
new file mode 100644
index 0000000..9c64fa4
--- /dev/null
+++ b/src/Framework/NewLife.Studio.Core/NewLife.Studio.Core.csproj
@@ -0,0 +1,14 @@
+<Project Sdk="Microsoft.NET.Sdk">
+
+ <PropertyGroup>
+ <TargetFramework>net9.0</TargetFramework>
+ <ImplicitUsings>enable</ImplicitUsings>
+ <Nullable>enable</Nullable>
+ </PropertyGroup>
+
+ <ItemGroup>
+ <PackageReference Include="Avalonia" Version="12.0.3" />
+ <PackageReference Include="NewLife.Core" Version="10.10.2025.0501" />
+ </ItemGroup>
+
+</Project>
diff --git a/src/Framework/NewLife.Studio.Core/StudioServices.cs b/src/Framework/NewLife.Studio.Core/StudioServices.cs
new file mode 100644
index 0000000..e2df17e
--- /dev/null
+++ b/src/Framework/NewLife.Studio.Core/StudioServices.cs
@@ -0,0 +1,29 @@
+namespace NewLife.Studio.Core;
+
+/// <summary>全局服务定位器,用于模块访问 DI 容器中的服务</summary>
+public static class StudioServices
+{
+ private static IServiceProvider? _serviceProvider;
+
+ /// <summary>初始化服务定位器</summary>
+ public static void Initialize(IServiceProvider serviceProvider)
+ {
+ _serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider));
+ }
+
+ /// <summary>获取服务</summary>
+ public static T? GetService<T>() where T : class
+ {
+ return _serviceProvider?.GetService(typeof(T)) as T;
+ }
+
+ /// <summary>获取必需服务</summary>
+ public static T GetRequiredService<T>() where T : class
+ {
+ if (_serviceProvider == null)
+ throw new InvalidOperationException("ServiceProvider not initialized");
+
+ return (T)_serviceProvider.GetService(typeof(T))
+ ?? throw new InvalidOperationException($"Service {typeof(T).Name} not registered");
+ }
+}
\ No newline at end of file
diff --git a/src/Framework/NewLife.Studio.Store/IStoreService.cs b/src/Framework/NewLife.Studio.Store/IStoreService.cs
new file mode 100644
index 0000000..7432590
--- /dev/null
+++ b/src/Framework/NewLife.Studio.Store/IStoreService.cs
@@ -0,0 +1,24 @@
+using NewLife.Studio.Core.DTOs;
+
+namespace NewLife.Studio.Store;
+
+/// <summary>本地存储服务接口</summary>
+public interface IStoreService
+{
+ // 连接管理
+ Task<List<ConnectionInfo>> ListConnectionsAsync();
+ Task SaveConnectionAsync(ConnectionInfo conn);
+ Task DeleteConnectionAsync(string id);
+
+ // 查询历史
+ Task AddQueryHistoryAsync(QueryHistoryEntry entry);
+ Task<List<QueryHistoryEntry>> GetRecentQueriesAsync(int count = 50);
+
+ // AI 配置
+ Task<AiProfile?> GetAiProfileAsync();
+ Task SaveAiProfileAsync(AiProfile profile);
+
+ // 应用偏好
+ Task<AppPreference> GetPreferencesAsync();
+ Task SavePreferencesAsync(AppPreference pref);
+}
\ No newline at end of file
diff --git a/src/Framework/NewLife.Studio.Store/Models/StoredAiProfile.cs b/src/Framework/NewLife.Studio.Store/Models/StoredAiProfile.cs
new file mode 100644
index 0000000..8d1a33e
--- /dev/null
+++ b/src/Framework/NewLife.Studio.Store/Models/StoredAiProfile.cs
@@ -0,0 +1,10 @@
+namespace NewLife.Studio.Store.Models;
+
+/// <summary>持久化 AI 配置</summary>
+public class StoredAiProfile
+{
+ public string ProviderType { get; set; } = "openai";
+ public string Endpoint { get; set; } = "https://api.openai.com/v1";
+ public string ApiKey { get; set; } = "";
+ public string Model { get; set; } = "gpt-4o";
+}
\ No newline at end of file
diff --git a/src/Framework/NewLife.Studio.Store/Models/StoredAppPreference.cs b/src/Framework/NewLife.Studio.Store/Models/StoredAppPreference.cs
new file mode 100644
index 0000000..78bb9cb
--- /dev/null
+++ b/src/Framework/NewLife.Studio.Store/Models/StoredAppPreference.cs
@@ -0,0 +1,10 @@
+namespace NewLife.Studio.Store.Models;
+
+/// <summary>持久化应用偏好</summary>
+public class StoredAppPreference
+{
+ public int MaxRows { get; set; } = 1000;
+ public string DefaultExportPath { get; set; } = "";
+ public string Theme { get; set; } = "Light";
+ public string Language { get; set; } = "zh-CN";
+}
\ No newline at end of file
diff --git a/src/Framework/NewLife.Studio.Store/Models/StoredConnection.cs b/src/Framework/NewLife.Studio.Store/Models/StoredConnection.cs
new file mode 100644
index 0000000..3f17040
--- /dev/null
+++ b/src/Framework/NewLife.Studio.Store/Models/StoredConnection.cs
@@ -0,0 +1,40 @@
+using NewLife.Studio.Core.DTOs;
+
+namespace NewLife.Studio.Store.Models;
+
+/// <summary>持久化连接格式</summary>
+public class StoredConnection
+{
+ public string Id { get; set; } = "";
+ public string Name { get; set; } = "";
+ public string ConnectionString { get; set; } = "";
+ public string ProviderType { get; set; } = "sqlite";
+ public DateTime LastUsedAt { get; set; }
+ public string Group { get; set; } = "";
+
+ public ConnectionInfo ToDto()
+ {
+ return new ConnectionInfo
+ {
+ Id = Id,
+ Name = Name,
+ ConnectionString = ConnectionString,
+ ProviderType = ProviderType,
+ LastUsedAt = LastUsedAt,
+ Group = Group
+ };
+ }
+
+ public static StoredConnection FromDto(ConnectionInfo dto)
+ {
+ return new StoredConnection
+ {
+ Id = dto.Id,
+ Name = dto.Name,
+ ConnectionString = dto.ConnectionString,
+ ProviderType = dto.ProviderType,
+ LastUsedAt = dto.LastUsedAt,
+ Group = dto.Group
+ };
+ }
+}
\ No newline at end of file
diff --git a/src/Framework/NewLife.Studio.Store/Models/StoredQueryHistory.cs b/src/Framework/NewLife.Studio.Store/Models/StoredQueryHistory.cs
new file mode 100644
index 0000000..b63c02c
--- /dev/null
+++ b/src/Framework/NewLife.Studio.Store/Models/StoredQueryHistory.cs
@@ -0,0 +1,9 @@
+using NewLife.Studio.Core.DTOs;
+
+namespace NewLife.Studio.Store.Models;
+
+/// <summary>持久化查询历史</summary>
+public class StoredQueryHistory
+{
+ public List<QueryHistoryEntry> Entries { get; set; } = [];
+}
\ No newline at end of file
diff --git a/src/Framework/NewLife.Studio.Store/NewLife.Studio.Store.csproj b/src/Framework/NewLife.Studio.Store/NewLife.Studio.Store.csproj
new file mode 100644
index 0000000..8087d97
--- /dev/null
+++ b/src/Framework/NewLife.Studio.Store/NewLife.Studio.Store.csproj
@@ -0,0 +1,17 @@
+<Project Sdk="Microsoft.NET.Sdk">
+
+ <PropertyGroup>
+ <TargetFramework>net9.0</TargetFramework>
+ <ImplicitUsings>enable</ImplicitUsings>
+ <Nullable>enable</Nullable>
+ </PropertyGroup>
+
+ <ItemGroup>
+ <InternalsVisibleTo Include="NewLife.Studio.Store.Tests" />
+ </ItemGroup>
+
+ <ItemGroup>
+ <ProjectReference Include="..\NewLife.Studio.Core\NewLife.Studio.Core.csproj" />
+ </ItemGroup>
+
+</Project>
diff --git a/src/Framework/NewLife.Studio.Store/SecretProtection.cs b/src/Framework/NewLife.Studio.Store/SecretProtection.cs
new file mode 100644
index 0000000..2ddfe38
--- /dev/null
+++ b/src/Framework/NewLife.Studio.Store/SecretProtection.cs
@@ -0,0 +1,58 @@
+using System.Security.Cryptography;
+using System.Text;
+
+namespace NewLife.Studio.Store;
+
+/// <summary>敏感信息加密保护(AES)</summary>
+public class SecretProtection
+{
+ private readonly byte[] _key;
+ private readonly byte[] _iv;
+
+ public SecretProtection()
+ {
+ // 使用机器相关的固定熵生成密钥
+ var entropy = Encoding.UTF8.GetBytes(
+ Environment.MachineName + Environment.UserName + "NewLife.Studio");
+ _key = SHA256.HashData(entropy);
+ _iv = MD5.HashData(entropy);
+ }
+
+ public string Protect(string plain)
+ {
+ if (string.IsNullOrEmpty(plain))
+ return "";
+
+ using var aes = Aes.Create();
+ aes.Key = _key;
+ aes.IV = _iv;
+
+ using var encryptor = aes.CreateEncryptor();
+ var plainBytes = Encoding.UTF8.GetBytes(plain);
+ var cipherBytes = encryptor.TransformFinalBlock(plainBytes, 0, plainBytes.Length);
+ return Convert.ToBase64String(cipherBytes);
+ }
+
+ public string Unprotect(string cipher)
+ {
+ if (string.IsNullOrEmpty(cipher))
+ return "";
+
+ try
+ {
+ using var aes = Aes.Create();
+ aes.Key = _key;
+ aes.IV = _iv;
+
+ using var decryptor = aes.CreateDecryptor();
+ var cipherBytes = Convert.FromBase64String(cipher);
+ var plainBytes = decryptor.TransformFinalBlock(cipherBytes, 0, cipherBytes.Length);
+ return Encoding.UTF8.GetString(plainBytes);
+ }
+ catch
+ {
+ // 解密失败返回原文(可能是旧格式未加密的数据)
+ return cipher;
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/Framework/NewLife.Studio.Store/StoreService.cs b/src/Framework/NewLife.Studio.Store/StoreService.cs
new file mode 100644
index 0000000..01b68be
--- /dev/null
+++ b/src/Framework/NewLife.Studio.Store/StoreService.cs
@@ -0,0 +1,262 @@
+using System.Text.Json;
+using NewLife.Studio.Core.DTOs;
+using NewLife.Studio.Store.Models;
+using NewLife.Log;
+
+namespace NewLife.Studio.Store;
+
+/// <summary>基于 JSON 文件的本地存储服务实现</summary>
+public class StoreService : IStoreService
+{
+ private readonly string _basePath;
+ private readonly SecretProtection _protection;
+ private readonly JsonSerializerOptions _jsonOptions;
+
+ private readonly string _connectionsFile;
+ private readonly string _historyFile;
+ private readonly string _aiProfileFile;
+ private readonly string _preferencesFile;
+
+ private readonly SemaphoreSlim _lock = new(1, 1);
+
+ public StoreService() : this(new SecretProtection()) { }
+
+ public StoreService(SecretProtection protection)
+ : this(
+ Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
+ "NewLife.Studio"),
+ protection)
+ {
+ }
+
+ internal StoreService(string basePath, SecretProtection protection)
+ {
+ _protection = protection;
+ _jsonOptions = new JsonSerializerOptions
+ {
+ WriteIndented = true,
+ PropertyNamingPolicy = JsonNamingPolicy.CamelCase
+ };
+
+ _basePath = basePath;
+
+ Directory.CreateDirectory(_basePath);
+
+ _connectionsFile = Path.Combine(_basePath, "connections.json");
+ _historyFile = Path.Combine(_basePath, "history.json");
+ _aiProfileFile = Path.Combine(_basePath, "ai_profile.json");
+ _preferencesFile = Path.Combine(_basePath, "preferences.json");
+ }
+
+ // ========== 连接管理 ==========
+
+ public async Task<List<ConnectionInfo>> ListConnectionsAsync()
+ {
+ await _lock.WaitAsync();
+ try
+ {
+ var stored = await LoadAsync<List<StoredConnection>>(_connectionsFile) ?? [];
+ return stored.Select(s =>
+ {
+ var dto = s.ToDto();
+ // 解密密码字段
+ dto.ConnectionString = _protection.Unprotect(dto.ConnectionString);
+ return dto;
+ }).ToList();
+ }
+ finally
+ {
+ _lock.Release();
+ }
+ }
+
+ public async Task SaveConnectionAsync(ConnectionInfo conn)
+ {
+ await _lock.WaitAsync();
+ try
+ {
+ var stored = await LoadAsync<List<StoredConnection>>(_connectionsFile) ?? [];
+
+ var existing = stored.FirstOrDefault(c => c.Id == conn.Id);
+ if (existing != null)
+ {
+ stored.Remove(existing);
+ }
+
+ // 加密存储
+ var toSave = StoredConnection.FromDto(conn);
+ toSave.ConnectionString = _protection.Protect(conn.ConnectionString);
+ stored.Add(toSave);
+
+ await SaveAsync(_connectionsFile, stored);
+ XTrace.WriteLine($"Store: Saved connection [{conn.Name}] ({conn.Id})");
+ }
+ finally
+ {
+ _lock.Release();
+ }
+ }
+
+ public async Task DeleteConnectionAsync(string id)
+ {
+ await _lock.WaitAsync();
+ try
+ {
+ var stored = await LoadAsync<List<StoredConnection>>(_connectionsFile) ?? [];
+ stored.RemoveAll(c => c.Id == id);
+ await SaveAsync(_connectionsFile, stored);
+ XTrace.WriteLine($"Store: Deleted connection {id}");
+ }
+ finally
+ {
+ _lock.Release();
+ }
+ }
+
+ // ========== 查询历史 ==========
+
+ public async Task AddQueryHistoryAsync(QueryHistoryEntry entry)
+ {
+ await _lock.WaitAsync();
+ try
+ {
+ var stored = await LoadAsync<StoredQueryHistory>(_historyFile) ?? new StoredQueryHistory();
+ stored.Entries.Insert(0, entry);
+
+ // 最多保留 500 条
+ if (stored.Entries.Count > 500)
+ {
+ stored.Entries = stored.Entries.Take(500).ToList();
+ }
+
+ await SaveAsync(_historyFile, stored);
+ }
+ finally
+ {
+ _lock.Release();
+ }
+ }
+
+ public async Task<List<QueryHistoryEntry>> GetRecentQueriesAsync(int count = 50)
+ {
+ await _lock.WaitAsync();
+ try
+ {
+ var stored = await LoadAsync<StoredQueryHistory>(_historyFile);
+ return stored?.Entries.Take(count).ToList() ?? [];
+ }
+ finally
+ {
+ _lock.Release();
+ }
+ }
+
+ // ========== AI 配置 ==========
+
+ public async Task<AiProfile?> GetAiProfileAsync()
+ {
+ await _lock.WaitAsync();
+ try
+ {
+ var stored = await LoadAsync<StoredAiProfile>(_aiProfileFile);
+ if (stored == null)
+ return null;
+
+ return new AiProfile
+ {
+ ProviderType = stored.ProviderType,
+ Endpoint = stored.Endpoint,
+ ApiKey = _protection.Unprotect(stored.ApiKey),
+ Model = stored.Model
+ };
+ }
+ finally
+ {
+ _lock.Release();
+ }
+ }
+
+ public async Task SaveAiProfileAsync(AiProfile profile)
+ {
+ await _lock.WaitAsync();
+ try
+ {
+ var stored = new StoredAiProfile
+ {
+ ProviderType = profile.ProviderType,
+ Endpoint = profile.Endpoint,
+ ApiKey = _protection.Protect(profile.ApiKey),
+ Model = profile.Model
+ };
+ await SaveAsync(_aiProfileFile, stored);
+ XTrace.WriteLine("Store: Saved AI profile");
+ }
+ finally
+ {
+ _lock.Release();
+ }
+ }
+
+ // ========== 应用偏好 ==========
+
+ public async Task<AppPreference> GetPreferencesAsync()
+ {
+ await _lock.WaitAsync();
+ try
+ {
+ var stored = await LoadAsync<StoredAppPreference>(_preferencesFile);
+ if (stored == null)
+ return new AppPreference();
+
+ return new AppPreference
+ {
+ MaxRows = stored.MaxRows,
+ DefaultExportPath = stored.DefaultExportPath,
+ Theme = stored.Theme,
+ Language = stored.Language
+ };
+ }
+ finally
+ {
+ _lock.Release();
+ }
+ }
+
+ public async Task SavePreferencesAsync(AppPreference pref)
+ {
+ await _lock.WaitAsync();
+ try
+ {
+ await SaveAsync(_preferencesFile, new StoredAppPreference
+ {
+ MaxRows = pref.MaxRows,
+ DefaultExportPath = pref.DefaultExportPath,
+ Theme = pref.Theme,
+ Language = pref.Language
+ });
+ XTrace.WriteLine("Store: Saved preferences");
+ }
+ finally
+ {
+ _lock.Release();
+ }
+ }
+
+ // ========== 底层 JSON 读写 ==========
+
+ private async Task<T?> LoadAsync<T>(string filePath) where T : class
+ {
+ if (!File.Exists(filePath))
+ return null;
+
+ var json = await File.ReadAllTextAsync(filePath);
+ return JsonSerializer.Deserialize<T>(json, _jsonOptions);
+ }
+
+ private async Task SaveAsync<T>(string filePath, T data)
+ {
+ var json = JsonSerializer.Serialize(data, _jsonOptions);
+ await File.WriteAllTextAsync(filePath, json);
+ }
+}
\ No newline at end of file
diff --git a/src/Modules/DataStudio/DataStudio.csproj b/src/Modules/DataStudio/DataStudio.csproj
new file mode 100644
index 0000000..9ed4db5
--- /dev/null
+++ b/src/Modules/DataStudio/DataStudio.csproj
@@ -0,0 +1,24 @@
+<Project Sdk="Microsoft.NET.Sdk">
+
+ <PropertyGroup>
+ <TargetFramework>net9.0</TargetFramework>
+ <ImplicitUsings>enable</ImplicitUsings>
+ <Nullable>enable</Nullable>
+ <RootNamespace>NewLife.Studio.Modules.DataStudio</RootNamespace>
+ <AssemblyName>NewLife.Studio.Modules.DataStudio</AssemblyName>
+ <AvaloniaUseCompiledBindingsByDefault>false</AvaloniaUseCompiledBindingsByDefault>
+ </PropertyGroup>
+
+ <ItemGroup>
+ <ProjectReference Include="..\..\Framework\NewLife.Studio.Core\NewLife.Studio.Core.csproj" />
+ <ProjectReference Include="..\..\Framework\NewLife.Studio.Store\NewLife.Studio.Store.csproj" />
+ <ProjectReference Include="..\..\Providers\NewLife.Studio.Data\NewLife.Studio.Data.csproj" />
+ </ItemGroup>
+
+ <ItemGroup>
+ <PackageReference Include="Avalonia" Version="12.0.3" />
+ <PackageReference Include="Avalonia.Controls.DataGrid" Version="12.0.0" />
+ <PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.1" />
+ </ItemGroup>
+
+</Project>
diff --git a/src/Modules/DataStudio/DataStudioModule.cs b/src/Modules/DataStudio/DataStudioModule.cs
new file mode 100644
index 0000000..b13636d
--- /dev/null
+++ b/src/Modules/DataStudio/DataStudioModule.cs
@@ -0,0 +1,31 @@
+using Avalonia.Controls;
+using NewLife.Studio.Core;
+using NewLife.Studio.Modules.DataStudio.Views;
+
+namespace NewLife.Studio.Modules.DataStudio;
+
+/// <summary>Data Studio 模块 — 数据库管理</summary>
+public class DataStudioModule : IStudioModule
+{
+ public string Id => "data-studio";
+ public string DisplayName => "数据管理";
+ public string Icon => "database";
+ public int Order => 0;
+
+ public Task OnActivateAsync(CancellationToken ct = default)
+ {
+ NewLife.Log.XTrace.WriteLine("DataStudio: Activated");
+ return Task.CompletedTask;
+ }
+
+ public Task OnDeactivateAsync(CancellationToken ct = default)
+ {
+ NewLife.Log.XTrace.WriteLine("DataStudio: Deactivated");
+ return Task.CompletedTask;
+ }
+
+ public Control GetView()
+ {
+ return new DataStudioView();
+ }
+}
\ No newline at end of file
diff --git a/src/Modules/DataStudio/ViewModels/ConnectionListViewModel.cs b/src/Modules/DataStudio/ViewModels/ConnectionListViewModel.cs
new file mode 100644
index 0000000..4b2ae2b
--- /dev/null
+++ b/src/Modules/DataStudio/ViewModels/ConnectionListViewModel.cs
@@ -0,0 +1,96 @@
+using System.Collections.ObjectModel;
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using NewLife.Studio.Core.DTOs;
+using NewLife.Studio.Data;
+using NewLife.Studio.Store;
+using NewLife.Log;
+
+namespace NewLife.Studio.Modules.DataStudio.ViewModels;
+
+/// <summary>连接列表 ViewModel</summary>
+public partial class ConnectionListViewModel : ObservableObject
+{
+ private readonly IStoreService _storeService;
+ private readonly IDataProvider _dataProvider;
+
+ [ObservableProperty]
+ private ObservableCollection<ConnectionInfo> _connections = [];
+
+ [ObservableProperty]
+ private ConnectionInfo? _selectedConnection;
+
+ public event EventHandler<IDbSession>? ConnectionOpened;
+
+ public ConnectionListViewModel(IStoreService storeService, IDataProvider dataProvider)
+ {
+ _storeService = storeService;
+ _dataProvider = dataProvider;
+ }
+
+ public async Task LoadAsync()
+ {
+ var list = await _storeService.ListConnectionsAsync();
+ Connections = new ObservableCollection<ConnectionInfo>(list);
+ XTrace.WriteLine($"ConnectionList: Loaded {Connections.Count} connections");
+ }
+
+ [RelayCommand]
+ private async Task AddConnection()
+ {
+ // 简单实现:添加一个 SQLite 文件连接
+ var conn = new ConnectionInfo
+ {
+ Name = $"SQLite-{DateTime.Now:yyyyMMdd-HHmmss}",
+ ConnectionString = "Data Source=:memory:",
+ ProviderType = "sqlite"
+ };
+ await _storeService.SaveConnectionAsync(conn);
+ Connections.Add(conn);
+ SelectedConnection = conn;
+ }
+
+ [RelayCommand]
+ private async Task EditConnection()
+ {
+ if (SelectedConnection == null) return;
+ await _storeService.SaveConnectionAsync(SelectedConnection);
+ }
+
+ [RelayCommand]
+ private async Task DeleteConnection()
+ {
+ if (SelectedConnection == null) return;
+ await _storeService.DeleteConnectionAsync(SelectedConnection.Id);
+ Connections.Remove(SelectedConnection);
+ SelectedConnection = null;
+ }
+
+ [RelayCommand]
+ private async Task TestConnection()
+ {
+ if (SelectedConnection == null) return;
+ var ok = await _dataProvider.TestConnectionAsync(SelectedConnection);
+ var msg = ok ? "连接测试成功" : "连接测试失败";
+ XTrace.WriteLine($"TestConnection: {SelectedConnection.Name} -> {msg}");
+ }
+
+ [RelayCommand]
+ private async Task OpenConnection()
+ {
+ if (SelectedConnection == null) return;
+
+ try
+ {
+ var session = await _dataProvider.OpenSessionAsync(SelectedConnection);
+ SelectedConnection.LastUsedAt = DateTime.Now;
+ await _storeService.SaveConnectionAsync(SelectedConnection);
+ ConnectionOpened?.Invoke(this, session);
+ XTrace.WriteLine($"ConnectionList: Opened {SelectedConnection.Name}");
+ }
+ catch (Exception ex)
+ {
+ XTrace.WriteLine($"ConnectionList: Failed to open {SelectedConnection.Name}: {ex.Message}");
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/Modules/DataStudio/ViewModels/DataStudioViewModel.cs b/src/Modules/DataStudio/ViewModels/DataStudioViewModel.cs
new file mode 100644
index 0000000..b4f1f5e
--- /dev/null
+++ b/src/Modules/DataStudio/ViewModels/DataStudioViewModel.cs
@@ -0,0 +1,35 @@
+using CommunityToolkit.Mvvm.ComponentModel;
+using NewLife.Studio.Core.DTOs;
+using NewLife.Studio.Data;
+using NewLife.Studio.Store;
+using NewLife.Log;
+
+namespace NewLife.Studio.Modules.DataStudio.ViewModels;
+
+/// <summary>数据管理主 ViewModel</summary>
+public partial class DataStudioViewModel : ObservableObject
+{
+ public ConnectionListViewModel ConnectionList { get; }
+ public ObjectTreeViewModel ObjectTree { get; }
+ public SqlEditorViewModel SqlEditor { get; }
+
+ [ObservableProperty]
+ private IDbSession? _activeSession;
+
+ public DataStudioViewModel(IStoreService storeService, IDataProvider dataProvider)
+ {
+ ConnectionList = new ConnectionListViewModel(storeService, dataProvider);
+ ObjectTree = new ObjectTreeViewModel();
+ SqlEditor = new SqlEditorViewModel(storeService);
+
+ ConnectionList.ConnectionOpened += OnConnectionOpened;
+ }
+
+ private void OnConnectionOpened(object? sender, IDbSession session)
+ {
+ ActiveSession = session;
+ ObjectTree.SetSession(session);
+ SqlEditor.SetSession(session);
+ XTrace.WriteLine($"DataStudio: Connection opened {session.SessionId}");
+ }
+}
\ No newline at end of file
diff --git a/src/Modules/DataStudio/ViewModels/ObjectTreeViewModel.cs b/src/Modules/DataStudio/ViewModels/ObjectTreeViewModel.cs
new file mode 100644
index 0000000..c012442
--- /dev/null
+++ b/src/Modules/DataStudio/ViewModels/ObjectTreeViewModel.cs
@@ -0,0 +1,111 @@
+using System.Collections.ObjectModel;
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using NewLife.Studio.Core.DTOs;
+using NewLife.Studio.Data;
+using NewLife.Log;
+
+namespace NewLife.Studio.Modules.DataStudio.ViewModels;
+
+/// <summary>对象树节点</summary>
+public partial class TreeNode : ObservableObject
+{
+ [ObservableProperty]
+ private string _name = "";
+
+ [ObservableProperty]
+ private string _nodeType = ""; // connection, table, column
+
+ [ObservableProperty]
+ private ObservableCollection<TreeNode>? _children;
+
+ [ObservableProperty]
+ private bool _isExpanded;
+
+ public string? TableName { get; set; }
+ public string? DataType { get; set; } // for columns
+ public bool IsPrimaryKey { get; set; }
+}
+
+/// <summary>对象树 ViewModel</summary>
+public partial class ObjectTreeViewModel : ObservableObject
+{
+ private IDbSession? _session;
+
+ [ObservableProperty]
+ private ObservableCollection<TreeNode> _nodes = [];
+
+ public event EventHandler<string>? TableSelected;
+
+ public void SetSession(IDbSession session)
+ {
+ _session = session;
+ _ = LoadTablesAsync();
+ }
+
+ [RelayCommand]
+ private async Task LoadTablesAsync()
+ {
+ if (_session == null) return;
+
+ Nodes.Clear();
+ var tables = await _session.GetTablesAsync();
+
+ foreach (var table in tables)
+ {
+ var node = new TreeNode
+ {
+ Name = table.Name,
+ NodeType = "table",
+ TableName = table.Name,
+ Children = []
+ };
+ // 添加占位节点以支持展开
+ node.Children.Add(new TreeNode { Name = "加载中...", NodeType = "placeholder" });
+ Nodes.Add(node);
+ }
+
+ XTrace.WriteLine($"ObjectTree: Loaded {tables.Length} tables");
+ }
+
+ [RelayCommand]
+ private async Task ExpandTableAsync(TreeNode? node)
+ {
+ if (node == null || _session == null || node.NodeType != "table")
+ return;
+
+ if (node.Children?.Count > 0 && node.Children[0].NodeType != "placeholder")
+ return; // 已加载
+
+ try
+ {
+ var columns = await _session.GetColumnsAsync(node.Name);
+ node.Children = [];
+ foreach (var col in columns)
+ {
+ var pk = col.IsPrimaryKey ? " (PK)" : "";
+ var nullable = col.IsNullable ? ", nullable" : ", not null";
+ node.Children.Add(new TreeNode
+ {
+ Name = $"{col.Name}{pk}",
+ NodeType = "column",
+ DataType = col.DataType + nullable,
+ IsPrimaryKey = col.IsPrimaryKey,
+ Children = null
+ });
+ }
+
+ TableSelected?.Invoke(this, node.Name);
+ XTrace.WriteLine($"ObjectTree: Expanded table {node.Name}, {columns.Length} columns");
+ }
+ catch (Exception ex)
+ {
+ XTrace.WriteLine($"ObjectTree: Failed to expand {node.Name}: {ex.Message}");
+ }
+ }
+
+ public void SelectTable(string tableName)
+ {
+ TableSelected?.Invoke(this, tableName);
+ }
+}
\ No newline at end of file
diff --git a/src/Modules/DataStudio/ViewModels/ResultGridViewModel.cs b/src/Modules/DataStudio/ViewModels/ResultGridViewModel.cs
new file mode 100644
index 0000000..6ac1984
--- /dev/null
+++ b/src/Modules/DataStudio/ViewModels/ResultGridViewModel.cs
@@ -0,0 +1,107 @@
+using System.Collections.ObjectModel;
+using System.Text;
+using System.Text.Json;
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using NewLife.Studio.Core.DTOs;
+
+namespace NewLife.Studio.Modules.DataStudio.ViewModels;
+
+/// <summary>结果网格 ViewModel</summary>
+public partial class ResultGridViewModel : ObservableObject
+{
+ [ObservableProperty]
+ private ObservableCollection<ColumnInfo> _columns = [];
+
+ [ObservableProperty]
+ private ObservableCollection<object?[]> _rows = [];
+
+ [ObservableProperty]
+ private long _elapsedMs;
+
+ [ObservableProperty]
+ private int _rowCount;
+
+ [ObservableProperty]
+ private bool _isTruncated;
+
+ [ObservableProperty]
+ private string? _error;
+
+ public string TruncatedWarning => IsTruncated ? "(结果已裁剪)" : "";
+ public string ElapsedText => $"耗时: {ElapsedMs}ms";
+ public string RowCountText => $"行数: {RowCount}";
+ public bool HasError => !string.IsNullOrEmpty(Error);
+ public bool HasRows => Rows.Count > 0 && Columns.Count > 0;
+
+ public void SetResult(QueryResult result)
+ {
+ Columns = new ObservableCollection<ColumnInfo>(result.Columns);
+ Rows = new ObservableCollection<object?[]>(result.Rows);
+ ElapsedMs = result.ElapsedMs;
+ RowCount = result.RowCount;
+ IsTruncated = result.Truncated;
+ Error = result.Error;
+
+ OnPropertyChanged(nameof(TruncatedWarning));
+ OnPropertyChanged(nameof(ElapsedText));
+ OnPropertyChanged(nameof(RowCountText));
+ OnPropertyChanged(nameof(HasError));
+ OnPropertyChanged(nameof(HasRows));
+ }
+
+ public async Task ExportCsvAsync(string filePath)
+ {
+ var sb = new StringBuilder();
+
+ // 写入 UTF-8 BOM 表头
+ var columnNames = Columns.Select(c => EscapeCsvField(c.Name)).ToList();
+ sb.AppendLine(string.Join(",", columnNames));
+
+ // 写入数据行
+ foreach (var row in Rows)
+ {
+ var fields = new List<string>();
+ for (int i = 0; i < Columns.Count; i++)
+ {
+ var val = i < row.Length ? row[i]?.ToString() ?? "" : "";
+ fields.Add(EscapeCsvField(val));
+ }
+ sb.AppendLine(string.Join(",", fields));
+ }
+
+ await File.WriteAllTextAsync(filePath, sb.ToString(), Encoding.UTF8);
+ }
+
+ public async Task ExportJsonAsync(string filePath)
+ {
+ var list = new List<Dictionary<string, object?>>();
+ foreach (var row in Rows)
+ {
+ var dict = new Dictionary<string, object?>();
+ for (int i = 0; i < Columns.Count; i++)
+ {
+ var val = i < row.Length ? row[i] : null;
+ dict[Columns[i].Name] = val;
+ }
+ list.Add(dict);
+ }
+
+ var json = JsonSerializer.Serialize(list, new JsonSerializerOptions
+ {
+ WriteIndented = true,
+ PropertyNamingPolicy = JsonNamingPolicy.CamelCase
+ });
+
+ await File.WriteAllTextAsync(filePath, json, Encoding.UTF8);
+ }
+
+ private static string EscapeCsvField(string field)
+ {
+ if (field.Contains(',') || field.Contains('"') || field.Contains('\n') || field.Contains('\r'))
+ {
+ return $"\"{field.Replace("\"", "\"\"")}\"";
+ }
+ return field;
+ }
+}
\ No newline at end of file
diff --git a/src/Modules/DataStudio/ViewModels/SqlEditorViewModel.cs b/src/Modules/DataStudio/ViewModels/SqlEditorViewModel.cs
new file mode 100644
index 0000000..7ef2a70
--- /dev/null
+++ b/src/Modules/DataStudio/ViewModels/SqlEditorViewModel.cs
@@ -0,0 +1,106 @@
+using System.Collections.ObjectModel;
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using NewLife.Studio.Core.DTOs;
+using NewLife.Studio.Data;
+using NewLife.Studio.Store;
+using NewLife.Log;
+
+namespace NewLife.Studio.Modules.DataStudio.ViewModels;
+
+/// <summary>查询 Tab</summary>
+public partial class QueryTab : ObservableObject
+{
+ [ObservableProperty]
+ private string _sql = "SELECT * FROM ";
+
+ [ObservableProperty]
+ private QueryResult? _result;
+
+ [ObservableProperty]
+ private string _title = "查询 1";
+
+ public ResultGridViewModel ResultGrid { get; } = new();
+
+ partial void OnResultChanged(QueryResult? value)
+ {
+ if (value != null)
+ {
+ ResultGrid.SetResult(value);
+ }
+ }
+}
+
+/// <summary>SQL 编辑器 ViewModel</summary>
+public partial class SqlEditorViewModel : ObservableObject
+{
+ private readonly IStoreService _storeService;
+ private IDbSession? _session;
+
+ [ObservableProperty]
+ private ObservableCollection<QueryTab> _tabs = [];
+
+ [ObservableProperty]
+ private QueryTab? _activeTab;
+
+ private int _tabCounter;
+
+ public SqlEditorViewModel(IStoreService storeService)
+ {
+ _storeService = storeService;
+ }
+
+ public void SetSession(IDbSession session)
+ {
+ _session = session;
+ }
+
+ [RelayCommand]
+ private void NewTab()
+ {
+ _tabCounter++;
+ var tab = new QueryTab
+ {
+ Title = $"查询 {_tabCounter}"
+ };
+ Tabs.Add(tab);
+ ActiveTab = tab;
+ }
+
+ [RelayCommand]
+ private void CloseTab(QueryTab? tab)
+ {
+ if (tab != null)
+ {
+ Tabs.Remove(tab);
+ }
+ }
+
+ [RelayCommand]
+ private async Task ExecuteAsync()
+ {
+ if (ActiveTab == null || _session == null)
+ return;
+
+ var request = new QueryRequest
+ {
+ Sql = ActiveTab.Sql,
+ MaxRows = 1000,
+ TimeoutSeconds = 30
+ };
+
+ var result = await _session.ExecuteQueryAsync(request);
+ ActiveTab.Result = result;
+
+ // 写入历史
+ _ = _storeService.AddQueryHistoryAsync(new QueryHistoryEntry
+ {
+ Sql = request.Sql,
+ ConnectionName = _session.Connection.Name,
+ ElapsedMs = result.ElapsedMs,
+ RowCount = result.RowCount
+ });
+
+ XTrace.WriteLine($"SqlEditor: Executed query, {result.RowCount} rows, {result.ElapsedMs}ms");
+ }
+}
\ No newline at end of file
diff --git a/src/Modules/DataStudio/Views/ConnectionListView.axaml b/src/Modules/DataStudio/Views/ConnectionListView.axaml
new file mode 100644
index 0000000..9246308
--- /dev/null
+++ b/src/Modules/DataStudio/Views/ConnectionListView.axaml
@@ -0,0 +1,29 @@
+<UserControl xmlns="https://github.com/avaloniaui"
+ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
+ x:Class="NewLife.Studio.Modules.DataStudio.Views.ConnectionListView"
+ MaxHeight="200">
+ <Border BorderBrush="#E0E0E0" BorderThickness="1" Padding="4" CornerRadius="4">
+ <Grid RowDefinitions="Auto,*">
+ <StackPanel Grid.Row="0" Orientation="Horizontal" Spacing="4" Margin="0,0,0,4">
+ <Button Content="+ 新建" Command="{Binding AddConnectionCommand}" Width="60" />
+ <Button Content="测试" Command="{Binding TestConnectionCommand}" Width="50" />
+ <Button Content="编辑" Command="{Binding EditConnectionCommand}" Width="50" />
+ <Button Content="删除" Command="{Binding DeleteConnectionCommand}" Width="50" />
+ <Button Content="打开" Command="{Binding OpenConnectionCommand}" Width="50" />
+ </StackPanel>
+ <ListBox Grid.Row="1" ItemsSource="{Binding Connections}"
+ SelectedItem="{Binding SelectedConnection}"
+ ScrollViewer.VerticalScrollBarVisibility="Auto">
+ <ListBox.ItemTemplate>
+ <DataTemplate>
+ <StackPanel Orientation="Vertical" Margin="2">
+ <TextBlock Text="{Binding Name}" FontWeight="SemiBold" />
+ <TextBlock Text="{Binding ConnectionString}" FontSize="10" Foreground="Gray"
+ TextTrimming="CharacterEllipsis" />
+ </StackPanel>
+ </DataTemplate>
+ </ListBox.ItemTemplate>
+ </ListBox>
+ </Grid>
+ </Border>
+</UserControl>
\ No newline at end of file
diff --git a/src/Modules/DataStudio/Views/ConnectionListView.axaml.cs b/src/Modules/DataStudio/Views/ConnectionListView.axaml.cs
new file mode 100644
index 0000000..bd687ab
--- /dev/null
+++ b/src/Modules/DataStudio/Views/ConnectionListView.axaml.cs
@@ -0,0 +1,11 @@
+using Avalonia.Controls;
+
+namespace NewLife.Studio.Modules.DataStudio.Views;
+
+public partial class ConnectionListView : UserControl
+{
+ public ConnectionListView()
+ {
+ InitializeComponent();
+ }
+}
\ No newline at end of file
diff --git a/src/Modules/DataStudio/Views/DataStudioView.axaml b/src/Modules/DataStudio/Views/DataStudioView.axaml
new file mode 100644
index 0000000..d8302d0
--- /dev/null
+++ b/src/Modules/DataStudio/Views/DataStudioView.axaml
@@ -0,0 +1,15 @@
+<UserControl xmlns="https://github.com/avaloniaui"
+ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
+ xmlns:views="clr-namespace:NewLife.Studio.Modules.DataStudio.Views"
+ x:Class="NewLife.Studio.Modules.DataStudio.Views.DataStudioView">
+ <Grid ColumnDefinitions="250,*" Margin="4">
+ <!-- 左侧: 连接列表 + 对象树 -->
+ <Grid Grid.Column="0" RowDefinitions="Auto,*,Auto">
+ <views:ConnectionListView Grid.Row="0" x:Name="ConnectionListControl" />
+ <views:ObjectTreeView Grid.Row="1" x:Name="ObjectTreeControl" Margin="0,4,0,0" />
+ </Grid>
+
+ <!-- 右侧: SQL 编辑器 + 结果网格 -->
+ <views:SqlEditorView Grid.Column="1" x:Name="SqlEditorControl" Margin="4,0,0,0" />
+ </Grid>
+</UserControl>
\ No newline at end of file
diff --git a/src/Modules/DataStudio/Views/DataStudioView.axaml.cs b/src/Modules/DataStudio/Views/DataStudioView.axaml.cs
new file mode 100644
index 0000000..42e7f17
--- /dev/null
+++ b/src/Modules/DataStudio/Views/DataStudioView.axaml.cs
@@ -0,0 +1,29 @@
+using Avalonia.Controls;
+using NewLife.Studio.Modules.DataStudio.ViewModels;
+using NewLife.Studio.Core;
+using NewLife.Studio.Store;
+using NewLife.Studio.Data;
+
+namespace NewLife.Studio.Modules.DataStudio.Views;
+
+public partial class DataStudioView : UserControl
+{
+ private readonly DataStudioViewModel _viewModel;
+
+ public DataStudioView()
+ {
+ InitializeComponent();
+
+ var storeService = StudioServices.GetService<IStoreService>() ?? new StoreService();
+ var dataProvider = StudioServices.GetService<IDataProvider>() ?? new Data.Providers.SQLite.SQLiteProvider();
+
+ _viewModel = new DataStudioViewModel(storeService, dataProvider);
+ DataContext = _viewModel;
+
+ ConnectionListControl.DataContext = _viewModel.ConnectionList;
+ ObjectTreeControl.DataContext = _viewModel.ObjectTree;
+ SqlEditorControl.DataContext = _viewModel.SqlEditor;
+
+ Loaded += async (_, _) => await _viewModel.ConnectionList.LoadAsync();
+ }
+}
\ No newline at end of file
diff --git a/src/Modules/DataStudio/Views/ObjectTreeView.axaml b/src/Modules/DataStudio/Views/ObjectTreeView.axaml
new file mode 100644
index 0000000..4743ba2
--- /dev/null
+++ b/src/Modules/DataStudio/Views/ObjectTreeView.axaml
@@ -0,0 +1,20 @@
+<UserControl xmlns="https://github.com/avaloniaui"
+ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
+ x:Class="NewLife.Studio.Modules.DataStudio.Views.ObjectTreeView">
+ <Border BorderBrush="#E0E0E0" BorderThickness="1" Padding="4" CornerRadius="4">
+ <Grid RowDefinitions="Auto,*">
+ <TextBlock Grid.Row="0" Text="对象浏览器" FontWeight="Bold" Margin="0,0,0,4" />
+ <TreeView Grid.Row="1" ItemsSource="{Binding Nodes}"
+ ScrollViewer.VerticalScrollBarVisibility="Auto">
+ <TreeView.ItemTemplate>
+ <TreeDataTemplate ItemsSource="{Binding Children}">
+ <StackPanel Orientation="Horizontal" Spacing="4">
+ <TextBlock Text="{Binding Name}" />
+ <TextBlock Text="{Binding DataType}" FontSize="10" Foreground="Gray" />
+ </StackPanel>
+ </TreeDataTemplate>
+ </TreeView.ItemTemplate>
+ </TreeView>
+ </Grid>
+ </Border>
+</UserControl>
\ No newline at end of file
diff --git a/src/Modules/DataStudio/Views/ObjectTreeView.axaml.cs b/src/Modules/DataStudio/Views/ObjectTreeView.axaml.cs
new file mode 100644
index 0000000..6f481b9
--- /dev/null
+++ b/src/Modules/DataStudio/Views/ObjectTreeView.axaml.cs
@@ -0,0 +1,13 @@
+using Avalonia.Controls;
+using Avalonia.Interactivity;
+using NewLife.Studio.Modules.DataStudio.ViewModels;
+
+namespace NewLife.Studio.Modules.DataStudio.Views;
+
+public partial class ObjectTreeView : UserControl
+{
+ public ObjectTreeView()
+ {
+ InitializeComponent();
+ }
+}
\ No newline at end of file
diff --git a/src/Modules/DataStudio/Views/ResultGridView.axaml b/src/Modules/DataStudio/Views/ResultGridView.axaml
new file mode 100644
index 0000000..350bbd9
--- /dev/null
+++ b/src/Modules/DataStudio/Views/ResultGridView.axaml
@@ -0,0 +1,32 @@
+<UserControl xmlns="https://github.com/avaloniaui"
+ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
+ x:Class="NewLife.Studio.Modules.DataStudio.Views.ResultGridView">
+ <Border BorderBrush="#E0E0E0" BorderThickness="1" Padding="4">
+ <Grid RowDefinitions="Auto,Auto,*,Auto">
+ <!-- 工具栏 -->
+ <StackPanel Grid.Row="0" Orientation="Horizontal" Spacing="4" Margin="0,0,0,4">
+ <Button Content="导出 CSV" x:Name="ExportCsvButton" Width="80" />
+ <Button Content="导出 JSON" x:Name="ExportJsonButton" Width="80" />
+ </StackPanel>
+
+ <!-- 错误信息 -->
+ <TextBlock Grid.Row="1" Text="{Binding Error}" Foreground="Red"
+ IsVisible="{Binding HasError}" Margin="0,0,0,4" />
+
+ <!-- 数据网格 -->
+ <DataGrid Grid.Row="2" ItemsSource="{Binding Rows}"
+ AutoGenerateColumns="True"
+ IsReadOnly="True"
+ ScrollViewer.HorizontalScrollBarVisibility="Auto"
+ ScrollViewer.VerticalScrollBarVisibility="Auto" />
+
+ <!-- 底部状态 -->
+ <StackPanel Grid.Row="3" Orientation="Horizontal" Spacing="16" Margin="0,4,0,0">
+ <TextBlock Text="{Binding ElapsedText}" FontSize="11" Foreground="Gray" />
+ <TextBlock Text="{Binding RowCountText}" FontSize="11" Foreground="Gray" />
+ <TextBlock Text="{Binding TruncatedWarning}" FontSize="11"
+ Foreground="Orange" FontWeight="SemiBold" />
+ </StackPanel>
+ </Grid>
+ </Border>
+</UserControl>
\ No newline at end of file
diff --git a/src/Modules/DataStudio/Views/ResultGridView.axaml.cs b/src/Modules/DataStudio/Views/ResultGridView.axaml.cs
new file mode 100644
index 0000000..3fee8ab
--- /dev/null
+++ b/src/Modules/DataStudio/Views/ResultGridView.axaml.cs
@@ -0,0 +1,51 @@
+using Avalonia.Controls;
+using Avalonia.Interactivity;
+using Avalonia.Platform.Storage;
+using NewLife.Studio.Modules.DataStudio.ViewModels;
+
+namespace NewLife.Studio.Modules.DataStudio.Views;
+
+public partial class ResultGridView : UserControl
+{
+ private ResultGridViewModel? _vm;
+
+ public ResultGridView()
+ {
+ InitializeComponent();
+
+ ExportCsvButton.Click += async (_, _) =>
+ {
+ if (_vm == null || !_vm.HasRows) return;
+ var filePath = await GetSavePathAsync("查询结果.csv");
+ if (filePath != null)
+ await _vm.ExportCsvAsync(filePath);
+ };
+
+ ExportJsonButton.Click += async (_, _) =>
+ {
+ if (_vm == null || !_vm.HasRows) return;
+ var filePath = await GetSavePathAsync("查询结果.json");
+ if (filePath != null)
+ await _vm.ExportJsonAsync(filePath);
+ };
+
+ DataContextChanged += (_, _) =>
+ {
+ _vm = DataContext as ResultGridViewModel;
+ };
+ }
+
+ private async Task<string?> GetSavePathAsync(string defaultName)
+ {
+ var topLevel = TopLevel.GetTopLevel(this);
+ if (topLevel == null) return null;
+
+ var file = await topLevel.StorageProvider.SaveFilePickerAsync(new FilePickerSaveOptions
+ {
+ Title = "导出文件",
+ SuggestedFileName = defaultName
+ });
+
+ return file?.TryGetLocalPath();
+ }
+}
\ No newline at end of file
diff --git a/src/Modules/DataStudio/Views/SqlEditorView.axaml b/src/Modules/DataStudio/Views/SqlEditorView.axaml
new file mode 100644
index 0000000..211dcfc
--- /dev/null
+++ b/src/Modules/DataStudio/Views/SqlEditorView.axaml
@@ -0,0 +1,41 @@
+<UserControl xmlns="https://github.com/avaloniaui"
+ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
+ xmlns:views="clr-namespace:NewLife.Studio.Modules.DataStudio.Views"
+ x:Class="NewLife.Studio.Modules.DataStudio.Views.SqlEditorView">
+ <Grid RowDefinitions="Auto,Auto,*,Auto">
+ <!-- 工具栏 -->
+ <StackPanel Grid.Row="0" Orientation="Horizontal" Spacing="4" Margin="0,0,0,4">
+ <Button Content="+ 新建查询" Command="{Binding NewTabCommand}" Width="80" />
+ <Button Content="执行 (F5)" Command="{Binding ExecuteCommand}" Width="70" />
+ </StackPanel>
+
+ <!-- Tab 头 -->
+ <ListBox Grid.Row="1" ItemsSource="{Binding Tabs}"
+ SelectedItem="{Binding ActiveTab}" Height="28"
+ ScrollViewer.HorizontalScrollBarVisibility="Auto">
+ <ListBox.ItemsPanel>
+ <ItemsPanelTemplate>
+ <StackPanel Orientation="Horizontal" />
+ </ItemsPanelTemplate>
+ </ListBox.ItemsPanel>
+ <ListBox.ItemTemplate>
+ <DataTemplate>
+ <StackPanel Orientation="Horizontal">
+ <TextBlock Text="{Binding Title}" Padding="8,2" />
+ </StackPanel>
+ </DataTemplate>
+ </ListBox.ItemTemplate>
+ </ListBox>
+
+ <!-- SQL 编辑器 -->
+ <Grid Grid.Row="2" RowDefinitions="Auto,*">
+ <TextBox Grid.Row="0" Text="{Binding ActiveTab.Sql}"
+ AcceptsReturn="True" Height="120"
+ FontFamily="Consolas" FontSize="13"
+ PlaceholderText="输入 SQL 查询语句..." />
+ <!-- 结果网格 -->
+ <views:ResultGridView Grid.Row="1" x:Name="ResultGridControl"
+ Margin="0,4,0,0" />
+ </Grid>
+ </Grid>
+</UserControl>
\ No newline at end of file
diff --git a/src/Modules/DataStudio/Views/SqlEditorView.axaml.cs b/src/Modules/DataStudio/Views/SqlEditorView.axaml.cs
new file mode 100644
index 0000000..8f9adc7
--- /dev/null
+++ b/src/Modules/DataStudio/Views/SqlEditorView.axaml.cs
@@ -0,0 +1,44 @@
+using Avalonia.Controls;
+using NewLife.Studio.Modules.DataStudio.ViewModels;
+
+namespace NewLife.Studio.Modules.DataStudio.Views;
+
+public partial class SqlEditorView : UserControl
+{
+ private SqlEditorViewModel? _viewModel;
+
+ public SqlEditorView()
+ {
+ InitializeComponent();
+
+ DataContextChanged += (_, _) =>
+ {
+ if (_viewModel != null)
+ _viewModel.PropertyChanged -= OnVmPropertyChanged;
+
+ _viewModel = DataContext as SqlEditorViewModel;
+
+ if (_viewModel != null)
+ {
+ _viewModel.PropertyChanged += OnVmPropertyChanged;
+ SyncResultGrid();
+ }
+ };
+ }
+
+ private void OnVmPropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e)
+ {
+ if (e.PropertyName == nameof(SqlEditorViewModel.ActiveTab))
+ {
+ SyncResultGrid();
+ }
+ }
+
+ private void SyncResultGrid()
+ {
+ if (_viewModel?.ActiveTab?.ResultGrid != null)
+ {
+ ResultGridControl.DataContext = _viewModel.ActiveTab.ResultGrid;
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/Providers/NewLife.Studio.Data/IDataProvider.cs b/src/Providers/NewLife.Studio.Data/IDataProvider.cs
new file mode 100644
index 0000000..1755876
--- /dev/null
+++ b/src/Providers/NewLife.Studio.Data/IDataProvider.cs
@@ -0,0 +1,14 @@
+using NewLife.Studio.Core.DTOs;
+
+namespace NewLife.Studio.Data;
+
+/// <summary>数据库 Provider 接口</summary>
+public interface IDataProvider
+{
+ string ProviderName { get; }
+ string[] SupportedSchemes { get; }
+ bool CanTestConnection { get; }
+
+ Task<bool> TestConnectionAsync(ConnectionInfo conn, CancellationToken ct = default);
+ Task<IDbSession> OpenSessionAsync(ConnectionInfo conn, CancellationToken ct = default);
+}
\ No newline at end of file
diff --git a/src/Providers/NewLife.Studio.Data/IDbSession.cs b/src/Providers/NewLife.Studio.Data/IDbSession.cs
new file mode 100644
index 0000000..8051fc8
--- /dev/null
+++ b/src/Providers/NewLife.Studio.Data/IDbSession.cs
@@ -0,0 +1,16 @@
+using NewLife.Studio.Core.DTOs;
+
+namespace NewLife.Studio.Data;
+
+/// <summary>数据库会话接口</summary>
+public interface IDbSession : IDisposable
+{
+ string SessionId { get; }
+ ConnectionInfo Connection { get; }
+ bool IsOpen { get; }
+
+ Task<TableInfo[]> GetTablesAsync(CancellationToken ct = default);
+ Task<ColumnInfo[]> GetColumnsAsync(string tableName, CancellationToken ct = default);
+ Task<QueryResult> ExecuteQueryAsync(QueryRequest request, CancellationToken ct = default);
+ Task CloseAsync();
+}
\ No newline at end of file
diff --git a/src/Providers/NewLife.Studio.Data/NewLife.Studio.Data.csproj b/src/Providers/NewLife.Studio.Data/NewLife.Studio.Data.csproj
new file mode 100644
index 0000000..62a7609
--- /dev/null
+++ b/src/Providers/NewLife.Studio.Data/NewLife.Studio.Data.csproj
@@ -0,0 +1,17 @@
+<Project Sdk="Microsoft.NET.Sdk">
+
+ <PropertyGroup>
+ <TargetFramework>net9.0</TargetFramework>
+ <ImplicitUsings>enable</ImplicitUsings>
+ <Nullable>enable</Nullable>
+ </PropertyGroup>
+
+ <ItemGroup>
+ <ProjectReference Include="..\..\Framework\NewLife.Studio.Core\NewLife.Studio.Core.csproj" />
+ </ItemGroup>
+
+ <ItemGroup>
+ <PackageReference Include="Microsoft.Data.Sqlite" Version="9.0.0" />
+ </ItemGroup>
+
+</Project>
diff --git a/src/Providers/NewLife.Studio.Data/Providers/SQLite/SQLiteMetadataReader.cs b/src/Providers/NewLife.Studio.Data/Providers/SQLite/SQLiteMetadataReader.cs
new file mode 100644
index 0000000..cf311fc
--- /dev/null
+++ b/src/Providers/NewLife.Studio.Data/Providers/SQLite/SQLiteMetadataReader.cs
@@ -0,0 +1,60 @@
+using Microsoft.Data.Sqlite;
+using NewLife.Studio.Core.DTOs;
+using NewLife.Log;
+
+namespace NewLife.Studio.Data.Providers.SQLite;
+
+/// <summary>SQLite 元数据读取器</summary>
+public class SQLiteMetadataReader
+{
+ /// <summary>解析 PRAGMA table_info 结果为 ColumnInfo[]</summary>
+ public static ColumnInfo[] ParseTableInfo(SqliteDataReader reader)
+ {
+ var columns = new List<ColumnInfo>();
+ while (reader.Read())
+ {
+ columns.Add(new ColumnInfo
+ {
+ Ordinal = reader.GetInt32(0), // cid
+ Name = reader.GetString(1), // name
+ DataType = reader.GetString(2), // type
+ IsNullable = !reader.GetBoolean(3), // notnull
+ DefaultValue = reader.IsDBNull(4) ? null : reader.GetString(4), // dflt_value
+ IsPrimaryKey = reader.GetBoolean(5) // pk
+ });
+ }
+ return columns.ToArray();
+ }
+
+ /// <summary>解析 PRAGMA index_list 结果</summary>
+ public static List<string> ParseIndexList(SqliteConnection connection, string tableName)
+ {
+ var indexes = new List<string>();
+ using var cmd = connection.CreateCommand();
+ cmd.CommandText = $"PRAGMA index_list('{tableName}')";
+ using var reader = cmd.ExecuteReader();
+ while (reader.Read())
+ {
+ var name = reader.GetString(1); // name
+ indexes.Add(name);
+ }
+ return indexes;
+ }
+
+ /// <summary>解析 PRAGMA foreign_key_list 结果</summary>
+ public static List<string> ParseForeignKeyList(SqliteConnection connection, string tableName)
+ {
+ var fks = new List<string>();
+ using var cmd = connection.CreateCommand();
+ cmd.CommandText = $"PRAGMA foreign_key_list('{tableName}')";
+ using var reader = cmd.ExecuteReader();
+ while (reader.Read())
+ {
+ var from = reader.GetString(3); // from column
+ var table = reader.GetString(2); // table
+ var to = reader.GetString(4); // to column
+ fks.Add($"{from} -> {table}({to})");
+ }
+ return fks;
+ }
+}
\ No newline at end of file
diff --git a/src/Providers/NewLife.Studio.Data/Providers/SQLite/SQLiteProvider.cs b/src/Providers/NewLife.Studio.Data/Providers/SQLite/SQLiteProvider.cs
new file mode 100644
index 0000000..2d0bf41
--- /dev/null
+++ b/src/Providers/NewLife.Studio.Data/Providers/SQLite/SQLiteProvider.cs
@@ -0,0 +1,48 @@
+using Microsoft.Data.Sqlite;
+using NewLife.Studio.Core.DTOs;
+using NewLife.Log;
+
+namespace NewLife.Studio.Data.Providers.SQLite;
+
+/// <summary>SQLite 数据库 Provider</summary>
+public class SQLiteProvider : IDataProvider
+{
+ public string ProviderName => "SQLite";
+ public string[] SupportedSchemes => ["sqlite", "sqlite3"];
+ public bool CanTestConnection => true;
+
+ public async Task<bool> TestConnectionAsync(ConnectionInfo conn, CancellationToken ct = default)
+ {
+ try
+ {
+ using var connection = new SqliteConnection(GetConnectionString(conn));
+ await connection.OpenAsync(ct);
+ using var cmd = connection.CreateCommand();
+ cmd.CommandText = "SELECT 1";
+ await cmd.ExecuteScalarAsync(ct);
+ await connection.CloseAsync();
+ return true;
+ }
+ catch (Exception ex)
+ {
+ XTrace.WriteLine($"SQLite TestConnection failed: {ex.Message}");
+ return false;
+ }
+ }
+
+ public Task<IDbSession> OpenSessionAsync(ConnectionInfo conn, CancellationToken ct = default)
+ {
+ var session = new SQLiteSession(conn);
+ return Task.FromResult<IDbSession>(session);
+ }
+
+ private static string GetConnectionString(ConnectionInfo conn)
+ {
+ // 如果 ConnectionString 以 Data Source= 开头,直接使用
+ if (conn.ConnectionString.StartsWith("Data Source=", StringComparison.OrdinalIgnoreCase))
+ return conn.ConnectionString;
+
+ // 否则当作文件路径处理
+ return $"Data Source={conn.ConnectionString}";
+ }
+}
\ No newline at end of file
diff --git a/src/Providers/NewLife.Studio.Data/Providers/SQLite/SQLiteSession.cs b/src/Providers/NewLife.Studio.Data/Providers/SQLite/SQLiteSession.cs
new file mode 100644
index 0000000..df57076
--- /dev/null
+++ b/src/Providers/NewLife.Studio.Data/Providers/SQLite/SQLiteSession.cs
@@ -0,0 +1,157 @@
+using System.Diagnostics;
+using Microsoft.Data.Sqlite;
+using NewLife.Studio.Core.DTOs;
+using NewLife.Log;
+
+namespace NewLife.Studio.Data.Providers.SQLite;
+
+/// <summary>SQLite 数据库会话</summary>
+public class SQLiteSession : IDbSession
+{
+ private SqliteConnection? _connection;
+ private bool _disposed;
+
+ public string SessionId { get; } = Guid.NewGuid().ToString("N");
+ public ConnectionInfo Connection { get; }
+ public bool IsOpen => _connection?.State == System.Data.ConnectionState.Open;
+
+ public SQLiteSession(ConnectionInfo connection)
+ {
+ Connection = connection;
+ }
+
+ private SqliteConnection GetConnection()
+ {
+ if (_connection == null)
+ {
+ var connStr = Connection.ConnectionString;
+ if (!connStr.StartsWith("Data Source=", StringComparison.OrdinalIgnoreCase))
+ connStr = $"Data Source={connStr}";
+
+ _connection = new SqliteConnection(connStr);
+ _connection.Open();
+ XTrace.WriteLine($"SQLiteSession: Opened connection {SessionId}");
+ }
+
+ if (_connection.State != System.Data.ConnectionState.Open)
+ _connection.Open();
+
+ return _connection;
+ }
+
+ public Task<TableInfo[]> GetTablesAsync(CancellationToken ct = default)
+ {
+ var conn = GetConnection();
+ using var cmd = conn.CreateCommand();
+ cmd.CommandText = "SELECT name FROM sqlite_master WHERE type='table' ORDER BY name";
+
+ var tables = new List<TableInfo>();
+ using var reader = cmd.ExecuteReader();
+ while (reader.Read())
+ {
+ tables.Add(new TableInfo
+ {
+ Name = reader.GetString(0),
+ Schema = "main"
+ });
+ }
+
+ return Task.FromResult(tables.ToArray());
+ }
+
+ public Task<ColumnInfo[]> GetColumnsAsync(string tableName, CancellationToken ct = default)
+ {
+ var conn = GetConnection();
+ using var cmd = conn.CreateCommand();
+ cmd.CommandText = $"PRAGMA table_info('{tableName}')";
+
+ using var reader = cmd.ExecuteReader();
+ var columns = SQLiteMetadataReader.ParseTableInfo(reader);
+ return Task.FromResult(columns);
+ }
+
+ public async Task<QueryResult> ExecuteQueryAsync(QueryRequest request, CancellationToken ct = default)
+ {
+ var sw = Stopwatch.StartNew();
+ var result = new QueryResult();
+
+ try
+ {
+ var conn = GetConnection();
+ using var cmd = conn.CreateCommand();
+ cmd.CommandText = request.Sql;
+ cmd.CommandTimeout = request.TimeoutSeconds;
+
+ using var reader = await cmd.ExecuteReaderAsync(ct);
+
+ // 提取列信息
+ var columns = new List<ColumnInfo>();
+ for (int i = 0; i < reader.FieldCount; i++)
+ {
+ columns.Add(new ColumnInfo
+ {
+ Ordinal = i,
+ Name = reader.GetName(i),
+ DataType = reader.GetDataTypeName(i)
+ });
+ }
+ result.Columns = columns.ToArray();
+
+ // 读取数据行
+ var rows = new List<object?[]>();
+ int rowCount = 0;
+ while (await reader.ReadAsync(ct))
+ {
+ if (rowCount >= request.MaxRows)
+ {
+ result.Truncated = true;
+ break;
+ }
+
+ var row = new object?[reader.FieldCount];
+ for (int i = 0; i < reader.FieldCount; i++)
+ {
+ row[i] = reader.IsDBNull(i) ? null : reader.GetValue(i);
+ }
+ rows.Add(row);
+ rowCount++;
+ }
+
+ result.Rows = rows;
+ result.RowCount = rowCount;
+ }
+ catch (Exception ex)
+ {
+ result.Error = ex.Message;
+ XTrace.WriteLine($"SQLiteSession ExecuteQuery error: {ex.Message}");
+ }
+ finally
+ {
+ sw.Stop();
+ result.ElapsedMs = sw.ElapsedMilliseconds;
+ }
+
+ return result;
+ }
+
+ public async Task CloseAsync()
+ {
+ if (_connection != null)
+ {
+ await _connection.CloseAsync();
+ _connection.Dispose();
+ _connection = null;
+ XTrace.WriteLine($"SQLiteSession: Closed connection {SessionId}");
+ }
+ }
+
+ public void Dispose()
+ {
+ if (!_disposed)
+ {
+ _connection?.Dispose();
+ _connection = null;
+ _disposed = true;
+ }
+ }
+}
\ No newline at end of file
diff --git a/tests/NewLife.Studio.AI.Tests/AIProviderFactoryTests.cs b/tests/NewLife.Studio.AI.Tests/AIProviderFactoryTests.cs
new file mode 100644
index 0000000..1fc2af9
--- /dev/null
+++ b/tests/NewLife.Studio.AI.Tests/AIProviderFactoryTests.cs
@@ -0,0 +1,98 @@
+using NewLife.Studio.AI;
+using NewLife.Studio.AI.Providers;
+using Xunit;
+
+namespace NewLife.Studio.AI.Tests;
+
+public class AIProviderFactoryTests : IDisposable
+{
+ private const string DefaultEndpoint = "https://api.openai.com/v1";
+ private const string DefaultApiKey = "sk-test-key";
+ private const string DefaultModel = "gpt-4o";
+
+ private readonly HttpClient _httpClient;
+
+ public AIProviderFactoryTests()
+ {
+ _httpClient = new HttpClient();
+ }
+
+ public void Dispose()
+ {
+ _httpClient.Dispose();
+ }
+
+ [Fact]
+ public void Create_WithOpenAI_ReturnsOpenAIProvider()
+ {
+ var provider = AIProviderFactory.Create(_httpClient, "openai", DefaultEndpoint, DefaultApiKey, DefaultModel);
+
+ Assert.NotNull(provider);
+ Assert.IsType<OpenAIProvider>(provider);
+ Assert.Equal("OpenAI", provider.ProviderName);
+ }
+
+ [Fact]
+ public void Create_WithAzure_ReturnsOpenAIProvider()
+ {
+ var provider = AIProviderFactory.Create(_httpClient, "azure", DefaultEndpoint, DefaultApiKey, DefaultModel);
+
+ Assert.NotNull(provider);
+ Assert.IsType<OpenAIProvider>(provider);
+ }
+
+ [Fact]
+ public void Create_WithOllama_ReturnsOpenAIProvider()
+ {
+ var provider = AIProviderFactory.Create(_httpClient, "ollama", DefaultEndpoint, DefaultApiKey, DefaultModel);
+
+ Assert.NotNull(provider);
+ Assert.IsType<OpenAIProvider>(provider);
+ }
+
+ [Fact]
+ public void Create_WithUpperCaseProviderType_ReturnsCorrectProvider()
+ {
+ var provider = AIProviderFactory.Create(_httpClient, "OPENAI", DefaultEndpoint, DefaultApiKey, DefaultModel);
+
+ Assert.NotNull(provider);
+ Assert.IsType<OpenAIProvider>(provider);
+ }
+
+ [Fact]
+ public void Create_WithMixedCaseProviderType_ReturnsCorrectProvider()
+ {
+ var provider = AIProviderFactory.Create(_httpClient, "OpenAi", DefaultEndpoint, DefaultApiKey, DefaultModel);
+
+ Assert.NotNull(provider);
+ Assert.IsType<OpenAIProvider>(provider);
+ }
+
+ [Fact]
+ public void Create_WithUnknownProviderType_ThrowsArgumentException()
+ {
+ var exception = Assert.Throws<ArgumentException>(() =>
+ AIProviderFactory.Create(_httpClient, "unknown", DefaultEndpoint, DefaultApiKey, DefaultModel));
+
+ Assert.Contains("unknown", exception.Message);
+ Assert.Contains("Unknown provider type", exception.Message);
+ }
+
+ [Fact]
+ public void Create_WithEmptyProviderType_ThrowsArgumentException()
+ {
+ Assert.Throws<ArgumentException>(() =>
+ AIProviderFactory.Create(_httpClient, "", DefaultEndpoint, DefaultApiKey, DefaultModel));
+ }
+
+ [Theory]
+ [InlineData("openai")]
+ [InlineData("azure")]
+ [InlineData("ollama")]
+ public void Create_WithValidProviderTypes_ReturnsNonNullProvider(string providerType)
+ {
+ var provider = AIProviderFactory.Create(_httpClient, providerType, DefaultEndpoint, DefaultApiKey, DefaultModel);
+
+ Assert.NotNull(provider);
+ }
+}
\ No newline at end of file
diff --git a/tests/NewLife.Studio.AI.Tests/AIServiceTests.cs b/tests/NewLife.Studio.AI.Tests/AIServiceTests.cs
new file mode 100644
index 0000000..59fe6c2
--- /dev/null
+++ b/tests/NewLife.Studio.AI.Tests/AIServiceTests.cs
@@ -0,0 +1,577 @@
+using NewLife.Studio.AI;
+using NewLife.Studio.AI.Models;
+using NewLife.Studio.AI.ToolCalling;
+using Xunit;
+
+namespace NewLife.Studio.AI.Tests;
+
+public class AIServiceTests
+{
+ private readonly ToolRegistry _toolRegistry;
+ private readonly FakeAIProvider _fakeProvider;
+
+ public AIServiceTests()
+ {
+ _toolRegistry = new ToolRegistry();
+ _fakeProvider = new FakeAIProvider();
+ }
+
+ [Fact]
+ public void Constructor_WithSystemPrompt_AddsSystemMessageToHistory()
+ {
+ _fakeProvider.EnqueueResponse(new ChatResponse
+ {
+ Choices = new List<ChatChoice>
+ {
+ new()
+ {
+ Message = new ChatMessage { Role = "assistant", Content = "Hello!" },
+ FinishReason = "stop"
+ }
+ }
+ });
+
+ var service = new AIService(_fakeProvider, _toolRegistry, "You are a helpful assistant.");
+
+ Assert.Single(service.History);
+ Assert.Equal("system", service.History[0].Role);
+ Assert.Equal("You are a helpful assistant.", service.History[0].Content);
+ }
+
+ [Fact]
+ public void Constructor_WithNullSystemPrompt_DoesNotAddSystemMessage()
+ {
+ _fakeProvider.EnqueueResponse(new ChatResponse
+ {
+ Choices = new List<ChatChoice>
+ {
+ new()
+ {
+ Message = new ChatMessage { Role = "assistant", Content = "Hello!" },
+ FinishReason = "stop"
+ }
+ }
+ });
+
+ var service = new AIService(_fakeProvider, _toolRegistry, null);
+
+ Assert.Empty(service.History);
+ }
+
+ [Fact]
+ public async Task ChatAsync_SimpleMessage_ReturnsAssistantContent()
+ {
+ _fakeProvider.EnqueueResponse(new ChatResponse
+ {
+ Choices = new List<ChatChoice>
+ {
+ new()
+ {
+ Message = new ChatMessage { Role = "assistant", Content = "I received your query." },
+ FinishReason = "stop"
+ }
+ }
+ });
+
+ var service = new AIService(_fakeProvider, _toolRegistry);
+ var result = await service.ChatAsync("What tables exist?");
+
+ Assert.Equal("I received your query.", result);
+ }
+
+ [Fact]
+ public async Task ChatAsync_StoresHistoryAfterConversation()
+ {
+ _fakeProvider.EnqueueResponse(new ChatResponse
+ {
+ Choices = new List<ChatChoice>
+ {
+ new()
+ {
+ Message = new ChatMessage { Role = "assistant", Content = "Response text" },
+ FinishReason = "stop"
+ }
+ }
+ });
+
+ var service = new AIService(_fakeProvider, _toolRegistry);
+ await service.ChatAsync("User message");
+
+ Assert.Equal(2, service.History.Count);
+ Assert.Equal("user", service.History[0].Role);
+ Assert.Equal("User message", service.History[0].Content);
+ Assert.Equal("assistant", service.History[1].Role);
+ Assert.Equal("Response text", service.History[1].Content);
+ }
+
+ [Fact]
+ public async Task ChatAsync_WithSystemPrompt_PreservesSystemMessageInHistory()
+ {
+ _fakeProvider.EnqueueResponse(new ChatResponse
+ {
+ Choices = new List<ChatChoice>
+ {
+ new()
+ {
+ Message = new ChatMessage { Role = "assistant", Content = "I understand." },
+ FinishReason = "stop"
+ }
+ }
+ });
+
+ var service = new AIService(_fakeProvider, _toolRegistry, "System instruction");
+ await service.ChatAsync("User query");
+
+ Assert.Equal("system", service.History[0].Role);
+ Assert.Equal("System instruction", service.History[0].Content);
+ Assert.Equal("user", service.History[1].Role);
+ Assert.Equal("assistant", service.History[2].Role);
+ Assert.Equal(3, service.History.Count);
+ }
+
+ [Fact]
+ public async Task ChatAsync_ToolCallingLoop_ExecutesToolsAndReturnsFinalResponse()
+ {
+ // First response: AI returns a tool call
+ _fakeProvider.EnqueueResponse(new ChatResponse
+ {
+ Choices = new List<ChatChoice>
+ {
+ new()
+ {
+ Message = new ChatMessage
+ {
+ Role = "assistant",
+ Content = null,
+ ToolCalls = new List<ToolCall>
+ {
+ new()
+ {
+ Id = "call_100",
+ Type = "function",
+ Function = new FunctionCall
+ {
+ Name = "get_data",
+ Arguments = "{\"table\":\"users\"}"
+ }
+ }
+ }
+ },
+ FinishReason = "tool_calls"
+ }
+ }
+ });
+
+ // Second response: AI processes tool result and returns final answer
+ _fakeProvider.EnqueueResponse(new ChatResponse
+ {
+ Choices = new List<ChatChoice>
+ {
+ new()
+ {
+ Message = new ChatMessage { Role = "assistant", Content = "Found 3 users." },
+ FinishReason = "stop"
+ }
+ }
+ });
+
+ _toolRegistry.Register("get_data", "Get data from table", null,
+ args => Task.FromResult("[\"Alice\", \"Bob\", \"Charlie\"]"));
+
+ var service = new AIService(_fakeProvider, _toolRegistry);
+ var result = await service.ChatAsync("Show me all users");
+
+ Assert.Equal("Found 3 users.", result);
+ }
+
+ [Fact]
+ public async Task ChatAsync_ToolCallFiresOnToolCallCallback()
+ {
+ _fakeProvider.EnqueueResponse(new ChatResponse
+ {
+ Choices = new List<ChatChoice>
+ {
+ new()
+ {
+ Message = new ChatMessage
+ {
+ Role = "assistant",
+ Content = null,
+ ToolCalls = new List<ToolCall>
+ {
+ new()
+ {
+ Id = "call_cb",
+ Type = "function",
+ Function = new FunctionCall
+ {
+ Name = "callback_test",
+ Arguments = "{\"key\":\"value\"}"
+ }
+ }
+ }
+ },
+ FinishReason = "tool_calls"
+ }
+ }
+ });
+
+ _fakeProvider.EnqueueResponse(new ChatResponse
+ {
+ Choices = new List<ChatChoice>
+ {
+ new()
+ {
+ Message = new ChatMessage { Role = "assistant", Content = "Done." },
+ FinishReason = "stop"
+ }
+ }
+ });
+
+ _toolRegistry.Register("callback_test", "Callback test tool", null,
+ args => Task.FromResult("done"));
+
+ ToolCall? capturedToolCall = null;
+ ToolResult? capturedToolResult = null;
+
+ var service = new AIService(_fakeProvider, _toolRegistry);
+ await service.ChatAsync("Trigger callback", (tc, tr) =>
+ {
+ capturedToolCall = tc;
+ capturedToolResult = tr;
+ });
+
+ Assert.NotNull(capturedToolCall);
+ Assert.Equal("call_cb", capturedToolCall!.Id);
+ Assert.Equal("callback_test", capturedToolCall.Function.Name);
+ Assert.Equal("{\"key\":\"value\"}", capturedToolCall.Function.Arguments);
+ Assert.NotNull(capturedToolResult);
+ Assert.Equal("call_cb", capturedToolResult!.ToolCallId);
+ Assert.Equal("done", capturedToolResult.Output);
+ }
+
+ [Fact]
+ public void ClearHistory_ResetsState()
+ {
+ _fakeProvider.EnqueueResponse(new ChatResponse
+ {
+ Choices = new List<ChatChoice>
+ {
+ new()
+ {
+ Message = new ChatMessage { Role = "assistant", Content = "Hi" },
+ FinishReason = "stop"
+ }
+ }
+ });
+
+ var service = new AIService(_fakeProvider, _toolRegistry, "system prompt");
+
+ Assert.NotEmpty(service.History);
+
+ service.ClearHistory();
+
+ Assert.Single(service.History);
+ Assert.Equal("system", service.History[0].Role);
+ Assert.Equal("system prompt", service.History[0].Content);
+ }
+
+ [Fact]
+ public async Task ClearHistory_WithoutSystemPrompt_ResultsInEmptyHistory()
+ {
+ _fakeProvider.EnqueueResponse(new ChatResponse
+ {
+ Choices = new List<ChatChoice>
+ {
+ new()
+ {
+ Message = new ChatMessage { Role = "assistant", Content = "Hi" },
+ FinishReason = "stop"
+ }
+ }
+ });
+
+ var service = new AIService(_fakeProvider, _toolRegistry, null);
+
+ // Add a user message to make history non-empty
+ _fakeProvider.EnqueueResponse(new ChatResponse
+ {
+ Choices = new List<ChatChoice>
+ {
+ new()
+ {
+ Message = new ChatMessage { Role = "assistant", Content = "Hi again" },
+ FinishReason = "stop"
+ }
+ }
+ });
+ await service.ChatAsync("Hello");
+
+ Assert.NotEmpty(service.History);
+
+ service.ClearHistory();
+
+ Assert.Empty(service.History);
+ }
+
+ [Fact]
+ public async Task ChatAsync_MultipleConversationTurns_AccumulatesHistory()
+ {
+ _fakeProvider.EnqueueResponse(new ChatResponse
+ {
+ Choices = new List<ChatChoice>
+ {
+ new()
+ {
+ Message = new ChatMessage { Role = "assistant", Content = "First response" },
+ FinishReason = "stop"
+ }
+ }
+ });
+
+ _fakeProvider.EnqueueResponse(new ChatResponse
+ {
+ Choices = new List<ChatChoice>
+ {
+ new()
+ {
+ Message = new ChatMessage { Role = "assistant", Content = "Second response" },
+ FinishReason = "stop"
+ }
+ }
+ });
+
+ var service = new AIService(_fakeProvider, _toolRegistry);
+
+ await service.ChatAsync("First message");
+ await service.ChatAsync("Second message");
+
+ // Turn 1: user, assistant → 2 messages
+ // Turn 2: user, assistant → +2 = 4 total
+ Assert.Equal(4, service.History.Count);
+ Assert.Contains(service.History, m => m.Content == "First response");
+ Assert.Contains(service.History, m => m.Content == "Second response");
+ }
+
+ [Fact]
+ public async Task ChatAsync_MaxToolCallLoop_DoesNotInfiniteLoop()
+ {
+ // Simulate tool call responses that always trigger another tool call
+ for (int i = 0; i < 10; i++)
+ {
+ _fakeProvider.EnqueueResponse(new ChatResponse
+ {
+ Choices = new List<ChatChoice>
+ {
+ new()
+ {
+ Message = new ChatMessage
+ {
+ Role = "assistant",
+ Content = null,
+ ToolCalls = new List<ToolCall>
+ {
+ new()
+ {
+ Id = $"call_{i}",
+ Type = "function",
+ Function = new FunctionCall
+ {
+ Name = "loop_test",
+ Arguments = "{}"
+ }
+ }
+ }
+ },
+ FinishReason = "tool_calls"
+ }
+ }
+ });
+ }
+
+ _toolRegistry.Register("loop_test", "Loop test", null,
+ _ => Task.FromResult("looping"));
+
+ var service = new AIService(_fakeProvider, _toolRegistry);
+ var result = await service.ChatAsync("Start loop");
+
+ // Should stop after max 5 loops
+ Assert.Equal("AI 未返回有效响应", result);
+ }
+
+ [Fact]
+ public async Task ChatAsync_NullContentToolCall_ContinuesLoop()
+ {
+ // First: tool call with null content
+ _fakeProvider.EnqueueResponse(new ChatResponse
+ {
+ Choices = new List<ChatChoice>
+ {
+ new()
+ {
+ Message = new ChatMessage
+ {
+ Role = "assistant",
+ Content = null,
+ ToolCalls = new List<ToolCall>
+ {
+ new()
+ {
+ Id = "call_nc",
+ Type = "function",
+ Function = new FunctionCall
+ {
+ Name = "null_continue",
+ Arguments = "{}"
+ }
+ }
+ }
+ },
+ FinishReason = "tool_calls"
+ }
+ }
+ });
+
+ // Second: final response
+ _fakeProvider.EnqueueResponse(new ChatResponse
+ {
+ Choices = new List<ChatChoice>
+ {
+ new()
+ {
+ Message = new ChatMessage { Role = "assistant", Content = "Final answer" },
+ FinishReason = "stop"
+ }
+ }
+ });
+
+ _toolRegistry.Register("null_continue", "Null continue test", null,
+ _ => Task.FromResult("processed"));
+
+ var service = new AIService(_fakeProvider, _toolRegistry);
+ var result = await service.ChatAsync("Query");
+
+ Assert.Equal("Final answer", result);
+ }
+
+ [Fact]
+ public async Task ChatAsync_EmptyChoices_ReturnsFallbackMessage()
+ {
+ _fakeProvider.EnqueueResponse(new ChatResponse
+ {
+ Choices = new List<ChatChoice>()
+ });
+
+ var service = new AIService(_fakeProvider, _toolRegistry);
+ var result = await service.ChatAsync("Any query");
+
+ Assert.Equal("AI 未返回有效响应", result);
+ }
+
+ [Fact]
+ public async Task ChatAsync_NullContentAndNoToolCalls_ReturnsFallbackMessage()
+ {
+ _fakeProvider.EnqueueResponse(new ChatResponse
+ {
+ Choices = new List<ChatChoice>
+ {
+ new()
+ {
+ Message = new ChatMessage { Role = "assistant", Content = null, ToolCalls = null },
+ FinishReason = "stop"
+ }
+ }
+ });
+
+ var service = new AIService(_fakeProvider, _toolRegistry);
+ var result = await service.ChatAsync("Query");
+
+ Assert.Equal("AI 未返回有效响应", result);
+ }
+
+ [Fact]
+ public async Task ChatAsync_ToolCallError_StillAddsToolMessageToHistory()
+ {
+ // First: tool call to unknown tool
+ _fakeProvider.EnqueueResponse(new ChatResponse
+ {
+ Choices = new List<ChatChoice>
+ {
+ new()
+ {
+ Message = new ChatMessage
+ {
+ Role = "assistant",
+ Content = null,
+ ToolCalls = new List<ToolCall>
+ {
+ new()
+ {
+ Id = "call_err",
+ Type = "function",
+ Function = new FunctionCall
+ {
+ Name = "unknown_tool",
+ Arguments = "{}"
+ }
+ }
+ }
+ },
+ FinishReason = "tool_calls"
+ }
+ }
+ });
+
+ // Second: final response
+ _fakeProvider.EnqueueResponse(new ChatResponse
+ {
+ Choices = new List<ChatChoice>
+ {
+ new()
+ {
+ Message = new ChatMessage { Role = "assistant", Content = "Tool failed, but I'll answer anyway." },
+ FinishReason = "stop"
+ }
+ }
+ });
+
+ var service = new AIService(_fakeProvider, _toolRegistry);
+ var result = await service.ChatAsync("Query");
+
+ // Verify tool response was stored in history
+ Assert.Contains(service.History, m => m.Role == "tool" && m.Content!.Contains("Unknown tool"));
+ Assert.Equal("Tool failed, but I'll answer anyway.", result);
+ }
+
+ /// <summary>Fake IAIProvider that returns queued responses for testing.</summary>
+ private class FakeAIProvider : IAIProvider
+ {
+ private readonly Queue<ChatResponse> _responses = new();
+
+ public string ProviderName => "Fake";
+
+ public void EnqueueResponse(ChatResponse response)
+ {
+ _responses.Enqueue(response);
+ }
+
+ public Task<ChatResponse> ChatAsync(ChatRequest request, CancellationToken ct = default)
+ {
+ if (_responses.Count > 0)
+ {
+ return Task.FromResult(_responses.Dequeue());
+ }
+ return Task.FromResult(new ChatResponse
+ {
+ Choices = new List<ChatChoice>
+ {
+ new()
+ {
+ Message = new ChatMessage { Role = "assistant", Content = "Default response" },
+ FinishReason = "stop"
+ }
+ }
+ });
+ }
+ }
+}
\ No newline at end of file
diff --git a/tests/NewLife.Studio.AI.Tests/ChatModelsTests.cs b/tests/NewLife.Studio.AI.Tests/ChatModelsTests.cs
new file mode 100644
index 0000000..313b541
--- /dev/null
+++ b/tests/NewLife.Studio.AI.Tests/ChatModelsTests.cs
@@ -0,0 +1,404 @@
+using System.Text.Json;
+using NewLife.Studio.AI.Models;
+using Xunit;
+
+namespace NewLife.Studio.AI.Tests;
+
+public class ChatModelsTests
+{
+ private static readonly JsonSerializerOptions JsonOptions = new()
+ {
+ PropertyNameCaseInsensitive = true,
+ PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower
+ };
+
+ [Fact]
+ public void ChatMessage_Serialize_ProducesCorrectJson()
+ {
+ var message = new ChatMessage
+ {
+ Role = "user",
+ Content = "Hello, how are you?"
+ };
+
+ var json = JsonSerializer.Serialize(message, JsonOptions);
+
+ Assert.Contains("\"role\"", json);
+ Assert.Contains("\"user\"", json);
+ Assert.Contains("\"content\"", json);
+ Assert.Contains("Hello, how are you?", json);
+ }
+
+ [Fact]
+ public void ChatMessage_Deserialize_FromValidJson()
+ {
+ var json = """{"role":"assistant","content":"I am fine, thank you!"}""";
+
+ var message = JsonSerializer.Deserialize<ChatMessage>(json, JsonOptions);
+
+ Assert.NotNull(message);
+ Assert.Equal("assistant", message.Role);
+ Assert.Equal("I am fine, thank you!", message.Content);
+ }
+
+ [Fact]
+ public void ChatMessage_WithToolCalls_SerializeAndDeserialize()
+ {
+ var message = new ChatMessage
+ {
+ Role = "assistant",
+ Content = null,
+ ToolCalls = new List<ToolCall>
+ {
+ new()
+ {
+ Id = "call_abc123",
+ Type = "function",
+ Function = new FunctionCall
+ {
+ Name = "get_weather",
+ Arguments = "{\"location\":\"Beijing\"}"
+ }
+ }
+ }
+ };
+
+ var json = JsonSerializer.Serialize(message, JsonOptions);
+ var deserialized = JsonSerializer.Deserialize<ChatMessage>(json, JsonOptions);
+
+ Assert.NotNull(deserialized);
+ Assert.Equal("assistant", deserialized.Role);
+ Assert.Null(deserialized.Content);
+ Assert.NotNull(deserialized.ToolCalls);
+ Assert.Single(deserialized.ToolCalls);
+ Assert.Equal("call_abc123", deserialized.ToolCalls[0].Id);
+ Assert.Equal("get_weather", deserialized.ToolCalls[0].Function.Name);
+ }
+
+ [Fact]
+ public void ChatMessage_ToolMessage_SerializeAndDeserialize()
+ {
+ var message = new ChatMessage
+ {
+ Role = "tool",
+ ToolCallId = "call_abc123",
+ Content = "25°C, sunny"
+ };
+
+ var json = JsonSerializer.Serialize(message, JsonOptions);
+ var deserialized = JsonSerializer.Deserialize<ChatMessage>(json, JsonOptions);
+
+ Assert.NotNull(deserialized);
+ Assert.Equal("tool", deserialized.Role);
+ Assert.Equal("call_abc123", deserialized.ToolCallId);
+ Assert.Equal("25°C, sunny", deserialized.Content);
+ }
+
+ [Fact]
+ public void ChatMessage_SystemRole_SerializeCorrectly()
+ {
+ var message = new ChatMessage
+ {
+ Role = "system",
+ Content = "You are a helpful assistant."
+ };
+
+ var json = JsonSerializer.Serialize(message, JsonOptions);
+
+ Assert.Contains("\"role\":\"system\"", json);
+ Assert.Contains("\"content\":\"You are a helpful assistant.\"", json);
+ }
+
+ [Fact]
+ public void ChatRequest_Serialize_IncludesAllFields()
+ {
+ var request = new ChatRequest
+ {
+ Model = "gpt-4o",
+ Messages = new List<ChatMessage>
+ {
+ new() { Role = "system", Content = "You are a helpful assistant." },
+ new() { Role = "user", Content = "What is the weather?" }
+ },
+ MaxTokens = 2048,
+ Temperature = 0.5
+ };
+
+ var json = JsonSerializer.Serialize(request, JsonOptions);
+
+ Assert.Contains("\"model\":\"gpt-4o\"", json);
+ Assert.Contains("\"messages\"", json);
+ Assert.Contains("\"max_tokens\":2048", json);
+ Assert.Contains("\"temperature\":0.5", json);
+ }
+
+ [Fact]
+ public void ChatRequest_Serialize_WithToolsArray()
+ {
+ var request = new ChatRequest
+ {
+ Model = "gpt-4o",
+ Messages = new List<ChatMessage>
+ {
+ new() { Role = "user", Content = "Find users" }
+ },
+ Tools = new List<ToolDefinition>
+ {
+ new()
+ {
+ Type = "function",
+ Function = new FunctionDef
+ {
+ Name = "search_users",
+ Description = "Search users by criteria",
+ Parameters = new { type = "object", properties = new { } }
+ }
+ }
+ }
+ };
+
+ var json = JsonSerializer.Serialize(request, JsonOptions);
+
+ Assert.Contains("\"tools\"", json);
+ Assert.Contains("\"search_users\"", json);
+ }
+
+ [Fact]
+ public void ChatRequest_Serialize_WithNullTools_SerializesAsNull()
+ {
+ var request = new ChatRequest
+ {
+ Model = "gpt-4o",
+ Messages = new List<ChatMessage>
+ {
+ new() { Role = "user", Content = "Hello" }
+ },
+ Tools = null
+ };
+
+ var json = JsonSerializer.Serialize(request, JsonOptions);
+
+ Assert.Contains("\"tools\":null", json);
+ }
+
+ [Fact]
+ public void ChatRequest_Deserialize_FromCompleteJson()
+ {
+ var json = """
+ {
+ "model": "gpt-4o",
+ "messages": [
+ {"role": "system", "content": "You are helpful."},
+ {"role": "user", "content": "Query data"}
+ ],
+ "max_tokens": 4096,
+ "temperature": 0.1
+ }
+ """;
+
+ var request = JsonSerializer.Deserialize<ChatRequest>(json, JsonOptions);
+
+ Assert.NotNull(request);
+ Assert.Equal("gpt-4o", request.Model);
+ Assert.Equal(2, request.Messages.Count);
+ Assert.Equal(4096, request.MaxTokens);
+ Assert.Equal(0.1, request.Temperature);
+ }
+
+ [Fact]
+ public void ChatResponse_Deserialize_FromSampleJson()
+ {
+ var json = """
+ {
+ "choices": [
+ {
+ "message": {
+ "role": "assistant",
+ "content": "I can help you with that!"
+ },
+ "finish_reason": "stop"
+ }
+ ],
+ "usage": {
+ "prompt_tokens": 25,
+ "completion_tokens": 15
+ }
+ }
+ """;
+
+ var response = JsonSerializer.Deserialize<ChatResponse>(json, JsonOptions);
+
+ Assert.NotNull(response);
+ Assert.Single(response.Choices);
+ Assert.Equal("assistant", response.Choices[0].Message.Role);
+ Assert.Equal("I can help you with that!", response.Choices[0].Message.Content);
+ Assert.Equal("stop", response.Choices[0].FinishReason);
+ Assert.NotNull(response.Usage);
+ Assert.Equal(25, response.Usage.PromptTokens);
+ Assert.Equal(15, response.Usage.CompletionTokens);
+ }
+
+ [Fact]
+ public void ChatResponse_Deserialize_WithToolCalls()
+ {
+ var json = """
+ {
+ "choices": [
+ {
+ "message": {
+ "role": "assistant",
+ "content": null,
+ "tool_calls": [
+ {
+ "id": "call_001",
+ "type": "function",
+ "function": {
+ "name": "query.select",
+ "arguments": "{\\\"sql\\\":\\\"SELECT * FROM users\\\"}"
+ }
+ }
+ ]
+ },
+ "finish_reason": "tool_calls"
+ }
+ ],
+ "usage": {
+ "prompt_tokens": 50,
+ "completion_tokens": 20
+ }
+ }
+ """;
+
+ var response = JsonSerializer.Deserialize<ChatResponse>(json, JsonOptions);
+
+ Assert.NotNull(response);
+ Assert.Single(response.Choices);
+ Assert.Equal("tool_calls", response.Choices[0].FinishReason);
+ var toolCalls = response.Choices[0].Message.ToolCalls;
+ Assert.NotNull(toolCalls);
+ Assert.Single(toolCalls!);
+ Assert.Equal("call_001", toolCalls![0].Id);
+ Assert.Equal("query.select", toolCalls[0].Function.Name);
+ }
+
+ [Fact]
+ public void ChatResponse_Deserialize_EmptyChoices_ReturnsEmpty()
+ {
+ var json = """{"choices":[],"usage":null}""";
+
+ var response = JsonSerializer.Deserialize<ChatResponse>(json, JsonOptions);
+
+ Assert.NotNull(response);
+ Assert.Empty(response.Choices);
+ }
+
+ [Fact]
+ public void ToolCall_DefaultValues_AreCorrect()
+ {
+ var toolCall = new ToolCall();
+
+ Assert.Equal("", toolCall.Id);
+ Assert.Equal("function", toolCall.Type);
+ Assert.NotNull(toolCall.Function);
+ }
+
+ [Fact]
+ public void ToolDefinition_DefaultValues_AreCorrect()
+ {
+ var definition = new ToolDefinition();
+
+ Assert.Equal("function", definition.Type);
+ Assert.NotNull(definition.Function);
+ }
+
+ [Fact]
+ public void FunctionDef_DefaultValues_AreCorrect()
+ {
+ var def = new FunctionDef();
+
+ Assert.Equal("", def.Name);
+ Assert.Equal("", def.Description);
+ Assert.Null(def.Parameters);
+ }
+
+ [Fact]
+ public void ChatRequest_DefaultValues_AreCorrect()
+ {
+ var request = new ChatRequest();
+
+ Assert.Equal("gpt-4o", request.Model);
+ Assert.NotNull(request.Messages);
+ Assert.Empty(request.Messages);
+ Assert.Null(request.Tools);
+ Assert.Equal(4096, request.MaxTokens);
+ Assert.Equal(0.1, request.Temperature);
+ }
+
+ [Fact]
+ public void ToolResult_SetProperties_WorkCorrectly()
+ {
+ var result = new ToolResult
+ {
+ ToolCallId = "call_test",
+ Output = "Query executed successfully",
+ Error = null
+ };
+
+ Assert.Equal("call_test", result.ToolCallId);
+ Assert.Equal("Query executed successfully", result.Output);
+ Assert.Null(result.Error);
+ }
+
+ [Fact]
+ public void ToolResult_ErrorScenario_WorksCorrectly()
+ {
+ var result = new ToolResult
+ {
+ ToolCallId = "call_error",
+ Output = null,
+ Error = "Unknown tool: invalid.tool"
+ };
+
+ Assert.Equal("call_error", result.ToolCallId);
+ Assert.Null(result.Output);
+ Assert.Equal("Unknown tool: invalid.tool", result.Error);
+ }
+
+ [Fact]
+ public void ChatRequest_SerializeDeserialize_RoundTrip()
+ {
+ var original = new ChatRequest
+ {
+ Model = "gpt-4o",
+ Messages = new List<ChatMessage>
+ {
+ new() { Role = "system", Content = "System message" },
+ new() { Role = "user", Content = "User message" }
+ },
+ Tools = new List<ToolDefinition>
+ {
+ new()
+ {
+ Function = new FunctionDef
+ {
+ Name = "test_tool",
+ Description = "A test tool"
+ }
+ }
+ },
+ MaxTokens = 100,
+ Temperature = 0.7
+ };
+
+ var json = JsonSerializer.Serialize(original, JsonOptions);
+ var deserialized = JsonSerializer.Deserialize<ChatRequest>(json, JsonOptions);
+
+ Assert.NotNull(deserialized);
+ Assert.Equal(original.Model, deserialized.Model);
+ Assert.Equal(original.Messages.Count, deserialized.Messages.Count);
+ Assert.Equal(original.MaxTokens, deserialized.MaxTokens);
+ Assert.Equal(original.Temperature, deserialized.Temperature);
+ Assert.NotNull(deserialized.Tools);
+ Assert.Single(deserialized.Tools);
+ }
+}
\ No newline at end of file
diff --git a/tests/NewLife.Studio.AI.Tests/NewLife.Studio.AI.Tests.csproj b/tests/NewLife.Studio.AI.Tests/NewLife.Studio.AI.Tests.csproj
new file mode 100644
index 0000000..f073203
--- /dev/null
+++ b/tests/NewLife.Studio.AI.Tests/NewLife.Studio.AI.Tests.csproj
@@ -0,0 +1,26 @@
+<Project Sdk="Microsoft.NET.Sdk">
+
+ <PropertyGroup>
+ <TargetFramework>net9.0</TargetFramework>
+ <ImplicitUsings>enable</ImplicitUsings>
+ <Nullable>enable</Nullable>
+ <IsPackable>false</IsPackable>
+ <IsTestProject>true</IsTestProject>
+ <RootNamespace>NewLife.Studio.AI.Tests</RootNamespace>
+ </PropertyGroup>
+
+ <ItemGroup>
+ <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.13.0" />
+ <PackageReference Include="xunit" Version="2.9.3" />
+ <PackageReference Include="xunit.runner.visualstudio" Version="3.0.2" />
+ <PackageReference Include="coverlet.collector" Version="6.0.4">
+ <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
+ <PrivateAssets>all</PrivateAssets>
+ </PackageReference>
+ </ItemGroup>
+
+ <ItemGroup>
+ <ProjectReference Include="..\..\src\Framework\NewLife.Studio.AI\NewLife.Studio.AI.csproj" />
+ </ItemGroup>
+
+</Project>
\ No newline at end of file
diff --git a/tests/NewLife.Studio.AI.Tests/QuerySafetyFilterTests.cs b/tests/NewLife.Studio.AI.Tests/QuerySafetyFilterTests.cs
new file mode 100644
index 0000000..30a4334
--- /dev/null
+++ b/tests/NewLife.Studio.AI.Tests/QuerySafetyFilterTests.cs
@@ -0,0 +1,233 @@
+using NewLife.Studio.AI.Safety;
+using Xunit;
+
+namespace NewLife.Studio.AI.Tests;
+
+public class QuerySafetyFilterTests
+{
+ [Fact]
+ public void Validate_SimpleSelect_ReturnsSafe()
+ {
+ var (isSafe, reason) = QuerySafetyFilter.Validate("SELECT * FROM users");
+
+ Assert.True(isSafe);
+ Assert.Null(reason);
+ }
+
+ [Fact]
+ public void Validate_SelectWithWhere_ReturnsSafe()
+ {
+ var (isSafe, reason) = QuerySafetyFilter.Validate("SELECT id, name FROM users WHERE age > 18");
+
+ Assert.True(isSafe);
+ Assert.Null(reason);
+ }
+
+ [Fact]
+ public void Validate_SelectWithJoin_ReturnsSafe()
+ {
+ var (isSafe, reason) = QuerySafetyFilter.Validate(
+ "SELECT u.name, o.total FROM users u INNER JOIN orders o ON u.id = o.user_id");
+
+ Assert.True(isSafe);
+ Assert.Null(reason);
+ }
+
+ [Fact]
+ public void Validate_Explain_ReturnsSafe()
+ {
+ var (isSafe, reason) = QuerySafetyFilter.Validate("EXPLAIN SELECT * FROM users");
+
+ Assert.True(isSafe);
+ Assert.Null(reason);
+ }
+
+ [Fact]
+ public void Validate_Pragma_ReturnsSafe()
+ {
+ var (isSafe, reason) = QuerySafetyFilter.Validate("PRAGMA table_info(users)");
+
+ Assert.True(isSafe);
+ Assert.Null(reason);
+ }
+
+ [Fact]
+ public void Validate_WithClause_ReturnsSafe()
+ {
+ var (isSafe, reason) = QuerySafetyFilter.Validate(
+ "WITH cte AS (SELECT id FROM users) SELECT * FROM cte");
+
+ Assert.True(isSafe);
+ Assert.Null(reason);
+ }
+
+ [Fact]
+ public void Validate_LowerCaseSelect_ReturnsSafe()
+ {
+ var (isSafe, reason) = QuerySafetyFilter.Validate("select * from users");
+
+ Assert.True(isSafe);
+ Assert.Null(reason);
+ }
+
+ [Fact]
+ public void Validate_MixedCaseSelect_ReturnsSafe()
+ {
+ var (isSafe, reason) = QuerySafetyFilter.Validate("Select * From users");
+
+ Assert.True(isSafe);
+ Assert.Null(reason);
+ }
+
+ [Fact]
+ public void Validate_Insert_ReturnsBlocked()
+ {
+ var (isSafe, reason) = QuerySafetyFilter.Validate("INSERT INTO users (name) VALUES ('test')");
+
+ Assert.False(isSafe);
+ Assert.NotNull(reason);
+ }
+
+ [Fact]
+ public void Validate_Update_ReturnsBlocked()
+ {
+ var (isSafe, reason) = QuerySafetyFilter.Validate("UPDATE users SET name = 'changed' WHERE id = 1");
+
+ Assert.False(isSafe);
+ Assert.NotNull(reason);
+ }
+
+ [Fact]
+ public void Validate_Delete_ReturnsBlocked()
+ {
+ var (isSafe, reason) = QuerySafetyFilter.Validate("DELETE FROM users WHERE id = 1");
+
+ Assert.False(isSafe);
+ Assert.NotNull(reason);
+ }
+
+ [Fact]
+ public void Validate_Drop_ReturnsBlocked()
+ {
+ var (isSafe, reason) = QuerySafetyFilter.Validate("DROP TABLE users");
+
+ Assert.False(isSafe);
+ Assert.NotNull(reason);
+ }
+
+ [Fact]
+ public void Validate_Create_ReturnsBlocked()
+ {
+ var (isSafe, reason) = QuerySafetyFilter.Validate("CREATE TABLE new_table (id INTEGER PRIMARY KEY)");
+
+ Assert.False(isSafe);
+ Assert.NotNull(reason);
+ }
+
+ [Fact]
+ public void Validate_Alter_ReturnsBlocked()
+ {
+ var (isSafe, reason) = QuerySafetyFilter.Validate("ALTER TABLE users ADD COLUMN email TEXT");
+
+ Assert.False(isSafe);
+ Assert.NotNull(reason);
+ }
+
+ [Fact]
+ public void Validate_Truncate_ReturnsBlocked()
+ {
+ var (isSafe, reason) = QuerySafetyFilter.Validate("TRUNCATE TABLE users");
+
+ Assert.False(isSafe);
+ Assert.NotNull(reason);
+ }
+
+ [Fact]
+ public void Validate_MultiStatement_ReturnsBlocked()
+ {
+ var (isSafe, reason) = QuerySafetyFilter.Validate("SELECT 1; DROP TABLE users");
+
+ Assert.False(isSafe);
+ Assert.NotNull(reason);
+ Assert.Contains("多语句", reason);
+ }
+
+ [Fact]
+ public void Validate_MultiStatementWithSemicolonInMiddle_ReturnsBlocked()
+ {
+ var (isSafe, reason) = QuerySafetyFilter.Validate("SELECT * FROM users;INSERT INTO logs VALUES(1)");
+
+ Assert.False(isSafe);
+ Assert.NotNull(reason);
+ }
+
+ [Fact]
+ public void Validate_EmptyString_ReturnsBlocked()
+ {
+ var (isSafe, reason) = QuerySafetyFilter.Validate("");
+
+ Assert.False(isSafe);
+ Assert.NotNull(reason);
+ Assert.Contains("只读", reason);
+ }
+
+ [Fact]
+ public void Validate_WhitespaceString_ReturnsBlocked()
+ {
+ var (isSafe, reason) = QuerySafetyFilter.Validate(" ");
+
+ Assert.False(isSafe);
+ Assert.NotNull(reason);
+ Assert.Contains("只读", reason);
+ }
+
+ [Fact]
+ public void Validate_Grant_ReturnsBlocked()
+ {
+ var (isSafe, reason) = QuerySafetyFilter.Validate("GRANT SELECT ON users TO someone");
+
+ Assert.False(isSafe);
+ Assert.NotNull(reason);
+ }
+
+ [Fact]
+ public void Validate_Merge_ReturnsBlocked()
+ {
+ var (isSafe, reason) = QuerySafetyFilter.Validate("MERGE INTO users USING source ON users.id = source.id");
+
+ Assert.False(isSafe);
+ Assert.NotNull(reason);
+ }
+
+ [Fact]
+ public void Validate_LowerCaseInsert_ReturnsBlocked()
+ {
+ var (isSafe, reason) = QuerySafetyFilter.Validate("insert into users (name) values ('test')");
+
+ Assert.False(isSafe);
+ Assert.NotNull(reason);
+ }
+
+ [Fact]
+ public void Validate_RandomNonSql_ReturnsBlocked()
+ {
+ var (isSafe, reason) = QuerySafetyFilter.Validate("this is not sql at all");
+
+ Assert.False(isSafe);
+ Assert.NotNull(reason);
+ Assert.Contains("只读", reason);
+ }
+
+ /// <summary>
+ /// A SELECT containing a DML keyword in the middle (not via semicolon)
+ /// should still be blocked by the keyword-in-text check.
+ /// </summary>
+ [Fact]
+ public void Validate_SelectWithEmbeddedDmlKeyword_ReturnsBlocked()
+ {
+ var (isSafe, reason) = QuerySafetyFilter.Validate("SELECT * FROM users UPDATE accounts SET balance = 0");
+
+ Assert.False(isSafe);
+ Assert.NotNull(reason);
+ }
+}
\ No newline at end of file
diff --git a/tests/NewLife.Studio.AI.Tests/ToolRegistryTests.cs b/tests/NewLife.Studio.AI.Tests/ToolRegistryTests.cs
new file mode 100644
index 0000000..ab29efd
--- /dev/null
+++ b/tests/NewLife.Studio.AI.Tests/ToolRegistryTests.cs
@@ -0,0 +1,157 @@
+using NewLife.Studio.AI.Models;
+using NewLife.Studio.AI.ToolCalling;
+using Xunit;
+
+namespace NewLife.Studio.AI.Tests;
+
+public class ToolRegistryTests
+{
+ private readonly ToolRegistry _registry;
+
+ public ToolRegistryTests()
+ {
+ _registry = new ToolRegistry();
+ }
+
+ [Fact]
+ public void GetAllDefinitions_BeforeAnyRegistration_ReturnsEmptyList()
+ {
+ var definitions = _registry.GetAllDefinitions();
+
+ Assert.NotNull(definitions);
+ Assert.Empty(definitions);
+ }
+
+ [Fact]
+ public void Register_ThenGetAllDefinitions_ReturnsRegisteredTool()
+ {
+ _registry.Register("test.tool", "A test tool", null, _ => Task.FromResult("done"));
+
+ var definitions = _registry.GetAllDefinitions();
+
+ Assert.Single(definitions);
+ Assert.Equal("test.tool", definitions[0].Function.Name);
+ Assert.Equal("A test tool", definitions[0].Function.Description);
+ Assert.Equal("function", definitions[0].Type);
+ }
+
+ [Fact]
+ public void Register_MultipleTools_ReturnsAllDefinitions()
+ {
+ _registry.Register("tool1", "Tool 1", null, _ => Task.FromResult("1"));
+ _registry.Register("tool2", "Tool 2", null, _ => Task.FromResult("2"));
+ _registry.Register("tool3", "Tool 3", null, _ => Task.FromResult("3"));
+
+ var definitions = _registry.GetAllDefinitions();
+
+ Assert.Equal(3, definitions.Count);
+ Assert.Contains(definitions, d => d.Function.Name == "tool1");
+ Assert.Contains(definitions, d => d.Function.Name == "tool2");
+ Assert.Contains(definitions, d => d.Function.Name == "tool3");
+ }
+
+ [Fact]
+ public void Register_DuplicateName_OverwritesPrevious()
+ {
+ _registry.Register("dup.tool", "First registration", null, _ => Task.FromResult("first"));
+ _registry.Register("dup.tool", "Second registration", null, _ => Task.FromResult("second"));
+
+ var definitions = _registry.GetAllDefinitions();
+
+ Assert.Single(definitions);
+ Assert.Equal("Second registration", definitions[0].Function.Description);
+ }
+
+ [Fact]
+ public async Task ExecuteAsync_RegisteredTool_CallsHandlerAndReturnsOutput()
+ {
+ _registry.Register("echo", "Echo tool", new { type = "object", properties = new { } },
+ args => Task.FromResult($"Echo: {args}"));
+
+ var toolCall = new ToolCall
+ {
+ Id = "call_001",
+ Function = new FunctionCall
+ {
+ Name = "echo",
+ Arguments = "{\"message\":\"hello\"}"
+ }
+ };
+
+ var result = await _registry.ExecuteAsync(toolCall);
+
+ Assert.NotNull(result);
+ Assert.Equal("call_001", result.ToolCallId);
+ Assert.Null(result.Error);
+ Assert.Equal("Echo: {\"message\":\"hello\"}", result.Output);
+ }
+
+ [Fact]
+ public async Task ExecuteAsync_UnknownTool_ReturnsError()
+ {
+ var toolCall = new ToolCall
+ {
+ Id = "call_002",
+ Function = new FunctionCall
+ {
+ Name = "nonexistent",
+ Arguments = "{}"
+ }
+ };
+
+ var result = await _registry.ExecuteAsync(toolCall);
+
+ Assert.NotNull(result);
+ Assert.Equal("call_002", result.ToolCallId);
+ Assert.NotNull(result.Error);
+ Assert.Contains("Unknown tool", result.Error);
+ Assert.Contains("nonexistent", result.Error);
+ Assert.Null(result.Output);
+ }
+
+ [Fact]
+ public async Task ExecuteAsync_HandlerThrowsException_ReturnsError()
+ {
+ _registry.Register("failing.tool", "Always fails", null, args =>
+ {
+ throw new InvalidOperationException("Simulated handler failure");
+ });
+
+ var toolCall = new ToolCall
+ {
+ Id = "call_003",
+ Function = new FunctionCall
+ {
+ Name = "failing.tool",
+ Arguments = "{}"
+ }
+ };
+
+ var result = await _registry.ExecuteAsync(toolCall);
+
+ Assert.NotNull(result);
+ Assert.Equal("call_003", result.ToolCallId);
+ Assert.NotNull(result.Error);
+ Assert.Contains("Simulated handler failure", result.Error);
+ Assert.Null(result.Output);
+ }
+
+ [Fact]
+ public void Register_WithParametersSchema_StoresParametersCorrectly()
+ {
+ var parameters = new
+ {
+ type = "object",
+ properties = new
+ {
+ sql = new { type = "string", description = "SQL 语句" }
+ },
+ required = new[] { "sql" }
+ };
+
+ _registry.Register("query.select", "Execute SELECT", parameters, _ => Task.FromResult("ok"));
+
+ var definitions = _registry.GetAllDefinitions();
+ Assert.NotNull(definitions[0].Function.Parameters);
+ }
+}
\ No newline at end of file
diff --git a/tests/NewLife.Studio.Core.Tests/DTOs/AiProfileTests.cs b/tests/NewLife.Studio.Core.Tests/DTOs/AiProfileTests.cs
new file mode 100644
index 0000000..368f45f
--- /dev/null
+++ b/tests/NewLife.Studio.Core.Tests/DTOs/AiProfileTests.cs
@@ -0,0 +1,84 @@
+using NewLife.Studio.Core.DTOs;
+using Xunit;
+
+namespace NewLife.Studio.Core.Tests.DTOs;
+
+public class AiProfileTests
+{
+ [Fact]
+ public void Default_Values_Are_Set_Correctly()
+ {
+ var profile = new AiProfile();
+
+ Assert.Equal("openai", profile.ProviderType);
+ Assert.Equal("https://api.openai.com/v1", profile.Endpoint);
+ Assert.Equal("", profile.ApiKey);
+ Assert.Equal("gpt-4o", profile.Model);
+ }
+
+ [Fact]
+ public void Properties_Set_And_Get_Correctly()
+ {
+ var profile = new AiProfile
+ {
+ ProviderType = "azure",
+ Endpoint = "https://my-openai.openai.azure.com",
+ ApiKey = "sk-abc123",
+ Model = "gpt-4"
+ };
+
+ Assert.Equal("azure", profile.ProviderType);
+ Assert.Equal("https://my-openai.openai.azure.com", profile.Endpoint);
+ Assert.Equal("sk-abc123", profile.ApiKey);
+ Assert.Equal("gpt-4", profile.Model);
+ }
+
+ [Fact]
+ public void Null_Values_Can_Be_Assigned()
+ {
+ var profile = new AiProfile
+ {
+ ProviderType = null!,
+ Endpoint = null!,
+ ApiKey = null!,
+ Model = null!
+ };
+
+ Assert.Null(profile.ProviderType);
+ Assert.Null(profile.Endpoint);
+ Assert.Null(profile.ApiKey);
+ Assert.Null(profile.Model);
+ }
+
+ [Fact]
+ public void Empty_String_Values_Are_Allowed()
+ {
+ var profile = new AiProfile
+ {
+ ProviderType = "",
+ Endpoint = "",
+ ApiKey = "",
+ Model = ""
+ };
+
+ Assert.Equal("", profile.ProviderType);
+ Assert.Equal("", profile.Endpoint);
+ Assert.Equal("", profile.ApiKey);
+ Assert.Equal("", profile.Model);
+ }
+
+ [Fact]
+ public void Custom_Provider_Configuration_Works()
+ {
+ var profile = new AiProfile
+ {
+ ProviderType = "ollama",
+ Endpoint = "http://localhost:11434/v1",
+ Model = "llama3"
+ };
+
+ Assert.Equal("ollama", profile.ProviderType);
+ Assert.Equal("http://localhost:11434/v1", profile.Endpoint);
+ Assert.Equal("llama3", profile.Model);
+ }
+}
\ No newline at end of file
diff --git a/tests/NewLife.Studio.Core.Tests/DTOs/AppPreferenceTests.cs b/tests/NewLife.Studio.Core.Tests/DTOs/AppPreferenceTests.cs
new file mode 100644
index 0000000..18e4125
--- /dev/null
+++ b/tests/NewLife.Studio.Core.Tests/DTOs/AppPreferenceTests.cs
@@ -0,0 +1,80 @@
+using NewLife.Studio.Core.DTOs;
+using Xunit;
+
+namespace NewLife.Studio.Core.Tests.DTOs;
+
+public class AppPreferenceTests
+{
+ [Fact]
+ public void Default_Values_Are_Set_Correctly()
+ {
+ var pref = new AppPreference();
+
+ Assert.Equal(1000, pref.MaxRows);
+ Assert.Equal("", pref.DefaultExportPath);
+ Assert.Equal("Light", pref.Theme);
+ Assert.Equal("zh-CN", pref.Language);
+ }
+
+ [Fact]
+ public void Properties_Set_And_Get_Correctly()
+ {
+ var pref = new AppPreference
+ {
+ MaxRows = 500,
+ DefaultExportPath = "C:\\Exports\\data.csv",
+ Theme = "Dark",
+ Language = "en-US"
+ };
+
+ Assert.Equal(500, pref.MaxRows);
+ Assert.Equal("C:\\Exports\\data.csv", pref.DefaultExportPath);
+ Assert.Equal("Dark", pref.Theme);
+ Assert.Equal("en-US", pref.Language);
+ }
+
+ [Fact]
+ public void Null_Values_Can_Be_Assigned()
+ {
+ var pref = new AppPreference
+ {
+ DefaultExportPath = null!,
+ Theme = null!,
+ Language = null!
+ };
+
+ Assert.Null(pref.DefaultExportPath);
+ Assert.Null(pref.Theme);
+ Assert.Null(pref.Language);
+ }
+
+ [Fact]
+ public void Empty_String_Values_Are_Allowed()
+ {
+ var pref = new AppPreference
+ {
+ DefaultExportPath = "",
+ Theme = "",
+ Language = ""
+ };
+
+ Assert.Equal("", pref.DefaultExportPath);
+ Assert.Equal("", pref.Theme);
+ Assert.Equal("", pref.Language);
+ }
+
+ [Fact]
+ public void MaxRows_Boundary_Values()
+ {
+ var pref = new AppPreference();
+
+ pref.MaxRows = 0;
+ Assert.Equal(0, pref.MaxRows);
+
+ pref.MaxRows = int.MaxValue;
+ Assert.Equal(int.MaxValue, pref.MaxRows);
+
+ pref.MaxRows = -1;
+ Assert.Equal(-1, pref.MaxRows);
+ }
+}
\ No newline at end of file
diff --git a/tests/NewLife.Studio.Core.Tests/DTOs/ConnectionInfoTests.cs b/tests/NewLife.Studio.Core.Tests/DTOs/ConnectionInfoTests.cs
new file mode 100644
index 0000000..9108075
--- /dev/null
+++ b/tests/NewLife.Studio.Core.Tests/DTOs/ConnectionInfoTests.cs
@@ -0,0 +1,97 @@
+using NewLife.Studio.Core.DTOs;
+using Xunit;
+
+namespace NewLife.Studio.Core.Tests.DTOs;
+
+public class ConnectionInfoTests
+{
+ [Fact]
+ public void Default_Values_Are_Set_Correctly()
+ {
+ var info = new ConnectionInfo();
+
+ Assert.NotEmpty(info.Id);
+ Assert.Equal(32, info.Id.Length); // Guid N format
+ Assert.Equal("", info.Name);
+ Assert.Equal("", info.ConnectionString);
+ Assert.Equal("sqlite", info.ProviderType);
+ Assert.Equal(default, info.LastUsedAt);
+ Assert.Equal("", info.Group);
+ }
+
+ [Fact]
+ public void Id_Default_Is_Valid_Guid()
+ {
+ var info1 = new ConnectionInfo();
+ var info2 = new ConnectionInfo();
+
+ Assert.True(Guid.TryParse(info1.Id, out _));
+ Assert.NotEqual(info1.Id, info2.Id);
+ }
+
+ [Fact]
+ public void Properties_Set_And_Get_Correctly()
+ {
+ var now = DateTime.UtcNow;
+ var info = new ConnectionInfo
+ {
+ Id = "conn-001",
+ Name = "My Database",
+ ConnectionString = "Server=localhost;Database=mydb",
+ ProviderType = "sqlserver",
+ LastUsedAt = now,
+ Group = "Production"
+ };
+
+ Assert.Equal("conn-001", info.Id);
+ Assert.Equal("My Database", info.Name);
+ Assert.Equal("Server=localhost;Database=mydb", info.ConnectionString);
+ Assert.Equal("sqlserver", info.ProviderType);
+ Assert.Equal(now, info.LastUsedAt);
+ Assert.Equal("Production", info.Group);
+ }
+
+ [Fact]
+ public void Null_Values_Can_Be_Assigned()
+ {
+ var info = new ConnectionInfo
+ {
+ Name = null!,
+ ConnectionString = null!,
+ Group = null!
+ };
+
+ Assert.Null(info.Name);
+ Assert.Null(info.ConnectionString);
+ Assert.Null(info.Group);
+ }
+
+ [Fact]
+ public void Empty_String_Values_Are_Handled()
+ {
+ var info = new ConnectionInfo
+ {
+ Name = "",
+ ConnectionString = "",
+ ProviderType = "",
+ Group = ""
+ };
+
+ Assert.Equal("", info.Name);
+ Assert.Equal("", info.ConnectionString);
+ Assert.Equal("", info.ProviderType);
+ Assert.Equal("", info.Group);
+ }
+
+ [Fact]
+ public void LastUsedAt_Boundary_Values()
+ {
+ var info = new ConnectionInfo();
+
+ info.LastUsedAt = DateTime.MinValue;
+ Assert.Equal(DateTime.MinValue, info.LastUsedAt);
+
+ info.LastUsedAt = DateTime.MaxValue;
+ Assert.Equal(DateTime.MaxValue, info.LastUsedAt);
+ }
+}
\ No newline at end of file
diff --git a/tests/NewLife.Studio.Core.Tests/DTOs/QueryHistoryEntryTests.cs b/tests/NewLife.Studio.Core.Tests/DTOs/QueryHistoryEntryTests.cs
new file mode 100644
index 0000000..0b9fd3e
--- /dev/null
+++ b/tests/NewLife.Studio.Core.Tests/DTOs/QueryHistoryEntryTests.cs
@@ -0,0 +1,123 @@
+using NewLife.Studio.Core.DTOs;
+using Xunit;
+
+namespace NewLife.Studio.Core.Tests.DTOs;
+
+public class QueryHistoryEntryTests
+{
+ [Fact]
+ public void Default_Values_Are_Set_Correctly()
+ {
+ var entry = new QueryHistoryEntry();
+
+ Assert.NotEmpty(entry.Id);
+ Assert.True(Guid.TryParse(entry.Id, out _));
+ Assert.Equal("", entry.Sql);
+ Assert.Equal("", entry.ConnectionName);
+ Assert.True(entry.ExecutedAt <= DateTime.Now);
+ Assert.True(entry.ExecutedAt > DateTime.Now.AddSeconds(-5));
+ Assert.Equal(0L, entry.ElapsedMs);
+ Assert.Equal(0, entry.RowCount);
+ }
+
+ [Fact]
+ public void Id_Default_Generates_Unique_Guid_Each_Time()
+ {
+ var entry1 = new QueryHistoryEntry();
+ var entry2 = new QueryHistoryEntry();
+
+ Assert.NotEqual(entry1.Id, entry2.Id);
+ Assert.True(Guid.TryParse(entry1.Id, out _));
+ Assert.True(Guid.TryParse(entry2.Id, out _));
+ }
+
+ [Fact]
+ public void Properties_Set_And_Get_Correctly()
+ {
+ var executedAt = new DateTime(2025, 5, 25, 10, 30, 0, DateTimeKind.Utc);
+ var entry = new QueryHistoryEntry
+ {
+ Id = "hist-001",
+ Sql = "SELECT * FROM Users",
+ ConnectionName = "Production DB",
+ ExecutedAt = executedAt,
+ ElapsedMs = 250,
+ RowCount = 1500
+ };
+
+ Assert.Equal("hist-001", entry.Id);
+ Assert.Equal("SELECT * FROM Users", entry.Sql);
+ Assert.Equal("Production DB", entry.ConnectionName);
+ Assert.Equal(executedAt, entry.ExecutedAt);
+ Assert.Equal(250L, entry.ElapsedMs);
+ Assert.Equal(1500, entry.RowCount);
+ }
+
+ [Fact]
+ public void Null_Values_Can_Be_Assigned()
+ {
+ var entry = new QueryHistoryEntry
+ {
+ Sql = null!,
+ ConnectionName = null!
+ };
+
+ Assert.Null(entry.Sql);
+ Assert.Null(entry.ConnectionName);
+ }
+
+ [Fact]
+ public void Empty_String_Values_Are_Allowed()
+ {
+ var entry = new QueryHistoryEntry
+ {
+ Sql = "",
+ ConnectionName = ""
+ };
+
+ Assert.Equal("", entry.Sql);
+ Assert.Equal("", entry.ConnectionName);
+ }
+
+ [Fact]
+ public void ElapsedMs_Boundary_Values()
+ {
+ var entry = new QueryHistoryEntry();
+
+ entry.ElapsedMs = 0;
+ Assert.Equal(0L, entry.ElapsedMs);
+
+ entry.ElapsedMs = long.MaxValue;
+ Assert.Equal(long.MaxValue, entry.ElapsedMs);
+
+ entry.ElapsedMs = -1;
+ Assert.Equal(-1L, entry.ElapsedMs);
+ }
+
+ [Fact]
+ public void RowCount_Boundary_Values()
+ {
+ var entry = new QueryHistoryEntry();
+
+ entry.RowCount = 0;
+ Assert.Equal(0, entry.RowCount);
+
+ entry.RowCount = int.MaxValue;
+ Assert.Equal(int.MaxValue, entry.RowCount);
+
+ entry.RowCount = -1;
+ Assert.Equal(-1, entry.RowCount);
+ }
+
+ [Fact]
+ public void ExecutedAt_Boundary_Values()
+ {
+ var entry = new QueryHistoryEntry();
+
+ entry.ExecutedAt = DateTime.MinValue;
+ Assert.Equal(DateTime.MinValue, entry.ExecutedAt);
+
+ entry.ExecutedAt = DateTime.MaxValue;
+ Assert.Equal(DateTime.MaxValue, entry.ExecutedAt);
+ }
+}
\ No newline at end of file
diff --git a/tests/NewLife.Studio.Core.Tests/DTOs/QueryResultTests.cs b/tests/NewLife.Studio.Core.Tests/DTOs/QueryResultTests.cs
new file mode 100644
index 0000000..b0380b4
--- /dev/null
+++ b/tests/NewLife.Studio.Core.Tests/DTOs/QueryResultTests.cs
@@ -0,0 +1,119 @@
+using NewLife.Studio.Core.DTOs;
+using Xunit;
+
+namespace NewLife.Studio.Core.Tests.DTOs;
+
+public class QueryResultTests
+{
+ [Fact]
+ public void Default_Values_Are_Set_Correctly()
+ {
+ var result = new QueryResult();
+
+ Assert.NotNull(result.Columns);
+ Assert.Empty(result.Columns);
+ Assert.NotNull(result.Rows);
+ Assert.Empty(result.Rows);
+ Assert.Equal(0, result.RowCount);
+ Assert.Equal(0L, result.ElapsedMs);
+ Assert.False(result.Truncated);
+ Assert.Null(result.Error);
+ }
+
+ [Fact]
+ public void Properties_Set_And_Get_Correctly()
+ {
+ var columns = new[]
+ {
+ new ColumnInfo { Name = "Id", DataType = "int" },
+ new ColumnInfo { Name = "Name", DataType = "varchar" }
+ };
+ var rows = new List<object?[]>
+ {
+ new object?[] { 1, "Alice" },
+ new object?[] { 2, "Bob" }
+ };
+
+ var result = new QueryResult
+ {
+ Columns = columns,
+ Rows = rows,
+ RowCount = 2,
+ ElapsedMs = 150,
+ Truncated = true,
+ Error = "Query timeout"
+ };
+
+ Assert.Equal(columns, result.Columns);
+ Assert.Equal(rows, result.Rows);
+ Assert.Equal(2, result.RowCount);
+ Assert.Equal(150L, result.ElapsedMs);
+ Assert.True(result.Truncated);
+ Assert.Equal("Query timeout", result.Error);
+ }
+
+ [Fact]
+ public void Error_Can_Be_Null()
+ {
+ var result = new QueryResult { Error = null };
+
+ Assert.Null(result.Error);
+ }
+
+ [Fact]
+ public void Empty_Columns_And_Rows_Are_Allowed()
+ {
+ var result = new QueryResult
+ {
+ Columns = [],
+ Rows = []
+ };
+
+ Assert.NotNull(result.Columns);
+ Assert.Empty(result.Columns);
+ Assert.NotNull(result.Rows);
+ Assert.Empty(result.Rows);
+ }
+
+ [Fact]
+ public void RowCount_Can_Differ_From_Rows_Count()
+ {
+ var result = new QueryResult
+ {
+ Rows = new List<object?[]> { new object?[] { 1 } },
+ RowCount = 1000 // truncated result
+ };
+
+ Assert.Single(result.Rows);
+ Assert.Equal(1000, result.RowCount);
+ }
+
+ [Fact]
+ public void ElapsedMs_Boundary_Values()
+ {
+ var result = new QueryResult();
+
+ result.ElapsedMs = 0;
+ Assert.Equal(0L, result.ElapsedMs);
+
+ result.ElapsedMs = long.MaxValue;
+ Assert.Equal(long.MaxValue, result.ElapsedMs);
+
+ result.ElapsedMs = -1;
+ Assert.Equal(-1L, result.ElapsedMs);
+ }
+
+ [Fact]
+ public void Truncated_Can_Be_Toggled()
+ {
+ var result = new QueryResult();
+
+ Assert.False(result.Truncated);
+
+ result.Truncated = true;
+ Assert.True(result.Truncated);
+
+ result.Truncated = false;
+ Assert.False(result.Truncated);
+ }
+}
\ No newline at end of file
diff --git a/tests/NewLife.Studio.Core.Tests/DTOs/TableInfoTests.cs b/tests/NewLife.Studio.Core.Tests/DTOs/TableInfoTests.cs
new file mode 100644
index 0000000..6454039
--- /dev/null
+++ b/tests/NewLife.Studio.Core.Tests/DTOs/TableInfoTests.cs
@@ -0,0 +1,86 @@
+using NewLife.Studio.Core.DTOs;
+using Xunit;
+
+namespace NewLife.Studio.Core.Tests.DTOs;
+
+public class TableInfoTests
+{
+ [Fact]
+ public void Default_Values_Are_Set_Correctly()
+ {
+ var info = new TableInfo();
+
+ Assert.Equal("", info.Name);
+ Assert.Equal("", info.Schema);
+ Assert.Equal(0L, info.RowCount);
+ }
+
+ [Fact]
+ public void Properties_Set_And_Get_Correctly()
+ {
+ var info = new TableInfo
+ {
+ Name = "Users",
+ Schema = "dbo",
+ RowCount = 50000
+ };
+
+ Assert.Equal("Users", info.Name);
+ Assert.Equal("dbo", info.Schema);
+ Assert.Equal(50000L, info.RowCount);
+ }
+
+ [Fact]
+ public void Null_Values_Can_Be_Assigned()
+ {
+ var info = new TableInfo
+ {
+ Name = null!,
+ Schema = null!
+ };
+
+ Assert.Null(info.Name);
+ Assert.Null(info.Schema);
+ }
+
+ [Fact]
+ public void Empty_String_Values_Are_Allowed()
+ {
+ var info = new TableInfo
+ {
+ Name = "",
+ Schema = ""
+ };
+
+ Assert.Equal("", info.Name);
+ Assert.Equal("", info.Schema);
+ }
+
+ [Fact]
+ public void RowCount_Boundary_Values()
+ {
+ var info = new TableInfo();
+
+ info.RowCount = 0;
+ Assert.Equal(0L, info.RowCount);
+
+ info.RowCount = long.MaxValue;
+ Assert.Equal(long.MaxValue, info.RowCount);
+
+ info.RowCount = -1;
+ Assert.Equal(-1L, info.RowCount);
+ }
+
+ [Fact]
+ public void Schema_Can_Be_Empty_When_No_Schema_Used()
+ {
+ var info = new TableInfo
+ {
+ Name = "sqlite_table",
+ Schema = ""
+ };
+
+ Assert.Equal("sqlite_table", info.Name);
+ Assert.Equal("", info.Schema);
+ }
+}
\ No newline at end of file
diff --git a/tests/NewLife.Studio.Core.Tests/ModuleInfoTests.cs b/tests/NewLife.Studio.Core.Tests/ModuleInfoTests.cs
new file mode 100644
index 0000000..c144cfa
--- /dev/null
+++ b/tests/NewLife.Studio.Core.Tests/ModuleInfoTests.cs
@@ -0,0 +1,82 @@
+using Avalonia.Controls;
+using NewLife.Studio.Core;
+using Xunit;
+
+namespace NewLife.Studio.Core.Tests;
+
+public class ModuleInfoTests
+{
+ private class TestModule : IStudioModule
+ {
+ public string Id => "test-module";
+ public string DisplayName => "Test Module";
+ public string Icon => "test-icon";
+ public int Order => 10;
+
+ public Task OnActivateAsync(CancellationToken ct = default) => Task.CompletedTask;
+ public Task OnDeactivateAsync(CancellationToken ct = default) => Task.CompletedTask;
+ public Control GetView() => new TextBlock();
+ }
+
+ [Fact]
+ public void All_Properties_Are_Required_And_Init_Only()
+ {
+ var module = new TestModule();
+ var info = new ModuleInfo
+ {
+ Id = "mod-001",
+ DisplayName = "My Module",
+ Icon = "icon-star",
+ Order = 5,
+ Module = module
+ };
+
+ Assert.Equal("mod-001", info.Id);
+ Assert.Equal("My Module", info.DisplayName);
+ Assert.Equal("icon-star", info.Icon);
+ Assert.Equal(5, info.Order);
+ Assert.Same(module, info.Module);
+ }
+
+ [Fact]
+ public void Order_Default_Is_Zero_When_Not_Specified()
+ {
+ var module = new TestModule();
+ var info = new ModuleInfo
+ {
+ Id = "mod-default",
+ DisplayName = "Default Order",
+ Icon = "icon-default",
+ Module = module
+ };
+
+ Assert.Equal(0, info.Order);
+ }
+
+ [Fact]
+ public void ModuleInfo_Is_Immutable_After_Construction()
+ {
+ var module = new TestModule();
+ var info = new ModuleInfo
+ {
+ Id = "mod-immutable",
+ DisplayName = "Immutable Module",
+ Icon = "icon-lock",
+ Order = 99,
+ Module = module
+ };
+
+ var originalId = info.Id;
+ var originalName = info.DisplayName;
+ var originalIcon = info.Icon;
+ var originalOrder = info.Order;
+ var originalModule = info.Module;
+
+ // Properties should return same values after repeated access
+ Assert.Equal(originalId, info.Id);
+ Assert.Equal(originalName, info.DisplayName);
+ Assert.Equal(originalIcon, info.Icon);
+ Assert.Equal(originalOrder, info.Order);
+ Assert.Same(originalModule, info.Module);
+ }
+}
\ No newline at end of file
diff --git a/tests/NewLife.Studio.Core.Tests/NewLife.Studio.Core.Tests.csproj b/tests/NewLife.Studio.Core.Tests/NewLife.Studio.Core.Tests.csproj
new file mode 100644
index 0000000..53e3aac
--- /dev/null
+++ b/tests/NewLife.Studio.Core.Tests/NewLife.Studio.Core.Tests.csproj
@@ -0,0 +1,26 @@
+<Project Sdk="Microsoft.NET.Sdk">
+
+ <PropertyGroup>
+ <TargetFramework>net9.0</TargetFramework>
+ <ImplicitUsings>enable</ImplicitUsings>
+ <Nullable>enable</Nullable>
+ <IsPackable>false</IsPackable>
+ <IsTestProject>true</IsTestProject>
+ <RootNamespace>NewLife.Studio.Core.Tests</RootNamespace>
+ </PropertyGroup>
+
+ <ItemGroup>
+ <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.13.0" />
+ <PackageReference Include="xunit" Version="2.9.3" />
+ <PackageReference Include="xunit.runner.visualstudio" Version="3.0.2" />
+ <PackageReference Include="coverlet.collector" Version="6.0.4">
+ <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
+ <PrivateAssets>all</PrivateAssets>
+ </PackageReference>
+ </ItemGroup>
+
+ <ItemGroup>
+ <ProjectReference Include="..\..\src\Framework\NewLife.Studio.Core\NewLife.Studio.Core.csproj" />
+ </ItemGroup>
+
+</Project>
\ No newline at end of file
diff --git a/tests/NewLife.Studio.Core.Tests/StudioExceptionTests.cs b/tests/NewLife.Studio.Core.Tests/StudioExceptionTests.cs
new file mode 100644
index 0000000..75fa4c1
--- /dev/null
+++ b/tests/NewLife.Studio.Core.Tests/StudioExceptionTests.cs
@@ -0,0 +1,110 @@
+using NewLife.Studio.Core;
+using Xunit;
+
+namespace NewLife.Studio.Core.Tests;
+
+public class StudioExceptionTests
+{
+ [Fact]
+ public void Constructor_With_Message_Sets_Message_Property()
+ {
+ var ex = new StudioException("An error occurred");
+
+ Assert.Equal("An error occurred", ex.Message);
+ Assert.Null(ex.InnerException);
+ }
+
+ [Fact]
+ public void Constructor_With_Message_And_InnerException_Sets_Both()
+ {
+ var inner = new InvalidOperationException("inner error");
+ var ex = new StudioException("outer error", inner);
+
+ Assert.Equal("outer error", ex.Message);
+ Assert.Same(inner, ex.InnerException);
+ }
+
+ [Fact]
+ public void StudioException_Inherits_From_Exception()
+ {
+ var ex = new StudioException("test");
+
+ Assert.IsAssignableFrom<Exception>(ex);
+ }
+
+ [Fact]
+ public void Can_Be_Caught_As_StudioException()
+ {
+ var caught = false;
+ try
+ {
+ throw new StudioException("test throw");
+ }
+ catch (StudioException)
+ {
+ caught = true;
+ }
+
+ Assert.True(caught);
+ }
+
+ [Fact]
+ public void Can_Be_Caught_As_Exception()
+ {
+ var caught = false;
+ try
+ {
+ throw new StudioException("test throw");
+ }
+ catch (Exception)
+ {
+ caught = true;
+ }
+
+ Assert.True(caught);
+ }
+
+ [Fact]
+ public void Empty_Message_Is_Allowed()
+ {
+ var ex = new StudioException("");
+
+ Assert.Equal("", ex.Message);
+ }
+
+ [Fact]
+ public void Null_InnerException_Is_Allowed()
+ {
+ var ex = new StudioException("test", null!);
+
+ Assert.Null(ex.InnerException);
+ }
+
+ [Fact]
+ public void StackTrace_Is_Preserved()
+ {
+ StudioException? ex = null;
+ try
+ {
+ throw new StudioException("test");
+ }
+ catch (StudioException caught)
+ {
+ ex = caught;
+ }
+
+ Assert.NotNull(ex);
+ Assert.NotNull(ex.StackTrace);
+ }
+
+ [Fact]
+ public void Nested_Inner_Exception_Chain_Works()
+ {
+ var innerMost = new ArgumentException("innermost");
+ var inner = new StudioException("middle", innerMost);
+ var outer = new StudioException("outer", inner);
+
+ Assert.Same(inner, outer.InnerException);
+ Assert.Same(innerMost, outer.InnerException?.InnerException);
+ }
+}
\ No newline at end of file
diff --git a/tests/NewLife.Studio.Core.Tests/StudioServicesTests.cs b/tests/NewLife.Studio.Core.Tests/StudioServicesTests.cs
new file mode 100644
index 0000000..66196bf
--- /dev/null
+++ b/tests/NewLife.Studio.Core.Tests/StudioServicesTests.cs
@@ -0,0 +1,134 @@
+using NewLife.Studio.Core;
+using Xunit;
+
+namespace NewLife.Studio.Core.Tests;
+
+public class StudioServicesTests
+{
+ /// <summary>Minimal IServiceProvider stub for testing without DI package dependency.</summary>
+ private class TestServiceProvider : IServiceProvider
+ {
+ private readonly Dictionary<Type, object> _services = new();
+
+ public void Register<T>(T instance) where T : class
+ {
+ _services[typeof(T)] = instance;
+ }
+
+ public object? GetService(Type serviceType)
+ {
+ _services.TryGetValue(serviceType, out var service);
+ return service;
+ }
+ }
+
+ [Fact]
+ public void Initialize_With_Null_Throws_ArgumentNullException()
+ {
+ var ex = Assert.Throws<ArgumentNullException>(() => StudioServices.Initialize(null!));
+ Assert.Equal("serviceProvider", ex.ParamName);
+ }
+
+ [Fact]
+ public void GetRequiredService_Throws_When_Not_Initialized()
+ {
+ // We cannot easily reset the static field, so we test that
+ // calling GetRequiredService on the uninitialized static throws.
+ // Since other tests may have initialized it, this test verifies
+ // the behavior when _serviceProvider is null.
+ // Use reflection to reset for a clean test.
+ ResetStudioServices();
+
+ var ex = Assert.Throws<InvalidOperationException>(() => StudioServices.GetRequiredService<string>());
+ Assert.Contains("not initialized", ex.Message);
+ }
+
+ [Fact]
+ public void Initialize_Then_GetRequiredService_Returns_Registered_Service()
+ {
+ ResetStudioServices();
+
+ var provider = new TestServiceProvider();
+ var myService = "Hello, World!";
+ provider.Register(myService);
+
+ StudioServices.Initialize(provider);
+
+ var result = StudioServices.GetRequiredService<string>();
+ Assert.Equal("Hello, World!", result);
+ }
+
+ [Fact]
+ public void Initialize_Then_GetService_Returns_Registered_Service()
+ {
+ ResetStudioServices();
+
+ var provider = new TestServiceProvider();
+ var myService = "Hello, Service!";
+ provider.Register(myService);
+
+ StudioServices.Initialize(provider);
+
+ var result = StudioServices.GetService<string>();
+ Assert.NotNull(result);
+ Assert.Equal("Hello, Service!", result);
+ }
+
+ [Fact]
+ public void GetService_Returns_Null_When_Service_Not_Registered()
+ {
+ ResetStudioServices();
+
+ var provider = new TestServiceProvider();
+ StudioServices.Initialize(provider);
+
+ var result = StudioServices.GetService<string>();
+ Assert.Null(result);
+ }
+
+ [Fact]
+ public void GetRequiredService_Throws_When_Service_Not_Registered()
+ {
+ ResetStudioServices();
+
+ var provider = new TestServiceProvider();
+ StudioServices.Initialize(provider);
+
+ var ex = Assert.Throws<InvalidOperationException>(() => StudioServices.GetRequiredService<System.Text.StringBuilder>());
+ Assert.Contains("StringBuilder", ex.Message);
+ Assert.Contains("not registered", ex.Message);
+ }
+
+ [Fact]
+ public void GetService_Returns_Null_When_Not_Initialized()
+ {
+ ResetStudioServices();
+
+ var result = StudioServices.GetService<string>();
+ Assert.Null(result);
+ }
+
+ [Fact]
+ public void Initialize_Can_Be_Called_Multiple_Times()
+ {
+ ResetStudioServices();
+
+ var provider1 = new TestServiceProvider();
+ provider1.Register("service1");
+ StudioServices.Initialize(provider1);
+ Assert.Equal("service1", StudioServices.GetRequiredService<string>());
+
+ var provider2 = new TestServiceProvider();
+ provider2.Register("service2");
+ StudioServices.Initialize(provider2);
+ Assert.Equal("service2", StudioServices.GetRequiredService<string>());
+ }
+
+ /// <summary>Reset the static service provider to null for clean test state.</summary>
+ private static void ResetStudioServices()
+ {
+ var field = typeof(StudioServices).GetField("_serviceProvider",
+ System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static);
+ field?.SetValue(null, null);
+ }
+}
\ No newline at end of file
diff --git a/tests/NewLife.Studio.Data.Tests/NewLife.Studio.Data.Tests.csproj b/tests/NewLife.Studio.Data.Tests/NewLife.Studio.Data.Tests.csproj
new file mode 100644
index 0000000..4eb181b
--- /dev/null
+++ b/tests/NewLife.Studio.Data.Tests/NewLife.Studio.Data.Tests.csproj
@@ -0,0 +1,30 @@
+<Project Sdk="Microsoft.NET.Sdk">
+
+ <PropertyGroup>
+ <TargetFramework>net9.0</TargetFramework>
+ <ImplicitUsings>enable</ImplicitUsings>
+ <Nullable>enable</Nullable>
+ <IsPackable>false</IsPackable>
+ <IsTestProject>true</IsTestProject>
+ <RootNamespace>NewLife.Studio.Data.Tests</RootNamespace>
+ </PropertyGroup>
+
+ <ItemGroup>
+ <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.13.0" />
+ <PackageReference Include="xunit" Version="2.9.3" />
+ <PackageReference Include="xunit.runner.visualstudio" Version="3.0.2" />
+ <PackageReference Include="coverlet.collector" Version="6.0.4">
+ <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
+ <PrivateAssets>all</PrivateAssets>
+ </PackageReference>
+ </ItemGroup>
+
+ <ItemGroup>
+ <PackageReference Include="Microsoft.Data.Sqlite" Version="9.0.0" />
+ </ItemGroup>
+
+ <ItemGroup>
+ <ProjectReference Include="..\..\src\Providers\NewLife.Studio.Data\NewLife.Studio.Data.csproj" />
+ </ItemGroup>
+
+</Project>
\ No newline at end of file
diff --git a/tests/NewLife.Studio.Data.Tests/SQLiteMetadataReaderTests.cs b/tests/NewLife.Studio.Data.Tests/SQLiteMetadataReaderTests.cs
new file mode 100644
index 0000000..1d18785
--- /dev/null
+++ b/tests/NewLife.Studio.Data.Tests/SQLiteMetadataReaderTests.cs
@@ -0,0 +1,139 @@
+using Microsoft.Data.Sqlite;
+using NewLife.Studio.Core.DTOs;
+using NewLife.Studio.Data.Providers.SQLite;
+using Xunit;
+
+namespace NewLife.Studio.Data.Tests;
+
+public class SQLiteMetadataReaderTests : IDisposable
+{
+ private readonly SqliteConnection _connection;
+
+ public SQLiteMetadataReaderTests()
+ {
+ _connection = new SqliteConnection("Data Source=:memory:");
+ _connection.Open();
+ }
+
+ public void Dispose()
+ {
+ _connection?.Dispose();
+ }
+
+ private void ExecuteSql(string sql)
+ {
+ using var cmd = _connection.CreateCommand();
+ cmd.CommandText = sql;
+ cmd.ExecuteNonQuery();
+ }
+
+ private SqliteDataReader ExecutePragmaTableInfo(string tableName)
+ {
+ // Do NOT dispose cmd here — SqliteDataReader is tied to the command,
+ // and disposing the command closes the reader.
+ var cmd = _connection.CreateCommand();
+ cmd.CommandText = $"PRAGMA table_info('{tableName}')";
+ return cmd.ExecuteReader();
+ }
+
+ [Fact]
+ public void ParseTableInfo_FromRealPragmaResults_ReturnsCorrectColumns()
+ {
+ ExecuteSql("CREATE TABLE test_meta (" +
+ "id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, " +
+ "name TEXT NOT NULL DEFAULT 'untitled', " +
+ "score REAL);");
+
+ using var reader = ExecutePragmaTableInfo("test_meta");
+ var columns = SQLiteMetadataReader.ParseTableInfo(reader);
+
+ Assert.Equal(3, columns.Length);
+
+ // id: cid=0, name=id, type=INTEGER, notnull=1 (true but ! => false), pk=1
+ var idCol = columns[0];
+ Assert.Equal(0, idCol.Ordinal);
+ Assert.Equal("id", idCol.Name);
+ Assert.Equal("INTEGER", idCol.DataType);
+ Assert.False(idCol.IsNullable);
+ Assert.Null(idCol.DefaultValue);
+ Assert.True(idCol.IsPrimaryKey);
+
+ // name: cid=1, name=name, type=TEXT, notnull=1, dflt='untitled', pk=0
+ var nameCol = columns[1];
+ Assert.Equal(1, nameCol.Ordinal);
+ Assert.Equal("name", nameCol.Name);
+ Assert.Equal("TEXT", nameCol.DataType);
+ Assert.False(nameCol.IsNullable);
+ Assert.Equal("'untitled'", nameCol.DefaultValue);
+ Assert.False(nameCol.IsPrimaryKey);
+
+ // score: cid=2, name=score, type=REAL, notnull=0, dflt=null, pk=0
+ var scoreCol = columns[2];
+ Assert.Equal(2, scoreCol.Ordinal);
+ Assert.Equal("score", scoreCol.Name);
+ Assert.Equal("REAL", scoreCol.DataType);
+ Assert.True(scoreCol.IsNullable);
+ Assert.Null(scoreCol.DefaultValue);
+ Assert.False(scoreCol.IsPrimaryKey);
+ }
+
+ [Fact]
+ public void ParseTableInfo_WithNoRows_ReturnsEmptyArray()
+ {
+ // PRAGMA table_info on a non-existent table yields no rows
+ using var reader = ExecutePragmaTableInfo("nonexistent_table_xyz");
+ var columns = SQLiteMetadataReader.ParseTableInfo(reader);
+
+ Assert.Empty(columns);
+ }
+
+ [Fact]
+ public void ParseIndexList_ReturnsCorrectIndexNames()
+ {
+ ExecuteSql("CREATE TABLE test_idx (id INTEGER PRIMARY KEY, email TEXT UNIQUE, name TEXT);");
+ ExecuteSql("CREATE INDEX idx_test_idx_name ON test_idx(name);");
+
+ var indexes = SQLiteMetadataReader.ParseIndexList(_connection, "test_idx");
+
+ // At least: sqlite_autoindex_test_idx_1 (for UNIQUE on email)
+ // And: idx_test_idx_name
+ Assert.Contains(indexes, i => i == "idx_test_idx_name");
+ }
+
+ [Fact]
+ public void ParseIndexList_WithNoIndexes_ReturnsEmptyList()
+ {
+ ExecuteSql("CREATE TABLE test_no_idx (id INTEGER PRIMARY KEY, data TEXT);");
+
+ var indexes = SQLiteMetadataReader.ParseIndexList(_connection, "test_no_idx");
+
+ // No user-defined indexes, sqlite autoindex may exist for PK — should just run without error
+ Assert.NotNull(indexes);
+ }
+
+ [Fact]
+ public void ParseForeignKeyList_ReturnsCorrectForeignKeyText()
+ {
+ ExecuteSql("CREATE TABLE test_departments (id INTEGER PRIMARY KEY, name TEXT);");
+ ExecuteSql("CREATE TABLE test_employees (" +
+ "id INTEGER PRIMARY KEY, " +
+ "name TEXT, " +
+ "dept_id INTEGER REFERENCES test_departments(id));");
+
+ var fks = SQLiteMetadataReader.ParseForeignKeyList(_connection, "test_employees");
+
+ Assert.NotEmpty(fks);
+ Assert.Contains("dept_id -> test_departments(id)", fks);
+ }
+
+ [Fact]
+ public void ParseForeignKeyList_WithNoForeignKeys_ReturnsEmptyList()
+ {
+ ExecuteSql("CREATE TABLE test_no_fk (id INTEGER PRIMARY KEY, data TEXT);");
+
+ var fks = SQLiteMetadataReader.ParseForeignKeyList(_connection, "test_no_fk");
+
+ Assert.NotNull(fks);
+ Assert.Empty(fks);
+ }
+}
\ No newline at end of file
diff --git a/tests/NewLife.Studio.Data.Tests/SQLiteProviderTests.cs b/tests/NewLife.Studio.Data.Tests/SQLiteProviderTests.cs
new file mode 100644
index 0000000..a1a9fd9
--- /dev/null
+++ b/tests/NewLife.Studio.Data.Tests/SQLiteProviderTests.cs
@@ -0,0 +1,96 @@
+using NewLife.Studio.Core.DTOs;
+using NewLife.Studio.Data.Providers.SQLite;
+using Xunit;
+
+namespace NewLife.Studio.Data.Tests;
+
+public class SQLiteProviderTests
+{
+ private static ConnectionInfo CreateInMemoryConnection() => new()
+ {
+ Name = "test-sqlite",
+ ConnectionString = "Data Source=:memory:",
+ ProviderType = "sqlite"
+ };
+
+ [Fact]
+ public void ProviderName_Returns_SQLite()
+ {
+ var provider = new SQLiteProvider();
+
+ var result = provider.ProviderName;
+
+ Assert.Equal("SQLite", result);
+ }
+
+ [Fact]
+ public void SupportedSchemes_Contains_Sqlite()
+ {
+ var provider = new SQLiteProvider();
+
+ var schemes = provider.SupportedSchemes;
+
+ Assert.Contains("sqlite", schemes);
+ }
+
+ [Fact]
+ public void SupportedSchemes_Contains_Sqlite3()
+ {
+ var provider = new SQLiteProvider();
+
+ var schemes = provider.SupportedSchemes;
+
+ Assert.Contains("sqlite3", schemes);
+ }
+
+ [Fact]
+ public async Task TestConnectionAsync_WithInMemoryConnection_ReturnsTrue()
+ {
+ var provider = new SQLiteProvider();
+ var conn = CreateInMemoryConnection();
+
+ var result = await provider.TestConnectionAsync(conn);
+
+ Assert.True(result);
+ }
+
+ [Fact]
+ public async Task TestConnectionAsync_WithInvalidPath_ReturnsFalse()
+ {
+ var provider = new SQLiteProvider();
+ var conn = new ConnectionInfo
+ {
+ Name = "invalid",
+ ConnectionString = @"C:\nonexistent\path\to\missing.db",
+ ProviderType = "sqlite"
+ };
+
+ var result = await provider.TestConnectionAsync(conn);
+
+ Assert.False(result);
+ }
+
+ [Fact]
+ public async Task OpenSessionAsync_Returns_IDbSession_WithCorrectConnection()
+ {
+ var provider = new SQLiteProvider();
+ var conn = CreateInMemoryConnection();
+
+ var session = await provider.OpenSessionAsync(conn);
+
+ Assert.NotNull(session);
+ Assert.Same(conn, session.Connection);
+ Assert.False(string.IsNullOrEmpty(session.SessionId));
+ }
+
+ [Fact]
+ public async Task OpenSessionAsync_ReturnsNotOpen_BeforeAnyOperation()
+ {
+ var provider = new SQLiteProvider();
+ var conn = CreateInMemoryConnection();
+
+ var session = await provider.OpenSessionAsync(conn);
+
+ Assert.False(session.IsOpen, "Session should not be open until first operation");
+ }
+}
\ No newline at end of file
diff --git a/tests/NewLife.Studio.Data.Tests/SQLiteSessionTests.cs b/tests/NewLife.Studio.Data.Tests/SQLiteSessionTests.cs
new file mode 100644
index 0000000..546c336
--- /dev/null
+++ b/tests/NewLife.Studio.Data.Tests/SQLiteSessionTests.cs
@@ -0,0 +1,246 @@
+using Microsoft.Data.Sqlite;
+using NewLife.Studio.Core.DTOs;
+using NewLife.Studio.Data.Providers.SQLite;
+using Xunit;
+
+namespace NewLife.Studio.Data.Tests;
+
+public class SQLiteSessionTests : IDisposable
+{
+ private readonly SqliteConnection _setupConnection;
+
+ public SQLiteSessionTests()
+ {
+ // shared in-memory DB: tables created via _setupConnection
+ // are visible to SQLiteSession connections opened with the same shared URI
+ _setupConnection = new SqliteConnection("Data Source=file::memory:?cache=shared");
+ _setupConnection.Open();
+ }
+
+ public void Dispose()
+ {
+ _setupConnection?.Dispose();
+ }
+
+ private static ConnectionInfo CreateInMemoryConnection() => new()
+ {
+ Name = "test-session",
+ ConnectionString = "Data Source=file::memory:?cache=shared",
+ ProviderType = "sqlite"
+ };
+
+ private void ExecuteSql(string sql)
+ {
+ using var cmd = _setupConnection.CreateCommand();
+ cmd.CommandText = sql;
+ cmd.ExecuteNonQuery();
+ }
+
+ [Fact]
+ public async Task GetTablesAsync_WithCreatedTable_ReturnsTable()
+ {
+ ExecuteSql("CREATE TABLE test_users (id INTEGER PRIMARY KEY, name TEXT NOT NULL);");
+ var session = new SQLiteSession(CreateInMemoryConnection());
+
+ var tables = await session.GetTablesAsync();
+
+ Assert.NotEmpty(tables);
+ Assert.Contains(tables, t => t.Name == "test_users" && t.Schema == "main");
+ }
+
+ [Fact]
+ public async Task GetTablesAsync_WhenEmpty_Database_ReturnsNoTables()
+ {
+ // Use a private in-memory connection (not shared) so no tables exist
+ var conn = new ConnectionInfo
+ {
+ Name = "test-empty",
+ ConnectionString = "Data Source=:memory:",
+ ProviderType = "sqlite"
+ };
+ var session = new SQLiteSession(conn);
+
+ var tables = await session.GetTablesAsync();
+
+ Assert.Empty(tables);
+ }
+
+ [Fact]
+ public async Task GetColumnsAsync_ReturnsCorrectColumnInfo()
+ {
+ ExecuteSql("CREATE TABLE test_products (" +
+ "id INTEGER PRIMARY KEY NOT NULL, " +
+ "name TEXT NOT NULL, " +
+ "price REAL, " +
+ "description TEXT DEFAULT 'N/A');");
+ var session = new SQLiteSession(CreateInMemoryConnection());
+
+ var columns = await session.GetColumnsAsync("test_products");
+
+ Assert.Equal(4, columns.Length);
+
+ var idCol = columns.First(c => c.Name == "id");
+ Assert.Equal("INTEGER", idCol.DataType);
+ Assert.False(idCol.IsNullable);
+ Assert.True(idCol.IsPrimaryKey);
+ Assert.Equal(0, idCol.Ordinal);
+
+ var nameCol = columns.First(c => c.Name == "name");
+ Assert.Equal("TEXT", nameCol.DataType);
+ Assert.False(nameCol.IsNullable);
+
+ var priceCol = columns.First(c => c.Name == "price");
+ Assert.Equal("REAL", priceCol.DataType);
+ Assert.True(priceCol.IsNullable);
+
+ var descCol = columns.First(c => c.Name == "description");
+ Assert.Equal("TEXT", descCol.DataType);
+ Assert.Equal("'N/A'", descCol.DefaultValue);
+ }
+
+ [Fact]
+ public async Task ExecuteQueryAsync_WithSelect_ReturnsExpectedRows()
+ {
+ ExecuteSql("CREATE TABLE test_items (id INTEGER PRIMARY KEY, value TEXT);");
+ ExecuteSql("INSERT INTO test_items (value) VALUES ('alpha');");
+ ExecuteSql("INSERT INTO test_items (value) VALUES ('beta');");
+ ExecuteSql("INSERT INTO test_items (value) VALUES ('gamma');");
+ var session = new SQLiteSession(CreateInMemoryConnection());
+
+ var request = new QueryRequest
+ {
+ Sql = "SELECT id, value FROM test_items ORDER BY id",
+ MaxRows = 100
+ };
+ var result = await session.ExecuteQueryAsync(request);
+
+ Assert.Null(result.Error);
+ Assert.Equal(3, result.RowCount);
+ Assert.False(result.Truncated);
+ Assert.Equal(2, result.Columns.Length);
+ Assert.Equal("id", result.Columns[0].Name);
+ Assert.Equal("value", result.Columns[1].Name);
+
+ Assert.Equal(3, result.Rows.Count);
+ Assert.Equal("alpha", result.Rows[0][1]);
+ Assert.Equal("beta", result.Rows[1][1]);
+ Assert.Equal("gamma", result.Rows[2][1]);
+ }
+
+ [Fact]
+ public async Task ExecuteQueryAsync_MaxRowsTruncation_TruncatesResult()
+ {
+ ExecuteSql("CREATE TABLE test_rows (id INTEGER PRIMARY KEY, num INTEGER);");
+ for (int i = 0; i < 10; i++)
+ ExecuteSql($"INSERT INTO test_rows (num) VALUES ({i});");
+ var session = new SQLiteSession(CreateInMemoryConnection());
+
+ var request = new QueryRequest
+ {
+ Sql = "SELECT id, num FROM test_rows ORDER BY id",
+ MaxRows = 4
+ };
+ var result = await session.ExecuteQueryAsync(request);
+
+ Assert.True(result.Truncated);
+ Assert.Equal(4, result.RowCount);
+ Assert.Equal(4, result.Rows.Count);
+ }
+
+ [Fact]
+ public async Task ExecuteQueryAsync_WithInvalidSql_ReturnsError()
+ {
+ var session = new SQLiteSession(CreateInMemoryConnection());
+
+ var request = new QueryRequest
+ {
+ Sql = "SELECTZ * FROM nonexistent_table;"
+ };
+ var result = await session.ExecuteQueryAsync(request);
+
+ Assert.NotNull(result.Error);
+ Assert.NotEmpty(result.Error);
+ }
+
+ [Fact]
+ public async Task ExecuteQueryAsync_RecordsElapsedTime()
+ {
+ ExecuteSql("CREATE TABLE test_time (id INTEGER PRIMARY KEY, data TEXT);");
+ var session = new SQLiteSession(CreateInMemoryConnection());
+
+ var request = new QueryRequest
+ {
+ Sql = "SELECT * FROM test_time",
+ MaxRows = 100
+ };
+ var result = await session.ExecuteQueryAsync(request);
+
+ Assert.True(result.ElapsedMs >= 0);
+ }
+
+ [Fact]
+ public async Task ExecuteQueryAsync_WithDBNull_ReturnsNullInRow()
+ {
+ ExecuteSql("CREATE TABLE test_nulls (id INTEGER PRIMARY KEY, value TEXT);");
+ ExecuteSql("INSERT INTO test_nulls (value) VALUES (NULL);");
+ var session = new SQLiteSession(CreateInMemoryConnection());
+
+ var request = new QueryRequest
+ {
+ Sql = "SELECT id, value FROM test_nulls",
+ MaxRows = 100
+ };
+ var result = await session.ExecuteQueryAsync(request);
+
+ Assert.Equal(1, result.RowCount);
+ Assert.Null(result.Rows[0][1]);
+ }
+
+ [Fact]
+ public async Task CloseAsync_ClosesSession()
+ {
+ ExecuteSql("CREATE TABLE test_close (id INTEGER PRIMARY KEY);");
+ var session = new SQLiteSession(CreateInMemoryConnection());
+ // force open
+ await session.GetTablesAsync();
+ Assert.True(session.IsOpen);
+
+ await session.CloseAsync();
+
+ Assert.False(session.IsOpen);
+ }
+
+ [Fact]
+ public async Task Dispose_ClosesConnection()
+ {
+ ExecuteSql("CREATE TABLE test_dispose (id INTEGER PRIMARY KEY);");
+ var session = new SQLiteSession(CreateInMemoryConnection());
+ await session.GetTablesAsync();
+ Assert.True(session.IsOpen);
+
+ session.Dispose();
+
+ Assert.False(session.IsOpen);
+ }
+
+ [Fact]
+ public async Task CloseAsync_CanBeCalledMultipleTimes()
+ {
+ var session = new SQLiteSession(CreateInMemoryConnection());
+ await session.CloseAsync();
+ await session.CloseAsync();
+
+ Assert.False(session.IsOpen);
+ }
+
+ [Fact]
+ public async Task Dispose_CanBeCalledMultipleTimes()
+ {
+ var session = new SQLiteSession(CreateInMemoryConnection());
+
+ session.Dispose();
+ session.Dispose();
+
+ Assert.False(session.IsOpen);
+ }
+}
\ No newline at end of file
diff --git a/tests/NewLife.Studio.Modules.DataStudio.Tests/ConnectionListViewModelTests.cs b/tests/NewLife.Studio.Modules.DataStudio.Tests/ConnectionListViewModelTests.cs
new file mode 100644
index 0000000..f5a40ff
--- /dev/null
+++ b/tests/NewLife.Studio.Modules.DataStudio.Tests/ConnectionListViewModelTests.cs
@@ -0,0 +1,271 @@
+using System.Collections.ObjectModel;
+using Moq;
+using NewLife.Studio.Core.DTOs;
+using NewLife.Studio.Data;
+using NewLife.Studio.Modules.DataStudio.ViewModels;
+using NewLife.Studio.Store;
+using Xunit;
+
+namespace NewLife.Studio.Modules.DataStudio.Tests;
+
+public class ConnectionListViewModelTests
+{
+ private readonly Mock<IStoreService> _storeServiceMock;
+ private readonly Mock<IDataProvider> _dataProviderMock;
+ private readonly ConnectionListViewModel _vm;
+
+ public ConnectionListViewModelTests()
+ {
+ _storeServiceMock = new Mock<IStoreService>();
+ _dataProviderMock = new Mock<IDataProvider>();
+ _vm = new ConnectionListViewModel(_storeServiceMock.Object, _dataProviderMock.Object);
+ }
+
+ [Fact]
+ public void InitialState_HasEmptyConnections()
+ {
+ Assert.NotNull(_vm.Connections);
+ Assert.Empty(_vm.Connections);
+ }
+
+ [Fact]
+ public void InitialState_SelectedConnection_IsNull()
+ {
+ Assert.Null(_vm.SelectedConnection);
+ }
+
+ [Fact]
+ public async Task LoadAsync_PopulatesConnections_FromStore()
+ {
+ var storedConnections = new List<ConnectionInfo>
+ {
+ new() { Id = "1", Name = "SQLite-Local", ConnectionString = "Data Source=:memory:", ProviderType = "sqlite" },
+ new() { Id = "2", Name = "Postgres-Prod", ConnectionString = "Host=localhost;...", ProviderType = "postgres" }
+ };
+ _storeServiceMock
+ .Setup(s => s.ListConnectionsAsync())
+ .ReturnsAsync(storedConnections);
+
+ await _vm.LoadAsync();
+
+ Assert.Equal(2, _vm.Connections.Count);
+ Assert.Equal("SQLite-Local", _vm.Connections[0].Name);
+ Assert.Equal("Postgres-Prod", _vm.Connections[1].Name);
+ }
+
+ [Fact]
+ public async Task LoadAsync_WithEmptyStore_ResultsInEmptyConnections()
+ {
+ _storeServiceMock
+ .Setup(s => s.ListConnectionsAsync())
+ .ReturnsAsync(new List<ConnectionInfo>());
+
+ await _vm.LoadAsync();
+
+ Assert.Empty(_vm.Connections);
+ }
+
+ [Fact]
+ public async Task AddConnectionCommand_AddsConnection_ToCollection()
+ {
+ Assert.Empty(_vm.Connections);
+
+ await _vm.AddConnectionCommand.ExecuteAsync(null);
+
+ Assert.Single(_vm.Connections);
+ var conn = _vm.Connections[0];
+ Assert.StartsWith("SQLite-", conn.Name);
+ Assert.Equal("Data Source=:memory:", conn.ConnectionString);
+ Assert.Equal("sqlite", conn.ProviderType);
+
+ _storeServiceMock.Verify(
+ s => s.SaveConnectionAsync(It.Is<ConnectionInfo>(c => c.ProviderType == "sqlite")),
+ Times.Once);
+ }
+
+ [Fact]
+ public async Task AddConnectionCommand_SetsSelectedConnection()
+ {
+ await _vm.AddConnectionCommand.ExecuteAsync(null);
+
+ Assert.NotNull(_vm.SelectedConnection);
+ Assert.Same(_vm.Connections[0], _vm.SelectedConnection);
+ }
+
+ [Fact]
+ public async Task AddConnectionCommand_MultipleConnections_SavesEach()
+ {
+ await _vm.AddConnectionCommand.ExecuteAsync(null);
+ await _vm.AddConnectionCommand.ExecuteAsync(null);
+
+ Assert.Equal(2, _vm.Connections.Count);
+ _storeServiceMock.Verify(
+ s => s.SaveConnectionAsync(It.IsAny<ConnectionInfo>()),
+ Times.Exactly(2));
+ }
+
+ [Fact]
+ public async Task DeleteConnectionCommand_WithSelectedConnection_RemovesIt()
+ {
+ await _vm.AddConnectionCommand.ExecuteAsync(null);
+ var conn = _vm.SelectedConnection;
+ Assert.NotNull(conn);
+
+ await _vm.DeleteConnectionCommand.ExecuteAsync(null);
+
+ Assert.Empty(_vm.Connections);
+ Assert.Null(_vm.SelectedConnection);
+ _storeServiceMock.Verify(
+ s => s.DeleteConnectionAsync(conn!.Id),
+ Times.Once);
+ }
+
+ [Fact]
+ public async Task DeleteConnectionCommand_WithNoSelection_DoesNothing()
+ {
+ _vm.SelectedConnection = null;
+
+ await _vm.DeleteConnectionCommand.ExecuteAsync(null);
+
+ Assert.Empty(_vm.Connections);
+ _storeServiceMock.Verify(
+ s => s.DeleteConnectionAsync(It.IsAny<string>()),
+ Times.Never);
+ }
+
+ [Fact]
+ public async Task EditConnectionCommand_WithSelectedConnection_SavesToStore()
+ {
+ await _vm.AddConnectionCommand.ExecuteAsync(null);
+ // Reset mock invocations to isolate the EditConnection call
+ _storeServiceMock.Invocations.Clear();
+ _vm.SelectedConnection!.Name = "Renamed";
+
+ await _vm.EditConnectionCommand.ExecuteAsync(null);
+
+ _storeServiceMock.Verify(
+ s => s.SaveConnectionAsync(It.Is<ConnectionInfo>(c => c.Name == "Renamed")),
+ Times.Once);
+ }
+
+ [Fact]
+ public async Task EditConnectionCommand_WithNoSelection_DoesNothing()
+ {
+ _vm.SelectedConnection = null;
+
+ await _vm.EditConnectionCommand.ExecuteAsync(null);
+
+ _storeServiceMock.Verify(
+ s => s.SaveConnectionAsync(It.IsAny<ConnectionInfo>()),
+ Times.Never);
+ }
+
+ [Fact]
+ public async Task TestConnectionCommand_WithNoSelection_DoesNothing()
+ {
+ _vm.SelectedConnection = null;
+
+ await _vm.TestConnectionCommand.ExecuteAsync(null);
+
+ _dataProviderMock.Verify(
+ p => p.TestConnectionAsync(It.IsAny<ConnectionInfo>(), It.IsAny<CancellationToken>()),
+ Times.Never);
+ }
+
+ [Fact]
+ public async Task TestConnectionCommand_WithSelectedConnection_TestsConnection()
+ {
+ await _vm.AddConnectionCommand.ExecuteAsync(null);
+ _dataProviderMock
+ .Setup(p => p.TestConnectionAsync(It.IsAny<ConnectionInfo>(), It.IsAny<CancellationToken>()))
+ .ReturnsAsync(true);
+
+ await _vm.TestConnectionCommand.ExecuteAsync(null);
+
+ _dataProviderMock.Verify(
+ p => p.TestConnectionAsync(It.IsAny<ConnectionInfo>(), It.IsAny<CancellationToken>()),
+ Times.Once);
+ }
+
+ [Fact]
+ public async Task TestConnectionCommand_FailedConnection_DoesNotThrow()
+ {
+ await _vm.AddConnectionCommand.ExecuteAsync(null);
+ _dataProviderMock
+ .Setup(p => p.TestConnectionAsync(It.IsAny<ConnectionInfo>(), It.IsAny<CancellationToken>()))
+ .ReturnsAsync(false);
+
+ var exception = await Record.ExceptionAsync(
+ () => _vm.TestConnectionCommand.ExecuteAsync(null));
+ Assert.Null(exception);
+ }
+
+ [Fact]
+ public async Task OpenConnectionCommand_WithNoSelection_DoesNothing()
+ {
+ _vm.SelectedConnection = null;
+
+ await _vm.OpenConnectionCommand.ExecuteAsync(null);
+
+ _dataProviderMock.Verify(
+ p => p.OpenSessionAsync(It.IsAny<ConnectionInfo>(), It.IsAny<CancellationToken>()),
+ Times.Never);
+ }
+
+ [Fact]
+ public async Task OpenConnectionCommand_FiresConnectionOpenedEvent()
+ {
+ await _vm.AddConnectionCommand.ExecuteAsync(null);
+ var mockSession = new Mock<IDbSession>();
+ _dataProviderMock
+ .Setup(p => p.OpenSessionAsync(It.IsAny<ConnectionInfo>(), It.IsAny<CancellationToken>()))
+ .ReturnsAsync(mockSession.Object);
+
+ IDbSession? receivedSession = null;
+ _vm.ConnectionOpened += (_, session) => receivedSession = session;
+
+ await _vm.OpenConnectionCommand.ExecuteAsync(null);
+
+ Assert.NotNull(receivedSession);
+ Assert.Same(mockSession.Object, receivedSession);
+ }
+
+ [Fact]
+ public async Task OpenConnectionCommand_UpdatesLastUsedAt()
+ {
+ await _vm.AddConnectionCommand.ExecuteAsync(null);
+ var now = new DateTime(2025, 1, 15, 12, 0, 0);
+ _vm.SelectedConnection!.LastUsedAt = DateTime.MinValue;
+
+ var mockSession = new Mock<IDbSession>();
+ _dataProviderMock
+ .Setup(p => p.OpenSessionAsync(It.IsAny<ConnectionInfo>(), It.IsAny<CancellationToken>()))
+ .ReturnsAsync(mockSession.Object);
+
+ await _vm.OpenConnectionCommand.ExecuteAsync(null);
+
+ Assert.NotEqual(DateTime.MinValue, _vm.SelectedConnection!.LastUsedAt);
+ }
+
+ [Fact]
+ public async Task OpenConnectionCommand_Exception_DoesNotThrow()
+ {
+ await _vm.AddConnectionCommand.ExecuteAsync(null);
+ _dataProviderMock
+ .Setup(p => p.OpenSessionAsync(It.IsAny<ConnectionInfo>(), It.IsAny<CancellationToken>()))
+ .ThrowsAsync(new InvalidOperationException("Connection failed"));
+
+ var exception = await Record.ExceptionAsync(
+ () => _vm.OpenConnectionCommand.ExecuteAsync(null));
+ Assert.Null(exception);
+ }
+
+ [Fact]
+ public void SelectedConnection_CanBeSetExternally()
+ {
+ var conn = new ConnectionInfo { Id = "test", Name = "External" };
+ _vm.SelectedConnection = conn;
+
+ Assert.Same(conn, _vm.SelectedConnection);
+ }
+}
\ No newline at end of file
diff --git a/tests/NewLife.Studio.Modules.DataStudio.Tests/DataStudioModuleTests.cs b/tests/NewLife.Studio.Modules.DataStudio.Tests/DataStudioModuleTests.cs
new file mode 100644
index 0000000..b5c51aa
--- /dev/null
+++ b/tests/NewLife.Studio.Modules.DataStudio.Tests/DataStudioModuleTests.cs
@@ -0,0 +1,92 @@
+using Avalonia.Controls;
+using NewLife.Studio.Core;
+using Xunit;
+
+namespace NewLife.Studio.Modules.DataStudio.Tests;
+
+public class DataStudioModuleTests
+{
+ private readonly DataStudioModule _module = new();
+
+ [Fact]
+ public void Id_Returns_DataStudio()
+ {
+ Assert.Equal("data-studio", _module.Id);
+ }
+
+ [Fact]
+ public void DisplayName_Returns_NormalChineseName()
+ {
+ Assert.Equal("数据管理", _module.DisplayName);
+ }
+
+ [Fact]
+ public void Icon_Is_NotNull()
+ {
+ Assert.NotNull(_module.Icon);
+ }
+
+ [Fact]
+ public void Icon_Is_NonEmpty()
+ {
+ Assert.NotEmpty(_module.Icon);
+ }
+
+ [Fact]
+ public void Order_Returns_Zero()
+ {
+ Assert.Equal(0, _module.Order);
+ }
+
+ [Fact]
+ public void GetView_Returns_NonNullControl()
+ {
+ var view = _module.GetView();
+ Assert.NotNull(view);
+ Assert.IsAssignableFrom<Control>(view);
+ }
+
+ [Fact]
+ public async Task OnActivateAsync_Completes_WithoutError()
+ {
+ var exception = await Record.ExceptionAsync(() => _module.OnActivateAsync());
+ Assert.Null(exception);
+ }
+
+ [Fact]
+ public async Task OnActivateAsync_WithCancellationToken_Completes()
+ {
+ using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(1));
+ var exception = await Record.ExceptionAsync(() => _module.OnActivateAsync(cts.Token));
+ Assert.Null(exception);
+ }
+
+ [Fact]
+ public async Task OnDeactivateAsync_Completes_WithoutError()
+ {
+ var exception = await Record.ExceptionAsync(() => _module.OnDeactivateAsync());
+ Assert.Null(exception);
+ }
+
+ [Fact]
+ public async Task OnDeactivateAsync_WithCancellationToken_Completes()
+ {
+ using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(1));
+ var exception = await Record.ExceptionAsync(() => _module.OnDeactivateAsync(cts.Token));
+ Assert.Null(exception);
+ }
+
+ [Fact]
+ public void Implements_IStudioModule()
+ {
+ Assert.IsAssignableFrom<IStudioModule>(_module);
+ }
+
+ [Fact]
+ public void GetView_EachCall_ReturnsNewInstance()
+ {
+ var view1 = _module.GetView();
+ var view2 = _module.GetView();
+ Assert.NotSame(view1, view2);
+ }
+}
\ No newline at end of file
diff --git a/tests/NewLife.Studio.Modules.DataStudio.Tests/NewLife.Studio.Modules.DataStudio.Tests.csproj b/tests/NewLife.Studio.Modules.DataStudio.Tests/NewLife.Studio.Modules.DataStudio.Tests.csproj
new file mode 100644
index 0000000..c10c058
--- /dev/null
+++ b/tests/NewLife.Studio.Modules.DataStudio.Tests/NewLife.Studio.Modules.DataStudio.Tests.csproj
@@ -0,0 +1,27 @@
+<Project Sdk="Microsoft.NET.Sdk">
+
+ <PropertyGroup>
+ <TargetFramework>net9.0</TargetFramework>
+ <ImplicitUsings>enable</ImplicitUsings>
+ <Nullable>enable</Nullable>
+ <IsPackable>false</IsPackable>
+ <IsTestProject>true</IsTestProject>
+ <RootNamespace>NewLife.Studio.Modules.DataStudio.Tests</RootNamespace>
+ </PropertyGroup>
+
+ <ItemGroup>
+ <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.13.0" />
+ <PackageReference Include="Moq" Version="4.20.72" />
+ <PackageReference Include="xunit" Version="2.9.3" />
+ <PackageReference Include="xunit.runner.visualstudio" Version="3.0.2" />
+ <PackageReference Include="coverlet.collector" Version="6.0.4">
+ <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
+ <PrivateAssets>all</PrivateAssets>
+ </PackageReference>
+ </ItemGroup>
+
+ <ItemGroup>
+ <ProjectReference Include="..\..\src\Modules\DataStudio\DataStudio.csproj" />
+ </ItemGroup>
+
+</Project>
\ No newline at end of file
diff --git a/tests/NewLife.Studio.Modules.DataStudio.Tests/QueryTabTests.cs b/tests/NewLife.Studio.Modules.DataStudio.Tests/QueryTabTests.cs
new file mode 100644
index 0000000..fab7b59
--- /dev/null
+++ b/tests/NewLife.Studio.Modules.DataStudio.Tests/QueryTabTests.cs
@@ -0,0 +1,150 @@
+using NewLife.Studio.Core.DTOs;
+using NewLife.Studio.Modules.DataStudio.ViewModels;
+using Xunit;
+
+namespace NewLife.Studio.Modules.DataStudio.Tests;
+
+public class QueryTabTests
+{
+ [Fact]
+ public void Default_Sql_IsSelectStarFrom()
+ {
+ var tab = new QueryTab();
+ Assert.Equal("SELECT * FROM ", tab.Sql);
+ }
+
+ [Fact]
+ public void Default_Title_IsQuery1()
+ {
+ var tab = new QueryTab();
+ Assert.Equal("查询 1", tab.Title);
+ }
+
+ [Fact]
+ public void Default_Result_IsNull()
+ {
+ var tab = new QueryTab();
+ Assert.Null(tab.Result);
+ }
+
+ [Fact]
+ public void ResultGrid_IsNotNull()
+ {
+ var tab = new QueryTab();
+ Assert.NotNull(tab.ResultGrid);
+ }
+
+ [Fact]
+ public void Sql_CanBeSet()
+ {
+ var tab = new QueryTab();
+ tab.Sql = "SELECT * FROM users WHERE id = 1";
+ Assert.Equal("SELECT * FROM users WHERE id = 1", tab.Sql);
+ }
+
+ [Fact]
+ public void Title_CanBeSet()
+ {
+ var tab = new QueryTab();
+ tab.Title = "自定义查询";
+ Assert.Equal("自定义查询", tab.Title);
+ }
+
+ [Fact]
+ public void Setting_Result_UpdatesResultGrid()
+ {
+ var tab = new QueryTab();
+ var result = new QueryResult
+ {
+ Columns = new[]
+ {
+ new ColumnInfo { Name = "Id", DataType = "INTEGER" },
+ new ColumnInfo { Name = "Name", DataType = "TEXT" }
+ },
+ Rows = new List<object?[]>
+ {
+ new object?[] { 1, "Test" }
+ },
+ RowCount = 1,
+ ElapsedMs = 42
+ };
+
+ tab.Result = result;
+
+ Assert.Same(result, tab.Result);
+ Assert.Equal(1, tab.ResultGrid.RowCount);
+ Assert.Equal(42, tab.ResultGrid.ElapsedMs);
+ Assert.Equal(2, tab.ResultGrid.Columns.Count);
+ Assert.Equal("Id", tab.ResultGrid.Columns[0].Name);
+ }
+
+ [Fact]
+ public void Setting_Result_ToNull_ClearsNothing()
+ {
+ var tab = new QueryTab();
+ // First set a result
+ tab.Result = new QueryResult
+ {
+ Columns = new[] { new ColumnInfo { Name = "X" } },
+ Rows = new List<object?[]> { new object?[] { 1 } },
+ RowCount = 1
+ };
+
+ // Then set to null - OnResultChanged won't call SetResult for null values
+ tab.Result = null;
+
+ // ResultGrid still holds previous data (since OnResultChanged only acts when value != null)
+ Assert.Equal(1, tab.ResultGrid.RowCount);
+ }
+
+ [Fact]
+ public void Setting_Result_WithError_PropagatesError()
+ {
+ var tab = new QueryTab();
+ tab.Result = new QueryResult
+ {
+ Columns = [],
+ Rows = [],
+ Error = "Syntax error near 'INNER'"
+ };
+
+ Assert.Equal("Syntax error near 'INNER'", tab.ResultGrid.Error);
+ Assert.True(tab.ResultGrid.HasError);
+ }
+
+ [Fact]
+ public void Setting_Result_WithTruncated_PropagatesWarning()
+ {
+ var tab = new QueryTab();
+ tab.Result = new QueryResult
+ {
+ Columns = [],
+ Rows = [],
+ Truncated = true,
+ RowCount = 1000
+ };
+
+ Assert.True(tab.ResultGrid.IsTruncated);
+ Assert.Equal("(结果已裁剪)", tab.ResultGrid.TruncatedWarning);
+ Assert.Equal(1000, tab.ResultGrid.RowCount);
+ }
+
+ [Fact]
+ public void EachQueryTab_HasIndependentResultGrid()
+ {
+ var tab1 = new QueryTab();
+ var tab2 = new QueryTab();
+
+ Assert.NotSame(tab1.ResultGrid, tab2.ResultGrid);
+
+ tab1.Result = new QueryResult
+ {
+ Columns = new[] { new ColumnInfo { Name = "A" } },
+ Rows = new List<object?[]> { new object?[] { 1 } },
+ RowCount = 1
+ };
+
+ Assert.Equal(1, tab1.ResultGrid.RowCount);
+ Assert.Equal(0, tab2.ResultGrid.RowCount);
+ }
+}
\ No newline at end of file
diff --git a/tests/NewLife.Studio.Modules.DataStudio.Tests/ResultGridViewModelTests.cs b/tests/NewLife.Studio.Modules.DataStudio.Tests/ResultGridViewModelTests.cs
new file mode 100644
index 0000000..aa63b71
--- /dev/null
+++ b/tests/NewLife.Studio.Modules.DataStudio.Tests/ResultGridViewModelTests.cs
@@ -0,0 +1,553 @@
+using System.Collections.ObjectModel;
+using System.Reflection;
+using System.Text.Json;
+using NewLife.Studio.Core.DTOs;
+using NewLife.Studio.Modules.DataStudio.ViewModels;
+using Xunit;
+
+namespace NewLife.Studio.Modules.DataStudio.Tests;
+
+public class ResultGridViewModelTests
+{
+ private static ColumnInfo[] SampleColumns => new[]
+ {
+ new ColumnInfo { Name = "Id", DataType = "INTEGER", IsPrimaryKey = true, Ordinal = 0 },
+ new ColumnInfo { Name = "Name", DataType = "TEXT", Ordinal = 1 },
+ new ColumnInfo { Name = "Age", DataType = "INTEGER", Ordinal = 2 }
+ };
+
+ private static List<object?[]> SampleRows => new()
+ {
+ new object?[] { 1, "Alice", 30 },
+ new object?[] { 2, "Bob", 25 },
+ new object?[] { 3, "Charlie", 35 },
+ };
+
+ private static QueryResult CreateResult(int rowCount = 3, long elapsedMs = 150, bool truncated = false, string? error = null)
+ {
+ return new QueryResult
+ {
+ Columns = SampleColumns,
+ Rows = SampleRows,
+ RowCount = rowCount,
+ ElapsedMs = elapsedMs,
+ Truncated = truncated,
+ Error = error
+ };
+ }
+
+ [Fact]
+ public void InitialState_HasEmptyState()
+ {
+ var vm = new ResultGridViewModel();
+ Assert.Empty(vm.Columns);
+ Assert.Empty(vm.Rows);
+ Assert.Equal(0, vm.ElapsedMs);
+ Assert.Equal(0, vm.RowCount);
+ Assert.False(vm.IsTruncated);
+ Assert.Null(vm.Error);
+ }
+
+ [Fact]
+ public void SetResult_PopulatesColumns()
+ {
+ var vm = new ResultGridViewModel();
+ vm.SetResult(CreateResult());
+
+ Assert.Equal(3, vm.Columns.Count);
+ Assert.Equal("Id", vm.Columns[0].Name);
+ Assert.Equal("Name", vm.Columns[1].Name);
+ Assert.Equal("Age", vm.Columns[2].Name);
+ }
+
+ [Fact]
+ public void SetResult_PopulatesRows()
+ {
+ var vm = new ResultGridViewModel();
+ vm.SetResult(CreateResult());
+
+ Assert.Equal(3, vm.Rows.Count);
+ Assert.Equal(1, vm.Rows[0][0]);
+ Assert.Equal("Alice", vm.Rows[0][1]);
+ Assert.Equal(30, vm.Rows[0][2]);
+ }
+
+ [Fact]
+ public void SetResult_SetsElapsedMs()
+ {
+ var vm = new ResultGridViewModel();
+ vm.SetResult(CreateResult(elapsedMs: 250));
+
+ Assert.Equal(250, vm.ElapsedMs);
+ }
+
+ [Fact]
+ public void SetResult_SetsRowCount()
+ {
+ var vm = new ResultGridViewModel();
+ vm.SetResult(CreateResult(rowCount: 100));
+
+ Assert.Equal(100, vm.RowCount);
+ }
+
+ [Fact]
+ public void SetResult_IsTruncated_WhenTrue()
+ {
+ var vm = new ResultGridViewModel();
+ vm.SetResult(CreateResult(truncated: true));
+
+ Assert.True(vm.IsTruncated);
+ }
+
+ [Fact]
+ public void TruncatedWarning_WhenIsTruncated_ReturnsWarning()
+ {
+ var vm = new ResultGridViewModel();
+ vm.SetResult(CreateResult(truncated: true));
+
+ Assert.Equal("(结果已裁剪)", vm.TruncatedWarning);
+ }
+
+ [Fact]
+ public void TruncatedWarning_WhenNotTruncated_ReturnsEmpty()
+ {
+ var vm = new ResultGridViewModel();
+ vm.SetResult(CreateResult(truncated: false));
+
+ Assert.Equal("", vm.TruncatedWarning);
+ }
+
+ [Fact]
+ public void HasError_WhenErrorIsSet_ReturnsTrue()
+ {
+ var vm = new ResultGridViewModel();
+ vm.SetResult(CreateResult(error: "Connection failed"));
+
+ Assert.True(vm.HasError);
+ Assert.Equal("Connection failed", vm.Error);
+ }
+
+ [Fact]
+ public void HasError_WhenErrorIsNull_ReturnsFalse()
+ {
+ var vm = new ResultGridViewModel();
+ vm.SetResult(CreateResult(error: null));
+
+ Assert.False(vm.HasError);
+ }
+
+ [Fact]
+ public void HasError_WhenErrorIsEmpty_ReturnsFalse()
+ {
+ var vm = new ResultGridViewModel();
+ vm.SetResult(CreateResult(error: ""));
+
+ Assert.False(vm.HasError);
+ }
+
+ [Fact]
+ public void HasRows_WhenDataExists_ReturnsTrue()
+ {
+ var vm = new ResultGridViewModel();
+ vm.SetResult(CreateResult());
+
+ Assert.True(vm.HasRows);
+ }
+
+ [Fact]
+ public void HasRows_WhenEmpty_ReturnsFalse()
+ {
+ var vm = new ResultGridViewModel();
+ vm.SetResult(new QueryResult { Columns = [], Rows = [] });
+
+ Assert.False(vm.HasRows);
+ }
+
+ [Fact]
+ public void HasRows_WhenOnlyColumns_ReturnsFalse()
+ {
+ var vm = new ResultGridViewModel();
+ vm.SetResult(new QueryResult { Columns = SampleColumns, Rows = [] });
+
+ Assert.False(vm.HasRows);
+ }
+
+ [Fact]
+ public void HasRows_WhenOnlyRows_ReturnsFalse()
+ {
+ var vm = new ResultGridViewModel();
+ vm.SetResult(new QueryResult { Columns = [], Rows = SampleRows });
+
+ Assert.False(vm.HasRows);
+ }
+
+ [Fact]
+ public void ElapsedText_ReturnsFormattedString()
+ {
+ var vm = new ResultGridViewModel();
+ vm.SetResult(CreateResult(elapsedMs: 42));
+
+ Assert.Equal("耗时: 42ms", vm.ElapsedText);
+ }
+
+ [Fact]
+ public void RowCountText_ReturnsFormattedString()
+ {
+ var vm = new ResultGridViewModel();
+ vm.SetResult(CreateResult(rowCount: 7));
+
+ Assert.Equal("行数: 7", vm.RowCountText);
+ }
+
+ [Fact]
+ public async Task ExportCsvAsync_CreatesValidCsvFile()
+ {
+ var vm = new ResultGridViewModel();
+ vm.SetResult(CreateResult());
+
+ var filePath = Path.Combine(Path.GetTempPath(), $"test_export_{Guid.NewGuid():N}.csv");
+ try
+ {
+ await vm.ExportCsvAsync(filePath);
+
+ Assert.True(File.Exists(filePath));
+ var content = await File.ReadAllTextAsync(filePath);
+
+ var lines = content.Split('\n', StringSplitOptions.RemoveEmptyEntries);
+ Assert.Equal(4, lines.Length); // header + 3 rows
+
+ Assert.Equal("Id,Name,Age", lines[0].TrimEnd('\r'));
+ Assert.Equal("1,Alice,30", lines[1].TrimEnd('\r'));
+ Assert.Equal("2,Bob,25", lines[2].TrimEnd('\r'));
+ Assert.Equal("3,Charlie,35", lines[3].TrimEnd('\r'));
+ }
+ finally
+ {
+ if (File.Exists(filePath))
+ File.Delete(filePath);
+ }
+ }
+
+ [Fact]
+ public async Task ExportCsvAsync_HandlesCommasInField()
+ {
+ var vm = new ResultGridViewModel();
+ vm.SetResult(new QueryResult
+ {
+ Columns = new[] { new ColumnInfo { Name = "Name", DataType = "TEXT" } },
+ Rows = new List<object?[]> { new object?[] { "Doe, John" } },
+ RowCount = 1
+ });
+
+ var filePath = Path.Combine(Path.GetTempPath(), $"test_export_comma_{Guid.NewGuid():N}.csv");
+ try
+ {
+ await vm.ExportCsvAsync(filePath);
+
+ var content = await File.ReadAllTextAsync(filePath);
+ var lines = content.Split('\n', StringSplitOptions.RemoveEmptyEntries);
+ Assert.Equal(2, lines.Length);
+ Assert.Equal("\"Doe, John\"", lines[1].TrimEnd('\r'));
+ }
+ finally
+ {
+ if (File.Exists(filePath))
+ File.Delete(filePath);
+ }
+ }
+
+ [Fact]
+ public async Task ExportCsvAsync_HandlesQuotesInField()
+ {
+ var vm = new ResultGridViewModel();
+ vm.SetResult(new QueryResult
+ {
+ Columns = new[] { new ColumnInfo { Name = "Name", DataType = "TEXT" } },
+ Rows = new List<object?[]> { new object?[] { "He said \"Hello\"" } },
+ RowCount = 1
+ });
+
+ var filePath = Path.Combine(Path.GetTempPath(), $"test_export_quote_{Guid.NewGuid():N}.csv");
+ try
+ {
+ await vm.ExportCsvAsync(filePath);
+
+ var content = await File.ReadAllTextAsync(filePath);
+ var lines = content.Split('\n', StringSplitOptions.RemoveEmptyEntries);
+ Assert.Equal(2, lines.Length);
+ Assert.Equal("\"He said \"\"Hello\"\"\"", lines[1].TrimEnd('\r'));
+ }
+ finally
+ {
+ if (File.Exists(filePath))
+ File.Delete(filePath);
+ }
+ }
+
+ [Fact]
+ public async Task ExportCsvAsync_HandlesNewlinesInField()
+ {
+ var vm = new ResultGridViewModel();
+ vm.SetResult(new QueryResult
+ {
+ Columns = new[] { new ColumnInfo { Name = "Description", DataType = "TEXT" } },
+ Rows = new List<object?[]> { new object?[] { "Line1\nLine2" } },
+ RowCount = 1
+ });
+
+ var filePath = Path.Combine(Path.GetTempPath(), $"test_export_newline_{Guid.NewGuid():N}.csv");
+ try
+ {
+ await vm.ExportCsvAsync(filePath);
+
+ var content = await File.ReadAllTextAsync(filePath);
+ // A newline inside a quoted field: the field is wrapped in quotes.
+ // SB.AppendLine uses Environment.NewLine (\r\n on Windows).
+ Assert.Contains("Line1", content);
+ Assert.Contains("Line2", content);
+ Assert.Contains("\"Line1\nLine2\"", content);
+ }
+ finally
+ {
+ if (File.Exists(filePath))
+ File.Delete(filePath);
+ }
+ }
+
+ [Fact]
+ public async Task ExportCsvAsync_HandlesMixedSpecialChars()
+ {
+ var vm = new ResultGridViewModel();
+ vm.SetResult(new QueryResult
+ {
+ Columns = new[] {
+ new ColumnInfo { Name = "Col1", DataType = "TEXT" },
+ new ColumnInfo { Name = "Col2", DataType = "TEXT" }
+ },
+ Rows = new List<object?[]> { new object?[] { "a,b", "x\"y" } },
+ RowCount = 1
+ });
+
+ var filePath = Path.Combine(Path.GetTempPath(), $"test_export_mixed_{Guid.NewGuid():N}.csv");
+ try
+ {
+ await vm.ExportCsvAsync(filePath);
+
+ var content = await File.ReadAllTextAsync(filePath);
+ var lines = content.Split('\n', StringSplitOptions.RemoveEmptyEntries);
+ Assert.Equal(2, lines.Length);
+ Assert.Equal("\"a,b\",\"x\"\"y\"", lines[1].TrimEnd('\r'));
+ }
+ finally
+ {
+ if (File.Exists(filePath))
+ File.Delete(filePath);
+ }
+ }
+
+ [Fact]
+ public async Task ExportCsvAsync_NoSpecialChars_NoQuoting()
+ {
+ var vm = new ResultGridViewModel();
+ vm.SetResult(new QueryResult
+ {
+ Columns = new[] { new ColumnInfo { Name = "Simple", DataType = "TEXT" } },
+ Rows = new List<object?[]> { new object?[] { "PlainText" } },
+ RowCount = 1
+ });
+
+ var filePath = Path.Combine(Path.GetTempPath(), $"test_export_plain_{Guid.NewGuid():N}.csv");
+ try
+ {
+ await vm.ExportCsvAsync(filePath);
+
+ var content = await File.ReadAllTextAsync(filePath);
+ var lines = content.Split('\n', StringSplitOptions.RemoveEmptyEntries);
+ Assert.Equal(2, lines.Length);
+ Assert.Equal("PlainText", lines[1].TrimEnd('\r'));
+ }
+ finally
+ {
+ if (File.Exists(filePath))
+ File.Delete(filePath);
+ }
+ }
+
+ [Fact]
+ public async Task ExportJsonAsync_CreatesValidJson()
+ {
+ var vm = new ResultGridViewModel();
+ vm.SetResult(CreateResult());
+
+ var filePath = Path.Combine(Path.GetTempPath(), $"test_export_{Guid.NewGuid():N}.json");
+ try
+ {
+ await vm.ExportJsonAsync(filePath);
+
+ Assert.True(File.Exists(filePath));
+ var content = await File.ReadAllTextAsync(filePath);
+
+ var list = JsonSerializer.Deserialize<List<Dictionary<string, JsonElement>>>(content);
+ Assert.NotNull(list);
+ Assert.Equal(3, list!.Count);
+
+ // Note: CamelCasePropertyNamingPolicy only affects property names,
+ // not dictionary keys. Dictionary keys retain their original casing.
+ Assert.Equal(1, list[0]["Id"].GetInt32());
+ Assert.Equal("Alice", list[0]["Name"].GetString());
+ Assert.Equal(30, list[0]["Age"].GetInt32());
+ }
+ finally
+ {
+ if (File.Exists(filePath))
+ File.Delete(filePath);
+ }
+ }
+
+ [Fact]
+ public async Task ExportJsonAsync_UsesIndentedFormat()
+ {
+ var vm = new ResultGridViewModel();
+ vm.SetResult(new QueryResult
+ {
+ Columns = new[] { new ColumnInfo { Name = "FullName", DataType = "TEXT" } },
+ Rows = new List<object?[]> { new object?[] { "Test" } },
+ RowCount = 1
+ });
+
+ var filePath = Path.Combine(Path.GetTempPath(), $"test_export_camel_{Guid.NewGuid():N}.json");
+ try
+ {
+ await vm.ExportJsonAsync(filePath);
+
+ var content = await File.ReadAllTextAsync(filePath);
+ // Verify it is valid JSON with indented formatting
+ Assert.Contains("FullName", content);
+ Assert.Contains("Test", content);
+ Assert.Contains("\n", content); // indented → multi-line
+
+ // Re-parse to confirm valid JSON
+ var list = JsonSerializer.Deserialize<List<Dictionary<string, JsonElement>>>(content);
+ Assert.NotNull(list);
+ Assert.Single(list!);
+ Assert.Equal("Test", list[0]["FullName"].GetString());
+ }
+ finally
+ {
+ if (File.Exists(filePath))
+ File.Delete(filePath);
+ }
+ }
+
+ [Fact]
+ public void EscapeCsvField_WithComma_WrapsInQuotes()
+ {
+ var vm = new ResultGridViewModel();
+ vm.SetResult(new QueryResult
+ {
+ Columns = new[] { new ColumnInfo { Name = "Field", DataType = "TEXT" } },
+ Rows = new List<object?[]> { new object?[] { "a,b" } },
+ RowCount = 1
+ });
+
+ var field = EscapeCsvFieldViaReflection("a,b");
+ Assert.Equal("\"a,b\"", field);
+ }
+
+ [Fact]
+ public void EscapeCsvField_WithQuote_DoublesQuoteAndWraps()
+ {
+ var field = EscapeCsvFieldViaReflection("a\"b");
+ Assert.Equal("\"a\"\"b\"", field);
+ }
+
+ [Fact]
+ public void EscapeCsvField_WithNewline_WrapsInQuotes()
+ {
+ var field = EscapeCsvFieldViaReflection("a\nb");
+ Assert.Equal("\"a\nb\"", field);
+ }
+
+ [Fact]
+ public void EscapeCsvField_WithCarriageReturn_WrapsInQuotes()
+ {
+ var field = EscapeCsvFieldViaReflection("a\rb");
+ Assert.Equal("\"a\rb\"", field);
+ }
+
+ [Fact]
+ public void EscapeCsvField_PlainText_ReturnsSame()
+ {
+ var field = EscapeCsvFieldViaReflection("HelloWorld");
+ Assert.Equal("HelloWorld", field);
+ }
+
+ [Fact]
+ public void EscapeCsvField_EmptyString_ReturnsEmpty()
+ {
+ var field = EscapeCsvFieldViaReflection("");
+ Assert.Equal("", field);
+ }
+
+ [Fact]
+ public void EscapeCsvField_NumericText_ReturnsSame()
+ {
+ var field = EscapeCsvFieldViaReflection("12345");
+ Assert.Equal("12345", field);
+ }
+
+ [Fact]
+ public async Task ExportCsvAsync_EmptyColumns_OnlyWritesEmptyLine()
+ {
+ var vm = new ResultGridViewModel();
+ vm.SetResult(new QueryResult { Columns = [], Rows = [], RowCount = 0 });
+
+ var filePath = Path.Combine(Path.GetTempPath(), $"test_export_empty_{Guid.NewGuid():N}.csv");
+ try
+ {
+ await vm.ExportCsvAsync(filePath);
+
+ var content = await File.ReadAllTextAsync(filePath);
+ var lines = content.Split('\n', StringSplitOptions.RemoveEmptyEntries);
+ // Header with no columns yields an empty string then AppendLine adds \r\n
+ // Single empty line
+ Assert.Single(lines);
+ }
+ finally
+ {
+ if (File.Exists(filePath))
+ File.Delete(filePath);
+ }
+ }
+
+ [Fact]
+ public async Task ExportJsonAsync_EmptyData_ProducesEmptyJsonArray()
+ {
+ var vm = new ResultGridViewModel();
+ vm.SetResult(new QueryResult { Columns = [], Rows = [], RowCount = 0 });
+
+ var filePath = Path.Combine(Path.GetTempPath(), $"test_export_empty_{Guid.NewGuid():N}.json");
+ try
+ {
+ await vm.ExportJsonAsync(filePath);
+
+ var content = await File.ReadAllTextAsync(filePath);
+ Assert.Equal("[]", content.Trim());
+ }
+ finally
+ {
+ if (File.Exists(filePath))
+ File.Delete(filePath);
+ }
+ }
+
+ private static string EscapeCsvFieldViaReflection(string field)
+ {
+ var method = typeof(ResultGridViewModel).GetMethod(
+ "EscapeCsvField",
+ BindingFlags.NonPublic | BindingFlags.Static);
+
+ Assert.NotNull(method);
+ return (string)method!.Invoke(null, new object[] { field })!;
+ }
+}
\ No newline at end of file
diff --git a/tests/NewLife.Studio.Modules.DataStudio.Tests/SqlEditorViewModelTests.cs b/tests/NewLife.Studio.Modules.DataStudio.Tests/SqlEditorViewModelTests.cs
new file mode 100644
index 0000000..f9e02e4
--- /dev/null
+++ b/tests/NewLife.Studio.Modules.DataStudio.Tests/SqlEditorViewModelTests.cs
@@ -0,0 +1,272 @@
+using Moq;
+using NewLife.Studio.Core.DTOs;
+using NewLife.Studio.Data;
+using NewLife.Studio.Modules.DataStudio.ViewModels;
+using NewLife.Studio.Store;
+using Xunit;
+
+namespace NewLife.Studio.Modules.DataStudio.Tests;
+
+public class SqlEditorViewModelTests
+{
+ private readonly Mock<IStoreService> _storeServiceMock;
+ private readonly SqlEditorViewModel _vm;
+
+ public SqlEditorViewModelTests()
+ {
+ _storeServiceMock = new Mock<IStoreService>();
+ _vm = new SqlEditorViewModel(_storeServiceMock.Object);
+ }
+
+ [Fact]
+ public void InitialState_HasEmptyTabs()
+ {
+ Assert.NotNull(_vm.Tabs);
+ Assert.Empty(_vm.Tabs);
+ }
+
+ [Fact]
+ public void InitialState_ActiveTab_IsNull()
+ {
+ Assert.Null(_vm.ActiveTab);
+ }
+
+ [Fact]
+ public void NewTabCommand_CreatesNewTab()
+ {
+ _vm.NewTabCommand.Execute(null);
+
+ Assert.Single(_vm.Tabs);
+ Assert.NotNull(_vm.Tabs[0]);
+ }
+
+ [Fact]
+ public void NewTabCommand_SetsActiveTab()
+ {
+ _vm.NewTabCommand.Execute(null);
+
+ Assert.NotNull(_vm.ActiveTab);
+ Assert.Same(_vm.Tabs[0], _vm.ActiveTab);
+ }
+
+ [Fact]
+ public void NewTabCommand_FirstTab_HasDefaultTitle()
+ {
+ _vm.NewTabCommand.Execute(null);
+
+ Assert.Equal("查询 1", _vm.Tabs[0].Title);
+ }
+
+ [Fact]
+ public void NewTabCommand_SecondTab_HasIncrementedTitle()
+ {
+ _vm.NewTabCommand.Execute(null); // "查询 1"
+ _vm.NewTabCommand.Execute(null); // "查询 2"
+
+ Assert.Equal(2, _vm.Tabs.Count);
+ Assert.Equal("查询 1", _vm.Tabs[0].Title);
+ Assert.Equal("查询 2", _vm.Tabs[1].Title);
+ }
+
+ [Fact]
+ public void NewTabCommand_ThirdTab_HasIncrementedTitle()
+ {
+ _vm.NewTabCommand.Execute(null);
+ _vm.NewTabCommand.Execute(null);
+ _vm.NewTabCommand.Execute(null);
+
+ Assert.Equal(3, _vm.Tabs.Count);
+ Assert.Equal("查询 3", _vm.Tabs[2].Title);
+ }
+
+ [Fact]
+ public void NewTabCommand_EachTabHasIndependentViewModel()
+ {
+ _vm.NewTabCommand.Execute(null);
+ _vm.NewTabCommand.Execute(null);
+
+ Assert.NotSame(_vm.Tabs[0].ResultGrid, _vm.Tabs[1].ResultGrid);
+ }
+
+ [Fact]
+ public void NewTabCommand_ActiveTabIsLastCreated()
+ {
+ _vm.NewTabCommand.Execute(null);
+ var first = _vm.ActiveTab;
+ _vm.NewTabCommand.Execute(null);
+ var second = _vm.ActiveTab;
+
+ Assert.NotSame(first, second);
+ Assert.Same(_vm.Tabs[1], _vm.ActiveTab);
+ }
+
+ [Fact]
+ public void CloseTabCommand_RemovesTab()
+ {
+ _vm.NewTabCommand.Execute(null);
+ var tab = _vm.Tabs[0];
+
+ _vm.CloseTabCommand.Execute(tab);
+
+ Assert.Empty(_vm.Tabs);
+ }
+
+ [Fact]
+ public void CloseTabCommand_WithNull_DoesNothing()
+ {
+ _vm.NewTabCommand.Execute(null);
+
+ _vm.CloseTabCommand.Execute(null);
+
+ Assert.Single(_vm.Tabs);
+ }
+
+ [Fact]
+ public void CloseTabCommand_RemovesMiddleTab_KeepsOthers()
+ {
+ _vm.NewTabCommand.Execute(null); // tabs[0]
+ _vm.NewTabCommand.Execute(null); // tabs[1]
+ _vm.NewTabCommand.Execute(null); // tabs[2]
+
+ _vm.CloseTabCommand.Execute(_vm.Tabs[1]);
+
+ Assert.Equal(2, _vm.Tabs.Count);
+ Assert.Equal("查询 1", _vm.Tabs[0].Title);
+ Assert.Equal("查询 3", _vm.Tabs[1].Title);
+ }
+
+ [Fact]
+ public async Task ExecuteAsync_WithNullActiveTab_DoesNothing()
+ {
+ var mockSession = new Mock<IDbSession>();
+ _vm.SetSession(mockSession.Object);
+
+ await _vm.ExecuteCommand.ExecuteAsync(null);
+
+ mockSession.Verify(
+ s => s.ExecuteQueryAsync(It.IsAny<QueryRequest>(), It.IsAny<CancellationToken>()),
+ Times.Never);
+ }
+
+ [Fact]
+ public async Task ExecuteAsync_WithNullSession_DoesNothing()
+ {
+ _vm.NewTabCommand.Execute(null);
+
+ await _vm.ExecuteCommand.ExecuteAsync(null);
+
+ // No session, no store calls either
+ _storeServiceMock.Verify(
+ s => s.AddQueryHistoryAsync(It.IsAny<QueryHistoryEntry>()),
+ Times.Never);
+ }
+
+ [Fact]
+ public async Task ExecuteAsync_ExecutesQuery_AndSetsResult()
+ {
+ _vm.NewTabCommand.Execute(null);
+ _vm.ActiveTab!.Sql = "SELECT * FROM users";
+
+ var mockSession = new Mock<IDbSession>();
+ var result = new QueryResult
+ {
+ Columns = new[] { new ColumnInfo { Name = "Id", DataType = "INTEGER" } },
+ Rows = new List<object?[]> { new object?[] { 1 } },
+ RowCount = 1,
+ ElapsedMs = 10
+ };
+ mockSession
+ .Setup(s => s.ExecuteQueryAsync(It.IsAny<QueryRequest>(), It.IsAny<CancellationToken>()))
+ .ReturnsAsync(result);
+ mockSession
+ .Setup(s => s.Connection)
+ .Returns(new ConnectionInfo { Name = "TestDB" });
+ _storeServiceMock
+ .Setup(s => s.AddQueryHistoryAsync(It.IsAny<QueryHistoryEntry>()))
+ .Returns(Task.CompletedTask);
+
+ _vm.SetSession(mockSession.Object);
+
+ await _vm.ExecuteCommand.ExecuteAsync(null);
+
+ Assert.NotNull(_vm.ActiveTab.Result);
+ Assert.Equal(1, _vm.ActiveTab.Result!.RowCount);
+ mockSession.Verify(
+ s => s.ExecuteQueryAsync(
+ It.Is<QueryRequest>(r => r.Sql == "SELECT * FROM users"),
+ It.IsAny<CancellationToken>()),
+ Times.Once);
+ }
+
+ [Fact]
+ public async Task ExecuteAsync_AddsQueryHistory()
+ {
+ _vm.NewTabCommand.Execute(null);
+ _vm.ActiveTab!.Sql = "SELECT 1";
+
+ var mockSession = new Mock<IDbSession>();
+ mockSession
+ .Setup(s => s.ExecuteQueryAsync(It.IsAny<QueryRequest>(), It.IsAny<CancellationToken>()))
+ .ReturnsAsync(new QueryResult
+ {
+ Columns = [],
+ Rows = [],
+ RowCount = 0,
+ ElapsedMs = 5
+ });
+ mockSession
+ .Setup(s => s.Connection)
+ .Returns(new ConnectionInfo { Name = "HistoryDB" });
+ _storeServiceMock
+ .Setup(s => s.AddQueryHistoryAsync(It.IsAny<QueryHistoryEntry>()))
+ .Returns(Task.CompletedTask);
+
+ _vm.SetSession(mockSession.Object);
+
+ await _vm.ExecuteCommand.ExecuteAsync(null);
+
+ _storeServiceMock.Verify(
+ s => s.AddQueryHistoryAsync(It.Is<QueryHistoryEntry>(
+ h => h.Sql == "SELECT 1" && h.ConnectionName == "HistoryDB" && h.ElapsedMs == 5)),
+ Times.Once);
+ }
+
+ [Fact]
+ public async Task SetSession_StoresSessionInternally()
+ {
+ var mockSession = new Mock<IDbSession>();
+ _vm.SetSession(mockSession.Object);
+
+ // Can verify by running ExecuteAsync successfully
+ _vm.NewTabCommand.Execute(null);
+ mockSession
+ .Setup(s => s.ExecuteQueryAsync(It.IsAny<QueryRequest>(), It.IsAny<CancellationToken>()))
+ .ReturnsAsync(new QueryResult { Columns = [], Rows = [] });
+ mockSession
+ .Setup(s => s.Connection)
+ .Returns(new ConnectionInfo { Name = "Test" });
+ _storeServiceMock
+ .Setup(s => s.AddQueryHistoryAsync(It.IsAny<QueryHistoryEntry>()))
+ .Returns(Task.CompletedTask);
+
+ // Should not throw
+ var exception = await Record.ExceptionAsync(() => _vm.ExecuteCommand.ExecuteAsync(null));
+ Assert.Null(exception);
+ }
+
+ [Fact]
+ public void Tabs_Collection_CanBeObservedExternally()
+ {
+ var addedTabs = new List<QueryTab>();
+ _vm.Tabs.CollectionChanged += (_, args) =>
+ {
+ if (args.NewItems != null)
+ foreach (QueryTab tab in args.NewItems)
+ addedTabs.Add(tab);
+ };
+
+ _vm.NewTabCommand.Execute(null);
+
+ Assert.Single(addedTabs);
+ }
+}
\ No newline at end of file
diff --git a/tests/NewLife.Studio.Store.Tests/NewLife.Studio.Store.Tests.csproj b/tests/NewLife.Studio.Store.Tests/NewLife.Studio.Store.Tests.csproj
new file mode 100644
index 0000000..e3376aa
--- /dev/null
+++ b/tests/NewLife.Studio.Store.Tests/NewLife.Studio.Store.Tests.csproj
@@ -0,0 +1,26 @@
+<Project Sdk="Microsoft.NET.Sdk">
+
+ <PropertyGroup>
+ <TargetFramework>net9.0</TargetFramework>
+ <ImplicitUsings>enable</ImplicitUsings>
+ <Nullable>enable</Nullable>
+ <IsPackable>false</IsPackable>
+ <IsTestProject>true</IsTestProject>
+ <RootNamespace>NewLife.Studio.Store.Tests</RootNamespace>
+ </PropertyGroup>
+
+ <ItemGroup>
+ <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.13.0" />
+ <PackageReference Include="xunit" Version="2.9.3" />
+ <PackageReference Include="xunit.runner.visualstudio" Version="3.0.2" />
+ <PackageReference Include="coverlet.collector" Version="6.0.4">
+ <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
+ <PrivateAssets>all</PrivateAssets>
+ </PackageReference>
+ </ItemGroup>
+
+ <ItemGroup>
+ <ProjectReference Include="..\..\src\Framework\NewLife.Studio.Store\NewLife.Studio.Store.csproj" />
+ </ItemGroup>
+
+</Project>
\ No newline at end of file
diff --git a/tests/NewLife.Studio.Store.Tests/SecretProtectionTests.cs b/tests/NewLife.Studio.Store.Tests/SecretProtectionTests.cs
new file mode 100644
index 0000000..8a242c5
--- /dev/null
+++ b/tests/NewLife.Studio.Store.Tests/SecretProtectionTests.cs
@@ -0,0 +1,134 @@
+using Xunit;
+
+namespace NewLife.Studio.Store.Tests;
+
+public class SecretProtectionTests
+{
+ [Fact]
+ public void Protect_Then_Unprotect_RoundTrip()
+ {
+ var protection = new SecretProtection();
+ const string original = "Server=localhost;Database=mydb;Uid=admin;Pwd=secret123;";
+
+ var encrypted = protection.Protect(original);
+ var decrypted = protection.Unprotect(encrypted);
+
+ Assert.Equal(original, decrypted);
+ Assert.NotEqual(original, encrypted);
+ }
+
+ [Fact]
+ public void Unprotect_EmptyString_ReturnsEmpty()
+ {
+ var protection = new SecretProtection();
+
+ var result = protection.Unprotect("");
+
+ Assert.Equal("", result);
+ }
+
+ [Fact]
+ public void Unprotect_NullString_ReturnsEmpty()
+ {
+ var protection = new SecretProtection();
+
+ var result = protection.Unprotect(null!);
+
+ Assert.Equal("", result);
+ }
+
+ [Fact]
+ public void Protect_EmptyString_ReturnsEmpty()
+ {
+ var protection = new SecretProtection();
+
+ var result = protection.Protect("");
+
+ Assert.Equal("", result);
+ }
+
+ [Fact]
+ public void Protect_NullString_ReturnsEmpty()
+ {
+ var protection = new SecretProtection();
+
+ var result = protection.Protect(null!);
+
+ Assert.Equal("", result);
+ }
+
+ [Fact]
+ public void DifferentInputs_ProduceDifferentCiphertext()
+ {
+ var protection = new SecretProtection();
+ const string input1 = "connection-string-one";
+ const string input2 = "connection-string-two";
+
+ var cipher1 = protection.Protect(input1);
+ var cipher2 = protection.Protect(input2);
+
+ Assert.NotEqual(cipher1, cipher2);
+ }
+
+ [Fact]
+ public void SameInput_ProducesDeterministicCiphertext()
+ {
+ var protection = new SecretProtection();
+ const string input = "deterministic-test-input";
+
+ var cipher1 = protection.Protect(input);
+ var cipher2 = protection.Protect(input);
+
+ Assert.Equal(cipher1, cipher2);
+ }
+
+ [Fact]
+ public void Unprotect_InvalidBase64_ReturnsOriginalInput()
+ {
+ var protection = new SecretProtection();
+ const string invalid = "this-is-not-base64!!";
+
+ var result = protection.Unprotect(invalid);
+
+ Assert.Equal(invalid, result);
+ }
+
+ [Fact]
+ public void Protect_Unprotect_WithSpecialCharacters()
+ {
+ var protection = new SecretProtection();
+ const string original = "p@$$w0rd!测试中文\n\t\\特殊字符";
+
+ var encrypted = protection.Protect(original);
+ var decrypted = protection.Unprotect(encrypted);
+
+ Assert.Equal(original, decrypted);
+ }
+
+ [Fact]
+ public void Protect_Unprotect_WithVeryLongString()
+ {
+ var protection = new SecretProtection();
+ var original = new string('x', 10000);
+
+ var encrypted = protection.Protect(original);
+ var decrypted = protection.Unprotect(encrypted);
+
+ Assert.Equal(original, decrypted);
+ }
+
+ [Fact]
+ public void Unprotect_TamperedCiphertext_ReturnsOriginalInput()
+ {
+ var protection = new SecretProtection();
+ var encrypted = protection.Protect("secret-value");
+ // Flip a character in the middle
+ var chars = encrypted.ToCharArray();
+ chars[chars.Length / 2] = chars[chars.Length / 2] == 'A' ? 'B' : 'A';
+ var tampered = new string(chars);
+
+ var result = protection.Unprotect(tampered);
+
+ Assert.Equal(tampered, result);
+ }
+}
\ No newline at end of file
diff --git a/tests/NewLife.Studio.Store.Tests/StoredConnectionTests.cs b/tests/NewLife.Studio.Store.Tests/StoredConnectionTests.cs
new file mode 100644
index 0000000..2b8c52e
--- /dev/null
+++ b/tests/NewLife.Studio.Store.Tests/StoredConnectionTests.cs
@@ -0,0 +1,97 @@
+using NewLife.Studio.Core.DTOs;
+using NewLife.Studio.Store.Models;
+using Xunit;
+
+namespace NewLife.Studio.Store.Tests;
+
+public class StoredConnectionTests
+{
+ [Fact]
+ public void ToDto_ReturnsConnectionInfoWithSameValues()
+ {
+ var stored = new StoredConnection
+ {
+ Id = "test-id",
+ Name = "Production MySQL",
+ ConnectionString = "Server=db.example.com;Database=app;",
+ ProviderType = "mysql",
+ LastUsedAt = new DateTime(2025, 1, 15, 14, 30, 0),
+ Group = "Production"
+ };
+
+ var dto = stored.ToDto();
+
+ Assert.Equal(stored.Id, dto.Id);
+ Assert.Equal(stored.Name, dto.Name);
+ Assert.Equal(stored.ConnectionString, dto.ConnectionString);
+ Assert.Equal(stored.ProviderType, dto.ProviderType);
+ Assert.Equal(stored.LastUsedAt, dto.LastUsedAt);
+ Assert.Equal(stored.Group, dto.Group);
+ }
+
+ [Fact]
+ public void FromDto_ReturnsStoredConnectionWithSameValues()
+ {
+ var dto = new ConnectionInfo
+ {
+ Id = Guid.NewGuid().ToString("N"),
+ Name = "Dev SQLite",
+ ConnectionString = "Data Source=dev.db",
+ ProviderType = "sqlite",
+ LastUsedAt = DateTime.UtcNow,
+ Group = "Development"
+ };
+
+ var stored = StoredConnection.FromDto(dto);
+
+ Assert.Equal(dto.Id, stored.Id);
+ Assert.Equal(dto.Name, stored.Name);
+ Assert.Equal(dto.ConnectionString, stored.ConnectionString);
+ Assert.Equal(dto.ProviderType, stored.ProviderType);
+ Assert.Equal(dto.LastUsedAt, stored.LastUsedAt);
+ Assert.Equal(dto.Group, stored.Group);
+ }
+
+ [Fact]
+ public void FromDto_Then_ToDto_RoundTrip()
+ {
+ var original = new ConnectionInfo
+ {
+ Id = Guid.NewGuid().ToString("N"),
+ Name = "RoundTrip Test",
+ ConnectionString = "Host=roundtrip;Port=5432;",
+ ProviderType = "postgres",
+ LastUsedAt = new DateTime(2025, 6, 1, 9, 0, 0),
+ Group = "Staging"
+ };
+
+ var stored = StoredConnection.FromDto(original);
+ var roundTripped = stored.ToDto();
+
+ Assert.Equal(original.Id, roundTripped.Id);
+ Assert.Equal(original.Name, roundTripped.Name);
+ Assert.Equal(original.ConnectionString, roundTripped.ConnectionString);
+ Assert.Equal(original.ProviderType, roundTripped.ProviderType);
+ Assert.Equal(original.LastUsedAt, roundTripped.LastUsedAt);
+ Assert.Equal(original.Group, roundTripped.Group);
+ }
+
+ [Fact]
+ public void FromDto_Then_ToDto_PreservesDefaultValues()
+ {
+ var dto = new ConnectionInfo
+ {
+ Name = "Defaults Test"
+ };
+
+ var stored = StoredConnection.FromDto(dto);
+ var result = stored.ToDto();
+
+ Assert.Equal(dto.Id, result.Id);
+ Assert.Equal("Defaults Test", result.Name);
+ Assert.Equal("", result.ConnectionString);
+ Assert.Equal("sqlite", result.ProviderType);
+ Assert.Equal("", result.Group);
+ Assert.Equal(default, result.LastUsedAt);
+ }
+}
\ No newline at end of file
diff --git a/tests/NewLife.Studio.Store.Tests/StoreServiceTests.cs b/tests/NewLife.Studio.Store.Tests/StoreServiceTests.cs
new file mode 100644
index 0000000..b09e002
--- /dev/null
+++ b/tests/NewLife.Studio.Store.Tests/StoreServiceTests.cs
@@ -0,0 +1,377 @@
+using NewLife.Studio.Core.DTOs;
+using NewLife.Studio.Store;
+using Xunit;
+
+namespace NewLife.Studio.Store.Tests;
+
+public class StoreServiceTests : IDisposable
+{
+ private readonly string _tempDir;
+ private readonly StoreService _store;
+
+ public StoreServiceTests()
+ {
+ _tempDir = Path.Combine(Path.GetTempPath(), $"StoreTests_{Guid.NewGuid():N}");
+ _store = new StoreService(_tempDir, new SecretProtection());
+ }
+
+ public void Dispose()
+ {
+ try
+ {
+ if (Directory.Exists(_tempDir))
+ Directory.Delete(_tempDir, recursive: true);
+ }
+ catch
+ {
+ // Best effort cleanup
+ }
+ }
+
+ // ========== 连接管理 ==========
+
+ [Fact]
+ public async Task SaveConnection_Then_ListConnections()
+ {
+ var conn = new ConnectionInfo
+ {
+ Id = "conn-001",
+ Name = "Test DB",
+ ConnectionString = "Server=localhost;Database=test;",
+ ProviderType = "mysql",
+ Group = "Production"
+ };
+
+ await _store.SaveConnectionAsync(conn);
+ var connections = await _store.ListConnectionsAsync();
+
+ Assert.Single(connections);
+
+ var listed = connections[0];
+ Assert.Equal(conn.Id, listed.Id);
+ Assert.Equal(conn.Name, listed.Name);
+ Assert.Equal(conn.ConnectionString, listed.ConnectionString);
+ Assert.Equal(conn.ProviderType, listed.ProviderType);
+ Assert.Equal(conn.Group, listed.Group);
+ }
+
+ [Fact]
+ public async Task SaveConnection_ConnectionStringIsEncryptedInFile()
+ {
+ var conn = new ConnectionInfo
+ {
+ Id = "conn-enc-test",
+ Name = "Encrypted DB",
+ ConnectionString = "Server=prod-server;Pwd=super-secret;"
+ };
+
+ await _store.SaveConnectionAsync(conn);
+
+ // 从文件直接读取,验证连接字符串已加密
+ var json = await File.ReadAllTextAsync(
+ Path.Combine(_tempDir, "connections.json"));
+ Assert.DoesNotContain("super-secret", json);
+ Assert.DoesNotContain("prod-server", json);
+ }
+
+ [Fact]
+ public async Task SaveConnection_UpdateExisting()
+ {
+ var conn = new ConnectionInfo
+ {
+ Id = "conn-update",
+ Name = "Original Name",
+ ConnectionString = "Server=old;",
+ ProviderType = "sqlite"
+ };
+
+ await _store.SaveConnectionAsync(conn);
+
+ conn.Name = "Updated Name";
+ conn.ConnectionString = "Server=new;";
+ await _store.SaveConnectionAsync(conn);
+
+ var connections = await _store.ListConnectionsAsync();
+
+ Assert.Single(connections);
+ Assert.Equal("Updated Name", connections[0].Name);
+ Assert.Equal("Server=new;", connections[0].ConnectionString);
+ }
+
+ [Fact]
+ public async Task ListConnections_WhenEmpty_ReturnsEmptyList()
+ {
+ var connections = await _store.ListConnectionsAsync();
+
+ Assert.NotNull(connections);
+ Assert.Empty(connections);
+ }
+
+ [Fact]
+ public async Task DeleteConnection_RemovesConnection()
+ {
+ var conn = new ConnectionInfo { Id = "conn-del", Name = "To Delete" };
+ await _store.SaveConnectionAsync(conn);
+
+ await _store.DeleteConnectionAsync("conn-del");
+
+ var connections = await _store.ListConnectionsAsync();
+ Assert.Empty(connections);
+ }
+
+ [Fact]
+ public async Task DeleteConnection_NonExistent_DoesNotThrow()
+ {
+ var conn = new ConnectionInfo { Id = "conn-keep", Name = "Keep Me" };
+ await _store.SaveConnectionAsync(conn);
+
+ await _store.DeleteConnectionAsync("non-existent-id");
+
+ var connections = await _store.ListConnectionsAsync();
+ Assert.Single(connections);
+ }
+
+ [Fact]
+ public async Task SaveMultipleConnections_Then_ListAll()
+ {
+ var conn1 = new ConnectionInfo { Id = "c1", Name = "DB-1", Group = "Prod" };
+ var conn2 = new ConnectionInfo { Id = "c2", Name = "DB-2", Group = "Dev" };
+ var conn3 = new ConnectionInfo { Id = "c3", Name = "DB-3", Group = "Prod" };
+
+ await _store.SaveConnectionAsync(conn1);
+ await _store.SaveConnectionAsync(conn2);
+ await _store.SaveConnectionAsync(conn3);
+
+ var connections = await _store.ListConnectionsAsync();
+
+ Assert.Equal(3, connections.Count);
+ Assert.Contains(connections, c => c.Name == "DB-1");
+ Assert.Contains(connections, c => c.Name == "DB-2");
+ Assert.Contains(connections, c => c.Name == "DB-3");
+ }
+
+ // ========== 查询历史 ==========
+
+ [Fact]
+ public async Task AddQueryHistory_Then_GetRecentQueries()
+ {
+ var entry = new QueryHistoryEntry
+ {
+ Id = "q1",
+ Sql = "SELECT * FROM users",
+ ConnectionName = "Test DB",
+ ExecutedAt = DateTime.Parse("2025-01-01T10:00:00Z"),
+ ElapsedMs = 100,
+ RowCount = 42
+ };
+
+ await _store.AddQueryHistoryAsync(entry);
+ var queries = await _store.GetRecentQueriesAsync();
+
+ Assert.Single(queries);
+ Assert.Equal(entry.Sql, queries[0].Sql);
+ Assert.Equal(entry.ConnectionName, queries[0].ConnectionName);
+ Assert.Equal(entry.RowCount, queries[0].RowCount);
+ Assert.Equal(entry.ElapsedMs, queries[0].ElapsedMs);
+ }
+
+ [Fact]
+ public async Task GetRecentQueries_NewestFirst()
+ {
+ var older = new QueryHistoryEntry
+ {
+ Id = "old",
+ Sql = "SELECT 1",
+ ExecutedAt = DateTime.Parse("2025-01-01T10:00:00Z")
+ };
+ var newer = new QueryHistoryEntry
+ {
+ Id = "new",
+ Sql = "SELECT 2",
+ ExecutedAt = DateTime.Parse("2025-02-01T10:00:00Z")
+ };
+
+ await _store.AddQueryHistoryAsync(older);
+ await _store.AddQueryHistoryAsync(newer);
+
+ var queries = await _store.GetRecentQueriesAsync();
+
+ Assert.Equal(2, queries.Count);
+ Assert.Equal("SELECT 2", queries[0].Sql);
+ Assert.Equal("SELECT 1", queries[1].Sql);
+ }
+
+ [Fact]
+ public async Task GetRecentQueries_RespectsLimit()
+ {
+ for (var i = 0; i < 100; i++)
+ {
+ await _store.AddQueryHistoryAsync(new QueryHistoryEntry
+ {
+ Id = $"q{i}",
+ Sql = $"SELECT {i}",
+ ExecutedAt = DateTime.Now.AddMinutes(-i)
+ });
+ }
+
+ var queries = await _store.GetRecentQueriesAsync(10);
+
+ Assert.Equal(10, queries.Count);
+ }
+
+ [Fact]
+ public async Task GetRecentQueries_WhenNoHistory_ReturnsEmptyList()
+ {
+ var queries = await _store.GetRecentQueriesAsync();
+
+ Assert.NotNull(queries);
+ Assert.Empty(queries);
+ }
+
+ [Fact]
+ public async Task GetRecentQueries_DefaultLimitIs50()
+ {
+ for (var i = 0; i < 60; i++)
+ {
+ await _store.AddQueryHistoryAsync(new QueryHistoryEntry
+ {
+ Id = $"q{i}",
+ Sql = $"SELECT {i}",
+ ExecutedAt = DateTime.Now.AddMinutes(-i)
+ });
+ }
+
+ var queries = await _store.GetRecentQueriesAsync();
+
+ Assert.Equal(50, queries.Count);
+ }
+
+ // ========== AI 配置 ==========
+
+ [Fact]
+ public async Task SaveAiProfile_Then_GetAiProfile()
+ {
+ var original = new AiProfile
+ {
+ ProviderType = "azure",
+ Endpoint = "https://my-azure.openai.com",
+ ApiKey = "sk-abc123-secret-key",
+ Model = "gpt-4"
+ };
+
+ await _store.SaveAiProfileAsync(original);
+ var retrieved = await _store.GetAiProfileAsync();
+
+ Assert.NotNull(retrieved);
+ Assert.Equal(original.ProviderType, retrieved.ProviderType);
+ Assert.Equal(original.Endpoint, retrieved.Endpoint);
+ Assert.Equal(original.ApiKey, retrieved.ApiKey);
+ Assert.Equal(original.Model, retrieved.Model);
+ }
+
+ [Fact]
+ public async Task SaveAiProfile_ApiKeyIsEncryptedInFile()
+ {
+ var profile = new AiProfile
+ {
+ ProviderType = "openai",
+ ApiKey = "sk-top-secret-key-12345"
+ };
+
+ await _store.SaveAiProfileAsync(profile);
+
+ var json = await File.ReadAllTextAsync(
+ Path.Combine(_tempDir, "ai_profile.json"));
+ Assert.DoesNotContain("sk-top-secret-key-12345", json);
+ }
+
+ [Fact]
+ public async Task GetAiProfile_WhenNoFile_ReturnsNull()
+ {
+ var profile = await _store.GetAiProfileAsync();
+
+ Assert.Null(profile);
+ }
+
+ [Fact]
+ public async Task SaveAiProfile_UpdateExisting()
+ {
+ var original = new AiProfile
+ {
+ ProviderType = "openai",
+ ApiKey = "old-key"
+ };
+ await _store.SaveAiProfileAsync(original);
+
+ var updated = new AiProfile
+ {
+ ProviderType = "azure",
+ ApiKey = "new-key",
+ Model = "gpt-4-turbo"
+ };
+ await _store.SaveAiProfileAsync(updated);
+
+ var retrieved = await _store.GetAiProfileAsync();
+
+ Assert.NotNull(retrieved);
+ Assert.Equal("azure", retrieved.ProviderType);
+ Assert.Equal("new-key", retrieved.ApiKey);
+ Assert.Equal("gpt-4-turbo", retrieved.Model);
+ }
+
+ // ========== 应用偏好 ==========
+
+ [Fact]
+ public async Task SavePreferences_Then_GetPreferences()
+ {
+ var pref = new AppPreference
+ {
+ MaxRows = 500,
+ DefaultExportPath = "/home/user/exports",
+ Theme = "Dark",
+ Language = "en-US"
+ };
+
+ await _store.SavePreferencesAsync(pref);
+ var retrieved = await _store.GetPreferencesAsync();
+
+ Assert.Equal(pref.MaxRows, retrieved.MaxRows);
+ Assert.Equal(pref.DefaultExportPath, retrieved.DefaultExportPath);
+ Assert.Equal(pref.Theme, retrieved.Theme);
+ Assert.Equal(pref.Language, retrieved.Language);
+ }
+
+ [Fact]
+ public async Task GetPreferences_WhenNoFile_ReturnsDefaults()
+ {
+ var pref = await _store.GetPreferencesAsync();
+
+ Assert.NotNull(pref);
+ Assert.Equal(1000, pref.MaxRows);
+ Assert.Equal("", pref.DefaultExportPath);
+ Assert.Equal("Light", pref.Theme);
+ Assert.Equal("zh-CN", pref.Language);
+ }
+
+ [Fact]
+ public async Task SavePreferences_UpdateExisting()
+ {
+ var pref = new AppPreference
+ {
+ MaxRows = 200,
+ Theme = "Light"
+ };
+ await _store.SavePreferencesAsync(pref);
+
+ pref.MaxRows = 5000;
+ pref.Theme = "Dark";
+ pref.Language = "ja-JP";
+ await _store.SavePreferencesAsync(pref);
+
+ var retrieved = await _store.GetPreferencesAsync();
+
+ Assert.Equal(5000, retrieved.MaxRows);
+ Assert.Equal("Dark", retrieved.Theme);
+ Assert.Equal("ja-JP", retrieved.Language);
+ }
+}
\ No newline at end of file