NewLife/NewLife.Studio

feat: 初始化NewLife Studio项目,完成基础框架与数据管理模块

新增项目整体结构,包含核心库、数据提供层、AI模块、数据管理模块与主应用程序,实现:
1. 基础的MVVM架构与服务定位
2. 数据库连接管理与SQL查询功能
3. AI对话与工具调用基础框架
4. 模块化加载与主窗口UI框架
5. 配套单元测试与项目配置
何炳宏 authored at 2026-05-26 12:09:09
5eb3d11
Tree
0 Parent(s)
Summary: 111 changed files with 9633 additions and 0 deletions.
Added +4 -0
Added +339 -0
Added +13 -0
Added +532 -0
Added +227 -0
Added +292 -0
Added +23 -0
Added +225 -0
Added +15 -0
Added +62 -0
Added +18 -0
Added +0 -0
Added +23 -0
Added +244 -0
Added +5 -0
Added +16 -0
Added +10 -0
Added +60 -0
Added +12 -0
Added +26 -0
Added +36 -0
Added +21 -0
Added +84 -0
Added +37 -0
Added +6 -0
Added +7 -0
Added +24 -0
Added +57 -0
Added +19 -0
Added +13 -0
Added +112 -0
Added +13 -0
Added +40 -0
Added +59 -0
Added +102 -0
Added +126 -0
Added +71 -0
Added +10 -0
Added +10 -0
Added +12 -0
Added +12 -0
Added +12 -0
Added +10 -0
Added +12 -0
Added +9 -0
Added +8 -0
Added +28 -0
Added +11 -0
Added +14 -0
Added +29 -0
Added +24 -0
Added +10 -0
Added +10 -0
Added +40 -0
Added +9 -0
Added +17 -0
Added +58 -0
Added +262 -0
Added +24 -0
Added +31 -0
Added +96 -0
Added +35 -0
Added +111 -0
Added +107 -0
Added +106 -0
Added +29 -0
Added +11 -0
Added +15 -0
Added +29 -0
Added +20 -0
Added +13 -0
Added +32 -0
Added +51 -0
Added +41 -0
Added +44 -0
Added +14 -0
Added +16 -0
Added +17 -0
Added +60 -0
Added +48 -0
Added +157 -0
Added +98 -0
Added +577 -0
Added +404 -0
Added +26 -0
Added +233 -0
Added +157 -0
Added +84 -0
Added +80 -0
Added +97 -0
Added +123 -0
Added +119 -0
Added +86 -0
Added +82 -0
Added +26 -0
Added +110 -0
Added +134 -0
Added +30 -0
Added +139 -0
Added +96 -0
Added +246 -0
Added +271 -0
Added +92 -0
Added +27 -0
Added +150 -0
Added +553 -0
Added +272 -0
Added +26 -0
Added +134 -0
Added +97 -0
Added +377 -0
Added +4 -0
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
Added +339 -0
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
Added +13 -0
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
Added +532 -0
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`        |
+
Added +227 -0
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
Added +292 -0
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
Added +23 -0
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
Added +225 -0
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
Added +15 -0
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
Added +62 -0
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
Added +18 -0
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>
Added +0 -0
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
Added +23 -0
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
Added +244 -0
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
Added +5 -0
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
Added +16 -0
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
Added +10 -0
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
Added +60 -0
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
Added +12 -0
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
Added +26 -0
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
Added +36 -0
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>
Added +21 -0
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
Added +84 -0
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
Added +37 -0
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;
+    }
+}
Added +6 -0
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!";
+}
Added +7 -0
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
+{
+}
Added +24 -0
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
Added +57 -0
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
Added +19 -0
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
Added +13 -0
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
Added +112 -0
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
Added +13 -0
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>
Added +40 -0
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
Added +59 -0
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
Added +102 -0
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
Added +126 -0
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
Added +71 -0
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
Added +10 -0
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
Added +10 -0
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
Added +12 -0
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
Added +12 -0
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
Added +12 -0
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
Added +10 -0
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
Added +12 -0
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
Added +9 -0
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
Added +8 -0
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
Added +28 -0
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
Added +11 -0
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
Added +14 -0
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>
Added +29 -0
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
Added +24 -0
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
Added +10 -0
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
Added +10 -0
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
Added +40 -0
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
Added +9 -0
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
Added +17 -0
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>
Added +58 -0
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
Added +262 -0
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
Added +24 -0
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>
Added +31 -0
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
Added +96 -0
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
Added +35 -0
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
Added +111 -0
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
Added +107 -0
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
Added +106 -0
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
Added +29 -0
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
Added +11 -0
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
Added +15 -0
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
Added +29 -0
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
Added +20 -0
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
Added +13 -0
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
Added +32 -0
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
Added +51 -0
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
Added +41 -0
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
Added +44 -0
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
Added +14 -0
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
Added +16 -0
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
Added +17 -0
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>
Added +60 -0
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
Added +48 -0
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
Added +157 -0
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
Added +98 -0
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
Added +577 -0
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
Added +404 -0
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
Added +26 -0
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
Added +233 -0
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
Added +157 -0
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
Added +84 -0
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
Added +80 -0
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
Added +97 -0
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
Added +123 -0
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
Added +119 -0
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
Added +86 -0
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
Added +82 -0
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
Added +26 -0
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
Added +110 -0
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
Added +134 -0
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
Added +30 -0
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
Added +139 -0
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
Added +96 -0
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
Added +246 -0
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
Added +271 -0
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
Added +92 -0
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
Added +27 -0
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
Added +150 -0
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
Added +553 -0
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
Added +272 -0
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
Added +26 -0
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
Added +134 -0
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
Added +97 -0
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
Added +377 -0
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