mirror of
https://github.com/EthanMarti/infio-copilot.git
synced 2026-01-16 08:21:55 +00:00
Compare commits
93 Commits
2ef2a4d18e
...
1764f9170e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1764f9170e | ||
|
|
2132318a40 | ||
|
|
2291e635ae | ||
|
|
2c98acd969 | ||
|
|
91b87d8f68 | ||
|
|
d49fb587d2 | ||
|
|
669656e138 | ||
|
|
1483b3b8b9 | ||
|
|
9e8a9f4c0a | ||
|
|
d9923ff890 | ||
|
|
ffc29db771 | ||
|
|
5b0ca2fb10 | ||
|
|
cfa856fea8 | ||
|
|
3f9cff3f53 | ||
|
|
a6593fdbef | ||
|
|
272082f6ca | ||
|
|
b66d3ab21f | ||
|
|
9a848489e3 | ||
|
|
80e72394a9 | ||
|
|
e2df9a7995 | ||
|
|
519fc3769d | ||
|
|
429c4886e7 | ||
|
|
263a75ca6e | ||
|
|
dea60c3627 | ||
|
|
3ca234c1a2 | ||
|
|
0f04b3c413 | ||
|
|
36778565cd | ||
|
|
d99ea8f2f6 | ||
|
|
c0cd2ccf4d | ||
|
|
34296e6871 | ||
|
|
c1fbd4da21 | ||
|
|
21f4734917 | ||
|
|
10a1c8a23c | ||
|
|
8ac5945f9b | ||
|
|
627a19206e | ||
|
|
d3271f85e9 | ||
|
|
306817741f | ||
|
|
bff3e05d93 | ||
|
|
c89186a40d | ||
|
|
3db334c6e8 | ||
|
|
51f8620815 | ||
|
|
932b2d3d7f | ||
|
|
a9c2c7bc16 | ||
|
|
63b7eec906 | ||
|
|
ec3061dfb5 | ||
|
|
03c467753a | ||
|
|
e3f54d4c26 | ||
|
|
322e88fa9c | ||
|
|
54a14dedd6 | ||
|
|
a2fcb7c20f | ||
|
|
bbd89fbfa4 | ||
|
|
4b7efe8d29 | ||
|
|
c657a50563 | ||
|
|
558e3b3fe4 | ||
|
|
4e139ecc4f | ||
|
|
bed96a5233 | ||
|
|
8b3babc28e | ||
|
|
65c5df3d22 | ||
|
|
cd65d6b3de | ||
|
|
923d98cae9 | ||
|
|
a269258353 | ||
|
|
98bc810b86 | ||
|
|
fea5b382cf | ||
|
|
89bc10d16d | ||
|
|
b69f1e3865 | ||
|
|
553f42652d | ||
|
|
dedf69ee6f | ||
|
|
4f5b3f5d04 | ||
|
|
f3a0252ab6 | ||
|
|
772270863c | ||
|
|
0df4e4edd3 | ||
|
|
a81d5b159e | ||
|
|
7ffdb164b0 | ||
|
|
1a508078be | ||
|
|
7b48192bd9 | ||
|
|
09aed46739 | ||
|
|
c35f884764 | ||
|
|
35d1ddc979 | ||
|
|
263b4555cd | ||
|
|
57ef7e1f9f | ||
|
|
87c79b45d6 | ||
|
|
b3e16d6bcb | ||
|
|
7416ddffaa | ||
|
|
34d0f1f70c | ||
|
|
9b4bfe97b4 | ||
|
|
c3cc81624f | ||
|
|
5c383c0634 | ||
|
|
2363e964ad | ||
|
|
208e14f8e8 | ||
|
|
d57551dd23 | ||
|
|
bc4476a3f3 | ||
|
|
6beb29bf41 | ||
|
|
31394a3c2c |
@ -1,7 +0,0 @@
|
|||||||
{
|
|
||||||
"tabWidth": 2,
|
|
||||||
"useTabs": false,
|
|
||||||
"semi": false,
|
|
||||||
"singleQuote": true,
|
|
||||||
"trailingComma": "all"
|
|
||||||
}
|
|
||||||
8
.prettierrc.json
Normal file
8
.prettierrc.json
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"tabWidth": 4,
|
||||||
|
"useTabs": true,
|
||||||
|
"printWidth": 120,
|
||||||
|
"semi": false,
|
||||||
|
"bracketSameLine": true,
|
||||||
|
"ignore": ["node_modules", "dist", "build", "out", ".next", ".venv", "pnpm-lock.yaml"]
|
||||||
|
}
|
||||||
1
.trunk/actions
Symbolic link
1
.trunk/actions
Symbolic link
@ -0,0 +1 @@
|
|||||||
|
/Users/dfx/.cache/trunk/repos/01f245efe699634f1bfdc90b794bb5b4/actions
|
||||||
1
.trunk/logs
Symbolic link
1
.trunk/logs
Symbolic link
@ -0,0 +1 @@
|
|||||||
|
/Users/dfx/.cache/trunk/repos/01f245efe699634f1bfdc90b794bb5b4/logs
|
||||||
1
.trunk/notifications
Symbolic link
1
.trunk/notifications
Symbolic link
@ -0,0 +1 @@
|
|||||||
|
/Users/dfx/.cache/trunk/repos/01f245efe699634f1bfdc90b794bb5b4/notifications
|
||||||
1
.trunk/plugins/trunk
Symbolic link
1
.trunk/plugins/trunk
Symbolic link
@ -0,0 +1 @@
|
|||||||
|
/Users/dfx/.cache/trunk/plugins/https---github-com-trunk-io-plugins/v1.7.2-4ebadccd80b22638
|
||||||
1
.trunk/tools
Symbolic link
1
.trunk/tools
Symbolic link
@ -0,0 +1 @@
|
|||||||
|
/Users/dfx/.cache/trunk/repos/01f245efe699634f1bfdc90b794bb5b4/tools
|
||||||
@ -1,4 +1,72 @@
|
|||||||
releases:
|
releases:
|
||||||
|
- version: "0.8.6"
|
||||||
|
features:
|
||||||
|
- "fix mobile version build index error"
|
||||||
|
- version: "0.8.5"
|
||||||
|
features:
|
||||||
|
- "add mobile version for pro user, fix update error"
|
||||||
|
- version: "0.8.4"
|
||||||
|
features:
|
||||||
|
- "test mobile version"
|
||||||
|
- version: "0.8.3"
|
||||||
|
features:
|
||||||
|
- "fix this update pro version error"
|
||||||
|
- version: "0.8.1"
|
||||||
|
fixes:
|
||||||
|
- "fix infio provider api key error"
|
||||||
|
- version: "0.8.0"
|
||||||
|
features:
|
||||||
|
- "add infio pro"
|
||||||
|
- version: "0.7.6"
|
||||||
|
features:
|
||||||
|
- "update mcp settings file watcher"
|
||||||
|
- version: "0.7.5"
|
||||||
|
features:
|
||||||
|
- "fix insight model error"
|
||||||
|
- version: "0.7.4"
|
||||||
|
fixes:
|
||||||
|
- "fix moonshot provider cors error"
|
||||||
|
- "add bm25 search support"
|
||||||
|
- version: "0.7.3"
|
||||||
|
features:
|
||||||
|
- "add idb support"
|
||||||
|
- version: "0.7.2"
|
||||||
|
fixes:
|
||||||
|
- "fix workspace vector index error"
|
||||||
|
- "update insight and search view style"
|
||||||
|
- version: "0.7.1"
|
||||||
|
fixes:
|
||||||
|
- "fix settings migration"
|
||||||
|
- version: "0.7.0"
|
||||||
|
features:
|
||||||
|
- "add insight transformation"
|
||||||
|
- "add workspace"
|
||||||
|
- "add dataview support"
|
||||||
|
- "add local embedding model support"
|
||||||
|
- version: "0.6.18"
|
||||||
|
fixes:
|
||||||
|
- "fix mermaid block style, add copy btn"
|
||||||
|
- version: "0.6.17"
|
||||||
|
fixes:
|
||||||
|
- "fix mermaid block style"
|
||||||
|
- "fix chat view style"
|
||||||
|
- version: "0.6.16"
|
||||||
|
fixes:
|
||||||
|
- "fix chat view style"
|
||||||
|
- version: "0.6.15"
|
||||||
|
improvements:
|
||||||
|
- "update chat view style"
|
||||||
|
- version: "0.6.14"
|
||||||
|
fixes:
|
||||||
|
- "fix search view user select text error"
|
||||||
|
- "fix model select error"
|
||||||
|
- version: "0.6.13"
|
||||||
|
features:
|
||||||
|
- "fix settings migration"
|
||||||
|
- "update settings migration"
|
||||||
|
- version: "0.6.12"
|
||||||
|
features:
|
||||||
|
- "fix settings migration"
|
||||||
- version: "0.6.10"
|
- version: "0.6.10"
|
||||||
features:
|
features:
|
||||||
- "update chat history view"
|
- "update chat history view"
|
||||||
|
|||||||
178
README-dataview.md
Normal file
178
README-dataview.md
Normal file
@ -0,0 +1,178 @@
|
|||||||
|
# Dataview 集成使用指南
|
||||||
|
|
||||||
|
本插件已成功集成 Dataview 功能,让你可以在插件中执行 Dataview 查询。
|
||||||
|
|
||||||
|
## 功能特性
|
||||||
|
|
||||||
|
### 1. DataviewManager 类
|
||||||
|
- 检查 Dataview 插件是否可用
|
||||||
|
- 执行各种类型的 Dataview 查询(LIST、TABLE、TASK、CALENDAR)
|
||||||
|
- 获取页面数据和任务信息
|
||||||
|
- 搜索和过滤功能
|
||||||
|
|
||||||
|
### 2. DataviewQueryBuilder 类
|
||||||
|
- 链式查询构建器
|
||||||
|
- 支持复杂查询的构建
|
||||||
|
- 类型安全的查询构建
|
||||||
|
|
||||||
|
### 3. 命令面板集成
|
||||||
|
- 新增"执行 Dataview 查询"命令
|
||||||
|
- 可通过命令面板快速访问
|
||||||
|
|
||||||
|
## 使用方法
|
||||||
|
|
||||||
|
### 通过命令面板使用
|
||||||
|
|
||||||
|
1. 打开命令面板(Ctrl/Cmd + P)
|
||||||
|
2. 输入"执行 Dataview 查询"
|
||||||
|
3. 在弹出的对话框中输入你的查询
|
||||||
|
4. 查询结果将保存到新的笔记中
|
||||||
|
|
||||||
|
### 编程方式使用
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 在插件代码中使用
|
||||||
|
import { createDataviewManager } from './utils/dataview';
|
||||||
|
|
||||||
|
// 创建 DataviewManager 实例
|
||||||
|
const dataviewManager = createDataviewManager(this.app);
|
||||||
|
|
||||||
|
// 检查 Dataview 是否可用
|
||||||
|
if (dataviewManager.isDataviewAvailable()) {
|
||||||
|
// 执行查询
|
||||||
|
const result = await dataviewManager.executeQuery('LIST FROM #项目');
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
console.log('查询结果:', result.data);
|
||||||
|
} else {
|
||||||
|
console.error('查询失败:', result.error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用查询构建器
|
||||||
|
const queryBuilder = dataviewManager.createQueryBuilder();
|
||||||
|
const result = await queryBuilder
|
||||||
|
.type('table')
|
||||||
|
.select('file.name', 'file.mtime')
|
||||||
|
.from('#项目')
|
||||||
|
.where('file.mtime >= date(today) - dur(7 days)')
|
||||||
|
.sort('file.mtime', 'DESC')
|
||||||
|
.limit(10)
|
||||||
|
.execute();
|
||||||
|
```
|
||||||
|
|
||||||
|
## 常用查询示例
|
||||||
|
|
||||||
|
### 1. 列出所有笔记
|
||||||
|
```dataview
|
||||||
|
LIST FROM ""
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 今天创建的笔记
|
||||||
|
```dataview
|
||||||
|
LIST WHERE file.cday = date(today)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 最近7天修改的笔记
|
||||||
|
```dataview
|
||||||
|
LIST WHERE file.mtime >= date(today) - dur(7 days) SORT file.mtime DESC
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. 带有特定标签的笔记
|
||||||
|
```dataview
|
||||||
|
LIST FROM #项目
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. 未完成的任务
|
||||||
|
```dataview
|
||||||
|
TASK WHERE !completed
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. 今天到期的任务
|
||||||
|
```dataview
|
||||||
|
TASK WHERE due = date(today)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7. 文件夹中的笔记表格
|
||||||
|
```dataview
|
||||||
|
TABLE file.name, file.mtime, file.size
|
||||||
|
FROM "项目文件夹"
|
||||||
|
SORT file.mtime DESC
|
||||||
|
```
|
||||||
|
|
||||||
|
## API 参考
|
||||||
|
|
||||||
|
### DataviewManager
|
||||||
|
|
||||||
|
#### 方法
|
||||||
|
|
||||||
|
- `isDataviewAvailable(): boolean` - 检查 Dataview 插件是否可用
|
||||||
|
- `executeQuery(query: string): Promise<DataviewQueryResult>` - 执行查询
|
||||||
|
- `getPage(path: string): unknown` - 获取页面数据
|
||||||
|
- `getPages(source?: string): unknown[]` - 获取所有页面
|
||||||
|
- `searchPages(query: string): unknown[]` - 搜索页面
|
||||||
|
- `getPagesByTag(tag: string): unknown[]` - 获取带有特定标签的页面
|
||||||
|
- `getPagesByFolder(folder: string): unknown[]` - 获取文件夹中的页面
|
||||||
|
- `getTasks(source?: string): unknown[]` - 获取任务
|
||||||
|
- `getIncompleteTasks(source?: string): unknown[]` - 获取未完成的任务
|
||||||
|
- `getCompletedTasks(source?: string): unknown[]` - 获取已完成的任务
|
||||||
|
|
||||||
|
### DataviewQueryBuilder
|
||||||
|
|
||||||
|
#### 方法
|
||||||
|
|
||||||
|
- `type(type: 'table' | 'list' | 'task' | 'calendar'): this` - 设置查询类型
|
||||||
|
- `select(...fields: string[]): this` - 添加字段选择(用于 table 查询)
|
||||||
|
- `from(source: string): this` - 添加数据源
|
||||||
|
- `where(condition: string): this` - 添加 WHERE 条件
|
||||||
|
- `sort(field: string, direction: 'ASC' | 'DESC'): this` - 添加排序
|
||||||
|
- `limit(count: number): this` - 添加限制
|
||||||
|
- `groupBy(field: string): this` - 添加分组
|
||||||
|
- `build(): string` - 构建查询字符串
|
||||||
|
- `execute(): Promise<DataviewQueryResult>` - 执行查询
|
||||||
|
|
||||||
|
### DataviewQueryResult
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface DataviewQueryResult {
|
||||||
|
success: boolean;
|
||||||
|
data?: unknown;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 注意事项
|
||||||
|
|
||||||
|
1. 确保已安装并启用 Dataview 插件
|
||||||
|
2. 查询语法遵循 Dataview 的标准语法
|
||||||
|
3. 查询结果会自动保存到新的笔记中
|
||||||
|
4. 支持所有 Dataview 的查询类型:LIST、TABLE、TASK、CALENDAR
|
||||||
|
|
||||||
|
## 故障排除
|
||||||
|
|
||||||
|
### Dataview 插件未安装或未启用
|
||||||
|
- 确保在 Obsidian 中安装了 Dataview 插件
|
||||||
|
- 确保 Dataview 插件已启用
|
||||||
|
|
||||||
|
### 查询语法错误
|
||||||
|
- 检查查询语法是否符合 Dataview 标准
|
||||||
|
- 参考 Dataview 官方文档:https://blacksmithgu.github.io/obsidian-dataview/
|
||||||
|
|
||||||
|
### 查询结果为空
|
||||||
|
- 检查查询条件是否正确
|
||||||
|
- 确保有符合条件的文件存在
|
||||||
|
|
||||||
|
## 扩展功能
|
||||||
|
|
||||||
|
你可以通过以下方式扩展 Dataview 集成:
|
||||||
|
|
||||||
|
1. 添加自定义查询模板
|
||||||
|
2. 集成到聊天界面中
|
||||||
|
3. 添加查询历史记录
|
||||||
|
4. 实现查询结果的可视化展示
|
||||||
|
|
||||||
|
## 更多资源
|
||||||
|
|
||||||
|
- [Dataview 官方文档](https://blacksmithgu.github.io/obsidian-dataview/)
|
||||||
|
- [Dataview 查询语法](https://blacksmithgu.github.io/obsidian-dataview/queries/structure/)
|
||||||
|
- [Dataview API 文档](https://blacksmithgu.github.io/obsidian-dataview/api/intro/)
|
||||||
34
README.md
34
README.md
@ -4,17 +4,26 @@
|
|||||||
|
|
||||||
<a href="README.md" target="_blank"><b>English</b></a> | <a href="README_zh-CN.md" target="_blank"><b>中文</b></a>
|
<a href="README.md" target="_blank"><b>English</b></a> | <a href="README_zh-CN.md" target="_blank"><b>中文</b></a>
|
||||||
|
|
||||||
## Latest Version
|
## ✨ What's New
|
||||||
[0.5.0](https://github.com/infiolab/infio-copilot/releases/tag/0.5.0) Enhanced performance and stability improvements, added MCP support
|
[0.7.2](https://github.com/infiolab/infio-copilot/releases/tag/0.7.2)
|
||||||
|
We're excited to announce a major update packed with new features to streamline your workflow and supercharge your knowledge management within Obsidian.
|
||||||
|
---
|
||||||
|
|
||||||
## Recent Updates
|
* **🚀 Out-of-the-Box Embedding Model**
|
||||||
[0.2.4](https://github.com/infiolab/infio-copilot/releases/tag/0.2.4) Added multilingual support
|
To help you get started faster, we now include a default local embedding model (`bge-micro-v2`). No more manual setup is required to use powerful semantic features!
|
||||||
|
|
||||||
[0.2.3](https://github.com/infiolab/infio-copilot/releases/tag/0.2.3) Add custom mode config, you can create you own agent now
|
* **🗂️ Workspaces**
|
||||||
|
Organize your projects, research, and personal notes with the new **Workspaces** feature. Keep your context clean and switch between different setups seamlessly.
|
||||||
|
|
||||||
[0.1.7](https://github.com/infiolab/infio-copilot/releases/tag/0.1.7) Added image selector modal, allowing users to search, select, and upload images in obsidian vault or local file browser
|
* **💡 Insights**
|
||||||
|
Go beyond simple notes with our new **Insights** feature. Synthesize information, discover connections, and gain a deeper understanding of your knowledge base.
|
||||||
|
|
||||||
|
* **🔍 Advanced Multi-Dimensional Queries**
|
||||||
|
Converse with your notes! You can now perform complex queries based on various dimensions like time, tasks, and other metadata. Finding the exact piece of information has never been easier.
|
||||||
|
|
||||||
|
* **✍️ New "Write" Mode**
|
||||||
|
We've rebuilt our **Write** mode from the ground up to provide a more intuitive, powerful, and distraction-free writing experience.
|
||||||
|
|
||||||
[0.1.6](https://github.com/infiolab/infio-copilot/releases/tag/0.1.6) update apply view, you can edit content in apply view
|
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
@ -24,10 +33,15 @@
|
|||||||
| 📝 Autocomplete | Receive context-aware writing suggestions as you type |
|
| 📝 Autocomplete | Receive context-aware writing suggestions as you type |
|
||||||
| ✏️ Inline Editing | Edit your notes directly within the current file |
|
| ✏️ Inline Editing | Edit your notes directly within the current file |
|
||||||
| 🔍 Vault Chat | Interact with your entire Obsidian vault using AI |
|
| 🔍 Vault Chat | Interact with your entire Obsidian vault using AI |
|
||||||
| 🖼️ Image Analysis | Upload and analyze images from your vault or local system |
|
| 🔍 Vault Search | Use semantic search to explore your entire vault |
|
||||||
| ⌨️ Commands | Create and manage custom commands for quick actions |
|
| ⌨️ Commands | Create and manage custom commands for quick actions |
|
||||||
| 🎯 Custom Mode | Define personalized AI modes with specific behaviors |
|
| 🎯 Custom Mode | Define personalized AI modes with specific behaviors |
|
||||||
| 🔌 MCP | Manage Model Context Protocol integrations |
|
| 🔌 MCP | Manage Model Context Protocol integrations |
|
||||||
|
| 🗂️ Workspaces | Organize projects, research, and personal notes with seamless context switching |
|
||||||
|
| 💡 Insights | Synthesize information, discover connections, and gain deeper understanding |
|
||||||
|
| 🔍 dataview query | Perform complex queries based on time, tasks, and metadata |
|
||||||
|
| ✍️ New Write Mode | Rebuilt writing experience with intuitive, powerful, and distraction-free interface |
|
||||||
|
|
||||||
|
|
||||||
### Chat & Edit Flow
|
### Chat & Edit Flow
|
||||||
|
|
||||||
@ -83,8 +97,8 @@ Leverage the power of AI to interact with your entire Obsidian vault, gaining in
|
|||||||
* Infio Copilot: Infio add selection to chat -> cmd + shift + L
|
* Infio Copilot: Infio add selection to chat -> cmd + shift + L
|
||||||
* Infio Copilot: Infio Inline Edit -> cmd + shift + K
|
* Infio Copilot: Infio Inline Edit -> cmd + shift + K
|
||||||

|

|
||||||
7. If you need to chat with documents, you must configure an embedding model.
|
7. **NEW: Out-of-the-Box Embedding Model** - The plugin now includes a default local embedding model (`bge-micro-v2`), so you can start using semantic features immediately! For enhanced performance, you can still configure additional embedding models:
|
||||||
- Currently, only SiliconFlow, Alibaba, Google, and OpenAI platforms support embedding models.
|
- Currently, SiliconFlow, Alibaba, Google, and OpenAI platforms support embedding models.
|
||||||
|
|
||||||
## Feedback and Support
|
## Feedback and Support
|
||||||
We value your input and want to ensure you can easily share your thoughts and report any issues:
|
We value your input and want to ensure you can easily share your thoughts and report any issues:
|
||||||
|
|||||||
@ -10,17 +10,23 @@ Infio Copilot 是一款可高度个人定制化的 Obsidian AI 插件,旨在
|
|||||||
|
|
||||||
[](https://ko-fi.com/felixduan)
|
[](https://ko-fi.com/felixduan)
|
||||||
|
|
||||||
## 最新版本
|
# Pro Version
|
||||||
[0.5.0](https://github.com/infiolab/infio-copilot/releases/tag/0.5.0) 增强性能和稳定性改进, 增加了 MC P支持
|
|
||||||
|
|
||||||
## 最近更新
|
|
||||||
[0.2.4](https://github.com/infiolab/infio-copilot/releases/tag/0.2.4) 增加了多语言支持
|
|
||||||
|
|
||||||
[0.2.3](https://github.com/infiolab/infio-copilot/releases/tag/0.2.3) 增加了自定义模式配置,现在无法创建自己的 agent
|
# 🚀 新版本发布:引入工作区、洞察与本地模型!
|
||||||
|
|
||||||
[0.1.7](https://github.com/infiolab/infio-copilot/releases/tag/0.1.7) 增加了图片选择器模态框,允许用户在 Obsidian vault 或本地文件浏览器中搜索、选择和上传图片
|
我们很高兴地宣布一个重要更新,它将彻底改变您的知识管理体验。此版本引入了强大的新功能,如工作区、洞察、以及开箱即用的本地嵌入模型,让您更深入地与笔记互动。
|
||||||
|
|
||||||
[0.1.6](https://github.com/infiolab/infio-copilot/releases/tag/0.1.6) 更新了应用视图 (apply view),现在可以在应用视图中编辑内容
|
---
|
||||||
|
* **🧠 内置本地嵌入模型**:现在默认包含 `LocalProdver(bge-micro-v2)` 模型。无需任何额外配置,即可享受强大的本地语义搜索和分析功能。
|
||||||
|
|
||||||
|
* **🗂️ 工作区 (Workspaces)**:引入全新的工作区功能,帮助您更好地组织和隔离不同的项目和知识领域,让您的工作流更加清晰。
|
||||||
|
|
||||||
|
* **💡 洞察 (Insights)**:我们增加了强大的“洞察”功能。您可以从笔记中提取关键摘要、进行反思或生成内容大纲,从您的知识库中发现深层联系。
|
||||||
|
|
||||||
|
* **🔍 多维度查询与对话**:像与人交谈一样与您的笔记互动。现在您可以根据时间、任务状态等多种维度进行查询,轻松找到所需信息。
|
||||||
|
|
||||||
|
* **✍️ 全新 `write` 模式**:一个专为写作而生的新模式,提供更专注、更流畅的创作体验,帮助您将想法转化为结构清晰的文档。
|
||||||
|
|
||||||
## 功能特点
|
## 功能特点
|
||||||
|
|
||||||
@ -30,10 +36,14 @@ Infio Copilot 是一款可高度个人定制化的 Obsidian AI 插件,旨在
|
|||||||
| 📝 智能补全 | 在输入时获取上下文感知的写作建议 |
|
| 📝 智能补全 | 在输入时获取上下文感知的写作建议 |
|
||||||
| ✏️ 内联编辑 | 直接在当前文件中编辑笔记 |
|
| ✏️ 内联编辑 | 直接在当前文件中编辑笔记 |
|
||||||
| 🔍 全库对话 | 使用 AI 与整个 Obsidian vault 交互 |
|
| 🔍 全库对话 | 使用 AI 与整个 Obsidian vault 交互 |
|
||||||
| 🖼️ 图片分析 | 上传并分析来自 vault 或本地系统的图片 |
|
| 语义搜索 | |
|
||||||
| ⌨️ 快捷命令 | 创建和管理自定义快捷命令,实现快速操作 |
|
| ⌨️ 快捷命令 | 创建和管理自定义快捷命令,实现快速操作 |
|
||||||
| 🎯 自定义Mode | 定义具有特定行为的个性化 AI 模式 |
|
| 🎯 自定义Mode | 定义具有特定行为的个性化 AI 模式 |
|
||||||
| 🔌 MCP | 管理模型上下文协议集成 |
|
| 🔌 MCP | 管理模型上下文协议集成 |
|
||||||
|
| 🗂️ 工作空间 | 组织项目、研究和个人笔记,无缝切换上下文 |
|
||||||
|
| 💡 深度洞察 | 综合信息、发现连接、获得更深层次的理解 |
|
||||||
|
| 🔍 多维查询 | 基于时间、任务和元数据执行复杂查询 |
|
||||||
|
| ✍️ 新写作模式 | 重构的写作体验,提供直观、强大且无干扰的界面 |
|
||||||
|
|
||||||
### 🖋️ 内联编辑
|
### 🖋️ 内联编辑
|
||||||
|
|
||||||
@ -95,8 +105,6 @@ Infio Copilot 是一款可高度个人定制化的 Obsidian AI 插件,旨在
|
|||||||
* Infio Copilot: Infio add selection to chat -> cmd + shift + L
|
* Infio Copilot: Infio add selection to chat -> cmd + shift + L
|
||||||
* Infio Copilot: Infio Inline Edit -> cmd + shift + K
|
* Infio Copilot: Infio Inline Edit -> cmd + shift + K
|
||||||

|

|
||||||
7. 如果需要 跟文档聊天 , 需要配置 embedding 模型
|
|
||||||
- 目前之后 SiliconFlow Alibaba Google OpenAI 平台支持嵌入模型
|
|
||||||
|
|
||||||
## 反馈与支持
|
## 反馈与支持
|
||||||
我们重视您的意见,并希望确保您能轻松分享想法和报告问题:
|
我们重视您的意见,并希望确保您能轻松分享想法和报告问题:
|
||||||
|
|||||||
@ -2,7 +2,8 @@ import path from 'path'
|
|||||||
import esbuild from 'esbuild'
|
import esbuild from 'esbuild'
|
||||||
import process from 'process'
|
import process from 'process'
|
||||||
import builtins from 'builtin-modules'
|
import builtins from 'builtin-modules'
|
||||||
import inlineWorkerPlugin from "esbuild-plugin-inline-worker";
|
import inlineWorkerPlugin from "esbuild-plugin-inline-worker"
|
||||||
|
import { visualizer } from "esbuild-visualizer";
|
||||||
const nodeBuiltins = [...builtins, ...builtins.map((mod) => `node:${mod}`)]
|
const nodeBuiltins = [...builtins, ...builtins.map((mod) => `node:${mod}`)]
|
||||||
|
|
||||||
const banner = `/*
|
const banner = `/*
|
||||||
@ -19,11 +20,13 @@ const context = await esbuild.context({
|
|||||||
},
|
},
|
||||||
entryPoints: ['src/main.ts'],
|
entryPoints: ['src/main.ts'],
|
||||||
bundle: true,
|
bundle: true,
|
||||||
plugins: [inlineWorkerPlugin({
|
plugins: [
|
||||||
define: {
|
inlineWorkerPlugin({
|
||||||
'process': '{}', // 继承主配置
|
define: {
|
||||||
},
|
'process': '{}', // 继承主配置
|
||||||
})],
|
},
|
||||||
|
})
|
||||||
|
],
|
||||||
external: [
|
external: [
|
||||||
'fs',
|
'fs',
|
||||||
'obsidian',
|
'obsidian',
|
||||||
@ -53,7 +56,7 @@ const context = await esbuild.context({
|
|||||||
'process.env.NODE_ENV': JSON.stringify(prod ? 'production' : 'development'),
|
'process.env.NODE_ENV': JSON.stringify(prod ? 'production' : 'development'),
|
||||||
},
|
},
|
||||||
inject: [path.resolve('import-meta-url-shim.js')],
|
inject: [path.resolve('import-meta-url-shim.js')],
|
||||||
target: 'es2020',
|
target: 'es2022',
|
||||||
logLevel: 'info', // 'debug' for more detailed output
|
logLevel: 'info', // 'debug' for more detailed output
|
||||||
logOverride: {
|
logOverride: {
|
||||||
'import-is-undefined': 'silent', // 忽略 import-is-undefined 警告
|
'import-is-undefined': 'silent', // 忽略 import-is-undefined 警告
|
||||||
@ -62,10 +65,38 @@ const context = await esbuild.context({
|
|||||||
treeShaking: true,
|
treeShaking: true,
|
||||||
outfile: 'main.js',
|
outfile: 'main.js',
|
||||||
minify: prod,
|
minify: prod,
|
||||||
|
// 生产环境去掉调试语句与版权注释以进一步减小体积
|
||||||
|
drop: prod ? ['console', 'debugger'] : [],
|
||||||
|
legalComments: prod ? 'none' : 'inline',
|
||||||
|
metafile: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
if (prod) {
|
if (prod) {
|
||||||
await context.rebuild()
|
const result = await context.rebuild()
|
||||||
|
|
||||||
|
// 如果启用分析,生成可视化报告
|
||||||
|
if (process.env.ANALYZE && result.metafile) {
|
||||||
|
const fs = await import('fs')
|
||||||
|
|
||||||
|
// 将 metafile 写入临时文件,然后使用命令行工具
|
||||||
|
fs.writeFileSync('metafile.json', JSON.stringify(result.metafile))
|
||||||
|
console.log('📊 Generating bundle analysis report...')
|
||||||
|
|
||||||
|
// 使用命令行工具生成报告
|
||||||
|
const { exec } = await import('child_process')
|
||||||
|
exec('npx esbuild-visualizer --metadata metafile.json --filename bundle-analysis.html --template treemap --open', (error, stdout, stderr) => {
|
||||||
|
if (error) {
|
||||||
|
console.error('Error generating report:', error)
|
||||||
|
} else {
|
||||||
|
console.log('📊 Bundle analysis report generated: bundle-analysis.html')
|
||||||
|
// 清理临时文件
|
||||||
|
try {
|
||||||
|
fs.unlinkSync('metafile.json')
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
process.exit(0)
|
process.exit(0)
|
||||||
} else {
|
} else {
|
||||||
await context.watch()
|
await context.watch()
|
||||||
|
|||||||
@ -1,10 +1,11 @@
|
|||||||
{
|
{
|
||||||
"id": "infio-copilot",
|
"id": "infio-copilot",
|
||||||
"name": "Infio Copilot",
|
"name": "Infio Copilot",
|
||||||
"version": "0.6.10",
|
"version": "0.8.6",
|
||||||
"minAppVersion": "0.15.0",
|
"minAppVersion": "0.15.0",
|
||||||
"description": "A Cursor-inspired AI assistant for notes that offers smart autocomplete and interactive chat with your selected notes",
|
"description": "A Cursor-inspired AI assistant for notes that offers smart autocomplete and interactive chat with your selected notes",
|
||||||
"author": "Felix.D",
|
"author": "Felix.D",
|
||||||
"authorUrl": "https://github.com/infiolab",
|
"authorUrl": "https://github.com/infiolab",
|
||||||
"isDesktopOnly": true
|
"isDesktopOnly": false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
38
package.json
38
package.json
@ -1,12 +1,14 @@
|
|||||||
{
|
{
|
||||||
"name": "obsidian-infio-copilot",
|
"name": "obsidian-infio-copilot",
|
||||||
"version": "0.6.10",
|
"version": "0.8.6",
|
||||||
"description": "A Cursor-inspired AI assistant that offers smart autocomplete and interactive chat with your selected notes",
|
"description": "A Cursor-inspired AI assistant that offers smart autocomplete and interactive chat with your selected notes",
|
||||||
"main": "main.js",
|
"main": "main.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"bundle-pglite": "node scripts/bundle-pglite-resources.mjs",
|
"bundle-pglite": "node scripts/bundle-pglite-resources.mjs",
|
||||||
"dev": "npm run bundle-pglite && node esbuild.config.mjs",
|
"dev": "npm run bundle-pglite && node esbuild.config.mjs",
|
||||||
"build": "npm run bundle-pglite && tsc -noEmit -skipLibCheck && node esbuild.config.mjs production",
|
"build": "npm run bundle-pglite && tsc -noEmit -skipLibCheck && node esbuild.config.mjs production",
|
||||||
|
"analyze": "npm run bundle-pglite && ANALYZE=true node esbuild.config.mjs production",
|
||||||
|
"analyze:dev": "npm run bundle-pglite && ANALYZE=true node esbuild.config.mjs",
|
||||||
"version": "node version-bump.mjs",
|
"version": "node version-bump.mjs",
|
||||||
"type:check": "tsc --noEmit",
|
"type:check": "tsc --noEmit",
|
||||||
"test": "jest",
|
"test": "jest",
|
||||||
@ -33,6 +35,7 @@
|
|||||||
"builtin-modules": "3.3.0",
|
"builtin-modules": "3.3.0",
|
||||||
"drizzle-kit": "^0.26.2",
|
"drizzle-kit": "^0.26.2",
|
||||||
"esbuild": "0.17.3",
|
"esbuild": "0.17.3",
|
||||||
|
"esbuild-visualizer": "^0.7.0",
|
||||||
"eslint": "^8.57.1",
|
"eslint": "^8.57.1",
|
||||||
"eslint-config-prettier": "^9.1.0",
|
"eslint-config-prettier": "^9.1.0",
|
||||||
"eslint-plugin-css-modules": "^2.12.0",
|
"eslint-plugin-css-modules": "^2.12.0",
|
||||||
@ -41,9 +44,12 @@
|
|||||||
"eslint-plugin-no-inline-styles": "^1.0.5",
|
"eslint-plugin-no-inline-styles": "^1.0.5",
|
||||||
"eslint-plugin-react": "^7.37.3",
|
"eslint-plugin-react": "^7.37.3",
|
||||||
"eslint-plugin-react-hooks": "^5.0.0",
|
"eslint-plugin-react-hooks": "^5.0.0",
|
||||||
|
"install": "^0.13.0",
|
||||||
"jest": "^29.7.0",
|
"jest": "^29.7.0",
|
||||||
"jest-environment-jsdom": "^29.7.0",
|
"jest-environment-jsdom": "^29.7.0",
|
||||||
|
"npm": "^11.4.2",
|
||||||
"obsidian": "^1.8.7",
|
"obsidian": "^1.8.7",
|
||||||
|
"obsidian-dataview": "^0.5.68",
|
||||||
"prettier": "^3.4.2",
|
"prettier": "^3.4.2",
|
||||||
"stylelint": "^16.12.0",
|
"stylelint": "^16.12.0",
|
||||||
"ts-jest": "^29.2.5",
|
"ts-jest": "^29.2.5",
|
||||||
@ -53,12 +59,19 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@anthropic-ai/sdk": "^0.27.3",
|
"@anthropic-ai/sdk": "^0.27.3",
|
||||||
"@codemirror/basic-setup": "^0.20.0",
|
"@codemirror/basic-setup": "^0.20.0",
|
||||||
|
"@codemirror/commands": "^6.7.1",
|
||||||
|
"@codemirror/lang-json": "^6.0.1",
|
||||||
"@codemirror/lang-markdown": "^6.3.2",
|
"@codemirror/lang-markdown": "^6.3.2",
|
||||||
|
"@codemirror/language": "^6.11.2",
|
||||||
"@codemirror/merge": "^6.10.0",
|
"@codemirror/merge": "^6.10.0",
|
||||||
|
"@codemirror/state": "^6.5.2",
|
||||||
|
"@codemirror/theme-one-dark": "^6.1.2",
|
||||||
|
"@codemirror/view": "^6.35.0",
|
||||||
"@electric-sql/pglite": "0.2.14",
|
"@electric-sql/pglite": "0.2.14",
|
||||||
"@google/genai": "^1.2.0",
|
"@google/genai": "^1.2.0",
|
||||||
"@google/generative-ai": "^0.21.0",
|
"@google/generative-ai": "^0.21.0",
|
||||||
"@langchain/core": "^0.3.26",
|
"@huggingface/transformers": "^3.6.1",
|
||||||
|
"@langchain/core": "^0.3.40",
|
||||||
"@lexical/clipboard": "^0.17.1",
|
"@lexical/clipboard": "^0.17.1",
|
||||||
"@lexical/react": "^0.17.1",
|
"@lexical/react": "^0.17.1",
|
||||||
"@lexical/rich-text": "^0.27.2",
|
"@lexical/rich-text": "^0.27.2",
|
||||||
@ -70,9 +83,12 @@
|
|||||||
"@radix-ui/react-select": "^2.1.6",
|
"@radix-ui/react-select": "^2.1.6",
|
||||||
"@radix-ui/react-tooltip": "^1.1.3",
|
"@radix-ui/react-tooltip": "^1.1.3",
|
||||||
"@tanstack/react-query": "^5.56.2",
|
"@tanstack/react-query": "^5.56.2",
|
||||||
|
"@types/mermaid": "^9.2.0",
|
||||||
|
"@xenova/transformers": "^2.17.2",
|
||||||
"axios": "^1.8.3",
|
"axios": "^1.8.3",
|
||||||
"chokidar": "^4.0.3",
|
"chokidar": "^4.0.3",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"codemirror": "^6.0.1",
|
||||||
"delay": "^6.0.0",
|
"delay": "^6.0.0",
|
||||||
"diff": "^7.0.0",
|
"diff": "^7.0.0",
|
||||||
"diff-match-patch": "^1.0.5",
|
"diff-match-patch": "^1.0.5",
|
||||||
@ -88,16 +104,19 @@
|
|||||||
"js-tiktoken": "^1.0.15",
|
"js-tiktoken": "^1.0.15",
|
||||||
"js-yaml": "^4.1.0",
|
"js-yaml": "^4.1.0",
|
||||||
"json5": "^2.2.3",
|
"json5": "^2.2.3",
|
||||||
"langchain": "^0.3.2",
|
"jszip": "^3.10.1",
|
||||||
|
"langchain": "^0.3.15",
|
||||||
"lexical": "^0.17.1",
|
"lexical": "^0.17.1",
|
||||||
"lodash": "^4.17.21",
|
"lodash": "^4.17.21",
|
||||||
"lodash.debounce": "^4.0.8",
|
"lodash.debounce": "^4.0.8",
|
||||||
"lodash.isequal": "^4.5.0",
|
"lodash.isequal": "^4.5.0",
|
||||||
"lru-cache": "^10.1.0",
|
"lru-cache": "^10.1.0",
|
||||||
"lucide-react": "^0.447.0",
|
"lucide-react": "^0.447.0",
|
||||||
|
"mermaid": "^11.6.0",
|
||||||
"micromatch": "^4.0.5",
|
"micromatch": "^4.0.5",
|
||||||
"minimatch": "^10.0.1",
|
"minimatch": "^10.0.1",
|
||||||
"neverthrow": "^6.1.0",
|
"neverthrow": "^6.1.0",
|
||||||
|
"node-machine-id": "^1.1.12",
|
||||||
"openai": "^4.73.0",
|
"openai": "^4.73.0",
|
||||||
"p-limit": "^6.1.0",
|
"p-limit": "^6.1.0",
|
||||||
"parse5": "^7.1.2",
|
"parse5": "^7.1.2",
|
||||||
@ -112,10 +131,21 @@
|
|||||||
"reconnecting-eventsource": "^1.6.4",
|
"reconnecting-eventsource": "^1.6.4",
|
||||||
"rehype-raw": "^7.0.0",
|
"rehype-raw": "^7.0.0",
|
||||||
"remark-gfm": "^4.0.0",
|
"remark-gfm": "^4.0.0",
|
||||||
|
"remove-markdown": "^0.6.2",
|
||||||
|
"sanitize-basename": "^2.0.2",
|
||||||
"shell-env": "^4.0.1",
|
"shell-env": "^4.0.1",
|
||||||
"simple-git": "^3.27.0",
|
"simple-git": "^3.27.0",
|
||||||
|
"smart-embed-model": "^1.0.7",
|
||||||
"string-similarity": "^4.0.4",
|
"string-similarity": "^4.0.4",
|
||||||
|
"styled-components": "^6.1.19",
|
||||||
|
"unsanitize-basename": "^2.0.1",
|
||||||
"uuid": "^10.0.0",
|
"uuid": "^10.0.0",
|
||||||
"zod": "^3.22.4"
|
"zod": "3.24.2"
|
||||||
|
},
|
||||||
|
"pnpm": {
|
||||||
|
"overrides": {
|
||||||
|
"zod": "3.24.2",
|
||||||
|
"@langchain/core": "0.3.40"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
6690
pnpm-lock.yaml
generated
6690
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
182
src/BaseFileView.tsx
Normal file
182
src/BaseFileView.tsx
Normal file
@ -0,0 +1,182 @@
|
|||||||
|
import { EditorState, Extension } from "@codemirror/state";
|
||||||
|
import { EditorView, ViewUpdate } from "@codemirror/view";
|
||||||
|
import { TFile, TextFileView, WorkspaceLeaf } from "obsidian";
|
||||||
|
|
||||||
|
import InfioPlugin from './main';
|
||||||
|
|
||||||
|
export default abstract class BaseView extends TextFileView {
|
||||||
|
public plugin: InfioPlugin;
|
||||||
|
protected cmEditor: EditorView;
|
||||||
|
protected editorEl: HTMLElement;
|
||||||
|
protected state: { filePath?: string } | null = null;
|
||||||
|
protected isEditorLoaded: boolean = false;
|
||||||
|
protected currentFilePath: string | null = null;
|
||||||
|
protected isClosing: boolean = false;
|
||||||
|
|
||||||
|
protected constructor(leaf: WorkspaceLeaf, plugin: InfioPlugin) {
|
||||||
|
super(leaf);
|
||||||
|
this.plugin = plugin;
|
||||||
|
}
|
||||||
|
|
||||||
|
onload(): void {
|
||||||
|
super.onload();
|
||||||
|
this.editorEl = this.contentEl.createDiv("datafile-source-view mod-cm6");
|
||||||
|
|
||||||
|
this.cmEditor = new EditorView({
|
||||||
|
state: this.createDefaultEditorState(),
|
||||||
|
parent: this.editorEl,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.app.workspace.trigger("codemirror", this.cmEditor);
|
||||||
|
this.isEditorLoaded = true;
|
||||||
|
|
||||||
|
// Load file content if state contains filePath and editor is now loaded
|
||||||
|
if (this.state?.filePath) {
|
||||||
|
this.loadFileFromPath(this.state.filePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async setState(state: { filePath?: string }): Promise<void> {
|
||||||
|
this.state = state;
|
||||||
|
// If filePath is provided and editor is loaded, load the file immediately
|
||||||
|
if (state.filePath && this.isEditorLoaded) {
|
||||||
|
await this.loadFileFromPath(state.filePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getState(): { filePath?: string } {
|
||||||
|
return { filePath: this.currentFilePath };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async loadFileFromPath(filePath: string): Promise<void> {
|
||||||
|
// Store the current file path for saving
|
||||||
|
this.currentFilePath = filePath;
|
||||||
|
|
||||||
|
// Try to get the file from vault first (for regular files)
|
||||||
|
const file = this.app.vault.getAbstractFileByPath(filePath);
|
||||||
|
|
||||||
|
if (file && file instanceof TFile) {
|
||||||
|
// Regular file in vault
|
||||||
|
this.file = file;
|
||||||
|
await this.onLoadFile(file);
|
||||||
|
} else {
|
||||||
|
// File not in vault (hidden directory), read directly from filesystem
|
||||||
|
console.log('File not in vault, reading directly from filesystem');
|
||||||
|
await this.loadFileFromFilesystem(filePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async loadFileFromFilesystem(filePath: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
// Use vault adapter to read file directly from filesystem
|
||||||
|
const content = await this.app.vault.adapter.read(filePath);
|
||||||
|
this.setViewData(content, true);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load file from filesystem:', error);
|
||||||
|
// If file doesn't exist, create it with empty content
|
||||||
|
this.setViewData('{}', true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async onLoadFile(file: TFile): Promise<void> {
|
||||||
|
try {
|
||||||
|
const content = await this.app.vault.cachedRead(file);
|
||||||
|
this.setViewData(content, true);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load file content:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getViewData(): string {
|
||||||
|
return this.cmEditor.state.doc.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
setViewData(data: string, clear: boolean): void {
|
||||||
|
if (clear) {
|
||||||
|
this.cmEditor.dispatch({ changes: { from: 0, to: this.cmEditor.state.doc.length, insert: data } });
|
||||||
|
} else {
|
||||||
|
this.cmEditor.dispatch({ changes: { from: 0, to: this.cmEditor.state.doc.length, insert: data } });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
clear(): void {
|
||||||
|
this.setViewData('', true);
|
||||||
|
}
|
||||||
|
|
||||||
|
async save(clear?: boolean): Promise<void> {
|
||||||
|
// Prevent saving if the view is closing
|
||||||
|
if (this.isClosing) {
|
||||||
|
console.log("save() called during close, skipping to prevent data loss");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = this.getViewData();
|
||||||
|
|
||||||
|
// Additional safety check: don't save if content is empty and we had content before
|
||||||
|
if (!content.trim() && this.currentFilePath) {
|
||||||
|
console.log("Refusing to save empty content, potential data loss prevented");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.file) {
|
||||||
|
// Regular file in vault
|
||||||
|
await this.app.vault.modify(this.file, content);
|
||||||
|
} else if (this.currentFilePath) {
|
||||||
|
// File in hidden directory, save directly to filesystem
|
||||||
|
await this.app.vault.adapter.write(this.currentFilePath, content);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (clear) {
|
||||||
|
this.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// gets the title of the document
|
||||||
|
getDisplayText(): string {
|
||||||
|
if (this.file) {
|
||||||
|
return this.file.basename;
|
||||||
|
}
|
||||||
|
if (this.currentFilePath) {
|
||||||
|
return this.currentFilePath.split('/').pop() || "JSON File";
|
||||||
|
}
|
||||||
|
if (this.state?.filePath) {
|
||||||
|
return this.state.filePath.split('/').pop() || "JSON File";
|
||||||
|
}
|
||||||
|
return "NOFILE";
|
||||||
|
}
|
||||||
|
|
||||||
|
onClose(): Promise<void> {
|
||||||
|
this.isClosing = true;
|
||||||
|
return super.onClose();
|
||||||
|
}
|
||||||
|
|
||||||
|
async reload(): Promise<void> {
|
||||||
|
await this.save(false);
|
||||||
|
|
||||||
|
const data = this.getViewData();
|
||||||
|
this.cmEditor.setState(this.createDefaultEditorState());
|
||||||
|
this.setViewData(data, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected onEditorUpdate(update: ViewUpdate): void {
|
||||||
|
if (update.docChanged && !this.isClosing) {
|
||||||
|
this.requestSave();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
abstract getViewType(): string;
|
||||||
|
|
||||||
|
protected abstract getEditorExtensions(): Extension[];
|
||||||
|
|
||||||
|
private createDefaultEditorState(): EditorState {
|
||||||
|
return EditorState.create({
|
||||||
|
extensions: [...this.getCommonEditorExtensions(), ...this.getEditorExtensions()]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private getCommonEditorExtensions(): Extension[] {
|
||||||
|
const extensions: Extension[] = [];
|
||||||
|
extensions.push(EditorView.lineWrapping);
|
||||||
|
return extensions;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,3 +1,4 @@
|
|||||||
|
// @ts-nocheck
|
||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||||
import { ItemView, WorkspaceLeaf } from 'obsidian'
|
import { ItemView, WorkspaceLeaf } from 'obsidian'
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
@ -8,12 +9,14 @@ import { CHAT_VIEW_TYPE } from './constants'
|
|||||||
import { AppProvider } from './contexts/AppContext'
|
import { AppProvider } from './contexts/AppContext'
|
||||||
import { DarkModeProvider } from './contexts/DarkModeContext'
|
import { DarkModeProvider } from './contexts/DarkModeContext'
|
||||||
import { DatabaseProvider } from './contexts/DatabaseContext'
|
import { DatabaseProvider } from './contexts/DatabaseContext'
|
||||||
|
import { DataviewProvider } from './contexts/DataviewContext'
|
||||||
import { DialogProvider } from './contexts/DialogContext'
|
import { DialogProvider } from './contexts/DialogContext'
|
||||||
import { DiffStrategyProvider } from './contexts/DiffStrategyContext'
|
import { DiffStrategyProvider } from './contexts/DiffStrategyContext'
|
||||||
import { LLMProvider } from './contexts/LLMContext'
|
import { LLMProvider } from './contexts/LLMContext'
|
||||||
import { McpHubProvider } from './contexts/McpHubContext'
|
import { McpHubProvider } from './contexts/McpHubContext'
|
||||||
import { RAGProvider } from './contexts/RAGContext'
|
import { RAGProvider } from './contexts/RAGContext'
|
||||||
import { SettingsProvider } from './contexts/SettingsContext'
|
import { SettingsProvider } from './contexts/SettingsContext'
|
||||||
|
import { TransProvider } from './contexts/TransContext'
|
||||||
import InfioPlugin from './main'
|
import InfioPlugin from './main'
|
||||||
import { MentionableBlockData } from './types/mentionable'
|
import { MentionableBlockData } from './types/mentionable'
|
||||||
import { InfioSettings } from './types/settings'
|
import { InfioSettings } from './types/settings'
|
||||||
@ -29,7 +32,9 @@ export class ChatView extends ItemView {
|
|||||||
private plugin: InfioPlugin,
|
private plugin: InfioPlugin,
|
||||||
) {
|
) {
|
||||||
super(leaf)
|
super(leaf)
|
||||||
|
// @ts-ignore
|
||||||
this.settings = plugin.settings
|
this.settings = plugin.settings
|
||||||
|
// @ts-ignore
|
||||||
this.initialChatProps = plugin.initChatProps
|
this.initialChatProps = plugin.initChatProps
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -57,8 +62,15 @@ export class ChatView extends ItemView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async render() {
|
async render() {
|
||||||
|
// 确保容器元素存在
|
||||||
|
const containerElement = this.containerEl.children[1]
|
||||||
|
if (!containerElement || !(containerElement instanceof HTMLElement)) {
|
||||||
|
console.error('ChatView: Container element not found or invalid')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (!this.root) {
|
if (!this.root) {
|
||||||
this.root = createRoot(this.containerEl.children[1])
|
this.root = createRoot(containerElement)
|
||||||
}
|
}
|
||||||
|
|
||||||
const queryClient = new QueryClient({
|
const queryClient = new QueryClient({
|
||||||
@ -76,8 +88,10 @@ export class ChatView extends ItemView {
|
|||||||
<AppProvider app={this.app}>
|
<AppProvider app={this.app}>
|
||||||
<SettingsProvider
|
<SettingsProvider
|
||||||
settings={this.settings}
|
settings={this.settings}
|
||||||
|
// @ts-ignore
|
||||||
setSettings={(newSettings) => this.plugin.setSettings(newSettings)}
|
setSettings={(newSettings) => this.plugin.setSettings(newSettings)}
|
||||||
addSettingsChangeListener={(listener) =>
|
addSettingsChangeListener={(listener) =>
|
||||||
|
// @ts-ignore
|
||||||
this.plugin.addSettingsListener(listener)
|
this.plugin.addSettingsListener(listener)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@ -88,17 +102,21 @@ export class ChatView extends ItemView {
|
|||||||
>
|
>
|
||||||
<DiffStrategyProvider diffStrategy={this.plugin.diffStrategy}>
|
<DiffStrategyProvider diffStrategy={this.plugin.diffStrategy}>
|
||||||
<RAGProvider getRAGEngine={() => this.plugin.getRAGEngine()}>
|
<RAGProvider getRAGEngine={() => this.plugin.getRAGEngine()}>
|
||||||
<McpHubProvider getMcpHub={() => this.plugin.getMcpHub()}>
|
<TransProvider getTransEngine={() => this.plugin.getTransEngine()}>
|
||||||
<QueryClientProvider client={queryClient}>
|
<DataviewProvider dataviewManager={this.plugin.dataviewManager}>
|
||||||
<React.StrictMode>
|
<McpHubProvider getMcpHub={() => this.plugin.getMcpHub()}>
|
||||||
<DialogProvider
|
<QueryClientProvider client={queryClient}>
|
||||||
container={this.containerEl.children[1] as HTMLElement}
|
<React.StrictMode>
|
||||||
>
|
<DialogProvider
|
||||||
<Chat ref={this.chatRef} {...this.initialChatProps} />
|
container={containerElement}
|
||||||
</DialogProvider>
|
>
|
||||||
</React.StrictMode>
|
<Chat ref={this.chatRef} {...this.initialChatProps} />
|
||||||
</QueryClientProvider>
|
</DialogProvider>
|
||||||
</McpHubProvider>
|
</React.StrictMode>
|
||||||
|
</QueryClientProvider>
|
||||||
|
</McpHubProvider>
|
||||||
|
</DataviewProvider>
|
||||||
|
</TransProvider>
|
||||||
</RAGProvider>
|
</RAGProvider>
|
||||||
</DiffStrategyProvider>
|
</DiffStrategyProvider>
|
||||||
</DatabaseProvider>
|
</DatabaseProvider>
|
||||||
|
|||||||
32
src/JsonFileView.tsx
Normal file
32
src/JsonFileView.tsx
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
|
||||||
|
import { json } from "@codemirror/lang-json";
|
||||||
|
import { Extension } from "@codemirror/state";
|
||||||
|
import { EditorView } from "@codemirror/view";
|
||||||
|
import { basicSetup } from "codemirror";
|
||||||
|
import { WorkspaceLeaf } from "obsidian";
|
||||||
|
|
||||||
|
import BaseView from "./BaseFileView";
|
||||||
|
import { JSON_VIEW_TYPE } from './constants';
|
||||||
|
import InfioPlugin from './main';
|
||||||
|
import { getIndentByTabExtension } from "./utils/indentation-provider";
|
||||||
|
|
||||||
|
export default class JsonView extends BaseView {
|
||||||
|
constructor(leaf: WorkspaceLeaf, plugin: InfioPlugin) {
|
||||||
|
super(leaf, plugin);
|
||||||
|
}
|
||||||
|
|
||||||
|
getViewType(): string {
|
||||||
|
return JSON_VIEW_TYPE;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected getEditorExtensions(): Extension[] {
|
||||||
|
const extensions = [
|
||||||
|
basicSetup,
|
||||||
|
getIndentByTabExtension(),
|
||||||
|
json(),
|
||||||
|
EditorView.updateListener.of(this.onEditorUpdate.bind(this))
|
||||||
|
];
|
||||||
|
|
||||||
|
return extensions;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,7 +1,8 @@
|
|||||||
import { CheckSquare, Clock, Edit3, MessageSquare, Pencil, Search, Square, Trash2, CopyPlus } from 'lucide-react'
|
import { CheckSquare, Clock, CopyPlus, Globe, MessageSquare, Pencil, Search, Sparkles, Square, Trash2 } from 'lucide-react'
|
||||||
import { Notice } from 'obsidian'
|
import { Notice } from 'obsidian'
|
||||||
import React, { useMemo, useRef, useState } from 'react'
|
import React, { useMemo, useRef, useState } from 'react'
|
||||||
|
|
||||||
|
import { useSettings } from '../../contexts/SettingsContext'
|
||||||
import { useChatHistory } from '../../hooks/use-chat-history'
|
import { useChatHistory } from '../../hooks/use-chat-history'
|
||||||
import { t } from '../../lang/helpers'
|
import { t } from '../../lang/helpers'
|
||||||
import { ChatConversationMeta } from '../../types/chat'
|
import { ChatConversationMeta } from '../../types/chat'
|
||||||
@ -23,11 +24,21 @@ const ChatHistoryView = ({
|
|||||||
deleteConversation,
|
deleteConversation,
|
||||||
updateConversationTitle,
|
updateConversationTitle,
|
||||||
chatList,
|
chatList,
|
||||||
|
cleanupOutdatedChats,
|
||||||
} = useChatHistory()
|
} = useChatHistory()
|
||||||
|
|
||||||
// search term
|
// search term
|
||||||
const [searchTerm, setSearchTerm] = useState('')
|
const [searchTerm, setSearchTerm] = useState('')
|
||||||
|
|
||||||
|
const { settings } = useSettings()
|
||||||
|
|
||||||
|
const currentWorkspace = React.useMemo(() => {
|
||||||
|
return settings.workspace || 'vault'
|
||||||
|
}, [settings.workspace])
|
||||||
|
|
||||||
|
// workspace filter state
|
||||||
|
const [filterByWorkspace, setFilterByWorkspace] = useState(false)
|
||||||
|
|
||||||
// editing conversation id
|
// editing conversation id
|
||||||
const [editingConversationId, setEditingConversationId] = useState<string | null>(null)
|
const [editingConversationId, setEditingConversationId] = useState<string | null>(null)
|
||||||
|
|
||||||
@ -37,21 +48,56 @@ const ChatHistoryView = ({
|
|||||||
|
|
||||||
const titleInputRefs = useRef<Map<string, HTMLInputElement>>(new Map())
|
const titleInputRefs = useRef<Map<string, HTMLInputElement>>(new Map())
|
||||||
|
|
||||||
|
const handleCleanup = async () => {
|
||||||
|
const confirmed = confirm(String(t('chat.history.cleanupConfirm')))
|
||||||
|
if (!confirmed) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const count = await cleanupOutdatedChats()
|
||||||
|
if (count > 0) {
|
||||||
|
new Notice(String(t('chat.history.cleanupSuccess', { count })))
|
||||||
|
} else {
|
||||||
|
new Notice(String(t('chat.history.cleanupNone')))
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
new Notice(String(t('chat.history.cleanupFailed')))
|
||||||
|
console.error('Failed to cleanup outdated chats', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// handle search
|
// handle search
|
||||||
const handleSearch = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleSearch = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
setSearchTerm(e.target.value)
|
setSearchTerm(e.target.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// toggle workspace filter
|
||||||
|
const toggleWorkspaceFilter = () => {
|
||||||
|
setFilterByWorkspace(!filterByWorkspace)
|
||||||
|
}
|
||||||
|
|
||||||
// filter conversations list
|
// filter conversations list
|
||||||
const filteredConversations = useMemo(() => {
|
const filteredConversations = useMemo(() => {
|
||||||
if (!searchTerm.trim()) {
|
let filtered = chatList
|
||||||
return chatList
|
|
||||||
|
// Apply search filter
|
||||||
|
if (searchTerm.trim()) {
|
||||||
|
filtered = filtered.filter(
|
||||||
|
conversation =>
|
||||||
|
conversation.title.toLowerCase().includes(searchTerm.toLowerCase())
|
||||||
|
)
|
||||||
}
|
}
|
||||||
return chatList.filter(
|
|
||||||
conversation =>
|
// Apply workspace filter
|
||||||
conversation.title.toLowerCase().includes(searchTerm.toLowerCase())
|
if (filterByWorkspace) {
|
||||||
)
|
filtered = filtered.filter(
|
||||||
}, [chatList, searchTerm])
|
conversation => conversation.workspace === currentWorkspace
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return filtered
|
||||||
|
}, [chatList, searchTerm, filterByWorkspace, currentWorkspace])
|
||||||
|
|
||||||
// toggle selection mode
|
// toggle selection mode
|
||||||
const toggleSelectionMode = () => {
|
const toggleSelectionMode = () => {
|
||||||
@ -99,12 +145,12 @@ const ChatHistoryView = ({
|
|||||||
// batch delete selected conversations
|
// batch delete selected conversations
|
||||||
const handleBatchDelete = async () => {
|
const handleBatchDelete = async () => {
|
||||||
if (selectedConversations.size === 0) {
|
if (selectedConversations.size === 0) {
|
||||||
new Notice('请先选择要删除的对话')
|
new Notice(String(t('chat.history.selectFirst')))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// show confirmation
|
// show confirmation
|
||||||
const confirmed = confirm(`确定要删除选中的 ${selectedConversations.size} 个对话吗?此操作不可撤销。`)
|
const confirmed = confirm(String(t('chat.history.batchDeleteConfirm', { count: selectedConversations.size })))
|
||||||
if (!confirmed) {
|
if (!confirmed) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -126,10 +172,10 @@ const ChatHistoryView = ({
|
|||||||
|
|
||||||
// show results
|
// show results
|
||||||
if (deletedIds.length > 0) {
|
if (deletedIds.length > 0) {
|
||||||
new Notice(`成功删除 ${deletedIds.length} 个对话`)
|
new Notice(String(t('chat.history.batchDeleteSuccess', { count: deletedIds.length })))
|
||||||
}
|
}
|
||||||
if (errors.length > 0) {
|
if (errors.length > 0) {
|
||||||
new Notice(`${errors.length} 个对话删除失败`)
|
new Notice(String(t('chat.history.batchDeleteFailed', { count: errors.length })))
|
||||||
}
|
}
|
||||||
|
|
||||||
// clear selections
|
// clear selections
|
||||||
@ -192,13 +238,21 @@ const ChatHistoryView = ({
|
|||||||
<h2>{t('chat.history.title')}</h2>
|
<h2>{t('chat.history.title')}</h2>
|
||||||
</div>
|
</div>
|
||||||
<div className="infio-chat-history-header-actions">
|
<div className="infio-chat-history-header-actions">
|
||||||
|
<button
|
||||||
|
onClick={handleCleanup}
|
||||||
|
className="infio-chat-history-cleanup-btn"
|
||||||
|
title={String(t('chat.history.cleanupTitle'))}
|
||||||
|
>
|
||||||
|
<Sparkles size={16} />
|
||||||
|
{t('chat.history.cleanup')}
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={toggleSelectionMode}
|
onClick={toggleSelectionMode}
|
||||||
className={`infio-chat-history-selection-btn ${selectionMode ? 'active' : ''}`}
|
className={`infio-chat-history-selection-btn ${selectionMode ? 'active' : ''}`}
|
||||||
title={selectionMode ? '退出选择模式' : '进入选择模式'}
|
title={selectionMode ? String(t('chat.history.exitSelection')) : String(t('chat.history.enterSelection'))}
|
||||||
>
|
>
|
||||||
<CopyPlus size={16} />
|
<CopyPlus size={16} />
|
||||||
{selectionMode ? '取消' : '多选'}
|
{selectionMode ? t('chat.history.cancel') : t('chat.history.multiSelect')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -206,7 +260,7 @@ const ChatHistoryView = ({
|
|||||||
{/* description */}
|
{/* description */}
|
||||||
<div className="infio-chat-history-tip">
|
<div className="infio-chat-history-tip">
|
||||||
{selectionMode
|
{selectionMode
|
||||||
? `选择模式 - 已选择 ${selectedConversations.size} 个对话`
|
? String(t('chat.history.selectionMode', { count: selectedConversations.size }))
|
||||||
: String(t('chat.history.description'))
|
: String(t('chat.history.description'))
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
@ -222,12 +276,12 @@ const ChatHistoryView = ({
|
|||||||
{isAllSelected ? (
|
{isAllSelected ? (
|
||||||
<>
|
<>
|
||||||
<CheckSquare size={16} />
|
<CheckSquare size={16} />
|
||||||
取消全选
|
{t('chat.history.unselectAll')}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Square size={16} />
|
<Square size={16} />
|
||||||
全选
|
{t('chat.history.selectAll')}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
@ -239,7 +293,7 @@ const ChatHistoryView = ({
|
|||||||
className="infio-chat-history-batch-delete-btn"
|
className="infio-chat-history-batch-delete-btn"
|
||||||
>
|
>
|
||||||
<Trash2 size={16} />
|
<Trash2 size={16} />
|
||||||
批量删除 ({selectedConversations.size})
|
{t('chat.history.batchDelete')} ({selectedConversations.size})
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -257,6 +311,18 @@ const ChatHistoryView = ({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* workspace filter */}
|
||||||
|
<div className="infio-chat-history-workspace-filter">
|
||||||
|
<button
|
||||||
|
onClick={toggleWorkspaceFilter}
|
||||||
|
className={`infio-chat-history-workspace-filter-btn ${filterByWorkspace ? 'active' : ''}`}
|
||||||
|
title={filterByWorkspace ? String(t('chat.history.showAllChats')) : String(t('chat.history.showWorkspaceChats'))}
|
||||||
|
>
|
||||||
|
<Globe size={14} />
|
||||||
|
{t('chat.history.currentWorkspace')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* conversations list */}
|
{/* conversations list */}
|
||||||
<div className="infio-chat-history-list">
|
<div className="infio-chat-history-list">
|
||||||
{filteredConversations.length === 0 ? (
|
{filteredConversations.length === 0 ? (
|
||||||
@ -325,6 +391,11 @@ const ChatHistoryView = ({
|
|||||||
{formatDate(conversation.updatedAt)}
|
{formatDate(conversation.updatedAt)}
|
||||||
</div>
|
</div>
|
||||||
<div className="infio-chat-history-conversation-title">{conversation.title}</div>
|
<div className="infio-chat-history-conversation-title">{conversation.title}</div>
|
||||||
|
{conversation.workspace && (
|
||||||
|
<div className="infio-chat-history-workspace">
|
||||||
|
{t('chat.history.workspaceLabel', { workspace: conversation.workspace })}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{!selectionMode && (
|
{!selectionMode && (
|
||||||
<div className="infio-chat-history-actions">
|
<div className="infio-chat-history-actions">
|
||||||
@ -398,6 +469,8 @@ const ChatHistoryView = ({
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.infio-chat-history-filter-btn,
|
||||||
|
.infio-chat-history-cleanup-btn,
|
||||||
.infio-chat-history-selection-btn {
|
.infio-chat-history-selection-btn {
|
||||||
display: flex !important;
|
display: flex !important;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@ -415,11 +488,14 @@ const ChatHistoryView = ({
|
|||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.infio-chat-history-filter-btn:hover,
|
||||||
|
.infio-chat-history-cleanup-btn:hover,
|
||||||
.infio-chat-history-selection-btn:hover {
|
.infio-chat-history-selection-btn:hover {
|
||||||
background-color: var(--background-modifier-hover, #f5f5f5);
|
background-color: var(--background-modifier-hover, #f5f5f5);
|
||||||
border-color: var(--background-modifier-border-hover, #d0d0d0);
|
border-color: var(--background-modifier-border-hover, #d0d0d0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.infio-chat-history-filter-btn.active,
|
||||||
.infio-chat-history-selection-btn.active {
|
.infio-chat-history-selection-btn.active {
|
||||||
background-color: var(--interactive-accent, #007acc);
|
background-color: var(--interactive-accent, #007acc);
|
||||||
color: var(--text-on-accent, #ffffff);
|
color: var(--text-on-accent, #ffffff);
|
||||||
@ -509,7 +585,6 @@ const ChatHistoryView = ({
|
|||||||
border: 1px solid var(--background-modifier-border);
|
border: 1px solid var(--background-modifier-border);
|
||||||
border-radius: var(--radius-s);
|
border-radius: var(--radius-s);
|
||||||
padding: 6px 12px;
|
padding: 6px 12px;
|
||||||
margin-bottom: var(--size-4-3);
|
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
height: 36px;
|
height: 36px;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
@ -645,6 +720,13 @@ const ChatHistoryView = ({
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.infio-chat-history-workspace {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 11px;
|
||||||
|
margin-top: 2px;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
.infio-chat-history-actions {
|
.infio-chat-history-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
@ -738,6 +820,38 @@ const ChatHistoryView = ({
|
|||||||
.infio-chat-history-cancel-btn:hover {
|
.infio-chat-history-cancel-btn:hover {
|
||||||
background-color: var(--background-modifier-hover);
|
background-color: var(--background-modifier-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.infio-chat-history-workspace-filter {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-start;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.infio-chat-history-workspace-filter-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
background-color: transparent;
|
||||||
|
border: 1px solid var(--background-modifier-border);
|
||||||
|
color: var(--text-muted);
|
||||||
|
padding: 6px 12px;
|
||||||
|
border-radius: var(--radius-s);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: var(--font-ui-small);
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.infio-chat-history-workspace-filter-btn:hover {
|
||||||
|
background-color: var(--background-modifier-hover);
|
||||||
|
color: var(--text-normal);
|
||||||
|
}
|
||||||
|
|
||||||
|
.infio-chat-history-workspace-filter-btn.active {
|
||||||
|
background-color: var(--interactive-accent);
|
||||||
|
color: var(--text-on-accent);
|
||||||
|
border-color: var(--interactive-accent);
|
||||||
|
}
|
||||||
`}
|
`}
|
||||||
</style>
|
</style>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -2,8 +2,8 @@ import * as path from 'path'
|
|||||||
|
|
||||||
import { BaseSerializedNode } from '@lexical/clipboard/clipboard'
|
import { BaseSerializedNode } from '@lexical/clipboard/clipboard'
|
||||||
import { useMutation } from '@tanstack/react-query'
|
import { useMutation } from '@tanstack/react-query'
|
||||||
import { CircleStop, History, NotebookPen, Plus, Search, Server, SquareSlash, Undo } from 'lucide-react'
|
import { Box, Lightbulb, CircleStop, History, NotebookPen, Plus, Search, Server, SquareSlash, Undo } from 'lucide-react'
|
||||||
import { App, Notice } from 'obsidian'
|
import { App, Notice, TFile, TFolder, WorkspaceLeaf } from 'obsidian'
|
||||||
import {
|
import {
|
||||||
forwardRef,
|
forwardRef,
|
||||||
useCallback,
|
useCallback,
|
||||||
@ -15,14 +15,16 @@ import {
|
|||||||
} from 'react'
|
} from 'react'
|
||||||
import { v4 as uuidv4 } from 'uuid'
|
import { v4 as uuidv4 } from 'uuid'
|
||||||
|
|
||||||
import { ApplyViewState } from '../../ApplyView'
|
import { ApplyView, ApplyViewState } from '../../ApplyView'
|
||||||
import { APPLY_VIEW_TYPE } from '../../constants'
|
import { APPLY_VIEW_TYPE } from '../../constants'
|
||||||
import { useApp } from '../../contexts/AppContext'
|
import { useApp } from '../../contexts/AppContext'
|
||||||
|
import { useDataview } from '../../contexts/DataviewContext'
|
||||||
import { useDiffStrategy } from '../../contexts/DiffStrategyContext'
|
import { useDiffStrategy } from '../../contexts/DiffStrategyContext'
|
||||||
import { useLLM } from '../../contexts/LLMContext'
|
import { useLLM } from '../../contexts/LLMContext'
|
||||||
import { useMcpHub } from '../../contexts/McpHubContext'
|
import { useMcpHub } from '../../contexts/McpHubContext'
|
||||||
import { useRAG } from '../../contexts/RAGContext'
|
import { useRAG } from '../../contexts/RAGContext'
|
||||||
import { useSettings } from '../../contexts/SettingsContext'
|
import { useSettings } from '../../contexts/SettingsContext'
|
||||||
|
import { useTrans } from '../../contexts/TransContext'
|
||||||
import { matchSearchUsingCorePlugin } from '../../core/file-search/match/coreplugin-match'
|
import { matchSearchUsingCorePlugin } from '../../core/file-search/match/coreplugin-match'
|
||||||
import { matchSearchUsingOmnisearch } from '../../core/file-search/match/omnisearch-match'
|
import { matchSearchUsingOmnisearch } from '../../core/file-search/match/omnisearch-match'
|
||||||
import { regexSearchUsingCorePlugin } from '../../core/file-search/regex/coreplugin-regex'
|
import { regexSearchUsingCorePlugin } from '../../core/file-search/regex/coreplugin-regex'
|
||||||
@ -33,9 +35,13 @@ import {
|
|||||||
LLMBaseUrlNotSetException,
|
LLMBaseUrlNotSetException,
|
||||||
LLMModelNotSetException,
|
LLMModelNotSetException,
|
||||||
} from '../../core/llm/exception'
|
} from '../../core/llm/exception'
|
||||||
|
import { TransformationType } from '../../core/transformations/trans-engine'
|
||||||
|
import { Workspace } from '../../database/json/workspace/types'
|
||||||
|
import { WorkspaceManager } from '../../database/json/workspace/WorkspaceManager'
|
||||||
import { useChatHistory } from '../../hooks/use-chat-history'
|
import { useChatHistory } from '../../hooks/use-chat-history'
|
||||||
import { useCustomModes } from '../../hooks/use-custom-mode'
|
import { useCustomModes } from '../../hooks/use-custom-mode'
|
||||||
import { t } from '../../lang/helpers'
|
import { t } from '../../lang/helpers'
|
||||||
|
import { PreviewView } from '../../PreviewView'
|
||||||
import { ApplyStatus, ToolArgs } from '../../types/apply'
|
import { ApplyStatus, ToolArgs } from '../../types/apply'
|
||||||
import { ChatMessage, ChatUserMessage } from '../../types/chat'
|
import { ChatMessage, ChatUserMessage } from '../../types/chat'
|
||||||
import {
|
import {
|
||||||
@ -45,7 +51,7 @@ import {
|
|||||||
MentionableCurrentFile,
|
MentionableCurrentFile,
|
||||||
} from '../../types/mentionable'
|
} from '../../types/mentionable'
|
||||||
import { ApplyEditToFile, SearchAndReplace } from '../../utils/apply'
|
import { ApplyEditToFile, SearchAndReplace } from '../../utils/apply'
|
||||||
import { listFilesAndFolders } from '../../utils/glob-utils'
|
import { listFilesAndFolders, semanticSearchFiles } from '../../utils/glob-utils'
|
||||||
import {
|
import {
|
||||||
getMentionableKey,
|
getMentionableKey,
|
||||||
serializeMentionable,
|
serializeMentionable,
|
||||||
@ -55,8 +61,8 @@ import { openSettingsModalWithError } from '../../utils/open-settings-modal'
|
|||||||
import { PromptGenerator, addLineNumbers } from '../../utils/prompt-generator'
|
import { PromptGenerator, addLineNumbers } from '../../utils/prompt-generator'
|
||||||
// Removed empty line above, added one below for group separation
|
// Removed empty line above, added one below for group separation
|
||||||
import { fetchUrlsContent, onEnt, webSearch } from '../../utils/web-search'
|
import { fetchUrlsContent, onEnt, webSearch } from '../../utils/web-search'
|
||||||
|
import ErrorBoundary from '../common/ErrorBoundary'
|
||||||
|
|
||||||
import { ModeSelect } from './chat-input/ModeSelect'; // Start of new group
|
|
||||||
import PromptInputWithActions, { ChatUserInputRef } from './chat-input/PromptInputWithActions'
|
import PromptInputWithActions, { ChatUserInputRef } from './chat-input/PromptInputWithActions'
|
||||||
import { editorStateToPlainText } from './chat-input/utils/editor-state-to-plain-text'
|
import { editorStateToPlainText } from './chat-input/utils/editor-state-to-plain-text'
|
||||||
import ChatHistoryView from './ChatHistoryView'
|
import ChatHistoryView from './ChatHistoryView'
|
||||||
@ -64,6 +70,7 @@ import CommandsView from './CommandsView'
|
|||||||
import CustomModeView from './CustomModeView'
|
import CustomModeView from './CustomModeView'
|
||||||
import FileReadResults from './FileReadResults'
|
import FileReadResults from './FileReadResults'
|
||||||
import HelloInfo from './HelloInfo'
|
import HelloInfo from './HelloInfo'
|
||||||
|
import InsightView from './InsightView'
|
||||||
import MarkdownReasoningBlock from './Markdown/MarkdownReasoningBlock'
|
import MarkdownReasoningBlock from './Markdown/MarkdownReasoningBlock'
|
||||||
import McpHubView from './McpHubView'; // Moved after MarkdownReasoningBlock
|
import McpHubView from './McpHubView'; // Moved after MarkdownReasoningBlock
|
||||||
import QueryProgress, { QueryProgressState } from './QueryProgress'
|
import QueryProgress, { QueryProgressState } from './QueryProgress'
|
||||||
@ -72,6 +79,8 @@ import SearchView from './SearchView'
|
|||||||
import SimilaritySearchResults from './SimilaritySearchResults'
|
import SimilaritySearchResults from './SimilaritySearchResults'
|
||||||
import UserMessageView from './UserMessageView'
|
import UserMessageView from './UserMessageView'
|
||||||
import WebsiteReadResults from './WebsiteReadResults'
|
import WebsiteReadResults from './WebsiteReadResults'
|
||||||
|
import WorkspaceSelect from './WorkspaceSelect'
|
||||||
|
import WorkspaceView from './WorkspaceView'
|
||||||
|
|
||||||
// Add an empty line here
|
// Add an empty line here
|
||||||
const getNewInputMessage = (app: App, defaultMention: string): ChatUserMessage => {
|
const getNewInputMessage = (app: App, defaultMention: string): ChatUserMessage => {
|
||||||
@ -113,7 +122,9 @@ const Chat = forwardRef<ChatRef, ChatProps>((props, ref) => {
|
|||||||
const app = useApp()
|
const app = useApp()
|
||||||
const { settings, setSettings } = useSettings()
|
const { settings, setSettings } = useSettings()
|
||||||
const { getRAGEngine } = useRAG()
|
const { getRAGEngine } = useRAG()
|
||||||
|
const { getTransEngine } = useTrans()
|
||||||
const diffStrategy = useDiffStrategy()
|
const diffStrategy = useDiffStrategy()
|
||||||
|
const dataviewManager = useDataview()
|
||||||
const { getMcpHub } = useMcpHub()
|
const { getMcpHub } = useMcpHub()
|
||||||
const { customModeList, customModePrompts } = useCustomModes()
|
const { customModeList, customModePrompts } = useCustomModes()
|
||||||
|
|
||||||
@ -131,6 +142,10 @@ const Chat = forwardRef<ChatRef, ChatProps>((props, ref) => {
|
|||||||
return new PromptGenerator(getRAGEngine, app, settings, diffStrategy, customModePrompts, customModeList, getMcpHub)
|
return new PromptGenerator(getRAGEngine, app, settings, diffStrategy, customModePrompts, customModeList, getMcpHub)
|
||||||
}, [getRAGEngine, app, settings, diffStrategy, customModePrompts, customModeList, getMcpHub])
|
}, [getRAGEngine, app, settings, diffStrategy, customModePrompts, customModeList, getMcpHub])
|
||||||
|
|
||||||
|
const workspaceManager = useMemo(() => {
|
||||||
|
return new WorkspaceManager(app)
|
||||||
|
}, [app])
|
||||||
|
|
||||||
const [inputMessage, setInputMessage] = useState<ChatUserMessage>(() => {
|
const [inputMessage, setInputMessage] = useState<ChatUserMessage>(() => {
|
||||||
const newMessage = getNewInputMessage(app, settings.defaultMention)
|
const newMessage = getNewInputMessage(app, settings.defaultMention)
|
||||||
if (props.selectedBlock) {
|
if (props.selectedBlock) {
|
||||||
@ -178,10 +193,10 @@ const Chat = forwardRef<ChatRef, ChatProps>((props, ref) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const [tab, setTab] = useState<'chat' | 'commands' | 'custom-mode' | 'mcp' | 'search' | 'history'>('chat')
|
const [tab, setTab] = useState<'chat' | 'commands' | 'custom-mode' | 'mcp' | 'search' | 'history' | 'workspace' | 'insights'>('chat')
|
||||||
|
|
||||||
const [selectedSerializedNodes, setSelectedSerializedNodes] = useState<BaseSerializedNode[]>([])
|
const [selectedSerializedNodes, setSelectedSerializedNodes] = useState<BaseSerializedNode[]>([])
|
||||||
|
|
||||||
// 跟踪正在编辑的消息ID
|
// 跟踪正在编辑的消息ID
|
||||||
const [editingMessageId, setEditingMessageId] = useState<string | null>(null)
|
const [editingMessageId, setEditingMessageId] = useState<string | null>(null)
|
||||||
|
|
||||||
@ -414,6 +429,14 @@ const Chat = forwardRef<ChatRef, ChatProps>((props, ref) => {
|
|||||||
if (toolArgs.type === 'write_to_file') {
|
if (toolArgs.type === 'write_to_file') {
|
||||||
let newFile = false
|
let newFile = false
|
||||||
if (!opFile) {
|
if (!opFile) {
|
||||||
|
// 确保目录结构存在
|
||||||
|
const dir = path.dirname(toolArgs.filepath)
|
||||||
|
if (dir && dir !== '.' && dir !== '/') {
|
||||||
|
const dirExists = await app.vault.adapter.exists(dir)
|
||||||
|
if (!dirExists) {
|
||||||
|
await app.vault.adapter.mkdir(dir)
|
||||||
|
}
|
||||||
|
}
|
||||||
opFile = await app.vault.create(toolArgs.filepath, '')
|
opFile = await app.vault.create(toolArgs.filepath, '')
|
||||||
newFile = true
|
newFile = true
|
||||||
}
|
}
|
||||||
@ -602,8 +625,24 @@ const Chat = forwardRef<ChatRef, ChatProps>((props, ref) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
} else if (toolArgs.type === 'list_files') {
|
} else if (toolArgs.type === 'list_files') {
|
||||||
const files = await listFilesAndFolders(app.vault, toolArgs.filepath)
|
// 获取当前工作区
|
||||||
const formattedContent = `[list_files for '${toolArgs.filepath}'] Result:\n${files.join('\n')}\n`;
|
let currentWorkspace: Workspace | null = null
|
||||||
|
if (settings.workspace && settings.workspace !== 'vault') {
|
||||||
|
currentWorkspace = await workspaceManager.findByName(String(settings.workspace))
|
||||||
|
}
|
||||||
|
|
||||||
|
const files = await listFilesAndFolders(
|
||||||
|
app.vault,
|
||||||
|
toolArgs.filepath,
|
||||||
|
toolArgs.recursive,
|
||||||
|
currentWorkspace || undefined,
|
||||||
|
app
|
||||||
|
)
|
||||||
|
|
||||||
|
const contextInfo = currentWorkspace
|
||||||
|
? `workspace '${currentWorkspace.name}'`
|
||||||
|
: toolArgs.filepath || 'vault root'
|
||||||
|
const formattedContent = `[list_files for '${contextInfo}'] Result:\n${files.join('\n')}\n`;
|
||||||
return {
|
return {
|
||||||
type: 'list_files',
|
type: 'list_files',
|
||||||
applyMsgId,
|
applyMsgId,
|
||||||
@ -666,24 +705,25 @@ const Chat = forwardRef<ChatRef, ChatProps>((props, ref) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (toolArgs.type === 'semantic_search_files') {
|
} else if (toolArgs.type === 'semantic_search_files') {
|
||||||
const scope_folders = toolArgs.filepath
|
// 获取当前工作区
|
||||||
&& toolArgs.filepath !== ''
|
let currentWorkspace: Workspace | null = null
|
||||||
&& toolArgs.filepath !== '.'
|
if (settings.workspace && settings.workspace !== 'vault') {
|
||||||
&& toolArgs.filepath !== '/'
|
currentWorkspace = await workspaceManager.findByName(String(settings.workspace))
|
||||||
? { files: [], folders: [toolArgs.filepath] }
|
|
||||||
: undefined
|
|
||||||
const results = await (await getRAGEngine()).processQuery({
|
|
||||||
query: toolArgs.query,
|
|
||||||
scope: scope_folders,
|
|
||||||
})
|
|
||||||
let snippets = results.map(({ path, content, metadata }) => {
|
|
||||||
const contentWithLineNumbers = addLineNumbers(content, metadata.startLine)
|
|
||||||
return `<file_block_content location="${path}#L${metadata.startLine}-${metadata.endLine}">\n${contentWithLineNumbers}\n</file_block_content>`
|
|
||||||
}).join('\n\n')
|
|
||||||
if (snippets.length === 0) {
|
|
||||||
snippets = `No results found for '${toolArgs.query}'`
|
|
||||||
}
|
}
|
||||||
const formattedContent = `[semantic_search_files for '${toolArgs.filepath}'] Result:\n${snippets}\n`;
|
|
||||||
|
const snippets = await semanticSearchFiles(
|
||||||
|
await getRAGEngine(),
|
||||||
|
toolArgs.query,
|
||||||
|
toolArgs.filepath,
|
||||||
|
currentWorkspace || undefined,
|
||||||
|
app,
|
||||||
|
await getTransEngine()
|
||||||
|
)
|
||||||
|
|
||||||
|
const contextInfo = currentWorkspace
|
||||||
|
? `workspace '${currentWorkspace.name}'`
|
||||||
|
: toolArgs.filepath || 'vault'
|
||||||
|
const formattedContent = `[semantic_search_files for '${contextInfo}'] Result:\n${snippets}\n`;
|
||||||
return {
|
return {
|
||||||
type: 'semantic_search_files',
|
type: 'semantic_search_files',
|
||||||
applyMsgId,
|
applyMsgId,
|
||||||
@ -791,6 +831,246 @@ const Chat = forwardRef<ChatRef, ChatProps>((props, ref) => {
|
|||||||
mentionables: [],
|
mentionables: [],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else if (toolArgs.type === 'dataview_query') {
|
||||||
|
if (!dataviewManager) {
|
||||||
|
throw new Error('DataviewManager 未初始化')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!dataviewManager.isDataviewAvailable()) {
|
||||||
|
throw new Error('Dataview 插件未安装或未启用,请先安装并启用 Dataview 插件')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 执行 Dataview 查询
|
||||||
|
const result = await dataviewManager.executeQuery(toolArgs.query)
|
||||||
|
|
||||||
|
let formattedContent: string;
|
||||||
|
if (result.success) {
|
||||||
|
formattedContent = `[dataview_query] 查询成功:\n${result.data}`;
|
||||||
|
} else {
|
||||||
|
formattedContent = `[dataview_query] 查询失败:\n${result.error}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: 'dataview_query',
|
||||||
|
applyMsgId,
|
||||||
|
applyStatus: result.success ? ApplyStatus.Applied : ApplyStatus.Failed,
|
||||||
|
returnMsg: {
|
||||||
|
role: 'user',
|
||||||
|
applyStatus: ApplyStatus.Idle,
|
||||||
|
content: null,
|
||||||
|
promptContent: formattedContent,
|
||||||
|
id: uuidv4(),
|
||||||
|
mentionables: [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (toolArgs.type === 'call_transformations') {
|
||||||
|
// Handling for the unified transformations tool
|
||||||
|
try {
|
||||||
|
console.log("call_transformations", toolArgs)
|
||||||
|
// Validate that the transformation type is a valid enum member
|
||||||
|
const validTransformationTypes = Object.values(TransformationType) as string[]
|
||||||
|
if (!validTransformationTypes.includes(toolArgs.transformation)) {
|
||||||
|
throw new Error(`Unsupported transformation type: ${toolArgs.transformation}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const transformationType = toolArgs.transformation;
|
||||||
|
const transEngine = await getTransEngine();
|
||||||
|
|
||||||
|
// Execute the transformation using the TransEngine
|
||||||
|
const transformationResult = await transEngine.runTransformation({
|
||||||
|
filePath: toolArgs.path,
|
||||||
|
transformationType: transformationType as TransformationType,
|
||||||
|
model: {
|
||||||
|
provider: settings.applyModelProvider,
|
||||||
|
modelId: settings.applyModelId,
|
||||||
|
},
|
||||||
|
saveToDatabase: true
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!transformationResult.success) {
|
||||||
|
throw new Error(transformationResult.error || 'Transformation failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build the result message
|
||||||
|
let formattedContent = `[${toolArgs.transformation}] transformation complete:\n\n${transformationResult.result}`;
|
||||||
|
|
||||||
|
if (transformationResult.truncated) {
|
||||||
|
formattedContent += `\n\n*Note: The original content was too long (${transformationResult.originalTokens} tokens) and was truncated to ${transformationResult.processedTokens} tokens for processing.*`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: toolArgs.type,
|
||||||
|
applyMsgId,
|
||||||
|
applyStatus: ApplyStatus.Applied,
|
||||||
|
returnMsg: {
|
||||||
|
role: 'user',
|
||||||
|
applyStatus: ApplyStatus.Idle,
|
||||||
|
content: null,
|
||||||
|
promptContent: formattedContent,
|
||||||
|
id: uuidv4(),
|
||||||
|
mentionables: [],
|
||||||
|
}
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Transformation failed (${toolArgs.transformation}):`, error);
|
||||||
|
return {
|
||||||
|
type: toolArgs.type,
|
||||||
|
applyMsgId,
|
||||||
|
applyStatus: ApplyStatus.Failed,
|
||||||
|
returnMsg: {
|
||||||
|
role: 'user',
|
||||||
|
applyStatus: ApplyStatus.Idle,
|
||||||
|
content: null,
|
||||||
|
promptContent: `[${toolArgs.transformation}] transformation failed: ${error instanceof Error ? error.message : String(error)}`,
|
||||||
|
id: uuidv4(),
|
||||||
|
mentionables: [],
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} else if (toolArgs.type === 'manage_files') {
|
||||||
|
try {
|
||||||
|
const results: string[] = [];
|
||||||
|
|
||||||
|
// 处理每个文件操作
|
||||||
|
for (const operation of toolArgs.operations) {
|
||||||
|
switch (operation.action) {
|
||||||
|
case 'create_folder':
|
||||||
|
if (operation.path) {
|
||||||
|
const folderExists = await app.vault.adapter.exists(operation.path);
|
||||||
|
if (!folderExists) {
|
||||||
|
await app.vault.adapter.mkdir(operation.path);
|
||||||
|
results.push(`✅ 成功创建文件夹: ${operation.path}`);
|
||||||
|
} else {
|
||||||
|
results.push(`⚠️ 文件夹已存在: ${operation.path}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'move':
|
||||||
|
if (operation.source_path && operation.destination_path) {
|
||||||
|
// 使用 getAbstractFileByPath 而不是 getFileByPath,这样可以获取文件和文件夹
|
||||||
|
const sourceFile = app.vault.getAbstractFileByPath(operation.source_path);
|
||||||
|
if (sourceFile) {
|
||||||
|
// 确保目标目录存在
|
||||||
|
const destDir = path.dirname(operation.destination_path);
|
||||||
|
if (destDir && destDir !== '.' && destDir !== '/') {
|
||||||
|
const dirExists = await app.vault.adapter.exists(destDir);
|
||||||
|
if (!dirExists) {
|
||||||
|
await app.vault.adapter.mkdir(destDir);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await app.vault.rename(sourceFile, operation.destination_path);
|
||||||
|
const itemType = sourceFile instanceof TFile ? '文件' : '文件夹';
|
||||||
|
results.push(`✅ 成功移动${itemType}: ${operation.source_path} → ${operation.destination_path}`);
|
||||||
|
} else {
|
||||||
|
results.push(`❌ 源文件或文件夹不存在: ${operation.source_path}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'delete':
|
||||||
|
if (operation.path) {
|
||||||
|
// 使用 getAbstractFileByPath 而不是 getFileByPath
|
||||||
|
const fileOrFolder = app.vault.getAbstractFileByPath(operation.path);
|
||||||
|
if (fileOrFolder) {
|
||||||
|
try {
|
||||||
|
const isFolder = fileOrFolder instanceof TFolder;
|
||||||
|
// 使用 trash 方法将文件/文件夹移到回收站,更安全
|
||||||
|
// system: true 尝试使用系统回收站,失败则使用 Obsidian 本地回收站
|
||||||
|
await app.vault.trash(fileOrFolder, true);
|
||||||
|
const itemType = isFolder ? '文件夹' : '文件';
|
||||||
|
results.push(`✅ 成功将${itemType}移到回收站: ${operation.path}`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('删除失败:', error);
|
||||||
|
results.push(`❌ 删除失败: ${operation.path} - ${error.message}`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
results.push(`❌ 文件或文件夹不存在: ${operation.path}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'copy':
|
||||||
|
if (operation.source_path && operation.destination_path) {
|
||||||
|
// 文件夹复制比较复杂,需要递归处理
|
||||||
|
const sourceFile = app.vault.getAbstractFileByPath(operation.source_path);
|
||||||
|
if (sourceFile) {
|
||||||
|
if (sourceFile instanceof TFile) {
|
||||||
|
// 文件复制
|
||||||
|
const destDir = path.dirname(operation.destination_path);
|
||||||
|
if (destDir && destDir !== '.' && destDir !== '/') {
|
||||||
|
const dirExists = await app.vault.adapter.exists(destDir);
|
||||||
|
if (!dirExists) {
|
||||||
|
await app.vault.adapter.mkdir(destDir);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const content = await app.vault.read(sourceFile);
|
||||||
|
await app.vault.create(operation.destination_path, content);
|
||||||
|
results.push(`✅ 成功复制文件: ${operation.source_path} → ${operation.destination_path}`);
|
||||||
|
} else if (sourceFile instanceof TFolder) {
|
||||||
|
// 文件夹复制需要递归处理
|
||||||
|
results.push(`❌ 文件夹复制功能暂未实现: ${operation.source_path}`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
results.push(`❌ 源文件或文件夹不存在: ${operation.source_path}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'rename':
|
||||||
|
if (operation.path && operation.new_name) {
|
||||||
|
// 使用 getAbstractFileByPath 而不是 getFileByPath
|
||||||
|
const file = app.vault.getAbstractFileByPath(operation.path);
|
||||||
|
if (file) {
|
||||||
|
const newPath = path.join(path.dirname(operation.path), operation.new_name);
|
||||||
|
await app.vault.rename(file, newPath);
|
||||||
|
const itemType = file instanceof TFile ? '文件' : '文件夹';
|
||||||
|
results.push(`✅ 成功重命名${itemType}: ${operation.path} → ${newPath}`);
|
||||||
|
} else {
|
||||||
|
results.push(`❌ 文件或文件夹不存在: ${operation.path}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
results.push(`❌ 不支持的操作类型: ${String(operation.action)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const formattedContent = `[manage_files] 文件管理操作结果:\n${results.join('\n')}`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: 'manage_files',
|
||||||
|
applyMsgId,
|
||||||
|
applyStatus: ApplyStatus.Applied,
|
||||||
|
returnMsg: {
|
||||||
|
role: 'user',
|
||||||
|
applyStatus: ApplyStatus.Idle,
|
||||||
|
content: null,
|
||||||
|
promptContent: formattedContent,
|
||||||
|
id: uuidv4(),
|
||||||
|
mentionables: [],
|
||||||
|
}
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('文件管理操作失败:', error);
|
||||||
|
return {
|
||||||
|
type: 'manage_files',
|
||||||
|
applyMsgId,
|
||||||
|
applyStatus: ApplyStatus.Failed,
|
||||||
|
returnMsg: {
|
||||||
|
role: 'user',
|
||||||
|
applyStatus: ApplyStatus.Idle,
|
||||||
|
content: null,
|
||||||
|
promptContent: `[manage_files] 文件管理操作失败: ${error instanceof Error ? error.message : String(error)}`,
|
||||||
|
id: uuidv4(),
|
||||||
|
mentionables: [],
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 处理未知的工具类型
|
||||||
|
throw new Error(`Unsupported tool type: ${(toolArgs as any).type || 'unknown'}`);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to apply changes', error)
|
console.error('Failed to apply changes', error)
|
||||||
@ -854,6 +1134,8 @@ const Chat = forwardRef<ChatRef, ChatProps>((props, ref) => {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setFocusedMessageId(inputMessage.id)
|
setFocusedMessageId(inputMessage.id)
|
||||||
|
// 初始化当前活动文件引用
|
||||||
|
currentActiveFileRef.current = app.workspace.getActiveFile()
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
@ -871,10 +1153,27 @@ const Chat = forwardRef<ChatRef, ChatProps>((props, ref) => {
|
|||||||
updateConversationAsync()
|
updateConversationAsync()
|
||||||
}, [currentConversationId, chatMessages, createOrUpdateConversation])
|
}, [currentConversationId, chatMessages, createOrUpdateConversation])
|
||||||
|
|
||||||
|
// 保存当前活动文件的引用,用于比较是否真的发生了变化
|
||||||
|
const currentActiveFileRef = useRef<TFile | null>(null)
|
||||||
|
|
||||||
// Updates the currentFile of the focused message (input or chat history)
|
// Updates the currentFile of the focused message (input or chat history)
|
||||||
// This happens when active file changes or focused message changes
|
// This happens when active file changes or focused message changes
|
||||||
const handleActiveLeafChange = useCallback(() => {
|
const handleActiveLeafChange = useCallback((leaf: WorkspaceLeaf | null) => {
|
||||||
|
// 过滤掉 ApplyView 和 PreviewView 的切换
|
||||||
|
if ((leaf?.view instanceof ApplyView) || (leaf?.view instanceof PreviewView)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const activeFile = app.workspace.getActiveFile()
|
const activeFile = app.workspace.getActiveFile()
|
||||||
|
|
||||||
|
// 🎯 关键优化:只有当活动文件真正发生变化时才更新
|
||||||
|
if (activeFile === currentActiveFileRef.current) {
|
||||||
|
return // 文件没有变化,不需要更新
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新文件引用
|
||||||
|
currentActiveFileRef.current = activeFile
|
||||||
|
|
||||||
if (!activeFile) return
|
if (!activeFile) return
|
||||||
|
|
||||||
const mentionable: Omit<MentionableCurrentFile, 'id'> = {
|
const mentionable: Omit<MentionableCurrentFile, 'id'> = {
|
||||||
@ -986,7 +1285,9 @@ const Chat = forwardRef<ChatRef, ChatProps>((props, ref) => {
|
|||||||
<div className="infio-chat-container">
|
<div className="infio-chat-container">
|
||||||
{/* header view */}
|
{/* header view */}
|
||||||
<div className="infio-chat-header">
|
<div className="infio-chat-header">
|
||||||
<ModeSelect />
|
<div className="infio-chat-header-title">
|
||||||
|
<WorkspaceSelect />
|
||||||
|
</div>
|
||||||
<div className="infio-chat-header-buttons">
|
<div className="infio-chat-header-buttons">
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@ -1021,6 +1322,30 @@ const Chat = forwardRef<ChatRef, ChatProps>((props, ref) => {
|
|||||||
>
|
>
|
||||||
<Search size={18} color={tab === 'search' ? 'var(--text-accent)' : 'var(--text-color)'} />
|
<Search size={18} color={tab === 'search' ? 'var(--text-accent)' : 'var(--text-color)'} />
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
if (tab === 'insights') {
|
||||||
|
setTab('chat')
|
||||||
|
} else {
|
||||||
|
setTab('insights')
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="infio-chat-list-dropdown"
|
||||||
|
>
|
||||||
|
<Lightbulb size={18} color={tab === 'insights' ? 'var(--text-accent)' : 'var(--text-color)'} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
if (tab === 'workspace') {
|
||||||
|
setTab('chat')
|
||||||
|
} else {
|
||||||
|
setTab('workspace')
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="infio-chat-list-dropdown"
|
||||||
|
>
|
||||||
|
<Box size={18} color={tab === 'workspace' ? 'var(--text-accent)' : 'var(--text-color)'} />
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
// switch between chat and prompts
|
// switch between chat and prompts
|
||||||
@ -1128,18 +1453,20 @@ const Chat = forwardRef<ChatRef, ChatProps>((props, ref) => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<UserMessageView
|
<ErrorBoundary>
|
||||||
content={message.content}
|
<UserMessageView
|
||||||
mentionables={message.mentionables}
|
content={message.content}
|
||||||
onEdit={() => {
|
mentionables={message.mentionables}
|
||||||
setEditingMessageId(message.id)
|
onEdit={() => {
|
||||||
setFocusedMessageId(message.id)
|
setEditingMessageId(message.id)
|
||||||
// 延迟聚焦,确保组件已渲染
|
setFocusedMessageId(message.id)
|
||||||
setTimeout(() => {
|
// 延迟聚焦,确保组件已渲染
|
||||||
chatUserInputRefs.current.get(message.id)?.focus()
|
setTimeout(() => {
|
||||||
}, 0)
|
chatUserInputRefs.current.get(message.id)?.focus()
|
||||||
}}
|
}, 0)
|
||||||
/>
|
}}
|
||||||
|
/>
|
||||||
|
</ErrorBoundary>
|
||||||
)}
|
)}
|
||||||
{message.fileReadResults && (
|
{message.fileReadResults && (
|
||||||
<FileReadResults
|
<FileReadResults
|
||||||
@ -1253,6 +1580,14 @@ const Chat = forwardRef<ChatRef, ChatProps>((props, ref) => {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
) : tab === 'workspace' ? (
|
||||||
|
<div className="infio-chat-commands">
|
||||||
|
<WorkspaceView />
|
||||||
|
</div>
|
||||||
|
) : tab === 'insights' ? (
|
||||||
|
<div className="infio-chat-commands">
|
||||||
|
<InsightView />
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="infio-chat-commands">
|
<div className="infio-chat-commands">
|
||||||
<McpHubView />
|
<McpHubView />
|
||||||
|
|||||||
@ -1,38 +1,50 @@
|
|||||||
import { NotebookPen, Search, Server, SquareSlash } from 'lucide-react';
|
import { History, Lightbulb, NotebookPen, Search, Server, SquareSlash } from 'lucide-react';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
import { t } from '../../lang/helpers';
|
import { t } from '../../lang/helpers';
|
||||||
|
|
||||||
interface HelloInfoProps {
|
interface HelloInfoProps {
|
||||||
onNavigate: (tab: 'commands' | 'custom-mode' | 'mcp' | 'search') => void;
|
onNavigate: (tab: 'commands' | 'custom-mode' | 'mcp' | 'search' | 'history' | 'insights') => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const HelloInfo: React.FC<HelloInfoProps> = ({ onNavigate }) => {
|
const HelloInfo: React.FC<HelloInfoProps> = ({ onNavigate }) => {
|
||||||
const navigationItems = [
|
const navigationItems = [
|
||||||
{
|
{
|
||||||
label: '语义搜索',
|
label: t('chat.navigation.history'),
|
||||||
description: '使用 RAG 在笔记库中进行语义搜索',
|
description: t('chat.navigation.historyDesc'),
|
||||||
|
icon: <History size={20} />,
|
||||||
|
action: () => onNavigate('history'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t('chat.navigation.search'),
|
||||||
|
description: t('chat.navigation.searchDesc'),
|
||||||
icon: <Search size={20} />,
|
icon: <Search size={20} />,
|
||||||
action: () => onNavigate('search'),
|
action: () => onNavigate('search'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: t('chat.navigation.commands'),
|
label: t('chat.navigation.insights'),
|
||||||
description: t('chat.navigation.commandsDesc'),
|
description: t('chat.navigation.insightsDesc'),
|
||||||
icon: <SquareSlash size={20} />,
|
icon: <Lightbulb size={20} />,
|
||||||
action: () => onNavigate('commands'),
|
action: () => onNavigate('insights'),
|
||||||
},
|
},
|
||||||
{
|
// {
|
||||||
label: t('chat.navigation.customMode'),
|
// label: t('chat.navigation.commands'),
|
||||||
description: t('chat.navigation.customModeDesc'),
|
// description: t('chat.navigation.commandsDesc'),
|
||||||
icon: <NotebookPen size={20} />,
|
// icon: <SquareSlash size={20} />,
|
||||||
action: () => onNavigate('custom-mode'),
|
// action: () => onNavigate('commands'),
|
||||||
},
|
// },
|
||||||
{
|
// {
|
||||||
label: t('chat.navigation.mcp'),
|
// label: t('chat.navigation.customMode'),
|
||||||
description: t('chat.navigation.mcpDesc'),
|
// description: t('chat.navigation.customModeDesc'),
|
||||||
icon: <Server size={20} />,
|
// icon: <NotebookPen size={20} />,
|
||||||
action: () => onNavigate('mcp'),
|
// action: () => onNavigate('custom-mode'),
|
||||||
}
|
// },
|
||||||
|
// {
|
||||||
|
// label: t('chat.navigation.mcp'),
|
||||||
|
// description: t('chat.navigation.mcpDesc'),
|
||||||
|
// icon: <Server size={20} />,
|
||||||
|
// action: () => onNavigate('mcp'),
|
||||||
|
// }
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
1643
src/components/chat-view/InsightView.tsx
Normal file
1643
src/components/chat-view/InsightView.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,83 @@
|
|||||||
|
import { Check, ChevronDown, ChevronRight, Database, Loader2, X } from 'lucide-react'
|
||||||
|
import React, { useState } from 'react'
|
||||||
|
|
||||||
|
import { t } from '../../../lang/helpers'
|
||||||
|
import { ApplyStatus, DataviewQueryToolArgs } from "../../../types/apply"
|
||||||
|
|
||||||
|
export default function MarkdownDataviewQueryBlock({
|
||||||
|
applyStatus,
|
||||||
|
onApply,
|
||||||
|
query,
|
||||||
|
outputFormat,
|
||||||
|
finish
|
||||||
|
}: {
|
||||||
|
applyStatus: ApplyStatus
|
||||||
|
onApply: (args: DataviewQueryToolArgs) => void
|
||||||
|
query: string
|
||||||
|
outputFormat: string
|
||||||
|
finish: boolean
|
||||||
|
}) {
|
||||||
|
const [isOpen, setIsOpen] = useState(false)
|
||||||
|
const [isHovered, setIsHovered] = useState(false)
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (finish && applyStatus === ApplyStatus.Idle) {
|
||||||
|
onApply({
|
||||||
|
type: 'dataview_query',
|
||||||
|
query: query,
|
||||||
|
outputFormat: outputFormat,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, [finish])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`infio-chat-code-block has-filename`}
|
||||||
|
onMouseEnter={() => setIsHovered(true)}
|
||||||
|
onMouseLeave={() => setIsHovered(false)}
|
||||||
|
>
|
||||||
|
<div className={'infio-chat-code-block-header'}>
|
||||||
|
<div
|
||||||
|
className={'infio-chat-code-block-header-filename'}
|
||||||
|
onClick={() => setIsOpen(!isOpen)}
|
||||||
|
style={{ cursor: isHovered ? 'pointer' : 'default' }}
|
||||||
|
>
|
||||||
|
{isHovered ? (
|
||||||
|
isOpen ? <ChevronDown size={14} className="infio-chat-code-block-header-icon" /> : <ChevronRight size={14} className="infio-chat-code-block-header-icon" />
|
||||||
|
) : (
|
||||||
|
<Database size={14} className="infio-chat-code-block-header-icon" />
|
||||||
|
)}
|
||||||
|
Dataview 查询 ({outputFormat})
|
||||||
|
</div>
|
||||||
|
<div className={'infio-chat-code-block-header-button'}>
|
||||||
|
<button
|
||||||
|
className="infio-dataview-query-button"
|
||||||
|
disabled={true}
|
||||||
|
>
|
||||||
|
{
|
||||||
|
!finish || applyStatus === ApplyStatus.Idle ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="spinner" size={14} /> 执行中...
|
||||||
|
</>
|
||||||
|
) : applyStatus === ApplyStatus.Applied ? (
|
||||||
|
<>
|
||||||
|
<Check size={14} /> 完成
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<X size={14} /> 失败
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{isOpen && (
|
||||||
|
<div className={'infio-chat-code-block-content'}>
|
||||||
|
<pre>
|
||||||
|
<code>{query}</code>
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
124
src/components/chat-view/Markdown/MarkdownManageFilesBlock.tsx
Normal file
124
src/components/chat-view/Markdown/MarkdownManageFilesBlock.tsx
Normal file
@ -0,0 +1,124 @@
|
|||||||
|
import { Check, Copy, FileIcon, FolderPlus, Loader2, Move, Trash2, X } from 'lucide-react'
|
||||||
|
import React, { useState } from 'react'
|
||||||
|
|
||||||
|
import { ApplyStatus, ManageFilesToolArgs } from "../../../types/apply"
|
||||||
|
|
||||||
|
interface ManageFilesOperation {
|
||||||
|
action: 'create_folder' | 'move' | 'delete' | 'copy' | 'rename'
|
||||||
|
path?: string
|
||||||
|
source_path?: string
|
||||||
|
destination_path?: string
|
||||||
|
new_name?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MarkdownManageFilesBlock({
|
||||||
|
applyStatus,
|
||||||
|
onApply,
|
||||||
|
operations,
|
||||||
|
finish
|
||||||
|
}: {
|
||||||
|
applyStatus: ApplyStatus
|
||||||
|
onApply: (args: ManageFilesToolArgs) => void
|
||||||
|
operations: ManageFilesOperation[]
|
||||||
|
finish: boolean
|
||||||
|
}) {
|
||||||
|
const [applying, setApplying] = useState(false)
|
||||||
|
|
||||||
|
const getOperationIcon = (action: string) => {
|
||||||
|
switch (action) {
|
||||||
|
case 'create_folder':
|
||||||
|
return <FolderPlus size={14} className="infio-chat-code-block-header-icon" />
|
||||||
|
case 'move':
|
||||||
|
return <Move size={14} className="infio-chat-code-block-header-icon" />
|
||||||
|
case 'delete':
|
||||||
|
return <Trash2 size={14} className="infio-chat-code-block-header-icon" />
|
||||||
|
case 'copy':
|
||||||
|
return <Copy size={14} className="infio-chat-code-block-header-icon" />
|
||||||
|
case 'rename':
|
||||||
|
return <FileIcon size={14} className="infio-chat-code-block-header-icon" />
|
||||||
|
default:
|
||||||
|
return <FileIcon size={14} className="infio-chat-code-block-header-icon" />
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getOperationDescription = (operation: ManageFilesOperation) => {
|
||||||
|
switch (operation.action) {
|
||||||
|
case 'create_folder':
|
||||||
|
return `创建文件夹:${operation.path}`
|
||||||
|
case 'move':
|
||||||
|
return `移动文件:${operation.source_path} → ${operation.destination_path}`
|
||||||
|
case 'delete':
|
||||||
|
return `删除:${operation.path}`
|
||||||
|
case 'copy':
|
||||||
|
return `复制:${operation.source_path} → ${operation.destination_path}`
|
||||||
|
case 'rename':
|
||||||
|
return `重命名:${operation.path} → ${operation.new_name}`
|
||||||
|
default:
|
||||||
|
return `未知操作`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleApply = async () => {
|
||||||
|
if (applyStatus !== ApplyStatus.Idle) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setApplying(true)
|
||||||
|
onApply({
|
||||||
|
type: 'manage_files',
|
||||||
|
operations: operations,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`infio-chat-code-block has-filename`}>
|
||||||
|
<div className={'infio-chat-code-block-header'}>
|
||||||
|
<div className={'infio-chat-code-block-header-filename'}>
|
||||||
|
<FolderPlus size={14} className="infio-chat-code-block-header-icon" />
|
||||||
|
文件管理操作 ({operations.length} 个操作)
|
||||||
|
</div>
|
||||||
|
<div className={'infio-chat-code-block-header-button'}>
|
||||||
|
<button
|
||||||
|
onClick={handleApply}
|
||||||
|
className="infio-apply-button"
|
||||||
|
disabled={applyStatus !== ApplyStatus.Idle || applying || !finish}
|
||||||
|
>
|
||||||
|
{
|
||||||
|
!finish ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="spinner" size={14} /> 准备执行
|
||||||
|
</>
|
||||||
|
) : applyStatus === ApplyStatus.Idle ? (
|
||||||
|
applying ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="spinner" size={14} /> 执行中
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'执行操作'
|
||||||
|
)
|
||||||
|
) : applyStatus === ApplyStatus.Applied ? (
|
||||||
|
<>
|
||||||
|
<Check size={14} /> 已完成
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<X size={14} /> 执行失败
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="infio-chat-code-block-content">
|
||||||
|
{operations.map((operation, index) => (
|
||||||
|
<div key={index} className="manage-files-operation">
|
||||||
|
<div className="operation-item">
|
||||||
|
{getOperationIcon(operation.action)}
|
||||||
|
<span className="operation-description">
|
||||||
|
{getOperationDescription(operation)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
58
src/components/chat-view/Markdown/MarkdownPlanBlock.tsx
Normal file
58
src/components/chat-view/Markdown/MarkdownPlanBlock.tsx
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
import { AlignLeft, ChevronDown, ChevronRight } from 'lucide-react'
|
||||||
|
import { PropsWithChildren, useEffect, useRef, useState } from 'react'
|
||||||
|
|
||||||
|
import { useDarkModeContext } from "../../../contexts/DarkModeContext"
|
||||||
|
import { t } from '../../../lang/helpers'
|
||||||
|
|
||||||
|
import { MemoizedSyntaxHighlighterWrapper } from "./SyntaxHighlighterWrapper"
|
||||||
|
|
||||||
|
export default function MarkdownPlanBlock({
|
||||||
|
planContent,
|
||||||
|
}: PropsWithChildren<{
|
||||||
|
planContent: string
|
||||||
|
}>) {
|
||||||
|
const { isDarkMode } = useDarkModeContext()
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
|
const [isOpen, setIsOpen] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (containerRef.current) {
|
||||||
|
containerRef.current.scrollTop = containerRef.current.scrollHeight
|
||||||
|
}
|
||||||
|
}, [planContent])
|
||||||
|
|
||||||
|
return (
|
||||||
|
planContent && (
|
||||||
|
<div
|
||||||
|
className={`infio-chat-code-block has-filename infio-reasoning-block`}
|
||||||
|
>
|
||||||
|
<div className={'infio-chat-code-block-header'}>
|
||||||
|
<div className={'infio-chat-code-block-header-filename'}>
|
||||||
|
<AlignLeft size={12} className="infio-chat-code-block-header-icon" />
|
||||||
|
{t('chat.reactMarkdown.plan')}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="clickable-icon infio-chat-list-dropdown"
|
||||||
|
onClick={() => setIsOpen(!isOpen)}
|
||||||
|
>
|
||||||
|
{isOpen ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className="infio-reasoning-content-wrapper"
|
||||||
|
>
|
||||||
|
<MemoizedSyntaxHighlighterWrapper
|
||||||
|
isDarkMode={isDarkMode}
|
||||||
|
language="markdown"
|
||||||
|
hasFilename={true}
|
||||||
|
wrapLines={true}
|
||||||
|
isOpen={isOpen}
|
||||||
|
>
|
||||||
|
{planContent}
|
||||||
|
</MemoizedSyntaxHighlighterWrapper>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -1,4 +1,4 @@
|
|||||||
import { ChevronDown, ChevronRight, Brain } from 'lucide-react'
|
import { Brain, ChevronDown, ChevronRight } from 'lucide-react'
|
||||||
import { PropsWithChildren, useEffect, useRef, useState } from 'react'
|
import { PropsWithChildren, useEffect, useRef, useState } from 'react'
|
||||||
|
|
||||||
import { useDarkModeContext } from "../../../contexts/DarkModeContext"
|
import { useDarkModeContext } from "../../../contexts/DarkModeContext"
|
||||||
|
|||||||
@ -0,0 +1,110 @@
|
|||||||
|
import { Sparkles } from 'lucide-react'
|
||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
import { useApp } from "../../../contexts/AppContext"
|
||||||
|
import { ApplyStatus, ToolArgs } from "../../../types/apply"
|
||||||
|
import { openMarkdownFile } from "../../../utils/obsidian"
|
||||||
|
|
||||||
|
export type TransformationToolType = 'call_transformations'
|
||||||
|
|
||||||
|
interface MarkdownTransformationToolBlockProps {
|
||||||
|
applyStatus: ApplyStatus
|
||||||
|
onApply: (args: ToolArgs) => void
|
||||||
|
toolType: TransformationToolType
|
||||||
|
path: string
|
||||||
|
transformation?: string
|
||||||
|
finish: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const getTransformationConfig = (transformation: string) => {
|
||||||
|
switch (transformation) {
|
||||||
|
case 'analyze_paper':
|
||||||
|
return {
|
||||||
|
icon: <Sparkles size={14} className="infio-chat-code-block-header-icon" />,
|
||||||
|
title: 'Analyze Paper',
|
||||||
|
description: 'Deep analysis of academic papers'
|
||||||
|
}
|
||||||
|
case 'key_insights':
|
||||||
|
return {
|
||||||
|
icon: <Sparkles size={14} className="infio-chat-code-block-header-icon" />,
|
||||||
|
title: 'Key Insights',
|
||||||
|
description: 'Extract key insights'
|
||||||
|
}
|
||||||
|
case 'dense_summary':
|
||||||
|
return {
|
||||||
|
icon: <Sparkles size={14} className="infio-chat-code-block-header-icon" />,
|
||||||
|
title: 'Dense Summary',
|
||||||
|
description: 'Create information-dense summary'
|
||||||
|
}
|
||||||
|
case 'reflections':
|
||||||
|
return {
|
||||||
|
icon: <Sparkles size={14} className="infio-chat-code-block-header-icon" />,
|
||||||
|
title: 'Deep Reflections',
|
||||||
|
description: 'Generate deep reflections'
|
||||||
|
}
|
||||||
|
case 'table_of_contents':
|
||||||
|
return {
|
||||||
|
icon: <Sparkles size={14} className="infio-chat-code-block-header-icon" />,
|
||||||
|
title: 'Table of Contents',
|
||||||
|
description: 'Generate table of contents structure'
|
||||||
|
}
|
||||||
|
case 'simple_summary':
|
||||||
|
return {
|
||||||
|
icon: <Sparkles size={14} className="infio-chat-code-block-header-icon" />,
|
||||||
|
title: 'Simple Summary',
|
||||||
|
description: 'Create readable summary'
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return {
|
||||||
|
icon: <Sparkles size={14} className="infio-chat-code-block-header-icon" />,
|
||||||
|
title: 'Document Processing',
|
||||||
|
description: 'Process document'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MarkdownTransformationToolBlock({
|
||||||
|
applyStatus,
|
||||||
|
onApply,
|
||||||
|
path,
|
||||||
|
transformation,
|
||||||
|
finish
|
||||||
|
}: MarkdownTransformationToolBlockProps) {
|
||||||
|
const app = useApp()
|
||||||
|
const config = getTransformationConfig(transformation || '')
|
||||||
|
|
||||||
|
const handleClick = () => {
|
||||||
|
if (path) {
|
||||||
|
openMarkdownFile(app, path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (finish && applyStatus === ApplyStatus.Idle) {
|
||||||
|
onApply({
|
||||||
|
type: 'call_transformations',
|
||||||
|
path: path || '',
|
||||||
|
transformation: transformation || ''
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, [finish])
|
||||||
|
|
||||||
|
const getDisplayText = () => {
|
||||||
|
return `${config.title}: ${path || '未指定路径'}`
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`infio-chat-code-block ${path ? 'has-filename' : ''}`}
|
||||||
|
onClick={handleClick}
|
||||||
|
style={{ cursor: path ? 'pointer' : 'default' }}
|
||||||
|
>
|
||||||
|
<div className={'infio-chat-code-block-header'}>
|
||||||
|
<div className={'infio-chat-code-block-header-filename'}>
|
||||||
|
{config.icon}
|
||||||
|
<span>{getDisplayText()}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -1,13 +1,13 @@
|
|||||||
import * as Tooltip from '@radix-ui/react-tooltip';
|
import * as Tooltip from '@radix-ui/react-tooltip';
|
||||||
import { Check, CircleCheckBig, CircleHelp, CopyIcon, FilePlus2 } from 'lucide-react';
|
import { Check, CircleCheckBig, CircleHelp, CopyIcon, FilePlus2 } from 'lucide-react';
|
||||||
import { ReactNode, useState } from 'react';
|
import { ReactNode, useState } from 'react';
|
||||||
import ReactMarkdown from 'react-markdown';
|
|
||||||
import rehypeRaw from 'rehype-raw';
|
|
||||||
import { useApp } from 'src/contexts/AppContext';
|
import { useApp } from 'src/contexts/AppContext';
|
||||||
|
|
||||||
import { t } from '../../../lang/helpers'
|
import { t } from '../../../lang/helpers'
|
||||||
|
|
||||||
function CopyButton({ message }: { message: string }) {
|
import RawMarkdownBlock from './RawMarkdownBlock'
|
||||||
|
|
||||||
|
export function CopyButton({ message }: { message: string }) {
|
||||||
const [copied, setCopied] = useState(false)
|
const [copied, setCopied] = useState(false)
|
||||||
|
|
||||||
const handleCopy = async () => {
|
const handleCopy = async () => {
|
||||||
@ -43,7 +43,7 @@ function CopyButton({ message }: { message: string }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function CreateNewFileButton({ message }: { message: string }) {
|
export function CreateNewFileButton({ message }: { message: string }) {
|
||||||
const app = useApp()
|
const app = useApp()
|
||||||
const [created, setCreated] = useState(false)
|
const [created, setCreated] = useState(false)
|
||||||
|
|
||||||
@ -138,12 +138,10 @@ const MarkdownWithIcons = ({
|
|||||||
<>
|
<>
|
||||||
<div className={`${className}`}>
|
<div className={`${className}`}>
|
||||||
<span>{iconName && renderIcon()} {renderTitle()}</span>
|
<span>{iconName && renderIcon()} {renderTitle()}</span>
|
||||||
<ReactMarkdown
|
<RawMarkdownBlock
|
||||||
|
content={markdownContent}
|
||||||
className={`${className}`}
|
className={`${className}`}
|
||||||
rehypePlugins={[rehypeRaw]}
|
/>
|
||||||
>
|
|
||||||
{markdownContent}
|
|
||||||
</ReactMarkdown>
|
|
||||||
</div>
|
</div>
|
||||||
{markdownContent && finish && iconName === "attempt_completion" &&
|
{markdownContent && finish && iconName === "attempt_completion" &&
|
||||||
<div className="infio-chat-message-actions">
|
<div className="infio-chat-message-actions">
|
||||||
|
|||||||
496
src/components/chat-view/Markdown/MermaidBlock.tsx
Normal file
496
src/components/chat-view/Markdown/MermaidBlock.tsx
Normal file
@ -0,0 +1,496 @@
|
|||||||
|
import { CopyIcon } from "lucide-react"
|
||||||
|
import mermaid from "mermaid"
|
||||||
|
import { memo, useEffect, useRef, useState } from "react"
|
||||||
|
import styled from "styled-components"
|
||||||
|
|
||||||
|
import { PREVIEW_VIEW_TYPE } from "../../../constants"
|
||||||
|
import { useApp } from "../../../contexts/AppContext"
|
||||||
|
import { useDarkModeContext } from "../../../contexts/DarkModeContext"
|
||||||
|
import { t } from '../../../lang/helpers'
|
||||||
|
import { PreviewView, PreviewViewState } from "../../../PreviewView"
|
||||||
|
import { useCopyToClipboard } from "../../../utils/clipboard"
|
||||||
|
import { useDebounceEffect } from "../../../utils/useDebounceEffect"
|
||||||
|
|
||||||
|
// Obsidian 暗色主题配置
|
||||||
|
const OBSIDIAN_DARK_THEME = {
|
||||||
|
background: "#202020",
|
||||||
|
textColor: "#dcddde",
|
||||||
|
mainBkg: "#2f3136",
|
||||||
|
nodeBorder: "#484b51",
|
||||||
|
lineColor: "#8e9297",
|
||||||
|
primaryColor: "#7289da",
|
||||||
|
primaryTextColor: "#ffffff",
|
||||||
|
primaryBorderColor: "#7289da",
|
||||||
|
secondaryColor: "#2f3136",
|
||||||
|
tertiaryColor: "#36393f",
|
||||||
|
|
||||||
|
// Class diagram specific
|
||||||
|
classText: "#dcddde",
|
||||||
|
|
||||||
|
// State diagram specific
|
||||||
|
labelColor: "#dcddde",
|
||||||
|
|
||||||
|
// Sequence diagram specific
|
||||||
|
actorLineColor: "#8e9297",
|
||||||
|
actorBkg: "#2f3136",
|
||||||
|
actorBorder: "#484b51",
|
||||||
|
actorTextColor: "#dcddde",
|
||||||
|
|
||||||
|
// Flow diagram specific
|
||||||
|
fillType0: "#2f3136",
|
||||||
|
fillType1: "#36393f",
|
||||||
|
fillType2: "#40444b",
|
||||||
|
}
|
||||||
|
|
||||||
|
// Obsidian 亮色主题配置
|
||||||
|
const OBSIDIAN_LIGHT_THEME = {
|
||||||
|
background: "#ffffff",
|
||||||
|
textColor: "#2e3338",
|
||||||
|
mainBkg: "#f6f6f6",
|
||||||
|
nodeBorder: "#d1d9e0",
|
||||||
|
lineColor: "#747f8d",
|
||||||
|
primaryColor: "#5865f2",
|
||||||
|
primaryTextColor: "#ffffff",
|
||||||
|
primaryBorderColor: "#5865f2",
|
||||||
|
secondaryColor: "#f6f6f6",
|
||||||
|
tertiaryColor: "#e3e5e8",
|
||||||
|
|
||||||
|
// Class diagram specific
|
||||||
|
classText: "#2e3338",
|
||||||
|
|
||||||
|
// State diagram specific
|
||||||
|
labelColor: "#2e3338",
|
||||||
|
|
||||||
|
// Sequence diagram specific
|
||||||
|
actorLineColor: "#747f8d",
|
||||||
|
actorBkg: "#f6f6f6",
|
||||||
|
actorBorder: "#d1d9e0",
|
||||||
|
actorTextColor: "#2e3338",
|
||||||
|
|
||||||
|
// Flow diagram specific
|
||||||
|
fillType0: "#f6f6f6",
|
||||||
|
fillType1: "#e3e5e8",
|
||||||
|
fillType2: "#dae0e6",
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MermaidBlockProps {
|
||||||
|
code: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MermaidToolbarProps {
|
||||||
|
code: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function MermaidToolbar({ code }: MermaidToolbarProps) {
|
||||||
|
const { showCopyFeedback, copyWithFeedback } = useCopyToClipboard()
|
||||||
|
|
||||||
|
const handleCopy = (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
// We wrap the code in a markdown block for easy pasting
|
||||||
|
copyWithFeedback("```mermaid\n" + code + "\n```")
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ToolbarContainer className="mermaid-toolbar">
|
||||||
|
<ToolbarButton className="mermaid-toolbar-btn" onClick={handleCopy} aria-label={t("common:copy_code")}>
|
||||||
|
<CopyIcon size={12} />
|
||||||
|
</ToolbarButton>
|
||||||
|
</ToolbarContainer>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MermaidButtonProps {
|
||||||
|
code: string
|
||||||
|
children: React.ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
function MermaidButton({ code, children }: MermaidButtonProps) {
|
||||||
|
return (
|
||||||
|
<MermaidWrapper>
|
||||||
|
{children}
|
||||||
|
<MermaidToolbar code={code} />
|
||||||
|
</MermaidWrapper>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function MermaidBlock({ code }: MermaidBlockProps) {
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [isErrorExpanded, setIsErrorExpanded] = useState(false)
|
||||||
|
const { showCopyFeedback, copyWithFeedback } = useCopyToClipboard()
|
||||||
|
|
||||||
|
const { isDarkMode } = useDarkModeContext()
|
||||||
|
const app = useApp()
|
||||||
|
|
||||||
|
// 根据主题模式初始化Mermaid配置
|
||||||
|
const initializeMermaid = (darkMode: boolean) => {
|
||||||
|
const currentTheme = darkMode ? OBSIDIAN_DARK_THEME : OBSIDIAN_LIGHT_THEME
|
||||||
|
|
||||||
|
mermaid.initialize({
|
||||||
|
startOnLoad: false,
|
||||||
|
securityLevel: "loose",
|
||||||
|
theme: darkMode ? "dark" : "default",
|
||||||
|
themeVariables: {
|
||||||
|
...currentTheme,
|
||||||
|
fontSize: "16px",
|
||||||
|
fontFamily: "system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif",
|
||||||
|
|
||||||
|
// Additional styling
|
||||||
|
noteTextColor: currentTheme.textColor,
|
||||||
|
noteBkgColor: currentTheme.tertiaryColor,
|
||||||
|
noteBorderColor: currentTheme.nodeBorder,
|
||||||
|
|
||||||
|
// Improve contrast for special elements
|
||||||
|
critBorderColor: darkMode ? "#ff9580" : "#dc2626",
|
||||||
|
critBkgColor: darkMode ? "#803d36" : "#fef2f2",
|
||||||
|
|
||||||
|
// Task diagram specific
|
||||||
|
taskTextColor: currentTheme.textColor,
|
||||||
|
taskTextOutsideColor: currentTheme.textColor,
|
||||||
|
taskTextLightColor: currentTheme.textColor,
|
||||||
|
|
||||||
|
// Numbers/sections
|
||||||
|
sectionBkgColor: currentTheme.mainBkg,
|
||||||
|
sectionBkgColor2: currentTheme.secondaryColor,
|
||||||
|
|
||||||
|
// Alt sections in sequence diagrams
|
||||||
|
altBackground: currentTheme.mainBkg,
|
||||||
|
|
||||||
|
// Links
|
||||||
|
linkColor: currentTheme.primaryColor,
|
||||||
|
|
||||||
|
// Borders and lines
|
||||||
|
compositeBackground: currentTheme.mainBkg,
|
||||||
|
compositeBorder: currentTheme.nodeBorder,
|
||||||
|
titleColor: currentTheme.textColor,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1) Whenever `code` or `isDarkMode` changes, mark that we need to re-render a new chart
|
||||||
|
useEffect(() => {
|
||||||
|
setIsLoading(true)
|
||||||
|
setError(null)
|
||||||
|
}, [code, isDarkMode])
|
||||||
|
|
||||||
|
// 2) Debounce the actual parse/render
|
||||||
|
useDebounceEffect(
|
||||||
|
() => {
|
||||||
|
if (containerRef.current) {
|
||||||
|
containerRef.current.innerHTML = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// 根据当前主题重新初始化Mermaid
|
||||||
|
initializeMermaid(isDarkMode)
|
||||||
|
|
||||||
|
mermaid
|
||||||
|
.parse(code)
|
||||||
|
.then(() => {
|
||||||
|
const id = `mermaid-${Math.random().toString(36).substring(2)}`
|
||||||
|
return mermaid.render(id, code)
|
||||||
|
})
|
||||||
|
.then(({ svg }) => {
|
||||||
|
if (containerRef.current) {
|
||||||
|
containerRef.current.innerHTML = svg
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((err: Error) => {
|
||||||
|
console.warn("Mermaid parse/render failed:", err)
|
||||||
|
setError(err.message || "Failed to render Mermaid diagram")
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setIsLoading(false)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
500, // Delay 500ms
|
||||||
|
[code, isDarkMode], // Dependencies for scheduling
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called when user clicks the rendered diagram.
|
||||||
|
* Opens the Mermaid diagram in a new preview tab.
|
||||||
|
*/
|
||||||
|
const handleClick = async () => {
|
||||||
|
if (!containerRef.current) return
|
||||||
|
const svgEl = containerRef.current.querySelector("svg")
|
||||||
|
if (!svgEl) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 获取当前主题背景色
|
||||||
|
const backgroundColor = isDarkMode ? OBSIDIAN_DARK_THEME.background : OBSIDIAN_LIGHT_THEME.background
|
||||||
|
|
||||||
|
// 创建一个包装器来包含 SVG 和样式
|
||||||
|
const svgHTML = `
|
||||||
|
<div style="
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
background-color: ${backgroundColor};
|
||||||
|
max-width: 100%;
|
||||||
|
">
|
||||||
|
${svgEl.outerHTML}
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
|
||||||
|
// 查找是否已经有相同内容的预览 tab
|
||||||
|
const existingLeaf = app.workspace
|
||||||
|
.getLeavesOfType(PREVIEW_VIEW_TYPE)
|
||||||
|
.find(
|
||||||
|
(leaf) =>
|
||||||
|
leaf.view instanceof PreviewView && leaf.view.state?.title === 'Mermaid 图表预览'
|
||||||
|
)
|
||||||
|
|
||||||
|
if (existingLeaf) {
|
||||||
|
// 如果已存在,关闭现有的然后重新创建以更新内容
|
||||||
|
// existingLeaf.detach()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建新的预览 tab
|
||||||
|
app.workspace.getLeaf(true).setViewState({
|
||||||
|
type: PREVIEW_VIEW_TYPE,
|
||||||
|
active: true,
|
||||||
|
state: {
|
||||||
|
content: svgHTML,
|
||||||
|
title: 'Mermaid 图表预览',
|
||||||
|
} satisfies PreviewViewState,
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Error opening Mermaid preview:", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy functionality handled directly through the copyWithFeedback utility
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MermaidBlockContainer>
|
||||||
|
{isLoading && <LoadingMessage>{t("common:mermaid.loading")}</LoadingMessage>}
|
||||||
|
|
||||||
|
{error ? (
|
||||||
|
<ErrorContainer>
|
||||||
|
<ErrorHeader
|
||||||
|
$isExpanded={isErrorExpanded}
|
||||||
|
onClick={() => setIsErrorExpanded(!isErrorExpanded)}>
|
||||||
|
<ErrorHeaderContent>
|
||||||
|
<WarningIcon className="codicon codicon-warning" />
|
||||||
|
<ErrorTitle>{t("common:mermaid.render_error")}</ErrorTitle>
|
||||||
|
</ErrorHeaderContent>
|
||||||
|
<ErrorHeaderActions>
|
||||||
|
<CopyButton
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
const combinedContent = `Error: ${error}\n\n\`\`\`mermaid\n${code}\n\`\`\``
|
||||||
|
copyWithFeedback(combinedContent, e)
|
||||||
|
}}>
|
||||||
|
<span className={`codicon codicon-${showCopyFeedback ? "check" : "copy"}`}></span>
|
||||||
|
</CopyButton>
|
||||||
|
<span className={`codicon codicon-chevron-${isErrorExpanded ? "up" : "down"}`}></span>
|
||||||
|
</ErrorHeaderActions>
|
||||||
|
</ErrorHeader>
|
||||||
|
{isErrorExpanded && (
|
||||||
|
<ErrorContent>
|
||||||
|
<ErrorMessage>{error}</ErrorMessage>
|
||||||
|
<code className="language-mermaid">{code}</code>
|
||||||
|
</ErrorContent>
|
||||||
|
)}
|
||||||
|
</ErrorContainer>
|
||||||
|
) : (
|
||||||
|
<MermaidButton code={code}>
|
||||||
|
<SvgContainer onClick={handleClick} ref={containerRef} $isLoading={isLoading} />
|
||||||
|
</MermaidButton>
|
||||||
|
)}
|
||||||
|
</MermaidBlockContainer>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const MermaidWrapper = styled.div`
|
||||||
|
position: relative;
|
||||||
|
margin: 8px 0;
|
||||||
|
|
||||||
|
&:hover .mermaid-toolbar {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mermaid-toolbar-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background-color: transparent !important;
|
||||||
|
border: none !important;
|
||||||
|
box-shadow: none !important;
|
||||||
|
color: var(--text-muted);
|
||||||
|
padding: 0 !important;
|
||||||
|
margin: 0 !important;
|
||||||
|
width: 24px !important;
|
||||||
|
height: 24px !important;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: var(--background-modifier-hover) !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
const ToolbarContainer = styled.div`
|
||||||
|
position: absolute;
|
||||||
|
top: 8px;
|
||||||
|
right: 8px;
|
||||||
|
z-index: 10;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.2s ease-in-out;
|
||||||
|
background-color: var(--background-secondary);
|
||||||
|
border: 1px solid var(--background-modifier-border);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 2px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
opacity: 1; /* Keep it visible when hovering over the toolbar itself */
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
const ToolbarButton = styled.button`
|
||||||
|
padding: 4px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
border-radius: 4px;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: var(--text-normal);
|
||||||
|
background-color: var(--background-modifier-hover);
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
const MermaidBlockContainer = styled.div`
|
||||||
|
position: relative;
|
||||||
|
`
|
||||||
|
|
||||||
|
const LoadingMessage = styled.div`
|
||||||
|
padding: 8px 0;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-style: italic;
|
||||||
|
font-size: 0.9em;
|
||||||
|
`
|
||||||
|
|
||||||
|
const ErrorContainer = styled.div`
|
||||||
|
margin-top: 0px;
|
||||||
|
overflow: hidden;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
`
|
||||||
|
|
||||||
|
interface ErrorHeaderProps {
|
||||||
|
$isExpanded: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const ErrorHeader = styled.div<ErrorHeaderProps>`
|
||||||
|
border-bottom: ${(props) => (props.$isExpanded ? "1px solid var(--background-modifier-border)" : "none")};
|
||||||
|
font-weight: normal;
|
||||||
|
font-size: var(--font-ui-small);
|
||||||
|
color: var(--text-normal);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
cursor: pointer;
|
||||||
|
`
|
||||||
|
|
||||||
|
const ErrorHeaderContent = styled.div`
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
flex-grow: 1;
|
||||||
|
`
|
||||||
|
|
||||||
|
const WarningIcon = styled.span`
|
||||||
|
color: var(--text-warning);
|
||||||
|
opacity: 0.8;
|
||||||
|
font-size: 16px;
|
||||||
|
margin-bottom: -1.5px;
|
||||||
|
`
|
||||||
|
|
||||||
|
const ErrorTitle = styled.span`
|
||||||
|
font-weight: bold;
|
||||||
|
`
|
||||||
|
|
||||||
|
const ErrorHeaderActions = styled.div`
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
`
|
||||||
|
|
||||||
|
const ErrorContent = styled.div`
|
||||||
|
padding: 8px;
|
||||||
|
background-color: var(--background-primary);
|
||||||
|
border-top: none;
|
||||||
|
`
|
||||||
|
|
||||||
|
const ErrorMessage = styled.div`
|
||||||
|
margin-bottom: 8px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
`
|
||||||
|
|
||||||
|
const CopyButton = styled.button`
|
||||||
|
padding: 3px;
|
||||||
|
height: 24px;
|
||||||
|
margin-right: 4px;
|
||||||
|
color: var(--text-normal);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
interface SvgContainerProps {
|
||||||
|
$isLoading: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const SvgContainer = styled.div<SvgContainerProps>`
|
||||||
|
opacity: ${(props) => (props.$isLoading ? 0.3 : 1)};
|
||||||
|
min-height: 20px;
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
max-height: 600px;
|
||||||
|
|
||||||
|
/* Ensure the SVG fills the container width and maintains aspect ratio */
|
||||||
|
& > svg {
|
||||||
|
display: block; /* Ensure block layout */
|
||||||
|
width: 100%;
|
||||||
|
max-height: 100%; /* Respect container's max-height */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hover effect to indicate clickability */
|
||||||
|
&:hover {
|
||||||
|
opacity: 0.8;
|
||||||
|
transform: scale(1.02);
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Click hint overlay */
|
||||||
|
&:hover::after {
|
||||||
|
content: '点击查看大图';
|
||||||
|
position: absolute;
|
||||||
|
bottom: 8px;
|
||||||
|
right: 8px;
|
||||||
|
background: rgba(0, 0, 0, 0.7);
|
||||||
|
color: white;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 0.9;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
export const MemoizedMermaidBlock = memo(MermaidBlock)
|
||||||
65
src/components/chat-view/Markdown/RawMarkdownBlock.tsx
Normal file
65
src/components/chat-view/Markdown/RawMarkdownBlock.tsx
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import ReactMarkdown from 'react-markdown'
|
||||||
|
import rehypeRaw from 'rehype-raw'
|
||||||
|
import remarkGfm from 'remark-gfm'
|
||||||
|
|
||||||
|
import { useDarkModeContext } from '../../../contexts/DarkModeContext'
|
||||||
|
|
||||||
|
import { MemoizedMermaidBlock } from './MermaidBlock'
|
||||||
|
import { MemoizedSyntaxHighlighterWrapper } from './SyntaxHighlighterWrapper'
|
||||||
|
|
||||||
|
interface RawMarkdownBlockProps {
|
||||||
|
content: string
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RawMarkdownBlock({
|
||||||
|
content,
|
||||||
|
className = "infio-markdown",
|
||||||
|
}: RawMarkdownBlockProps) {
|
||||||
|
const {isDarkMode} = useDarkModeContext()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ReactMarkdown
|
||||||
|
className={className}
|
||||||
|
remarkPlugins={[remarkGfm]}
|
||||||
|
rehypePlugins={[rehypeRaw]}
|
||||||
|
components={{
|
||||||
|
code({ className, children, ...props }) {
|
||||||
|
const match = /language-(\w+)/.exec(className || '')
|
||||||
|
const language = match ? match[1] : undefined
|
||||||
|
const isInline = !className
|
||||||
|
|
||||||
|
// Mermaid 图表渲染
|
||||||
|
if (!isInline && language === 'mermaid') {
|
||||||
|
const codeText = String(children || "")
|
||||||
|
return (
|
||||||
|
<MemoizedMermaidBlock
|
||||||
|
code={codeText}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 代码块使用语法高亮
|
||||||
|
if (!isInline && language) {
|
||||||
|
return (
|
||||||
|
<MemoizedSyntaxHighlighterWrapper
|
||||||
|
isDarkMode={isDarkMode}
|
||||||
|
language={language}
|
||||||
|
hasFilename={false}
|
||||||
|
wrapLines={true}
|
||||||
|
>
|
||||||
|
{String(children).replace(/\n$/, '')}
|
||||||
|
</MemoizedSyntaxHighlighterWrapper>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 内联代码使用原生样式
|
||||||
|
return <code {...props}>{children}</code>
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</ReactMarkdown>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -1,4 +1,4 @@
|
|||||||
import { AlertTriangle, ChevronDown, ChevronRight, FileText, Folder, Power, RotateCcw, Trash2, Wrench } from 'lucide-react'
|
import { AlertTriangle, ChevronDown, ChevronRight, ExternalLink, FileText, Folder, Power, RotateCcw, Trash2, Wrench } from 'lucide-react'
|
||||||
import { Notice } from 'obsidian'
|
import { Notice } from 'obsidian'
|
||||||
import React, { useEffect, useState } from 'react'
|
import React, { useEffect, useState } from 'react'
|
||||||
|
|
||||||
@ -108,6 +108,17 @@ const McpHubView = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleOpenConfigFile = async () => {
|
||||||
|
const hub = await getMcpHub();
|
||||||
|
if (hub) {
|
||||||
|
try {
|
||||||
|
await hub.openMcpSettingsFile();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to open config file:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const toggleServerExpansion = (serverKey: string) => {
|
const toggleServerExpansion = (serverKey: string) => {
|
||||||
setExpandedServers(prev => ({ ...prev, [serverKey]: !prev[serverKey] }));
|
setExpandedServers(prev => ({ ...prev, [serverKey]: !prev[serverKey] }));
|
||||||
if (!expandedServers[serverKey] && !activeServerDetailTab[serverKey]) {
|
if (!expandedServers[serverKey] && !activeServerDetailTab[serverKey]) {
|
||||||
@ -196,7 +207,15 @@ const McpHubView = () => {
|
|||||||
<div className="infio-mcp-hub-container">
|
<div className="infio-mcp-hub-container">
|
||||||
{/* Header Section */}
|
{/* Header Section */}
|
||||||
<div className="infio-mcp-hub-header">
|
<div className="infio-mcp-hub-header">
|
||||||
<h2 className="infio-mcp-hub-title">{t('mcpHub.title')}</h2>
|
<h3 className="infio-mcp-hub-title">{t('mcpHub.title')}</h3>
|
||||||
|
<div className="infio-mcp-hub-actions">
|
||||||
|
<button
|
||||||
|
onClick={fetchServers}
|
||||||
|
className="obsidian-insight-refresh-btn"
|
||||||
|
>
|
||||||
|
<RotateCcw size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* MCP Settings */}
|
{/* MCP Settings */}
|
||||||
@ -218,6 +237,15 @@ const McpHubView = () => {
|
|||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Configuration File Access */}
|
||||||
|
<button
|
||||||
|
onClick={handleOpenConfigFile}
|
||||||
|
className="infio-mcp-config-button"
|
||||||
|
>
|
||||||
|
<ExternalLink size={16} />
|
||||||
|
<span>{t('mcpHub.openConfigFile')}</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Create New Server Section */}
|
{/* Create New Server Section */}
|
||||||
@ -273,6 +301,11 @@ const McpHubView = () => {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
mcpServers.map(server => {
|
mcpServers.map(server => {
|
||||||
|
// Add null check for server object
|
||||||
|
if (!server || !server.name) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const serverKey = `${server.name}-${server.source || 'global'}`;
|
const serverKey = `${server.name}-${server.source || 'global'}`;
|
||||||
const isExpanded = !!expandedServers[serverKey];
|
const isExpanded = !!expandedServers[serverKey];
|
||||||
const currentDetailTab = activeServerDetailTab[serverKey] || 'tools';
|
const currentDetailTab = activeServerDetailTab[serverKey] || 'tools';
|
||||||
@ -285,7 +318,7 @@ const McpHubView = () => {
|
|||||||
{isExpanded ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
|
{isExpanded ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
|
||||||
</div>
|
</div>
|
||||||
<span className={`infio-mcp-hub-status-indicator ${server.status === 'connected' ? 'connected' : server.status === 'connecting' ? 'connecting' : 'disconnected'} ${server.disabled ? 'disabled' : ''}`}></span>
|
<span className={`infio-mcp-hub-status-indicator ${server.status === 'connected' ? 'connected' : server.status === 'connecting' ? 'connecting' : 'disconnected'} ${server.disabled ? 'disabled' : ''}`}></span>
|
||||||
<h3 className="infio-mcp-hub-name">{server.name.replace('infio-builtin-server', 'builtin')}</h3>
|
<h3 className="infio-mcp-hub-name">{server.name ? server.name.replace('infio-builtin-server', 'builtin') : 'Unknown Server'}</h3>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="infio-mcp-hub-actions" onClick={(e) => e.stopPropagation()}>
|
<div className="infio-mcp-hub-actions" onClick={(e) => e.stopPropagation()}>
|
||||||
@ -352,7 +385,7 @@ const McpHubView = () => {
|
|||||||
<div className="infio-mcp-tab-content">
|
<div className="infio-mcp-tab-content">
|
||||||
{currentDetailTab === 'tools' && (
|
{currentDetailTab === 'tools' && (
|
||||||
<div className="infio-mcp-tools-list">
|
<div className="infio-mcp-tools-list">
|
||||||
{(server.tools && server.tools.length > 0) ? server.tools.map(tool => <ToolRow key={tool.name} tool={tool} />) : <p className="infio-mcp-empty-message">{t('mcpHub.noTools')}</p>}
|
{(server.tools && server.tools.length > 0) ? server.tools.filter(tool => tool && tool.name).map(tool => <ToolRow key={tool.name} tool={tool} />) : <p className="infio-mcp-empty-message">{t('mcpHub.noTools')}</p>}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{currentDetailTab === 'resources' && (
|
{currentDetailTab === 'resources' && (
|
||||||
@ -443,6 +476,57 @@ const McpHubView = () => {
|
|||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.infio-mcp-hub-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--size-2-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.obsidian-insight-refresh-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background-color: transparent !important;
|
||||||
|
border: none !important;
|
||||||
|
box-shadow: none !important;
|
||||||
|
color: var(--text-muted);
|
||||||
|
padding: 0 !important;
|
||||||
|
margin: 0 !important;
|
||||||
|
width: 24px !important;
|
||||||
|
height: 24px !important;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: var(--background-modifier-hover) !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.obsidian-insight-refresh-btn:hover:not(:disabled) {
|
||||||
|
background-color: var(--interactive-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.infio-mcp-config-button {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
background-color: var(--interactive-normal);
|
||||||
|
color: var(--text-normal);
|
||||||
|
border: 1px solid var(--background-modifier-border);
|
||||||
|
border-radius: var(--radius-s);
|
||||||
|
padding: 8px 16px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.infio-mcp-config-button:hover {
|
||||||
|
background-color: var(--interactive-hover);
|
||||||
|
border-color: var(--interactive-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.infio-mcp-config-button:active {
|
||||||
|
transform: translateY(1px);
|
||||||
|
}
|
||||||
|
|
||||||
/* Search Section */
|
/* Search Section */
|
||||||
.infio-mcp-search-section {
|
.infio-mcp-search-section {
|
||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
|
|||||||
@ -1,5 +1,4 @@
|
|||||||
import React, { useMemo } from 'react'
|
import React, { useMemo } from 'react'
|
||||||
import Markdown from 'react-markdown'
|
|
||||||
|
|
||||||
import { ApplyStatus, ToolArgs } from '../../types/apply'
|
import { ApplyStatus, ToolArgs } from '../../types/apply'
|
||||||
import {
|
import {
|
||||||
@ -8,19 +7,23 @@ import {
|
|||||||
} from '../../utils/parse-infio-block'
|
} from '../../utils/parse-infio-block'
|
||||||
|
|
||||||
import MarkdownApplyDiffBlock from './Markdown/MarkdownApplyDiffBlock'
|
import MarkdownApplyDiffBlock from './Markdown/MarkdownApplyDiffBlock'
|
||||||
|
import MarkdownDataviewQueryBlock from './Markdown/MarkdownDataviewQueryBlock'
|
||||||
import MarkdownEditFileBlock from './Markdown/MarkdownEditFileBlock'
|
import MarkdownEditFileBlock from './Markdown/MarkdownEditFileBlock'
|
||||||
import MarkdownFetchUrlsContentBlock from './Markdown/MarkdownFetchUrlsContentBlock'
|
import MarkdownFetchUrlsContentBlock from './Markdown/MarkdownFetchUrlsContentBlock'
|
||||||
import MarkdownListFilesBlock from './Markdown/MarkdownListFilesBlock'
|
import MarkdownListFilesBlock from './Markdown/MarkdownListFilesBlock'
|
||||||
|
import MarkdownManageFilesBlock from './Markdown/MarkdownManageFilesBlock'
|
||||||
|
import MarkdownMatchSearchFilesBlock from './Markdown/MarkdownMatchSearchFilesBlock'
|
||||||
import MarkdownReadFileBlock from './Markdown/MarkdownReadFileBlock'
|
import MarkdownReadFileBlock from './Markdown/MarkdownReadFileBlock'
|
||||||
import MarkdownReasoningBlock from './Markdown/MarkdownReasoningBlock'
|
import MarkdownReasoningBlock from './Markdown/MarkdownReasoningBlock'
|
||||||
import MarkdownMatchSearchFilesBlock from './Markdown/MarkdownMatchSearchFilesBlock'
|
|
||||||
import MarkdownRegexSearchFilesBlock from './Markdown/MarkdownRegexSearchFilesBlock'
|
import MarkdownRegexSearchFilesBlock from './Markdown/MarkdownRegexSearchFilesBlock'
|
||||||
import MarkdownSearchAndReplace from './Markdown/MarkdownSearchAndReplace'
|
import MarkdownSearchAndReplace from './Markdown/MarkdownSearchAndReplace'
|
||||||
import MarkdownSearchWebBlock from './Markdown/MarkdownSearchWebBlock'
|
import MarkdownSearchWebBlock from './Markdown/MarkdownSearchWebBlock'
|
||||||
import MarkdownSemanticSearchFilesBlock from './Markdown/MarkdownSemanticSearchFilesBlock'
|
import MarkdownSemanticSearchFilesBlock from './Markdown/MarkdownSemanticSearchFilesBlock'
|
||||||
import MarkdownSwitchModeBlock from './Markdown/MarkdownSwitchModeBlock'
|
import MarkdownSwitchModeBlock from './Markdown/MarkdownSwitchModeBlock'
|
||||||
import MarkdownToolResult from './Markdown/MarkdownToolResult'
|
import MarkdownToolResult from './Markdown/MarkdownToolResult'
|
||||||
|
import MarkdownTransformationToolBlock from './Markdown/MarkdownTransformationToolBlock'
|
||||||
import MarkdownWithIcons from './Markdown/MarkdownWithIcon'
|
import MarkdownWithIcons from './Markdown/MarkdownWithIcon'
|
||||||
|
import RawMarkdownBlock from './Markdown/RawMarkdownBlock'
|
||||||
import UseMcpToolBlock from './Markdown/UseMcpToolBlock'
|
import UseMcpToolBlock from './Markdown/UseMcpToolBlock'
|
||||||
|
|
||||||
function ReactMarkdown({
|
function ReactMarkdown({
|
||||||
@ -41,15 +44,16 @@ function ReactMarkdown({
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{blocks.map((block, index) =>
|
{blocks.map((block, index) =>
|
||||||
block.type === 'thinking' ? (
|
block.type === 'think' ? (
|
||||||
<Markdown key={"markdown-" + index} className="infio-markdown">
|
|
||||||
{block.content}
|
|
||||||
</Markdown>
|
|
||||||
) : block.type === 'think' ? (
|
|
||||||
<MarkdownReasoningBlock
|
<MarkdownReasoningBlock
|
||||||
key={"reasoning-" + index}
|
key={"reasoning-" + index}
|
||||||
reasoningContent={block.content}
|
reasoningContent={block.content}
|
||||||
/>
|
/>
|
||||||
|
) : block.type === 'thinking' ? (
|
||||||
|
<RawMarkdownBlock
|
||||||
|
key={"plan-" + index}
|
||||||
|
content={block.content}
|
||||||
|
/>
|
||||||
) : block.type === 'write_to_file' ? (
|
) : block.type === 'write_to_file' ? (
|
||||||
<MarkdownEditFileBlock
|
<MarkdownEditFileBlock
|
||||||
key={"write-to-file-" + index}
|
key={"write-to-file-" + index}
|
||||||
@ -200,15 +204,44 @@ function ReactMarkdown({
|
|||||||
parameters={block.parameters}
|
parameters={block.parameters}
|
||||||
finish={block.finish}
|
finish={block.finish}
|
||||||
/>
|
/>
|
||||||
|
) : block.type === 'dataview_query' ? (
|
||||||
|
<MarkdownDataviewQueryBlock
|
||||||
|
key={"dataview-query-" + index}
|
||||||
|
applyStatus={applyStatus}
|
||||||
|
onApply={onApply}
|
||||||
|
query={block.query}
|
||||||
|
outputFormat={block.outputFormat}
|
||||||
|
finish={block.finish}
|
||||||
|
/>
|
||||||
|
) : block.type === 'call_transformations' ? (
|
||||||
|
<MarkdownTransformationToolBlock
|
||||||
|
key={"call-transformations-" + index}
|
||||||
|
applyStatus={applyStatus}
|
||||||
|
onApply={onApply}
|
||||||
|
toolType="call_transformations"
|
||||||
|
path={block.path}
|
||||||
|
transformation={block.transformation}
|
||||||
|
finish={block.finish}
|
||||||
|
/>
|
||||||
|
) : block.type === 'manage_files' ? (
|
||||||
|
<MarkdownManageFilesBlock
|
||||||
|
key={"manage-files-" + index}
|
||||||
|
applyStatus={applyStatus}
|
||||||
|
onApply={onApply}
|
||||||
|
operations={block.operations}
|
||||||
|
finish={block.finish}
|
||||||
|
/>
|
||||||
) : block.type === 'tool_result' ? (
|
) : block.type === 'tool_result' ? (
|
||||||
<MarkdownToolResult
|
<MarkdownToolResult
|
||||||
key={"tool-result-" + index}
|
key={"tool-result-" + index}
|
||||||
content={block.content}
|
content={block.content}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Markdown key={"markdown-" + index} className="infio-markdown">
|
<RawMarkdownBlock
|
||||||
{block.content}
|
key={"markdown-" + index}
|
||||||
</Markdown>
|
content={block.content}
|
||||||
|
className="infio-markdown"
|
||||||
|
/>
|
||||||
),
|
),
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -49,25 +49,25 @@ const UserMessageView: React.FC<UserMessageViewProps> = ({
|
|||||||
<span key={index} className="infio-mention-tag">
|
<span key={index} className="infio-mention-tag">
|
||||||
{Icon && <Icon size={12} />}
|
{Icon && <Icon size={12} />}
|
||||||
{mentionable.type === 'current-file' && (
|
{mentionable.type === 'current-file' && (
|
||||||
<span>{mentionable.file.name}</span>
|
<span>{mentionable.file?.name || 'Not Found'}</span>
|
||||||
)}
|
)}
|
||||||
{mentionable.type === 'vault' && (
|
{mentionable.type === 'vault' && (
|
||||||
<span>Vault</span>
|
<span>Vault</span>
|
||||||
)}
|
)}
|
||||||
{mentionable.type === 'block' && (
|
{mentionable.type === 'block' && (
|
||||||
<span>{mentionable.file.name}</span>
|
<span>{mentionable.file?.name || 'Not Found'}</span>
|
||||||
)}
|
)}
|
||||||
{mentionable.type === 'file' && (
|
{mentionable.type === 'file' && (
|
||||||
<span>{mentionable.file.name}</span>
|
<span>{mentionable.file?.name || 'Not Found'}</span>
|
||||||
)}
|
)}
|
||||||
{mentionable.type === 'folder' && (
|
{mentionable.type === 'folder' && (
|
||||||
<span>{mentionable.folder.name}</span>
|
<span>{mentionable.folder?.name || 'Not Found'}</span>
|
||||||
)}
|
)}
|
||||||
{mentionable.type === 'url' && (
|
{mentionable.type === 'url' && (
|
||||||
<span>{mentionable.url}</span>
|
<span>{mentionable.url}</span>
|
||||||
)}
|
)}
|
||||||
{mentionable.type === 'image' && (
|
{mentionable.type === 'image' && (
|
||||||
<span>{mentionable.name}</span>
|
<span>{mentionable.name || 'Image'}</span>
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
@ -120,7 +120,6 @@ const UserMessageView: React.FC<UserMessageViewProps> = ({
|
|||||||
border: 2px solid var(--background-modifier-border);
|
border: 2px solid var(--background-modifier-border);
|
||||||
border-radius: var(--radius-s);
|
border-radius: var(--radius-s);
|
||||||
padding: calc(var(--size-2-2) + 1px);
|
padding: calc(var(--size-2-2) + 1px);
|
||||||
min-height: 62px;
|
|
||||||
gap: var(--size-2-2);
|
gap: var(--size-2-2);
|
||||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||||
transition: all 0.15s ease-in-out;
|
transition: all 0.15s ease-in-out;
|
||||||
|
|||||||
843
src/components/chat-view/WorkspaceEditModal.tsx
Normal file
843
src/components/chat-view/WorkspaceEditModal.tsx
Normal file
@ -0,0 +1,843 @@
|
|||||||
|
import { ChevronDown, FolderOpen, Plus, Tag, Trash2, X } from 'lucide-react'
|
||||||
|
import { App, TFolder } from 'obsidian'
|
||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
|
||||||
|
import { Workspace, WorkspaceContent } from '../../database/json/workspace/types'
|
||||||
|
import { t } from '../../lang/helpers'
|
||||||
|
|
||||||
|
interface WorkspaceEditModalProps {
|
||||||
|
workspace?: Workspace
|
||||||
|
app: App
|
||||||
|
isOpen: boolean
|
||||||
|
onClose: () => void
|
||||||
|
onSave: (updatedWorkspace: Partial<Workspace>) => Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
const WorkspaceEditModal = ({
|
||||||
|
workspace,
|
||||||
|
app,
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
onSave
|
||||||
|
}: WorkspaceEditModalProps) => {
|
||||||
|
// 生成默认工作区名称
|
||||||
|
const getDefaultWorkspaceName = (): string => {
|
||||||
|
const now = new Date()
|
||||||
|
const date = `${now.getFullYear()}-${(now.getMonth() + 1).toString().padStart(2, '0')}-${now.getDate().toString().padStart(2, '0')}`
|
||||||
|
return String(t('workspace.editModal.defaultName', { date }))
|
||||||
|
}
|
||||||
|
|
||||||
|
const [name, setName] = useState(workspace?.name || getDefaultWorkspaceName())
|
||||||
|
const [content, setContent] = useState<WorkspaceContent[]>(workspace?.content ? [...workspace.content] : [])
|
||||||
|
const [availableFolders, setAvailableFolders] = useState<string[]>([])
|
||||||
|
const [availableTags, setAvailableTags] = useState<string[]>([])
|
||||||
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
|
|
||||||
|
// 智能添加相关状态
|
||||||
|
const [inputValue, setInputValue] = useState('')
|
||||||
|
const [showSuggestions, setShowSuggestions] = useState(false)
|
||||||
|
const [filteredSuggestions, setFilteredSuggestions] = useState<{type: 'folder' | 'tag', value: string}[]>([])
|
||||||
|
const [selectedSuggestionIndex, setSelectedSuggestionIndex] = useState(-1)
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null)
|
||||||
|
const suggestionsRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
// 获取可用的文件夹和标签
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) return
|
||||||
|
|
||||||
|
const loadAvailableOptions = async () => {
|
||||||
|
// 获取所有文件夹
|
||||||
|
const folders: string[] = []
|
||||||
|
const addFolder = (folder: TFolder) => {
|
||||||
|
folders.push(folder.path)
|
||||||
|
}
|
||||||
|
|
||||||
|
app.vault.getAllFolders(false).forEach(folder => {
|
||||||
|
addFolder(folder)
|
||||||
|
})
|
||||||
|
|
||||||
|
setAvailableFolders(folders.sort())
|
||||||
|
|
||||||
|
// 直接使用 Obsidian 的内置接口获取所有标签
|
||||||
|
try {
|
||||||
|
// @ts-ignore
|
||||||
|
const tagsObject = app.metadataCache.getTags() as Record<string, number> // 获取所有标签 {'#tag1': 2, '#tag2': 4}
|
||||||
|
const tags = Object.keys(tagsObject).sort()
|
||||||
|
setAvailableTags(tags)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取标签失败:', error)
|
||||||
|
setAvailableTags([])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadAvailableOptions()
|
||||||
|
}, [isOpen, app])
|
||||||
|
|
||||||
|
// 重置表单
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
setName(workspace?.name || getDefaultWorkspaceName())
|
||||||
|
setContent(workspace?.content ? [...workspace.content] : [])
|
||||||
|
}
|
||||||
|
}, [isOpen, workspace])
|
||||||
|
|
||||||
|
// 更新建议列表
|
||||||
|
useEffect(() => {
|
||||||
|
if (!inputValue.trim()) {
|
||||||
|
setFilteredSuggestions([])
|
||||||
|
setShowSuggestions(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const suggestions: {type: 'folder' | 'tag', value: string}[] = []
|
||||||
|
const searchTerm = inputValue.toLowerCase()
|
||||||
|
|
||||||
|
// 搜索匹配的文件夹
|
||||||
|
availableFolders.forEach(folder => {
|
||||||
|
if (folder.toLowerCase().includes(searchTerm)) {
|
||||||
|
// 检查是否已存在
|
||||||
|
const exists = content.some(item =>
|
||||||
|
item.type === 'folder' && item.content === folder
|
||||||
|
)
|
||||||
|
if (!exists) {
|
||||||
|
suggestions.push({ type: 'folder', value: folder })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 搜索匹配的标签
|
||||||
|
availableTags.forEach(tag => {
|
||||||
|
// 改善搜索匹配逻辑,支持中文和更灵活的匹配
|
||||||
|
const tagForSearch = tag.toLowerCase()
|
||||||
|
const shouldMatch = searchTerm.startsWith('#')
|
||||||
|
? tagForSearch.includes(searchTerm.toLowerCase()) // 如果搜索词以#开头,直接匹配
|
||||||
|
: tagForSearch.includes(searchTerm) || tagForSearch.includes(`#${searchTerm}`) // 否则同时匹配带#和不带#的情况
|
||||||
|
|
||||||
|
if (shouldMatch) {
|
||||||
|
// 检查是否已存在
|
||||||
|
const exists = content.some(item =>
|
||||||
|
item.type === 'tag' && item.content === tag
|
||||||
|
)
|
||||||
|
if (!exists) {
|
||||||
|
suggestions.push({ type: 'tag', value: tag })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 如果输入以#开头,优先显示标签建议
|
||||||
|
if (inputValue.startsWith('#')) {
|
||||||
|
suggestions.sort((a, b) => {
|
||||||
|
if (a.type === 'tag' && b.type !== 'tag') return -1
|
||||||
|
if (a.type !== 'tag' && b.type === 'tag') return 1
|
||||||
|
return 0
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
// 否则优先显示文件夹建议
|
||||||
|
suggestions.sort((a, b) => {
|
||||||
|
if (a.type === 'folder' && b.type !== 'folder') return -1
|
||||||
|
if (a.type !== 'folder' && b.type === 'folder') return 1
|
||||||
|
return 0
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
setFilteredSuggestions(suggestions.slice(0, 20)) // 限制显示数量
|
||||||
|
setShowSuggestions(suggestions.length > 0)
|
||||||
|
setSelectedSuggestionIndex(-1)
|
||||||
|
}, [inputValue, availableFolders, availableTags, content])
|
||||||
|
|
||||||
|
// 添加内容项
|
||||||
|
const addContentItem = (type: 'folder' | 'tag', contentValue: string) => {
|
||||||
|
if (!contentValue.trim()) return
|
||||||
|
|
||||||
|
// 检查是否已存在
|
||||||
|
const exists = content.some(item =>
|
||||||
|
item.type === type && item.content === contentValue
|
||||||
|
)
|
||||||
|
|
||||||
|
if (exists) return
|
||||||
|
|
||||||
|
const newItem: WorkspaceContent = {
|
||||||
|
type,
|
||||||
|
content: contentValue
|
||||||
|
}
|
||||||
|
|
||||||
|
setContent([...content, newItem])
|
||||||
|
|
||||||
|
// 清空输入框和建议
|
||||||
|
setInputValue('')
|
||||||
|
setShowSuggestions(false)
|
||||||
|
setSelectedSuggestionIndex(-1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理建议选择
|
||||||
|
const handleSuggestionSelect = (suggestion: {type: 'folder' | 'tag', value: string}) => {
|
||||||
|
addContentItem(suggestion.type, suggestion.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理手动添加
|
||||||
|
const handleManualAdd = () => {
|
||||||
|
const value = inputValue.trim()
|
||||||
|
if (!value) return
|
||||||
|
|
||||||
|
// 自动判断类型
|
||||||
|
const type = value.startsWith('#') ? 'tag' : 'folder'
|
||||||
|
addContentItem(type, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理键盘事件
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
|
if (!showSuggestions) {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
handleManualAdd()
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (e.key) {
|
||||||
|
case 'ArrowDown':
|
||||||
|
e.preventDefault()
|
||||||
|
setSelectedSuggestionIndex(prev =>
|
||||||
|
prev < filteredSuggestions.length - 1 ? prev + 1 : prev
|
||||||
|
)
|
||||||
|
break
|
||||||
|
case 'ArrowUp':
|
||||||
|
e.preventDefault()
|
||||||
|
setSelectedSuggestionIndex(prev => prev > 0 ? prev - 1 : -1)
|
||||||
|
break
|
||||||
|
case 'Enter':
|
||||||
|
e.preventDefault()
|
||||||
|
if (selectedSuggestionIndex >= 0 && selectedSuggestionIndex < filteredSuggestions.length) {
|
||||||
|
handleSuggestionSelect(filteredSuggestions[selectedSuggestionIndex])
|
||||||
|
} else {
|
||||||
|
handleManualAdd()
|
||||||
|
}
|
||||||
|
break
|
||||||
|
case 'Escape':
|
||||||
|
setShowSuggestions(false)
|
||||||
|
setSelectedSuggestionIndex(-1)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 点击外部关闭建议
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
|
const target = event.target
|
||||||
|
if (
|
||||||
|
target instanceof Node &&
|
||||||
|
inputRef.current &&
|
||||||
|
!inputRef.current.contains(target) &&
|
||||||
|
suggestionsRef.current &&
|
||||||
|
!suggestionsRef.current.contains(target)
|
||||||
|
) {
|
||||||
|
setShowSuggestions(false)
|
||||||
|
setSelectedSuggestionIndex(-1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('mousedown', handleClickOutside)
|
||||||
|
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// 删除内容项
|
||||||
|
const removeContentItem = (index: number) => {
|
||||||
|
setContent(content.filter((_, i) => i !== index))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存更改
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!name.trim()) {
|
||||||
|
alert(t('workspace.editModal.nameRequired'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoading(true)
|
||||||
|
try {
|
||||||
|
await onSave({
|
||||||
|
name: name.trim(),
|
||||||
|
content
|
||||||
|
})
|
||||||
|
onClose()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('保存工作区失败:', error)
|
||||||
|
alert(t('workspace.editModal.saveFailed'))
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isOpen) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="workspace-edit-modal-overlay">
|
||||||
|
<div className="workspace-edit-modal">
|
||||||
|
{/* 头部 */}
|
||||||
|
<div className="workspace-edit-modal-header">
|
||||||
|
<h3>{workspace ? t('workspace.editModal.editTitle') : t('workspace.editModal.createTitle')}</h3>
|
||||||
|
<button
|
||||||
|
className="workspace-edit-modal-close"
|
||||||
|
onClick={onClose}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
<X size={20} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 内容 */}
|
||||||
|
<div className="workspace-edit-modal-content">
|
||||||
|
{/* 工作区名称 */}
|
||||||
|
<div className="workspace-edit-section">
|
||||||
|
<label className="workspace-edit-label">{t('workspace.editModal.nameLabel')}</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
className="workspace-edit-input"
|
||||||
|
placeholder={workspace ? t('workspace.editModal.namePlaceholder') : t('workspace.editModal.newNamePlaceholder')}
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 工作区内容 */}
|
||||||
|
<div className="workspace-edit-section">
|
||||||
|
<label className="workspace-edit-label">{t('workspace.editModal.contentLabel')}</label>
|
||||||
|
|
||||||
|
{/* 当前内容列表 */}
|
||||||
|
<div className="workspace-content-list">
|
||||||
|
{content.map((item, index) => (
|
||||||
|
<div key={index} className="workspace-content-item">
|
||||||
|
<div className="workspace-content-item-info">
|
||||||
|
{item.type === 'folder' ? (
|
||||||
|
<FolderOpen size={16} />
|
||||||
|
) : (
|
||||||
|
<Tag size={16} />
|
||||||
|
)}
|
||||||
|
<span className="workspace-content-item-text">
|
||||||
|
{item.content}
|
||||||
|
</span>
|
||||||
|
<span className="workspace-content-item-type">
|
||||||
|
({item.type === 'folder' ? t('workspace.editModal.folder') : t('workspace.editModal.tag')})
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="workspace-content-item-remove"
|
||||||
|
onClick={() => removeContentItem(index)}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
<Trash2 size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{content.length === 0 && (
|
||||||
|
<div className="workspace-content-empty">
|
||||||
|
{t('workspace.editModal.noContent')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 智能添加 - 作为内容列表的一部分 */}
|
||||||
|
<div className="workspace-smart-add-item">
|
||||||
|
<div className="workspace-smart-add-container">
|
||||||
|
<div className={`workspace-smart-input-wrapper ${showSuggestions ? 'has-suggestions' : ''}`}>
|
||||||
|
<Plus size={16} className="workspace-smart-add-icon" />
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="text"
|
||||||
|
value={inputValue}
|
||||||
|
onChange={(e) => setInputValue(e.target.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
onFocus={() => {
|
||||||
|
if (filteredSuggestions.length > 0) {
|
||||||
|
setShowSuggestions(true)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder={t('workspace.editModal.addPlaceholder')}
|
||||||
|
className="workspace-smart-input"
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
{showSuggestions && (
|
||||||
|
<ChevronDown
|
||||||
|
size={16}
|
||||||
|
className="workspace-smart-dropdown-icon workspace-smart-dropdown-icon-up"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 建议下拉列表 */}
|
||||||
|
{showSuggestions && filteredSuggestions.length > 0 && (
|
||||||
|
<div ref={suggestionsRef} className="workspace-suggestions">
|
||||||
|
{filteredSuggestions.map((suggestion, index) => (
|
||||||
|
<div
|
||||||
|
key={`${suggestion.type}-${suggestion.value}`}
|
||||||
|
className={`workspace-suggestion-item ${
|
||||||
|
index === selectedSuggestionIndex ? 'selected' : ''
|
||||||
|
}`}
|
||||||
|
onClick={() => handleSuggestionSelect(suggestion)}
|
||||||
|
onMouseEnter={() => setSelectedSuggestionIndex(index)}
|
||||||
|
>
|
||||||
|
<div className="workspace-suggestion-content">
|
||||||
|
{suggestion.type === 'folder' ? (
|
||||||
|
<FolderOpen size={14} />
|
||||||
|
) : (
|
||||||
|
<Tag size={14} />
|
||||||
|
)}
|
||||||
|
<span className="workspace-suggestion-text">
|
||||||
|
{suggestion.value}
|
||||||
|
</span>
|
||||||
|
<span className="workspace-suggestion-type">
|
||||||
|
{suggestion.type === 'folder' ? t('workspace.editModal.folder') : t('workspace.editModal.tag')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="workspace-smart-add-tip">
|
||||||
|
{t('workspace.editModal.tip')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 底部按钮 */}
|
||||||
|
<div className="workspace-edit-modal-footer">
|
||||||
|
<button
|
||||||
|
className="workspace-edit-btn workspace-edit-btn-cancel"
|
||||||
|
onClick={onClose}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
{t('workspace.editModal.cancel')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="workspace-edit-btn workspace-edit-btn-save"
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
{isLoading
|
||||||
|
? (workspace ? t('workspace.editModal.saving') : t('workspace.editModal.creating'))
|
||||||
|
: (workspace ? t('workspace.editModal.save') : t('workspace.editModal.create'))
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 样式 */}
|
||||||
|
<style>
|
||||||
|
{`
|
||||||
|
.workspace-edit-modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background-color: rgba(0, 0, 0, 0.5);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-edit-modal {
|
||||||
|
background-color: var(--background-primary);
|
||||||
|
border: 1px solid var(--background-modifier-border);
|
||||||
|
border-radius: var(--radius-m);
|
||||||
|
width: 90%;
|
||||||
|
max-width: 600px;
|
||||||
|
max-height: 80vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-edit-modal-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 16px 20px;
|
||||||
|
border-bottom: 1px solid var(--background-modifier-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-edit-modal-header h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-edit-modal-close {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 4px;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--text-muted);
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-edit-modal-close:hover:not(:disabled) {
|
||||||
|
background-color: var(--background-modifier-hover);
|
||||||
|
color: var(--text-normal);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-edit-modal-close:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-edit-modal-content {
|
||||||
|
flex: 1;
|
||||||
|
padding: 20px;
|
||||||
|
overflow-y: auto;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-edit-section {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-edit-label {
|
||||||
|
display: block;
|
||||||
|
font-weight: 500;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
color: var(--text-normal);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-edit-input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: 1px solid var(--background-modifier-border);
|
||||||
|
border-radius: var(--radius-s);
|
||||||
|
background-color: var(--background-primary);
|
||||||
|
color: var(--text-normal);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-edit-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--text-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-edit-input:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-content-list {
|
||||||
|
border: 1px solid var(--background-modifier-border);
|
||||||
|
border-radius: var(--radius-s);
|
||||||
|
margin-bottom: 2px;
|
||||||
|
max-height: 200px;
|
||||||
|
overflow-y: auto;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-content-list::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-content-list::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-content-list::-webkit-scrollbar-thumb {
|
||||||
|
background-color: var(--background-modifier-border);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-content-list::-webkit-scrollbar-thumb:hover {
|
||||||
|
background-color: var(--background-modifier-border-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-content-item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-bottom: 1px solid var(--background-modifier-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-content-item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-content-item-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-content-item-text {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-content-item-type {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-content-item-remove {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 4px;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--text-error);
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-content-item-remove:hover:not(:disabled) {
|
||||||
|
background-color: var(--background-modifier-error);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-content-item-remove:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-content-empty {
|
||||||
|
padding: 20px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-smart-add-item {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-smart-add-container {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-smart-input-wrapper {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
border: 1px solid var(--background-modifier-border);
|
||||||
|
border-radius: var(--radius-s);
|
||||||
|
background-color: var(--background-primary);
|
||||||
|
padding: 8px 12px;
|
||||||
|
gap: 8px;
|
||||||
|
transition: border-color 0.2s ease, box-shadow 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-smart-input-wrapper:hover {
|
||||||
|
border-color: var(--background-modifier-border-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-smart-input-wrapper:focus-within {
|
||||||
|
border-color: var(--text-accent);
|
||||||
|
box-shadow: 0 0 0 2px rgba(var(--text-accent-rgb), 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-smart-input-wrapper.has-suggestions {
|
||||||
|
border-radius: 0 0 var(--radius-s) var(--radius-s);
|
||||||
|
border-top-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-smart-input-wrapper.has-suggestions:focus-within {
|
||||||
|
border-radius: 0 0 var(--radius-s) var(--radius-s);
|
||||||
|
border-top-color: var(--text-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-smart-add-container:focus-within .workspace-suggestions {
|
||||||
|
border-color: var(--text-accent);
|
||||||
|
box-shadow: 0 -4px 12px rgba(0, 0, 0, 0.1), 0 0 0 2px rgba(var(--text-accent-rgb), 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-smart-input {
|
||||||
|
flex: 1;
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-normal);
|
||||||
|
font-size: 14px;
|
||||||
|
outline: none;
|
||||||
|
min-height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-smart-input:focus {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-smart-input:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-smart-input::placeholder {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-smart-add-icon {
|
||||||
|
color: var(--text-muted);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-smart-dropdown-icon {
|
||||||
|
color: var(--text-muted);
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-smart-dropdown-icon-up {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
.workspace-suggestions {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 100%;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
background-color: var(--background-primary);
|
||||||
|
border: 1px solid var(--background-modifier-border);
|
||||||
|
border-bottom: none;
|
||||||
|
border-radius: var(--radius-s) var(--radius-s) 0 0;
|
||||||
|
box-shadow: 0 -4px 12px rgba(0, 0, 0, 0.1);
|
||||||
|
z-index: 1000;
|
||||||
|
max-height: 160px;
|
||||||
|
overflow-y: auto;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
margin-bottom: -1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-suggestions::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-suggestions::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-suggestions::-webkit-scrollbar-thumb {
|
||||||
|
background-color: var(--background-modifier-border);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-suggestions::-webkit-scrollbar-thumb:hover {
|
||||||
|
background-color: var(--background-modifier-border-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-suggestion-item {
|
||||||
|
padding: 10px 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
border-bottom: 1px solid var(--background-modifier-border);
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-suggestion-item:first-child {
|
||||||
|
border-radius: var(--radius-s) var(--radius-s) 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-suggestion-item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-suggestion-item:hover,
|
||||||
|
.workspace-suggestion-item.selected {
|
||||||
|
background-color: var(--background-modifier-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-suggestion-item.selected {
|
||||||
|
background-color: var(--background-modifier-active-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-suggestion-content {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-suggestion-text {
|
||||||
|
flex: 1;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-normal);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-suggestion-type {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
background-color: var(--background-secondary);
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: calc(var(--radius-s) - 1px);
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-weight: 500;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-suggestion-item:hover .workspace-suggestion-type,
|
||||||
|
.workspace-suggestion-item.selected .workspace-suggestion-type {
|
||||||
|
background-color: var(--background-modifier-border);
|
||||||
|
color: var(--text-normal);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-smart-add-tip {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
line-height: 1.4;
|
||||||
|
margin-top: 8px;
|
||||||
|
padding: 0 2px;
|
||||||
|
background-color: var(--background-secondary-alt);
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: var(--radius-s);
|
||||||
|
border-left: 2px solid var(--text-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-edit-modal-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 16px 20px;
|
||||||
|
border-top: 1px solid var(--background-modifier-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-edit-btn {
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-radius: var(--radius-s);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-edit-btn:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-edit-btn-cancel {
|
||||||
|
background-color: var(--background-secondary);
|
||||||
|
border: 1px solid var(--background-modifier-border);
|
||||||
|
color: var(--text-normal);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-edit-btn-cancel:hover:not(:disabled) {
|
||||||
|
background-color: var(--background-modifier-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-edit-btn-save {
|
||||||
|
background-color: var(--text-accent);
|
||||||
|
border: 1px solid var(--text-accent);
|
||||||
|
color: var(--text-on-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-edit-btn-save:hover:not(:disabled) {
|
||||||
|
background-color: var(--text-accent-hover);
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
</style>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default WorkspaceEditModal
|
||||||
295
src/components/chat-view/WorkspaceSelect.tsx
Normal file
295
src/components/chat-view/WorkspaceSelect.tsx
Normal file
@ -0,0 +1,295 @@
|
|||||||
|
import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
|
||||||
|
import { Check, ChevronDown, ChevronUp } from 'lucide-react'
|
||||||
|
import { Notice } from 'obsidian'
|
||||||
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
|
|
||||||
|
import { useApp } from '../../contexts/AppContext'
|
||||||
|
import { useSettings } from '../../contexts/SettingsContext'
|
||||||
|
import { Workspace } from '../../database/json/workspace/types'
|
||||||
|
import { WorkspaceManager } from '../../database/json/workspace/WorkspaceManager'
|
||||||
|
|
||||||
|
interface WorkspaceInfo extends Workspace {
|
||||||
|
isCurrent: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const WorkspaceSelect = () => {
|
||||||
|
const app = useApp()
|
||||||
|
const { settings, setSettings } = useSettings()
|
||||||
|
const [workspaces, setWorkspaces] = useState<WorkspaceInfo[]>([])
|
||||||
|
const [isOpen, setIsOpen] = useState(false)
|
||||||
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
|
const [workspaceManager, setWorkspaceManager] = useState<WorkspaceManager | null>(null)
|
||||||
|
|
||||||
|
// 初始化工作区管理器
|
||||||
|
useEffect(() => {
|
||||||
|
const manager = new WorkspaceManager(app)
|
||||||
|
setWorkspaceManager(manager)
|
||||||
|
}, [app])
|
||||||
|
|
||||||
|
// 获取当前工作区名称
|
||||||
|
const getCurrentWorkspaceName = () => {
|
||||||
|
return settings.workspace || 'vault'
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取工作区列表
|
||||||
|
const getWorkspaces = useCallback(async () => {
|
||||||
|
if (!workspaceManager) return []
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 确保默认 vault 工作区存在
|
||||||
|
await workspaceManager.ensureDefaultVaultWorkspace()
|
||||||
|
|
||||||
|
// 获取所有工作区
|
||||||
|
const workspaceMetadata = await workspaceManager.listWorkspaces()
|
||||||
|
|
||||||
|
const workspaceList: WorkspaceInfo[] = []
|
||||||
|
const currentWorkspaceName = getCurrentWorkspaceName()
|
||||||
|
|
||||||
|
for (const meta of workspaceMetadata) {
|
||||||
|
const workspace = await workspaceManager.findById(meta.id)
|
||||||
|
if (workspace) {
|
||||||
|
workspaceList.push({
|
||||||
|
...workspace,
|
||||||
|
isCurrent: workspace.name === currentWorkspaceName,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 按名称排序,vault 排在最前面
|
||||||
|
workspaceList.sort((a, b) => {
|
||||||
|
if (a.name === 'vault') return -1
|
||||||
|
if (b.name === 'vault') return 1
|
||||||
|
return a.name.localeCompare(b.name)
|
||||||
|
})
|
||||||
|
|
||||||
|
return workspaceList
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取工作区列表失败:', error)
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}, [workspaceManager, settings.workspace])
|
||||||
|
|
||||||
|
// 刷新工作区列表
|
||||||
|
const refreshWorkspaces = useCallback(async () => {
|
||||||
|
setIsLoading(true)
|
||||||
|
try {
|
||||||
|
const workspaceList = await getWorkspaces()
|
||||||
|
setWorkspaces(workspaceList)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('刷新工作区列表失败:', error)
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false)
|
||||||
|
}
|
||||||
|
}, [getWorkspaces])
|
||||||
|
|
||||||
|
// 切换到指定工作区
|
||||||
|
const switchToWorkspace = async (workspace: WorkspaceInfo) => {
|
||||||
|
if (workspace.isCurrent) {
|
||||||
|
setIsOpen(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 更新设置中的工作区
|
||||||
|
setSettings({
|
||||||
|
...settings,
|
||||||
|
workspace: workspace.name
|
||||||
|
})
|
||||||
|
|
||||||
|
// 关闭下拉菜单
|
||||||
|
setIsOpen(false)
|
||||||
|
|
||||||
|
// 刷新工作区列表以更新状态
|
||||||
|
await refreshWorkspaces()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('切换工作区失败:', error)
|
||||||
|
new Notice('切换工作区失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 初始化和设置变化时刷新
|
||||||
|
useEffect(() => {
|
||||||
|
refreshWorkspaces()
|
||||||
|
}, [refreshWorkspaces])
|
||||||
|
|
||||||
|
// 下拉菜单打开时刷新数据
|
||||||
|
const handleOpenChange = (open: boolean) => {
|
||||||
|
if (open && !isOpen) {
|
||||||
|
refreshWorkspaces()
|
||||||
|
}
|
||||||
|
setIsOpen(open)
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentWorkspace = workspaces.find(w => w.isCurrent)
|
||||||
|
const displayName = currentWorkspace?.name || getCurrentWorkspaceName()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<DropdownMenu.Root open={isOpen} onOpenChange={handleOpenChange}>
|
||||||
|
<DropdownMenu.Trigger className="infio-workspace-select">
|
||||||
|
<span className="infio-workspace-select__name">
|
||||||
|
{displayName}
|
||||||
|
</span>
|
||||||
|
<div className="infio-workspace-select__icon">
|
||||||
|
{isOpen ? <ChevronUp size={12} /> : <ChevronDown size={12} />}
|
||||||
|
</div>
|
||||||
|
</DropdownMenu.Trigger>
|
||||||
|
|
||||||
|
<DropdownMenu.Portal>
|
||||||
|
<DropdownMenu.Content className="infio-popover infio-workspace-select-content">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="infio-workspace-loading">
|
||||||
|
加载中...
|
||||||
|
</div>
|
||||||
|
) : workspaces.length === 0 ? (
|
||||||
|
<div className="infio-workspace-empty">
|
||||||
|
暂无工作区
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ul>
|
||||||
|
{workspaces.map((workspace) => (
|
||||||
|
<DropdownMenu.Item
|
||||||
|
key={workspace.id}
|
||||||
|
onSelect={() => switchToWorkspace(workspace)}
|
||||||
|
asChild
|
||||||
|
>
|
||||||
|
<li className={`infio-workspace-item`}>
|
||||||
|
<span className="infio-workspace-item-name">
|
||||||
|
{workspace.name}
|
||||||
|
</span>
|
||||||
|
{workspace.isCurrent && (
|
||||||
|
<Check size={14} className="infio-workspace-check" />
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</DropdownMenu.Content>
|
||||||
|
</DropdownMenu.Portal>
|
||||||
|
</DropdownMenu.Root>
|
||||||
|
|
||||||
|
<style>{`
|
||||||
|
button.infio-workspace-select {
|
||||||
|
background-color: var(--background-modifier-hover);
|
||||||
|
box-shadow: none;
|
||||||
|
border: none;
|
||||||
|
padding: var(--size-4-1) var(--size-4-3);
|
||||||
|
font-size: var(--font-small);
|
||||||
|
font-weight: var(--font-medium);
|
||||||
|
color: var(--text-muted);
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-start;
|
||||||
|
align-items: center;
|
||||||
|
cursor: pointer;
|
||||||
|
height: auto;
|
||||||
|
max-width: 100%;
|
||||||
|
gap: var(--size-2-2);
|
||||||
|
border-radius: var(--radius-l);
|
||||||
|
transition: all 0.15s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.infio-workspace-select:hover {
|
||||||
|
color: var(--text-normal);
|
||||||
|
background-color: var(--background-modifier-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
button.infio-workspace-select:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.infio-workspace-select__name {
|
||||||
|
flex-shrink: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
flex-grow: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.infio-workspace-select__icon {
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.infio-workspace-select-content {
|
||||||
|
min-width: auto !important;
|
||||||
|
width: fit-content !important;
|
||||||
|
max-width: 200px;
|
||||||
|
max-height: 200px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.infio-workspace-loading,
|
||||||
|
.infio-workspace-empty {
|
||||||
|
padding: var(--size-4-3) var(--size-4-2);
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: var(--font-small);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.infio-workspace-item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
padding: var(--size-4-2) var(--size-4-2);
|
||||||
|
white-space: nowrap;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.infio-workspace-item-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--size-2-1);
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.infio-workspace-item-name {
|
||||||
|
font-size: var(--font-small);
|
||||||
|
font-weight: 500;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.infio-workspace-item-info {
|
||||||
|
font-size: var(--font-smallest);
|
||||||
|
color: var(--text-muted);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.infio-workspace-check {
|
||||||
|
color: var(--text-accent);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 滚动条样式 */
|
||||||
|
.infio-workspace-select-content::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.infio-workspace-select-content::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.infio-workspace-select-content::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--background-modifier-border);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.infio-workspace-select-content::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: var(--background-modifier-border-hover);
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default WorkspaceSelect
|
||||||
907
src/components/chat-view/WorkspaceView.tsx
Normal file
907
src/components/chat-view/WorkspaceView.tsx
Normal file
@ -0,0 +1,907 @@
|
|||||||
|
import {
|
||||||
|
ArrowRight,
|
||||||
|
Box,
|
||||||
|
ChevronDown,
|
||||||
|
ChevronRight,
|
||||||
|
FolderOpen,
|
||||||
|
MessageSquare,
|
||||||
|
Pencil,
|
||||||
|
Plus,
|
||||||
|
RotateCcw,
|
||||||
|
Tag,
|
||||||
|
Trash2
|
||||||
|
} from 'lucide-react'
|
||||||
|
import { Notice } from 'obsidian'
|
||||||
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
|
|
||||||
|
import { useApp } from '../../contexts/AppContext'
|
||||||
|
import { useSettings } from '../../contexts/SettingsContext'
|
||||||
|
import { Workspace, WorkspaceContent } from '../../database/json/workspace/types'
|
||||||
|
import { WorkspaceManager } from '../../database/json/workspace/WorkspaceManager'
|
||||||
|
import { t } from '../../lang/helpers'
|
||||||
|
|
||||||
|
import WorkspaceEditModal from './WorkspaceEditModal'
|
||||||
|
|
||||||
|
interface WorkspaceInfo extends Workspace {
|
||||||
|
isCurrent: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const WorkspaceView = () => {
|
||||||
|
const app = useApp()
|
||||||
|
const { settings, setSettings } = useSettings()
|
||||||
|
const [workspaces, setWorkspaces] = useState<WorkspaceInfo[]>([])
|
||||||
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
|
const [workspaceManager, setWorkspaceManager] = useState<WorkspaceManager | null>(null)
|
||||||
|
const [editingWorkspace, setEditingWorkspace] = useState<Workspace | null>(null)
|
||||||
|
const [isEditModalOpen, setIsEditModalOpen] = useState(false)
|
||||||
|
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false)
|
||||||
|
|
||||||
|
// 初始化工作区管理器
|
||||||
|
useEffect(() => {
|
||||||
|
const manager = new WorkspaceManager(app)
|
||||||
|
setWorkspaceManager(manager)
|
||||||
|
}, [app])
|
||||||
|
|
||||||
|
// 获取当前工作区名称
|
||||||
|
const getCurrentWorkspaceName = (): string => {
|
||||||
|
return settings.workspace || 'vault'
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取工作区列表
|
||||||
|
const getWorkspaces = useCallback(async (): Promise<WorkspaceInfo[]> => {
|
||||||
|
if (!workspaceManager) return []
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 确保默认 vault 工作区存在
|
||||||
|
await workspaceManager.ensureDefaultVaultWorkspace()
|
||||||
|
|
||||||
|
// 获取所有工作区
|
||||||
|
const workspaceMetadata = await workspaceManager.listWorkspaces()
|
||||||
|
|
||||||
|
const workspaceList: WorkspaceInfo[] = []
|
||||||
|
const currentWorkspaceName = getCurrentWorkspaceName()
|
||||||
|
|
||||||
|
for (const meta of workspaceMetadata) {
|
||||||
|
const workspace = await workspaceManager.findById(meta.id)
|
||||||
|
if (workspace) {
|
||||||
|
workspaceList.push({
|
||||||
|
...workspace,
|
||||||
|
isCurrent: workspace.name === currentWorkspaceName
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return workspaceList
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取工作区列表失败:', error)
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}, [workspaceManager, settings.workspace])
|
||||||
|
|
||||||
|
// 刷新工作区列表
|
||||||
|
const refreshWorkspaces = useCallback(async () => {
|
||||||
|
setIsLoading(true)
|
||||||
|
try {
|
||||||
|
const workspaceList = await getWorkspaces()
|
||||||
|
setWorkspaces(workspaceList)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('刷新工作区列表失败:', error)
|
||||||
|
new Notice(String(t('workspace.notices.refreshFailed')))
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false)
|
||||||
|
}
|
||||||
|
}, [getWorkspaces])
|
||||||
|
|
||||||
|
// 切换到指定工作区
|
||||||
|
const switchToWorkspace = async (workspace: WorkspaceInfo) => {
|
||||||
|
if (workspace.isCurrent) {
|
||||||
|
new Notice(String(t('workspace.notices.alreadyInWorkspace')))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 更新设置中的工作区
|
||||||
|
setSettings({
|
||||||
|
...settings,
|
||||||
|
workspace: workspace.name
|
||||||
|
})
|
||||||
|
|
||||||
|
// 刷新工作区列表以更新状态
|
||||||
|
await refreshWorkspaces()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('切换工作区失败:', error)
|
||||||
|
new Notice(String(t('workspace.notices.switchFailed')))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除工作区
|
||||||
|
const deleteWorkspace = async (workspace: WorkspaceInfo) => {
|
||||||
|
if (!workspaceManager) return
|
||||||
|
|
||||||
|
if (workspace.isCurrent) {
|
||||||
|
new Notice(String(t('workspace.notices.cannotDeleteCurrent')))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (workspace.name === 'vault') {
|
||||||
|
new Notice(String(t('workspace.notices.cannotDeleteDefault')))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const success = await workspaceManager.deleteWorkspace(workspace.id)
|
||||||
|
if (success) {
|
||||||
|
new Notice(String(t('workspace.notices.deleted', { name: workspace.name })))
|
||||||
|
await refreshWorkspaces()
|
||||||
|
} else {
|
||||||
|
new Notice(String(t('workspace.notices.deleteFailed')))
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('删除工作区失败:', error)
|
||||||
|
new Notice(String(t('workspace.notices.deleteFailed')))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建新工作区
|
||||||
|
const createNewWorkspace = () => {
|
||||||
|
setIsCreateModalOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 关闭创建模态框
|
||||||
|
const closeCreateModal = () => {
|
||||||
|
setIsCreateModalOpen(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存新工作区
|
||||||
|
const saveNewWorkspace = async (workspaceData: Partial<Workspace>) => {
|
||||||
|
if (!workspaceManager) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const newWorkspace = await workspaceManager.createWorkspace({
|
||||||
|
name: workspaceData.name || String(t('workspace.newWorkspace')),
|
||||||
|
content: workspaceData.content || [],
|
||||||
|
metadata: {
|
||||||
|
description: workspaceData.metadata?.description || String(t('workspace.newWorkspace'))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
new Notice(String(t('workspace.notices.created', { name: newWorkspace.name })))
|
||||||
|
await refreshWorkspaces()
|
||||||
|
closeCreateModal()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('创建工作区失败:', error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 打开编辑工作区模态框
|
||||||
|
const openEditModal = (workspace: WorkspaceInfo) => {
|
||||||
|
setEditingWorkspace(workspace)
|
||||||
|
setIsEditModalOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 关闭编辑模态框
|
||||||
|
const closeEditModal = () => {
|
||||||
|
setIsEditModalOpen(false)
|
||||||
|
setEditingWorkspace(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存工作区编辑
|
||||||
|
const saveWorkspaceEdit = async (updates: Partial<Workspace>) => {
|
||||||
|
if (!workspaceManager || !editingWorkspace) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
await workspaceManager.updateWorkspace(editingWorkspace.id, updates)
|
||||||
|
new Notice(String(t('workspace.notices.updated', { name: updates.name || editingWorkspace.name })))
|
||||||
|
await refreshWorkspaces()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('更新工作区失败:', error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 格式化工作区内容
|
||||||
|
const formatWorkspaceContent = (content: WorkspaceContent[]): string => {
|
||||||
|
if (content.length === 0) return String(t('workspace.empty'))
|
||||||
|
|
||||||
|
const folders = content.filter(c => c.type === 'folder').length
|
||||||
|
const tags = content.filter(c => c.type === 'tag').length
|
||||||
|
|
||||||
|
const parts = []
|
||||||
|
if (folders > 0) parts.push(`${folders} ${String(t('workspace.folders'))}`)
|
||||||
|
if (tags > 0) parts.push(`${tags} ${String(t('workspace.tags'))}`)
|
||||||
|
|
||||||
|
return parts.join(', ') || String(t('workspace.noContent'))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 展开状态管理
|
||||||
|
const [expandedWorkspaces, setExpandedWorkspaces] = useState<Set<string>>(new Set())
|
||||||
|
const [expandedChats, setExpandedChats] = useState<Set<string>>(new Set())
|
||||||
|
|
||||||
|
// 切换工作区内容展开状态
|
||||||
|
const toggleWorkspaceExpanded = (workspaceId: string) => {
|
||||||
|
const newExpanded = new Set(expandedWorkspaces)
|
||||||
|
if (newExpanded.has(workspaceId)) {
|
||||||
|
newExpanded.delete(workspaceId)
|
||||||
|
} else {
|
||||||
|
newExpanded.add(workspaceId)
|
||||||
|
}
|
||||||
|
setExpandedWorkspaces(newExpanded)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 切换对话历史展开状态
|
||||||
|
const toggleChatExpanded = (workspaceId: string) => {
|
||||||
|
const newExpanded = new Set(expandedChats)
|
||||||
|
if (newExpanded.has(workspaceId)) {
|
||||||
|
newExpanded.delete(workspaceId)
|
||||||
|
} else {
|
||||||
|
newExpanded.add(workspaceId)
|
||||||
|
}
|
||||||
|
setExpandedChats(newExpanded)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 格式化时间
|
||||||
|
const formatLastOpened = (timestamp?: number) => {
|
||||||
|
if (!timestamp) return '未知'
|
||||||
|
|
||||||
|
const now = Date.now()
|
||||||
|
const diff = now - timestamp
|
||||||
|
const minutes = Math.floor(diff / 60000)
|
||||||
|
const hours = Math.floor(diff / 3600000)
|
||||||
|
const days = Math.floor(diff / 86400000)
|
||||||
|
|
||||||
|
if (minutes < 1) return '刚刚'
|
||||||
|
if (minutes < 60) return `${minutes} 分钟前`
|
||||||
|
if (hours < 24) return `${hours} 小时前`
|
||||||
|
if (days < 7) return `${days} 天前`
|
||||||
|
|
||||||
|
return new Date(timestamp).toLocaleDateString('zh-CN', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 组件初始化
|
||||||
|
useEffect(() => {
|
||||||
|
refreshWorkspaces().catch((error) => {
|
||||||
|
console.error('初始化工作区列表失败:', error)
|
||||||
|
})
|
||||||
|
}, [refreshWorkspaces])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="infio-workspace-view-container">
|
||||||
|
{/* 头部 */}
|
||||||
|
<div className="infio-workspace-view-header">
|
||||||
|
<div className="infio-workspace-view-title">
|
||||||
|
<h2>{t('workspace.title')}</h2>
|
||||||
|
</div>
|
||||||
|
<div className="infio-workspace-view-header-actions">
|
||||||
|
<button
|
||||||
|
onClick={refreshWorkspaces}
|
||||||
|
className="infio-workspace-view-refresh-btn"
|
||||||
|
disabled={isLoading}
|
||||||
|
title={t('workspace.refreshTooltip')}
|
||||||
|
>
|
||||||
|
<RotateCcw size={16} className={isLoading ? 'spinning' : ''} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 描述 */}
|
||||||
|
<div className="infio-workspace-view-tip">
|
||||||
|
{t('workspace.description')}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 创建新工作区按钮 */}
|
||||||
|
<div className="infio-workspace-view-create-action">
|
||||||
|
<button
|
||||||
|
className="infio-workspace-view-create-btn"
|
||||||
|
onClick={createNewWorkspace}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
<Plus size={16} />
|
||||||
|
<span>{t('workspace.createNew')}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 工作区列表 */}
|
||||||
|
<div className="infio-workspace-view-list">
|
||||||
|
<div className="infio-workspace-view-list-header">
|
||||||
|
<h3>{t('workspace.recentWorkspaces')}</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="infio-workspace-view-loading">
|
||||||
|
{t('workspace.loading')}
|
||||||
|
</div>
|
||||||
|
) : workspaces.length === 0 ? (
|
||||||
|
<div className="infio-workspace-view-empty">
|
||||||
|
<Box size={48} className="infio-workspace-view-empty-icon" />
|
||||||
|
<p>{t('workspace.noWorkspaces')}</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="infio-workspace-view-items">
|
||||||
|
{workspaces.map((workspace, index) => (
|
||||||
|
<div
|
||||||
|
key={workspace.id || index}
|
||||||
|
className={`infio-workspace-view-item ${workspace.isCurrent ? 'current' : ''}`}
|
||||||
|
>
|
||||||
|
<div className="infio-workspace-view-item-header">
|
||||||
|
<div className="infio-workspace-view-item-icon">
|
||||||
|
{workspace.isCurrent ? (
|
||||||
|
<Box size={20} />
|
||||||
|
) : (
|
||||||
|
<Box size={20} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="infio-workspace-view-item-name">
|
||||||
|
{workspace.name}
|
||||||
|
{workspace.isCurrent && (
|
||||||
|
<span className="infio-workspace-view-current-badge">{String(t('workspace.current'))}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="infio-workspace-view-item-actions">
|
||||||
|
{!workspace.isCurrent && (
|
||||||
|
<button
|
||||||
|
onClick={() => switchToWorkspace(workspace)}
|
||||||
|
className="infio-workspace-view-action-btn switch-btn"
|
||||||
|
title="切换到此工作区"
|
||||||
|
>
|
||||||
|
<ArrowRight size={16} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{workspace.name !== 'vault' && (
|
||||||
|
<button
|
||||||
|
onClick={() => openEditModal(workspace)}
|
||||||
|
className="infio-workspace-view-action-btn"
|
||||||
|
title={String(t('workspace.editTooltip'))}
|
||||||
|
>
|
||||||
|
<Pencil size={16} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{!workspace.isCurrent && workspace.name !== 'vault' && (
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
if (confirm(String(t('workspace.deleteConfirm', { name: workspace.name })))) {
|
||||||
|
deleteWorkspace(workspace)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="infio-workspace-view-action-btn danger"
|
||||||
|
title={String(t('workspace.deleteTooltip'))}
|
||||||
|
>
|
||||||
|
<Trash2 size={16} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="infio-workspace-view-item-content">
|
||||||
|
{/* 工作区内容 */}
|
||||||
|
<div
|
||||||
|
className="infio-workspace-view-item-path clickable"
|
||||||
|
onClick={() => toggleWorkspaceExpanded(workspace.id)}
|
||||||
|
>
|
||||||
|
<div className="infio-workspace-view-item-path-info">
|
||||||
|
<FolderOpen size={12} />
|
||||||
|
{formatWorkspaceContent(workspace.content)}
|
||||||
|
</div>
|
||||||
|
{workspace.content.length > 0 && (
|
||||||
|
<div className="infio-workspace-view-expand-icon">
|
||||||
|
{expandedWorkspaces.has(workspace.id) ? (
|
||||||
|
<ChevronDown size={14} />
|
||||||
|
) : (
|
||||||
|
<ChevronRight size={14} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 展开的内容详情 */}
|
||||||
|
{expandedWorkspaces.has(workspace.id) && workspace.content.length > 0 && (
|
||||||
|
<div className="infio-workspace-view-content-details">
|
||||||
|
<div className="infio-workspace-view-content-list">
|
||||||
|
{workspace.content.map((item, itemIndex) => (
|
||||||
|
<div key={itemIndex} className="infio-workspace-view-content-item">
|
||||||
|
{item.type === 'folder' ? (
|
||||||
|
<FolderOpen size={14} />
|
||||||
|
) : (
|
||||||
|
<Tag size={14} />
|
||||||
|
)}
|
||||||
|
<span className="infio-workspace-view-content-text">
|
||||||
|
{item.content}
|
||||||
|
</span>
|
||||||
|
<span className="infio-workspace-view-content-type">
|
||||||
|
{item.type === 'folder' ? '文件夹' : '标签'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 对话历史 */}
|
||||||
|
<div
|
||||||
|
className="infio-workspace-view-chat-info clickable"
|
||||||
|
onClick={() => toggleChatExpanded(workspace.id)}
|
||||||
|
>
|
||||||
|
<div className="infio-workspace-view-chat-info-content">
|
||||||
|
<MessageSquare size={12} />
|
||||||
|
<span>{workspace.chatHistory.length} {String(t('workspace.conversations'))}</span>
|
||||||
|
</div>
|
||||||
|
{workspace.chatHistory.length > 0 && (
|
||||||
|
<div className="infio-workspace-view-expand-icon">
|
||||||
|
{expandedChats.has(workspace.id) ? (
|
||||||
|
<ChevronDown size={14} />
|
||||||
|
) : (
|
||||||
|
<ChevronRight size={14} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 展开的对话历史详情 */}
|
||||||
|
{expandedChats.has(workspace.id) && workspace.chatHistory.length > 0 && (
|
||||||
|
<div className="infio-workspace-view-chat-details">
|
||||||
|
<div className="infio-workspace-view-chat-list">
|
||||||
|
{workspace.chatHistory.slice(-5).reverse().map((chat, chatIndex) => (
|
||||||
|
<div key={chatIndex} className="infio-workspace-view-chat-item">
|
||||||
|
<MessageSquare size={14} />
|
||||||
|
<span className="infio-workspace-view-chat-title">
|
||||||
|
{chat.title || `对话 ${chat.id.slice(0, 8)}`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="infio-workspace-view-item-meta">
|
||||||
|
{String(t('workspace.created'))}: {new Date(workspace.createdAt).toLocaleDateString('zh-CN')} |
|
||||||
|
{String(t('workspace.updated'))}: {formatLastOpened(workspace.updatedAt)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 编辑模态框 */}
|
||||||
|
{editingWorkspace && (
|
||||||
|
<WorkspaceEditModal
|
||||||
|
workspace={editingWorkspace}
|
||||||
|
app={app}
|
||||||
|
isOpen={isEditModalOpen}
|
||||||
|
onClose={closeEditModal}
|
||||||
|
onSave={saveWorkspaceEdit}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 创建工作区模态框 */}
|
||||||
|
<WorkspaceEditModal
|
||||||
|
workspace={undefined}
|
||||||
|
app={app}
|
||||||
|
isOpen={isCreateModalOpen}
|
||||||
|
onClose={closeCreateModal}
|
||||||
|
onSave={saveNewWorkspace}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 样式 */}
|
||||||
|
<style>
|
||||||
|
{`
|
||||||
|
.infio-workspace-view-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 16px;
|
||||||
|
gap: 16px;
|
||||||
|
color: var(--text-normal);
|
||||||
|
height: 100%;
|
||||||
|
overflow-y: auto;
|
||||||
|
scrollbar-width: none;
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.infio-workspace-view-container::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.infio-workspace-view-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 40px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.infio-workspace-view-title h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 24px;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.infio-workspace-view-header-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.infio-workspace-view-refresh-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background-color: transparent !important;
|
||||||
|
border: none !important;
|
||||||
|
box-shadow: none !important;
|
||||||
|
color: var(--text-muted);
|
||||||
|
padding: 0 !important;
|
||||||
|
margin: 0 !important;
|
||||||
|
width: 24px !important;
|
||||||
|
height: 24px !important;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: var(--background-modifier-hover) !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinning {
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
from { transform: rotate(0deg); }
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.infio-workspace-view-tip {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 14px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.infio-workspace-view-create-action {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.infio-workspace-view-create-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
background-color: var(--background-primary);
|
||||||
|
border: 1px solid var(--background-modifier-border);
|
||||||
|
color: var(--text-normal);
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-radius: var(--radius-m);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
width: 100%;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.infio-workspace-view-create-btn:hover:not(:disabled) {
|
||||||
|
background-color: var(--background-modifier-hover);
|
||||||
|
border-color: var(--text-accent);
|
||||||
|
color: var(--text-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.infio-workspace-view-create-btn:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.infio-workspace-view-current {
|
||||||
|
background-color: var(--background-secondary);
|
||||||
|
border: 1px solid var(--background-modifier-border);
|
||||||
|
border-radius: var(--radius-m);
|
||||||
|
padding: 16px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.infio-workspace-view-current-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-normal);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.infio-workspace-view-current-info {
|
||||||
|
margin-left: 26px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.infio-workspace-view-current-name {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 500;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.infio-workspace-view-current-status {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.infio-workspace-view-list {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.infio-workspace-view-list-header h3 {
|
||||||
|
margin: 0 0 12px 0;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.infio-workspace-view-loading {
|
||||||
|
padding: 20px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.infio-workspace-view-empty {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
padding: 40px 20px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.infio-workspace-view-empty-icon {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.infio-workspace-view-items {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.infio-workspace-view-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background-color: var(--background-primary);
|
||||||
|
border: 1px solid var(--background-modifier-border);
|
||||||
|
border-radius: var(--radius-s);
|
||||||
|
padding: 12px;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.infio-workspace-view-item:hover {
|
||||||
|
background-color: var(--background-modifier-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.infio-workspace-view-item.current {
|
||||||
|
border-color: var(--background-modifier-border);
|
||||||
|
background-color: var(--background-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.infio-workspace-view-item-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
width: 100%;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.infio-workspace-view-item-icon {
|
||||||
|
color: var(--text-muted);
|
||||||
|
flex-shrink: 0;
|
||||||
|
order: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.infio-workspace-view-item-name {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-weight: 500;
|
||||||
|
flex: 1;
|
||||||
|
margin-left: 8px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
justify-content: flex-start;
|
||||||
|
order: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.infio-workspace-view-item-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
order: 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.infio-workspace-view-item.current .infio-workspace-view-item-icon {
|
||||||
|
color: var(--text-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.infio-workspace-view-item-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.infio-workspace-view-current-badge {
|
||||||
|
background-color: var(--text-accent);
|
||||||
|
color: var(--text-on-accent);
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 3px 8px;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.infio-workspace-view-item-path {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 6px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
padding: 4px 0;
|
||||||
|
border-radius: var(--radius-s);
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.infio-workspace-view-item-path.clickable {
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 6px 8px;
|
||||||
|
margin: -2px -4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.infio-workspace-view-item-path.clickable:hover {
|
||||||
|
background-color: var(--background-modifier-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.infio-workspace-view-item-path-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.infio-workspace-view-expand-icon {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
color: var(--text-muted);
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.infio-workspace-view-content-details {
|
||||||
|
margin-top: 8px;
|
||||||
|
padding: 8px 0;
|
||||||
|
border-top: 1px solid var(--background-modifier-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.infio-workspace-view-content-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.infio-workspace-view-content-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
background-color: var(--background-secondary);
|
||||||
|
border-radius: var(--radius-s);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.infio-workspace-view-content-text {
|
||||||
|
flex: 1;
|
||||||
|
color: var(--text-normal);
|
||||||
|
font-weight: 500;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.infio-workspace-view-content-type {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 11px;
|
||||||
|
background-color: var(--background-modifier-border);
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 10px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.infio-workspace-view-chat-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 6px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
margin-top: 4px;
|
||||||
|
padding: 4px 0;
|
||||||
|
border-radius: var(--radius-s);
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.infio-workspace-view-chat-info.clickable {
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 6px 8px;
|
||||||
|
margin: 2px -4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.infio-workspace-view-chat-info.clickable:hover {
|
||||||
|
background-color: var(--background-modifier-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.infio-workspace-view-chat-info-content {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.infio-workspace-view-chat-details {
|
||||||
|
margin-top: 8px;
|
||||||
|
padding: 8px 0;
|
||||||
|
border-top: 1px solid var(--background-modifier-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.infio-workspace-view-chat-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.infio-workspace-view-chat-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
background-color: var(--background-secondary);
|
||||||
|
border-radius: var(--radius-s);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.infio-workspace-view-chat-title {
|
||||||
|
flex: 1;
|
||||||
|
color: var(--text-normal);
|
||||||
|
font-weight: 500;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.infio-workspace-view-item-meta {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.infio-workspace-view-action-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background-color: transparent !important;
|
||||||
|
border: none !important;
|
||||||
|
box-shadow: none !important;
|
||||||
|
color: var(--text-muted);
|
||||||
|
padding: 0 !important;
|
||||||
|
margin: 0 !important;
|
||||||
|
width: 24px !important;
|
||||||
|
height: 24px !important;
|
||||||
|
border-radius: var(--radius-s);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: var(--background-modifier-hover) !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
</style>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default WorkspaceView
|
||||||
@ -1,8 +1,7 @@
|
|||||||
import { ImageIcon } from 'lucide-react'
|
import { ImageUp } from 'lucide-react'
|
||||||
import { TFile } from 'obsidian'
|
import { TFile } from 'obsidian'
|
||||||
|
|
||||||
import { useApp } from '../../../contexts/AppContext'
|
import { useApp } from '../../../contexts/AppContext'
|
||||||
import { t } from '../../../lang/helpers'
|
|
||||||
import { ImageSelectorModal } from '../../modals/ImageSelectorModal'
|
import { ImageSelectorModal } from '../../modals/ImageSelectorModal'
|
||||||
|
|
||||||
export function ImageUploadButton({
|
export function ImageUploadButton({
|
||||||
@ -33,9 +32,9 @@ export function ImageUploadButton({
|
|||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
>
|
>
|
||||||
<div className="infio-chat-user-input-submit-button-icons">
|
<div className="infio-chat-user-input-submit-button-icons">
|
||||||
<ImageIcon size={12} />
|
<ImageUp size={14} />
|
||||||
</div>
|
</div>
|
||||||
<div>{t('chat.input.image')}</div>
|
{/* <div>{t('chat.input.image')}</div> */}
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -31,6 +31,7 @@ import OnMutationPlugin, {
|
|||||||
} from './plugins/on-mutation/OnMutationPlugin'
|
} from './plugins/on-mutation/OnMutationPlugin'
|
||||||
|
|
||||||
export type LexicalContentEditableProps = {
|
export type LexicalContentEditableProps = {
|
||||||
|
rootTheme?: string
|
||||||
editorRef: RefObject<LexicalEditor>
|
editorRef: RefObject<LexicalEditor>
|
||||||
contentEditableRef: RefObject<HTMLDivElement>
|
contentEditableRef: RefObject<HTMLDivElement>
|
||||||
onChange?: (content: SerializedEditorState) => void
|
onChange?: (content: SerializedEditorState) => void
|
||||||
@ -52,6 +53,7 @@ export type LexicalContentEditableProps = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function LexicalContentEditable({
|
export default function LexicalContentEditable({
|
||||||
|
rootTheme,
|
||||||
editorRef,
|
editorRef,
|
||||||
contentEditableRef,
|
contentEditableRef,
|
||||||
onChange,
|
onChange,
|
||||||
@ -68,7 +70,7 @@ export default function LexicalContentEditable({
|
|||||||
const initialConfig: InitialConfigType = {
|
const initialConfig: InitialConfigType = {
|
||||||
namespace: 'LexicalContentEditable',
|
namespace: 'LexicalContentEditable',
|
||||||
theme: {
|
theme: {
|
||||||
root: 'infio-chat-lexical-content-editable-root',
|
root: rootTheme || 'infio-chat-lexical-content-editable-root',
|
||||||
paragraph: 'infio-chat-lexical-content-editable-paragraph',
|
paragraph: 'infio-chat-lexical-content-editable-paragraph',
|
||||||
},
|
},
|
||||||
nodes: [MentionNode],
|
nodes: [MentionNode],
|
||||||
|
|||||||
@ -64,7 +64,7 @@ function FileBadge({
|
|||||||
className="infio-chat-user-input-file-badge-name-icon"
|
className="infio-chat-user-input-file-badge-name-icon"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<span>{mentionable.file.name}</span>
|
<span>{mentionable.file?.name || 'Unknown File'}</span>
|
||||||
</div>
|
</div>
|
||||||
</BadgeBase>
|
</BadgeBase>
|
||||||
)
|
)
|
||||||
@ -91,7 +91,7 @@ function FolderBadge({
|
|||||||
className="infio-chat-user-input-file-badge-name-icon"
|
className="infio-chat-user-input-file-badge-name-icon"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<span>{mentionable.folder.name}</span>
|
<span>{mentionable.folder?.name || 'Unknown Folder'}</span>
|
||||||
</div>
|
</div>
|
||||||
</BadgeBase>
|
</BadgeBase>
|
||||||
)
|
)
|
||||||
@ -147,7 +147,7 @@ function CurrentFileBadge({
|
|||||||
className="infio-chat-user-input-file-badge-name-icon"
|
className="infio-chat-user-input-file-badge-name-icon"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<span>{mentionable.file.name}</span>
|
<span>{mentionable.file?.name || 'Unknown File'}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="infio-chat-user-input-file-badge-name-block-suffix">
|
<div className="infio-chat-user-input-file-badge-name-block-suffix">
|
||||||
{' (Current file)'}
|
{' (Current file)'}
|
||||||
@ -177,7 +177,7 @@ function BlockBadge({
|
|||||||
className="infio-chat-user-input-file-badge-name-block-name-icon"
|
className="infio-chat-user-input-file-badge-name-block-name-icon"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<span>{mentionable.file.name}</span>
|
<span>{mentionable.file?.name || 'Unknown File'}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="infio-chat-user-input-file-badge-name-block-suffix">
|
<div className="infio-chat-user-input-file-badge-name-block-suffix">
|
||||||
{` (${mentionable.startLine}:${mentionable.endLine})`}
|
{` (${mentionable.startLine}:${mentionable.endLine})`}
|
||||||
@ -234,7 +234,7 @@ function ImageBadge({
|
|||||||
className="infio-chat-user-input-file-badge-name-icon"
|
className="infio-chat-user-input-file-badge-name-icon"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<span>{mentionable.name}</span>
|
<span>{mentionable.name || 'Unknown Image'}</span>
|
||||||
</div>
|
</div>
|
||||||
</BadgeBase>
|
</BadgeBase>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
|
import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
|
||||||
import { ChevronDown, ChevronUp } from 'lucide-react'
|
import { ChevronDown, ChevronUp, MessageSquare, Search, SquarePen } from 'lucide-react'
|
||||||
import { useEffect, useMemo, useState } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
|
|
||||||
import { useSettings } from '../../../contexts/SettingsContext'
|
import { useSettings } from '../../../contexts/SettingsContext'
|
||||||
@ -21,39 +21,179 @@ export function ModeSelect() {
|
|||||||
setMode(settings.mode)
|
setMode(settings.mode)
|
||||||
}, [settings.mode])
|
}, [settings.mode])
|
||||||
|
|
||||||
return (
|
// 为默认模式定义快捷键提示
|
||||||
<DropdownMenu.Root open={isOpen} onOpenChange={setIsOpen}>
|
const getShortcutText = (slug: string) => {
|
||||||
<DropdownMenu.Trigger className="infio-chat-input-model-select">
|
switch (slug) {
|
||||||
<div className="infio-chat-input-model-select__icon">
|
case 'write':
|
||||||
{isOpen ? <ChevronUp size={12} /> : <ChevronDown size={12} />}
|
return 'Ctrl+Shift+.'
|
||||||
</div>
|
case 'ask':
|
||||||
<div className="infio-chat-input-model-select__model-name">
|
return 'Ctrl+Shift+,'
|
||||||
{allModes.find((m) => m.slug === mode)?.name}
|
case 'research':
|
||||||
</div>
|
return 'Ctrl+Shift+/'
|
||||||
</DropdownMenu.Trigger>
|
default:
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
<DropdownMenu.Portal>
|
// 为默认模式定义图标
|
||||||
<DropdownMenu.Content
|
const getModeIcon = (slug: string) => {
|
||||||
className="infio-popover">
|
switch (slug) {
|
||||||
<ul>
|
case 'ask':
|
||||||
{allModes.map((mode) => (
|
return <MessageSquare size={14} />
|
||||||
<DropdownMenu.Item
|
case 'write':
|
||||||
key={mode.slug}
|
return <SquarePen size={14} />
|
||||||
onSelect={() => {
|
case 'research':
|
||||||
setMode(mode.slug)
|
return <Search size={14} />
|
||||||
setSettings({
|
default:
|
||||||
...settings,
|
return null
|
||||||
mode: mode.slug,
|
}
|
||||||
})
|
}
|
||||||
}}
|
|
||||||
asChild
|
|
||||||
>
|
return (
|
||||||
<li>{mode.name}</li>
|
<>
|
||||||
</DropdownMenu.Item>
|
<DropdownMenu.Root open={isOpen} onOpenChange={setIsOpen}>
|
||||||
))}
|
<DropdownMenu.Trigger className="infio-chat-input-mode-select">
|
||||||
</ul>
|
<span className="infio-mode-icon">{getModeIcon(mode)}</span>
|
||||||
</DropdownMenu.Content>
|
<div className="infio-chat-input-mode-select__model-name">
|
||||||
</DropdownMenu.Portal>
|
{allModes.find((m) => m.slug === mode)?.name}
|
||||||
</DropdownMenu.Root>
|
</div>
|
||||||
|
<div className="infio-chat-input-mode-select__icon">
|
||||||
|
{isOpen ? <ChevronUp size={12} /> : <ChevronDown size={12} />}
|
||||||
|
</div>
|
||||||
|
</DropdownMenu.Trigger>
|
||||||
|
|
||||||
|
<DropdownMenu.Portal>
|
||||||
|
<DropdownMenu.Content
|
||||||
|
className="infio-popover infio-mode-select-content">
|
||||||
|
<ul>
|
||||||
|
{allModes.map((mode) => {
|
||||||
|
const shortcut = getShortcutText(mode.slug)
|
||||||
|
const icon = getModeIcon(mode.slug)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownMenu.Item
|
||||||
|
key={mode.slug}
|
||||||
|
onSelect={() => {
|
||||||
|
setMode(mode.slug)
|
||||||
|
setSettings({
|
||||||
|
...settings,
|
||||||
|
mode: mode.slug,
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
asChild
|
||||||
|
>
|
||||||
|
<li className="infio-mode-item">
|
||||||
|
<div className="infio-mode-left">
|
||||||
|
{icon && (
|
||||||
|
<span className="infio-mode-icon">{icon}</span>
|
||||||
|
)}
|
||||||
|
<span className="infio-mode-name">{mode.name}</span>
|
||||||
|
</div>
|
||||||
|
{shortcut && (
|
||||||
|
<span className="infio-mode-shortcut">{shortcut}</span>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</DropdownMenu.Content>
|
||||||
|
</DropdownMenu.Portal>
|
||||||
|
</DropdownMenu.Root>
|
||||||
|
<style >{`
|
||||||
|
button.infio-chat-input-mode-select {
|
||||||
|
background-color: var(--background-modifier-hover);
|
||||||
|
box-shadow: none;
|
||||||
|
border: 1;
|
||||||
|
padding: var(--size-4-1) var(--size-4-2);
|
||||||
|
font-size: var(--font-smallest);
|
||||||
|
font-weight: var(--font-medium);
|
||||||
|
color: var(--text-muted);
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-start;
|
||||||
|
align-items: center;
|
||||||
|
cursor: pointer;
|
||||||
|
height: auto;
|
||||||
|
max-width: 100%;
|
||||||
|
gap: var(--size-2-2);
|
||||||
|
border-radius: var(--radius-l);
|
||||||
|
transition: all 0.15s ease-in-out;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: var(--text-normal);
|
||||||
|
background-color: var(--background-modifier-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.infio-chat-input-mode-select__mode-icon {
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
margin-top: var(--size-4-1);
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--text-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.infio-chat-input-mode-select__model-name {
|
||||||
|
flex-shrink: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
flex-grow: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.infio-chat-input-mode-select__icon {
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.infio-mode-select-content {
|
||||||
|
min-width: auto !important;
|
||||||
|
width: fit-content !important;
|
||||||
|
max-width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.infio-mode-item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
padding: var(--size-4-2) var(--size-4-2);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.infio-mode-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--size-2-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.infio-mode-icon {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--text-accent);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.infio-mode-name {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.infio-mode-shortcut {
|
||||||
|
font-size: var(--font-smallest);
|
||||||
|
color: var(--text-muted);
|
||||||
|
background-color: var(--background-modifier-border);
|
||||||
|
padding: var(--size-2-1) var(--size-2-2);
|
||||||
|
border-radius: var(--radius-s);
|
||||||
|
font-family: var(--font-monospace);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,12 +1,24 @@
|
|||||||
import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
|
import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
|
||||||
import Fuse, { FuseResult } from 'fuse.js'
|
import Fuse, { FuseResult } from 'fuse.js'
|
||||||
import { ChevronDown, ChevronUp, Star, StarOff } from 'lucide-react'
|
import { ChevronDown, ChevronUp, Star } from 'lucide-react'
|
||||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
|
|
||||||
import { useSettings } from '../../../contexts/SettingsContext'
|
import { useSettings } from '../../../contexts/SettingsContext'
|
||||||
import { t } from '../../../lang/helpers'
|
import { t } from '../../../lang/helpers'
|
||||||
import { ApiProvider } from '../../../types/llm/model'
|
import { ApiProvider } from '../../../types/llm/model'
|
||||||
import { GetAllProviders, GetProviderModelIds } from "../../../utils/api"
|
import { GetAllProviders, GetEmbeddingProviders, GetEmbeddingProviderModelIds, GetProviderModelsWithSettings } from "../../../utils/api"
|
||||||
|
|
||||||
|
// 优化模型名称显示的函数
|
||||||
|
const getOptimizedModelName = (modelId: string): string => {
|
||||||
|
if (!modelId) return modelId;
|
||||||
|
|
||||||
|
// 限制长度,如果太长则截断并添加省略号
|
||||||
|
if (modelId.length > 25) {
|
||||||
|
return modelId.substring(0, 22) + '...';
|
||||||
|
}
|
||||||
|
|
||||||
|
return modelId;
|
||||||
|
};
|
||||||
|
|
||||||
type TextSegment = {
|
type TextSegment = {
|
||||||
text: string;
|
text: string;
|
||||||
@ -134,25 +146,69 @@ const HighlightedText: React.FC<{ segments: TextSegment[] }> = ({ segments }) =>
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export function ModelSelect() {
|
type ModelType = 'chat' | 'insight' | 'apply' | 'embedding'
|
||||||
|
|
||||||
|
interface ModelSelectProps {
|
||||||
|
modelType?: ModelType
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ModelSelect({ modelType = 'chat' }: ModelSelectProps) {
|
||||||
const { settings, setSettings } = useSettings()
|
const { settings, setSettings } = useSettings()
|
||||||
const [isOpen, setIsOpen] = useState(false)
|
const [isOpen, setIsOpen] = useState(false)
|
||||||
const [modelProvider, setModelProvider] = useState(settings.chatModelProvider)
|
|
||||||
const [chatModelId, setChatModelId] = useState(settings.chatModelId)
|
// 根据模型类型获取相应的设置
|
||||||
|
const currentModelProvider = useMemo(() => {
|
||||||
|
switch (modelType) {
|
||||||
|
case 'insight':
|
||||||
|
return settings.insightModelProvider
|
||||||
|
case 'apply':
|
||||||
|
return settings.applyModelProvider
|
||||||
|
case 'embedding':
|
||||||
|
return settings.embeddingModelProvider
|
||||||
|
default:
|
||||||
|
return settings.chatModelProvider
|
||||||
|
}
|
||||||
|
}, [modelType, settings.insightModelProvider, settings.applyModelProvider, settings.embeddingModelProvider, settings.chatModelProvider])
|
||||||
|
|
||||||
|
const currentModelId = useMemo(() => {
|
||||||
|
switch (modelType) {
|
||||||
|
case 'insight':
|
||||||
|
return settings.insightModelId
|
||||||
|
case 'apply':
|
||||||
|
return settings.applyModelId
|
||||||
|
case 'embedding':
|
||||||
|
return settings.embeddingModelId
|
||||||
|
default:
|
||||||
|
return settings.chatModelId
|
||||||
|
}
|
||||||
|
}, [modelType, settings.insightModelId, settings.applyModelId, settings.embeddingModelId, settings.chatModelId])
|
||||||
|
|
||||||
|
const [modelProvider, setModelProvider] = useState(currentModelProvider)
|
||||||
|
const [chatModelId, setChatModelId] = useState(currentModelId)
|
||||||
const [modelIds, setModelIds] = useState<string[]>([])
|
const [modelIds, setModelIds] = useState<string[]>([])
|
||||||
const [isLoading, setIsLoading] = useState(true)
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
const [searchTerm, setSearchTerm] = useState("")
|
const [searchTerm, setSearchTerm] = useState("")
|
||||||
const [selectedIndex, setSelectedIndex] = useState(0)
|
const [selectedIndex, setSelectedIndex] = useState(0)
|
||||||
const inputRef = useRef<HTMLInputElement>(null)
|
const inputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
const providers = GetAllProviders()
|
const providers = useMemo(() => {
|
||||||
|
if (modelType === 'embedding') {
|
||||||
|
return GetEmbeddingProviders()
|
||||||
|
}
|
||||||
|
return GetAllProviders()
|
||||||
|
}, [modelType])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchModels = async () => {
|
const fetchModels = async () => {
|
||||||
setIsLoading(true)
|
setIsLoading(true)
|
||||||
try {
|
try {
|
||||||
const models = await GetProviderModelIds(modelProvider, settings)
|
if (modelType === 'embedding') {
|
||||||
setModelIds(models)
|
const models = GetEmbeddingProviderModelIds(modelProvider)
|
||||||
|
setModelIds(models)
|
||||||
|
} else {
|
||||||
|
const models = await GetProviderModelsWithSettings(modelProvider, settings)
|
||||||
|
setModelIds(Object.keys(models))
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to fetch provider models:', error)
|
console.error('Failed to fetch provider models:', error)
|
||||||
setModelIds([])
|
setModelIds([])
|
||||||
@ -164,16 +220,30 @@ export function ModelSelect() {
|
|||||||
fetchModels()
|
fetchModels()
|
||||||
}, [modelProvider, settings])
|
}, [modelProvider, settings])
|
||||||
|
|
||||||
// Sync chat model id & chat model provider
|
// Sync model id & model provider based on modelType
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setModelProvider(settings.chatModelProvider)
|
setModelProvider(currentModelProvider)
|
||||||
setChatModelId(settings.chatModelId)
|
setChatModelId(currentModelId)
|
||||||
}, [settings.chatModelProvider, settings.chatModelId])
|
}, [currentModelProvider, currentModelId])
|
||||||
|
|
||||||
const searchableItems = useMemo(() => {
|
const searchableItems = useMemo(() => {
|
||||||
|
// 根据模型类型获取相应的收藏列表
|
||||||
|
const getCollectedModels = () => {
|
||||||
|
switch (modelType) {
|
||||||
|
case 'insight':
|
||||||
|
return settings.collectedInsightModels || []
|
||||||
|
case 'apply':
|
||||||
|
return settings.collectedApplyModels || []
|
||||||
|
case 'embedding':
|
||||||
|
return settings.collectedEmbeddingModels || []
|
||||||
|
default:
|
||||||
|
return settings.collectedChatModels || []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 检查是否在收藏列表中
|
// 检查是否在收藏列表中
|
||||||
const isInCollected = (id: string) => {
|
const isInCollected = (id: string) => {
|
||||||
return settings.collectedChatModels?.some(item => item.provider === modelProvider && item.modelId === id) || false;
|
return getCollectedModels().some(item => item.provider === modelProvider && item.modelId === id) || false;
|
||||||
};
|
};
|
||||||
|
|
||||||
return modelIds.map((id) => ({
|
return modelIds.map((id) => ({
|
||||||
@ -182,17 +252,17 @@ export function ModelSelect() {
|
|||||||
provider: modelProvider,
|
provider: modelProvider,
|
||||||
isCollected: isInCollected(id),
|
isCollected: isInCollected(id),
|
||||||
}))
|
}))
|
||||||
}, [modelIds, modelProvider, settings.collectedChatModels])
|
}, [modelIds, modelProvider, modelType, settings.collectedChatModels, settings.collectedInsightModels, settings.collectedApplyModels, settings.collectedEmbeddingModels])
|
||||||
|
|
||||||
const fuse = useMemo(() => {
|
const fuse = useMemo(() => {
|
||||||
return new Fuse<SearchableItem>(searchableItems, {
|
return new Fuse<SearchableItem>(searchableItems, {
|
||||||
keys: ["html"],
|
keys: ["html"],
|
||||||
threshold: 0.6,
|
threshold: 1,
|
||||||
shouldSort: true,
|
shouldSort: true,
|
||||||
isCaseSensitive: false,
|
isCaseSensitive: false,
|
||||||
ignoreLocation: false,
|
ignoreLocation: false,
|
||||||
includeMatches: true,
|
includeMatches: true,
|
||||||
minMatchCharLength: 1,
|
minMatchCharLength: 2,
|
||||||
})
|
})
|
||||||
}, [searchableItems])
|
}, [searchableItems])
|
||||||
|
|
||||||
@ -217,11 +287,26 @@ export function ModelSelect() {
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
const isCurrentlyCollected = settings.collectedChatModels?.some(
|
// 根据模型类型获取相应的收藏列表
|
||||||
|
const getCollectedModels = () => {
|
||||||
|
switch (modelType) {
|
||||||
|
case 'insight':
|
||||||
|
return settings.collectedInsightModels || []
|
||||||
|
case 'apply':
|
||||||
|
return settings.collectedApplyModels || []
|
||||||
|
case 'embedding':
|
||||||
|
return settings.collectedEmbeddingModels || []
|
||||||
|
default:
|
||||||
|
return settings.collectedChatModels || []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentCollectedModels = getCollectedModels();
|
||||||
|
const isCurrentlyCollected = currentCollectedModels.some(
|
||||||
item => item.provider === modelProvider && item.modelId === id
|
item => item.provider === modelProvider && item.modelId === id
|
||||||
);
|
);
|
||||||
|
|
||||||
let newCollectedModels = settings.collectedChatModels || [];
|
let newCollectedModels = [...currentCollectedModels];
|
||||||
|
|
||||||
if (isCurrentlyCollected) {
|
if (isCurrentlyCollected) {
|
||||||
// remove
|
// remove
|
||||||
@ -233,83 +318,178 @@ export function ModelSelect() {
|
|||||||
newCollectedModels = [...newCollectedModels, { provider: modelProvider, modelId: id }];
|
newCollectedModels = [...newCollectedModels, { provider: modelProvider, modelId: id }];
|
||||||
}
|
}
|
||||||
|
|
||||||
setSettings({
|
// 根据模型类型更新相应的设置
|
||||||
...settings,
|
switch (modelType) {
|
||||||
collectedChatModels: newCollectedModels,
|
case 'insight':
|
||||||
});
|
setSettings({
|
||||||
|
...settings,
|
||||||
|
collectedInsightModels: newCollectedModels,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case 'apply':
|
||||||
|
setSettings({
|
||||||
|
...settings,
|
||||||
|
collectedApplyModels: newCollectedModels,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case 'embedding':
|
||||||
|
setSettings({
|
||||||
|
...settings,
|
||||||
|
collectedEmbeddingModels: newCollectedModels,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
setSettings({
|
||||||
|
...settings,
|
||||||
|
collectedChatModels: newCollectedModels,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<DropdownMenu.Root open={isOpen} onOpenChange={setIsOpen}>
|
<DropdownMenu.Root open={isOpen} onOpenChange={setIsOpen}>
|
||||||
<DropdownMenu.Trigger className="infio-chat-input-model-select">
|
<DropdownMenu.Trigger className="infio-chat-input-model-select">
|
||||||
|
{/* <div className="infio-chat-input-model-select__mode-icon">
|
||||||
|
<Brain size={16} />
|
||||||
|
</div> */}
|
||||||
|
<div
|
||||||
|
className="infio-chat-input-model-select__model-name"
|
||||||
|
title={chatModelId}
|
||||||
|
>
|
||||||
|
{getOptimizedModelName(chatModelId)}
|
||||||
|
</div>
|
||||||
<div className="infio-chat-input-model-select__icon">
|
<div className="infio-chat-input-model-select__icon">
|
||||||
{isOpen ? <ChevronUp size={12} /> : <ChevronDown size={12} />}
|
{isOpen ? <ChevronUp size={12} /> : <ChevronDown size={12} />}
|
||||||
</div>
|
</div>
|
||||||
<div className="infio-chat-input-model-select__model-name">
|
|
||||||
{chatModelId}
|
|
||||||
</div>
|
|
||||||
</DropdownMenu.Trigger>
|
</DropdownMenu.Trigger>
|
||||||
|
|
||||||
<DropdownMenu.Portal>
|
<DropdownMenu.Portal>
|
||||||
<DropdownMenu.Content className="infio-popover infio-llm-setting-combobox-dropdown">
|
<DropdownMenu.Content className="infio-popover infio-llm-setting-combobox-dropdown">
|
||||||
{/* collected models */}
|
{/* collected models */}
|
||||||
{settings.collectedChatModels?.length > 0 && (
|
{(() => {
|
||||||
<div className="infio-model-section">
|
const getCollectedModels = () => {
|
||||||
<div className="infio-model-section-title">
|
switch (modelType) {
|
||||||
<Star size={12} className="infio-star-active" /> {t('chat.input.collectedModels')}
|
case 'insight':
|
||||||
</div>
|
return settings.collectedInsightModels || []
|
||||||
<ul className="infio-collected-models-list">
|
case 'apply':
|
||||||
{settings.collectedChatModels.map((collectedModel, index) => (
|
return settings.collectedApplyModels || []
|
||||||
<DropdownMenu.Item
|
case 'embedding':
|
||||||
key={`${collectedModel.provider}-${collectedModel.modelId}`}
|
return settings.collectedEmbeddingModels || []
|
||||||
onSelect={() => {
|
default:
|
||||||
setSettings({
|
return settings.collectedChatModels || []
|
||||||
...settings,
|
}
|
||||||
chatModelProvider: collectedModel.provider,
|
}
|
||||||
chatModelId: collectedModel.modelId,
|
|
||||||
})
|
|
||||||
setChatModelId(collectedModel.modelId)
|
|
||||||
setSearchTerm("")
|
|
||||||
setIsOpen(false)
|
|
||||||
}}
|
|
||||||
className={`infio-llm-setting-combobox-option ${index === selectedIndex ? 'is-selected' : ''}`}
|
|
||||||
onMouseEnter={() => setSelectedIndex(index)}
|
|
||||||
asChild
|
|
||||||
>
|
|
||||||
<li
|
|
||||||
className="infio-llm-setting-model-item infio-collected-model-item"
|
|
||||||
title={`${collectedModel.provider}/${collectedModel.modelId}`}
|
|
||||||
>
|
|
||||||
<div className="infio-model-item-text-wrapper">
|
|
||||||
<span className="infio-provider-badge">{collectedModel.provider}</span>
|
|
||||||
<span>{collectedModel.modelId}</span>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className="infio-model-item-star"
|
|
||||||
title="remove from collected models"
|
|
||||||
>
|
|
||||||
<Star size={16} className="infio-star-active" onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
e.preventDefault();
|
|
||||||
// delete
|
|
||||||
const newCollectedModels = settings.collectedChatModels.filter(
|
|
||||||
item => !(item.provider === collectedModel.provider && item.modelId === collectedModel.modelId)
|
|
||||||
);
|
|
||||||
|
|
||||||
setSettings({
|
const collectedModels = getCollectedModels()
|
||||||
...settings,
|
|
||||||
collectedChatModels: newCollectedModels,
|
return collectedModels.length > 0 ? (
|
||||||
});
|
<div className="infio-model-section">
|
||||||
}} />
|
<div className="infio-model-section-title">
|
||||||
</div>
|
<Star size={12} className="infio-star-active" /> {t('chat.input.collectedModels')}
|
||||||
</li>
|
</div>
|
||||||
</DropdownMenu.Item>
|
<ul className="infio-collected-models-list">
|
||||||
))}
|
{collectedModels.map((collectedModel, index) => (
|
||||||
</ul>
|
<DropdownMenu.Item
|
||||||
<div className="infio-model-separator"></div>
|
key={`${collectedModel.provider}-${collectedModel.modelId}`}
|
||||||
</div>
|
onSelect={() => {
|
||||||
)}
|
// 根据模型类型更新相应的设置
|
||||||
|
switch (modelType) {
|
||||||
|
case 'insight':
|
||||||
|
setSettings({
|
||||||
|
...settings,
|
||||||
|
insightModelProvider: collectedModel.provider,
|
||||||
|
insightModelId: collectedModel.modelId,
|
||||||
|
})
|
||||||
|
break;
|
||||||
|
case 'apply':
|
||||||
|
setSettings({
|
||||||
|
...settings,
|
||||||
|
applyModelProvider: collectedModel.provider,
|
||||||
|
applyModelId: collectedModel.modelId,
|
||||||
|
})
|
||||||
|
break;
|
||||||
|
case 'embedding':
|
||||||
|
setSettings({
|
||||||
|
...settings,
|
||||||
|
embeddingModelProvider: collectedModel.provider,
|
||||||
|
embeddingModelId: collectedModel.modelId,
|
||||||
|
})
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
setSettings({
|
||||||
|
...settings,
|
||||||
|
chatModelProvider: collectedModel.provider,
|
||||||
|
chatModelId: collectedModel.modelId,
|
||||||
|
})
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
setChatModelId(collectedModel.modelId)
|
||||||
|
setSearchTerm("")
|
||||||
|
setIsOpen(false)
|
||||||
|
}}
|
||||||
|
className={`infio-llm-setting-combobox-option ${index === selectedIndex ? 'is-selected' : ''}`}
|
||||||
|
onMouseEnter={() => setSelectedIndex(index)}
|
||||||
|
asChild
|
||||||
|
>
|
||||||
|
<li
|
||||||
|
className="infio-llm-setting-model-item infio-collected-model-item"
|
||||||
|
title={`${collectedModel.provider}/${collectedModel.modelId}`}
|
||||||
|
>
|
||||||
|
<div className="infio-model-item-text-wrapper">
|
||||||
|
<span className="infio-provider-badge">{collectedModel.provider}</span>
|
||||||
|
<span title={collectedModel.modelId}>{collectedModel.modelId}</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="infio-model-item-star"
|
||||||
|
title="remove from collected models"
|
||||||
|
>
|
||||||
|
<Star size={16} className="infio-star-active" onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
e.preventDefault();
|
||||||
|
// delete
|
||||||
|
const newCollectedModels = collectedModels.filter(
|
||||||
|
item => !(item.provider === collectedModel.provider && item.modelId === collectedModel.modelId)
|
||||||
|
);
|
||||||
|
|
||||||
|
// 根据模型类型更新相应的设置
|
||||||
|
switch (modelType) {
|
||||||
|
case 'insight':
|
||||||
|
setSettings({
|
||||||
|
...settings,
|
||||||
|
collectedInsightModels: newCollectedModels,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case 'apply':
|
||||||
|
setSettings({
|
||||||
|
...settings,
|
||||||
|
collectedApplyModels: newCollectedModels,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case 'embedding':
|
||||||
|
setSettings({
|
||||||
|
...settings,
|
||||||
|
collectedEmbeddingModels: newCollectedModels,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
setSettings({
|
||||||
|
...settings,
|
||||||
|
collectedChatModels: newCollectedModels,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}} />
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<div className="infio-model-separator"></div>
|
||||||
|
</div>
|
||||||
|
) : null
|
||||||
|
})()}
|
||||||
|
|
||||||
<div className="infio-llm-setting-search-container">
|
<div className="infio-llm-setting-search-container">
|
||||||
<div className="infio-llm-setting-provider-container">
|
<div className="infio-llm-setting-provider-container">
|
||||||
@ -366,11 +546,37 @@ export function ModelSelect() {
|
|||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
const selectedOption = filteredOptions[selectedIndex]
|
const selectedOption = filteredOptions[selectedIndex]
|
||||||
if (selectedOption) {
|
if (selectedOption) {
|
||||||
setSettings({
|
// 根据模型类型更新相应的设置
|
||||||
...settings,
|
switch (modelType) {
|
||||||
chatModelProvider: modelProvider,
|
case 'insight':
|
||||||
chatModelId: selectedOption.id,
|
setSettings({
|
||||||
})
|
...settings,
|
||||||
|
insightModelProvider: modelProvider,
|
||||||
|
insightModelId: selectedOption.id,
|
||||||
|
})
|
||||||
|
break;
|
||||||
|
case 'apply':
|
||||||
|
setSettings({
|
||||||
|
...settings,
|
||||||
|
applyModelProvider: modelProvider,
|
||||||
|
applyModelId: selectedOption.id,
|
||||||
|
})
|
||||||
|
break;
|
||||||
|
case 'embedding':
|
||||||
|
setSettings({
|
||||||
|
...settings,
|
||||||
|
embeddingModelProvider: modelProvider,
|
||||||
|
embeddingModelId: selectedOption.id,
|
||||||
|
})
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
setSettings({
|
||||||
|
...settings,
|
||||||
|
chatModelProvider: modelProvider,
|
||||||
|
chatModelId: selectedOption.id,
|
||||||
|
})
|
||||||
|
break;
|
||||||
|
}
|
||||||
setChatModelId(selectedOption.id)
|
setChatModelId(selectedOption.id)
|
||||||
setSearchTerm("")
|
setSearchTerm("")
|
||||||
setIsOpen(false)
|
setIsOpen(false)
|
||||||
@ -403,11 +609,37 @@ export function ModelSelect() {
|
|||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === "Enter") {
|
if (e.key === "Enter") {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setSettings({
|
// 根据模型类型更新相应的设置
|
||||||
...settings,
|
switch (modelType) {
|
||||||
chatModelProvider: modelProvider,
|
case 'insight':
|
||||||
chatModelId: searchTerm,
|
setSettings({
|
||||||
})
|
...settings,
|
||||||
|
insightModelProvider: modelProvider,
|
||||||
|
insightModelId: searchTerm,
|
||||||
|
})
|
||||||
|
break;
|
||||||
|
case 'apply':
|
||||||
|
setSettings({
|
||||||
|
...settings,
|
||||||
|
applyModelProvider: modelProvider,
|
||||||
|
applyModelId: searchTerm,
|
||||||
|
})
|
||||||
|
break;
|
||||||
|
case 'embedding':
|
||||||
|
setSettings({
|
||||||
|
...settings,
|
||||||
|
embeddingModelProvider: modelProvider,
|
||||||
|
embeddingModelId: searchTerm,
|
||||||
|
})
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
setSettings({
|
||||||
|
...settings,
|
||||||
|
chatModelProvider: modelProvider,
|
||||||
|
chatModelId: searchTerm,
|
||||||
|
})
|
||||||
|
break;
|
||||||
|
}
|
||||||
setChatModelId(searchTerm)
|
setChatModelId(searchTerm)
|
||||||
setIsOpen(false)
|
setIsOpen(false)
|
||||||
}
|
}
|
||||||
@ -430,11 +662,37 @@ export function ModelSelect() {
|
|||||||
<DropdownMenu.Item
|
<DropdownMenu.Item
|
||||||
key={option.id}
|
key={option.id}
|
||||||
onSelect={() => {
|
onSelect={() => {
|
||||||
setSettings({
|
// 根据模型类型更新相应的设置
|
||||||
...settings,
|
switch (modelType) {
|
||||||
chatModelProvider: modelProvider,
|
case 'insight':
|
||||||
chatModelId: option.id,
|
setSettings({
|
||||||
})
|
...settings,
|
||||||
|
insightModelProvider: modelProvider,
|
||||||
|
insightModelId: option.id,
|
||||||
|
})
|
||||||
|
break;
|
||||||
|
case 'apply':
|
||||||
|
setSettings({
|
||||||
|
...settings,
|
||||||
|
applyModelProvider: modelProvider,
|
||||||
|
applyModelId: option.id,
|
||||||
|
})
|
||||||
|
break;
|
||||||
|
case 'embedding':
|
||||||
|
setSettings({
|
||||||
|
...settings,
|
||||||
|
embeddingModelProvider: modelProvider,
|
||||||
|
embeddingModelId: option.id,
|
||||||
|
})
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
setSettings({
|
||||||
|
...settings,
|
||||||
|
chatModelProvider: modelProvider,
|
||||||
|
chatModelId: option.id,
|
||||||
|
})
|
||||||
|
break;
|
||||||
|
}
|
||||||
setChatModelId(option.id)
|
setChatModelId(option.id)
|
||||||
setSearchTerm("")
|
setSearchTerm("")
|
||||||
setIsOpen(false)
|
setIsOpen(false)
|
||||||
@ -442,9 +700,21 @@ export function ModelSelect() {
|
|||||||
className={`infio-llm-setting-combobox-option ${isSelected ? 'is-selected' : ''}`}
|
className={`infio-llm-setting-combobox-option ${isSelected ? 'is-selected' : ''}`}
|
||||||
onMouseEnter={() => {
|
onMouseEnter={() => {
|
||||||
// 计算正确的鼠标悬停索引
|
// 计算正确的鼠标悬停索引
|
||||||
|
const getCollectedModels = () => {
|
||||||
|
switch (modelType) {
|
||||||
|
case 'insight':
|
||||||
|
return settings.collectedInsightModels || []
|
||||||
|
case 'apply':
|
||||||
|
return settings.collectedApplyModels || []
|
||||||
|
case 'embedding':
|
||||||
|
return settings.collectedEmbeddingModels || []
|
||||||
|
default:
|
||||||
|
return settings.collectedChatModels || []
|
||||||
|
}
|
||||||
|
}
|
||||||
const hoverIndex = searchTerm
|
const hoverIndex = searchTerm
|
||||||
? index
|
? index
|
||||||
: index + settings.collectedChatModels?.length;
|
: index + getCollectedModels().length;
|
||||||
setSelectedIndex(hoverIndex);
|
setSelectedIndex(hoverIndex);
|
||||||
}}
|
}}
|
||||||
asChild
|
asChild
|
||||||
@ -454,7 +724,11 @@ export function ModelSelect() {
|
|||||||
title={option.id}
|
title={option.id}
|
||||||
>
|
>
|
||||||
<div className="infio-model-item-text-wrapper">
|
<div className="infio-model-item-text-wrapper">
|
||||||
<HighlightedText segments={option.html} />
|
{searchTerm ? (
|
||||||
|
<HighlightedText segments={option.html} />
|
||||||
|
) : (
|
||||||
|
<span title={option.id}>{option.id}</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className="infio-model-item-star"
|
className="infio-model-item-star"
|
||||||
@ -503,6 +777,15 @@ export function ModelSelect() {
|
|||||||
display: block;
|
display: block;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Model name display optimization */
|
||||||
|
.infio-chat-input-model-select__model-name {
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
max-width: 200px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
.infio-llm-setting-model-item {
|
.infio-llm-setting-model-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
@ -586,9 +869,8 @@ export function ModelSelect() {
|
|||||||
text-align: left;
|
text-align: left;
|
||||||
appearance: none;
|
appearance: none;
|
||||||
-webkit-appearance: none;
|
-webkit-appearance: none;
|
||||||
background-color: var(--background-primary);
|
|
||||||
border: 1px solid var(--background-modifier-border);
|
border: 1px solid var(--background-modifier-border);
|
||||||
border-radius: 6px;
|
border-radius: 4px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
font-size: 0.9em;
|
font-size: 0.9em;
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
@ -603,7 +885,6 @@ export function ModelSelect() {
|
|||||||
|
|
||||||
.infio-llm-setting-provider-switch:hover {
|
.infio-llm-setting-provider-switch:hover {
|
||||||
border-color: var(--interactive-accent);
|
border-color: var(--interactive-accent);
|
||||||
background-color: var(--background-primary-alt);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.infio-llm-setting-provider-switch:focus {
|
.infio-llm-setting-provider-switch:focus {
|
||||||
@ -697,12 +978,9 @@ export function ModelSelect() {
|
|||||||
.infio-provider-badge {
|
.infio-provider-badge {
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
padding: 2px 6px;
|
padding: 2px 6px;
|
||||||
background-color: var(--background-modifier-hover);
|
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
margin-right: 6px;
|
margin-right: 6px;
|
||||||
color: var(--text-muted);
|
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
|
|
||||||
}
|
}
|
||||||
`}
|
`}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@ -13,6 +13,7 @@ import {
|
|||||||
|
|
||||||
import { useApp } from '../../../contexts/AppContext'
|
import { useApp } from '../../../contexts/AppContext'
|
||||||
import { useDarkModeContext } from '../../../contexts/DarkModeContext'
|
import { useDarkModeContext } from '../../../contexts/DarkModeContext'
|
||||||
|
import { useSettings } from '../../../contexts/SettingsContext'
|
||||||
import {
|
import {
|
||||||
Mentionable,
|
Mentionable,
|
||||||
MentionableImage,
|
MentionableImage,
|
||||||
@ -31,11 +32,10 @@ import { ImageUploadButton } from './ImageUploadButton'
|
|||||||
import LexicalContentEditable from './LexicalContentEditable'
|
import LexicalContentEditable from './LexicalContentEditable'
|
||||||
import MentionableBadge from './MentionableBadge'
|
import MentionableBadge from './MentionableBadge'
|
||||||
import { ModelSelect } from './ModelSelect'
|
import { ModelSelect } from './ModelSelect'
|
||||||
// import { ModeSelect } from './ModeSelect'
|
import { ModeSelect } from './ModeSelect'
|
||||||
import { MentionNode } from './plugins/mention/MentionNode'
|
import { MentionNode } from './plugins/mention/MentionNode'
|
||||||
import { NodeMutations } from './plugins/on-mutation/OnMutationPlugin'
|
import { NodeMutations } from './plugins/on-mutation/OnMutationPlugin'
|
||||||
import { SubmitButton } from './SubmitButton'
|
import { SubmitButton } from './SubmitButton'
|
||||||
|
|
||||||
export type ChatUserInputRef = {
|
export type ChatUserInputRef = {
|
||||||
focus: () => void
|
focus: () => void
|
||||||
}
|
}
|
||||||
@ -68,6 +68,7 @@ const PromptInputWithActions = forwardRef<ChatUserInputRef, ChatUserInputProps>(
|
|||||||
ref,
|
ref,
|
||||||
) => {
|
) => {
|
||||||
const app = useApp()
|
const app = useApp()
|
||||||
|
const { settings, setSettings } = useSettings()
|
||||||
|
|
||||||
const editorRef = useRef<LexicalEditor | null>(null)
|
const editorRef = useRef<LexicalEditor | null>(null)
|
||||||
const contentEditableRef = useRef<HTMLDivElement>(null)
|
const contentEditableRef = useRef<HTMLDivElement>(null)
|
||||||
@ -83,6 +84,50 @@ const PromptInputWithActions = forwardRef<ChatUserInputRef, ChatUserInputProps>(
|
|||||||
}
|
}
|
||||||
}, [addedBlockKey])
|
}, [addedBlockKey])
|
||||||
|
|
||||||
|
// 添加快捷键监听器
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
|
// 检查是否按下了 Cmd + Shift 键 (macOS)
|
||||||
|
if (event.ctrlKey && event.shiftKey) {
|
||||||
|
// 使用 event.key 直接匹配,不使用 toLowerCase()
|
||||||
|
switch (event.key) {
|
||||||
|
case '.':
|
||||||
|
case '>': // Shift + . 在某些键盘布局下可能是 >
|
||||||
|
event.preventDefault()
|
||||||
|
setSettings({
|
||||||
|
...settings,
|
||||||
|
mode: 'write',
|
||||||
|
})
|
||||||
|
break
|
||||||
|
case ',':
|
||||||
|
case '<': // Shift + , 在某些键盘布局下可能是 <
|
||||||
|
event.preventDefault()
|
||||||
|
setSettings({
|
||||||
|
...settings,
|
||||||
|
mode: 'ask',
|
||||||
|
})
|
||||||
|
break
|
||||||
|
case '/':
|
||||||
|
case '?': // Shift + / 在某些键盘布局下可能是 ?
|
||||||
|
event.preventDefault()
|
||||||
|
setSettings({
|
||||||
|
...settings,
|
||||||
|
mode: 'research',
|
||||||
|
})
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加事件监听器到 document
|
||||||
|
document.addEventListener('keydown', handleKeyDown)
|
||||||
|
|
||||||
|
// 清理函数
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('keydown', handleKeyDown)
|
||||||
|
}
|
||||||
|
}, [settings, setSettings])
|
||||||
|
|
||||||
useImperativeHandle(ref, () => ({
|
useImperativeHandle(ref, () => ({
|
||||||
focus: () => {
|
focus: () => {
|
||||||
contentEditableRef.current?.focus()
|
contentEditableRef.current?.focus()
|
||||||
@ -278,10 +323,11 @@ const PromptInputWithActions = forwardRef<ChatUserInputRef, ChatUserInputProps>(
|
|||||||
|
|
||||||
<div className="infio-chat-user-input-controls">
|
<div className="infio-chat-user-input-controls">
|
||||||
<div className="infio-chat-user-input-controls__model-select-container">
|
<div className="infio-chat-user-input-controls__model-select-container">
|
||||||
|
<ModeSelect />
|
||||||
<ModelSelect />
|
<ModelSelect />
|
||||||
<ImageUploadButton onUpload={handleUploadImages} />
|
|
||||||
</div>
|
</div>
|
||||||
<div className="infio-chat-user-input-controls__buttons">
|
<div className="infio-chat-user-input-controls__buttons">
|
||||||
|
<ImageUploadButton onUpload={handleUploadImages} />
|
||||||
<SubmitButton onClick={() => handleSubmit()} />
|
<SubmitButton onClick={() => handleSubmit()} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -6,10 +6,12 @@ import {
|
|||||||
useState
|
useState
|
||||||
} from 'react'
|
} from 'react'
|
||||||
|
|
||||||
|
|
||||||
import { Mentionable } from '../../../types/mentionable'
|
import { Mentionable } from '../../../types/mentionable'
|
||||||
|
|
||||||
import LexicalContentEditable from './LexicalContentEditable'
|
import LexicalContentEditable from './LexicalContentEditable'
|
||||||
import { SearchButton } from './SearchButton'
|
import { SearchButton } from './SearchButton'
|
||||||
|
import { SearchModeSelect } from './SearchModeSelect'
|
||||||
|
|
||||||
export type SearchInputRef = {
|
export type SearchInputRef = {
|
||||||
focus: () => void
|
focus: () => void
|
||||||
@ -25,26 +27,28 @@ export type SearchInputProps = {
|
|||||||
placeholder?: string
|
placeholder?: string
|
||||||
autoFocus?: boolean
|
autoFocus?: boolean
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
|
searchMode?: 'notes' | 'insights' | 'all'
|
||||||
|
onSearchModeChange?: (mode: 'notes' | 'insights' | 'all') => void
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查编辑器状态是否为空的辅助函数
|
// 检查编辑器状态是否为空
|
||||||
const isEditorStateEmpty = (editorState: SerializedEditorState): boolean => {
|
const isEditorStateEmpty = (editorState: SerializedEditorState): boolean => {
|
||||||
if (!editorState || !editorState.root || !editorState.root.children) {
|
try {
|
||||||
|
const root = editorState.root
|
||||||
|
if (!root || !root.children) return true
|
||||||
|
|
||||||
|
// 检查是否有实际内容
|
||||||
|
const hasContent = root.children.some((child: any) => {
|
||||||
|
if (child.type === 'paragraph') {
|
||||||
|
return child.children && child.children.length > 0
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
return !hasContent
|
||||||
|
} catch (error) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
const children = editorState.root.children
|
|
||||||
if (children.length === 0) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
// 检查是否只有空的段落
|
|
||||||
if (children.length === 1 && children[0].type === 'paragraph') {
|
|
||||||
const paragraph = children[0] as any
|
|
||||||
return !paragraph.children || paragraph.children.length === 0
|
|
||||||
}
|
|
||||||
|
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const SearchInputWithActions = forwardRef<SearchInputRef, SearchInputProps>(
|
const SearchInputWithActions = forwardRef<SearchInputRef, SearchInputProps>(
|
||||||
@ -56,6 +60,8 @@ const SearchInputWithActions = forwardRef<SearchInputRef, SearchInputProps>(
|
|||||||
placeholder = '',
|
placeholder = '',
|
||||||
autoFocus = false,
|
autoFocus = false,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
|
searchMode = 'all',
|
||||||
|
onSearchModeChange,
|
||||||
},
|
},
|
||||||
ref
|
ref
|
||||||
) => {
|
) => {
|
||||||
@ -112,6 +118,7 @@ const SearchInputWithActions = forwardRef<SearchInputRef, SearchInputProps>(
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<LexicalContentEditable
|
<LexicalContentEditable
|
||||||
|
rootTheme="infio-search-lexical-content-editable-root"
|
||||||
initialEditorState={(editor) => {
|
initialEditorState={(editor) => {
|
||||||
if (initialSerializedEditorState) {
|
if (initialSerializedEditorState) {
|
||||||
editor.setEditorState(
|
editor.setEditorState(
|
||||||
@ -139,7 +146,13 @@ const SearchInputWithActions = forwardRef<SearchInputRef, SearchInputProps>(
|
|||||||
|
|
||||||
<div className="infio-chat-user-input-controls">
|
<div className="infio-chat-user-input-controls">
|
||||||
<div className="infio-chat-user-input-controls__model-select-container">
|
<div className="infio-chat-user-input-controls__model-select-container">
|
||||||
{/* TODO: add model select */}
|
{onSearchModeChange && (
|
||||||
|
<SearchModeSelect
|
||||||
|
searchMode={searchMode}
|
||||||
|
onSearchModeChange={onSearchModeChange}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
<div className="infio-chat-user-input-controls__buttons">
|
<div className="infio-chat-user-input-controls__buttons">
|
||||||
<SearchButton onClick={() => handleSubmit()} />
|
<SearchButton onClick={() => handleSubmit()} />
|
||||||
|
|||||||
166
src/components/chat-view/chat-input/SearchModeSelect.tsx
Normal file
166
src/components/chat-view/chat-input/SearchModeSelect.tsx
Normal file
@ -0,0 +1,166 @@
|
|||||||
|
import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
|
||||||
|
import { ChevronDown, ChevronUp, FileText, Lightbulb, Globe } from 'lucide-react'
|
||||||
|
import { useState } from 'react'
|
||||||
|
|
||||||
|
import { t } from '../../../lang/helpers'
|
||||||
|
|
||||||
|
interface SearchModeSelectProps {
|
||||||
|
searchMode: 'notes' | 'insights' | 'all'
|
||||||
|
onSearchModeChange: (mode: 'notes' | 'insights' | 'all') => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SearchModeSelect({ searchMode, onSearchModeChange }: SearchModeSelectProps) {
|
||||||
|
const [isOpen, setIsOpen] = useState(false)
|
||||||
|
|
||||||
|
const searchModes = [
|
||||||
|
{
|
||||||
|
value: 'all' as const,
|
||||||
|
name: t('semanticSearch.searchMode.all'),
|
||||||
|
icon: <Globe size={14} />,
|
||||||
|
description: t('semanticSearch.searchMode.allDescription')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'notes' as const,
|
||||||
|
name: t('semanticSearch.searchMode.notes'),
|
||||||
|
icon: <FileText size={14} />,
|
||||||
|
description: t('semanticSearch.searchMode.notesDescription')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'insights' as const,
|
||||||
|
name: t('semanticSearch.searchMode.insights'),
|
||||||
|
icon: <Lightbulb size={14} />,
|
||||||
|
description: t('semanticSearch.searchMode.insightsDescription')
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
const currentMode = searchModes.find((m) => m.value === searchMode)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<DropdownMenu.Root open={isOpen} onOpenChange={setIsOpen}>
|
||||||
|
<DropdownMenu.Trigger className="infio-chat-input-search-mode-select">
|
||||||
|
<span className="infio-search-mode-icon">{currentMode?.icon}</span>
|
||||||
|
<div className="infio-chat-input-search-mode-select__mode-name">
|
||||||
|
{currentMode?.name}
|
||||||
|
</div>
|
||||||
|
<div className="infio-chat-input-search-mode-select__icon">
|
||||||
|
{isOpen ? <ChevronUp size={12} /> : <ChevronDown size={12} />}
|
||||||
|
</div>
|
||||||
|
</DropdownMenu.Trigger>
|
||||||
|
|
||||||
|
<DropdownMenu.Portal>
|
||||||
|
<DropdownMenu.Content
|
||||||
|
className="infio-popover infio-search-mode-select-content">
|
||||||
|
<ul>
|
||||||
|
{searchModes.map((mode) => (
|
||||||
|
<DropdownMenu.Item
|
||||||
|
key={mode.value}
|
||||||
|
onSelect={() => {
|
||||||
|
onSearchModeChange(mode.value)
|
||||||
|
}}
|
||||||
|
asChild
|
||||||
|
>
|
||||||
|
<li className="infio-search-mode-item">
|
||||||
|
<div className="infio-search-mode-left">
|
||||||
|
<span className="infio-search-mode-icon">{mode.icon}</span>
|
||||||
|
<div className="infio-search-mode-info">
|
||||||
|
<span className="infio-search-mode-name">{mode.name}</span>
|
||||||
|
<span className="infio-search-mode-description">{mode.description}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</DropdownMenu.Content>
|
||||||
|
</DropdownMenu.Portal>
|
||||||
|
</DropdownMenu.Root>
|
||||||
|
<style>{`
|
||||||
|
button.infio-chat-input-search-mode-select {
|
||||||
|
background-color: transparent;
|
||||||
|
box-shadow: none;
|
||||||
|
border: 1px solid var(--background-modifier-border);
|
||||||
|
padding: var(--size-2-1) var(--size-2-2);
|
||||||
|
font-size: var(--font-smallest);
|
||||||
|
font-weight: var(--font-medium);
|
||||||
|
color: var(--text-muted);
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-start;
|
||||||
|
align-items: center;
|
||||||
|
cursor: pointer;
|
||||||
|
height: auto;
|
||||||
|
max-width: 100%;
|
||||||
|
gap: var(--size-2-2);
|
||||||
|
border-radius: var(--radius-l);
|
||||||
|
transition: all 0.15s ease-in-out;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: var(--text-normal);
|
||||||
|
background-color: var(--background-modifier-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.infio-chat-input-search-mode-select__mode-name {
|
||||||
|
flex-shrink: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
flex-grow: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.infio-chat-input-search-mode-select__icon {
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.infio-search-mode-select-content {
|
||||||
|
min-width: auto !important;
|
||||||
|
width: fit-content !important;
|
||||||
|
max-width: 280px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.infio-search-mode-item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
padding: var(--size-4-2) var(--size-4-2);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.infio-search-mode-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--size-2-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.infio-search-mode-icon {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--text-accent);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.infio-search-mode-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--size-2-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.infio-search-mode-name {
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-weight: var(--font-medium);
|
||||||
|
}
|
||||||
|
|
||||||
|
.infio-search-mode-description {
|
||||||
|
font-size: var(--font-smallest);
|
||||||
|
color: var(--text-muted);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -1,14 +1,53 @@
|
|||||||
import { CornerDownLeftIcon } from 'lucide-react'
|
import { ArrowUpIcon } from 'lucide-react'
|
||||||
|
|
||||||
import { t } from '../../../lang/helpers'
|
// import { t } from '../../../lang/helpers'
|
||||||
|
|
||||||
export function SubmitButton({ onClick }: { onClick: () => void }) {
|
export function SubmitButton({ onClick }: { onClick: () => void }) {
|
||||||
return (
|
return (
|
||||||
<button className="infio-chat-user-input-submit-button" onClick={onClick}>
|
<>
|
||||||
{t('chat.input.submit')}
|
<button className="infio-chat-user-input-submit1-button" onClick={onClick}>
|
||||||
<div className="infio-chat-user-input-submit-button-icons">
|
{/* {t('chat.input.submit')} */}
|
||||||
<CornerDownLeftIcon size={12} />
|
<div className="infio-chat-user-input-submit1-button-icons">
|
||||||
</div>
|
<ArrowUpIcon size={14} />
|
||||||
</button>
|
</div>
|
||||||
)
|
</button>
|
||||||
|
<style>
|
||||||
|
{`
|
||||||
|
.infio-chat-user-input-controls .infio-chat-user-input-submit1-button {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: var(--size-4-1);
|
||||||
|
font-size: var(--font-smallest);
|
||||||
|
color: var(--text-on-accent);
|
||||||
|
background-color: var(--interactive-accent);
|
||||||
|
border: none;
|
||||||
|
box-shadow: none;
|
||||||
|
padding: 0;
|
||||||
|
border-radius: 50%;
|
||||||
|
width: var(--size-4-5);
|
||||||
|
height: var(--size-4-5);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s ease-in-out;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: var(--interactive-accent-hover);
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
transform: scale(0.95);
|
||||||
|
}
|
||||||
|
|
||||||
|
.infio-chat-user-input-submit-button-icons {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
</style>
|
||||||
|
</>
|
||||||
|
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
139
src/components/common/ErrorBoundary.tsx
Normal file
139
src/components/common/ErrorBoundary.tsx
Normal file
@ -0,0 +1,139 @@
|
|||||||
|
import { AlertTriangle } from 'lucide-react'
|
||||||
|
import React, { Component, ReactNode } from 'react'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
children: ReactNode
|
||||||
|
fallback?: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
interface State {
|
||||||
|
hasError: boolean
|
||||||
|
error?: Error
|
||||||
|
}
|
||||||
|
|
||||||
|
class ErrorBoundary extends Component<Props, State> {
|
||||||
|
constructor(props: Props) {
|
||||||
|
super(props)
|
||||||
|
this.state = { hasError: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
static getDerivedStateFromError(error: Error): State {
|
||||||
|
// 更新 state 使下一次渲染能够显示降级后的 UI
|
||||||
|
return { hasError: true, error }
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
|
||||||
|
// 你同样可以将错误日志上报给服务器
|
||||||
|
console.error('ErrorBoundary caught an error:', error, errorInfo)
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
if (this.state.hasError) {
|
||||||
|
// 你可以自定义降级后的 UI 并渲染
|
||||||
|
if (this.props.fallback) {
|
||||||
|
return this.props.fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="infio-error-boundary">
|
||||||
|
<div className="infio-error-boundary-content">
|
||||||
|
<AlertTriangle size={24} color="var(--text-error)" />
|
||||||
|
<h3>出现了一个错误</h3>
|
||||||
|
<p>渲染此组件时发生了错误。请尝试刷新页面或重新打开聊天窗口。</p>
|
||||||
|
{this.state.error && (
|
||||||
|
<details className="infio-error-details">
|
||||||
|
<summary>错误详情</summary>
|
||||||
|
<pre>{this.state.error.toString()}</pre>
|
||||||
|
</details>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => this.setState({ hasError: false, error: undefined })}
|
||||||
|
className="infio-retry-button"
|
||||||
|
>
|
||||||
|
重试
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
{`
|
||||||
|
.infio-error-boundary {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: var(--size-4-4);
|
||||||
|
background: var(--background-secondary);
|
||||||
|
border: 1px solid var(--background-modifier-border);
|
||||||
|
border-radius: var(--radius-s);
|
||||||
|
margin: var(--size-2-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.infio-error-boundary-content {
|
||||||
|
text-align: center;
|
||||||
|
max-width: 400px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--size-4-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.infio-error-boundary-content h3 {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text-error);
|
||||||
|
font-size: var(--font-ui-large);
|
||||||
|
}
|
||||||
|
|
||||||
|
.infio-error-boundary-content p {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text-normal);
|
||||||
|
line-height: var(--line-height-normal);
|
||||||
|
}
|
||||||
|
|
||||||
|
.infio-error-details {
|
||||||
|
width: 100%;
|
||||||
|
margin-top: var(--size-2-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.infio-error-details summary {
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: var(--font-ui-small);
|
||||||
|
}
|
||||||
|
|
||||||
|
.infio-error-details pre {
|
||||||
|
background: var(--background-primary-alt);
|
||||||
|
padding: var(--size-2-2);
|
||||||
|
border-radius: var(--radius-s);
|
||||||
|
font-size: var(--font-text-small);
|
||||||
|
color: var(--text-error);
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-wrap: break-word;
|
||||||
|
margin-top: var(--size-2-1);
|
||||||
|
max-height: 200px;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.infio-retry-button {
|
||||||
|
background: var(--interactive-accent);
|
||||||
|
color: var(--text-on-accent);
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-s);
|
||||||
|
padding: var(--size-2-2) var(--size-4-2);
|
||||||
|
font-size: var(--font-ui-medium);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.15s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.infio-retry-button:hover {
|
||||||
|
background: var(--interactive-accent-hover);
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
</style>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.props.children
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ErrorBoundary
|
||||||
246
src/components/modals/ApiKeyModal.tsx
Normal file
246
src/components/modals/ApiKeyModal.tsx
Normal file
@ -0,0 +1,246 @@
|
|||||||
|
import { App, Modal } from 'obsidian'
|
||||||
|
import { createRoot, Root } from 'react-dom/client'
|
||||||
|
|
||||||
|
import { INFIO_PLATFORM_URL } from '../../constants'
|
||||||
|
interface ApiKeyModalContentProps {
|
||||||
|
onClose: () => void
|
||||||
|
app: App
|
||||||
|
}
|
||||||
|
|
||||||
|
const ApiKeyModalContent: React.FC<ApiKeyModalContentProps> = ({ onClose, app }) => {
|
||||||
|
const handleGetApiKeyClick = () => {
|
||||||
|
window.open(`${INFIO_PLATFORM_URL}/api-keys`, '_blank')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleUpgradeProClick = () => {
|
||||||
|
window.open(`${INFIO_PLATFORM_URL}/billing`, '_blank')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleOpenSettingsClick = () => {
|
||||||
|
onClose()
|
||||||
|
// 打开设置面板到 Infio Provider 选项卡
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const setting = (app as any).setting
|
||||||
|
setting.open()
|
||||||
|
setting.openTabById('infio-copilot')
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="api-key-modal-content">
|
||||||
|
<div className="api-key-modal-header">
|
||||||
|
<h2>配置 API Key</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="api-key-modal-body">
|
||||||
|
<div className="api-key-message">
|
||||||
|
{/* <div className="api-key-icon">🔑</div> */}
|
||||||
|
<p>请先在 Infio Provider 设置中配置 Infio API Key</p>
|
||||||
|
<p>您可以按照以下步骤获取和配置 API Key:</p>
|
||||||
|
|
||||||
|
<div className="api-key-steps">
|
||||||
|
<div className="api-key-step">
|
||||||
|
<div className="step-number">1</div>
|
||||||
|
<div className="step-content">
|
||||||
|
<h4>获取 API Key</h4>
|
||||||
|
<p>访问 Infio 平台获取您的 API Key</p>
|
||||||
|
<button className="step-action-btn" onClick={handleGetApiKeyClick}>
|
||||||
|
获取 API Key
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="api-key-step">
|
||||||
|
<div className="step-number">2</div>
|
||||||
|
<div className="step-content">
|
||||||
|
<h4>确保已升级为 Pro 会员</h4>
|
||||||
|
<p>升级到 Pro 会员以享受更多高级功能和模型访问权限</p>
|
||||||
|
<button className="step-action-btn" onClick={handleUpgradeProClick}>
|
||||||
|
升级到 Pro
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="api-key-step">
|
||||||
|
<div className="step-number">3</div>
|
||||||
|
<div className="step-content">
|
||||||
|
<h4>配置 Infio API Key</h4>
|
||||||
|
<p>在插件设置中的 Infio Provider 部分配置您的 Infio API Key</p>
|
||||||
|
<button className="step-action-btn" onClick={handleOpenSettingsClick}>
|
||||||
|
打开设置
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="api-key-modal-footer">
|
||||||
|
<button className="api-key-btn-close" onClick={onClose}>
|
||||||
|
关闭
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
{`
|
||||||
|
.api-key-modal-content {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-key-modal-header {
|
||||||
|
padding: 20px 24px 16px;
|
||||||
|
border-bottom: 1px solid var(--background-modifier-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-key-modal-header h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-normal);
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-key-modal-body {
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-key-message {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-key-icon {
|
||||||
|
font-size: 48px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-key-message p {
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: var(--text-normal);
|
||||||
|
margin: 0 0 16px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-key-steps {
|
||||||
|
margin: 24px 0;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-key-step {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
padding: 16px;
|
||||||
|
background: var(--background-secondary);
|
||||||
|
border-radius: var(--radius-m);
|
||||||
|
border-left: 3px solid var(--interactive-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-number {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
background: var(--interactive-accent);
|
||||||
|
color: var(--text-on-accent);
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 16px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-content {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-content h4 {
|
||||||
|
margin: 0 0 8px 0;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-normal);
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-content p {
|
||||||
|
margin: 0 0 12px 0;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-action-btn {
|
||||||
|
padding: 8px 16px;
|
||||||
|
border: 1px solid var(--interactive-accent);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--interactive-accent);
|
||||||
|
border-radius: var(--radius-s);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-action-btn:hover {
|
||||||
|
background: var(--interactive-accent);
|
||||||
|
color: var(--text-on-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-key-modal-footer {
|
||||||
|
padding: 16px 24px 24px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-key-btn-close {
|
||||||
|
padding: 10px 24px;
|
||||||
|
border: 1px solid var(--background-modifier-border);
|
||||||
|
background: var(--background-secondary);
|
||||||
|
color: var(--text-normal);
|
||||||
|
border-radius: var(--radius-s);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-key-btn-close:hover {
|
||||||
|
background: var(--background-modifier-hover);
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
</style>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ApiKeyModal extends Modal {
|
||||||
|
private root: Root | null = null
|
||||||
|
|
||||||
|
constructor(app: App) {
|
||||||
|
super(app)
|
||||||
|
}
|
||||||
|
|
||||||
|
onOpen(): void {
|
||||||
|
const { contentEl, modalEl } = this
|
||||||
|
|
||||||
|
// 添加特定的CSS类
|
||||||
|
modalEl.addClass('mod-api-key')
|
||||||
|
|
||||||
|
// 设置模态框样式
|
||||||
|
modalEl.style.width = '520px'
|
||||||
|
modalEl.style.maxWidth = '90vw'
|
||||||
|
|
||||||
|
this.root = createRoot(contentEl)
|
||||||
|
|
||||||
|
this.root.render(
|
||||||
|
<ApiKeyModalContent
|
||||||
|
onClose={() => this.close()}
|
||||||
|
app={this.app}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
onClose(): void {
|
||||||
|
const { contentEl, modalEl } = this
|
||||||
|
modalEl.removeClass('mod-api-key')
|
||||||
|
this.root?.unmount()
|
||||||
|
this.root = null
|
||||||
|
contentEl.empty()
|
||||||
|
}
|
||||||
|
}
|
||||||
183
src/components/modals/ProUpgradeModal.tsx
Normal file
183
src/components/modals/ProUpgradeModal.tsx
Normal file
@ -0,0 +1,183 @@
|
|||||||
|
import { App, Modal } from 'obsidian'
|
||||||
|
import { createRoot, Root } from 'react-dom/client'
|
||||||
|
|
||||||
|
interface ProUpgradeModalContentProps {
|
||||||
|
onClose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const ProUpgradeModalContent: React.FC<ProUpgradeModalContentProps> = ({ onClose }) => {
|
||||||
|
const handleUpgradeClick = () => {
|
||||||
|
window.open('https://platform.infio.app/billing', '_blank')
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="pro-upgrade-modal-content">
|
||||||
|
<div className="pro-upgrade-modal-header">
|
||||||
|
<h2>升级到 Pro 会员</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pro-upgrade-modal-body">
|
||||||
|
<div className="pro-upgrade-message">
|
||||||
|
<div className="pro-upgrade-icon">⭐</div>
|
||||||
|
<p>您的账户不是Pro会员,无法升级到Pro版本。</p>
|
||||||
|
<p>请先升级到Pro,享受更多高级功能:</p>
|
||||||
|
<ul className="pro-upgrade-features">
|
||||||
|
<li>✨ 全新的UI界面</li>
|
||||||
|
<li>🚀 在线市场使用权限</li>
|
||||||
|
<li>💎 专注任务模型访问权限</li>
|
||||||
|
<li>🎯 专业技术支持</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pro-upgrade-modal-footer">
|
||||||
|
<button className="pro-upgrade-btn-cancel" onClick={onClose}>
|
||||||
|
关闭
|
||||||
|
</button>
|
||||||
|
<button className="pro-upgrade-btn-upgrade" onClick={handleUpgradeClick}>
|
||||||
|
立即升级 Pro
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
{`
|
||||||
|
.pro-upgrade-modal-content {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pro-upgrade-modal-header {
|
||||||
|
padding: 20px 24px 16px;
|
||||||
|
border-bottom: 1px solid var(--background-modifier-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pro-upgrade-modal-header h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-normal);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pro-upgrade-modal-body {
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pro-upgrade-message {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pro-upgrade-icon {
|
||||||
|
font-size: 48px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pro-upgrade-message p {
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: var(--text-normal);
|
||||||
|
margin: 0 0 16px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pro-upgrade-features {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 20px 0;
|
||||||
|
text-align: left;
|
||||||
|
background: var(--background-secondary);
|
||||||
|
border-radius: var(--radius-m);
|
||||||
|
padding: 16px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pro-upgrade-features li {
|
||||||
|
padding: 8px 0;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-normal);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pro-upgrade-modal-footer {
|
||||||
|
padding: 16px 24px 24px;
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pro-upgrade-btn-cancel {
|
||||||
|
padding: 10px 20px;
|
||||||
|
border: 1px solid var(--background-modifier-border);
|
||||||
|
background: var(--background-secondary);
|
||||||
|
color: var(--text-normal);
|
||||||
|
border-radius: var(--radius-s);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pro-upgrade-btn-cancel:hover {
|
||||||
|
background: var(--background-modifier-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pro-upgrade-btn-upgrade {
|
||||||
|
padding: 10px 20px;
|
||||||
|
border: none;
|
||||||
|
background: linear-gradient(135deg, var(--interactive-accent), var(--interactive-accent-hover));
|
||||||
|
color: var(--text-on-accent);
|
||||||
|
border-radius: var(--radius-s);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pro-upgrade-btn-upgrade:hover {
|
||||||
|
background: linear-gradient(135deg, var(--interactive-accent-hover), var(--interactive-accent));
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pro-upgrade-btn-upgrade:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
</style>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ProUpgradeModal extends Modal {
|
||||||
|
private root: Root | null = null
|
||||||
|
|
||||||
|
constructor(app: App) {
|
||||||
|
super(app)
|
||||||
|
}
|
||||||
|
|
||||||
|
onOpen(): void {
|
||||||
|
const { contentEl, modalEl } = this
|
||||||
|
|
||||||
|
// 添加特定的CSS类
|
||||||
|
modalEl.addClass('mod-pro-upgrade')
|
||||||
|
|
||||||
|
// 设置模态框样式
|
||||||
|
modalEl.style.width = '480px'
|
||||||
|
modalEl.style.maxWidth = '90vw'
|
||||||
|
|
||||||
|
this.root = createRoot(contentEl)
|
||||||
|
|
||||||
|
this.root.render(
|
||||||
|
<ProUpgradeModalContent
|
||||||
|
onClose={() => this.close()}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
onClose(): void {
|
||||||
|
const { contentEl, modalEl } = this
|
||||||
|
modalEl.removeClass('mod-pro-upgrade')
|
||||||
|
this.root?.unmount()
|
||||||
|
this.root = null
|
||||||
|
contentEl.empty()
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -14,19 +14,36 @@ export default function PreviewViewRoot({
|
|||||||
const closeIcon = getIcon('x')
|
const closeIcon = getIcon('x')
|
||||||
const contentRef = useRef<HTMLDivElement>(null)
|
const contentRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
// 显示原始文本内容
|
// 显示内容 - 支持 HTML 和纯文本
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (contentRef.current && state.content) {
|
if (contentRef.current && state.content) {
|
||||||
// 清空现有内容
|
// 清空现有内容
|
||||||
contentRef.current.empty()
|
contentRef.current.innerHTML = ''
|
||||||
|
|
||||||
// 创建预格式化文本元素
|
// 判断是否为 HTML 内容(包含 SVG)
|
||||||
const preElement = document.createElement('pre')
|
const isHtmlContent = state.content.trim().startsWith('<') &&
|
||||||
preElement.className = 'infio-raw-content'
|
(state.content.includes('<svg') || state.content.includes('<div') ||
|
||||||
preElement.textContent = state.content
|
state.content.includes('<span') || state.content.includes('<pre'))
|
||||||
|
|
||||||
// 添加到容器
|
if (isHtmlContent) {
|
||||||
contentRef.current.appendChild(preElement)
|
// 如果是 HTML 内容,直接渲染
|
||||||
|
contentRef.current.innerHTML = state.content
|
||||||
|
|
||||||
|
// 为 SVG 添加适当的样式
|
||||||
|
const svgElements = contentRef.current.querySelectorAll('svg')
|
||||||
|
svgElements.forEach(svg => {
|
||||||
|
svg.style.maxWidth = '100%'
|
||||||
|
svg.style.height = 'auto'
|
||||||
|
svg.style.display = 'block'
|
||||||
|
svg.style.margin = '0 auto'
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
// 如果是纯文本,创建预格式化文本元素
|
||||||
|
const preElement = document.createElement('pre')
|
||||||
|
preElement.className = 'infio-raw-content'
|
||||||
|
preElement.textContent = state.content
|
||||||
|
contentRef.current.appendChild(preElement)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [state.content, state.file])
|
}, [state.content, state.file])
|
||||||
|
|
||||||
@ -61,7 +78,7 @@ export default function PreviewViewRoot({
|
|||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
ref={contentRef}
|
ref={contentRef}
|
||||||
className="markdown-preview-section"
|
className="markdown-preview-section infio-preview-content"
|
||||||
></div>
|
></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -92,6 +109,19 @@ export default function PreviewViewRoot({
|
|||||||
padding: 10px 0;
|
padding: 10px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.infio-preview-content {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.infio-preview-content svg {
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
display: block;
|
||||||
|
margin: 0 auto;
|
||||||
|
border-radius: var(--radius-s);
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
.infio-raw-content {
|
.infio-raw-content {
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
@ -99,6 +129,7 @@ export default function PreviewViewRoot({
|
|||||||
padding: 10px;
|
padding: 10px;
|
||||||
background-color: var(--background-secondary);
|
background-color: var(--background-secondary);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
|
text-align: left;
|
||||||
}
|
}
|
||||||
`}</style>
|
`}</style>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -3,22 +3,10 @@ import { LLMModel } from './types/llm/model'
|
|||||||
export const CHAT_VIEW_TYPE = 'infio-chat-view'
|
export const CHAT_VIEW_TYPE = 'infio-chat-view'
|
||||||
export const APPLY_VIEW_TYPE = 'infio-apply-view'
|
export const APPLY_VIEW_TYPE = 'infio-apply-view'
|
||||||
export const PREVIEW_VIEW_TYPE = 'infio-preview-view'
|
export const PREVIEW_VIEW_TYPE = 'infio-preview-view'
|
||||||
|
export const JSON_VIEW_TYPE = 'infio-json-view'
|
||||||
|
|
||||||
export const DEFAULT_MODELS: LLMModel[] = []
|
export const DEFAULT_MODELS: LLMModel[] = []
|
||||||
|
|
||||||
// export const PROVIDERS: ApiProvider[] = [
|
|
||||||
// 'Infio',
|
|
||||||
// 'OpenRouter',
|
|
||||||
// 'SiliconFlow',
|
|
||||||
// 'Anthropic',
|
|
||||||
// 'Deepseek',
|
|
||||||
// 'OpenAI',
|
|
||||||
// 'Google',
|
|
||||||
// 'Groq',
|
|
||||||
// 'Ollama',
|
|
||||||
// 'OpenAICompatible',
|
|
||||||
// ]
|
|
||||||
|
|
||||||
export const SUPPORT_EMBEDDING_SIMENTION: number[] = [
|
export const SUPPORT_EMBEDDING_SIMENTION: number[] = [
|
||||||
384,
|
384,
|
||||||
512,
|
512,
|
||||||
@ -33,7 +21,9 @@ export const OPENROUTER_BASE_URL = 'https://openrouter.ai/api/v1'
|
|||||||
export const GROK_BASE_URL = 'https://api.x.ai/v1'
|
export const GROK_BASE_URL = 'https://api.x.ai/v1'
|
||||||
export const SILICONFLOW_BASE_URL = 'https://api.siliconflow.cn/v1'
|
export const SILICONFLOW_BASE_URL = 'https://api.siliconflow.cn/v1'
|
||||||
export const ALIBABA_QWEN_BASE_URL = 'https://dashscope.aliyuncs.com/compatible-mode/v1'
|
export const ALIBABA_QWEN_BASE_URL = 'https://dashscope.aliyuncs.com/compatible-mode/v1'
|
||||||
|
export const MOONSHOT_BASE_URL = 'https://api.moonshot.cn/v1'
|
||||||
export const INFIO_BASE_URL = 'https://api.infio.app'
|
export const INFIO_BASE_URL = 'https://api.infio.app'
|
||||||
|
export const INFIO_PLATFORM_URL = 'https://platform.infio.app'
|
||||||
export const JINA_BASE_URL = 'https://r.jina.ai'
|
export const JINA_BASE_URL = 'https://r.jina.ai'
|
||||||
export const SERPER_BASE_URL = 'https://serpapi.com/search'
|
export const SERPER_BASE_URL = 'https://serpapi.com/search'
|
||||||
// Pricing in dollars per million tokens
|
// Pricing in dollars per million tokens
|
||||||
|
|||||||
@ -22,7 +22,14 @@ export function DarkModeProvider({ children }: { children: ReactNode }) {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleDarkMode = () => {
|
const handleDarkMode = () => {
|
||||||
setIsDarkMode(document.body.classList.contains('theme-dark'))
|
const newIsDarkMode = document.body.classList.contains('theme-dark')
|
||||||
|
// 只在实际发生变化时才更新状态
|
||||||
|
setIsDarkMode(prevIsDarkMode => {
|
||||||
|
if (prevIsDarkMode !== newIsDarkMode) {
|
||||||
|
return newIsDarkMode
|
||||||
|
}
|
||||||
|
return prevIsDarkMode
|
||||||
|
})
|
||||||
}
|
}
|
||||||
handleDarkMode()
|
handleDarkMode()
|
||||||
app.workspace.on('css-change', handleDarkMode)
|
app.workspace.on('css-change', handleDarkMode)
|
||||||
|
|||||||
28
src/contexts/DataviewContext.tsx
Normal file
28
src/contexts/DataviewContext.tsx
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
import {
|
||||||
|
PropsWithChildren,
|
||||||
|
createContext,
|
||||||
|
useContext,
|
||||||
|
useMemo
|
||||||
|
} from 'react'
|
||||||
|
|
||||||
|
import { DataviewManager } from '../utils/dataview'
|
||||||
|
|
||||||
|
const DataviewContext = createContext<DataviewManager | null>(null)
|
||||||
|
|
||||||
|
export function DataviewProvider({
|
||||||
|
dataviewManager,
|
||||||
|
children,
|
||||||
|
}: PropsWithChildren<{ dataviewManager: DataviewManager | null }>) {
|
||||||
|
const value = useMemo(() => {
|
||||||
|
return dataviewManager
|
||||||
|
}, [dataviewManager])
|
||||||
|
|
||||||
|
return <DataviewContext.Provider value={value}>{children}</DataviewContext.Provider>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDataview(): DataviewManager | null {
|
||||||
|
const context = useContext(DataviewContext)
|
||||||
|
// 注意:这里不抛出错误,允许返回 null
|
||||||
|
// 调用者需要自己检查 context 是否为 null
|
||||||
|
return context
|
||||||
|
}
|
||||||
39
src/contexts/TransContext.tsx
Normal file
39
src/contexts/TransContext.tsx
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
import {
|
||||||
|
PropsWithChildren,
|
||||||
|
createContext,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
} from 'react'
|
||||||
|
|
||||||
|
import { TransEngine } from '../core/transformations/trans-engine'
|
||||||
|
|
||||||
|
export type TransContextType = {
|
||||||
|
getTransEngine: () => Promise<TransEngine>
|
||||||
|
}
|
||||||
|
|
||||||
|
const TransContext = createContext<TransContextType | null>(null)
|
||||||
|
|
||||||
|
export function TransProvider({
|
||||||
|
getTransEngine,
|
||||||
|
children,
|
||||||
|
}: PropsWithChildren<{ getTransEngine: () => Promise<TransEngine> }>) {
|
||||||
|
useEffect(() => {
|
||||||
|
// start initialization of transEngine in the background
|
||||||
|
void getTransEngine()
|
||||||
|
}, [getTransEngine])
|
||||||
|
|
||||||
|
const value = useMemo(() => {
|
||||||
|
return { getTransEngine }
|
||||||
|
}, [getTransEngine])
|
||||||
|
|
||||||
|
return <TransContext.Provider value={value}>{children}</TransContext.Provider>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTrans() {
|
||||||
|
const context = useContext(TransContext)
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useTrans must be used within a TransProvider')
|
||||||
|
}
|
||||||
|
return context
|
||||||
|
}
|
||||||
@ -55,10 +55,15 @@ Only a single operation is allowed per tool use.
|
|||||||
The SEARCH section must exactly match existing content including whitespace and indentation.
|
The SEARCH section must exactly match existing content including whitespace and indentation.
|
||||||
If you're not confident in the exact content to search for, use the read_file tool first to get the exact content.
|
If you're not confident in the exact content to search for, use the read_file tool first to get the exact content.
|
||||||
When applying changes to Markdown, be careful about maintaining list structures, heading levels, and other Markdown formatting.
|
When applying changes to Markdown, be careful about maintaining list structures, heading levels, and other Markdown formatting.
|
||||||
ALWAYS make as many changes in a single 'apply_diff' request as possible using multiple SEARCH/REPLACE blocks
|
**IMPORTANT STRATEGY**: Use this tool ONLY for modifying specific, contiguous blocks of text.
|
||||||
|
- **STRICT 20-LINE LIMIT**: Each SEARCH block MUST NOT exceed 20 lines. This is a hard limit to ensure accuracy and avoid errors.
|
||||||
|
- **For larger changes**: If a single block of changes is more than 20 lines, or if you are rewriting most of the file, you MUST use \`write_to_file\` instead.
|
||||||
|
- **For scattered changes**: If you need to make the same small change in many different places, use \`search_and_replace\` as it is more efficient.
|
||||||
|
|
||||||
|
ALWAYS make as many changes in a single 'apply_diff' request as possible using multiple SEARCH/REPLACE blocks, but respect the 20-line limit per block.
|
||||||
|
|
||||||
Parameters:
|
Parameters:
|
||||||
- path: (required) The path of the file to modify (relative to the current working directory ${args.cwd})
|
- path: (required) The path of the file to modify
|
||||||
- diff: (required) The search/replace block defining the changes.
|
- diff: (required) The search/replace block defining the changes.
|
||||||
|
|
||||||
Diff format:
|
Diff format:
|
||||||
@ -72,72 +77,58 @@ Diff format:
|
|||||||
[new content to replace with]
|
[new content to replace with]
|
||||||
>>>>>>> REPLACE
|
>>>>>>> REPLACE
|
||||||
|
|
||||||
\`\`\`
|
|
||||||
|
|
||||||
Example:
|
|
||||||
|
|
||||||
Original Markdown file:
|
|
||||||
\`\`\`
|
|
||||||
1 | # Project Notes
|
|
||||||
2 |
|
|
||||||
3 | ## Tasks
|
|
||||||
4 | - [ ] Review documentation
|
|
||||||
5 | - [ ] Update examples
|
|
||||||
6 | - [ ] Add new section
|
|
||||||
\`\`\`
|
|
||||||
|
|
||||||
Search/Replace content:
|
|
||||||
\`\`\`
|
|
||||||
<<<<<<< SEARCH
|
|
||||||
:start_line:3
|
|
||||||
:end_line:6
|
|
||||||
-------
|
|
||||||
## Tasks
|
|
||||||
- [ ] Review documentation
|
|
||||||
- [ ] Update examples
|
|
||||||
- [ ] Add new section
|
|
||||||
=======
|
|
||||||
## Current Tasks
|
|
||||||
- [ ] Review documentation
|
|
||||||
- [x] Update examples
|
|
||||||
- [ ] Add new section
|
|
||||||
- [ ] Schedule team meeting
|
|
||||||
>>>>>>> REPLACE
|
|
||||||
|
|
||||||
\`\`\`
|
|
||||||
|
|
||||||
Search/Replace content with multi edits:
|
|
||||||
\`\`\`
|
|
||||||
<<<<<<< SEARCH
|
|
||||||
:start_line:1
|
|
||||||
:end_line:1
|
|
||||||
-------
|
|
||||||
# Project Notes
|
|
||||||
=======
|
|
||||||
# Project Notes (Updated)
|
|
||||||
>>>>>>> REPLACE
|
|
||||||
|
|
||||||
<<<<<<< SEARCH
|
|
||||||
:start_line:4
|
|
||||||
:end_line:5
|
|
||||||
-------
|
|
||||||
- [ ] Review documentation
|
|
||||||
- [ ] Update examples
|
|
||||||
=======
|
|
||||||
- [ ] Review documentation (priority)
|
|
||||||
- [x] Update examples
|
|
||||||
>>>>>>> REPLACE
|
|
||||||
\`\`\`
|
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
<apply_diff>
|
<apply_diff>
|
||||||
<path>File path here</path>
|
<path>File path here</path>
|
||||||
<diff>
|
<diff>
|
||||||
Your search/replace content here
|
Your search/replace content here
|
||||||
You can use multi search/replace block in one diff block, but make sure to include the line numbers for each block.
|
Only use a single line of '=======' between search and replacement content.
|
||||||
Only use a single line of '=======' between search and replacement content, because multiple '=======' will corrupt the file.
|
|
||||||
</diff>
|
</diff>
|
||||||
</apply_diff>`
|
</apply_diff>
|
||||||
|
|
||||||
|
Example1: CORRECT - Making multiple, small, independent changes in one call.
|
||||||
|
This is efficient for applying several unrelated fixes. Notice that each block is very small and respects the 20-line limit.
|
||||||
|
|
||||||
|
<apply_diff>
|
||||||
|
<path>notes.md</path>
|
||||||
|
<diff>
|
||||||
|
<<<<<<< SEARCH
|
||||||
|
:start_line:1
|
||||||
|
:end_line:1
|
||||||
|
-------
|
||||||
|
# Meeting Notes
|
||||||
|
=======
|
||||||
|
# Strategic Meeting Notes
|
||||||
|
>>>>>>> REPLACE
|
||||||
|
|
||||||
|
<<<<<<< SEARCH
|
||||||
|
:start_line:4
|
||||||
|
:end_line:4
|
||||||
|
-------
|
||||||
|
- Discuss Q3 roadmap
|
||||||
|
=======
|
||||||
|
- Finalize Q3 roadmap
|
||||||
|
>>>>>>> REPLACE
|
||||||
|
</diff>
|
||||||
|
</apply_diff>
|
||||||
|
|
||||||
|
Example 2: INCORRECT USAGE (ANTI-PATTERN) - The diff block is too large.
|
||||||
|
This demonstrates what NOT to do. A diff block of this size MUST BE REJECTED.
|
||||||
|
|
||||||
|
<apply_diff>
|
||||||
|
<path>long_file.md</path>
|
||||||
|
<diff>
|
||||||
|
<<<<<<< SEARCH
|
||||||
|
:start_line:10
|
||||||
|
:end_line:45
|
||||||
|
-------
|
||||||
|
... (35 lines of text to be replaced) ...
|
||||||
|
=======
|
||||||
|
... (new content) ...
|
||||||
|
>>>>>>> REPLACE
|
||||||
|
</diff>
|
||||||
|
</apply_diff>
|
||||||
|
`
|
||||||
}
|
}
|
||||||
|
|
||||||
async applyDiff(
|
async applyDiff(
|
||||||
@ -146,7 +137,7 @@ Only use a single line of '=======' between search and replacement content, beca
|
|||||||
_paramStartLine?: number,
|
_paramStartLine?: number,
|
||||||
_paramEndLine?: number,
|
_paramEndLine?: number,
|
||||||
): Promise<DiffResult> {
|
): Promise<DiffResult> {
|
||||||
let matches = [
|
const matches = [
|
||||||
...diffContent.matchAll(
|
...diffContent.matchAll(
|
||||||
/<<<<<<< SEARCH\n(:start_line:\s*(\d+)\n){0,1}(:end_line:\s*(\d+)\n){0,1}(-------\n){0,1}([\s\S]*?)\n?=======\n([\s\S]*?)\n?>>>>>>> REPLACE/g,
|
/<<<<<<< SEARCH\n(:start_line:\s*(\d+)\n){0,1}(:end_line:\s*(\d+)\n){0,1}(-------\n){0,1}([\s\S]*?)\n?=======\n([\s\S]*?)\n?>>>>>>> REPLACE/g,
|
||||||
),
|
),
|
||||||
@ -162,7 +153,7 @@ Only use a single line of '=======' between search and replacement content, beca
|
|||||||
const lineEnding = originalContent.includes("\r\n") ? "\r\n" : "\n"
|
const lineEnding = originalContent.includes("\r\n") ? "\r\n" : "\n"
|
||||||
let resultLines = originalContent.split(/\r?\n/)
|
let resultLines = originalContent.split(/\r?\n/)
|
||||||
let delta = 0
|
let delta = 0
|
||||||
let diffResults: DiffResult[] = []
|
const diffResults: DiffResult[] = []
|
||||||
let appliedCount = 0
|
let appliedCount = 0
|
||||||
const replacements = matches
|
const replacements = matches
|
||||||
.map((match) => ({
|
.map((match) => ({
|
||||||
@ -313,24 +304,25 @@ Only use a single line of '=======' between search and replacement content, beca
|
|||||||
const matchedLines = resultLines.slice(matchIndex, matchIndex + searchLines.length)
|
const matchedLines = resultLines.slice(matchIndex, matchIndex + searchLines.length)
|
||||||
|
|
||||||
// Get the exact indentation (preserving tabs/spaces) of each line
|
// Get the exact indentation (preserving tabs/spaces) of each line
|
||||||
|
const indentRegex = /^[\t ]*/
|
||||||
const originalIndents = matchedLines.map((line) => {
|
const originalIndents = matchedLines.map((line) => {
|
||||||
const match = line.match(/^[\t ]*/)
|
const match = indentRegex.exec(line)
|
||||||
return match ? match[0] : ""
|
return match ? match[0] : ""
|
||||||
})
|
})
|
||||||
|
|
||||||
// Get the exact indentation of each line in the search block
|
// Get the exact indentation of each line in the search block
|
||||||
const searchIndents = searchLines.map((line) => {
|
const searchIndents = searchLines.map((line) => {
|
||||||
const match = line.match(/^[\t ]*/)
|
const match = indentRegex.exec(line)
|
||||||
return match ? match[0] : ""
|
return match ? match[0] : ""
|
||||||
})
|
})
|
||||||
|
|
||||||
// Apply the replacement while preserving exact indentation
|
// Apply the replacement while preserving exact indentation
|
||||||
const indentedReplaceLines = replaceLines.map((line, i) => {
|
const indentedReplaceLines = replaceLines.map((line) => {
|
||||||
// Get the matched line's exact indentation
|
// Get the matched line's exact indentation
|
||||||
const matchedIndent = originalIndents[0] || ""
|
const matchedIndent = originalIndents[0] || ""
|
||||||
|
|
||||||
// Get the current line's indentation relative to the search content
|
// Get the current line's indentation relative to the search content
|
||||||
const currentIndentMatch = line.match(/^[\t ]*/)
|
const currentIndentMatch = indentRegex.exec(line)
|
||||||
const currentIndent = currentIndentMatch ? currentIndentMatch[0] : ""
|
const currentIndent = currentIndentMatch ? currentIndentMatch[0] : ""
|
||||||
const searchBaseIndent = searchIndents[0] || ""
|
const searchBaseIndent = searchIndents[0] || ""
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { ALIBABA_QWEN_BASE_URL, DEEPSEEK_BASE_URL, GROK_BASE_URL, INFIO_BASE_URL, OPENROUTER_BASE_URL, SILICONFLOW_BASE_URL } from '../../constants'
|
import { ALIBABA_QWEN_BASE_URL, DEEPSEEK_BASE_URL, GROK_BASE_URL, INFIO_BASE_URL, MOONSHOT_BASE_URL, OPENROUTER_BASE_URL, SILICONFLOW_BASE_URL } from '../../constants'
|
||||||
import { ApiProvider, LLMModel } from '../../types/llm/model'
|
import { ApiProvider, LLMModel } from '../../types/llm/model'
|
||||||
import {
|
import {
|
||||||
LLMOptions,
|
LLMOptions,
|
||||||
@ -39,6 +39,7 @@ class LLMManager implements LLMManagerInterface {
|
|||||||
private googleProvider: GeminiProvider
|
private googleProvider: GeminiProvider
|
||||||
private groqProvider: GroqProvider
|
private groqProvider: GroqProvider
|
||||||
private grokProvider: OpenAICompatibleProvider
|
private grokProvider: OpenAICompatibleProvider
|
||||||
|
private moonshotProvider: OpenAICompatibleProvider
|
||||||
private infioProvider: OpenAICompatibleProvider
|
private infioProvider: OpenAICompatibleProvider
|
||||||
private openrouterProvider: OpenAICompatibleProvider
|
private openrouterProvider: OpenAICompatibleProvider
|
||||||
private siliconflowProvider: OpenAICompatibleProvider
|
private siliconflowProvider: OpenAICompatibleProvider
|
||||||
@ -85,6 +86,12 @@ class LLMManager implements LLMManagerInterface {
|
|||||||
settings.grokProvider.baseUrl
|
settings.grokProvider.baseUrl
|
||||||
: GROK_BASE_URL
|
: GROK_BASE_URL
|
||||||
)
|
)
|
||||||
|
this.moonshotProvider = new OpenAICompatibleProvider(
|
||||||
|
settings.moonshotProvider.apiKey,
|
||||||
|
settings.moonshotProvider.baseUrl && settings.moonshotProvider.useCustomUrl ?
|
||||||
|
settings.moonshotProvider.baseUrl
|
||||||
|
: MOONSHOT_BASE_URL
|
||||||
|
)
|
||||||
this.ollamaProvider = new OllamaProvider(settings.ollamaProvider.baseUrl)
|
this.ollamaProvider = new OllamaProvider(settings.ollamaProvider.baseUrl)
|
||||||
this.openaiCompatibleProvider = new OpenAICompatibleProvider(settings.openaicompatibleProvider.apiKey, settings.openaicompatibleProvider.baseUrl)
|
this.openaiCompatibleProvider = new OpenAICompatibleProvider(settings.openaicompatibleProvider.apiKey, settings.openaicompatibleProvider.baseUrl)
|
||||||
this.isInfioEnabled = !!settings.infioProvider.apiKey
|
this.isInfioEnabled = !!settings.infioProvider.apiKey
|
||||||
@ -158,6 +165,12 @@ class LLMManager implements LLMManagerInterface {
|
|||||||
request,
|
request,
|
||||||
options,
|
options,
|
||||||
)
|
)
|
||||||
|
case ApiProvider.Moonshot:
|
||||||
|
return await this.moonshotProvider.generateResponse(
|
||||||
|
model,
|
||||||
|
request,
|
||||||
|
options,
|
||||||
|
)
|
||||||
case ApiProvider.OpenAICompatible:
|
case ApiProvider.OpenAICompatible:
|
||||||
return await this.openaiCompatibleProvider.generateResponse(model, request, options)
|
return await this.openaiCompatibleProvider.generateResponse(model, request, options)
|
||||||
default:
|
default:
|
||||||
@ -195,6 +208,8 @@ class LLMManager implements LLMManagerInterface {
|
|||||||
return await this.groqProvider.streamResponse(model, request, options)
|
return await this.groqProvider.streamResponse(model, request, options)
|
||||||
case ApiProvider.Grok:
|
case ApiProvider.Grok:
|
||||||
return await this.grokProvider.streamResponse(model, request, options)
|
return await this.grokProvider.streamResponse(model, request, options)
|
||||||
|
case ApiProvider.Moonshot:
|
||||||
|
return await this.moonshotProvider.streamResponse(model, request, options)
|
||||||
case ApiProvider.Ollama:
|
case ApiProvider.Ollama:
|
||||||
return await this.ollamaProvider.streamResponse(model, request, options)
|
return await this.ollamaProvider.streamResponse(model, request, options)
|
||||||
case ApiProvider.OpenAICompatible:
|
case ApiProvider.OpenAICompatible:
|
||||||
|
|||||||
@ -24,7 +24,10 @@ import { OpenAIMessageAdapter } from './openai-message-adapter'
|
|||||||
|
|
||||||
export class NoStainlessOpenAI extends OpenAI {
|
export class NoStainlessOpenAI extends OpenAI {
|
||||||
defaultHeaders() {
|
defaultHeaders() {
|
||||||
|
// 获取父类的默认头部,包含 Authorization
|
||||||
|
const parentHeaders = super.defaultHeaders()
|
||||||
return {
|
return {
|
||||||
|
...parentHeaders,
|
||||||
Accept: 'application/json',
|
Accept: 'application/json',
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import OpenAI from 'openai'
|
import OpenAI from 'openai'
|
||||||
|
|
||||||
import { ALIBABA_QWEN_BASE_URL } from '../../constants'
|
import { ALIBABA_QWEN_BASE_URL, INFIO_BASE_URL, MOONSHOT_BASE_URL } from '../../constants'
|
||||||
import { LLMModel } from '../../types/llm/model'
|
import { LLMModel } from '../../types/llm/model'
|
||||||
import {
|
import {
|
||||||
LLMOptions,
|
LLMOptions,
|
||||||
@ -14,39 +14,60 @@ import {
|
|||||||
|
|
||||||
import { BaseLLMProvider } from './base'
|
import { BaseLLMProvider } from './base'
|
||||||
import { LLMBaseUrlNotSetException } from './exception'
|
import { LLMBaseUrlNotSetException } from './exception'
|
||||||
|
import { NoStainlessOpenAI } from './ollama'
|
||||||
import { OpenAIMessageAdapter } from './openai-message-adapter'
|
import { OpenAIMessageAdapter } from './openai-message-adapter'
|
||||||
|
|
||||||
export class OpenAICompatibleProvider implements BaseLLMProvider {
|
export class OpenAICompatibleProvider implements BaseLLMProvider {
|
||||||
private adapter: OpenAIMessageAdapter
|
private adapter: OpenAIMessageAdapter
|
||||||
private client: OpenAI
|
private client: OpenAI | NoStainlessOpenAI
|
||||||
private apiKey: string
|
private apiKey: string
|
||||||
private baseURL: string
|
private baseURL: string
|
||||||
|
|
||||||
constructor(apiKey: string, baseURL: string) {
|
constructor(apiKey: string, baseURL: string) {
|
||||||
this.adapter = new OpenAIMessageAdapter()
|
this.adapter = new OpenAIMessageAdapter()
|
||||||
this.client = new OpenAI({
|
|
||||||
apiKey: apiKey,
|
|
||||||
baseURL: baseURL,
|
|
||||||
dangerouslyAllowBrowser: true,
|
|
||||||
})
|
|
||||||
this.apiKey = apiKey
|
this.apiKey = apiKey
|
||||||
this.baseURL = baseURL
|
this.baseURL = baseURL
|
||||||
|
|
||||||
|
// 判断是否需要使用 NoStainlessOpenAI 来解决 CORS 问题
|
||||||
|
const needsCorsAdapter = baseURL === MOONSHOT_BASE_URL ||
|
||||||
|
baseURL?.includes('api.moonshot.cn')
|
||||||
|
|
||||||
|
if (needsCorsAdapter) {
|
||||||
|
this.client = new NoStainlessOpenAI({
|
||||||
|
apiKey: apiKey,
|
||||||
|
baseURL: baseURL,
|
||||||
|
dangerouslyAllowBrowser: true,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
this.client = new OpenAI({
|
||||||
|
apiKey: apiKey,
|
||||||
|
baseURL: baseURL,
|
||||||
|
dangerouslyAllowBrowser: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查是否为阿里云Qwen API
|
// 检查是否为阿里云Qwen API
|
||||||
private isAlibabaQwen(): boolean {
|
private isAlibabaQwen(): boolean {
|
||||||
return this.baseURL === ALIBABA_QWEN_BASE_URL ||
|
return this.baseURL === ALIBABA_QWEN_BASE_URL ||
|
||||||
this.baseURL?.includes('dashscope.aliyuncs.com')
|
this.baseURL?.includes('dashscope.aliyuncs.com')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private isGemini(modelName: string): boolean {
|
||||||
|
return this.baseURL === INFIO_BASE_URL && modelName.includes('gemini')
|
||||||
|
}
|
||||||
|
|
||||||
// 获取提供商特定的额外参数
|
// 获取提供商特定的额外参数
|
||||||
private getExtraParams(isStreaming: boolean): Record<string, any> {
|
private getExtraParams(isStreaming: boolean, modelName: string): Record<string, unknown> {
|
||||||
const extraParams: Record<string, any> = {}
|
const extraParams: Record<string, unknown> = {}
|
||||||
|
|
||||||
// 阿里云Qwen API需要在非流式调用中设置 enable_thinking: false
|
// 阿里云Qwen API需要在非流式调用中设置 enable_thinking: false
|
||||||
if (this.isAlibabaQwen() && !isStreaming) {
|
if (this.isAlibabaQwen() && !isStreaming) {
|
||||||
extraParams.enable_thinking = false
|
extraParams.enable_thinking = false
|
||||||
}
|
}
|
||||||
|
if (this.isGemini(modelName)) {
|
||||||
|
extraParams.reasoning_effort = 'low';
|
||||||
|
}
|
||||||
|
|
||||||
return extraParams
|
return extraParams
|
||||||
}
|
}
|
||||||
@ -62,8 +83,8 @@ export class OpenAICompatibleProvider implements BaseLLMProvider {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const extraParams = this.getExtraParams(false) // 非流式调用
|
const extraParams = this.getExtraParams(false, model.modelId) // 非流式调用
|
||||||
return this.adapter.generateResponse(this.client, request, options, extraParams)
|
return this.adapter.generateResponse(this.client as OpenAI, request, options, extraParams)
|
||||||
}
|
}
|
||||||
|
|
||||||
async streamResponse(
|
async streamResponse(
|
||||||
@ -77,7 +98,7 @@ export class OpenAICompatibleProvider implements BaseLLMProvider {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const extraParams = this.getExtraParams(true) // 流式调用
|
const extraParams = this.getExtraParams(true, model.modelId) // 流式调用
|
||||||
return this.adapter.streamResponse(this.client, request, options, extraParams)
|
return this.adapter.streamResponse(this.client as OpenAI, request, options, extraParams)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -22,7 +22,7 @@ export class OpenAIMessageAdapter {
|
|||||||
client: OpenAI,
|
client: OpenAI,
|
||||||
request: LLMRequestNonStreaming,
|
request: LLMRequestNonStreaming,
|
||||||
options?: LLMOptions,
|
options?: LLMOptions,
|
||||||
extraParams?: Record<string, any>,
|
extraParams?: Record<string, unknown>,
|
||||||
): Promise<LLMResponseNonStreaming> {
|
): Promise<LLMResponseNonStreaming> {
|
||||||
const response = await client.chat.completions.create(
|
const response = await client.chat.completions.create(
|
||||||
{
|
{
|
||||||
@ -50,7 +50,7 @@ export class OpenAIMessageAdapter {
|
|||||||
client: OpenAI,
|
client: OpenAI,
|
||||||
request: LLMRequestStreaming,
|
request: LLMRequestStreaming,
|
||||||
options?: LLMOptions,
|
options?: LLMOptions,
|
||||||
extraParams?: Record<string, any>,
|
extraParams?: Record<string, unknown>,
|
||||||
): Promise<AsyncIterable<LLMResponseStreaming>> {
|
): Promise<AsyncIterable<LLMResponseStreaming>> {
|
||||||
const stream = await client.chat.completions.create(
|
const stream = await client.chat.completions.create(
|
||||||
{
|
{
|
||||||
|
|||||||
@ -1,7 +1,4 @@
|
|||||||
// Obsidian
|
|
||||||
import { App, EventRef, Notice, TFile, normalizePath } from 'obsidian';
|
|
||||||
|
|
||||||
// Node built-in
|
|
||||||
import * as path from "path";
|
import * as path from "path";
|
||||||
|
|
||||||
// SDK / External Libraries
|
// SDK / External Libraries
|
||||||
@ -18,17 +15,17 @@ import {
|
|||||||
import chokidar, { FSWatcher } from "chokidar"; // Keep chokidar
|
import chokidar, { FSWatcher } from "chokidar"; // Keep chokidar
|
||||||
import delay from "delay"; // Keep delay
|
import delay from "delay"; // Keep delay
|
||||||
import deepEqual from "fast-deep-equal"; // Keep fast-deep-equal
|
import deepEqual from "fast-deep-equal"; // Keep fast-deep-equal
|
||||||
|
import { App, EventRef, Notice, TFile, normalizePath } from 'obsidian';
|
||||||
import ReconnectingEventSource from "reconnecting-eventsource"; // Keep reconnecting-eventsource
|
import ReconnectingEventSource from "reconnecting-eventsource"; // Keep reconnecting-eventsource
|
||||||
import { EnvironmentVariables, shellEnvSync } from 'shell-env';
|
import { EnvironmentVariables, shellEnvSync } from 'shell-env';
|
||||||
import { z } from "zod"; // Keep zod
|
import { z } from "zod"; // Keep zod
|
||||||
// Internal/Project imports
|
|
||||||
|
|
||||||
import { INFIO_BASE_URL } from '../../constants'
|
// Internal/Project imports
|
||||||
|
import { INFIO_BASE_URL, JSON_VIEW_TYPE } from '../../constants';
|
||||||
import { t } from "../../lang/helpers";
|
import { t } from "../../lang/helpers";
|
||||||
import InfioPlugin from "../../main";
|
import InfioPlugin from "../../main";
|
||||||
// Assuming path is correct and will be resolved, if not, this will remain an error.
|
|
||||||
// Users should verify this path if issues persist.
|
|
||||||
import { injectEnv } from "../../utils/config";
|
import { injectEnv } from "../../utils/config";
|
||||||
|
import { ROOT_DIR } from '../prompts/constants';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
McpResource,
|
McpResource,
|
||||||
@ -39,7 +36,6 @@ import {
|
|||||||
McpToolCallResponse,
|
McpToolCallResponse,
|
||||||
} from "./type";
|
} from "./type";
|
||||||
|
|
||||||
|
|
||||||
export type McpConnection = {
|
export type McpConnection = {
|
||||||
server: McpServer
|
server: McpServer
|
||||||
client: Client
|
client: Client
|
||||||
@ -123,6 +119,21 @@ const McpSettingsSchema = z.object({
|
|||||||
mcpServers: z.record(ServerConfigSchema),
|
mcpServers: z.record(ServerConfigSchema),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Add type definitions for better type safety
|
||||||
|
type ConfigObject = Record<string, unknown> & {
|
||||||
|
command?: string
|
||||||
|
url?: string
|
||||||
|
type?: string
|
||||||
|
args?: string[]
|
||||||
|
env?: Record<string, string>
|
||||||
|
headers?: Record<string, string>
|
||||||
|
disabled?: boolean
|
||||||
|
timeout?: number
|
||||||
|
alwaysAllow?: string[]
|
||||||
|
watchPaths?: string[]
|
||||||
|
cwd?: string
|
||||||
|
}
|
||||||
|
|
||||||
// 内置服务器工具的 API 响应类型
|
// 内置服务器工具的 API 响应类型
|
||||||
interface BuiltInToolResponse {
|
interface BuiltInToolResponse {
|
||||||
name: string
|
name: string
|
||||||
@ -139,6 +150,7 @@ export class McpHub {
|
|||||||
private mcpSettingsFilePath: string | null = null
|
private mcpSettingsFilePath: string | null = null
|
||||||
// private globalMcpFilePath: string | null = null
|
// private globalMcpFilePath: string | null = null
|
||||||
private fileWatchers: Map<string, FSWatcher[]> = new Map()
|
private fileWatchers: Map<string, FSWatcher[]> = new Map()
|
||||||
|
private configFileChangeTimeout: NodeJS.Timeout | null = null
|
||||||
private isDisposed: boolean = false
|
private isDisposed: boolean = false
|
||||||
connections: McpConnection[] = []
|
connections: McpConnection[] = []
|
||||||
// 添加内置服务器连接
|
// 添加内置服务器连接
|
||||||
@ -203,8 +215,13 @@ export class McpHub {
|
|||||||
throw new Error("Server configuration must be an object.");
|
throw new Error("Server configuration must be an object.");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 使用类型保护而不是类型断言
|
// Use type guard to ensure config is an object
|
||||||
const configObj = config as Record<string, unknown>;
|
if (typeof config !== 'object' || config === null || Array.isArray(config)) {
|
||||||
|
throw new Error("Server configuration must be an object.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use proper type assertion with better typing
|
||||||
|
const configObj = config as ConfigObject;
|
||||||
|
|
||||||
// Detect configuration issues before validation
|
// Detect configuration issues before validation
|
||||||
const hasStdioFields = configObj.command !== undefined
|
const hasStdioFields = configObj.command !== undefined
|
||||||
@ -215,7 +232,7 @@ export class McpHub {
|
|||||||
throw new Error(mixedFieldsErrorMessage)
|
throw new Error(mixedFieldsErrorMessage)
|
||||||
}
|
}
|
||||||
|
|
||||||
const mutableConfig = { ...configObj }; // Create a mutable copy
|
const mutableConfig: ConfigObject = { ...configObj }; // Create a mutable copy with proper type
|
||||||
|
|
||||||
// Check if it's a stdio or SSE config and add type if missing
|
// Check if it's a stdio or SSE config and add type if missing
|
||||||
if (!mutableConfig.type) {
|
if (!mutableConfig.type) {
|
||||||
@ -329,26 +346,72 @@ export class McpHub {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async ensureMcpFileExists(): Promise<void> {
|
async ensureMcpFileExists(): Promise<void> {
|
||||||
const mcpFolderPath = ".infio_json_db/mcp"
|
// 新的配置目录和文件路径
|
||||||
if (!await this.app.vault.adapter.exists(normalizePath(mcpFolderPath))) {
|
const newMcpFolderPath = ROOT_DIR
|
||||||
await this.app.vault.createFolder(mcpFolderPath);
|
const newMcpSettingsFilePath = normalizePath(path.join(newMcpFolderPath, "mcp_settings.json"))
|
||||||
|
|
||||||
|
// 老的配置目录和文件路径
|
||||||
|
const oldMcpFolderPath = ".infio_json_db/mcp"
|
||||||
|
const oldMcpSettingsFilePath = normalizePath(path.join(oldMcpFolderPath, "settings.json"))
|
||||||
|
|
||||||
|
// 确保新的配置目录存在
|
||||||
|
if (!await this.app.vault.adapter.exists(normalizePath(newMcpFolderPath))) {
|
||||||
|
await this.app.vault.createFolder(normalizePath(newMcpFolderPath));
|
||||||
}
|
}
|
||||||
this.mcpSettingsFilePath = normalizePath(path.join(mcpFolderPath, "settings.json"))
|
|
||||||
const fileExists = await this.app.vault.adapter.exists(this.mcpSettingsFilePath);
|
// 设置新的配置文件路径
|
||||||
if (!fileExists) {
|
this.mcpSettingsFilePath = newMcpSettingsFilePath
|
||||||
await this.app.vault.adapter.write(
|
|
||||||
this.mcpSettingsFilePath,
|
// 检查新的配置文件是否存在
|
||||||
JSON.stringify({ mcpServers: {} }, null, 2)
|
const newFileExists = await this.app.vault.adapter.exists(newMcpSettingsFilePath)
|
||||||
);
|
const oldFileExists = await this.app.vault.adapter.exists(oldMcpSettingsFilePath)
|
||||||
|
|
||||||
|
// 处理迁移逻辑
|
||||||
|
if (oldFileExists && !newFileExists) {
|
||||||
|
// 情况1:只有老配置文件存在,需要迁移
|
||||||
|
try {
|
||||||
|
const oldConfigContent = await this.app.vault.adapter.read(oldMcpSettingsFilePath)
|
||||||
|
console.log("Found old MCP configuration file, migrating to new location...")
|
||||||
|
|
||||||
|
// 创建新配置文件,使用老配置的内容
|
||||||
|
await this.app.vault.create(newMcpSettingsFilePath, oldConfigContent)
|
||||||
|
|
||||||
|
// 删除老配置文件
|
||||||
|
await this.app.vault.adapter.remove(oldMcpSettingsFilePath)
|
||||||
|
console.log("Successfully migrated MCP configuration and removed old file")
|
||||||
|
|
||||||
|
// 尝试删除老的配置目录(如果为空)
|
||||||
|
try {
|
||||||
|
const oldFolderContents = await this.app.vault.adapter.list(normalizePath(oldMcpFolderPath))
|
||||||
|
if (oldFolderContents.files.length === 0 && oldFolderContents.folders.length === 0) {
|
||||||
|
await this.app.vault.adapter.rmdir(normalizePath(oldMcpFolderPath), false)
|
||||||
|
console.log("Removed empty old MCP configuration directory")
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("Could not remove old MCP configuration directory:", error)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to migrate old MCP configuration file:", error)
|
||||||
|
// 迁移失败时创建默认配置
|
||||||
|
const defaultConfig = JSON.stringify({ mcpServers: {} }, null, 2)
|
||||||
|
await this.app.vault.create(newMcpSettingsFilePath, defaultConfig)
|
||||||
|
}
|
||||||
|
} else if (oldFileExists && newFileExists) {
|
||||||
|
// 情况2:两个配置文件都存在,优先保留新配置,删除老配置
|
||||||
|
console.log("Both old and new MCP configuration files exist. Keeping new file and removing old file.")
|
||||||
|
try {
|
||||||
|
await this.app.vault.adapter.remove(oldMcpSettingsFilePath)
|
||||||
|
console.log("Removed old MCP configuration file")
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to remove old MCP configuration file:", error)
|
||||||
|
}
|
||||||
|
} else if (!newFileExists) {
|
||||||
|
// 情况3:新配置文件不存在,老配置文件也不存在,创建默认配置
|
||||||
|
console.log("No MCP configuration file found, creating default configuration...")
|
||||||
|
const defaultConfig = JSON.stringify({ mcpServers: {} }, null, 2)
|
||||||
|
await this.app.vault.create(newMcpSettingsFilePath, defaultConfig)
|
||||||
}
|
}
|
||||||
// this.globalMcpFilePath = normalizePath(path.join(mcpFolderPath, "global.json"))
|
// 情况4:只有新配置文件存在,什么都不做
|
||||||
// const fileExists1 = await this.app.vault.adapter.exists(this.globalMcpFilePath);
|
|
||||||
// if (!fileExists1) {
|
|
||||||
// await this.app.vault.adapter.write(
|
|
||||||
// this.globalMcpFilePath,
|
|
||||||
// JSON.stringify({ mcpServers: {} }, null, 2)
|
|
||||||
// );
|
|
||||||
// }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async getMcpSettingsFilePath(): Promise<string> {
|
async getMcpSettingsFilePath(): Promise<string> {
|
||||||
@ -363,6 +426,63 @@ export class McpHub {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opens the MCP settings file in Obsidian
|
||||||
|
*/
|
||||||
|
async openMcpSettingsFile(): Promise<void> {
|
||||||
|
try {
|
||||||
|
await this.ensureMcpFileExists();
|
||||||
|
const filePath = this.mcpSettingsFilePath;
|
||||||
|
|
||||||
|
console.log('Attempting to open MCP settings file:', filePath);
|
||||||
|
|
||||||
|
// 检查文件是否已经打开
|
||||||
|
let existingLeaf: any = null;
|
||||||
|
this.app.workspace.iterateAllLeaves((leaf) => {
|
||||||
|
if (leaf.view.getViewType() === JSON_VIEW_TYPE) {
|
||||||
|
// 检查视图状态中的文件路径
|
||||||
|
const viewState = leaf.view.getState();
|
||||||
|
if (viewState && typeof viewState === 'object' && 'filePath' in viewState &&
|
||||||
|
viewState !== null && !Array.isArray(viewState) &&
|
||||||
|
(viewState as { filePath: unknown }).filePath === filePath) {
|
||||||
|
existingLeaf = leaf;
|
||||||
|
return false; // 停止遍历
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingLeaf) {
|
||||||
|
// 如果文件已经打开,重新加载最新内容并激活 leaf
|
||||||
|
await existingLeaf.setViewState({
|
||||||
|
type: JSON_VIEW_TYPE,
|
||||||
|
active: true,
|
||||||
|
state: { filePath } // 重新设置状态以触发重新加载
|
||||||
|
});
|
||||||
|
this.app.workspace.setActiveLeaf(existingLeaf);
|
||||||
|
this.app.workspace.revealLeaf(existingLeaf);
|
||||||
|
console.log('MCP settings file is already open, reloading content and activating existing view:', filePath);
|
||||||
|
} else {
|
||||||
|
// 如果文件没有打开,创建新的 leaf
|
||||||
|
const leaf = this.app.workspace.getLeaf(true);
|
||||||
|
|
||||||
|
if (leaf) {
|
||||||
|
await leaf.setViewState({
|
||||||
|
type: JSON_VIEW_TYPE,
|
||||||
|
active: true,
|
||||||
|
state: { filePath } // 传递文件路径到视图
|
||||||
|
});
|
||||||
|
|
||||||
|
this.app.workspace.revealLeaf(leaf);
|
||||||
|
console.log('Successfully opened MCP settings file in JSON view:', filePath);
|
||||||
|
} else {
|
||||||
|
console.error('Failed to get workspace leaf for JSON view');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to open MCP settings file:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Combined and simplified initializeMcpServers, only for global scope
|
// Combined and simplified initializeMcpServers, only for global scope
|
||||||
private async initializeGlobalMcpServers(): Promise<void> {
|
private async initializeGlobalMcpServers(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
@ -391,8 +511,14 @@ export class McpHub {
|
|||||||
try {
|
try {
|
||||||
// 安全地处理未验证的配置
|
// 安全地处理未验证的配置
|
||||||
const serversToConnect = config.mcpServers;
|
const serversToConnect = config.mcpServers;
|
||||||
if (serversToConnect && typeof serversToConnect === 'object') {
|
if (serversToConnect && typeof serversToConnect === 'object' &&
|
||||||
await this.updateServerConnections(serversToConnect);
|
!Array.isArray(serversToConnect) && serversToConnect !== null) {
|
||||||
|
// Use type guard to ensure it's a proper record
|
||||||
|
const servers: Record<string, unknown> = {};
|
||||||
|
for (const [key, value] of Object.entries(serversToConnect)) {
|
||||||
|
servers[key] = value;
|
||||||
|
}
|
||||||
|
await this.updateServerConnections(servers);
|
||||||
} else {
|
} else {
|
||||||
await this.updateServerConnections({});
|
await this.updateServerConnections({});
|
||||||
}
|
}
|
||||||
@ -438,8 +564,8 @@ export class McpHub {
|
|||||||
// Inject environment variables to the config
|
// Inject environment variables to the config
|
||||||
let configInjected = { ...config };
|
let configInjected = { ...config };
|
||||||
try {
|
try {
|
||||||
// injectEnv might return a modified structure, so we re-validate.
|
// injectEnv might return a modified structure, so we re-validate.
|
||||||
const tempConfigAfterInject = await injectEnv(config as Record<string, unknown>);
|
const tempConfigAfterInject = await injectEnv(config);
|
||||||
const validatedInjectedConfig = ServerConfigSchema.safeParse(tempConfigAfterInject);
|
const validatedInjectedConfig = ServerConfigSchema.safeParse(tempConfigAfterInject);
|
||||||
if (validatedInjectedConfig.success) {
|
if (validatedInjectedConfig.success) {
|
||||||
configInjected = validatedInjectedConfig.data;
|
configInjected = validatedInjectedConfig.data;
|
||||||
@ -658,7 +784,7 @@ export class McpHub {
|
|||||||
try {
|
try {
|
||||||
if (actualSource === "project") {
|
if (actualSource === "project") {
|
||||||
// Get project MCP config path
|
// Get project MCP config path
|
||||||
const projectMcpPath = normalizePath(".infio_json_db/mcp/mcp.json")
|
const projectMcpPath = normalizePath(path.join(ROOT_DIR, "mcp", "mcp_settings.json"))
|
||||||
if (await this.app.vault.adapter.exists(projectMcpPath)) {
|
if (await this.app.vault.adapter.exists(projectMcpPath)) {
|
||||||
configPath = projectMcpPath
|
configPath = projectMcpPath
|
||||||
const content = await this.app.vault.adapter.read(configPath)
|
const content = await this.app.vault.adapter.read(configPath)
|
||||||
@ -667,7 +793,7 @@ export class McpHub {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Get global MCP settings path
|
// Get global MCP settings path
|
||||||
configPath = normalizePath(".infio_json_db/mcp/settings.json")
|
configPath = this.mcpSettingsFilePath
|
||||||
const content = await this.app.vault.adapter.read(configPath)
|
const content = await this.app.vault.adapter.read(configPath)
|
||||||
const config = JSON.parse(content)
|
const config = JSON.parse(content)
|
||||||
alwaysAllowConfig = config.mcpServers?.[serverName]?.alwaysAllow || []
|
alwaysAllowConfig = config.mcpServers?.[serverName]?.alwaysAllow || []
|
||||||
@ -916,53 +1042,7 @@ export class McpHub {
|
|||||||
this.isConnecting = false
|
this.isConnecting = false
|
||||||
}
|
}
|
||||||
|
|
||||||
// private async notifyWebviewOfServerChanges(): Promise<void> {
|
|
||||||
// // Get global server order from settings file
|
|
||||||
// const settingsPath = await this.getMcpSettingsFilePath()
|
|
||||||
// const content = await fs.readFile(settingsPath, "utf-8")
|
|
||||||
// const config = JSON.parse(content)
|
|
||||||
// const globalServerOrder = Object.keys(config.mcpServers || {})
|
|
||||||
|
|
||||||
// // Get project server order if available
|
|
||||||
// const projectMcpPath = await this.getProjectMcpPath()
|
|
||||||
// let projectServerOrder: string[] = []
|
|
||||||
// if (projectMcpPath) {
|
|
||||||
// try {
|
|
||||||
// const projectContent = await fs.readFile(projectMcpPath, "utf-8")
|
|
||||||
// const projectConfig = JSON.parse(projectContent)
|
|
||||||
// projectServerOrder = Object.keys(projectConfig.mcpServers || {})
|
|
||||||
// } catch (error) {
|
|
||||||
// // Silently continue with empty project server order
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
|
|
||||||
// // Sort connections: first project servers in their defined order, then global servers in their defined order
|
|
||||||
// // This ensures that when servers have the same name, project servers are prioritized
|
|
||||||
// const sortedConnections = [...this.connections].sort((a, b) => {
|
|
||||||
// const aIsGlobal = a.server.source === "global" || !a.server.source
|
|
||||||
// const bIsGlobal = b.server.source === "global" || !b.server.source
|
|
||||||
|
|
||||||
// // If both are global or both are project, sort by their respective order
|
|
||||||
// if (aIsGlobal && bIsGlobal) {
|
|
||||||
// const indexA = globalServerOrder.indexOf(a.server.name)
|
|
||||||
// const indexB = globalServerOrder.indexOf(b.server.name)
|
|
||||||
// return indexA - indexB
|
|
||||||
// } else if (!aIsGlobal && !bIsGlobal) {
|
|
||||||
// const indexA = projectServerOrder.indexOf(a.server.name)
|
|
||||||
// const indexB = projectServerOrder.indexOf(b.server.name)
|
|
||||||
// return indexA - indexB
|
|
||||||
// }
|
|
||||||
|
|
||||||
// // Project servers come before global servers (reversed from original)
|
|
||||||
// return aIsGlobal ? 1 : -1
|
|
||||||
// })
|
|
||||||
|
|
||||||
// // Send sorted servers to webview
|
|
||||||
// await this.providerRef.deref()?.postMessageToWebview({
|
|
||||||
// type: "mcpServers",
|
|
||||||
// mcpServers: sortedConnections.map((connection) => connection.server),
|
|
||||||
// })
|
|
||||||
// }
|
|
||||||
|
|
||||||
public async toggleServerDisabled(
|
public async toggleServerDisabled(
|
||||||
serverName: string,
|
serverName: string,
|
||||||
@ -1027,7 +1107,7 @@ export class McpHub {
|
|||||||
// Determine which config file to update
|
// Determine which config file to update
|
||||||
let configPath: string
|
let configPath: string
|
||||||
if (source === "project") {
|
if (source === "project") {
|
||||||
const projectMcpPath = normalizePath(".infio_json_db/mcp/mcp.json")
|
const projectMcpPath = normalizePath(path.join(ROOT_DIR, "mcp", "mcp_settings.json"))
|
||||||
if (!await this.app.vault.adapter.exists(projectMcpPath)) {
|
if (!await this.app.vault.adapter.exists(projectMcpPath)) {
|
||||||
throw new Error("Project MCP configuration file not found")
|
throw new Error("Project MCP configuration file not found")
|
||||||
}
|
}
|
||||||
@ -1111,7 +1191,7 @@ export class McpHub {
|
|||||||
|
|
||||||
if (isProjectServer) {
|
if (isProjectServer) {
|
||||||
// Get project MCP config path
|
// Get project MCP config path
|
||||||
const projectMcpPath = normalizePath(".infio_json_db/mcp/mcp.json")
|
const projectMcpPath = normalizePath(path.join(ROOT_DIR, "mcp", "mcp_settings.json"))
|
||||||
if (!await this.app.vault.adapter.exists(projectMcpPath)) {
|
if (!await this.app.vault.adapter.exists(projectMcpPath)) {
|
||||||
throw new Error("Project MCP configuration file not found")
|
throw new Error("Project MCP configuration file not found")
|
||||||
}
|
}
|
||||||
@ -1135,8 +1215,10 @@ export class McpHub {
|
|||||||
|
|
||||||
// Remove the server from the settings
|
// Remove the server from the settings
|
||||||
if (config.mcpServers[serverName]) {
|
if (config.mcpServers[serverName]) {
|
||||||
// 使用 Reflect.deleteProperty 而不是 delete 操作符
|
// Use delete operator safely with type guard
|
||||||
Reflect.deleteProperty(config.mcpServers, serverName)
|
if (config.mcpServers && typeof config.mcpServers === 'object' && !Array.isArray(config.mcpServers)) {
|
||||||
|
delete config.mcpServers[serverName];
|
||||||
|
}
|
||||||
|
|
||||||
// Write the entire config back
|
// Write the entire config back
|
||||||
const updatedConfig = {
|
const updatedConfig = {
|
||||||
@ -1146,7 +1228,13 @@ export class McpHub {
|
|||||||
await this.app.vault.adapter.write(configPath, JSON.stringify(updatedConfig, null, 2))
|
await this.app.vault.adapter.write(configPath, JSON.stringify(updatedConfig, null, 2))
|
||||||
|
|
||||||
// Update server connections with the correct source
|
// Update server connections with the correct source
|
||||||
await this.updateServerConnections(config.mcpServers, serverSource)
|
const servers: Record<string, unknown> = {};
|
||||||
|
if (config.mcpServers && typeof config.mcpServers === 'object' && !Array.isArray(config.mcpServers)) {
|
||||||
|
for (const [key, value] of Object.entries(config.mcpServers)) {
|
||||||
|
servers[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await this.updateServerConnections(servers, serverSource)
|
||||||
|
|
||||||
// vscode.window.showInformationMessage(t("common:info.mcp_server_deleted", { serverName }))
|
// vscode.window.showInformationMessage(t("common:info.mcp_server_deleted", { serverName }))
|
||||||
} else {
|
} else {
|
||||||
@ -1184,7 +1272,7 @@ export class McpHub {
|
|||||||
// Determine which config file to update
|
// Determine which config file to update
|
||||||
let configPath: string
|
let configPath: string
|
||||||
if (source === "project") {
|
if (source === "project") {
|
||||||
const projectMcpPath = normalizePath(".infio_json_db/mcp/mcp.json")
|
const projectMcpPath = normalizePath(path.join(ROOT_DIR, "mcp", "mcp_settings.json"))
|
||||||
if (!await this.app.vault.adapter.exists(projectMcpPath)) {
|
if (!await this.app.vault.adapter.exists(projectMcpPath)) {
|
||||||
// Create project config file if it doesn't exist
|
// Create project config file if it doesn't exist
|
||||||
await this.app.vault.adapter.write(
|
await this.app.vault.adapter.write(
|
||||||
@ -1227,7 +1315,14 @@ export class McpHub {
|
|||||||
await this.app.vault.adapter.write(configPath, JSON.stringify(updatedConfig, null, 2))
|
await this.app.vault.adapter.write(configPath, JSON.stringify(updatedConfig, null, 2))
|
||||||
|
|
||||||
// Update server connections to connect to the new server
|
// Update server connections to connect to the new server
|
||||||
await this.updateServerConnections(currentConfig.mcpServers, source)
|
const servers: Record<string, unknown> = {};
|
||||||
|
if (currentConfig.mcpServers && typeof currentConfig.mcpServers === 'object' &&
|
||||||
|
!Array.isArray(currentConfig.mcpServers)) {
|
||||||
|
for (const [key, value] of Object.entries(currentConfig.mcpServers)) {
|
||||||
|
servers[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await this.updateServerConnections(servers, source)
|
||||||
|
|
||||||
console.log(`Successfully created and connected to MCP server: ${name}`)
|
console.log(`Successfully created and connected to MCP server: ${name}`)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -1332,6 +1427,7 @@ export class McpHub {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
|
// @ts-ignore
|
||||||
'Authorization': `Bearer ${this.plugin.settings.infioProvider.apiKey}`,
|
'Authorization': `Bearer ${this.plugin.settings.infioProvider.apiKey}`,
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
@ -1410,7 +1506,7 @@ export class McpHub {
|
|||||||
let configPath: string
|
let configPath: string
|
||||||
if (source === "project") {
|
if (source === "project") {
|
||||||
// Get project MCP config path
|
// Get project MCP config path
|
||||||
const projectMcpPath = normalizePath(".infio_json_db/mcp/mcp.json")
|
const projectMcpPath = normalizePath(path.join(ROOT_DIR, "mcp", "mcp_settings.json"))
|
||||||
if (!await this.app.vault.adapter.exists(projectMcpPath)) {
|
if (!await this.app.vault.adapter.exists(projectMcpPath)) {
|
||||||
throw new Error("Project MCP configuration file not found")
|
throw new Error("Project MCP configuration file not found")
|
||||||
}
|
}
|
||||||
@ -1512,7 +1608,7 @@ export class McpHub {
|
|||||||
name: this.BUILTIN_SERVER_NAME,
|
name: this.BUILTIN_SERVER_NAME,
|
||||||
config: JSON.stringify({ type: "builtin" }),
|
config: JSON.stringify({ type: "builtin" }),
|
||||||
status: "connected",
|
status: "connected",
|
||||||
disabled: false,
|
disabled: true,
|
||||||
source: "global",
|
source: "global",
|
||||||
tools: tools,
|
tools: tools,
|
||||||
resources: [], // 内置服务器暂不支持资源
|
resources: [], // 内置服务器暂不支持资源
|
||||||
@ -1545,6 +1641,7 @@ export class McpHub {
|
|||||||
const response = await fetch(`${INFIO_BASE_URL}/mcp/tools/list`, {
|
const response = await fetch(`${INFIO_BASE_URL}/mcp/tools/list`, {
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
|
// @ts-ignore
|
||||||
'Authorization': `Bearer ${this.plugin.settings.infioProvider.apiKey}`,
|
'Authorization': `Bearer ${this.plugin.settings.infioProvider.apiKey}`,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@ -1,12 +1,51 @@
|
|||||||
const MatchSearchFilesInstructions = "\n- You can use match_search_files to perform fuzzy-based searches across files using keyword/phrase. This tool is ideal for finding similar texts in notes. It excels at finding similar contents with similar keywords and phrases quickly."
|
const MatchSearchFilesInstructions = `- You can use the \`match_search_files\` tool to perform fuzzy-based searches across files using keywords/phrases to find similar texts and contents quickly.`
|
||||||
|
|
||||||
const RegexSearchFilesInstructions = "\n- You can use regex_search_files to perform pattern-based searches across files using regular expressions. This tool is ideal for finding exact text matches, specific patterns (like tags, links, dates, URLs), or structural elements in notes. It excels at locating precise format patterns and is perfect for finding connections between notes, frontmatter elements, or specific Markdown formatting."
|
const RegexSearchFilesInstructions = `- You can use the \`regex_search_files\` tool to perform pattern-based searches across files using regular expressions to find exact text matches, specific patterns, and structural elements.`
|
||||||
|
|
||||||
const SemanticSearchFilesInstructions = "\n- You can use semantic_search_files to find content based on meaning rather than exact text matches. Semantic search uses embedding vectors to understand concepts and ideas, finding relevant content even when keywords differ. This is especially powerful for discovering thematically related notes, answering conceptual questions about your knowledge base, or finding content when you don't know the exact wording used in the notes."
|
const SemanticSearchFilesInstructions = `- You can use the \`semantic_search_files\` tool to perform semantic searches across your entire vault. This tool is powerful for finding conceptually relevant notes, even if you don't know the exact keywords or file names. It's particularly useful for discovering connections between ideas, finding notes related to a topic or theme, exploring concepts across different contexts, or identifying knowledge gaps in your vault. This capability relies on a pre-built index of your notes.`
|
||||||
|
|
||||||
function getObsidianCapabilitiesSection(
|
function getObsidianCapabilitiesSection(
|
||||||
cwd: string,
|
cwd: string,
|
||||||
searchFilesTool: string,
|
searchFilesTool: string,
|
||||||
|
enableMcpHub?: boolean,
|
||||||
|
): string {
|
||||||
|
let searchFilesInstructions: string;
|
||||||
|
switch (searchFilesTool) {
|
||||||
|
case 'match':
|
||||||
|
searchFilesInstructions = MatchSearchFilesInstructions;
|
||||||
|
break;
|
||||||
|
case 'regex':
|
||||||
|
searchFilesInstructions = RegexSearchFilesInstructions;
|
||||||
|
break;
|
||||||
|
case 'semantic':
|
||||||
|
searchFilesInstructions = SemanticSearchFilesInstructions;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
searchFilesInstructions = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
return `====
|
||||||
|
|
||||||
|
CAPABILITIES
|
||||||
|
|
||||||
|
- You have access to tools that let you list files, search content, read and write files, and ask follow-up questions. These tools help you effectively accomplish a wide range of tasks, such as creating notes, making edits or improvements to existing notes, understanding the current state of an Obsidian vault, and much more.
|
||||||
|
- When the user initially gives you a task, environment_details will include a list of all files in the current Obsidian folder ('${cwd}'). This file list provides an overview of the vault structure, offering key insights into how knowledge is organized through directory and file names, as well as what file formats are being used. This information can guide your decision-making on which notes might be most relevant to explore further. If you need to explore directories outside the current folder, you can use the list_files tool. If you pass 'true' for the recursive parameter, it will list files recursively. Otherwise, it will list only files at the top level, which is better suited for generic directories where you don't necessarily need the nested structure.
|
||||||
|
- You have access to the powerful \`insights\` tool for knowledge synthesis and retrieval. This is your primary tool for "asking questions" to notes or note sets, enabling you to extract higher-level insights, summaries, and conceptual abstractions. It supports multiple transformations including simple summaries, key insights extraction, dense summaries, reflections, table of contents generation, and academic paper analysis.
|
||||||
|
${searchFilesInstructions}
|
||||||
|
- You have access to the \`manage_files\` tool for comprehensive file and folder management operations. Execute multiple operations in a single call including moving/renaming files and folders, creating new folders, and deleting files and folders. This enables efficient batch operations to reorganize vault structure and maintain organized knowledge base.
|
||||||
|
- You have access to powerful \`dataview_query\` tool for metadata lookup and data analysis. Execute Dataview queries (DQL) to find, filter, and analyze notes based on structural attributes like tags, folders, dates, file properties, tasks, and complex metadata relationships. This supports time-based queries, task management, file organization analysis, and advanced data aggregation.
|
||||||
|
${enableMcpHub
|
||||||
|
? `
|
||||||
|
- You have access to MCP servers that may provide additional tools and resources. Each server may provide different capabilities that you can use to accomplish tasks more effectively.
|
||||||
|
`
|
||||||
|
: ""
|
||||||
|
}
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLearnModeCapabilitiesSection(
|
||||||
|
cwd: string,
|
||||||
|
searchFilesTool: string,
|
||||||
): string {
|
): string {
|
||||||
let searchFilesInstructions: string;
|
let searchFilesInstructions: string;
|
||||||
switch (searchFilesTool) {
|
switch (searchFilesTool) {
|
||||||
@ -27,9 +66,13 @@ function getObsidianCapabilitiesSection(
|
|||||||
|
|
||||||
CAPABILITIES
|
CAPABILITIES
|
||||||
|
|
||||||
- You have access to tools that let you list files, search content, read and write files, and ask follow-up questions. These tools help you effectively accomplish a wide range of tasks, such as creating notes, making edits or improvements to existing notes, understanding the current state of an Obsidian vault, and much more.
|
- You are a specialized learning assistant with access to powerful transformation tools designed to enhance learning and comprehension within Obsidian vaults.
|
||||||
- When the user initially gives you a task, environment_details will include a list of all files in the current Obsidian folder ('${cwd}'). This file list provides an overview of the vault structure, offering key insights into how knowledge is organized through directory and file names, as well as what file formats are being used. This information can guide your decision-making on which notes might be most relevant to explore further. If you need to explore directories outside the current folder, you can use the list_files tool. If you pass 'true' for the recursive parameter, it will list files recursively. Otherwise, it will list only files at the top level, which is better suited for generic directories where you don't necessarily need the nested structure.${searchFilesInstructions}
|
- Your primary strength lies in processing learning materials using transformation tools like \`simple_summary\`, \`key_insights\`, \`dense_summary\`, \`reflections\`, \`table_of_contents\`, and \`analyze_paper\` to break down complex information into digestible formats.
|
||||||
`
|
- You excel at creating visual learning aids using Mermaid diagrams (concept maps, flowcharts, mind maps) that help users understand relationships between concepts and visualize learning pathways.
|
||||||
|
- You can generate structured study materials including flashcards, study guides, learning objectives, and practice questions tailored to the user's learning goals and current knowledge level.
|
||||||
|
- You have access to file management tools to organize learning materials, create structured note hierarchies, and maintain a well-organized knowledge base within the vault ('${cwd}').${searchFilesInstructions}
|
||||||
|
- You can identify knowledge gaps by analyzing existing notes and suggest learning paths to fill those gaps, connecting new information to the user's existing knowledge base.
|
||||||
|
- You specialize in active learning techniques that promote retention and understanding rather than passive information consumption, helping users engage deeply with their learning materials.`
|
||||||
}
|
}
|
||||||
|
|
||||||
function getDeepResearchCapabilitiesSection(): string {
|
function getDeepResearchCapabilitiesSection(): string {
|
||||||
@ -46,10 +89,13 @@ CAPABILITIES
|
|||||||
export function getCapabilitiesSection(
|
export function getCapabilitiesSection(
|
||||||
mode: string,
|
mode: string,
|
||||||
cwd: string,
|
cwd: string,
|
||||||
searchWebTool: string,
|
searchFileTool: string,
|
||||||
): string {
|
): string {
|
||||||
if (mode === 'research') {
|
if (mode === 'research') {
|
||||||
return getDeepResearchCapabilitiesSection();
|
return getDeepResearchCapabilitiesSection();
|
||||||
}
|
}
|
||||||
return getObsidianCapabilitiesSection(cwd, searchWebTool);
|
if (mode === 'learn') {
|
||||||
|
return getLearnModeCapabilitiesSection(cwd, searchFileTool);
|
||||||
|
}
|
||||||
|
return getObsidianCapabilitiesSection(cwd, searchFileTool);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,4 @@
|
|||||||
export { getRulesSection } from "./rules"
|
export { getRulesSection } from "./rules"
|
||||||
export { getSystemInfoSection } from "./system-info"
|
|
||||||
export { getObjectiveSection } from "./objective"
|
export { getObjectiveSection } from "./objective"
|
||||||
export { addCustomInstructions } from "./custom-instructions"
|
export { addCustomInstructions } from "./custom-instructions"
|
||||||
export { getSharedToolUseSection } from "./tool-use"
|
export { getSharedToolUseSection } from "./tool-use"
|
||||||
|
|||||||
@ -1,5 +1,25 @@
|
|||||||
|
function getLearnModeObjectiveSection(): string {
|
||||||
|
return `====
|
||||||
|
|
||||||
function getDeepResearchObjectiveSection(): string {
|
OBJECTIVE
|
||||||
|
|
||||||
|
You enhance learning and comprehension by transforming information into digestible, engaging formats and creating structured learning experiences.
|
||||||
|
|
||||||
|
1. **Analyze Learning Materials**: When users provide content, immediately assess it for learning potential and identify key concepts, complexity levels, and learning objectives.
|
||||||
|
2. **Apply Transformation Tools**: Use transformation tools like \`simple_summary\`, \`key_insights\`, \`dense_summary\`, \`reflections\`, and \`analyze_paper\` to break down complex information into learnable components.
|
||||||
|
3. **Create Learning Aids**: Generate structured study materials including:
|
||||||
|
- Concept maps and visual diagrams using Mermaid
|
||||||
|
- Flashcards for key terms and concepts
|
||||||
|
- Practice questions and reflection prompts
|
||||||
|
- Learning objectives and progress milestones
|
||||||
|
4. **Build Knowledge Connections**: Link new information to existing knowledge in the vault, creating a comprehensive learning network through [[note links]], tags, and explicit conceptual connections.
|
||||||
|
5. **Structure Learning Progression**: Organize content in logical learning sequences, from foundational concepts to advanced applications, supporting spaced repetition and active recall.
|
||||||
|
6. **Monitor Learning Progress**: Track understanding and suggest next steps, additional resources, or areas that need reinforcement based on the user's learning journey.
|
||||||
|
|
||||||
|
Before using any tool, analyze the learning context within <thinking></thinking> tags. Consider the user's learning goals, existing knowledge level, and how the current task fits into their broader learning objectives. Prioritize transformation tools for content analysis and focus on creating materials that promote active learning rather than passive consumption.`
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDeepResearchObjectiveSection(): string {
|
||||||
return `====
|
return `====
|
||||||
|
|
||||||
OBJECTIVE
|
OBJECTIVE
|
||||||
@ -33,5 +53,8 @@ export function getObjectiveSection(mode: string): string {
|
|||||||
if (mode === 'research') {
|
if (mode === 'research') {
|
||||||
return getDeepResearchObjectiveSection();
|
return getDeepResearchObjectiveSection();
|
||||||
}
|
}
|
||||||
|
if (mode === 'learn') {
|
||||||
|
return getLearnModeObjectiveSection();
|
||||||
|
}
|
||||||
return getObsidianObjectiveSection();
|
return getObsidianObjectiveSection();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,72 +1,60 @@
|
|||||||
import { DiffStrategy } from "../../diff/DiffStrategy"
|
import { DiffStrategy } from "../../diff/DiffStrategy"
|
||||||
|
|
||||||
function getEditingInstructions(diffStrategy?: DiffStrategy): string {
|
function getEditingInstructions(mode: string): string {
|
||||||
const instructions: string[] = []
|
if (mode !== 'write') {
|
||||||
const availableTools: string[] = []
|
return ""
|
||||||
|
|
||||||
const experiments = {
|
|
||||||
insert_content: true,
|
|
||||||
search_and_replace: true,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Collect available editing tools
|
return `- For editing documents, you have access to these tools: apply_diff (for replacing lines in existing documents), write_to_file (for creating new documents or complete document rewrites), insert_content (for adding lines to existing documents), search_and_replace (for finding and replacing individual pieces of text). You MUST follow this decision-making hierarchy to choose the correct tool:
|
||||||
if (diffStrategy) {
|
|
||||||
availableTools.push(
|
|
||||||
"apply_diff (for replacing lines in existing documents)",
|
|
||||||
"write_to_file (for creating new documents or complete document rewrites)",
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
availableTools.push("write_to_file (for creating new documents or complete document rewrites)")
|
|
||||||
}
|
|
||||||
if (experiments?.["insert_content"]) {
|
|
||||||
availableTools.push("insert_content (for adding lines to existing documents)")
|
|
||||||
}
|
|
||||||
if (experiments?.["search_and_replace"]) {
|
|
||||||
availableTools.push("search_and_replace (for finding and replacing individual pieces of text)")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Base editing instruction mentioning all available tools
|
1. **For Small, Scattered, Repetitive Changes**: If the task is to correct a specific term, a typo, or a pattern that appears in multiple, non-contiguous places in the file, your **first and only choice** should be \`search_and_replace\`. It is the most precise and efficient tool for this job.
|
||||||
if (availableTools.length > 1) {
|
|
||||||
instructions.push(`- For editing documents, you have access to these tools: ${availableTools.join(", ")}.`)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Additional details for experimental features
|
2. **For Focused, Contiguous Block Edits**: If the task is to modify a single, specific section of a file (like rewriting a paragraph or refactoring a function), use \`apply_diff\`. Remember the **strict 20-line limit** for each search block. If your planned change for a single block exceeds this limit, proceed to rule #3.
|
||||||
if (experiments?.["insert_content"]) {
|
|
||||||
instructions.push(
|
|
||||||
"- The insert_content tool adds lines of text to documents, such as adding a new paragraph to a document or inserting a new section in a paper. This tool will insert it at the specified line location. It can support multiple operations at once.",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (experiments?.["search_and_replace"]) {
|
3. **For Large-Scale Rewrites or Major Changes**: If the task requires modifying a large portion of the file (e.g., more than roughly 30-40% of the content), restructuring the entire document, or if a single change block violates the 20-line limit for \`apply_diff\`, you **MUST** use \`write_to_file\`. In these cases, first use \`read_file\` to get the full current content, make all your changes in your internal thought process, and then write the entire, new content back using \`write_to_file\`. This is safer and more efficient than many small diffs.
|
||||||
instructions.push(
|
|
||||||
"- The search_and_replace tool finds and replaces text or regex in documents. This tool allows you to search for a specific regex pattern or text and replace it with another value. Be cautious when using this tool to ensure you are replacing the correct text. It can support multiple operations at once.",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (availableTools.length > 1) {
|
- The rule "You should always prefer using other editing tools over write_to_file" is ONLY valid when the changes are small enough to be handled by \`search_and_replace\` or \`apply_diff\` according to the hierarchy above. For major rewrites, \`write_to_file\` is the PREFERRED tool.`
|
||||||
instructions.push(
|
|
||||||
"- You should always prefer using other editing tools over write_to_file when making changes to existing documents since write_to_file is much slower and cannot handle large files.",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
instructions.push(
|
|
||||||
"- When using the write_to_file tool to modify a note, use the tool directly with the desired content. You do not need to display the content before using the tool. ALWAYS provide the COMPLETE file content in your response. This is NON-NEGOTIABLE. Partial updates or placeholders like '// remainder of the note unchanged' are STRICTLY FORBIDDEN. You MUST include ALL parts of the file, even if they haven't been modified. Failure to do so will result in incomplete or broken notes, severely impacting the user's knowledge base.",
|
|
||||||
)
|
|
||||||
|
|
||||||
return instructions.join("\n")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getSearchInstructions(searchTool: string): string {
|
function getSearchInstructions(searchTool: string): string {
|
||||||
|
// Detailed search instructions are now integrated into individual tool descriptions
|
||||||
|
// This function only provides basic context about the current search method
|
||||||
if (searchTool === 'match') {
|
if (searchTool === 'match') {
|
||||||
return `- When using the match_search_files tool, craft your keyword/phrase carefully to balance specificity and flexibility. Based on the user's task, you may use it to find specific content, notes, headings, connections between notes, tags, or any text-based information across the Obsidian vault. The results include context, so analyze the surrounding text to better understand the matches. Leverage the match_search_files tool in combination with other tools for comprehensive analysis. For example, use it to find specific keywords or phrases, then use read_file to examine the full context of interesting matches before using write_to_file to make informed changes.`
|
return `- You can use match_search_files for keyword/phrase-based searches across the vault.`
|
||||||
} else if (searchTool === 'regex') {
|
} else if (searchTool === 'regex') {
|
||||||
return `- When using the regex_search_files tool, craft your regex patterns carefully to balance specificity and flexibility. Based on the user's task, you may use it to find specific content, notes, headings, connections between notes, tags, or any text-based information across the Obsidian vault. The results include context, so analyze the surrounding text to better understand the matches. Leverage the regex_search_files tool in combination with other tools for comprehensive analysis. For example, use it to find specific phrases or patterns, then use read_file to examine the full context of interesting matches before using write_to_file to make informed changes.`
|
return `- You can use regex_search_files for pattern-based searches across the vault.`
|
||||||
} else if (searchTool === 'semantic') {
|
} else if (searchTool === 'semantic') {
|
||||||
return `- When using the semantic_search_files tool, craft your natural language query to describe concepts and ideas rather than specific patterns. Based on the user's task, you may use it to find thematically related content, conceptually similar notes, or knowledge connections across the Obsidian vault, even when exact keywords aren't present. The results include context, so analyze the surrounding text to understand the conceptual relevance of each match. Leverage the semantic_search_files tool in combination with other tools for comprehensive analysis. For example, use it to find specific phrases or patterns, then use read_file to examine the full context of interesting matches before using write_to_file to make informed changes.`
|
return `- You can use semantic_search_files for concept-based searches across the vault.`
|
||||||
}
|
}
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getLearnModeRulesSection(
|
||||||
|
cwd: string,
|
||||||
|
searchTool: string,
|
||||||
|
): string {
|
||||||
|
return `====
|
||||||
|
|
||||||
|
RULES
|
||||||
|
|
||||||
|
- Your current obsidian directory is: ${cwd.toPosix()}
|
||||||
|
${getSearchInstructions(searchTool)}
|
||||||
|
- **Learning-First Approach**: Always prioritize transformation tools when users provide learning materials. Start by analyzing content with tools like \`simple_summary\`, \`key_insights\`, or \`analyze_paper\` before creating additional learning materials.
|
||||||
|
- **Active Learning Focus**: Generate interactive learning materials that promote engagement rather than passive consumption. Create flashcards, concept maps, practice questions, and reflection prompts.
|
||||||
|
- **Knowledge Connection**: When creating new learning notes, actively link them to existing knowledge in the vault using [[note links]], tags (#tag), and explicit connections. Help users build a comprehensive knowledge network.
|
||||||
|
- **Structured Learning Materials**: Organize learning content with clear hierarchies, learning objectives, key concepts, and progress tracking. Use appropriate Obsidian formatting including callouts, headings, and lists.
|
||||||
|
- **Visual Learning Aids**: Use Mermaid diagrams extensively to create concept maps, flowcharts, and visual representations that enhance understanding of complex topics.
|
||||||
|
- **Learning Progress Tracking**: When appropriate, suggest or create learning plans, milestones, and progress indicators to help users track their learning journey.
|
||||||
|
- **Spaced Repetition Support**: Structure learning materials to support spaced repetition and active recall techniques.
|
||||||
|
- When creating learning notes, follow Obsidian conventions with appropriate frontmatter, headings, and formatting that supports the learning process.
|
||||||
|
- Focus on breaking complex topics into digestible, learnable chunks that build upon each other logically.
|
||||||
|
- Use the tools provided efficiently to accomplish learning tasks. When completed, use the attempt_completion tool to present results.
|
||||||
|
- Ask questions only when necessary using ask_followup_question tool, but prefer using available tools to gather needed information.
|
||||||
|
- Your goal is to enhance learning and comprehension, not engage in extended conversations.
|
||||||
|
- Be direct and educational in your responses, focusing on learning outcomes rather than conversational pleasantries.
|
||||||
|
- Wait for user confirmation after each tool use before proceeding to ensure learning materials meet expectations.`
|
||||||
|
}
|
||||||
|
|
||||||
function getDeepResearchRulesSection(): string {
|
function getDeepResearchRulesSection(): string {
|
||||||
return `====
|
return `====
|
||||||
|
|
||||||
@ -84,20 +72,18 @@ RULES
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getObsidianRulesSection(
|
function getObsidianRulesSection(
|
||||||
|
mode: string,
|
||||||
cwd: string,
|
cwd: string,
|
||||||
searchTool: string,
|
searchTool: string,
|
||||||
supportsComputerUse: boolean,
|
|
||||||
diffStrategy?: DiffStrategy,
|
|
||||||
experiments?: Record<string, boolean> | undefined,
|
|
||||||
): string {
|
): string {
|
||||||
return `====
|
return `====
|
||||||
|
|
||||||
RULES
|
RULES
|
||||||
|
|
||||||
- Your current obsidian directory is: ${cwd.toPosix()}
|
- Your current working directory is: ${cwd.toPosix()}
|
||||||
${getSearchInstructions(searchTool)}
|
${getSearchInstructions(searchTool)}
|
||||||
- When creating new notes in Obsidian, organize them according to the existing vault structure unless the user specifies otherwise. Use appropriate file paths when writing files, as the write_to_file tool will automatically create any necessary directories. Structure the content logically, adhering to Obsidian conventions with appropriate frontmatter, headings, lists, and formatting. Unless otherwise specified, new notes should follow Markdown syntax with appropriate use of links ([[note name]]), tags (#tag), callouts, and other Obsidian-specific formatting.
|
- When creating new notes in Obsidian, organize them according to the existing vault structure unless the user specifies otherwise. Use appropriate file paths when writing files, as the write_to_file tool will automatically create any necessary directories. Structure the content logically, adhering to Obsidian conventions with appropriate frontmatter, headings, lists, and formatting. Unless otherwise specified, new notes should follow Markdown syntax with appropriate use of links ([[note name]]), tags (#tag), callouts, and other Obsidian-specific formatting.
|
||||||
${getEditingInstructions(diffStrategy)}
|
${getEditingInstructions(mode)}
|
||||||
- Be sure to consider the structure of the Obsidian vault (folders, naming conventions, note organization) when determining the appropriate format and content for new or modified notes. Also consider what files may be most relevant to accomplishing the task, for example examining backlinks, linked mentions, or tags would help you understand the relationships between notes, which you could incorporate into any content you write.
|
- Be sure to consider the structure of the Obsidian vault (folders, naming conventions, note organization) when determining the appropriate format and content for new or modified notes. Also consider what files may be most relevant to accomplishing the task, for example examining backlinks, linked mentions, or tags would help you understand the relationships between notes, which you could incorporate into any content you write.
|
||||||
- When making changes to content, always consider the context within the broader vault. Ensure that your changes maintain existing links, tags, and references, and that they follow the user's established formatting standards and organization.
|
- When making changes to content, always consider the context within the broader vault. Ensure that your changes maintain existing links, tags, and references, and that they follow the user's established formatting standards and organization.
|
||||||
- Do not ask for more information than necessary. Use the tools provided to accomplish the user's request efficiently and effectively. When you've completed your task, you must use the attempt_completion tool to present the result to the user. The user may provide feedback, which you can use to make improvements and try again.
|
- Do not ask for more information than necessary. Use the tools provided to accomplish the user's request efficiently and effectively. When you've completed your task, you must use the attempt_completion tool to present the result to the user. The user may provide feedback, which you can use to make improvements and try again.
|
||||||
@ -108,7 +94,7 @@ ${getEditingInstructions(diffStrategy)}
|
|||||||
- You are STRICTLY FORBIDDEN from starting your messages with "Great", "Certainly", "Okay", "Sure". You should NOT be conversational in your responses, but rather direct and to the point. For example you should NOT say "Great, I've updated the markdown" but instead something like "I've updated the markdown". It is important you be clear and technical in your messages.
|
- You are STRICTLY FORBIDDEN from starting your messages with "Great", "Certainly", "Okay", "Sure". You should NOT be conversational in your responses, but rather direct and to the point. For example you should NOT say "Great, I've updated the markdown" but instead something like "I've updated the markdown". It is important you be clear and technical in your messages.
|
||||||
- When presented with images, utilize your vision capabilities to thoroughly examine them and extract meaningful information. Incorporate these insights into your thought process as you accomplish the user's task.
|
- When presented with images, utilize your vision capabilities to thoroughly examine them and extract meaningful information. Incorporate these insights into your thought process as you accomplish the user's task.
|
||||||
- At the end of the first user message, you will automatically receive environment_details. This information is not written by the user themselves, but is auto-generated to provide potentially relevant context about the Obsidian environment. This includes the current file being edited, open tabs, and the vault structure. While this information can be valuable for understanding the context, do not treat it as a direct part of the user's request or response. Use it to inform your actions and decisions, but don't assume the user is explicitly asking about or referring to this information unless they clearly do so in their message. When using environment_details, explain your actions clearly to ensure the user understands, as they may not be aware of these details.
|
- At the end of the first user message, you will automatically receive environment_details. This information is not written by the user themselves, but is auto-generated to provide potentially relevant context about the Obsidian environment. This includes the current file being edited, open tabs, and the vault structure. While this information can be valuable for understanding the context, do not treat it as a direct part of the user's request or response. Use it to inform your actions and decisions, but don't assume the user is explicitly asking about or referring to this information unless they clearly do so in their message. When using environment_details, explain your actions clearly to ensure the user understands, as they may not be aware of these details.
|
||||||
- Pay special attention to the open tabs in environment_details, as they indicate which notes the user is currently working with and may be most relevant to their task. Similarly, the current file information shows which note is currently in focus and likely the primary subject of the user's request.
|
- MCP operations should be used one at a time, similar to other tool usage. Wait for confirmation of success before proceeding with additional operations.
|
||||||
- It is critical you wait for the user's response after each tool use, in order to confirm the success of the tool use. For example, if asked to create a structured note, you would create a file, wait for the user's response it was created successfully, then create another file if needed, wait for the user's response it was created successfully, etc.`
|
- It is critical you wait for the user's response after each tool use, in order to confirm the success of the tool use. For example, if asked to create a structured note, you would create a file, wait for the user's response it was created successfully, then create another file if needed, wait for the user's response it was created successfully, etc.`
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -123,5 +109,8 @@ export function getRulesSection(
|
|||||||
if (mode === 'research') {
|
if (mode === 'research') {
|
||||||
return getDeepResearchRulesSection();
|
return getDeepResearchRulesSection();
|
||||||
}
|
}
|
||||||
return getObsidianRulesSection(cwd, searchTool, supportsComputerUse, diffStrategy, experiments);
|
if (mode === 'learn') {
|
||||||
}
|
return getLearnModeRulesSection(cwd, searchTool);
|
||||||
|
}
|
||||||
|
return getObsidianRulesSection(mode, cwd, searchTool);
|
||||||
|
}
|
||||||
|
|||||||
@ -1,34 +0,0 @@
|
|||||||
import os from "os"
|
|
||||||
|
|
||||||
import { Platform } from 'obsidian';
|
|
||||||
|
|
||||||
|
|
||||||
export function getSystemInfoSection(cwd: string): string {
|
|
||||||
let platformName = "Unknown"
|
|
||||||
if (Platform.isMacOS) {
|
|
||||||
platformName = "Macos"
|
|
||||||
} else if (Platform.isWin) {
|
|
||||||
platformName = "Windows"
|
|
||||||
} else if (Platform.isLinux) {
|
|
||||||
platformName = "Linux"
|
|
||||||
} else if (Platform.isMobileApp) {
|
|
||||||
if (Platform.isTablet) {
|
|
||||||
platformName = "iPad"
|
|
||||||
} else if (Platform.isPhone) {
|
|
||||||
platformName = "iPhone"
|
|
||||||
} else if (Platform.isAndroidApp) {
|
|
||||||
platformName = "Android"
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
platformName = "Unknown"
|
|
||||||
}
|
|
||||||
const details = `====
|
|
||||||
|
|
||||||
SYSTEM INFORMATION
|
|
||||||
|
|
||||||
Platform: ${platformName}
|
|
||||||
Current Obsidian Directory: ${cwd.toPosix()}
|
|
||||||
`
|
|
||||||
|
|
||||||
return details
|
|
||||||
}
|
|
||||||
@ -1,4 +1,5 @@
|
|||||||
export function getToolUseGuidelinesSection(): string {
|
|
||||||
|
function getDefaultToolUseGuidelines(): string {
|
||||||
return `# Tool Use Guidelines
|
return `# Tool Use Guidelines
|
||||||
|
|
||||||
1. In <thinking> tags, assess what information you already have and what information you need to proceed with the task.
|
1. In <thinking> tags, assess what information you already have and what information you need to proceed with the task.
|
||||||
@ -18,3 +19,7 @@ It is crucial to proceed step-by-step, waiting for the user's message after each
|
|||||||
|
|
||||||
By waiting for and carefully considering the user's response after each tool use, you can react accordingly and make informed decisions about how to proceed with the task. This iterative process helps ensure the overall success and accuracy of your work.`
|
By waiting for and carefully considering the user's response after each tool use, you can react accordingly and make informed decisions about how to proceed with the task. This iterative process helps ensure the overall success and accuracy of your work.`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getToolUseGuidelinesSection(mode?: string): string {
|
||||||
|
return getDefaultToolUseGuidelines();
|
||||||
|
}
|
||||||
|
|||||||
@ -5,7 +5,7 @@ TOOL USE
|
|||||||
|
|
||||||
You have access to a set of tools that are executed upon the user's approval. You can use one tool per message, and will receive the result of that tool use in the user's response. You use tools step-by-step to accomplish a given task, with each tool use informed by the result of the previous tool use.
|
You have access to a set of tools that are executed upon the user's approval. You can use one tool per message, and will receive the result of that tool use in the user's response. You use tools step-by-step to accomplish a given task, with each tool use informed by the result of the previous tool use.
|
||||||
|
|
||||||
# Tool Use Formatting
|
## Tool Use Formatting
|
||||||
|
|
||||||
Tool use is formatted using XML-style tags. The tool name is enclosed in opening and closing tags, and each parameter is similarly enclosed within its own set of tags. Here's the structure:
|
Tool use is formatted using XML-style tags. The tool name is enclosed in opening and closing tags, and each parameter is similarly enclosed within its own set of tags. Here's the structure:
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,3 @@
|
|||||||
|
|
||||||
import * as path from 'path'
|
import * as path from 'path'
|
||||||
|
|
||||||
import { App, normalizePath } from 'obsidian'
|
import { App, normalizePath } from 'obsidian'
|
||||||
@ -10,9 +9,9 @@ import {
|
|||||||
ModeConfig,
|
ModeConfig,
|
||||||
PromptComponent,
|
PromptComponent,
|
||||||
defaultModeSlug,
|
defaultModeSlug,
|
||||||
|
defaultModes,
|
||||||
getGroupName,
|
getGroupName,
|
||||||
getModeBySlug,
|
getModeBySlug
|
||||||
defaultModes
|
|
||||||
} from "../../utils/modes"
|
} from "../../utils/modes"
|
||||||
import { DiffStrategy } from "../diff/DiffStrategy"
|
import { DiffStrategy } from "../diff/DiffStrategy"
|
||||||
import { McpHub } from "../mcp/McpHub"
|
import { McpHub } from "../mcp/McpHub"
|
||||||
@ -27,8 +26,7 @@ import {
|
|||||||
getObjectiveSection,
|
getObjectiveSection,
|
||||||
getRulesSection,
|
getRulesSection,
|
||||||
getSharedToolUseSection,
|
getSharedToolUseSection,
|
||||||
getSystemInfoSection,
|
getToolUseGuidelinesSection
|
||||||
getToolUseGuidelinesSection,
|
|
||||||
} from "./sections"
|
} from "./sections"
|
||||||
// import { loadSystemPromptFile } from "./sections/custom-system-prompt"
|
// import { loadSystemPromptFile } from "./sections/custom-system-prompt"
|
||||||
import { getToolDescriptionsForMode } from "./tools"
|
import { getToolDescriptionsForMode } from "./tools"
|
||||||
@ -82,12 +80,6 @@ export class SystemPrompt {
|
|||||||
experiments?: Record<string, boolean>,
|
experiments?: Record<string, boolean>,
|
||||||
enableMcpServerCreation?: boolean,
|
enableMcpServerCreation?: boolean,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
// if (!context) {
|
|
||||||
// throw new Error("Extension context is required for generating system prompt")
|
|
||||||
// }
|
|
||||||
|
|
||||||
// // If diff is disabled, don't pass the diffStrategy
|
|
||||||
// const effectiveDiffStrategy = diffEnabled ? diffStrategy : undefined
|
|
||||||
|
|
||||||
// Get the full mode config to ensure we have the role definition
|
// Get the full mode config to ensure we have the role definition
|
||||||
const modeConfig = getModeBySlug(mode, customModeConfigs) || defaultModes.find((m) => m.slug === mode) || defaultModes[0]
|
const modeConfig = getModeBySlug(mode, customModeConfigs) || defaultModes.find((m) => m.slug === mode) || defaultModes[0]
|
||||||
@ -117,7 +109,7 @@ ${getToolDescriptionsForMode(
|
|||||||
experiments,
|
experiments,
|
||||||
)}
|
)}
|
||||||
|
|
||||||
${getToolUseGuidelinesSection()}
|
${getToolUseGuidelinesSection(mode)}
|
||||||
|
|
||||||
${mcpServersSection}
|
${mcpServersSection}
|
||||||
|
|
||||||
@ -138,8 +130,6 @@ ${getRulesSection(
|
|||||||
experiments,
|
experiments,
|
||||||
)}
|
)}
|
||||||
|
|
||||||
${getSystemInfoSection(cwd)}
|
|
||||||
|
|
||||||
${getObjectiveSection(mode)}
|
${getObjectiveSection(mode)}
|
||||||
|
|
||||||
${await addCustomInstructions(this.app, promptComponent?.customInstructions || modeConfig.customInstructions || "", globalCustomInstructions || "", cwd, mode, { preferredLanguage })}`
|
${await addCustomInstructions(this.app, promptComponent?.customInstructions || modeConfig.customInstructions || "", globalCustomInstructions || "", cwd, mode, { preferredLanguage })}`
|
||||||
@ -167,7 +157,7 @@ ${await addCustomInstructions(this.app, promptComponent?.customInstructions || m
|
|||||||
|
|
||||||
const getPromptComponent = (value: unknown): PromptComponent | undefined => {
|
const getPromptComponent = (value: unknown): PromptComponent | undefined => {
|
||||||
if (typeof value === "object" && value !== null) {
|
if (typeof value === "object" && value !== null) {
|
||||||
return value as PromptComponent
|
return value
|
||||||
}
|
}
|
||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,13 +1,20 @@
|
|||||||
export function getAttemptCompletionDescription(): string {
|
export function getAttemptCompletionDescription(): string {
|
||||||
return `## attempt_completion
|
return `## attempt_completion
|
||||||
Description: After each tool use, the user will respond with the result of that tool use, i.e. if it succeeded or failed, along with any reasons for failure. Once you've received the results of tool uses and can confirm that the task is complete, use this tool to present the result of your work to the user. The user may respond with feedback if they are not satisfied with the result, which you can use to make improvements and try again.
|
Description: After each tool use, the user will respond with the result of that tool use, i.e. if it succeeded or failed, along with any reasons for failure. Once you've received the results of tool uses and can confirm that the task is complete, use this tool to present the result of your work to the user. The user may respond with feedback if they are not satisfied with the result, which you can use to make improvements and try again.
|
||||||
IMPORTANT NOTE: This tool CANNOT be used until you've confirmed from the user that any previous tool uses were successful. Failure to do so will result in document corruption and system failure. Before using this tool, you must ask yourself in <thinking></thinking> tags if you've confirmed from the user that any previous tool uses were successful. If not, then DO NOT use this tool.
|
IMPORTANT NOTE: This tool CANNOT be used until you've confirmed from the user that any previous tool uses were successful. Failure to do so will result in code corruption and system failure. Before using this tool, you must ask yourself in <thinking></thinking> tags if you've confirmed from the user that any previous tool uses were successful. If not, then DO NOT use this tool.
|
||||||
Parameters:
|
Parameters:
|
||||||
- result: The result of the task. Formulate this result in a way that is final and does not require further input from the user. Don't end your result with questions or offers for further assistance.
|
- result: (required) The result of the task. Formulate this result in a way that is final and does not require further input from the user. Don't end your result with questions or offers for further assistance.
|
||||||
Usage:
|
Usage:
|
||||||
<attempt_completion>
|
<attempt_completion>
|
||||||
<result>
|
<result>
|
||||||
Your final result description here
|
Your final result description here
|
||||||
</result>
|
</result>
|
||||||
|
</attempt_completion>
|
||||||
|
|
||||||
|
Example: Requesting to attempt completion with a result
|
||||||
|
<attempt_completion>
|
||||||
|
<result>
|
||||||
|
I've updated the CSS
|
||||||
|
</result>
|
||||||
</attempt_completion>`
|
</attempt_completion>`
|
||||||
}
|
}
|
||||||
|
|||||||
26
src/core/prompts/tools/call-insights.ts
Normal file
26
src/core/prompts/tools/call-insights.ts
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
import { ToolArgs } from "./types"
|
||||||
|
|
||||||
|
export function getCallInsightsDescription(args: ToolArgs): string {
|
||||||
|
return `## insights
|
||||||
|
Description: Use for **Knowledge Synthesis and Retrieval**. This is your primary tool for "asking questions" to a document or a set of documents. Use it to query your notes and extract higher-level insights, summaries, and other conceptual abstractions. Instead of just finding raw text, this tool helps you understand and synthesize the information within your vault.
|
||||||
|
Parameters:
|
||||||
|
- path: (required) The path to the file or folder to be processed (relative to the current working directory: ${args.cwd}).
|
||||||
|
- transformation: (required) The type of transformation to apply. Must be one of the following:
|
||||||
|
- **simple_summary**: Creates a clear, simple summary. Use when you need to quickly understand the main points or explain a complex topic easily.
|
||||||
|
- **key_insights**: Extracts high-level, critical insights and non-obvious connections. Use when you want to understand the deeper meaning or strategic implications.
|
||||||
|
- **dense_summary**: Provides a comprehensive, information-rich summary. Use when detail is important but you need it in a condensed format.
|
||||||
|
- **reflections**: Generates deep, reflective questions and perspectives to spark new ideas. Use when you want to think critically with your notes.
|
||||||
|
- **table_of_contents**: Creates a navigable table of contents for a long document or folder. Use for structuring and organizing content.
|
||||||
|
- **analyze_paper**: Performs an in-depth analysis of an academic paper, breaking down its components. Use for scholarly or research documents.
|
||||||
|
Usage:
|
||||||
|
<insights>
|
||||||
|
<path>path/to/your/file.md</path>
|
||||||
|
<transformation>simple_summary</transformation>
|
||||||
|
</insights>
|
||||||
|
|
||||||
|
Example: Getting the key insights from a project note
|
||||||
|
<insights>
|
||||||
|
<path>Projects/Project_Alpha_Retrospective.md</path>
|
||||||
|
<transformation>key_insights</transformation>
|
||||||
|
</insights>`
|
||||||
|
}
|
||||||
74
src/core/prompts/tools/dataview-query.ts
Normal file
74
src/core/prompts/tools/dataview-query.ts
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
import { ToolArgs } from "./types"
|
||||||
|
|
||||||
|
export function getDataviewQueryDescription(args: ToolArgs): string {
|
||||||
|
return `## dataview_query
|
||||||
|
Description: Use for **Metadata Lookup**. Executes a Dataview query to find notes based on structural attributes like tags, folders, dates, or other metadata properties. This is your primary tool when the user's request is about filtering or finding notes with specific characteristics, not about understanding a concept.
|
||||||
|
Parameters:
|
||||||
|
- query: (required) The Dataview query statement (DQL).
|
||||||
|
|
||||||
|
Common Query Patterns:
|
||||||
|
- Find notes with a tag: \`LIST FROM #project\`
|
||||||
|
- Find notes in a folder: \`LIST FROM "Meetings"\`
|
||||||
|
- Find notes by task completion: \`TASK WHERE completed\`
|
||||||
|
|
||||||
|
**Time-based Queries:**
|
||||||
|
- Recently created: \`WHERE file.ctime >= date(today) - dur(7 days)\`
|
||||||
|
- Recently modified: \`WHERE file.mtime >= date(today) - dur(3 days)\`
|
||||||
|
- Specific date: \`WHERE file.cday = date("2024-01-01")\`
|
||||||
|
|
||||||
|
**Tag-based Queries:**
|
||||||
|
- Contains specific tag: \`WHERE contains(file.tags, "#project")\`
|
||||||
|
- Multiple tag combination: \`WHERE contains(file.tags, "#work") AND contains(file.tags, "#urgent")\`
|
||||||
|
- Tag statistics: \`GROUP BY file.tags\`
|
||||||
|
|
||||||
|
**Task-based Queries:**
|
||||||
|
- Incomplete tasks: \`TASK WHERE !completed\`
|
||||||
|
- Specific priority tasks: \`TASK WHERE contains(text, "high priority")\`
|
||||||
|
|
||||||
|
**File Property Queries:**
|
||||||
|
- File size: \`WHERE file.size > 1000\`
|
||||||
|
- File type: \`WHERE file.ext = "md"\`
|
||||||
|
- Folder: \`FROM "Projects"\`
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
<dataview_query>
|
||||||
|
<query>Your Dataview query statement</query>
|
||||||
|
<output_format>table|list|task|calendar (optional)</output_format>
|
||||||
|
</dataview_query>
|
||||||
|
|
||||||
|
**Example 1: Get notes created in the last 7 days with #project tag**
|
||||||
|
<dataview_query>
|
||||||
|
<query>TABLE file.ctime as "Created", file.tags as "Tags"
|
||||||
|
FROM ""
|
||||||
|
WHERE file.ctime >= date(today) - dur(7 days) AND contains(file.tags, "#project")
|
||||||
|
SORT file.ctime DESC</query>
|
||||||
|
<output_format>table</output_format>
|
||||||
|
</dataview_query>
|
||||||
|
|
||||||
|
**Example 2: List all incomplete tasks**
|
||||||
|
<dataview_query>
|
||||||
|
<query>TASK
|
||||||
|
FROM ""
|
||||||
|
WHERE !completed
|
||||||
|
GROUP BY file.link</query>
|
||||||
|
<output_format>task</output_format>
|
||||||
|
</dataview_query>
|
||||||
|
|
||||||
|
**Example 3: Get notes modified in a week**
|
||||||
|
<dataview_query>
|
||||||
|
<query>LIST file.mtime
|
||||||
|
FROM ""
|
||||||
|
WHERE file.mtime >= date(today) - dur(7 days)
|
||||||
|
SORT file.mtime DESC</query>
|
||||||
|
<output_format>list</output_format>
|
||||||
|
</dataview_query>
|
||||||
|
|
||||||
|
**Advanced Features:**
|
||||||
|
- Use FLATTEN to expand array data
|
||||||
|
- Use GROUP BY for grouping and statistics
|
||||||
|
- Use complex WHERE conditions for filtering
|
||||||
|
- Support date calculations and comparisons
|
||||||
|
- Support regular expression matching
|
||||||
|
|
||||||
|
Note: Query statements must follow the DQL syntax specifications of the Dataview plugin. Current working directory: ${args.cwd}`
|
||||||
|
}
|
||||||
@ -1,14 +1,17 @@
|
|||||||
|
import { FilesSearchSettings } from "../../../types/settings"
|
||||||
import { Mode, ModeConfig, getGroupName, getModeConfig, isToolAllowedForMode } from "../../../utils/modes"
|
import { Mode, ModeConfig, getGroupName, getModeConfig, isToolAllowedForMode } from "../../../utils/modes"
|
||||||
import { DiffStrategy } from "../../diff/DiffStrategy"
|
import { DiffStrategy } from "../../diff/DiffStrategy"
|
||||||
import { McpHub } from "../../mcp/McpHub"
|
import { McpHub } from "../../mcp/McpHub"
|
||||||
import { FilesSearchSettings } from "../../../types/settings"
|
|
||||||
|
|
||||||
import { getAccessMcpResourceDescription } from "./access-mcp-resource"
|
import { getAccessMcpResourceDescription } from "./access-mcp-resource"
|
||||||
import { getAskFollowupQuestionDescription } from "./ask-followup-question"
|
import { getAskFollowupQuestionDescription } from "./ask-followup-question"
|
||||||
import { getAttemptCompletionDescription } from "./attempt-completion"
|
import { getAttemptCompletionDescription } from "./attempt-completion"
|
||||||
|
import { getCallInsightsDescription } from "./call-insights"
|
||||||
|
import { getDataviewQueryDescription } from "./dataview-query"
|
||||||
import { getFetchUrlsContentDescription } from "./fetch-url-content"
|
import { getFetchUrlsContentDescription } from "./fetch-url-content"
|
||||||
import { getInsertContentDescription } from "./insert-content"
|
import { getInsertContentDescription } from "./insert-content"
|
||||||
import { getListFilesDescription } from "./list-files"
|
import { getListFilesDescription } from "./list-files"
|
||||||
|
import { getManageFilesDescription } from "./manage-files"
|
||||||
import { getReadFileDescription } from "./read-file"
|
import { getReadFileDescription } from "./read-file"
|
||||||
import { getSearchAndReplaceDescription } from "./search-and-replace"
|
import { getSearchAndReplaceDescription } from "./search-and-replace"
|
||||||
import { getSearchFilesDescription } from "./search-files"
|
import { getSearchFilesDescription } from "./search-files"
|
||||||
@ -25,6 +28,8 @@ const toolDescriptionMap: Record<string, (args: ToolArgs) => string | undefined>
|
|||||||
write_to_file: (args) => getWriteToFileDescription(args),
|
write_to_file: (args) => getWriteToFileDescription(args),
|
||||||
search_files: (args) => getSearchFilesDescription(args),
|
search_files: (args) => getSearchFilesDescription(args),
|
||||||
list_files: (args) => getListFilesDescription(args),
|
list_files: (args) => getListFilesDescription(args),
|
||||||
|
insights: (args) => getCallInsightsDescription(args),
|
||||||
|
dataview_query: (args) => getDataviewQueryDescription(args),
|
||||||
ask_followup_question: () => getAskFollowupQuestionDescription(),
|
ask_followup_question: () => getAskFollowupQuestionDescription(),
|
||||||
attempt_completion: () => getAttemptCompletionDescription(),
|
attempt_completion: () => getAttemptCompletionDescription(),
|
||||||
switch_mode: () => getSwitchModeDescription(),
|
switch_mode: () => getSwitchModeDescription(),
|
||||||
@ -32,6 +37,7 @@ const toolDescriptionMap: Record<string, (args: ToolArgs) => string | undefined>
|
|||||||
use_mcp_tool: (args) => getUseMcpToolDescription(args),
|
use_mcp_tool: (args) => getUseMcpToolDescription(args),
|
||||||
access_mcp_resource: (args) => getAccessMcpResourceDescription(args),
|
access_mcp_resource: (args) => getAccessMcpResourceDescription(args),
|
||||||
search_and_replace: (args) => getSearchAndReplaceDescription(args),
|
search_and_replace: (args) => getSearchAndReplaceDescription(args),
|
||||||
|
manage_files: (args) => getManageFilesDescription(args),
|
||||||
apply_diff: (args) =>
|
apply_diff: (args) =>
|
||||||
args.diffStrategy ? args.diffStrategy.getToolDescription({ cwd: args.cwd, toolOptions: args.toolOptions }) : "",
|
args.diffStrategy ? args.diffStrategy.getToolDescription({ cwd: args.cwd, toolOptions: args.toolOptions }) : "",
|
||||||
search_web: (args): string | undefined => getSearchWebDescription(args),
|
search_web: (args): string | undefined => getSearchWebDescription(args),
|
||||||
@ -50,7 +56,9 @@ export function getToolDescriptionsForMode(
|
|||||||
customModes?: ModeConfig[],
|
customModes?: ModeConfig[],
|
||||||
experiments?: Record<string, boolean>,
|
experiments?: Record<string, boolean>,
|
||||||
): string {
|
): string {
|
||||||
|
// console.log("getToolDescriptionsForMode", mode, customModes)
|
||||||
const config = getModeConfig(mode, customModes)
|
const config = getModeConfig(mode, customModes)
|
||||||
|
// console.log("config", config)
|
||||||
const args: ToolArgs = {
|
const args: ToolArgs = {
|
||||||
cwd,
|
cwd,
|
||||||
searchSettings,
|
searchSettings,
|
||||||
@ -67,6 +75,7 @@ export function getToolDescriptionsForMode(
|
|||||||
config.groups.forEach((groupEntry) => {
|
config.groups.forEach((groupEntry) => {
|
||||||
const groupName = getGroupName(groupEntry)
|
const groupName = getGroupName(groupEntry)
|
||||||
const toolGroup = TOOL_GROUPS[groupName]
|
const toolGroup = TOOL_GROUPS[groupName]
|
||||||
|
console.log("toolGroup", toolGroup)
|
||||||
if (toolGroup) {
|
if (toolGroup) {
|
||||||
toolGroup.tools.forEach((tool) => {
|
toolGroup.tools.forEach((tool) => {
|
||||||
if (isToolAllowedForMode(tool, mode, customModes ?? [], experiments ?? {})) {
|
if (isToolAllowedForMode(tool, mode, customModes ?? [], experiments ?? {})) {
|
||||||
@ -78,10 +87,11 @@ export function getToolDescriptionsForMode(
|
|||||||
|
|
||||||
// Add always available tools
|
// Add always available tools
|
||||||
ALWAYS_AVAILABLE_TOOLS.forEach((tool) => tools.add(tool))
|
ALWAYS_AVAILABLE_TOOLS.forEach((tool) => tools.add(tool))
|
||||||
|
// console.log("tools", tools)
|
||||||
// Map tool descriptions for allowed tools
|
// Map tool descriptions for allowed tools
|
||||||
const descriptions = Array.from(tools).map((toolName) => {
|
const descriptions = Array.from(tools).map((toolName) => {
|
||||||
const descriptionFn = toolDescriptionMap[toolName]
|
const descriptionFn = toolDescriptionMap[toolName]
|
||||||
|
// console.log("descriptionFn", descriptionFn)
|
||||||
if (!descriptionFn) {
|
if (!descriptionFn) {
|
||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
@ -97,6 +107,8 @@ export function getToolDescriptionsForMode(
|
|||||||
|
|
||||||
// Export individual description functions for backward compatibility
|
// Export individual description functions for backward compatibility
|
||||||
export {
|
export {
|
||||||
getAccessMcpResourceDescription, getAskFollowupQuestionDescription, getAttemptCompletionDescription, getInsertContentDescription, getListFilesDescription, getReadFileDescription, getSearchAndReplaceDescription, getSearchFilesDescription, getSwitchModeDescription, getUseMcpToolDescription, getWriteToFileDescription
|
getAccessMcpResourceDescription, getReadFileDescription, getWriteToFileDescription, getSearchFilesDescription, getListFilesDescription,
|
||||||
|
getDataviewQueryDescription, getAskFollowupQuestionDescription, getAttemptCompletionDescription, getSwitchModeDescription, getInsertContentDescription,
|
||||||
|
getUseMcpToolDescription, getSearchAndReplaceDescription, getManageFilesDescription, getSearchWebDescription, getFetchUrlsContentDescription, getCallInsightsDescription as getCallInsightsDescription
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
66
src/core/prompts/tools/manage-files.ts
Normal file
66
src/core/prompts/tools/manage-files.ts
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
import { ToolArgs } from "./types"
|
||||||
|
|
||||||
|
export function getManageFilesDescription(args: ToolArgs): string {
|
||||||
|
return `## manage_files
|
||||||
|
Description: Request to perform file and folder management operations like moving, renaming, deleting, and creating folders. This tool can execute multiple operations in a single call, making it efficient for organizing the vault structure.
|
||||||
|
Parameters:
|
||||||
|
- operations: (required) A JSON array of file management operations. Each operation is an object with:
|
||||||
|
* action: (required) The type of operation. Can be "move", "delete", or "create_folder".
|
||||||
|
* ... and other parameters based on the action.
|
||||||
|
|
||||||
|
### Actions:
|
||||||
|
|
||||||
|
#### 1. Move / Rename
|
||||||
|
Moves or renames a file or folder.
|
||||||
|
- action: "move"
|
||||||
|
- source_path: (required) The current path of the file or folder.
|
||||||
|
- destination_path: (required) The new path for the file or folder.
|
||||||
|
|
||||||
|
#### 2. Delete
|
||||||
|
Deletes a file or folder.
|
||||||
|
- action: "delete"
|
||||||
|
- path: (required) The path of the file or folder to delete.
|
||||||
|
|
||||||
|
#### 3. Create Folder
|
||||||
|
Creates a new folder.
|
||||||
|
- action: "create_folder"
|
||||||
|
- path: (required) The path where the new folder should be created.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
<manage_files>
|
||||||
|
<operations>[
|
||||||
|
{
|
||||||
|
"action": "move",
|
||||||
|
"source_path": "Projects/Old Project.md",
|
||||||
|
"destination_path": "Archive/2023/Archived Project.md"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"action": "create_folder",
|
||||||
|
"path": "Projects/New Initiative/Assets"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"action": "delete",
|
||||||
|
"path": "Temporary/scratchpad.md"
|
||||||
|
}
|
||||||
|
]</operations>
|
||||||
|
</manage_files>
|
||||||
|
|
||||||
|
Example: Reorganize a project directory
|
||||||
|
<manage_files>
|
||||||
|
<operations>[
|
||||||
|
{
|
||||||
|
"action": "move",
|
||||||
|
"source_path": "MyProject/draft.md",
|
||||||
|
"destination_path": "MyProject/archive/draft_v1.md"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"action": "create_folder",
|
||||||
|
"path": "MyProject/media"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"action": "delete",
|
||||||
|
"path": "MyProject/obsolete_notes.md"
|
||||||
|
}
|
||||||
|
]</operations>
|
||||||
|
</manage_files>`
|
||||||
|
}
|
||||||
@ -67,6 +67,13 @@ Example: Requesting to search for all Markdown files in the current directory
|
|||||||
export function getSemanticSearchFilesDescription(args: ToolArgs): string {
|
export function getSemanticSearchFilesDescription(args: ToolArgs): string {
|
||||||
return `## semantic_search_files
|
return `## semantic_search_files
|
||||||
Description: Request to perform a semantic search across files in a specified directory. This tool searches for documents with content semantically related to your query, leveraging embedding vectors to find conceptually similar information. Ideal for finding relevant documents even when exact keywords are not known or for discovering thematically related content.
|
Description: Request to perform a semantic search across files in a specified directory. This tool searches for documents with content semantically related to your query, leveraging embedding vectors to find conceptually similar information. Ideal for finding relevant documents even when exact keywords are not known or for discovering thematically related content.
|
||||||
|
|
||||||
|
**Usage Guidelines:**
|
||||||
|
- Craft your natural language query to describe concepts and ideas rather than specific patterns
|
||||||
|
- Use this tool to find thematically related content, conceptually similar notes, or knowledge connections across the Obsidian vault, even when exact keywords aren't present
|
||||||
|
- The results include context, so analyze the surrounding text to understand the conceptual relevance of each match
|
||||||
|
- Leverage this tool in combination with other tools for comprehensive analysis (e.g., use semantic search to find relevant content, then use read_file to examine full context before making changes)
|
||||||
|
|
||||||
Parameters:
|
Parameters:
|
||||||
- path: (required) The path of the directory to search in (relative to the current working directory ${args.cwd}). This directory will be recursively searched.
|
- path: (required) The path of the directory to search in (relative to the current working directory ${args.cwd}). This directory will be recursively searched.
|
||||||
- query: (required) The natural language query describing the information you're looking for. The system will find documents with similar semantic meaning.
|
- query: (required) The natural language query describing the information you're looking for. The system will find documents with similar semantic meaning.
|
||||||
|
|||||||
@ -12,20 +12,19 @@ export const TOOL_DISPLAY_NAMES = {
|
|||||||
apply_diff: "apply changes",
|
apply_diff: "apply changes",
|
||||||
list_files: "list files",
|
list_files: "list files",
|
||||||
search_files: "search files",
|
search_files: "search files",
|
||||||
// list_code_definition_names: "list definitions",
|
dataview_query: "query dataview",
|
||||||
// browser_action: "use a browser",
|
use_mcp_tool: "use mcp tools",
|
||||||
// use_mcp_tool: "use mcp tools",
|
access_mcp_resource: "access mcp resources",
|
||||||
// access_mcp_resource: "access mcp resources",
|
insights: "call insights",
|
||||||
ask_followup_question: "ask questions",
|
ask_followup_question: "ask questions",
|
||||||
attempt_completion: "complete tasks",
|
attempt_completion: "complete tasks",
|
||||||
switch_mode: "switch modes",
|
switch_mode: "switch modes",
|
||||||
// new_task: "create new task",
|
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
// Define available tool groups
|
// Define available tool groups
|
||||||
export const TOOL_GROUPS: Record<string, ToolGroupConfig> = {
|
export const TOOL_GROUPS: Record<string, ToolGroupConfig> = {
|
||||||
read: {
|
read: {
|
||||||
tools: ["read_file", "list_files", "search_files"],
|
tools: ["read_file", "list_files", "search_files", "dataview_query"],
|
||||||
},
|
},
|
||||||
edit: {
|
edit: {
|
||||||
tools: ["apply_diff", "write_to_file", "insert_content", "search_and_replace"],
|
tools: ["apply_diff", "write_to_file", "insert_content", "search_and_replace"],
|
||||||
@ -33,12 +32,12 @@ export const TOOL_GROUPS: Record<string, ToolGroupConfig> = {
|
|||||||
research: {
|
research: {
|
||||||
tools: ["search_web", "fetch_urls_content"],
|
tools: ["search_web", "fetch_urls_content"],
|
||||||
},
|
},
|
||||||
// browser: {
|
insights: {
|
||||||
// tools: ["browser_action"],
|
tools: ["insights"],
|
||||||
// },
|
},
|
||||||
// command: {
|
manage_files: {
|
||||||
// tools: ["execute_command"],
|
tools: ["manage_files"],
|
||||||
// },
|
},
|
||||||
mcp: {
|
mcp: {
|
||||||
tools: ["use_mcp_tool", "access_mcp_resource"],
|
tools: ["use_mcp_tool", "access_mcp_resource"],
|
||||||
},
|
},
|
||||||
@ -61,11 +60,11 @@ export const ALWAYS_AVAILABLE_TOOLS = [
|
|||||||
export type ToolName = keyof typeof TOOL_DISPLAY_NAMES
|
export type ToolName = keyof typeof TOOL_DISPLAY_NAMES
|
||||||
|
|
||||||
// Tool helper functions
|
// Tool helper functions
|
||||||
export function getToolName(toolConfig: string | readonly [ToolName, ...any[]]): ToolName {
|
export function getToolName(toolConfig: string | readonly [ToolName, ...unknown[]]): ToolName {
|
||||||
return typeof toolConfig === "string" ? (toolConfig as ToolName) : toolConfig[0]
|
return typeof toolConfig === "string" ? toolConfig as ToolName : toolConfig[0]
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getToolOptions(toolConfig: string | readonly [ToolName, ...any[]]): any {
|
export function getToolOptions(toolConfig: string | readonly [ToolName, ...unknown[]]): unknown {
|
||||||
return typeof toolConfig === "string" ? undefined : toolConfig[1]
|
return typeof toolConfig === "string" ? undefined : toolConfig[1]
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -73,7 +72,8 @@ export function getToolOptions(toolConfig: string | readonly [ToolName, ...any[]
|
|||||||
export const GROUP_DISPLAY_NAMES: Record<ToolGroup, string> = {
|
export const GROUP_DISPLAY_NAMES: Record<ToolGroup, string> = {
|
||||||
read: "Read Files",
|
read: "Read Files",
|
||||||
edit: "Edit Files",
|
edit: "Edit Files",
|
||||||
// browser: "Use Browser",
|
research: "Research",
|
||||||
// command: "Run Commands",
|
insights: "insights",
|
||||||
// mcp: "Use MCP",
|
mcp: "MCP Tools",
|
||||||
|
modes: "Modes",
|
||||||
}
|
}
|
||||||
|
|||||||
39
src/core/prompts/transformations/analyze-paper.ts
Normal file
39
src/core/prompts/transformations/analyze-paper.ts
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
export const ANALYZE_PAPER_PROMPT = `# IDENTITY and PURPOSE
|
||||||
|
|
||||||
|
You are an insightful and analytical reader of academic papers, extracting the key components, significance, and broader implications. Your focus is to uncover the core contributions, practical applications, methodological strengths or weaknesses, and any surprising findings. You are especially attuned to the clarity of arguments, the relevance to existing literature, and potential impacts on both the specific field and broader contexts.
|
||||||
|
|
||||||
|
# STEPS
|
||||||
|
|
||||||
|
1. **READ AND UNDERSTAND THE PAPER**: Thoroughly read the paper, identifying its main focus, arguments, methods, results, and conclusions.
|
||||||
|
|
||||||
|
2. **IDENTIFY CORE ELEMENTS**:
|
||||||
|
- **Purpose**: What is the main goal or research question?
|
||||||
|
- **Contribution**: What new knowledge or innovation does this paper bring to the field?
|
||||||
|
- **Methods**: What methods are used, and are they novel or particularly effective?
|
||||||
|
- **Key Findings**: What are the most critical results, and why do they matter?
|
||||||
|
- **Limitations**: Are there any notable limitations or areas for further research?
|
||||||
|
|
||||||
|
3. **SYNTHESIZE THE MAIN POINTS**:
|
||||||
|
- Extract the key elements and organize them into insightful observations.
|
||||||
|
- Highlight the broader impact and potential applications.
|
||||||
|
- Note any aspects that challenge established views or introduce new questions.
|
||||||
|
|
||||||
|
# OUTPUT INSTRUCTIONS
|
||||||
|
|
||||||
|
- Structure the output as follows:
|
||||||
|
- **PURPOSE**: A concise summary of the main research question or goal (1-2 sentences).
|
||||||
|
- **CONTRIBUTION**: A bullet list of 2-3 points that describe what the paper adds to the field.
|
||||||
|
- **KEY FINDINGS**: A bullet list of 2-3 points summarizing the critical outcomes of the study.
|
||||||
|
- **IMPLICATIONS**: A bullet list of 2-3 points discussing the significance or potential impact of the findings on the field or broader context.
|
||||||
|
- **LIMITATIONS**: A bullet list of 1-2 points identifying notable limitations or areas for future work.
|
||||||
|
|
||||||
|
- **Bullet Points** should be between 15-20 words.
|
||||||
|
- Avoid starting each bullet point with the same word to maintain variety.
|
||||||
|
- Use clear and concise language that conveys the key ideas effectively.
|
||||||
|
- Do not include warnings, disclaimers, or personal opinions.
|
||||||
|
- Output only the requested sections with their respective labels.
|
||||||
|
|
||||||
|
You MUST respond in the {userLanguage} language.
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const ANALYZE_PAPER_DESCRIPTION = "Analyses a technical/scientific paper";
|
||||||
14
src/core/prompts/transformations/dense-summary.ts
Normal file
14
src/core/prompts/transformations/dense-summary.ts
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
export const DENSE_SUMMARY_PROMPT = `# MISSION
|
||||||
|
You are a Sparse Priming Representation (SPR) writer. Your goal is to render the user's input as an extremely concise and distilled SPR.
|
||||||
|
|
||||||
|
# THEORY
|
||||||
|
LLMs are a kind of deep neural network. They have been demonstrated to embed knowledge, abilities, and concepts, ranging from reasoning to planning, and even to theory of mind. These are called latent abilities and latent content, collectively referred to as latent space. The latent space of an LLM can be activated with the correct series of words as inputs, which will create a useful internal state of the neural network. You need to provide the most potent and succinct cues to prime another model effectively. Less is more.
|
||||||
|
|
||||||
|
# METHODOLOGY
|
||||||
|
Render the input as a distilled list of the **most critical** assertions, concepts, and associations. The idea is to capture the absolute essence with minimal words. Use complete sentences.
|
||||||
|
|
||||||
|
**!! CRITICAL INSTRUCTION !!**
|
||||||
|
**Your output MUST BE EXTREMELY CONCISE. Aim for a dense paragraph of no more than 3-5 sentences OR a bulleted list of 3-5 key points. Focus only on the highest-level insights and most essential concepts.**
|
||||||
|
You MUST respond in the {userLanguage} language.`;
|
||||||
|
|
||||||
|
export const DENSE_SUMMARY_DESCRIPTION = "Creates an extremely concise, rich summary of the content focusing on the most essential concepts";
|
||||||
16
src/core/prompts/transformations/hierarchical-summary.ts
Normal file
16
src/core/prompts/transformations/hierarchical-summary.ts
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
export const HIERARCHICAL_SUMMARY_PROMPT = `# MISSION
|
||||||
|
You are an expert knowledge architect responsible for creating hierarchical summaries of a knowledge base. You will be given a collection of summaries from files and sub-folders within a specific directory. Your mission is to synthesize these individual summaries into a single, cohesive, and abstract summary for the parent directory.
|
||||||
|
|
||||||
|
# METHODOLOGY
|
||||||
|
1. **Identify Core Themes**: Analyze the provided summaries to identify the main topics, recurring concepts, and overarching themes present in the directory.
|
||||||
|
2. **Synthesize, Don't Just List**: Do not simply concatenate or list the child summaries. Instead, integrate them. Explain what this collection of information represents as a whole. For example, instead of "This folder contains a summary of A and a summary of B," write "This folder explores the relationship between A and B, focusing on..."
|
||||||
|
3. **Capture Structure**: Briefly mention the types of content within (e.g., "Contains technical specifications, meeting notes, and final reports related to Project X.").
|
||||||
|
4. **Be Abstract and Concise**: The goal is to create a higher-level understanding. The output should be a dense, short paragraph that gives a bird's-eye view of the directory's contents and purpose.
|
||||||
|
5. **Focus on Relationships**: Highlight how the different pieces of content relate to each other and what they collectively achieve or represent.
|
||||||
|
|
||||||
|
**!! CRITICAL INSTRUCTION !!**
|
||||||
|
**Your output MUST BE CONCISE. Aim for 2-4 sentences that capture the essence and purpose of this directory as a cohesive unit. Focus on the highest-level insights and connections.**
|
||||||
|
You MUST respond in the {userLanguage} language.
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const HIERARCHICAL_SUMMARY_DESCRIPTION = "Creates a concise, high-level summary that synthesizes content from multiple files and folders into a cohesive understanding of the directory's purpose and themes";
|
||||||
26
src/core/prompts/transformations/key-insights.ts
Normal file
26
src/core/prompts/transformations/key-insights.ts
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
export const KEY_INSIGHTS_PROMPT = `# IDENTITY and PURPOSE
|
||||||
|
|
||||||
|
You extract surprising, powerful, and interesting insights from text content. You are interested in insights related to the purpose and meaning of life, human flourishing, the role of technology in the future of humanity, artificial intelligence and its affect on humans, memes, learning, reading, books, continuous improvement, and similar topics.
|
||||||
|
You create 15 word bullet points that capture the most important insights from the input.
|
||||||
|
Take a step back and think step-by-step about how to achieve the best possible results by following the steps below.
|
||||||
|
|
||||||
|
# STEPS
|
||||||
|
|
||||||
|
- Extract 20 to 50 of the most surprising, insightful, and/or interesting ideas from the input in a section called IDEAS, and write them on a virtual whiteboard in your mind using 15 word bullets. If there are less than 50 then collect all of them. Make sure you extract at least 20.
|
||||||
|
|
||||||
|
- From those IDEAS, extract the most powerful and insightful of them and write them in a section called INSIGHTS. Make sure you extract at least 10 and up to 25.
|
||||||
|
|
||||||
|
# OUTPUT INSTRUCTIONS
|
||||||
|
|
||||||
|
- INSIGHTS are essentially higher-level IDEAS that are more abstracted and wise.
|
||||||
|
- Output the INSIGHTS section only.
|
||||||
|
- Each bullet should be about 15 words in length.
|
||||||
|
- Do not give warnings or notes; only output the requested sections.
|
||||||
|
- You use bulleted lists for output, not numbered lists.
|
||||||
|
- Do not start items with the same opening words.
|
||||||
|
- Ensure you follow ALL these instructions when creating your output.
|
||||||
|
|
||||||
|
You MUST respond in the {userLanguage} language.
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const KEY_INSIGHTS_DESCRIPTION = "Extracts important insights and actionable items";
|
||||||
24
src/core/prompts/transformations/reflections.ts
Normal file
24
src/core/prompts/transformations/reflections.ts
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
export const REFLECTIONS_PROMPT = `# IDENTITY and PURPOSE
|
||||||
|
|
||||||
|
You extract deep, thought-provoking, and meaningful reflections from text content. You are especially focused on themes related to the human experience, such as the purpose of life, personal growth, the intersection of technology and humanity, artificial intelligence's societal impact, human potential, collective evolution, and transformative learning. Your reflections aim to provoke new ways of thinking, challenge assumptions, and provide a thoughtful synthesis of the content.
|
||||||
|
|
||||||
|
# STEPS
|
||||||
|
|
||||||
|
- Extract 3 to 5 of the most profound, thought-provoking, and/or meaningful ideas from the input in a section called REFLECTIONS.
|
||||||
|
- Each reflection should aim to explore underlying implications, connections to broader human experiences, or highlight a transformative perspective.
|
||||||
|
- Take a step back and consider the deeper significance or questions that arise from the content.
|
||||||
|
|
||||||
|
# OUTPUT INSTRUCTIONS
|
||||||
|
|
||||||
|
- The output section should be labeled as REFLECTIONS.
|
||||||
|
- Each bullet point should be between 20-25 words.
|
||||||
|
- Avoid repetition in the phrasing and ensure variety in sentence structure.
|
||||||
|
- The reflections should encourage deeper inquiry and provide a synthesis that transcends surface-level observations.
|
||||||
|
- Use bullet points, not numbered lists.
|
||||||
|
- Every bullet should be formatted as a question that elicits contemplation or a statement that offers a profound insight.
|
||||||
|
- Do not give warnings or notes; only output the requested section.
|
||||||
|
|
||||||
|
You MUST respond in the {userLanguage} language.
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const REFLECTIONS_DESCRIPTION = "Generates reflection questions from the document to help explore it further";
|
||||||
14
src/core/prompts/transformations/simple-summary.ts
Normal file
14
src/core/prompts/transformations/simple-summary.ts
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
export const SIMPLE_SUMMARY_PROMPT = `# SYSTEM ROLE
|
||||||
|
You are a content summarization assistant that creates dense, information-rich summaries optimized for machine understanding. Your summaries should capture key concepts with minimal words while maintaining complete, clear sentences.
|
||||||
|
|
||||||
|
# TASK
|
||||||
|
Analyze the provided content and create a summary that:
|
||||||
|
- Captures the core concepts and key information
|
||||||
|
- Uses clear, direct language
|
||||||
|
- Maintains context from any previous summaries
|
||||||
|
|
||||||
|
You MUST respond in the {userLanguage} language.
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const SIMPLE_SUMMARY_DESCRIPTION = "Generates a small summary of the content";
|
||||||
|
|
||||||
13
src/core/prompts/transformations/table-of-contents.ts
Normal file
13
src/core/prompts/transformations/table-of-contents.ts
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
export const TABLE_OF_CONTENTS_PROMPT = `# SYSTEM ROLE
|
||||||
|
You are a content analysis assistant that reads through documents and provides a Table of Contents (ToC) to help users identify what the document covers more easily.
|
||||||
|
Your ToC should capture all major topics and transitions in the content and should mention them in the order theh appear.
|
||||||
|
|
||||||
|
# TASK
|
||||||
|
Analyze the provided content and create a Table of Contents:
|
||||||
|
- Captures the core topics included in the text
|
||||||
|
- Gives a small description of what is covered
|
||||||
|
|
||||||
|
You MUST respond in the {userLanguage} language.
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const TABLE_OF_CONTENTS_DESCRIPTION = "Describes the different topics of the document";
|
||||||
@ -16,10 +16,66 @@ import {
|
|||||||
} from '../llm/exception'
|
} from '../llm/exception'
|
||||||
import { NoStainlessOpenAI } from '../llm/ollama'
|
import { NoStainlessOpenAI } from '../llm/ollama'
|
||||||
|
|
||||||
|
// EmbeddingManager 类型定义
|
||||||
|
type EmbeddingManager = {
|
||||||
|
modelLoaded: boolean
|
||||||
|
currentModel: string | null
|
||||||
|
loadModel(modelId: string, useGpu: boolean): Promise<any>
|
||||||
|
embed(text: string): Promise<{ vec: number[] }>
|
||||||
|
embedBatch(texts: string[]): Promise<{ vec: number[] }[]>
|
||||||
|
}
|
||||||
|
|
||||||
export const getEmbeddingModel = (
|
export const getEmbeddingModel = (
|
||||||
settings: InfioSettings,
|
settings: InfioSettings,
|
||||||
|
embeddingManager?: EmbeddingManager,
|
||||||
): EmbeddingModel => {
|
): EmbeddingModel => {
|
||||||
switch (settings.embeddingModelProvider) {
|
switch (settings.embeddingModelProvider) {
|
||||||
|
case ApiProvider.LocalProvider: {
|
||||||
|
if (!embeddingManager) {
|
||||||
|
throw new Error('EmbeddingManager is required for LocalProvider')
|
||||||
|
}
|
||||||
|
|
||||||
|
const modelInfo = GetEmbeddingModelInfo(settings.embeddingModelProvider, settings.embeddingModelId)
|
||||||
|
if (!modelInfo) {
|
||||||
|
throw new Error(`Embedding model ${settings.embeddingModelId} not found for provider ${settings.embeddingModelProvider}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: settings.embeddingModelId,
|
||||||
|
dimension: modelInfo.dimensions,
|
||||||
|
supportsBatch: true,
|
||||||
|
getEmbedding: async (text: string) => {
|
||||||
|
try {
|
||||||
|
// 确保模型已加载
|
||||||
|
if (!embeddingManager.modelLoaded || embeddingManager.currentModel !== settings.embeddingModelId) {
|
||||||
|
console.log(`Loading model: ${settings.embeddingModelId}`)
|
||||||
|
await embeddingManager.loadModel(settings.embeddingModelId, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await embeddingManager.embed(text)
|
||||||
|
return result.vec
|
||||||
|
} catch (error) {
|
||||||
|
console.error('LocalProvider embedding error:', error)
|
||||||
|
throw new Error(`LocalProvider embedding failed: ${error.message}`)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
getBatchEmbeddings: async (texts: string[]) => {
|
||||||
|
try {
|
||||||
|
// 确保模型已加载
|
||||||
|
if (!embeddingManager.modelLoaded || embeddingManager.currentModel !== settings.embeddingModelId) {
|
||||||
|
console.log(`Loading model: ${settings.embeddingModelId}`)
|
||||||
|
await embeddingManager.loadModel(settings.embeddingModelId, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const results = await embeddingManager.embedBatch(texts)
|
||||||
|
return results.map(result => result.vec)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('LocalProvider batch embedding error:', error)
|
||||||
|
throw new Error(`LocalProvider batch embedding failed: ${error.message}`)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
case ApiProvider.Infio: {
|
case ApiProvider.Infio: {
|
||||||
const openai = new OpenAI({
|
const openai = new OpenAI({
|
||||||
apiKey: settings.infioProvider.apiKey,
|
apiKey: settings.infioProvider.apiKey,
|
||||||
@ -27,6 +83,9 @@ export const getEmbeddingModel = (
|
|||||||
dangerouslyAllowBrowser: true,
|
dangerouslyAllowBrowser: true,
|
||||||
})
|
})
|
||||||
const modelInfo = GetEmbeddingModelInfo(settings.embeddingModelProvider, settings.embeddingModelId)
|
const modelInfo = GetEmbeddingModelInfo(settings.embeddingModelProvider, settings.embeddingModelId)
|
||||||
|
if (!modelInfo) {
|
||||||
|
throw new Error(`Embedding model ${settings.embeddingModelId} not found for provider ${settings.embeddingModelProvider}`)
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
id: settings.embeddingModelId,
|
id: settings.embeddingModelId,
|
||||||
dimension: modelInfo.dimensions,
|
dimension: modelInfo.dimensions,
|
||||||
@ -89,6 +148,9 @@ export const getEmbeddingModel = (
|
|||||||
dangerouslyAllowBrowser: true,
|
dangerouslyAllowBrowser: true,
|
||||||
})
|
})
|
||||||
const modelInfo = GetEmbeddingModelInfo(settings.embeddingModelProvider, settings.embeddingModelId)
|
const modelInfo = GetEmbeddingModelInfo(settings.embeddingModelProvider, settings.embeddingModelId)
|
||||||
|
if (!modelInfo) {
|
||||||
|
throw new Error(`Embedding model ${settings.embeddingModelId} not found for provider ${settings.embeddingModelProvider}`)
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
id: settings.embeddingModelId,
|
id: settings.embeddingModelId,
|
||||||
dimension: modelInfo.dimensions,
|
dimension: modelInfo.dimensions,
|
||||||
@ -151,6 +213,9 @@ export const getEmbeddingModel = (
|
|||||||
dangerouslyAllowBrowser: true,
|
dangerouslyAllowBrowser: true,
|
||||||
})
|
})
|
||||||
const modelInfo = GetEmbeddingModelInfo(settings.embeddingModelProvider, settings.embeddingModelId)
|
const modelInfo = GetEmbeddingModelInfo(settings.embeddingModelProvider, settings.embeddingModelId)
|
||||||
|
if (!modelInfo) {
|
||||||
|
throw new Error(`Embedding model ${settings.embeddingModelId} not found for provider ${settings.embeddingModelProvider}`)
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
id: settings.embeddingModelId,
|
id: settings.embeddingModelId,
|
||||||
dimension: modelInfo.dimensions,
|
dimension: modelInfo.dimensions,
|
||||||
@ -213,6 +278,9 @@ export const getEmbeddingModel = (
|
|||||||
dangerouslyAllowBrowser: true,
|
dangerouslyAllowBrowser: true,
|
||||||
})
|
})
|
||||||
const modelInfo = GetEmbeddingModelInfo(settings.embeddingModelProvider, settings.embeddingModelId)
|
const modelInfo = GetEmbeddingModelInfo(settings.embeddingModelProvider, settings.embeddingModelId)
|
||||||
|
if (!modelInfo) {
|
||||||
|
throw new Error(`Embedding model ${settings.embeddingModelId} not found for provider ${settings.embeddingModelProvider}`)
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
id: settings.embeddingModelId,
|
id: settings.embeddingModelId,
|
||||||
dimension: modelInfo.dimensions,
|
dimension: modelInfo.dimensions,
|
||||||
@ -271,6 +339,9 @@ export const getEmbeddingModel = (
|
|||||||
const client = new GoogleGenerativeAI(settings.googleProvider.apiKey)
|
const client = new GoogleGenerativeAI(settings.googleProvider.apiKey)
|
||||||
const model = client.getGenerativeModel({ model: settings.embeddingModelId })
|
const model = client.getGenerativeModel({ model: settings.embeddingModelId })
|
||||||
const modelInfo = GetEmbeddingModelInfo(settings.embeddingModelProvider, settings.embeddingModelId)
|
const modelInfo = GetEmbeddingModelInfo(settings.embeddingModelProvider, settings.embeddingModelId)
|
||||||
|
if (!modelInfo) {
|
||||||
|
throw new Error(`Embedding model ${settings.embeddingModelId} not found for provider ${settings.embeddingModelProvider}`)
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
id: settings.embeddingModelId,
|
id: settings.embeddingModelId,
|
||||||
dimension: modelInfo.dimensions,
|
dimension: modelInfo.dimensions,
|
||||||
|
|||||||
@ -2,17 +2,29 @@ import { App, TFile } from 'obsidian'
|
|||||||
|
|
||||||
import { QueryProgressState } from '../../components/chat-view/QueryProgress'
|
import { QueryProgressState } from '../../components/chat-view/QueryProgress'
|
||||||
import { DBManager } from '../../database/database-manager'
|
import { DBManager } from '../../database/database-manager'
|
||||||
|
import { Workspace } from '../../database/json/workspace/types'
|
||||||
import { VectorManager } from '../../database/modules/vector/vector-manager'
|
import { VectorManager } from '../../database/modules/vector/vector-manager'
|
||||||
import { SelectVector } from '../../database/schema'
|
import { SelectVector } from '../../database/schema'
|
||||||
import { EmbeddingModel } from '../../types/embedding'
|
import { EmbeddingModel } from '../../types/embedding'
|
||||||
import { ApiProvider } from '../../types/llm/model'
|
import { ApiProvider } from '../../types/llm/model'
|
||||||
import { InfioSettings } from '../../types/settings'
|
import { InfioSettings } from '../../types/settings'
|
||||||
|
import { getFilesWithTag } from '../../utils/glob-utils'
|
||||||
|
|
||||||
import { getEmbeddingModel } from './embedding'
|
import { getEmbeddingModel } from './embedding'
|
||||||
|
|
||||||
|
// EmbeddingManager 类型定义
|
||||||
|
type EmbeddingManager = {
|
||||||
|
modelLoaded: boolean
|
||||||
|
currentModel: string | null
|
||||||
|
loadModel(modelId: string, useGpu: boolean): Promise<unknown>
|
||||||
|
embed(text: string): Promise<{ vec: number[] }>
|
||||||
|
embedBatch(texts: string[]): Promise<{ vec: number[] }[]>
|
||||||
|
}
|
||||||
|
|
||||||
export class RAGEngine {
|
export class RAGEngine {
|
||||||
private app: App
|
private app: App
|
||||||
private settings: InfioSettings
|
private settings: InfioSettings
|
||||||
|
private embeddingManager?: EmbeddingManager
|
||||||
private vectorManager: VectorManager | null = null
|
private vectorManager: VectorManager | null = null
|
||||||
private embeddingModel: EmbeddingModel | null = null
|
private embeddingModel: EmbeddingModel | null = null
|
||||||
private initialized = false
|
private initialized = false
|
||||||
@ -21,11 +33,22 @@ export class RAGEngine {
|
|||||||
app: App,
|
app: App,
|
||||||
settings: InfioSettings,
|
settings: InfioSettings,
|
||||||
dbManager: DBManager,
|
dbManager: DBManager,
|
||||||
|
embeddingManager?: EmbeddingManager,
|
||||||
) {
|
) {
|
||||||
this.app = app
|
this.app = app
|
||||||
this.settings = settings
|
this.settings = settings
|
||||||
|
this.embeddingManager = embeddingManager
|
||||||
this.vectorManager = dbManager.getVectorManager()
|
this.vectorManager = dbManager.getVectorManager()
|
||||||
this.embeddingModel = getEmbeddingModel(settings)
|
if (settings.embeddingModelId && settings.embeddingModelId.trim() !== '') {
|
||||||
|
try {
|
||||||
|
this.embeddingModel = getEmbeddingModel(settings, embeddingManager)
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to initialize embedding model:', error)
|
||||||
|
this.embeddingModel = null
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.embeddingModel = null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
cleanup() {
|
cleanup() {
|
||||||
@ -35,7 +58,16 @@ export class RAGEngine {
|
|||||||
|
|
||||||
setSettings(settings: InfioSettings) {
|
setSettings(settings: InfioSettings) {
|
||||||
this.settings = settings
|
this.settings = settings
|
||||||
this.embeddingModel = getEmbeddingModel(settings)
|
if (settings.embeddingModelId && settings.embeddingModelId.trim() !== '') {
|
||||||
|
try {
|
||||||
|
this.embeddingModel = getEmbeddingModel(settings, this.embeddingManager)
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to initialize embedding model:', error)
|
||||||
|
this.embeddingModel = null
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.embeddingModel = null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async initializeDimension(): Promise<void> {
|
async initializeDimension(): Promise<void> {
|
||||||
@ -58,6 +90,37 @@ export class RAGEngine {
|
|||||||
this.embeddingModel,
|
this.embeddingModel,
|
||||||
{
|
{
|
||||||
chunkSize: this.settings.ragOptions.chunkSize,
|
chunkSize: this.settings.ragOptions.chunkSize,
|
||||||
|
batchSize: this.settings.ragOptions.batchSize,
|
||||||
|
excludePatterns: this.settings.ragOptions.excludePatterns,
|
||||||
|
includePatterns: this.settings.ragOptions.includePatterns,
|
||||||
|
reindexAll: options.reindexAll,
|
||||||
|
},
|
||||||
|
(indexProgress) => {
|
||||||
|
onQueryProgressChange?.({
|
||||||
|
type: 'indexing',
|
||||||
|
indexProgress,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
)
|
||||||
|
this.initialized = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateWorkspaceIndex(
|
||||||
|
workspace: Workspace,
|
||||||
|
options: { reindexAll: boolean },
|
||||||
|
onQueryProgressChange?: (queryProgress: QueryProgressState) => void,
|
||||||
|
): Promise<void> {
|
||||||
|
if (!this.embeddingModel) {
|
||||||
|
throw new Error('Embedding model is not set')
|
||||||
|
}
|
||||||
|
await this.initializeDimension()
|
||||||
|
|
||||||
|
await this.vectorManager.updateWorkspaceIndex(
|
||||||
|
this.embeddingModel,
|
||||||
|
workspace,
|
||||||
|
{
|
||||||
|
chunkSize: this.settings.ragOptions.chunkSize,
|
||||||
|
batchSize: this.settings.ragOptions.batchSize,
|
||||||
excludePatterns: this.settings.ragOptions.excludePatterns,
|
excludePatterns: this.settings.ragOptions.excludePatterns,
|
||||||
includePatterns: this.settings.ragOptions.includePatterns,
|
includePatterns: this.settings.ragOptions.includePatterns,
|
||||||
reindexAll: options.reindexAll,
|
reindexAll: options.reindexAll,
|
||||||
@ -82,6 +145,7 @@ export class RAGEngine {
|
|||||||
await this.vectorManager.UpdateFileVectorIndex(
|
await this.vectorManager.UpdateFileVectorIndex(
|
||||||
this.embeddingModel,
|
this.embeddingModel,
|
||||||
this.settings.ragOptions.chunkSize,
|
this.settings.ragOptions.chunkSize,
|
||||||
|
this.settings.ragOptions.batchSize,
|
||||||
file,
|
file,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -99,7 +163,7 @@ export class RAGEngine {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
async processQuery({
|
async processSimilarityQuery({
|
||||||
query,
|
query,
|
||||||
scope,
|
scope,
|
||||||
limit,
|
limit,
|
||||||
@ -147,10 +211,285 @@ export class RAGEngine {
|
|||||||
return queryResult
|
return queryResult
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async processQuery({
|
||||||
|
query,
|
||||||
|
scope,
|
||||||
|
limit,
|
||||||
|
language,
|
||||||
|
onQueryProgressChange,
|
||||||
|
}: {
|
||||||
|
query: string
|
||||||
|
scope?: {
|
||||||
|
files: string[]
|
||||||
|
folders: string[]
|
||||||
|
}
|
||||||
|
limit?: number
|
||||||
|
language?: string
|
||||||
|
onQueryProgressChange?: (queryProgress: QueryProgressState) => void
|
||||||
|
}): Promise<
|
||||||
|
(Omit<SelectVector, 'embedding'> & {
|
||||||
|
similarity: number
|
||||||
|
})[]
|
||||||
|
> {
|
||||||
|
if (!this.embeddingModel) {
|
||||||
|
throw new Error('Embedding model is not set')
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.initializeDimension()
|
||||||
|
|
||||||
|
onQueryProgressChange?.({
|
||||||
|
type: 'querying',
|
||||||
|
})
|
||||||
|
|
||||||
|
// 并行执行相似度搜索和全文搜索
|
||||||
|
const [similarityResults, fulltextResults] = await Promise.all([
|
||||||
|
this.processSimilarityQuery({
|
||||||
|
query,
|
||||||
|
scope,
|
||||||
|
limit,
|
||||||
|
onQueryProgressChange: undefined, // 避免重复触发进度回调
|
||||||
|
}),
|
||||||
|
this.processFulltextQuery({
|
||||||
|
query,
|
||||||
|
scope,
|
||||||
|
limit,
|
||||||
|
language,
|
||||||
|
onQueryProgressChange: undefined, // 避免重复触发进度回调
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
|
||||||
|
// 优化:如果其中一个搜索结果为空,直接返回另一个结果
|
||||||
|
let finalResults: (Omit<SelectVector, 'embedding'> & { similarity: number })[]
|
||||||
|
|
||||||
|
if (fulltextResults.length === 0) {
|
||||||
|
// 全文搜索结果为空,直接返回相似度搜索结果
|
||||||
|
finalResults = similarityResults
|
||||||
|
} else if (similarityResults.length === 0) {
|
||||||
|
// 相似度搜索结果为空,直接返回全文搜索结果(转换格式)
|
||||||
|
finalResults = fulltextResults.map(result => ({
|
||||||
|
...result,
|
||||||
|
similarity: 1 - (result.rank - 1) / fulltextResults.length, // 将rank转换为相似度分数
|
||||||
|
}))
|
||||||
|
} else {
|
||||||
|
// 两个搜索都有结果,使用 RRF 算法合并
|
||||||
|
const rrf_k = 60 // RRF 常数
|
||||||
|
const mergedResults = this.mergeWithRRF(similarityResults, fulltextResults, rrf_k)
|
||||||
|
|
||||||
|
// 转换为与现有接口兼容的格式
|
||||||
|
finalResults = mergedResults.map(result => ({
|
||||||
|
...result,
|
||||||
|
similarity: result.rrfScore, // 使用 RRF 分数作为相似度
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
onQueryProgressChange?.({
|
||||||
|
type: 'querying-done',
|
||||||
|
queryResult: finalResults,
|
||||||
|
})
|
||||||
|
|
||||||
|
return finalResults
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 使用倒数排名融合(RRF)算法合并相似度搜索和全文搜索结果
|
||||||
|
* @param similarityResults 相似度搜索结果
|
||||||
|
* @param fulltextResults 全文搜索结果
|
||||||
|
* @param k RRF 常数,通常为 60
|
||||||
|
* @returns 合并后的结果,按 RRF 分数排序
|
||||||
|
*/
|
||||||
|
private mergeWithRRF(
|
||||||
|
similarityResults: (Omit<SelectVector, 'embedding'> & { similarity: number })[],
|
||||||
|
fulltextResults: (Omit<SelectVector, 'embedding'> & { rank: number })[],
|
||||||
|
k: number = 60
|
||||||
|
): (Omit<SelectVector, 'embedding'> & { rrfScore: number })[] {
|
||||||
|
// 创建一个 Map 来存储每个文档的 RRF 分数
|
||||||
|
const rrfScores = new Map<string, {
|
||||||
|
doc: Omit<SelectVector, 'embedding'>,
|
||||||
|
score: number
|
||||||
|
}>()
|
||||||
|
|
||||||
|
// 处理相似度搜索结果
|
||||||
|
similarityResults.forEach((result, index) => {
|
||||||
|
const key = `${result.path}-${result.id}`
|
||||||
|
const rank = index + 1
|
||||||
|
const rrfScore = 1 / (k + rank)
|
||||||
|
|
||||||
|
if (rrfScores.has(key)) {
|
||||||
|
const existing = rrfScores.get(key)
|
||||||
|
if (existing) {
|
||||||
|
existing.score += rrfScore
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
rrfScores.set(key, {
|
||||||
|
doc: {
|
||||||
|
id: result.id,
|
||||||
|
path: result.path,
|
||||||
|
mtime: result.mtime,
|
||||||
|
content: result.content,
|
||||||
|
metadata: result.metadata,
|
||||||
|
},
|
||||||
|
score: rrfScore
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 处理全文搜索结果
|
||||||
|
fulltextResults.forEach((result, index) => {
|
||||||
|
const key = `${result.path}-${result.id}`
|
||||||
|
const rank = index + 1
|
||||||
|
const rrfScore = 1 / (k + rank)
|
||||||
|
|
||||||
|
if (rrfScores.has(key)) {
|
||||||
|
const existing = rrfScores.get(key)
|
||||||
|
if (existing) {
|
||||||
|
existing.score += rrfScore
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
rrfScores.set(key, {
|
||||||
|
doc: {
|
||||||
|
id: result.id,
|
||||||
|
path: result.path,
|
||||||
|
mtime: result.mtime,
|
||||||
|
content: result.content,
|
||||||
|
metadata: result.metadata,
|
||||||
|
},
|
||||||
|
score: rrfScore
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 转换为数组并进行归一化处理
|
||||||
|
const results = Array.from(rrfScores.values())
|
||||||
|
|
||||||
|
// 找到最大分数用于归一化
|
||||||
|
const maxScore = Math.max(...results.map(r => r.score))
|
||||||
|
|
||||||
|
// 归一化到 0~1 范围并按分数排序
|
||||||
|
const mergedResults = results
|
||||||
|
.map(({ doc, score }) => ({
|
||||||
|
...doc,
|
||||||
|
rrfScore: maxScore > 0 ? score / maxScore : 0 // 归一化到 0~1
|
||||||
|
}))
|
||||||
|
.sort((a, b) => b.rrfScore - a.rrfScore)
|
||||||
|
|
||||||
|
return mergedResults
|
||||||
|
}
|
||||||
|
|
||||||
|
async processFulltextQuery({
|
||||||
|
query,
|
||||||
|
scope,
|
||||||
|
limit,
|
||||||
|
language,
|
||||||
|
onQueryProgressChange,
|
||||||
|
}: {
|
||||||
|
query: string
|
||||||
|
scope?: {
|
||||||
|
files: string[]
|
||||||
|
folders: string[]
|
||||||
|
}
|
||||||
|
limit?: number
|
||||||
|
language?: string
|
||||||
|
onQueryProgressChange?: (queryProgress: QueryProgressState) => void
|
||||||
|
}): Promise<
|
||||||
|
(Omit<SelectVector, 'embedding'> & {
|
||||||
|
rank: number
|
||||||
|
})[]
|
||||||
|
> {
|
||||||
|
if (!this.embeddingModel) {
|
||||||
|
throw new Error('Embedding model is not set')
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.initializeDimension()
|
||||||
|
|
||||||
|
onQueryProgressChange?.({
|
||||||
|
type: 'querying',
|
||||||
|
})
|
||||||
|
|
||||||
|
const queryResult = await this.vectorManager.performFulltextSearch(
|
||||||
|
query,
|
||||||
|
this.embeddingModel,
|
||||||
|
{
|
||||||
|
limit: limit ?? this.settings.ragOptions.limit,
|
||||||
|
scope,
|
||||||
|
language: language || 'english',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
onQueryProgressChange?.({
|
||||||
|
type: 'querying-done',
|
||||||
|
queryResult: queryResult.map(result => ({
|
||||||
|
...result,
|
||||||
|
similarity: result.rank, // 为了兼容 QueryProgressState 类型
|
||||||
|
})),
|
||||||
|
})
|
||||||
|
|
||||||
|
return queryResult
|
||||||
|
}
|
||||||
|
|
||||||
async getEmbedding(query: string): Promise<number[]> {
|
async getEmbedding(query: string): Promise<number[]> {
|
||||||
if (!this.embeddingModel) {
|
if (!this.embeddingModel) {
|
||||||
throw new Error('Embedding model is not set')
|
throw new Error('Embedding model is not set')
|
||||||
}
|
}
|
||||||
return this.embeddingModel.getEmbedding(query)
|
return this.embeddingModel.getEmbedding(query)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getWorkspaceStatistics(workspace?: Workspace): Promise<{
|
||||||
|
totalFiles: number
|
||||||
|
totalChunks: number
|
||||||
|
}> {
|
||||||
|
if (!this.embeddingModel) {
|
||||||
|
throw new Error('Embedding model is not set')
|
||||||
|
}
|
||||||
|
await this.initializeDimension()
|
||||||
|
return await this.vectorManager.getWorkspaceStatistics(this.embeddingModel, workspace)
|
||||||
|
}
|
||||||
|
|
||||||
|
async getVaultStatistics(): Promise<{
|
||||||
|
totalFiles: number
|
||||||
|
totalChunks: number
|
||||||
|
}> {
|
||||||
|
if (!this.embeddingModel) {
|
||||||
|
throw new Error('Embedding model is not set')
|
||||||
|
}
|
||||||
|
await this.initializeDimension()
|
||||||
|
return await this.vectorManager.getVaultStatistics(this.embeddingModel)
|
||||||
|
}
|
||||||
|
|
||||||
|
async clearWorkspaceIndex(workspace?: Workspace): Promise<void> {
|
||||||
|
if (!this.embeddingModel) {
|
||||||
|
throw new Error('Embedding model is not set')
|
||||||
|
}
|
||||||
|
await this.initializeDimension()
|
||||||
|
|
||||||
|
if (workspace) {
|
||||||
|
// 获取工作区中的所有文件路径
|
||||||
|
const files: string[] = []
|
||||||
|
|
||||||
|
for (const item of workspace.content) {
|
||||||
|
if (item.type === 'folder') {
|
||||||
|
const folderPath = item.content
|
||||||
|
|
||||||
|
// 获取文件夹下的所有文件
|
||||||
|
const folderFiles = this.app.vault.getMarkdownFiles().filter(file =>
|
||||||
|
file.path.startsWith(folderPath === '/' ? '' : folderPath + '/')
|
||||||
|
)
|
||||||
|
|
||||||
|
files.push(...folderFiles.map(file => file.path))
|
||||||
|
} else if (item.type === 'tag') {
|
||||||
|
// 获取标签对应的所有文件
|
||||||
|
const tagFiles = getFilesWithTag(item.content, this.app)
|
||||||
|
files.push(...tagFiles)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除工作区相关的向量
|
||||||
|
if (files.length > 0) {
|
||||||
|
// 通过 VectorManager 的私有 repository 访问
|
||||||
|
await this.vectorManager['repository'].deleteVectorsForMultipleFiles(files, this.embeddingModel)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 清除所有向量
|
||||||
|
await this.vectorManager['repository'].clearAllVectors(this.embeddingModel)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
191
src/core/transformations/README.md
Normal file
191
src/core/transformations/README.md
Normal file
@ -0,0 +1,191 @@
|
|||||||
|
# 文档转换功能 (Document Transformation)
|
||||||
|
|
||||||
|
这个模块提供了使用 LLM 对文档进行各种预处理转换的功能。
|
||||||
|
|
||||||
|
## 功能特性
|
||||||
|
|
||||||
|
- 🔄 **多种转换类型**:支持 6 种不同的文档转换
|
||||||
|
- 📏 **智能截断**:自动处理过长的文档,在合适的位置截断
|
||||||
|
- 🚀 **批量处理**:支持同时执行多种转换
|
||||||
|
- 🛡️ **错误处理**:完善的错误处理和验证机制
|
||||||
|
- ⚡ **异步处理**:基于 Promise 的异步 API
|
||||||
|
|
||||||
|
## 支持的转换类型
|
||||||
|
|
||||||
|
| 转换类型 | 描述 | 适用场景 |
|
||||||
|
|---------|------|----------|
|
||||||
|
| `SIMPLE_SUMMARY` | 生成简单摘要 | 快速了解文档主要内容 |
|
||||||
|
| `DENSE_SUMMARY` | 生成深度摘要 | 保留更多细节的密集摘要 |
|
||||||
|
| `ANALYZE_PAPER` | 分析技术论文 | 学术论文的结构化分析 |
|
||||||
|
| `KEY_INSIGHTS` | 提取关键洞察 | 发现文档中的重要观点 |
|
||||||
|
| `TABLE_OF_CONTENTS` | 生成目录 | 了解文档结构和主要话题 |
|
||||||
|
| `REFLECTIONS` | 生成反思问题 | 促进深度思考的问题 |
|
||||||
|
|
||||||
|
## 基本使用方法
|
||||||
|
|
||||||
|
### 1. 单个转换
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { runTransformation, TransformationType } from './transformations';
|
||||||
|
|
||||||
|
async function performTransformation() {
|
||||||
|
const result = await runTransformation({
|
||||||
|
content: "你的文档内容...",
|
||||||
|
transformationType: TransformationType.SIMPLE_SUMMARY,
|
||||||
|
settings: yourInfioSettings
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
console.log('转换结果:', result.result);
|
||||||
|
} else {
|
||||||
|
console.error('转换失败:', result.error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 批量转换
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { runBatchTransformations, TransformationType } from './transformations';
|
||||||
|
|
||||||
|
async function performBatchTransformations() {
|
||||||
|
const results = await runBatchTransformations(
|
||||||
|
"你的文档内容...",
|
||||||
|
[
|
||||||
|
TransformationType.SIMPLE_SUMMARY,
|
||||||
|
TransformationType.KEY_INSIGHTS,
|
||||||
|
TransformationType.TABLE_OF_CONTENTS
|
||||||
|
],
|
||||||
|
yourInfioSettings
|
||||||
|
);
|
||||||
|
|
||||||
|
// 处理每个转换的结果
|
||||||
|
Object.entries(results).forEach(([type, result]) => {
|
||||||
|
if (result.success) {
|
||||||
|
console.log(`${type}:`, result.result);
|
||||||
|
} else {
|
||||||
|
console.error(`${type} 失败:`, result.error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 处理长文档
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const result = await runTransformation({
|
||||||
|
content: veryLongDocument,
|
||||||
|
transformationType: TransformationType.DENSE_SUMMARY,
|
||||||
|
settings: yourInfioSettings,
|
||||||
|
maxContentLength: 30000 // 限制最大处理长度
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.truncated) {
|
||||||
|
console.log(`文档被截断: ${result.originalLength} -> ${result.processedLength} 字符`);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## API 参考
|
||||||
|
|
||||||
|
### TransformationParams
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface TransformationParams {
|
||||||
|
content: string; // 要转换的文档内容
|
||||||
|
transformationType: TransformationType; // 转换类型
|
||||||
|
settings: InfioSettings; // 应用设置
|
||||||
|
model?: LLMModel; // 可选:指定使用的模型
|
||||||
|
maxContentLength?: number; // 可选:最大内容长度限制
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### TransformationResult
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface TransformationResult {
|
||||||
|
success: boolean; // 转换是否成功
|
||||||
|
result?: string; // 转换结果(成功时)
|
||||||
|
error?: string; // 错误信息(失败时)
|
||||||
|
truncated?: boolean; // 内容是否被截断
|
||||||
|
originalLength?: number; // 原始内容长度
|
||||||
|
processedLength?: number; // 处理后内容长度
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 文档大小处理
|
||||||
|
|
||||||
|
系统会自动处理过长的文档:
|
||||||
|
|
||||||
|
- **默认限制**:50,000 字符
|
||||||
|
- **最小长度**:100 字符
|
||||||
|
- **智能截断**:尝试在句子或段落边界处截断
|
||||||
|
- **保护机制**:确保截断后不会丢失过多内容
|
||||||
|
|
||||||
|
## 错误处理
|
||||||
|
|
||||||
|
常见的错误情况及处理:
|
||||||
|
|
||||||
|
- **空内容**:返回错误信息 "内容不能为空"
|
||||||
|
- **内容过短**:内容少于 100 字符时返回错误
|
||||||
|
- **不支持的转换类型**:返回相应错误信息
|
||||||
|
- **LLM 调用失败**:返回具体的调用错误信息
|
||||||
|
|
||||||
|
## 最佳实践
|
||||||
|
|
||||||
|
1. **内容验证**:在调用前确保内容不为空且长度适当
|
||||||
|
2. **错误处理**:始终检查 `result.success` 状态
|
||||||
|
3. **截断提示**:检查 `result.truncated` 以了解是否有内容被截断
|
||||||
|
4. **批量处理**:对于多种转换,使用 `runBatchTransformations` 提高效率
|
||||||
|
5. **模型选择**:根据需要选择合适的 LLM 模型
|
||||||
|
|
||||||
|
## 集成示例
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 在你的组件或服务中
|
||||||
|
import {
|
||||||
|
runTransformation,
|
||||||
|
TransformationType,
|
||||||
|
getAvailableTransformations
|
||||||
|
} from './core/prompts/transformations';
|
||||||
|
|
||||||
|
class DocumentProcessor {
|
||||||
|
constructor(private settings: InfioSettings) {}
|
||||||
|
|
||||||
|
async processDocument(content: string, type: TransformationType) {
|
||||||
|
try {
|
||||||
|
const result = await runTransformation({
|
||||||
|
content,
|
||||||
|
transformationType: type,
|
||||||
|
settings: this.settings
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
return result.result;
|
||||||
|
} else {
|
||||||
|
throw new Error(result.error);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('文档处理失败:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getAvailableTransformations() {
|
||||||
|
return getAvailableTransformations();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 注意事项
|
||||||
|
|
||||||
|
- 确保已正确配置 LLM 提供商的 API 密钥
|
||||||
|
- 转换质量依赖于所选择的 LLM 模型
|
||||||
|
- 处理大文档时可能需要较长时间
|
||||||
|
- 某些转换类型对特定类型的内容效果更好(如 `ANALYZE_PAPER` 适用于学术论文)
|
||||||
|
|
||||||
|
## 故障排除
|
||||||
|
|
||||||
|
1. **LLM 调用失败**:检查 API 密钥和网络连接
|
||||||
|
2. **转换结果为空**:可能是内容过短或模型无法理解内容
|
||||||
|
3. **内容被意外截断**:调整 `maxContentLength` 参数
|
||||||
|
4. **特定转换效果不佳**:尝试其他转换类型或检查内容是否适合该转换
|
||||||
1987
src/core/transformations/trans-engine.ts
Normal file
1987
src/core/transformations/trans-engine.ts
Normal file
File diff suppressed because it is too large
Load Diff
@ -6,6 +6,7 @@ import { createAndInitDb } from '../pgworker'
|
|||||||
|
|
||||||
import { CommandManager } from './modules/command/command-manager'
|
import { CommandManager } from './modules/command/command-manager'
|
||||||
import { ConversationManager } from './modules/conversation/conversation-manager'
|
import { ConversationManager } from './modules/conversation/conversation-manager'
|
||||||
|
import { InsightManager } from './modules/insight/insight-manager'
|
||||||
import { VectorManager } from './modules/vector/vector-manager'
|
import { VectorManager } from './modules/vector/vector-manager'
|
||||||
|
|
||||||
export class DBManager {
|
export class DBManager {
|
||||||
@ -14,18 +15,20 @@ export class DBManager {
|
|||||||
private vectorManager: VectorManager
|
private vectorManager: VectorManager
|
||||||
private CommandManager: CommandManager
|
private CommandManager: CommandManager
|
||||||
private conversationManager: ConversationManager
|
private conversationManager: ConversationManager
|
||||||
|
private insightManager: InsightManager
|
||||||
|
|
||||||
constructor(app: App) {
|
constructor(app: App) {
|
||||||
this.app = app
|
this.app = app
|
||||||
}
|
}
|
||||||
|
|
||||||
static async create(app: App): Promise<DBManager> {
|
static async create(app: App, filesystem: string): Promise<DBManager> {
|
||||||
const dbManager = new DBManager(app)
|
const dbManager = new DBManager(app)
|
||||||
dbManager.db = await createAndInitDb()
|
dbManager.db = await createAndInitDb(filesystem)
|
||||||
|
|
||||||
dbManager.vectorManager = new VectorManager(app, dbManager)
|
dbManager.vectorManager = new VectorManager(app, dbManager)
|
||||||
dbManager.CommandManager = new CommandManager(app, dbManager)
|
dbManager.CommandManager = new CommandManager(app, dbManager)
|
||||||
dbManager.conversationManager = new ConversationManager(app, dbManager)
|
dbManager.conversationManager = new ConversationManager(app, dbManager)
|
||||||
|
dbManager.insightManager = new InsightManager(app, dbManager)
|
||||||
|
|
||||||
return dbManager
|
return dbManager
|
||||||
}
|
}
|
||||||
@ -46,6 +49,10 @@ export class DBManager {
|
|||||||
return this.conversationManager
|
return this.conversationManager
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getInsightManager(): InsightManager {
|
||||||
|
return this.insightManager
|
||||||
|
}
|
||||||
|
|
||||||
async cleanup() {
|
async cleanup() {
|
||||||
this.db?.close()
|
this.db?.close()
|
||||||
this.db = null
|
this.db = null
|
||||||
|
|||||||
@ -1,49 +1,114 @@
|
|||||||
import { App } from 'obsidian'
|
import { App } from 'obsidian'
|
||||||
import { v4 as uuidv4 } from 'uuid'
|
import { v4 as uuidv4 } from 'uuid'
|
||||||
|
import sanitize from 'sanitize-basename'
|
||||||
|
import unsanitize from 'unsanitize-basename'
|
||||||
|
|
||||||
import { ChatConversationMeta } from '../../../types/chat'
|
import { ChatConversationMeta } from '../../../types/chat'
|
||||||
import { AbstractJsonRepository } from '../base'
|
import { AbstractJsonRepository } from '../base'
|
||||||
import { CHAT_DIR, ROOT_DIR } from '../constants'
|
import { CHAT_DIR, ROOT_DIR } from '../constants'
|
||||||
import { EmptyChatTitleException } from '../exception'
|
import { EmptyChatTitleException } from '../exception'
|
||||||
|
import { WorkspaceManager } from '../workspace/WorkspaceManager'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
CHAT_SCHEMA_VERSION,
|
CHAT_SCHEMA_VERSION,
|
||||||
ChatConversation
|
ChatConversation
|
||||||
} from './types'
|
} from './types'
|
||||||
|
|
||||||
|
|
||||||
export class ChatManager extends AbstractJsonRepository<
|
export class ChatManager extends AbstractJsonRepository<
|
||||||
ChatConversation,
|
ChatConversation,
|
||||||
ChatConversationMeta
|
ChatConversationMeta
|
||||||
> {
|
> {
|
||||||
constructor(app: App) {
|
private workspaceManager?: WorkspaceManager
|
||||||
|
|
||||||
|
constructor(app: App, workspaceManager?: WorkspaceManager) {
|
||||||
super(app, `${ROOT_DIR}/${CHAT_DIR}`)
|
super(app, `${ROOT_DIR}/${CHAT_DIR}`)
|
||||||
|
this.workspaceManager = workspaceManager
|
||||||
}
|
}
|
||||||
|
|
||||||
protected generateFileName(chat: ChatConversation): string {
|
protected generateFileName(chat: ChatConversation): string {
|
||||||
// Format: v{schemaVersion}_{title}_{updatedAt}_{id}.json
|
// 新格式 v2: v{schemaVersion}_{sanitizedTitle}_{updatedAt}_{id}_{workspaceId}.json
|
||||||
const encodedTitle = encodeURIComponent(chat.title)
|
const sanitizedTitle = sanitize(chat.title, { maxLength: 100 })
|
||||||
return `v${chat.schemaVersion}_${encodedTitle}_${chat.updatedAt}_${chat.id}.json`
|
// 如果没有工作区,使用 'vault' 作为默认值
|
||||||
|
const workspaceId = chat.workspace || 'vault'
|
||||||
|
return `v${chat.schemaVersion}_${sanitizedTitle}_${chat.updatedAt}_${chat.id}_${workspaceId}.json`
|
||||||
}
|
}
|
||||||
|
|
||||||
protected parseFileName(fileName: string): ChatConversationMeta | null {
|
protected parseFileName(fileName: string): ChatConversationMeta | null {
|
||||||
// Parse: v{schemaVersion}_{title}_{updatedAt}_{id}.json
|
// 通过头两个字符判断版本
|
||||||
|
if (fileName.startsWith('v2_')) {
|
||||||
|
return this.parseFileNameV2(fileName)
|
||||||
|
} else if (fileName.startsWith('v1_')) {
|
||||||
|
return this.parseFileNameV1(fileName)
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析新版本 (v2) 文件名
|
||||||
|
* 格式: v2_{sanitizedTitle}_{updatedAt}_{id}_{workspaceId}.json
|
||||||
|
*/
|
||||||
|
private parseFileNameV2(fileName: string): ChatConversationMeta | null {
|
||||||
const regex = new RegExp(
|
const regex = new RegExp(
|
||||||
`^v${CHAT_SCHEMA_VERSION}_(.+)_(\\d+)_([0-9a-f-]+)\\.json$`,
|
`^v2_(.+)_(\\d+)_([0-9a-f-]+)(?:_([^_]+))?\\.json$`,
|
||||||
)
|
)
|
||||||
const match = fileName.match(regex)
|
const match = fileName.match(regex)
|
||||||
|
|
||||||
if (!match) return null
|
if (!match) return null
|
||||||
|
|
||||||
const title = decodeURIComponent(match[1])
|
try {
|
||||||
const updatedAt = parseInt(match[2], 10)
|
// 使用 unsanitize-basename 还原原始标题
|
||||||
const id = match[3]
|
const title = unsanitize(match[1])
|
||||||
|
const updatedAt = parseInt(match[2], 10)
|
||||||
|
const id = match[3]
|
||||||
|
const workspaceId = match[4] // 可能为undefined(老格式)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
schemaVersion: CHAT_SCHEMA_VERSION,
|
schemaVersion: 2,
|
||||||
title,
|
title,
|
||||||
updatedAt,
|
updatedAt,
|
||||||
createdAt: 0,
|
createdAt: 0,
|
||||||
|
// 如果没有工作区信息(老格式),则认为是vault(全局消息)
|
||||||
|
workspace: workspaceId === 'vault' ? undefined : workspaceId,
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to unsanitize filename:', fileName, error)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析旧版本 (v1) 文件名
|
||||||
|
* 格式: v1_{encodedTitle}_{updatedAt}_{id}_{workspaceId}.json
|
||||||
|
*/
|
||||||
|
private parseFileNameV1(fileName: string): ChatConversationMeta | null {
|
||||||
|
const regex = new RegExp(
|
||||||
|
`^v1_(.+)_(\\d+)_([0-9a-f-]+)(?:_([^_]+))?\\.json$`,
|
||||||
|
)
|
||||||
|
const match = fileName.match(regex)
|
||||||
|
|
||||||
|
if (!match) return null
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 旧版本使用 decodeURIComponent
|
||||||
|
const title = decodeURIComponent(match[1])
|
||||||
|
const updatedAt = parseInt(match[2], 10)
|
||||||
|
const id = match[3]
|
||||||
|
const workspaceId = match[4] // 可能为undefined(老格式)
|
||||||
|
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
schemaVersion: 1,
|
||||||
|
title,
|
||||||
|
updatedAt,
|
||||||
|
createdAt: 0,
|
||||||
|
// 如果没有工作区信息(老格式),则认为是vault(全局消息)
|
||||||
|
workspace: workspaceId === 'vault' ? undefined : workspaceId,
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to decode v1 filename:', fileName, error)
|
||||||
|
return null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -66,16 +131,34 @@ export class ChatManager extends AbstractJsonRepository<
|
|||||||
}
|
}
|
||||||
|
|
||||||
await this.create(newChat)
|
await this.create(newChat)
|
||||||
|
|
||||||
|
// 如果有工作区信息,添加到工作区的聊天历史中
|
||||||
|
if (newChat.workspace && this.workspaceManager) {
|
||||||
|
try {
|
||||||
|
await this.workspaceManager.addChatToWorkspace(
|
||||||
|
newChat.workspace,
|
||||||
|
newChat.id,
|
||||||
|
newChat.title
|
||||||
|
)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to add chat to workspace:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return newChat
|
return newChat
|
||||||
}
|
}
|
||||||
|
|
||||||
public async findById(id: string): Promise<ChatConversation | null> {
|
public async findById(id: string): Promise<ChatConversation | null> {
|
||||||
const allMetadata = await this.listMetadata()
|
const allMetadata = await this.listMetadata()
|
||||||
const targetMetadata = allMetadata.find((meta) => meta.id === id)
|
const targetMetadatas = allMetadata.filter((meta) => meta.id === id)
|
||||||
|
|
||||||
if (!targetMetadata) return null
|
if (targetMetadatas.length === 0) return null
|
||||||
|
|
||||||
return this.read(targetMetadata.fileName)
|
// Sort by updatedAt descending to find the latest version
|
||||||
|
targetMetadatas.sort((a, b) => b.updatedAt - a.updatedAt)
|
||||||
|
const latestMetadata = targetMetadatas[0]
|
||||||
|
|
||||||
|
return this.read(latestMetadata.fileName)
|
||||||
}
|
}
|
||||||
|
|
||||||
public async updateChat(
|
public async updateChat(
|
||||||
@ -98,21 +181,124 @@ export class ChatManager extends AbstractJsonRepository<
|
|||||||
}
|
}
|
||||||
|
|
||||||
await this.update(chat, updatedChat)
|
await this.update(chat, updatedChat)
|
||||||
|
|
||||||
|
// 如果标题或工作区发生变化,更新工作区的聊天历史
|
||||||
|
if (this.workspaceManager && (updates.title !== undefined || updates.workspace !== undefined)) {
|
||||||
|
const workspaceId = updatedChat.workspace || chat.workspace
|
||||||
|
if (workspaceId) {
|
||||||
|
try {
|
||||||
|
await this.workspaceManager.addChatToWorkspace(
|
||||||
|
workspaceId,
|
||||||
|
updatedChat.id,
|
||||||
|
updatedChat.title
|
||||||
|
)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to update chat in workspace:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return updatedChat
|
return updatedChat
|
||||||
}
|
}
|
||||||
|
|
||||||
public async deleteChat(id: string): Promise<boolean> {
|
public async deleteChat(id: string): Promise<boolean> {
|
||||||
const allMetadata = await this.listMetadata()
|
const allMetadata = await this.listMetadata()
|
||||||
const targetMetadata = allMetadata.find((meta) => meta.id === id)
|
const targetsToDelete = allMetadata.filter((meta) => meta.id === id)
|
||||||
if (!targetMetadata) return false
|
|
||||||
|
if (targetsToDelete.length === 0) return false
|
||||||
|
|
||||||
|
// 获取聊天的工作区信息(从第一个匹配的元数据中获取)
|
||||||
|
const chatToDelete = await this.findById(id)
|
||||||
|
const workspaceId = chatToDelete?.workspace
|
||||||
|
|
||||||
|
// Delete all files associated with this ID
|
||||||
|
await Promise.all(targetsToDelete.map(meta => this.delete(meta.fileName)))
|
||||||
|
|
||||||
|
// 从工作区的聊天历史中移除
|
||||||
|
if (workspaceId && this.workspaceManager) {
|
||||||
|
try {
|
||||||
|
await this.workspaceManager.removeChatFromWorkspace(workspaceId, id)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to remove chat from workspace:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await this.delete(targetMetadata.fileName)
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
public async listChats(): Promise<ChatConversationMeta[]> {
|
public async cleanupOutdatedChats(): Promise<number> {
|
||||||
|
const allMetadata = await this.listMetadata()
|
||||||
|
const chatsById = new Map<string, (ChatConversationMeta & { fileName: string })[]>()
|
||||||
|
|
||||||
|
// Group chats by ID
|
||||||
|
for (const meta of allMetadata) {
|
||||||
|
if (!chatsById.has(meta.id)) {
|
||||||
|
chatsById.set(meta.id, [])
|
||||||
|
}
|
||||||
|
const chatGroup = chatsById.get(meta.id)
|
||||||
|
if (chatGroup) {
|
||||||
|
chatGroup.push(meta)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const filesToDelete: string[] = []
|
||||||
|
|
||||||
|
// Find outdated files for each ID
|
||||||
|
for (const chatGroup of chatsById.values()) {
|
||||||
|
if (chatGroup.length > 1) {
|
||||||
|
// Sort by date to find the newest
|
||||||
|
chatGroup.sort((a, b) => b.updatedAt - a.updatedAt)
|
||||||
|
// The first one is the latest, the rest are outdated
|
||||||
|
const outdatedFiles = chatGroup.slice(1)
|
||||||
|
for (const outdated of outdatedFiles) {
|
||||||
|
filesToDelete.push(outdated.fileName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filesToDelete.length > 0) {
|
||||||
|
await Promise.all(filesToDelete.map(fileName => this.delete(fileName)))
|
||||||
|
}
|
||||||
|
|
||||||
|
return filesToDelete.length
|
||||||
|
}
|
||||||
|
|
||||||
|
public async listChats(workspaceFilter?: string): Promise<ChatConversationMeta[]> {
|
||||||
|
console.log('listChats', workspaceFilter)
|
||||||
const metadata = await this.listMetadata()
|
const metadata = await this.listMetadata()
|
||||||
const sorted = metadata.sort((a, b) => b.updatedAt - a.updatedAt)
|
|
||||||
|
// Use a Map to store the latest version of each chat by ID.
|
||||||
|
const latestChats = new Map<string, ChatConversationMeta & { fileName: string }>()
|
||||||
|
|
||||||
|
for (const meta of metadata) {
|
||||||
|
const existing = latestChats.get(meta.id)
|
||||||
|
if (!existing || meta.updatedAt > existing.updatedAt) {
|
||||||
|
latestChats.set(meta.id, meta)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const uniqueMetadata = Array.from(latestChats.values())
|
||||||
|
|
||||||
|
// 将metadata转换为ChatConversationMeta格式
|
||||||
|
const chatMetadata: ChatConversationMeta[] = uniqueMetadata.map((meta) => ({
|
||||||
|
id: meta.id,
|
||||||
|
schemaVersion: meta.schemaVersion,
|
||||||
|
title: meta.title,
|
||||||
|
updatedAt: meta.updatedAt,
|
||||||
|
createdAt: meta.createdAt,
|
||||||
|
workspace: meta.workspace
|
||||||
|
}))
|
||||||
|
|
||||||
|
// 如果指定了工作区过滤器,则过滤对话
|
||||||
|
let filteredMetadata = chatMetadata
|
||||||
|
if (workspaceFilter !== undefined && workspaceFilter !== 'vault') {
|
||||||
|
// 获取指定工作区的对话
|
||||||
|
filteredMetadata = chatMetadata.filter(meta =>
|
||||||
|
meta.workspace === workspaceFilter
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const sorted = filteredMetadata.sort((a, b) => b.updatedAt - a.updatedAt)
|
||||||
return sorted
|
return sorted
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { SerializedChatMessage } from '../../../types/chat'
|
import { SerializedChatMessage } from '../../../types/chat'
|
||||||
|
|
||||||
export const CHAT_SCHEMA_VERSION = 1
|
export const CHAT_SCHEMA_VERSION = 2
|
||||||
|
|
||||||
export type ChatConversation = {
|
export type ChatConversation = {
|
||||||
id: string
|
id: string
|
||||||
@ -9,6 +9,7 @@ export type ChatConversation = {
|
|||||||
createdAt: number
|
createdAt: number
|
||||||
updatedAt: number
|
updatedAt: number
|
||||||
schemaVersion: number
|
schemaVersion: number
|
||||||
|
workspace?: string // 工作区ID,可选字段用于向后兼容
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ChatConversationMetadata = {
|
export type ChatConversationMetadata = {
|
||||||
@ -16,4 +17,5 @@ export type ChatConversationMetadata = {
|
|||||||
title: string
|
title: string
|
||||||
updatedAt: number
|
updatedAt: number
|
||||||
schemaVersion: number
|
schemaVersion: number
|
||||||
|
workspace?: string // 工作区ID,可选字段用于向后兼容
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,4 +3,5 @@ export const COMMAND_DIR = 'commands'
|
|||||||
export const CHAT_DIR = 'chats'
|
export const CHAT_DIR = 'chats'
|
||||||
export const CUSTOM_MODE_DIR = 'custom_modes'
|
export const CUSTOM_MODE_DIR = 'custom_modes'
|
||||||
export const CONVERT_DATA_DIR = 'convert_data'
|
export const CONVERT_DATA_DIR = 'convert_data'
|
||||||
|
export const WORKSPACE_DIR = 'workspaces'
|
||||||
export const INITIAL_MIGRATION_MARKER = '.initial_migration_completed'
|
export const INITIAL_MIGRATION_MARKER = '.initial_migration_completed'
|
||||||
|
|||||||
185
src/database/json/workspace/WorkspaceManager.ts
Normal file
185
src/database/json/workspace/WorkspaceManager.ts
Normal file
@ -0,0 +1,185 @@
|
|||||||
|
import { App } from 'obsidian'
|
||||||
|
import { v4 as uuidv4 } from 'uuid'
|
||||||
|
|
||||||
|
import { AbstractJsonRepository } from '../base'
|
||||||
|
import { ROOT_DIR, WORKSPACE_DIR } from '../constants'
|
||||||
|
|
||||||
|
import {
|
||||||
|
WORKSPACE_SCHEMA_VERSION,
|
||||||
|
Workspace,
|
||||||
|
WorkspaceMetadata
|
||||||
|
} from './types'
|
||||||
|
|
||||||
|
export class WorkspaceManager extends AbstractJsonRepository<
|
||||||
|
Workspace,
|
||||||
|
WorkspaceMetadata
|
||||||
|
> {
|
||||||
|
constructor(app: App) {
|
||||||
|
super(app, `${ROOT_DIR}/${WORKSPACE_DIR}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
protected generateFileName(workspace: Workspace): string {
|
||||||
|
// Format: v{schemaVersion}_{name}_{updatedAt}_{id}.json
|
||||||
|
const encodedName = encodeURIComponent(workspace.name)
|
||||||
|
return `v${workspace.schemaVersion}_${encodedName}_${workspace.updatedAt}_${workspace.id}.json`
|
||||||
|
}
|
||||||
|
|
||||||
|
protected parseFileName(fileName: string): WorkspaceMetadata | null {
|
||||||
|
// Parse: v{schemaVersion}_{name}_{updatedAt}_{id}.json
|
||||||
|
const regex = new RegExp(
|
||||||
|
`^v${WORKSPACE_SCHEMA_VERSION}_(.+)_(\\d+)_([0-9a-f-]+)\\.json$`,
|
||||||
|
)
|
||||||
|
const match = fileName.match(regex)
|
||||||
|
if (!match) return null
|
||||||
|
|
||||||
|
const name = decodeURIComponent(match[1])
|
||||||
|
const updatedAt = parseInt(match[2], 10)
|
||||||
|
const id = match[3]
|
||||||
|
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
updatedAt,
|
||||||
|
createdAt: 0,
|
||||||
|
schemaVersion: WORKSPACE_SCHEMA_VERSION,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async createWorkspace(
|
||||||
|
initialData: Partial<Workspace>,
|
||||||
|
): Promise<Workspace> {
|
||||||
|
const now = Date.now()
|
||||||
|
const newWorkspace: Workspace = {
|
||||||
|
id: uuidv4(),
|
||||||
|
name: 'New Workspace',
|
||||||
|
content: [],
|
||||||
|
chatHistory: [],
|
||||||
|
metadata: {},
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
schemaVersion: WORKSPACE_SCHEMA_VERSION,
|
||||||
|
...initialData,
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.create(newWorkspace)
|
||||||
|
return newWorkspace
|
||||||
|
}
|
||||||
|
|
||||||
|
public async findById(id: string): Promise<Workspace | null> {
|
||||||
|
const allMetadata = await this.listMetadata()
|
||||||
|
const targetMetadata = allMetadata.find((meta) => meta.id === id)
|
||||||
|
|
||||||
|
if (!targetMetadata) return null
|
||||||
|
|
||||||
|
return this.read(targetMetadata.fileName)
|
||||||
|
}
|
||||||
|
|
||||||
|
public async findByName(name: string): Promise<Workspace | null> {
|
||||||
|
const allMetadata = await this.listMetadata()
|
||||||
|
const targetMetadata = allMetadata.find((meta) => meta.name === name)
|
||||||
|
|
||||||
|
if (!targetMetadata) return null
|
||||||
|
|
||||||
|
return this.read(targetMetadata.fileName)
|
||||||
|
}
|
||||||
|
|
||||||
|
public async updateWorkspace(
|
||||||
|
id: string,
|
||||||
|
updates: Partial<
|
||||||
|
Omit<Workspace, 'id' | 'createdAt' | 'updatedAt' | 'schemaVersion'>
|
||||||
|
>,
|
||||||
|
): Promise<Workspace | null> {
|
||||||
|
const workspace = await this.findById(id)
|
||||||
|
if (!workspace) return null
|
||||||
|
|
||||||
|
const updatedWorkspace: Workspace = {
|
||||||
|
...workspace,
|
||||||
|
...updates,
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.update(workspace, updatedWorkspace)
|
||||||
|
return updatedWorkspace
|
||||||
|
}
|
||||||
|
|
||||||
|
public async deleteWorkspace(id: string): Promise<boolean> {
|
||||||
|
const allMetadata = await this.listMetadata()
|
||||||
|
const targetMetadata = allMetadata.find((meta) => meta.id === id)
|
||||||
|
if (!targetMetadata) return false
|
||||||
|
|
||||||
|
await this.delete(targetMetadata.fileName)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
public async listWorkspaces(): Promise<WorkspaceMetadata[]> {
|
||||||
|
const metadata = await this.listMetadata()
|
||||||
|
const sorted = metadata.sort((a, b) => b.updatedAt - a.updatedAt)
|
||||||
|
return sorted
|
||||||
|
}
|
||||||
|
|
||||||
|
public async addChatToWorkspace(
|
||||||
|
workspaceName: string,
|
||||||
|
chatId: string,
|
||||||
|
chatTitle: string
|
||||||
|
): Promise<Workspace | null> {
|
||||||
|
const workspace = await this.findByName(workspaceName)
|
||||||
|
if (!workspace) return null
|
||||||
|
|
||||||
|
const existingChatIndex = workspace.chatHistory.findIndex(
|
||||||
|
chat => chat.id === chatId
|
||||||
|
)
|
||||||
|
|
||||||
|
if (existingChatIndex >= 0) {
|
||||||
|
// 更新已存在的聊天标题
|
||||||
|
workspace.chatHistory[existingChatIndex].title = chatTitle
|
||||||
|
} else {
|
||||||
|
// 添加新聊天
|
||||||
|
workspace.chatHistory.push({ id: chatId, title: chatTitle })
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.updateWorkspace(workspace.id, {
|
||||||
|
chatHistory: workspace.chatHistory
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
public async removeChatFromWorkspace(
|
||||||
|
workspaceId: string,
|
||||||
|
chatId: string
|
||||||
|
): Promise<Workspace | null> {
|
||||||
|
const workspace = await this.findById(workspaceId)
|
||||||
|
if (!workspace) return null
|
||||||
|
|
||||||
|
workspace.chatHistory = workspace.chatHistory.filter(
|
||||||
|
chat => chat.id !== chatId
|
||||||
|
)
|
||||||
|
|
||||||
|
return this.updateWorkspace(workspaceId, {
|
||||||
|
chatHistory: workspace.chatHistory
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ensureDefaultVaultWorkspace(): Promise<Workspace> {
|
||||||
|
// 检查是否已存在默认的 vault 工作区
|
||||||
|
const existingVault = await this.findByName('vault')
|
||||||
|
if (existingVault) {
|
||||||
|
return existingVault
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建默认的 vault 工作区
|
||||||
|
const defaultWorkspace = await this.createWorkspace({
|
||||||
|
name: 'vault',
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'folder',
|
||||||
|
content: '/' // 整个 vault 根目录
|
||||||
|
}
|
||||||
|
],
|
||||||
|
metadata: {
|
||||||
|
isDefault: true,
|
||||||
|
description: 'all vault as workspace'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return defaultWorkspace
|
||||||
|
}
|
||||||
|
}
|
||||||
2
src/database/json/workspace/index.ts
Normal file
2
src/database/json/workspace/index.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export * from './types'
|
||||||
|
export * from './WorkspaceManager'
|
||||||
30
src/database/json/workspace/types.ts
Normal file
30
src/database/json/workspace/types.ts
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
export const WORKSPACE_SCHEMA_VERSION = 1
|
||||||
|
|
||||||
|
export interface WorkspaceContent {
|
||||||
|
type: 'tag' | 'folder'
|
||||||
|
content: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorkspaceChatHistory {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Workspace {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
content: WorkspaceContent[]
|
||||||
|
chatHistory: WorkspaceChatHistory[]
|
||||||
|
metadata: Record<string, any>
|
||||||
|
createdAt: number
|
||||||
|
updatedAt: number
|
||||||
|
schemaVersion: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorkspaceMetadata {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
updatedAt: number
|
||||||
|
createdAt: number
|
||||||
|
schemaVersion: number
|
||||||
|
}
|
||||||
2
src/database/modules/insight/index.ts
Normal file
2
src/database/modules/insight/index.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export { InsightRepository } from './insight-repository'
|
||||||
|
export { InsightManager } from './insight-manager'
|
||||||
361
src/database/modules/insight/insight-manager.ts
Normal file
361
src/database/modules/insight/insight-manager.ts
Normal file
@ -0,0 +1,361 @@
|
|||||||
|
import { App, TFile } from 'obsidian'
|
||||||
|
|
||||||
|
import { EmbeddingModel } from '../../../types/embedding'
|
||||||
|
import { DBManager } from '../../database-manager'
|
||||||
|
import { InsertSourceInsight, SelectSourceInsight } from '../../schema'
|
||||||
|
|
||||||
|
import { InsightRepository } from './insight-repository'
|
||||||
|
|
||||||
|
export class InsightManager {
|
||||||
|
private app: App
|
||||||
|
private repository: InsightRepository
|
||||||
|
private dbManager: DBManager
|
||||||
|
|
||||||
|
constructor(app: App, dbManager: DBManager) {
|
||||||
|
this.app = app
|
||||||
|
this.dbManager = dbManager
|
||||||
|
this.repository = new InsightRepository(app, dbManager.getPgClient())
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 执行洞察相似性搜索
|
||||||
|
*/
|
||||||
|
async performSimilaritySearch(
|
||||||
|
queryVector: number[],
|
||||||
|
embeddingModel: EmbeddingModel,
|
||||||
|
options: {
|
||||||
|
minSimilarity: number
|
||||||
|
limit: number
|
||||||
|
insightTypes?: string[]
|
||||||
|
sourceTypes?: ('document' | 'tag' | 'folder')[]
|
||||||
|
sourcePaths?: string[]
|
||||||
|
},
|
||||||
|
): Promise<
|
||||||
|
(Omit<SelectSourceInsight, 'embedding'> & {
|
||||||
|
similarity: number
|
||||||
|
})[]
|
||||||
|
> {
|
||||||
|
return await this.repository.performSimilaritySearch(
|
||||||
|
queryVector,
|
||||||
|
embeddingModel,
|
||||||
|
options,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 存储单个洞察
|
||||||
|
*/
|
||||||
|
async storeInsight(
|
||||||
|
insightData: {
|
||||||
|
insightType: string
|
||||||
|
insight: string
|
||||||
|
sourceType: 'document' | 'tag' | 'folder'
|
||||||
|
sourcePath: string
|
||||||
|
sourceMtime: number
|
||||||
|
embedding: number[]
|
||||||
|
},
|
||||||
|
embeddingModel: EmbeddingModel,
|
||||||
|
): Promise<void> {
|
||||||
|
const insertData: InsertSourceInsight = {
|
||||||
|
insight_type: insightData.insightType,
|
||||||
|
insight: insightData.insight,
|
||||||
|
source_type: insightData.sourceType,
|
||||||
|
source_path: insightData.sourcePath,
|
||||||
|
source_mtime: insightData.sourceMtime,
|
||||||
|
embedding: insightData.embedding,
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.repository.insertInsights([insertData], embeddingModel)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量存储洞察
|
||||||
|
*/
|
||||||
|
async storeBatchInsights(
|
||||||
|
insightsData: Array<{
|
||||||
|
insightType: string
|
||||||
|
insight: string
|
||||||
|
sourceType: 'document' | 'tag' | 'folder'
|
||||||
|
sourcePath: string
|
||||||
|
sourceMtime: number
|
||||||
|
embedding: number[]
|
||||||
|
}>,
|
||||||
|
embeddingModel: EmbeddingModel,
|
||||||
|
): Promise<void> {
|
||||||
|
const insertData: InsertSourceInsight[] = insightsData.map(data => ({
|
||||||
|
insight_type: data.insightType,
|
||||||
|
insight: data.insight,
|
||||||
|
source_type: data.sourceType,
|
||||||
|
source_path: data.sourcePath,
|
||||||
|
source_mtime: data.sourceMtime,
|
||||||
|
embedding: data.embedding,
|
||||||
|
}))
|
||||||
|
|
||||||
|
await this.repository.insertInsights(insertData, embeddingModel)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新现有洞察
|
||||||
|
*/
|
||||||
|
async updateInsight(
|
||||||
|
id: number,
|
||||||
|
updates: {
|
||||||
|
insightType?: string
|
||||||
|
insight?: string
|
||||||
|
sourceType?: 'document' | 'tag' | 'folder'
|
||||||
|
sourcePath?: string
|
||||||
|
sourceMtime?: number
|
||||||
|
embedding?: number[]
|
||||||
|
},
|
||||||
|
embeddingModel: EmbeddingModel,
|
||||||
|
): Promise<void> {
|
||||||
|
const updateData: Partial<InsertSourceInsight> = {}
|
||||||
|
|
||||||
|
if (updates.insightType !== undefined) {
|
||||||
|
updateData.insight_type = updates.insightType
|
||||||
|
}
|
||||||
|
if (updates.insight !== undefined) {
|
||||||
|
updateData.insight = updates.insight
|
||||||
|
}
|
||||||
|
if (updates.sourceType !== undefined) {
|
||||||
|
updateData.source_type = updates.sourceType
|
||||||
|
}
|
||||||
|
if (updates.sourcePath !== undefined) {
|
||||||
|
updateData.source_path = updates.sourcePath
|
||||||
|
}
|
||||||
|
if (updates.sourceMtime !== undefined) {
|
||||||
|
updateData.source_mtime = updates.sourceMtime
|
||||||
|
}
|
||||||
|
if (updates.embedding !== undefined) {
|
||||||
|
updateData.embedding = updates.embedding
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.repository.updateInsight(id, updateData, embeddingModel)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取所有洞察
|
||||||
|
*/
|
||||||
|
async getAllInsights(embeddingModel: EmbeddingModel): Promise<SelectSourceInsight[]> {
|
||||||
|
return await this.repository.getAllInsights(embeddingModel)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据源路径获取洞察
|
||||||
|
*/
|
||||||
|
async getInsightsBySourcePath(
|
||||||
|
sourcePath: string,
|
||||||
|
embeddingModel: EmbeddingModel,
|
||||||
|
): Promise<SelectSourceInsight[]> {
|
||||||
|
return await this.repository.getInsightsBySourcePath(sourcePath, embeddingModel)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据洞察类型获取洞察
|
||||||
|
*/
|
||||||
|
async getInsightsByType(
|
||||||
|
insightType: string,
|
||||||
|
embeddingModel: EmbeddingModel,
|
||||||
|
): Promise<SelectSourceInsight[]> {
|
||||||
|
return await this.repository.getInsightsByType(insightType, embeddingModel)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据源类型获取洞察
|
||||||
|
*/
|
||||||
|
async getInsightsBySourceType(
|
||||||
|
sourceType: 'document' | 'tag' | 'folder',
|
||||||
|
embeddingModel: EmbeddingModel,
|
||||||
|
): Promise<SelectSourceInsight[]> {
|
||||||
|
return await this.repository.getInsightsBySourceType(sourceType, embeddingModel)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除指定源路径的所有洞察
|
||||||
|
*/
|
||||||
|
async deleteInsightsBySourcePath(
|
||||||
|
sourcePath: string,
|
||||||
|
embeddingModel: EmbeddingModel,
|
||||||
|
): Promise<void> {
|
||||||
|
await this.repository.deleteInsightsBySourcePath(sourcePath, embeddingModel)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量删除多个源路径的洞察
|
||||||
|
*/
|
||||||
|
async deleteInsightsBySourcePaths(
|
||||||
|
sourcePaths: string[],
|
||||||
|
embeddingModel: EmbeddingModel,
|
||||||
|
): Promise<void> {
|
||||||
|
await this.repository.deleteInsightsBySourcePaths(sourcePaths, embeddingModel)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除指定类型的所有洞察
|
||||||
|
*/
|
||||||
|
async deleteInsightsByType(
|
||||||
|
insightType: string,
|
||||||
|
embeddingModel: EmbeddingModel,
|
||||||
|
): Promise<void> {
|
||||||
|
await this.repository.deleteInsightsByType(insightType, embeddingModel)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清空所有洞察
|
||||||
|
*/
|
||||||
|
async clearAllInsights(embeddingModel: EmbeddingModel): Promise<void> {
|
||||||
|
await this.repository.clearAllInsights(embeddingModel)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除指定ID的洞察
|
||||||
|
*/
|
||||||
|
async deleteInsightById(
|
||||||
|
id: number,
|
||||||
|
embeddingModel: EmbeddingModel,
|
||||||
|
): Promise<void> {
|
||||||
|
await this.repository.deleteInsightById(id, embeddingModel)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文件删除时清理相关洞察
|
||||||
|
*/
|
||||||
|
async cleanInsightsForDeletedFile(
|
||||||
|
file: TFile,
|
||||||
|
embeddingModel: EmbeddingModel,
|
||||||
|
): Promise<void> {
|
||||||
|
await this.repository.deleteInsightsBySourcePath(file.path, embeddingModel)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文件重命名时更新洞察路径
|
||||||
|
*/
|
||||||
|
async updateInsightsForRenamedFile(
|
||||||
|
oldPath: string,
|
||||||
|
newPath: string,
|
||||||
|
embeddingModel: EmbeddingModel,
|
||||||
|
): Promise<void> {
|
||||||
|
// 获取旧路径的所有洞察
|
||||||
|
const insights = await this.repository.getInsightsBySourcePath(oldPath, embeddingModel)
|
||||||
|
|
||||||
|
// 批量更新路径
|
||||||
|
for (const insight of insights) {
|
||||||
|
await this.repository.updateInsight(
|
||||||
|
insight.id,
|
||||||
|
{ source_path: newPath },
|
||||||
|
embeddingModel
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清理已删除文件的洞察(批量清理)
|
||||||
|
*/
|
||||||
|
async cleanInsightsForDeletedFiles(embeddingModel: EmbeddingModel): Promise<void> {
|
||||||
|
const allInsights = await this.repository.getAllInsights(embeddingModel)
|
||||||
|
const pathsToDelete: string[] = []
|
||||||
|
|
||||||
|
for (const insight of allInsights) {
|
||||||
|
if (insight.source_type === 'document') {
|
||||||
|
// 检查文件是否还存在
|
||||||
|
const file = this.app.vault.getAbstractFileByPath(insight.source_path)
|
||||||
|
if (!file) {
|
||||||
|
pathsToDelete.push(insight.source_path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pathsToDelete.length > 0) {
|
||||||
|
await this.repository.deleteInsightsBySourcePaths(pathsToDelete, embeddingModel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取洞察统计信息
|
||||||
|
*/
|
||||||
|
async getInsightStats(embeddingModel: EmbeddingModel): Promise<{
|
||||||
|
total: number
|
||||||
|
byType: Record<string, number>
|
||||||
|
bySourceType: Record<string, number>
|
||||||
|
}> {
|
||||||
|
const allInsights = await this.repository.getAllInsights(embeddingModel)
|
||||||
|
|
||||||
|
const stats = {
|
||||||
|
total: allInsights.length,
|
||||||
|
byType: {} as Record<string, number>,
|
||||||
|
bySourceType: {} as Record<string, number>,
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const insight of allInsights) {
|
||||||
|
// 统计洞察类型
|
||||||
|
stats.byType[insight.insight_type] = (stats.byType[insight.insight_type] || 0) + 1
|
||||||
|
|
||||||
|
// 统计源类型
|
||||||
|
stats.bySourceType[insight.source_type] = (stats.bySourceType[insight.source_type] || 0) + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
return stats
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 搜索洞察(文本搜索,非向量搜索)
|
||||||
|
*/
|
||||||
|
async searchInsightsByText(
|
||||||
|
searchText: string,
|
||||||
|
embeddingModel: EmbeddingModel,
|
||||||
|
options?: {
|
||||||
|
insightTypes?: string[]
|
||||||
|
sourceTypes?: ('document' | 'tag' | 'folder')[]
|
||||||
|
limit?: number
|
||||||
|
}
|
||||||
|
): Promise<SelectSourceInsight[]> {
|
||||||
|
// 这里可以实现基于文本的搜索逻辑
|
||||||
|
// 目前先返回所有洞察,然后在内存中过滤
|
||||||
|
const allInsights = await this.repository.getAllInsights(embeddingModel)
|
||||||
|
|
||||||
|
let filteredInsights = allInsights.filter(insight =>
|
||||||
|
insight.insight.toLowerCase().includes(searchText.toLowerCase()) ||
|
||||||
|
insight.insight_type.toLowerCase().includes(searchText.toLowerCase())
|
||||||
|
)
|
||||||
|
|
||||||
|
if (options?.insightTypes) {
|
||||||
|
filteredInsights = filteredInsights.filter(insight =>
|
||||||
|
options.insightTypes!.includes(insight.insight_type)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options?.sourceTypes) {
|
||||||
|
filteredInsights = filteredInsights.filter(insight =>
|
||||||
|
options.sourceTypes!.includes(insight.source_type)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options?.limit) {
|
||||||
|
filteredInsights = filteredInsights.slice(0, options.limit)
|
||||||
|
}
|
||||||
|
|
||||||
|
return filteredInsights
|
||||||
|
}
|
||||||
|
|
||||||
|
// /**
|
||||||
|
// * 根据源文件修改时间范围获取洞察
|
||||||
|
// */
|
||||||
|
// async getInsightsByMtimeRange(
|
||||||
|
// minMtime: number,
|
||||||
|
// maxMtime: number,
|
||||||
|
// embeddingModel: EmbeddingModel,
|
||||||
|
// ): Promise<SelectSourceInsight[]> {
|
||||||
|
// return await this.repository.getInsightsByMtimeRange(minMtime, maxMtime, embeddingModel)
|
||||||
|
// }
|
||||||
|
|
||||||
|
// /**
|
||||||
|
// * 根据源文件修改时间获取需要更新的洞察
|
||||||
|
// */
|
||||||
|
// async getOutdatedInsights(
|
||||||
|
// sourcePath: string,
|
||||||
|
// currentMtime: number,
|
||||||
|
// embeddingModel: EmbeddingModel,
|
||||||
|
// ): Promise<SelectSourceInsight[]> {
|
||||||
|
// return await this.repository.getOutdatedInsights(sourcePath, currentMtime, embeddingModel)
|
||||||
|
// }
|
||||||
|
}
|
||||||
327
src/database/modules/insight/insight-repository.ts
Normal file
327
src/database/modules/insight/insight-repository.ts
Normal file
@ -0,0 +1,327 @@
|
|||||||
|
import { PGliteInterface } from '@electric-sql/pglite'
|
||||||
|
import { App } from 'obsidian'
|
||||||
|
|
||||||
|
import { EmbeddingModel } from '../../../types/embedding'
|
||||||
|
import { DatabaseNotInitializedException } from '../../exception'
|
||||||
|
import { InsertSourceInsight, SelectSourceInsight, sourceInsightTables } from '../../schema'
|
||||||
|
|
||||||
|
export class InsightRepository {
|
||||||
|
private app: App
|
||||||
|
private db: PGliteInterface | null
|
||||||
|
|
||||||
|
constructor(app: App, pgClient: PGliteInterface | null) {
|
||||||
|
this.app = app
|
||||||
|
this.db = pgClient
|
||||||
|
}
|
||||||
|
|
||||||
|
private getTableName(embeddingModel: EmbeddingModel): string {
|
||||||
|
const tableDefinition = sourceInsightTables[embeddingModel.dimension]
|
||||||
|
if (!tableDefinition) {
|
||||||
|
throw new Error(`No source insight table definition found for model: ${embeddingModel.id}`)
|
||||||
|
}
|
||||||
|
return tableDefinition.name
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAllInsights(embeddingModel: EmbeddingModel): Promise<SelectSourceInsight[]> {
|
||||||
|
if (!this.db) {
|
||||||
|
throw new DatabaseNotInitializedException()
|
||||||
|
}
|
||||||
|
const tableName = this.getTableName(embeddingModel)
|
||||||
|
const result = await this.db.query<SelectSourceInsight>(
|
||||||
|
`SELECT * FROM "${tableName}" ORDER BY created_at DESC`
|
||||||
|
)
|
||||||
|
return result.rows
|
||||||
|
}
|
||||||
|
|
||||||
|
async getInsightsBySourcePath(
|
||||||
|
sourcePath: string,
|
||||||
|
embeddingModel: EmbeddingModel,
|
||||||
|
): Promise<SelectSourceInsight[]> {
|
||||||
|
if (!this.db) {
|
||||||
|
throw new DatabaseNotInitializedException()
|
||||||
|
}
|
||||||
|
const tableName = this.getTableName(embeddingModel)
|
||||||
|
const result = await this.db.query<SelectSourceInsight>(
|
||||||
|
`SELECT * FROM "${tableName}" WHERE source_path = $1 ORDER BY created_at DESC`,
|
||||||
|
[sourcePath]
|
||||||
|
)
|
||||||
|
return result.rows
|
||||||
|
}
|
||||||
|
|
||||||
|
async getInsightsByType(
|
||||||
|
insightType: string,
|
||||||
|
embeddingModel: EmbeddingModel,
|
||||||
|
): Promise<SelectSourceInsight[]> {
|
||||||
|
if (!this.db) {
|
||||||
|
throw new DatabaseNotInitializedException()
|
||||||
|
}
|
||||||
|
const tableName = this.getTableName(embeddingModel)
|
||||||
|
const result = await this.db.query<SelectSourceInsight>(
|
||||||
|
`SELECT * FROM "${tableName}" WHERE insight_type = $1 ORDER BY created_at DESC`,
|
||||||
|
[insightType]
|
||||||
|
)
|
||||||
|
return result.rows
|
||||||
|
}
|
||||||
|
|
||||||
|
async getInsightsBySourceType(
|
||||||
|
sourceType: 'document' | 'tag' | 'folder',
|
||||||
|
embeddingModel: EmbeddingModel,
|
||||||
|
): Promise<SelectSourceInsight[]> {
|
||||||
|
if (!this.db) {
|
||||||
|
throw new DatabaseNotInitializedException()
|
||||||
|
}
|
||||||
|
const tableName = this.getTableName(embeddingModel)
|
||||||
|
const result = await this.db.query<SelectSourceInsight>(
|
||||||
|
`SELECT * FROM "${tableName}" WHERE source_type = $1 ORDER BY created_at DESC`,
|
||||||
|
[sourceType]
|
||||||
|
)
|
||||||
|
return result.rows
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteInsightsBySourcePath(
|
||||||
|
sourcePath: string,
|
||||||
|
embeddingModel: EmbeddingModel,
|
||||||
|
): Promise<void> {
|
||||||
|
if (!this.db) {
|
||||||
|
throw new DatabaseNotInitializedException()
|
||||||
|
}
|
||||||
|
const tableName = this.getTableName(embeddingModel)
|
||||||
|
await this.db.query(
|
||||||
|
`DELETE FROM "${tableName}" WHERE source_path = $1`,
|
||||||
|
[sourcePath]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteInsightsBySourcePaths(
|
||||||
|
sourcePaths: string[],
|
||||||
|
embeddingModel: EmbeddingModel,
|
||||||
|
): Promise<void> {
|
||||||
|
if (!this.db) {
|
||||||
|
throw new DatabaseNotInitializedException()
|
||||||
|
}
|
||||||
|
const tableName = this.getTableName(embeddingModel)
|
||||||
|
await this.db.query(
|
||||||
|
`DELETE FROM "${tableName}" WHERE source_path = ANY($1)`,
|
||||||
|
[sourcePaths]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteInsightsByType(
|
||||||
|
insightType: string,
|
||||||
|
embeddingModel: EmbeddingModel,
|
||||||
|
): Promise<void> {
|
||||||
|
if (!this.db) {
|
||||||
|
throw new DatabaseNotInitializedException()
|
||||||
|
}
|
||||||
|
const tableName = this.getTableName(embeddingModel)
|
||||||
|
await this.db.query(
|
||||||
|
`DELETE FROM "${tableName}" WHERE insight_type = $1`,
|
||||||
|
[insightType]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async clearAllInsights(embeddingModel: EmbeddingModel): Promise<void> {
|
||||||
|
if (!this.db) {
|
||||||
|
throw new DatabaseNotInitializedException()
|
||||||
|
}
|
||||||
|
const tableName = this.getTableName(embeddingModel)
|
||||||
|
await this.db.query(`DELETE FROM "${tableName}"`)
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteInsightById(
|
||||||
|
id: number,
|
||||||
|
embeddingModel: EmbeddingModel,
|
||||||
|
): Promise<void> {
|
||||||
|
if (!this.db) {
|
||||||
|
throw new DatabaseNotInitializedException()
|
||||||
|
}
|
||||||
|
const tableName = this.getTableName(embeddingModel)
|
||||||
|
await this.db.query(
|
||||||
|
`DELETE FROM "${tableName}" WHERE id = $1`,
|
||||||
|
[id]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async insertInsights(
|
||||||
|
data: InsertSourceInsight[],
|
||||||
|
embeddingModel: EmbeddingModel,
|
||||||
|
): Promise<void> {
|
||||||
|
if (!this.db) {
|
||||||
|
throw new DatabaseNotInitializedException()
|
||||||
|
}
|
||||||
|
const tableName = this.getTableName(embeddingModel)
|
||||||
|
|
||||||
|
// 构建批量插入的 SQL
|
||||||
|
const values = data.map((insight, index) => {
|
||||||
|
const offset = index * 7
|
||||||
|
return `($${offset + 1}, $${offset + 2}, $${offset + 3}, $${offset + 4}, $${offset + 5}, $${offset + 6}, $${offset + 7})`
|
||||||
|
}).join(',')
|
||||||
|
|
||||||
|
const params = data.flatMap(insight => [
|
||||||
|
insight.insight_type,
|
||||||
|
insight.insight.replace(/\0/g, ''), // 清理null字节
|
||||||
|
insight.source_type,
|
||||||
|
insight.source_path,
|
||||||
|
insight.source_mtime,
|
||||||
|
`[${insight.embedding.join(',')}]`, // 转换为PostgreSQL vector格式
|
||||||
|
new Date() // updated_at
|
||||||
|
])
|
||||||
|
|
||||||
|
await this.db.query(
|
||||||
|
`INSERT INTO "${tableName}" (insight_type, insight, source_type, source_path, source_mtime, embedding, updated_at)
|
||||||
|
VALUES ${values}`,
|
||||||
|
params
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateInsight(
|
||||||
|
id: number,
|
||||||
|
data: Partial<InsertSourceInsight>,
|
||||||
|
embeddingModel: EmbeddingModel,
|
||||||
|
): Promise<void> {
|
||||||
|
if (!this.db) {
|
||||||
|
throw new DatabaseNotInitializedException()
|
||||||
|
}
|
||||||
|
const tableName = this.getTableName(embeddingModel)
|
||||||
|
|
||||||
|
const fields: string[] = []
|
||||||
|
const params: unknown[] = []
|
||||||
|
let paramIndex = 1
|
||||||
|
|
||||||
|
if (data.insight_type !== undefined) {
|
||||||
|
fields.push(`insight_type = $${paramIndex}`)
|
||||||
|
params.push(data.insight_type)
|
||||||
|
paramIndex++
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.insight !== undefined) {
|
||||||
|
fields.push(`insight = $${paramIndex}`)
|
||||||
|
params.push(data.insight.replace(/\0/g, ''))
|
||||||
|
paramIndex++
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.source_type !== undefined) {
|
||||||
|
fields.push(`source_type = $${paramIndex}`)
|
||||||
|
params.push(data.source_type)
|
||||||
|
paramIndex++
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.source_path !== undefined) {
|
||||||
|
fields.push(`source_path = $${paramIndex}`)
|
||||||
|
params.push(data.source_path)
|
||||||
|
paramIndex++
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.source_mtime !== undefined) {
|
||||||
|
fields.push(`source_mtime = $${paramIndex}`)
|
||||||
|
params.push(data.source_mtime)
|
||||||
|
paramIndex++
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.embedding !== undefined) {
|
||||||
|
fields.push(`embedding = $${paramIndex}`)
|
||||||
|
params.push(`[${data.embedding.join(',')}]`)
|
||||||
|
paramIndex++
|
||||||
|
}
|
||||||
|
|
||||||
|
fields.push(`updated_at = $${paramIndex}`)
|
||||||
|
params.push(new Date())
|
||||||
|
paramIndex++
|
||||||
|
|
||||||
|
params.push(id)
|
||||||
|
|
||||||
|
await this.db.query(
|
||||||
|
`UPDATE "${tableName}" SET ${fields.join(', ')} WHERE id = $${paramIndex}`,
|
||||||
|
params
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async performSimilaritySearch(
|
||||||
|
queryVector: number[],
|
||||||
|
embeddingModel: EmbeddingModel,
|
||||||
|
options: {
|
||||||
|
minSimilarity: number
|
||||||
|
limit: number
|
||||||
|
insightTypes?: string[]
|
||||||
|
sourceTypes?: ('document' | 'tag' | 'folder')[]
|
||||||
|
sourcePaths?: string[]
|
||||||
|
},
|
||||||
|
): Promise<
|
||||||
|
(Omit<SelectSourceInsight, 'embedding'> & {
|
||||||
|
similarity: number
|
||||||
|
})[]
|
||||||
|
> {
|
||||||
|
if (!this.db) {
|
||||||
|
throw new DatabaseNotInitializedException()
|
||||||
|
}
|
||||||
|
const tableName = this.getTableName(embeddingModel)
|
||||||
|
|
||||||
|
const whereConditions: string[] = ['1 - (embedding <=> $1::vector) > $2']
|
||||||
|
const params: unknown[] = [`[${queryVector.join(',')}]`, options.minSimilarity, options.limit]
|
||||||
|
let paramIndex = 4
|
||||||
|
|
||||||
|
if (options.insightTypes && options.insightTypes.length > 0) {
|
||||||
|
whereConditions.push(`insight_type = ANY($${paramIndex})`)
|
||||||
|
params.push(options.insightTypes)
|
||||||
|
paramIndex++
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.sourceTypes && options.sourceTypes.length > 0) {
|
||||||
|
whereConditions.push(`source_type = ANY($${paramIndex})`)
|
||||||
|
params.push(options.sourceTypes)
|
||||||
|
paramIndex++
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.sourcePaths && options.sourcePaths.length > 0) {
|
||||||
|
whereConditions.push(`source_path = ANY($${paramIndex})`)
|
||||||
|
params.push(options.sourcePaths)
|
||||||
|
paramIndex++
|
||||||
|
}
|
||||||
|
|
||||||
|
const query = `
|
||||||
|
SELECT
|
||||||
|
id, insight_type, insight, source_type, source_path, source_mtime, created_at, updated_at,
|
||||||
|
1 - (embedding <=> $1::vector) as similarity
|
||||||
|
FROM "${tableName}"
|
||||||
|
WHERE ${whereConditions.join(' AND ')}
|
||||||
|
ORDER BY similarity DESC
|
||||||
|
LIMIT $3
|
||||||
|
`
|
||||||
|
|
||||||
|
type SearchResult = Omit<SelectSourceInsight, 'embedding'> & { similarity: number }
|
||||||
|
const result = await this.db.query<SearchResult>(query, params)
|
||||||
|
return result.rows
|
||||||
|
}
|
||||||
|
|
||||||
|
// async getInsightsByMtimeRange(
|
||||||
|
// minMtime: number,
|
||||||
|
// maxMtime: number,
|
||||||
|
// embeddingModel: EmbeddingModel,
|
||||||
|
// ): Promise<SelectSourceInsight[]> {
|
||||||
|
// if (!this.db) {
|
||||||
|
// throw new DatabaseNotInitializedException()
|
||||||
|
// }
|
||||||
|
// const tableName = this.getTableName(embeddingModel)
|
||||||
|
// const result = await this.db.query<SelectSourceInsight>(
|
||||||
|
// `SELECT * FROM "${tableName}" WHERE source_mtime >= $1 AND source_mtime <= $2 ORDER BY created_at DESC`,
|
||||||
|
// [minMtime, maxMtime]
|
||||||
|
// )
|
||||||
|
// return result.rows
|
||||||
|
// }
|
||||||
|
|
||||||
|
// async getOutdatedInsights(
|
||||||
|
// sourcePath: string,
|
||||||
|
// currentMtime: number,
|
||||||
|
// embeddingModel: EmbeddingModel,
|
||||||
|
// ): Promise<SelectSourceInsight[]> {
|
||||||
|
// if (!this.db) {
|
||||||
|
// throw new DatabaseNotInitializedException()
|
||||||
|
// }
|
||||||
|
// const tableName = this.getTableName(embeddingModel)
|
||||||
|
// const result = await this.db.query<SelectSourceInsight>(
|
||||||
|
// `SELECT * FROM "${tableName}" WHERE source_path = $1 AND source_mtime < $2 ORDER BY created_at DESC`,
|
||||||
|
// [sourcePath, currentMtime]
|
||||||
|
// )
|
||||||
|
// return result.rows
|
||||||
|
// }
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@ -6,163 +6,208 @@ import { DatabaseNotInitializedException } from '../../exception'
|
|||||||
import { InsertVector, SelectVector, vectorTables } from '../../schema'
|
import { InsertVector, SelectVector, vectorTables } from '../../schema'
|
||||||
|
|
||||||
export class VectorRepository {
|
export class VectorRepository {
|
||||||
private app: App
|
private app: App
|
||||||
private db: PGliteInterface | null
|
private db: PGliteInterface | null
|
||||||
|
private stopWords: Set<string>
|
||||||
|
|
||||||
constructor(app: App, pgClient: PGliteInterface | null) {
|
constructor(app: App, pgClient: PGliteInterface | null) {
|
||||||
this.app = app
|
this.app = app
|
||||||
this.db = pgClient
|
this.db = pgClient
|
||||||
}
|
this.stopWords = new Set([
|
||||||
|
// Chinese stop words
|
||||||
|
'的', '在', '是', '了', '我', '你', '他', '她', '它', '请问', '如何', '一个', '什么', '怎么',
|
||||||
|
'这', '那', '和', '与', '或', '但', '因为', '所以', '如果', '虽然', '可是', '不过',
|
||||||
|
'也', '都', '还', '就', '又', '很', '最', '更', '非常', '特别', '比较', '相当',
|
||||||
|
'对', '于', '把', '被', '让', '使', '给', '为', '从', '到', '向', '往', '朝',
|
||||||
|
'上', '下', '里', '外', '前', '后', '左', '右', '中', '间', '内', '以', '及',
|
||||||
|
|
||||||
private getTableName(embeddingModel: EmbeddingModel): string {
|
// English stop words
|
||||||
const tableDefinition = vectorTables[embeddingModel.dimension]
|
'a', 'an', 'and', 'are', 'as', 'at', 'be', 'by', 'for', 'from', 'has', 'he',
|
||||||
if (!tableDefinition) {
|
'in', 'is', 'it', 'its', 'of', 'on', 'that', 'the', 'to', 'was', 'were', 'will',
|
||||||
throw new Error(`No table definition found for model: ${embeddingModel.id}`)
|
'with', 'would', 'could', 'should', 'can', 'may', 'might', 'must', 'shall',
|
||||||
}
|
'this', 'that', 'these', 'those', 'i', 'you', 'we', 'they', 'me', 'him', 'her',
|
||||||
return tableDefinition.name
|
'us', 'them', 'my', 'your', 'his', 'our', 'their', 'am', 'have', 'had', 'do',
|
||||||
}
|
'does', 'did', 'get', 'got', 'go', 'went', 'come', 'came', 'make', 'made',
|
||||||
|
'take', 'took', 'see', 'saw', 'know', 'knew', 'think', 'thought', 'say', 'said',
|
||||||
|
'tell', 'told', 'ask', 'asked', 'give', 'gave', 'find', 'found', 'work', 'worked',
|
||||||
|
'call', 'called', 'try', 'tried', 'need', 'needed', 'feel', 'felt', 'become',
|
||||||
|
'became', 'leave', 'left', 'put', 'keep', 'kept', 'let', 'begin', 'began',
|
||||||
|
'seem', 'seemed', 'help', 'helped', 'show', 'showed', 'hear', 'heard', 'play',
|
||||||
|
'played', 'run', 'ran', 'move', 'moved', 'live', 'lived', 'believe', 'believed',
|
||||||
|
'hold', 'held', 'bring', 'brought', 'happen', 'happened', 'write', 'wrote',
|
||||||
|
'sit', 'sat', 'stand', 'stood', 'lose', 'lost', 'pay', 'paid', 'meet', 'met',
|
||||||
|
'include', 'included', 'continue', 'continued', 'set', 'learn', 'learned',
|
||||||
|
'change', 'changed', 'lead', 'led', 'understand', 'understood', 'watch', 'watched',
|
||||||
|
'follow', 'followed', 'stop', 'stopped', 'create', 'created', 'speak', 'spoke',
|
||||||
|
'read', 'remember', 'remembered', 'consider', 'considered', 'appear', 'appeared',
|
||||||
|
'buy', 'bought', 'wait', 'waited', 'serve', 'served', 'die', 'died', 'send',
|
||||||
|
'sent', 'expect', 'expected', 'build', 'built', 'stay', 'stayed', 'fall', 'fell',
|
||||||
|
'cut', 'reach', 'reached', 'kill', 'killed', 'remain', 'remained', 'suggest',
|
||||||
|
'suggested', 'raise', 'raised', 'pass', 'passed', 'sell', 'sold', 'require',
|
||||||
|
'required', 'report', 'reported', 'decide', 'decided', 'pull', 'pulled'
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
async getAllIndexedFilePaths(embeddingModel: EmbeddingModel): Promise<string[]> {
|
private getTableName(embeddingModel: EmbeddingModel): string {
|
||||||
if (!this.db) {
|
const tableDefinition = vectorTables[embeddingModel.dimension]
|
||||||
throw new DatabaseNotInitializedException()
|
if (!tableDefinition) {
|
||||||
}
|
throw new Error(`No table definition found for model: ${embeddingModel.id}`)
|
||||||
const tableName = this.getTableName(embeddingModel)
|
|
||||||
const result = await this.db.query<{ path: string }>(
|
|
||||||
`SELECT DISTINCT path FROM "${tableName}"`
|
|
||||||
)
|
|
||||||
return result.rows.map((row: { path: string }) => row.path)
|
|
||||||
}
|
|
||||||
|
|
||||||
async getVectorsByFilePath(
|
|
||||||
filePath: string,
|
|
||||||
embeddingModel: EmbeddingModel,
|
|
||||||
): Promise<SelectVector[]> {
|
|
||||||
if (!this.db) {
|
|
||||||
throw new DatabaseNotInitializedException()
|
|
||||||
}
|
|
||||||
const tableName = this.getTableName(embeddingModel)
|
|
||||||
const result = await this.db.query<SelectVector>(
|
|
||||||
`SELECT * FROM "${tableName}" WHERE path = $1`,
|
|
||||||
[filePath]
|
|
||||||
)
|
|
||||||
return result.rows
|
|
||||||
}
|
|
||||||
|
|
||||||
async deleteVectorsForSingleFile(
|
|
||||||
filePath: string,
|
|
||||||
embeddingModel: EmbeddingModel,
|
|
||||||
): Promise<void> {
|
|
||||||
if (!this.db) {
|
|
||||||
throw new DatabaseNotInitializedException()
|
|
||||||
}
|
|
||||||
const tableName = this.getTableName(embeddingModel)
|
|
||||||
await this.db.query(
|
|
||||||
`DELETE FROM "${tableName}" WHERE path = $1`,
|
|
||||||
[filePath]
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
async deleteVectorsForMultipleFiles(
|
|
||||||
filePaths: string[],
|
|
||||||
embeddingModel: EmbeddingModel,
|
|
||||||
): Promise<void> {
|
|
||||||
if (!this.db) {
|
|
||||||
throw new DatabaseNotInitializedException()
|
|
||||||
}
|
|
||||||
const tableName = this.getTableName(embeddingModel)
|
|
||||||
await this.db.query(
|
|
||||||
`DELETE FROM "${tableName}" WHERE path = ANY($1)`,
|
|
||||||
[filePaths]
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
async clearAllVectors(embeddingModel: EmbeddingModel): Promise<void> {
|
|
||||||
if (!this.db) {
|
|
||||||
throw new DatabaseNotInitializedException()
|
|
||||||
}
|
|
||||||
const tableName = this.getTableName(embeddingModel)
|
|
||||||
await this.db.query(`DELETE FROM "${tableName}"`)
|
|
||||||
}
|
|
||||||
|
|
||||||
async insertVectors(
|
|
||||||
data: InsertVector[],
|
|
||||||
embeddingModel: EmbeddingModel,
|
|
||||||
): Promise<void> {
|
|
||||||
if (!this.db) {
|
|
||||||
throw new DatabaseNotInitializedException()
|
|
||||||
}
|
|
||||||
const tableName = this.getTableName(embeddingModel)
|
|
||||||
|
|
||||||
// 构建批量插入的 SQL
|
|
||||||
const values = data.map((vector, index) => {
|
|
||||||
const offset = index * 5
|
|
||||||
return `($${offset + 1}, $${offset + 2}, $${offset + 3}, $${offset + 4}, $${offset + 5})`
|
|
||||||
}).join(',')
|
|
||||||
|
|
||||||
const params = data.flatMap(vector => [
|
|
||||||
vector.path,
|
|
||||||
vector.mtime,
|
|
||||||
vector.content.replace(/\0/g, ''), // 清理null字节
|
|
||||||
`[${vector.embedding.join(',')}]`, // 转换为PostgreSQL vector格式
|
|
||||||
vector.metadata
|
|
||||||
])
|
|
||||||
|
|
||||||
await this.db.query(
|
|
||||||
`INSERT INTO "${tableName}" (path, mtime, content, embedding, metadata)
|
|
||||||
VALUES ${values}`,
|
|
||||||
params
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
async performSimilaritySearch(
|
|
||||||
queryVector: number[],
|
|
||||||
embeddingModel: EmbeddingModel,
|
|
||||||
options: {
|
|
||||||
minSimilarity: number
|
|
||||||
limit: number
|
|
||||||
scope?: {
|
|
||||||
files: string[]
|
|
||||||
folders: string[]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
): Promise<
|
|
||||||
(Omit<SelectVector, 'embedding'> & {
|
|
||||||
similarity: number
|
|
||||||
})[]
|
|
||||||
> {
|
|
||||||
if (!this.db) {
|
|
||||||
throw new DatabaseNotInitializedException()
|
|
||||||
}
|
|
||||||
const tableName = this.getTableName(embeddingModel)
|
|
||||||
|
|
||||||
let scopeCondition = ''
|
|
||||||
const params: any[] = [`[${queryVector.join(',')}]`, options.minSimilarity, options.limit]
|
|
||||||
let paramIndex = 4
|
|
||||||
|
|
||||||
if (options.scope) {
|
|
||||||
const conditions: string[] = []
|
|
||||||
|
|
||||||
if (options.scope.files.length > 0) {
|
|
||||||
conditions.push(`path = ANY($${paramIndex})`)
|
|
||||||
params.push(options.scope.files)
|
|
||||||
paramIndex++
|
|
||||||
}
|
|
||||||
|
|
||||||
if (options.scope.folders.length > 0) {
|
|
||||||
const folderConditions = options.scope.folders.map((folder, idx) => {
|
|
||||||
params.push(`${folder}/%`)
|
|
||||||
return `path LIKE $${paramIndex + idx}`
|
|
||||||
})
|
|
||||||
conditions.push(`(${folderConditions.join(' OR ')})`)
|
|
||||||
paramIndex += options.scope.folders.length
|
|
||||||
}
|
|
||||||
|
|
||||||
if (conditions.length > 0) {
|
|
||||||
scopeCondition = `AND (${conditions.join(' OR ')})`
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
return tableDefinition.name
|
||||||
const query = `
|
}
|
||||||
|
|
||||||
|
async getAllIndexedFilePaths(embeddingModel: EmbeddingModel): Promise<string[]> {
|
||||||
|
if (!this.db) {
|
||||||
|
throw new DatabaseNotInitializedException()
|
||||||
|
}
|
||||||
|
const tableName = this.getTableName(embeddingModel)
|
||||||
|
const result = await this.db.query<{ path: string }>(
|
||||||
|
`SELECT DISTINCT path FROM "${tableName}"`
|
||||||
|
)
|
||||||
|
return result.rows.map((row: { path: string }) => row.path)
|
||||||
|
}
|
||||||
|
|
||||||
|
async getMaxMtime(embeddingModel: EmbeddingModel): Promise<number | null> {
|
||||||
|
if (!this.db) {
|
||||||
|
throw new DatabaseNotInitializedException()
|
||||||
|
}
|
||||||
|
const tableName = this.getTableName(embeddingModel)
|
||||||
|
const result = await this.db.query<{ max_mtime: number | null }>(
|
||||||
|
`SELECT MAX(mtime) as max_mtime FROM "${tableName}"`
|
||||||
|
)
|
||||||
|
return result.rows[0]?.max_mtime || null
|
||||||
|
}
|
||||||
|
|
||||||
|
async getVectorsByFilePath(
|
||||||
|
filePath: string,
|
||||||
|
embeddingModel: EmbeddingModel,
|
||||||
|
): Promise<SelectVector[]> {
|
||||||
|
if (!this.db) {
|
||||||
|
throw new DatabaseNotInitializedException()
|
||||||
|
}
|
||||||
|
const tableName = this.getTableName(embeddingModel)
|
||||||
|
const result = await this.db.query<SelectVector>(
|
||||||
|
`SELECT * FROM "${tableName}" WHERE path = $1`,
|
||||||
|
[filePath]
|
||||||
|
)
|
||||||
|
return result.rows
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteVectorsForSingleFile(
|
||||||
|
filePath: string,
|
||||||
|
embeddingModel: EmbeddingModel,
|
||||||
|
): Promise<void> {
|
||||||
|
if (!this.db) {
|
||||||
|
throw new DatabaseNotInitializedException()
|
||||||
|
}
|
||||||
|
const tableName = this.getTableName(embeddingModel)
|
||||||
|
await this.db.query(
|
||||||
|
`DELETE FROM "${tableName}" WHERE path = $1`,
|
||||||
|
[filePath]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteVectorsForMultipleFiles(
|
||||||
|
filePaths: string[],
|
||||||
|
embeddingModel: EmbeddingModel,
|
||||||
|
): Promise<void> {
|
||||||
|
if (!this.db) {
|
||||||
|
throw new DatabaseNotInitializedException()
|
||||||
|
}
|
||||||
|
const tableName = this.getTableName(embeddingModel)
|
||||||
|
await this.db.query(
|
||||||
|
`DELETE FROM "${tableName}" WHERE path = ANY($1)`,
|
||||||
|
[filePaths]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async clearAllVectors(embeddingModel: EmbeddingModel): Promise<void> {
|
||||||
|
if (!this.db) {
|
||||||
|
throw new DatabaseNotInitializedException()
|
||||||
|
}
|
||||||
|
const tableName = this.getTableName(embeddingModel)
|
||||||
|
await this.db.query(`DELETE FROM "${tableName}"`)
|
||||||
|
}
|
||||||
|
|
||||||
|
async insertVectors(
|
||||||
|
data: InsertVector[],
|
||||||
|
embeddingModel: EmbeddingModel,
|
||||||
|
): Promise<void> {
|
||||||
|
if (!this.db) {
|
||||||
|
throw new DatabaseNotInitializedException()
|
||||||
|
}
|
||||||
|
const tableName = this.getTableName(embeddingModel)
|
||||||
|
|
||||||
|
// 构建批量插入的 SQL
|
||||||
|
const values = data.map((vector, index) => {
|
||||||
|
const offset = index * 5
|
||||||
|
return `($${offset + 1}, $${offset + 2}, $${offset + 3}, $${offset + 4}, $${offset + 5})`
|
||||||
|
}).join(',')
|
||||||
|
|
||||||
|
const params = data.flatMap(vector => [
|
||||||
|
vector.path,
|
||||||
|
vector.mtime,
|
||||||
|
vector.content.replace(/\0/g, ''), // 清理null字节
|
||||||
|
`[${vector.embedding.join(',')}]`, // 转换为PostgreSQL vector格式
|
||||||
|
vector.metadata
|
||||||
|
])
|
||||||
|
|
||||||
|
await this.db.query(
|
||||||
|
`INSERT INTO "${tableName}" (path, mtime, content, embedding, metadata)
|
||||||
|
VALUES ${values}`,
|
||||||
|
params
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async performSimilaritySearch(
|
||||||
|
queryVector: number[],
|
||||||
|
embeddingModel: EmbeddingModel,
|
||||||
|
options: {
|
||||||
|
minSimilarity: number
|
||||||
|
limit: number
|
||||||
|
scope?: {
|
||||||
|
files: string[]
|
||||||
|
folders: string[]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
): Promise<
|
||||||
|
(Omit<SelectVector, 'embedding'> & {
|
||||||
|
similarity: number
|
||||||
|
})[]
|
||||||
|
> {
|
||||||
|
if (!this.db) {
|
||||||
|
throw new DatabaseNotInitializedException()
|
||||||
|
}
|
||||||
|
const tableName = this.getTableName(embeddingModel)
|
||||||
|
|
||||||
|
let scopeCondition = ''
|
||||||
|
const params: unknown[] = [`[${queryVector.join(',')}]`, options.minSimilarity, options.limit]
|
||||||
|
let paramIndex = 4
|
||||||
|
|
||||||
|
if (options.scope) {
|
||||||
|
const conditions: string[] = []
|
||||||
|
|
||||||
|
if (options.scope.files.length > 0) {
|
||||||
|
conditions.push(`path = ANY($${paramIndex})`)
|
||||||
|
params.push(options.scope.files)
|
||||||
|
paramIndex++
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.scope.folders.length > 0) {
|
||||||
|
const folderConditions = options.scope.folders.map((folder, idx) => {
|
||||||
|
params.push(`${folder}/%`)
|
||||||
|
return `path LIKE $${paramIndex + idx}`
|
||||||
|
})
|
||||||
|
conditions.push(`(${folderConditions.join(' OR ')})`)
|
||||||
|
paramIndex += options.scope.folders.length
|
||||||
|
}
|
||||||
|
|
||||||
|
if (conditions.length > 0) {
|
||||||
|
scopeCondition = `AND (${conditions.join(' OR ')})`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const query = `
|
||||||
SELECT
|
SELECT
|
||||||
id, path, mtime, content, metadata,
|
id, path, mtime, content, metadata,
|
||||||
1 - (embedding <=> $1::vector) as similarity
|
1 - (embedding <=> $1::vector) as similarity
|
||||||
@ -173,8 +218,259 @@ export class VectorRepository {
|
|||||||
LIMIT $3
|
LIMIT $3
|
||||||
`
|
`
|
||||||
|
|
||||||
type SearchResult = Omit<SelectVector, 'embedding'> & { similarity: number }
|
type SearchResult = Omit<SelectVector, 'embedding'> & { similarity: number }
|
||||||
const result = await this.db.query<SearchResult>(query, params)
|
const result = await this.db.query<SearchResult>(query, params)
|
||||||
return result.rows
|
console.log("performSimilaritySearch result", result.rows)
|
||||||
|
return result.rows
|
||||||
|
}
|
||||||
|
|
||||||
|
async performFulltextSearch(
|
||||||
|
searchQuery: string,
|
||||||
|
embeddingModel: EmbeddingModel,
|
||||||
|
options: {
|
||||||
|
limit: number
|
||||||
|
scope?: {
|
||||||
|
files: string[]
|
||||||
|
folders: string[]
|
||||||
|
}
|
||||||
|
language?: string
|
||||||
|
},
|
||||||
|
): Promise<
|
||||||
|
(Omit<SelectVector, 'embedding'> & {
|
||||||
|
rank: number
|
||||||
|
})[]
|
||||||
|
> {
|
||||||
|
if (!this.db) {
|
||||||
|
throw new DatabaseNotInitializedException()
|
||||||
|
}
|
||||||
|
|
||||||
|
// handle query processing with segmentation and stop words filtering
|
||||||
|
const processedQuery = this.createFtsQuery(searchQuery, options.language || 'english')
|
||||||
|
|
||||||
|
const tableName = this.getTableName(embeddingModel)
|
||||||
|
const language = options.language || 'english'
|
||||||
|
|
||||||
|
let scopeCondition = ''
|
||||||
|
const params: unknown[] = [processedQuery, options.limit]
|
||||||
|
let paramIndex = 3
|
||||||
|
|
||||||
|
if (options.scope) {
|
||||||
|
const conditions: string[] = []
|
||||||
|
|
||||||
|
if (options.scope.files.length > 0) {
|
||||||
|
conditions.push(`path = ANY($${paramIndex})`)
|
||||||
|
params.push(options.scope.files)
|
||||||
|
paramIndex++
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.scope.folders.length > 0) {
|
||||||
|
const folderConditions = options.scope.folders.map((folder, idx) => {
|
||||||
|
params.push(`${folder}/%`)
|
||||||
|
return `path LIKE $${paramIndex + idx}`
|
||||||
|
})
|
||||||
|
conditions.push(`(${folderConditions.join(' OR ')})`)
|
||||||
|
paramIndex += options.scope.folders.length
|
||||||
|
}
|
||||||
|
|
||||||
|
if (conditions.length > 0) {
|
||||||
|
scopeCondition = `AND (${conditions.join(' OR ')})`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const query = `
|
||||||
|
SELECT
|
||||||
|
id, path, mtime, content, metadata,
|
||||||
|
ts_rank_cd(
|
||||||
|
COALESCE(content_tsv, to_tsvector('${language}', coalesce(content, ''))),
|
||||||
|
to_tsquery('${language}', $1)
|
||||||
|
) AS rank
|
||||||
|
FROM "${tableName}"
|
||||||
|
WHERE (
|
||||||
|
content_tsv @@ to_tsquery('${language}', $1)
|
||||||
|
OR (content_tsv IS NULL AND to_tsvector('${language}', coalesce(content, '')) @@ to_tsquery('${language}', $1))
|
||||||
|
)
|
||||||
|
${scopeCondition}
|
||||||
|
ORDER BY rank DESC
|
||||||
|
LIMIT $2
|
||||||
|
`
|
||||||
|
console.log("performFulltextSearch query", query)
|
||||||
|
type SearchResult = Omit<SelectVector, 'embedding'> & { rank: number }
|
||||||
|
const result = await this.db.query<SearchResult>(query, params)
|
||||||
|
console.log("performFulltextSearch result", result.rows)
|
||||||
|
return result.rows
|
||||||
|
}
|
||||||
|
|
||||||
|
public segmentTextForTsvector(text: string, language: string = 'zh-CN'): string {
|
||||||
|
try {
|
||||||
|
// Use Intl.Segmenter to add spaces between words for better TSVECTOR indexing
|
||||||
|
if (typeof Intl !== 'undefined' && Intl.Segmenter) {
|
||||||
|
const segmenter = new Intl.Segmenter(language, { granularity: 'word' })
|
||||||
|
const segments = segmenter.segment(text)
|
||||||
|
|
||||||
|
const segmentedText = Array.from(segments)
|
||||||
|
.map(segment => segment.segment)
|
||||||
|
.join(' ')
|
||||||
|
|
||||||
|
return segmentedText
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: add spaces around Chinese characters and punctuation
|
||||||
|
return text.replace(/([一-龯])/g, ' $1 ')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim()
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to segment text for TSVECTOR:', error)
|
||||||
|
return text
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private createFtsQuery(query: string, language: string): string {
|
||||||
|
try {
|
||||||
|
|
||||||
|
let keywords: string[] = []
|
||||||
|
|
||||||
|
// Try to use Intl.Segmenter for word segmentation
|
||||||
|
if (typeof Intl !== 'undefined' && Intl.Segmenter) {
|
||||||
|
try {
|
||||||
|
const segmenter = new Intl.Segmenter(language, { granularity: 'word' })
|
||||||
|
const segments = segmenter.segment(query)
|
||||||
|
|
||||||
|
keywords = Array.from(segments)
|
||||||
|
.filter(s => s.isWordLike)
|
||||||
|
.map(s => s.segment.trim())
|
||||||
|
.filter(word => {
|
||||||
|
// Filter out empty strings and stop words
|
||||||
|
if (!word || word.length === 0) return false
|
||||||
|
return !this.stopWords.has(word.toLowerCase())
|
||||||
|
})
|
||||||
|
.filter(word => {
|
||||||
|
// Keep all words with length > 0 since stop words are already filtered
|
||||||
|
return word.length > 0
|
||||||
|
})
|
||||||
|
} catch (segmentError) {
|
||||||
|
console.warn('Intl.Segmenter failed, falling back to simple splitting:', segmentError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to simple word splitting if Intl.Segmenter is not available or failed
|
||||||
|
if (keywords.length === 0) {
|
||||||
|
keywords = query
|
||||||
|
.split(/[\s\p{P}\p{S}]+/u) // Split by whitespace, punctuation, and symbols
|
||||||
|
.map(word => word.trim())
|
||||||
|
.filter(word => {
|
||||||
|
if (!word || word.length === 0) return false
|
||||||
|
return !this.stopWords.has(word.toLowerCase())
|
||||||
|
})
|
||||||
|
.filter(word => {
|
||||||
|
// Keep all words with length > 0 since stop words are already filtered
|
||||||
|
return word.length > 0
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no keywords remain, return original query
|
||||||
|
if (keywords.length === 0) {
|
||||||
|
return query
|
||||||
|
}
|
||||||
|
|
||||||
|
// Join keywords with & for PostgreSQL full-text search
|
||||||
|
const ftsQueryString = keywords.join(' | ')
|
||||||
|
|
||||||
|
console.log(`Original query: "${query}" -> Processed query: "${ftsQueryString}"`)
|
||||||
|
return ftsQueryString
|
||||||
|
} catch (error) {
|
||||||
|
// If all processing fails, return original query
|
||||||
|
console.warn('Failed to process FTS query:', error)
|
||||||
|
return query
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getWorkspaceStatistics(
|
||||||
|
embeddingModel: EmbeddingModel,
|
||||||
|
scope?: {
|
||||||
|
files: string[]
|
||||||
|
folders: string[]
|
||||||
|
}
|
||||||
|
): Promise<{
|
||||||
|
totalFiles: number
|
||||||
|
totalChunks: number
|
||||||
|
}> {
|
||||||
|
if (!this.db) {
|
||||||
|
throw new DatabaseNotInitializedException()
|
||||||
|
}
|
||||||
|
const tableName = this.getTableName(embeddingModel)
|
||||||
|
|
||||||
|
let scopeCondition = ''
|
||||||
|
const params: unknown[] = []
|
||||||
|
let paramIndex = 1
|
||||||
|
|
||||||
|
if (scope) {
|
||||||
|
const conditions: string[] = []
|
||||||
|
|
||||||
|
if (scope.files.length > 0) {
|
||||||
|
conditions.push(`path = ANY($${paramIndex})`)
|
||||||
|
params.push(scope.files)
|
||||||
|
paramIndex++
|
||||||
|
}
|
||||||
|
|
||||||
|
if (scope.folders.length > 0) {
|
||||||
|
const folderConditions = scope.folders.map((folder, idx) => {
|
||||||
|
params.push(`${folder}/%`)
|
||||||
|
return `path LIKE $${paramIndex + idx}`
|
||||||
|
})
|
||||||
|
conditions.push(`(${folderConditions.join(' OR ')})`)
|
||||||
|
paramIndex += scope.folders.length
|
||||||
|
}
|
||||||
|
|
||||||
|
if (conditions.length > 0) {
|
||||||
|
scopeCondition = `WHERE (${conditions.join(' OR ')})`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const query = `
|
||||||
|
SELECT
|
||||||
|
COUNT(DISTINCT path) as total_files,
|
||||||
|
COUNT(*) as total_chunks
|
||||||
|
FROM "${tableName}"
|
||||||
|
${scopeCondition}
|
||||||
|
`
|
||||||
|
|
||||||
|
const result = await this.db.query<{
|
||||||
|
total_files: number
|
||||||
|
total_chunks: number
|
||||||
|
}>(query, params)
|
||||||
|
|
||||||
|
const row = result.rows[0]
|
||||||
|
return {
|
||||||
|
totalFiles: Number(row?.total_files || 0),
|
||||||
|
totalChunks: Number(row?.total_chunks || 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getVaultStatistics(embeddingModel: EmbeddingModel): Promise<{
|
||||||
|
totalFiles: number
|
||||||
|
totalChunks: number
|
||||||
|
}> {
|
||||||
|
if (!this.db) {
|
||||||
|
throw new DatabaseNotInitializedException()
|
||||||
|
}
|
||||||
|
const tableName = this.getTableName(embeddingModel)
|
||||||
|
|
||||||
|
const query = `
|
||||||
|
SELECT
|
||||||
|
COUNT(DISTINCT path) as total_files,
|
||||||
|
COUNT(*) as total_chunks
|
||||||
|
FROM "${tableName}"
|
||||||
|
`
|
||||||
|
|
||||||
|
const result = await this.db.query<{
|
||||||
|
total_files: number
|
||||||
|
total_chunks: number
|
||||||
|
}>(query)
|
||||||
|
|
||||||
|
const row = result.rows[0]
|
||||||
|
return {
|
||||||
|
totalFiles: Number(row?.total_files || 0),
|
||||||
|
totalChunks: Number(row?.total_chunks || 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,7 +1,6 @@
|
|||||||
import { SerializedLexicalNode } from 'lexical'
|
import { SerializedLexicalNode } from 'lexical'
|
||||||
|
|
||||||
import { SUPPORT_EMBEDDING_SIMENTION } from '../constants'
|
import { SUPPORT_EMBEDDING_SIMENTION } from '../constants'
|
||||||
import { ApplyStatus } from '../types/apply'
|
|
||||||
// import { EmbeddingModelId } from '../types/embedding'
|
// import { EmbeddingModelId } from '../types/embedding'
|
||||||
|
|
||||||
// PostgreSQL column types
|
// PostgreSQL column types
|
||||||
@ -176,3 +175,65 @@ export type SelectMessage = {
|
|||||||
similarity_search_results?: string | null
|
similarity_search_results?: string | null
|
||||||
created_at: Date
|
created_at: Date
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Source Insight Table */
|
||||||
|
export type SourceInsightRecord = {
|
||||||
|
id: number
|
||||||
|
insight_type: string
|
||||||
|
insight: string
|
||||||
|
source_type: 'document' | 'tag' | 'folder'
|
||||||
|
source_path: string
|
||||||
|
source_mtime: number
|
||||||
|
embedding: number[]
|
||||||
|
created_at: Date
|
||||||
|
updated_at: Date
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SelectSourceInsight = SourceInsightRecord
|
||||||
|
export type InsertSourceInsight = Omit<SourceInsightRecord, 'id' | 'created_at' | 'updated_at'>
|
||||||
|
|
||||||
|
const createSourceInsightTable = (dimension: number): TableDefinition => {
|
||||||
|
const tableName = `source_insight_${dimension}`
|
||||||
|
|
||||||
|
const table: TableDefinition = {
|
||||||
|
name: tableName,
|
||||||
|
columns: {
|
||||||
|
id: { type: 'SERIAL', primaryKey: true },
|
||||||
|
insight_type: { type: 'TEXT', notNull: true },
|
||||||
|
insight: { type: 'TEXT', notNull: true },
|
||||||
|
source_type: { type: 'TEXT', notNull: true },
|
||||||
|
source_path: { type: 'TEXT', notNull: true },
|
||||||
|
source_mtime: { type: 'BIGINT', notNull: true },
|
||||||
|
embedding: { type: 'VECTOR', dimensions: dimension },
|
||||||
|
created_at: { type: 'TIMESTAMP', notNull: true, defaultNow: true },
|
||||||
|
updated_at: { type: 'TIMESTAMP', notNull: true, defaultNow: true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dimension <= 2000) {
|
||||||
|
table.indices = {
|
||||||
|
[`insightEmbeddingIndex_${dimension}`]: {
|
||||||
|
type: 'HNSW',
|
||||||
|
columns: ['embedding'],
|
||||||
|
options: 'vector_cosine_ops'
|
||||||
|
},
|
||||||
|
[`insightSourceIndex_${dimension}`]: {
|
||||||
|
type: 'BTREE',
|
||||||
|
columns: ['source_path']
|
||||||
|
},
|
||||||
|
[`insightTypeIndex_${dimension}`]: {
|
||||||
|
type: 'BTREE',
|
||||||
|
columns: ['insight_type']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return table
|
||||||
|
}
|
||||||
|
|
||||||
|
export const sourceInsightTables = SUPPORT_EMBEDDING_SIMENTION.reduce<
|
||||||
|
Record<number, TableDefinition>
|
||||||
|
>((acc, dimension) => {
|
||||||
|
acc[dimension] = createSourceInsightTable(dimension)
|
||||||
|
return acc
|
||||||
|
}, {})
|
||||||
|
|||||||
@ -94,6 +94,124 @@ export const migrations: Record<string, SqlMigration> = {
|
|||||||
ON "embeddings_384" ("path");
|
ON "embeddings_384" ("path");
|
||||||
`
|
`
|
||||||
},
|
},
|
||||||
|
source_insight: {
|
||||||
|
description: "Creates source insight tables and indexes for different embedding models",
|
||||||
|
sql: `
|
||||||
|
-- Create source insight tables for different embedding dimensions
|
||||||
|
CREATE TABLE IF NOT EXISTS "source_insight_1536" (
|
||||||
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
|
"insight_type" text NOT NULL,
|
||||||
|
"insight" text NOT NULL,
|
||||||
|
"source_type" text NOT NULL,
|
||||||
|
"source_path" text NOT NULL,
|
||||||
|
"source_mtime" bigint NOT NULL,
|
||||||
|
"embedding" vector(1536),
|
||||||
|
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS "source_insight_1024" (
|
||||||
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
|
"insight_type" text NOT NULL,
|
||||||
|
"insight" text NOT NULL,
|
||||||
|
"source_type" text NOT NULL,
|
||||||
|
"source_path" text NOT NULL,
|
||||||
|
"source_mtime" bigint NOT NULL,
|
||||||
|
"embedding" vector(1024),
|
||||||
|
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS "source_insight_768" (
|
||||||
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
|
"insight_type" text NOT NULL,
|
||||||
|
"insight" text NOT NULL,
|
||||||
|
"source_type" text NOT NULL,
|
||||||
|
"source_path" text NOT NULL,
|
||||||
|
"source_mtime" bigint NOT NULL,
|
||||||
|
"embedding" vector(768),
|
||||||
|
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS "source_insight_512" (
|
||||||
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
|
"insight_type" text NOT NULL,
|
||||||
|
"insight" text NOT NULL,
|
||||||
|
"source_type" text NOT NULL,
|
||||||
|
"source_path" text NOT NULL,
|
||||||
|
"source_mtime" bigint NOT NULL,
|
||||||
|
"embedding" vector(512),
|
||||||
|
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS "source_insight_384" (
|
||||||
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
|
"insight_type" text NOT NULL,
|
||||||
|
"insight" text NOT NULL,
|
||||||
|
"source_type" text NOT NULL,
|
||||||
|
"source_path" text NOT NULL,
|
||||||
|
"source_mtime" bigint NOT NULL,
|
||||||
|
"embedding" vector(384),
|
||||||
|
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Create HNSW indexes for embedding similarity search
|
||||||
|
CREATE INDEX IF NOT EXISTS "insightEmbeddingIndex_1536"
|
||||||
|
ON "source_insight_1536"
|
||||||
|
USING hnsw ("embedding" vector_cosine_ops);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS "insightEmbeddingIndex_1024"
|
||||||
|
ON "source_insight_1024"
|
||||||
|
USING hnsw ("embedding" vector_cosine_ops);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS "insightEmbeddingIndex_768"
|
||||||
|
ON "source_insight_768"
|
||||||
|
USING hnsw ("embedding" vector_cosine_ops);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS "insightEmbeddingIndex_512"
|
||||||
|
ON "source_insight_512"
|
||||||
|
USING hnsw ("embedding" vector_cosine_ops);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS "insightEmbeddingIndex_384"
|
||||||
|
ON "source_insight_384"
|
||||||
|
USING hnsw ("embedding" vector_cosine_ops);
|
||||||
|
|
||||||
|
-- Create B-tree indexes for source_path field
|
||||||
|
CREATE INDEX IF NOT EXISTS "insightSourceIndex_1536"
|
||||||
|
ON "source_insight_1536" ("source_path");
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS "insightSourceIndex_1024"
|
||||||
|
ON "source_insight_1024" ("source_path");
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS "insightSourceIndex_768"
|
||||||
|
ON "source_insight_768" ("source_path");
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS "insightSourceIndex_512"
|
||||||
|
ON "source_insight_512" ("source_path");
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS "insightSourceIndex_384"
|
||||||
|
ON "source_insight_384" ("source_path");
|
||||||
|
|
||||||
|
-- Create B-tree indexes for insight_type field
|
||||||
|
CREATE INDEX IF NOT EXISTS "insightTypeIndex_1536"
|
||||||
|
ON "source_insight_1536" ("insight_type");
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS "insightTypeIndex_1024"
|
||||||
|
ON "source_insight_1024" ("insight_type");
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS "insightTypeIndex_768"
|
||||||
|
ON "source_insight_768" ("insight_type");
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS "insightTypeIndex_512"
|
||||||
|
ON "source_insight_512" ("insight_type");
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS "insightTypeIndex_384"
|
||||||
|
ON "source_insight_384" ("insight_type");
|
||||||
|
`
|
||||||
|
},
|
||||||
template: {
|
template: {
|
||||||
description: "Creates template table with UUID support",
|
description: "Creates template table with UUID support",
|
||||||
sql: `
|
sql: `
|
||||||
@ -132,5 +250,119 @@ export const migrations: Record<string, SqlMigration> = {
|
|||||||
"created_at" timestamp DEFAULT now() NOT NULL
|
"created_at" timestamp DEFAULT now() NOT NULL
|
||||||
);
|
);
|
||||||
`
|
`
|
||||||
|
},
|
||||||
|
add_source_mtime: {
|
||||||
|
description: "Adds missing source_mtime column to existing source insight tables",
|
||||||
|
sql: `
|
||||||
|
-- Add source_mtime column to existing source insight tables if it doesn't exist
|
||||||
|
ALTER TABLE "source_insight_1536" ADD COLUMN IF NOT EXISTS "source_mtime" bigint NOT NULL DEFAULT 0;
|
||||||
|
ALTER TABLE "source_insight_1024" ADD COLUMN IF NOT EXISTS "source_mtime" bigint NOT NULL DEFAULT 0;
|
||||||
|
ALTER TABLE "source_insight_768" ADD COLUMN IF NOT EXISTS "source_mtime" bigint NOT NULL DEFAULT 0;
|
||||||
|
ALTER TABLE "source_insight_512" ADD COLUMN IF NOT EXISTS "source_mtime" bigint NOT NULL DEFAULT 0;
|
||||||
|
ALTER TABLE "source_insight_384" ADD COLUMN IF NOT EXISTS "source_mtime" bigint NOT NULL DEFAULT 0;
|
||||||
|
`
|
||||||
|
},
|
||||||
|
full_text_search: {
|
||||||
|
description: "Adds full-text search capabilities to embedding and source insight tables",
|
||||||
|
sql: `
|
||||||
|
-- Add content_tsv columns to embedding tables
|
||||||
|
ALTER TABLE "embeddings_1536" ADD COLUMN IF NOT EXISTS "content_tsv" TSVECTOR;
|
||||||
|
ALTER TABLE "embeddings_1024" ADD COLUMN IF NOT EXISTS "content_tsv" TSVECTOR;
|
||||||
|
ALTER TABLE "embeddings_768" ADD COLUMN IF NOT EXISTS "content_tsv" TSVECTOR;
|
||||||
|
ALTER TABLE "embeddings_512" ADD COLUMN IF NOT EXISTS "content_tsv" TSVECTOR;
|
||||||
|
ALTER TABLE "embeddings_384" ADD COLUMN IF NOT EXISTS "content_tsv" TSVECTOR;
|
||||||
|
|
||||||
|
-- Add insight_tsv columns to source insight tables
|
||||||
|
ALTER TABLE "source_insight_1536" ADD COLUMN IF NOT EXISTS "insight_tsv" TSVECTOR;
|
||||||
|
ALTER TABLE "source_insight_1024" ADD COLUMN IF NOT EXISTS "insight_tsv" TSVECTOR;
|
||||||
|
ALTER TABLE "source_insight_768" ADD COLUMN IF NOT EXISTS "insight_tsv" TSVECTOR;
|
||||||
|
ALTER TABLE "source_insight_512" ADD COLUMN IF NOT EXISTS "insight_tsv" TSVECTOR;
|
||||||
|
ALTER TABLE "source_insight_384" ADD COLUMN IF NOT EXISTS "insight_tsv" TSVECTOR;
|
||||||
|
|
||||||
|
-- Create trigger function for embeddings tables
|
||||||
|
CREATE OR REPLACE FUNCTION embeddings_tsv_trigger() RETURNS trigger AS $$
|
||||||
|
BEGIN
|
||||||
|
NEW.content_tsv := to_tsvector('english', coalesce(NEW.content, ''));
|
||||||
|
RETURN NEW;
|
||||||
|
END
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
-- Create trigger function for source insight tables
|
||||||
|
CREATE OR REPLACE FUNCTION source_insight_tsv_trigger() RETURNS trigger AS $$
|
||||||
|
BEGIN
|
||||||
|
NEW.insight_tsv := to_tsvector('english', coalesce(NEW.insight, ''));
|
||||||
|
RETURN NEW;
|
||||||
|
END
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
-- Create triggers for embeddings tables (drop if exists first)
|
||||||
|
DROP TRIGGER IF EXISTS tsvector_update_embeddings_1536 ON "embeddings_1536";
|
||||||
|
CREATE TRIGGER tsvector_update_embeddings_1536
|
||||||
|
BEFORE INSERT OR UPDATE ON "embeddings_1536"
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION embeddings_tsv_trigger();
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS tsvector_update_embeddings_1024 ON "embeddings_1024";
|
||||||
|
CREATE TRIGGER tsvector_update_embeddings_1024
|
||||||
|
BEFORE INSERT OR UPDATE ON "embeddings_1024"
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION embeddings_tsv_trigger();
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS tsvector_update_embeddings_768 ON "embeddings_768";
|
||||||
|
CREATE TRIGGER tsvector_update_embeddings_768
|
||||||
|
BEFORE INSERT OR UPDATE ON "embeddings_768"
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION embeddings_tsv_trigger();
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS tsvector_update_embeddings_512 ON "embeddings_512";
|
||||||
|
CREATE TRIGGER tsvector_update_embeddings_512
|
||||||
|
BEFORE INSERT OR UPDATE ON "embeddings_512"
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION embeddings_tsv_trigger();
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS tsvector_update_embeddings_384 ON "embeddings_384";
|
||||||
|
CREATE TRIGGER tsvector_update_embeddings_384
|
||||||
|
BEFORE INSERT OR UPDATE ON "embeddings_384"
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION embeddings_tsv_trigger();
|
||||||
|
|
||||||
|
-- Create triggers for source insight tables (drop if exists first)
|
||||||
|
DROP TRIGGER IF EXISTS tsvector_update_source_insight_1536 ON "source_insight_1536";
|
||||||
|
CREATE TRIGGER tsvector_update_source_insight_1536
|
||||||
|
BEFORE INSERT OR UPDATE ON "source_insight_1536"
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION source_insight_tsv_trigger();
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS tsvector_update_source_insight_1024 ON "source_insight_1024";
|
||||||
|
CREATE TRIGGER tsvector_update_source_insight_1024
|
||||||
|
BEFORE INSERT OR UPDATE ON "source_insight_1024"
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION source_insight_tsv_trigger();
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS tsvector_update_source_insight_768 ON "source_insight_768";
|
||||||
|
CREATE TRIGGER tsvector_update_source_insight_768
|
||||||
|
BEFORE INSERT OR UPDATE ON "source_insight_768"
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION source_insight_tsv_trigger();
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS tsvector_update_source_insight_512 ON "source_insight_512";
|
||||||
|
CREATE TRIGGER tsvector_update_source_insight_512
|
||||||
|
BEFORE INSERT OR UPDATE ON "source_insight_512"
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION source_insight_tsv_trigger();
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS tsvector_update_source_insight_384 ON "source_insight_384";
|
||||||
|
CREATE TRIGGER tsvector_update_source_insight_384
|
||||||
|
BEFORE INSERT OR UPDATE ON "source_insight_384"
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION source_insight_tsv_trigger();
|
||||||
|
|
||||||
|
-- Note: 现有数据的 tsvector 字段将保持为 NULL,只有新插入的数据会通过 trigger 自动填充
|
||||||
|
-- 这样可以避免大量 UPDATE 操作导致的文件句柄耗尽问题
|
||||||
|
|
||||||
|
-- Create GIN indexes for full-text search on embeddings tables
|
||||||
|
CREATE INDEX IF NOT EXISTS "embeddings_content_tsv_idx_1536" ON "embeddings_1536" USING GIN(content_tsv);
|
||||||
|
CREATE INDEX IF NOT EXISTS "embeddings_content_tsv_idx_1024" ON "embeddings_1024" USING GIN(content_tsv);
|
||||||
|
CREATE INDEX IF NOT EXISTS "embeddings_content_tsv_idx_768" ON "embeddings_768" USING GIN(content_tsv);
|
||||||
|
CREATE INDEX IF NOT EXISTS "embeddings_content_tsv_idx_512" ON "embeddings_512" USING GIN(content_tsv);
|
||||||
|
CREATE INDEX IF NOT EXISTS "embeddings_content_tsv_idx_384" ON "embeddings_384" USING GIN(content_tsv);
|
||||||
|
|
||||||
|
-- Create GIN indexes for full-text search on source insight tables
|
||||||
|
CREATE INDEX IF NOT EXISTS "source_insight_tsv_idx_1536" ON "source_insight_1536" USING GIN(insight_tsv);
|
||||||
|
CREATE INDEX IF NOT EXISTS "source_insight_tsv_idx_1024" ON "source_insight_1024" USING GIN(insight_tsv);
|
||||||
|
CREATE INDEX IF NOT EXISTS "source_insight_tsv_idx_768" ON "source_insight_768" USING GIN(insight_tsv);
|
||||||
|
CREATE INDEX IF NOT EXISTS "source_insight_tsv_idx_512" ON "source_insight_512" USING GIN(insight_tsv);
|
||||||
|
CREATE INDEX IF NOT EXISTS "source_insight_tsv_idx_384" ON "source_insight_384" USING GIN(insight_tsv);
|
||||||
|
`
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user