Compare commits

...

93 Commits

Author SHA1 Message Date
duanfuxiang
1764f9170e update release 2025-09-26 21:41:45 +08:00
duanfuxiang
2132318a40 update release log 2025-09-26 21:17:37 +08:00
duanfuxiang
2291e635ae update pgworker resources fetch. 2025-09-26 16:59:13 +08:00
duanfuxiang
2c98acd969 update release mobile 2025-09-24 00:14:13 +08:00
duanfuxiang
91b87d8f68 ignore build error 2025-09-23 21:37:19 +08:00
duanfuxiang
d49fb587d2 update manifest.json 2025-09-23 21:05:02 +08:00
duanfuxiang
669656e138 add mobile version for pro user 2025-09-23 16:00:22 +08:00
duanfuxiang
1483b3b8b9 fix mobile config 2025-09-22 19:25:30 +08:00
duanfuxiang
9e8a9f4c0a update release 2025-09-22 18:58:40 +08:00
duanfuxiang
d9923ff890 update release 0.8.3 2025-08-26 06:09:47 +08:00
duanfuxiang
ffc29db771 update release 2025-08-26 05:43:34 +08:00
duanfuxiang
5b0ca2fb10 fix pro bug 2025-08-24 18:55:14 +08:00
duanfuxiang
cfa856fea8 release log 2025-08-21 02:17:21 +08:00
duanfuxiang
3f9cff3f53 update release logs 2025-08-21 01:59:56 +08:00
duanfuxiang
a6593fdbef fix update error 2025-08-21 01:51:49 +08:00
duanfuxiang
272082f6ca update to pro 2025-08-21 00:59:12 +08:00
duanfuxiang
b66d3ab21f update release 2025-08-21 00:56:01 +08:00
duanfuxiang
9a848489e3 release version update 2025-08-20 22:08:04 +08:00
duanfuxiang
80e72394a9 将内置服务器的禁用状态更改为true,以确保在全局配置中保持一致性。 2025-07-19 09:30:49 +08:00
duanfuxiang
e2df9a7995 在BaseFileView.tsx中添加关闭状态管理,优化文件保存逻辑以防止数据丢失,确保在视图关闭时不进行保存操作。 2025-07-19 09:26:52 +08:00
duanfuxiang
519fc3769d 优化McpHub.ts中的配置文件处理逻辑,添加日志记录以便调试,确保新旧配置文件的迁移和兼容性,处理迁移失败时的默认配置创建。 2025-07-19 08:33:44 +08:00
duanfuxiang
429c4886e7 更新依赖版本,添加对 @codemirror/language 的支持,增强类型安全性,优化配置验证逻辑,修复旧配置目录删除逻辑中的潜在问题。 2025-07-19 07:52:18 +08:00
duanfuxiang
263a75ca6e 更新版本至0.7.6,添加MCP设置文件监视器功能,并在CHANGELOG中记录相关更改。 2025-07-19 07:41:08 +08:00
duanfuxiang
dea60c3627 重构McpHub.ts,迁移MCP配置文件至新路径,确保新旧配置文件的兼容性,添加错误处理和日志记录,优化配置验证逻辑。 2025-07-19 07:40:16 +08:00
duanfuxiang
3ca234c1a2 添加对 CodeMirror 的支持,更新相关依赖版本,并在样式中增加 JSON 视图的样式。新增 JSON 视图类型并实现打开配置文件的功能,更新国际化文本以支持配置文件操作。 2025-07-19 06:49:59 +08:00
duanfuxiang
0f04b3c413 更新版本至0.7.5,修复洞察模型错误,并在CHANGELOG中记录相关更改。 2025-07-18 00:50:18 +08:00
duanfuxiang
36778565cd update lock file 2025-07-15 22:47:52 +08:00
duanfuxiang
d99ea8f2f6 Update to version 0.7.4, fix CORS errors for the Moonshot provider, add BM25 search support, and document related changes in the CHANGELOG. 2025-07-15 22:41:21 +08:00
duanfuxiang
c0cd2ccf4d Added Moonshot API support, updated relevant models and settings, optimized OpenAI-compatible providers to handle CORS issues, and enhanced model management capabilities. 2025-07-15 22:25:05 +08:00
duanfuxiang
34296e6871 update vector manager 2025-07-13 07:02:54 +08:00
duanfuxiang
c1fbd4da21 update batch files in update index 2025-07-10 12:54:57 +08:00
duanfuxiang
21f4734917 Update version to 0.7.3, add support for IndexedDB, and document related features in CHANGELOG. 2025-07-08 21:34:14 +08:00
duanfuxiang
10a1c8a23c Enhance RAG functionality, add file system options for storing vector embeddings, support IndexedDB and Origin Private File System, update relevant settings and internationalization support. 2025-07-08 21:33:02 +08:00
duanfuxiang
8ac5945f9b fix workspace vector indexing error, update insights and search view styles. 2025-07-07 23:15:04 +08:00
duanfuxiang
627a19206e Optimize the vector manager by adding batch transaction insertion and delayed memory cleanup features, limit the maximum batch size to reduce memory pressure, and enhance system performance and stability. 2025-07-07 23:12:49 +08:00
duanfuxiang
d3271f85e9 Update the package.json and pnpm-lock.yaml files by adding dependencies for sanitize-basename and unsanitize-basename, optimize the chat manager to support new filename formats, and improve code readability as well as user experience. 2025-07-07 21:35:04 +08:00
duanfuxiang
306817741f Remove the markdown-to-text dependency, update the package.json file, enhance internationalization support for chat history view components, optimize user interaction prompts, and improve both user experience and code readability. 2025-07-07 18:09:38 +08:00
duanfuxiang
bff3e05d93 Add dependency on remove-markdown, update package.json and pnpm-lock.yaml files, optimize the search view component, enhance internationalization support, update user interaction prompts, and improve both user experience and code readability. 2025-07-07 17:32:49 +08:00
duanfuxiang
c89186a40d Optimize the search view component, add model selection functionality, support multiple search modes (notes, insights, all), update internationalization support, improve user interaction prompts, enhance log output, and ensure better user experience and code readability. 2025-07-07 16:56:12 +08:00
duanfuxiang
3db334c6e8 Optimize the search view component, add workspace statistics and RAG vector initialization features, update internationalization support, improve user interaction prompts, enhance log output, and ensure better user experience and code readability. 2025-07-07 09:47:37 +08:00
duanfuxiang
51f8620815 Optimize the insight view component, add workspace insight initialization functionality, update internationalization support, improve user interaction prompts, enhance log output, and ensure better user experience and code readability. 2025-07-06 17:33:09 +08:00
duanfuxiang
932b2d3d7f Optimize the insight view component, add internationalization support, update log output, improve user interaction prompts, and ensure better user experience and code readability. 2025-07-06 07:20:07 +08:00
duanfuxiang
a9c2c7bc16 update chunk size 2025-07-05 17:32:43 +08:00
duanfuxiang
63b7eec906 udpate release logs 2025-07-05 17:23:16 +08:00
duanfuxiang
ec3061dfb5 update settings test 2025-07-05 17:19:41 +08:00
duanfuxiang
03c467753a update settings 2025-07-05 17:18:14 +08:00
duanfuxiang
e3f54d4c26 更新依赖项:将 @langchain/core 版本升级至 0.3.40,并将 zod 版本更新至 3.24.2,同时添加 pnpm-lock.yaml 文件以管理依赖项的锁定版本。 2025-07-05 17:06:20 +08:00
duanfuxiang
322e88fa9c 更新版本至 0.7.0,添加新特性包括洞察转换、工作区、数据视图支持和本地嵌入模型支持,同时更新变更日志。 2025-07-05 16:34:20 +08:00
duanfuxiang
54a14dedd6 移除多个文件中的调试日志输出,优化代码可读性和性能。 2025-07-05 16:29:41 +08:00
duanfuxiang
a2fcb7c20f 更新 TransEngine 以支持嵌入管理器,优化日志输出,添加删除确认对话框功能,改进 ChatView 组件的文件管理和上下文信息处理,确保更好的用户体验和代码可读性。 2025-07-05 16:24:49 +08:00
duanfuxiang
bbd89fbfa4 update default settings 2025-07-05 15:47:59 +08:00
duanfuxiang
4b7efe8d29 删除 pnpm-lock.yaml 文件,更新嵌入管理器以优化消息处理和请求管理逻辑,调整模型加载和嵌入过程中的类型定义,确保更好的稳定性和性能。 2025-07-05 12:14:24 +08:00
duanfuxiang
c657a50563 更新嵌入管理器以支持 GPU 加速,调整批处理大小,优化内容处理逻辑,并添加获取数据库最大修改时间的功能以提高文件索引效率。同时修复了向量管理器中的类型问题,确保模型加载和嵌入过程的稳定性。 2025-07-05 07:40:54 +08:00
duanfuxiang
558e3b3fe4 添加 markdown-to-text 库以处理 Markdown 文本,更新向量管理器以使用递归字符文本分割器,并优化内容处理逻辑。同时更新本地嵌入模型的默认设置。 2025-07-05 05:42:39 +08:00
duanfuxiang
4e139ecc4f 更新 RAGEngine 和嵌入管理器以支持嵌入管理器的传递,添加本地提供者的嵌入模型加载逻辑,优化错误处理和消息处理机制。 2025-07-04 15:52:00 +08:00
duanfuxiang
bed96a5233 添加本地提供者支持,包括本地嵌入模型的描述、特性和设置,更新相关类型和API函数以支持新模型。 2025-07-04 11:43:37 +08:00
duanfuxiang
8b3babc28e 添加嵌入管理器的初始化和清理逻辑,更新嵌入模型加载和嵌入测试的实现。 2025-07-04 09:33:08 +08:00
duanfuxiang
65c5df3d22 add local embed 2025-07-04 09:28:12 +08:00
duanfuxiang
cd65d6b3de 添加清理过时对话的功能,更新聊天管理器以支持删除旧版本,优化查找最新对话的逻辑,并在聊天历史视图中添加清理按钮。 2025-07-03 12:15:07 +08:00
duanfuxiang
923d98cae9 更新多个转换提示,添加用户语言响应要求,并删除不再使用的简洁密集摘要提示。 2025-07-03 10:10:55 +08:00
duanfuxiang
a269258353 update trans 2025-07-03 09:03:00 +08:00
duanfuxiang
98bc810b86 update release logs 2025-07-02 11:59:16 +08:00
duanfuxiang
fea5b382cf update manage files 2025-07-02 08:09:22 +08:00
duanfuxiang
89bc10d16d use old prompt 2025-07-01 23:40:34 +08:00
duanfuxiang
b69f1e3865 update metadata 2025-07-01 17:33:59 +08:00
duanfuxiang
553f42652d update system prompt 2025-07-01 16:32:38 +08:00
duanfuxiang
dedf69ee6f update system 2025-07-01 08:57:44 +08:00
duanfuxiang
4f5b3f5d04 update save trans to database 2025-06-30 11:26:24 +08:00
duanfuxiang
f3a0252ab6 fix trans tool 2025-06-29 12:06:28 +08:00
duanfuxiang
772270863c update workspace 2025-06-29 12:06:28 +08:00
duanfuxiang
0df4e4edd3 update pglite, use opfs 2025-06-29 12:06:28 +08:00
duanfuxiang
a81d5b159e release log 2025-06-29 11:37:36 +08:00
duanfuxiang
7ffdb164b0 update, add copy in svg 2025-06-29 11:36:23 +08:00
duanfuxiang
1a508078be update chat view 2025-06-20 17:15:48 +08:00
duanfuxiang
7b48192bd9 update release logs 2025-06-20 17:13:46 +08:00
duanfuxiang
09aed46739 update, fixed chatview style 2025-06-20 13:51:53 +08:00
duanfuxiang
c35f884764 update chatview 2025-06-20 13:50:19 +08:00
duanfuxiang
35d1ddc979 update release log 2025-06-20 07:41:02 +08:00
duanfuxiang
263b4555cd update 2025-06-20 07:39:11 +08:00
duanfuxiang
57ef7e1f9f update release log 2025-06-18 12:21:15 +08:00
duanfuxiang
87c79b45d6 update chatview 2025-06-18 12:19:18 +08:00
duanfuxiang
b3e16d6bcb update release log 2025-06-18 09:51:01 +08:00
duanfuxiang
7416ddffaa update vector search result. 2025-06-18 09:47:51 +08:00
duanfuxiang
34d0f1f70c update test settings 2025-06-18 09:05:54 +08:00
duanfuxiang
9b4bfe97b4 update model select 2025-06-18 08:57:49 +08:00
duanfuxiang
c3cc81624f update models select 2025-06-18 08:02:32 +08:00
duanfuxiang
5c383c0634 save user custom input model name 2025-06-18 07:40:38 +08:00
duanfuxiang
2363e964ad update settings test 2025-06-17 17:57:45 +08:00
duanfuxiang
208e14f8e8 update some crash logs 2025-06-17 17:45:02 +08:00
duanfuxiang
d57551dd23 update release logs 2025-06-17 08:49:12 +08:00
duanfuxiang
bc4476a3f3 update release config update error 2025-06-17 08:46:58 +08:00
duanfuxiang
6beb29bf41 update release logs 2025-06-17 08:01:48 +08:00
duanfuxiang
31394a3c2c update release 2025-06-17 07:56:22 +08:00
143 changed files with 27420 additions and 4860 deletions

View File

@ -1,7 +0,0 @@
{
"tabWidth": 2,
"useTabs": false,
"semi": false,
"singleQuote": true,
"trailingComma": "all"
}

8
.prettierrc.json Normal file
View 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
View File

@ -0,0 +1 @@
/Users/dfx/.cache/trunk/repos/01f245efe699634f1bfdc90b794bb5b4/actions

1
.trunk/logs Symbolic link
View File

@ -0,0 +1 @@
/Users/dfx/.cache/trunk/repos/01f245efe699634f1bfdc90b794bb5b4/logs

1
.trunk/notifications Symbolic link
View File

@ -0,0 +1 @@
/Users/dfx/.cache/trunk/repos/01f245efe699634f1bfdc90b794bb5b4/notifications

1
.trunk/plugins/trunk Symbolic link
View File

@ -0,0 +1 @@
/Users/dfx/.cache/trunk/plugins/https---github-com-trunk-io-plugins/v1.7.2-4ebadccd80b22638

1
.trunk/tools Symbolic link
View File

@ -0,0 +1 @@
/Users/dfx/.cache/trunk/repos/01f245efe699634f1bfdc90b794bb5b4/tools

View File

@ -1,4 +1,72 @@
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"
features:
- "update chat history view"

178
README-dataview.md Normal file
View 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/)

View File

@ -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>
## Latest Version
[0.5.0](https://github.com/infiolab/infio-copilot/releases/tag/0.5.0) Enhanced performance and stability improvements, added MCP support
## ✨ What's New
[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
[0.2.4](https://github.com/infiolab/infio-copilot/releases/tag/0.2.4) Added multilingual support
* **🚀 Out-of-the-Box Embedding Model**
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
@ -24,10 +33,15 @@
| 📝 Autocomplete | Receive context-aware writing suggestions as you type |
| ✏️ Inline Editing | Edit your notes directly within the current file |
| 🔍 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 |
| 🎯 Custom Mode | Define personalized AI modes with specific behaviors |
| 🔌 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
@ -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 Inline Edit -> cmd + shift + K
![autocomplte](asserts/doc-set-hotkey.png)
7. If you need to chat with documents, you must configure an embedding model.
- Currently, only SiliconFlow, Alibaba, Google, and OpenAI platforms support embedding models.
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, SiliconFlow, Alibaba, Google, and OpenAI platforms support embedding models.
## Feedback and Support
We value your input and want to ensure you can easily share your thoughts and report any issues:

View File

@ -10,17 +10,23 @@ Infio Copilot 是一款可高度个人定制化的 Obsidian AI 插件,旨在
[![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/felixduan)
## 最新版本
[0.5.0](https://github.com/infiolab/infio-copilot/releases/tag/0.5.0) 增强性能和稳定性改进, 增加了 MC P支持
# Pro Version
## 最近更新
[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 交互 |
| 🖼️ 图片分析 | 上传并分析来自 vault 或本地系统的图片 |
| 语义搜索 | |
| ⌨️ 快捷命令 | 创建和管理自定义快捷命令,实现快速操作 |
| 🎯 自定义Mode | 定义具有特定行为的个性化 AI 模式 |
| 🔌 MCP | 管理模型上下文协议集成 |
| 🗂️ 工作空间 | 组织项目、研究和个人笔记,无缝切换上下文 |
| 💡 深度洞察 | 综合信息、发现连接、获得更深层次的理解 |
| 🔍 多维查询 | 基于时间、任务和元数据执行复杂查询 |
| ✍️ 新写作模式 | 重构的写作体验,提供直观、强大且无干扰的界面 |
### 🖋️ 内联编辑
@ -95,8 +105,6 @@ Infio Copilot 是一款可高度个人定制化的 Obsidian AI 插件,旨在
* Infio Copilot: Infio add selection to chat -> cmd + shift + L
* Infio Copilot: Infio Inline Edit -> cmd + shift + K
![autocomplte](asserts/doc-set-hotkey.png)
7. 如果需要 跟文档聊天 , 需要配置 embedding 模型
- 目前之后 SiliconFlow Alibaba Google OpenAI 平台支持嵌入模型
## 反馈与支持
我们重视您的意见,并希望确保您能轻松分享想法和报告问题:

View File

@ -2,7 +2,8 @@ import path from 'path'
import esbuild from 'esbuild'
import process from 'process'
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 banner = `/*
@ -19,11 +20,13 @@ const context = await esbuild.context({
},
entryPoints: ['src/main.ts'],
bundle: true,
plugins: [inlineWorkerPlugin({
define: {
'process': '{}', // 继承主配置
},
})],
plugins: [
inlineWorkerPlugin({
define: {
'process': '{}', // 继承主配置
},
})
],
external: [
'fs',
'obsidian',
@ -53,7 +56,7 @@ const context = await esbuild.context({
'process.env.NODE_ENV': JSON.stringify(prod ? 'production' : 'development'),
},
inject: [path.resolve('import-meta-url-shim.js')],
target: 'es2020',
target: 'es2022',
logLevel: 'info', // 'debug' for more detailed output
logOverride: {
'import-is-undefined': 'silent', // 忽略 import-is-undefined 警告
@ -62,10 +65,38 @@ const context = await esbuild.context({
treeShaking: true,
outfile: 'main.js',
minify: prod,
// 生产环境去掉调试语句与版权注释以进一步减小体积
drop: prod ? ['console', 'debugger'] : [],
legalComments: prod ? 'none' : 'inline',
metafile: true,
})
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)
} else {
await context.watch()

View File

@ -1,10 +1,11 @@
{
"id": "infio-copilot",
"name": "Infio Copilot",
"version": "0.6.10",
"version": "0.8.6",
"minAppVersion": "0.15.0",
"description": "A Cursor-inspired AI assistant for notes that offers smart autocomplete and interactive chat with your selected notes",
"author": "Felix.D",
"authorUrl": "https://github.com/infiolab",
"isDesktopOnly": true
"isDesktopOnly": false
}

View File

@ -1,12 +1,14 @@
{
"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",
"main": "main.js",
"scripts": {
"bundle-pglite": "node scripts/bundle-pglite-resources.mjs",
"dev": "npm run bundle-pglite && node esbuild.config.mjs",
"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",
"type:check": "tsc --noEmit",
"test": "jest",
@ -33,6 +35,7 @@
"builtin-modules": "3.3.0",
"drizzle-kit": "^0.26.2",
"esbuild": "0.17.3",
"esbuild-visualizer": "^0.7.0",
"eslint": "^8.57.1",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-css-modules": "^2.12.0",
@ -41,9 +44,12 @@
"eslint-plugin-no-inline-styles": "^1.0.5",
"eslint-plugin-react": "^7.37.3",
"eslint-plugin-react-hooks": "^5.0.0",
"install": "^0.13.0",
"jest": "^29.7.0",
"jest-environment-jsdom": "^29.7.0",
"npm": "^11.4.2",
"obsidian": "^1.8.7",
"obsidian-dataview": "^0.5.68",
"prettier": "^3.4.2",
"stylelint": "^16.12.0",
"ts-jest": "^29.2.5",
@ -53,12 +59,19 @@
"dependencies": {
"@anthropic-ai/sdk": "^0.27.3",
"@codemirror/basic-setup": "^0.20.0",
"@codemirror/commands": "^6.7.1",
"@codemirror/lang-json": "^6.0.1",
"@codemirror/lang-markdown": "^6.3.2",
"@codemirror/language": "^6.11.2",
"@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",
"@google/genai": "^1.2.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/react": "^0.17.1",
"@lexical/rich-text": "^0.27.2",
@ -70,9 +83,12 @@
"@radix-ui/react-select": "^2.1.6",
"@radix-ui/react-tooltip": "^1.1.3",
"@tanstack/react-query": "^5.56.2",
"@types/mermaid": "^9.2.0",
"@xenova/transformers": "^2.17.2",
"axios": "^1.8.3",
"chokidar": "^4.0.3",
"clsx": "^2.1.1",
"codemirror": "^6.0.1",
"delay": "^6.0.0",
"diff": "^7.0.0",
"diff-match-patch": "^1.0.5",
@ -88,16 +104,19 @@
"js-tiktoken": "^1.0.15",
"js-yaml": "^4.1.0",
"json5": "^2.2.3",
"langchain": "^0.3.2",
"jszip": "^3.10.1",
"langchain": "^0.3.15",
"lexical": "^0.17.1",
"lodash": "^4.17.21",
"lodash.debounce": "^4.0.8",
"lodash.isequal": "^4.5.0",
"lru-cache": "^10.1.0",
"lucide-react": "^0.447.0",
"mermaid": "^11.6.0",
"micromatch": "^4.0.5",
"minimatch": "^10.0.1",
"neverthrow": "^6.1.0",
"node-machine-id": "^1.1.12",
"openai": "^4.73.0",
"p-limit": "^6.1.0",
"parse5": "^7.1.2",
@ -112,10 +131,21 @@
"reconnecting-eventsource": "^1.6.4",
"rehype-raw": "^7.0.0",
"remark-gfm": "^4.0.0",
"remove-markdown": "^0.6.2",
"sanitize-basename": "^2.0.2",
"shell-env": "^4.0.1",
"simple-git": "^3.27.0",
"smart-embed-model": "^1.0.7",
"string-similarity": "^4.0.4",
"styled-components": "^6.1.19",
"unsanitize-basename": "^2.0.1",
"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

File diff suppressed because it is too large Load Diff

182
src/BaseFileView.tsx Normal file
View 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;
}
}

View File

@ -1,3 +1,4 @@
// @ts-nocheck
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { ItemView, WorkspaceLeaf } from 'obsidian'
import React from 'react'
@ -8,12 +9,14 @@ import { CHAT_VIEW_TYPE } from './constants'
import { AppProvider } from './contexts/AppContext'
import { DarkModeProvider } from './contexts/DarkModeContext'
import { DatabaseProvider } from './contexts/DatabaseContext'
import { DataviewProvider } from './contexts/DataviewContext'
import { DialogProvider } from './contexts/DialogContext'
import { DiffStrategyProvider } from './contexts/DiffStrategyContext'
import { LLMProvider } from './contexts/LLMContext'
import { McpHubProvider } from './contexts/McpHubContext'
import { RAGProvider } from './contexts/RAGContext'
import { SettingsProvider } from './contexts/SettingsContext'
import { TransProvider } from './contexts/TransContext'
import InfioPlugin from './main'
import { MentionableBlockData } from './types/mentionable'
import { InfioSettings } from './types/settings'
@ -29,7 +32,9 @@ export class ChatView extends ItemView {
private plugin: InfioPlugin,
) {
super(leaf)
// @ts-ignore
this.settings = plugin.settings
// @ts-ignore
this.initialChatProps = plugin.initChatProps
}
@ -57,8 +62,15 @@ export class ChatView extends ItemView {
}
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) {
this.root = createRoot(this.containerEl.children[1])
this.root = createRoot(containerElement)
}
const queryClient = new QueryClient({
@ -76,8 +88,10 @@ export class ChatView extends ItemView {
<AppProvider app={this.app}>
<SettingsProvider
settings={this.settings}
// @ts-ignore
setSettings={(newSettings) => this.plugin.setSettings(newSettings)}
addSettingsChangeListener={(listener) =>
// @ts-ignore
this.plugin.addSettingsListener(listener)
}
>
@ -88,17 +102,21 @@ export class ChatView extends ItemView {
>
<DiffStrategyProvider diffStrategy={this.plugin.diffStrategy}>
<RAGProvider getRAGEngine={() => this.plugin.getRAGEngine()}>
<McpHubProvider getMcpHub={() => this.plugin.getMcpHub()}>
<QueryClientProvider client={queryClient}>
<React.StrictMode>
<DialogProvider
container={this.containerEl.children[1] as HTMLElement}
>
<Chat ref={this.chatRef} {...this.initialChatProps} />
</DialogProvider>
</React.StrictMode>
</QueryClientProvider>
</McpHubProvider>
<TransProvider getTransEngine={() => this.plugin.getTransEngine()}>
<DataviewProvider dataviewManager={this.plugin.dataviewManager}>
<McpHubProvider getMcpHub={() => this.plugin.getMcpHub()}>
<QueryClientProvider client={queryClient}>
<React.StrictMode>
<DialogProvider
container={containerElement}
>
<Chat ref={this.chatRef} {...this.initialChatProps} />
</DialogProvider>
</React.StrictMode>
</QueryClientProvider>
</McpHubProvider>
</DataviewProvider>
</TransProvider>
</RAGProvider>
</DiffStrategyProvider>
</DatabaseProvider>

32
src/JsonFileView.tsx Normal file
View 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;
}
}

View File

@ -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 React, { useMemo, useRef, useState } from 'react'
import { useSettings } from '../../contexts/SettingsContext'
import { useChatHistory } from '../../hooks/use-chat-history'
import { t } from '../../lang/helpers'
import { ChatConversationMeta } from '../../types/chat'
@ -23,11 +24,21 @@ const ChatHistoryView = ({
deleteConversation,
updateConversationTitle,
chatList,
cleanupOutdatedChats,
} = useChatHistory()
// search term
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
const [editingConversationId, setEditingConversationId] = useState<string | null>(null)
@ -37,21 +48,56 @@ const ChatHistoryView = ({
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
const handleSearch = (e: React.ChangeEvent<HTMLInputElement>) => {
setSearchTerm(e.target.value)
}
// toggle workspace filter
const toggleWorkspaceFilter = () => {
setFilterByWorkspace(!filterByWorkspace)
}
// filter conversations list
const filteredConversations = useMemo(() => {
if (!searchTerm.trim()) {
return chatList
let filtered = chatList
// Apply search filter
if (searchTerm.trim()) {
filtered = filtered.filter(
conversation =>
conversation.title.toLowerCase().includes(searchTerm.toLowerCase())
)
}
return chatList.filter(
conversation =>
conversation.title.toLowerCase().includes(searchTerm.toLowerCase())
)
}, [chatList, searchTerm])
// Apply workspace filter
if (filterByWorkspace) {
filtered = filtered.filter(
conversation => conversation.workspace === currentWorkspace
)
}
return filtered
}, [chatList, searchTerm, filterByWorkspace, currentWorkspace])
// toggle selection mode
const toggleSelectionMode = () => {
@ -99,12 +145,12 @@ const ChatHistoryView = ({
// batch delete selected conversations
const handleBatchDelete = async () => {
if (selectedConversations.size === 0) {
new Notice('请先选择要删除的对话')
new Notice(String(t('chat.history.selectFirst')))
return
}
// show confirmation
const confirmed = confirm(`确定要删除选中的 ${selectedConversations.size} 个对话吗?此操作不可撤销。`)
const confirmed = confirm(String(t('chat.history.batchDeleteConfirm', { count: selectedConversations.size })))
if (!confirmed) {
return
}
@ -126,10 +172,10 @@ const ChatHistoryView = ({
// show results
if (deletedIds.length > 0) {
new Notice(`成功删除 ${deletedIds.length} 个对话`)
new Notice(String(t('chat.history.batchDeleteSuccess', { count: deletedIds.length })))
}
if (errors.length > 0) {
new Notice(`${errors.length} 个对话删除失败`)
new Notice(String(t('chat.history.batchDeleteFailed', { count: errors.length })))
}
// clear selections
@ -192,13 +238,21 @@ const ChatHistoryView = ({
<h2>{t('chat.history.title')}</h2>
</div>
<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
onClick={toggleSelectionMode}
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} />
{selectionMode ? '取消' : '多选'}
{selectionMode ? t('chat.history.cancel') : t('chat.history.multiSelect')}
</button>
</div>
</div>
@ -206,7 +260,7 @@ const ChatHistoryView = ({
{/* description */}
<div className="infio-chat-history-tip">
{selectionMode
? `选择模式 - 已选择 ${selectedConversations.size} 个对话`
? String(t('chat.history.selectionMode', { count: selectedConversations.size }))
: String(t('chat.history.description'))
}
</div>
@ -222,12 +276,12 @@ const ChatHistoryView = ({
{isAllSelected ? (
<>
<CheckSquare size={16} />
{t('chat.history.unselectAll')}
</>
) : (
<>
<Square size={16} />
{t('chat.history.selectAll')}
</>
)}
</button>
@ -239,7 +293,7 @@ const ChatHistoryView = ({
className="infio-chat-history-batch-delete-btn"
>
<Trash2 size={16} />
({selectedConversations.size})
{t('chat.history.batchDelete')} ({selectedConversations.size})
</button>
</div>
</div>
@ -257,6 +311,18 @@ const ChatHistoryView = ({
/>
</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 */}
<div className="infio-chat-history-list">
{filteredConversations.length === 0 ? (
@ -325,6 +391,11 @@ const ChatHistoryView = ({
{formatDate(conversation.updatedAt)}
</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>
{!selectionMode && (
<div className="infio-chat-history-actions">
@ -398,6 +469,8 @@ const ChatHistoryView = ({
flex-shrink: 0;
}
.infio-chat-history-filter-btn,
.infio-chat-history-cleanup-btn,
.infio-chat-history-selection-btn {
display: flex !important;
align-items: center;
@ -415,11 +488,14 @@ const ChatHistoryView = ({
box-sizing: border-box;
}
.infio-chat-history-filter-btn:hover,
.infio-chat-history-cleanup-btn:hover,
.infio-chat-history-selection-btn:hover {
background-color: var(--background-modifier-hover, #f5f5f5);
border-color: var(--background-modifier-border-hover, #d0d0d0);
}
.infio-chat-history-filter-btn.active,
.infio-chat-history-selection-btn.active {
background-color: var(--interactive-accent, #007acc);
color: var(--text-on-accent, #ffffff);
@ -509,7 +585,6 @@ const ChatHistoryView = ({
border: 1px solid var(--background-modifier-border);
border-radius: var(--radius-s);
padding: 6px 12px;
margin-bottom: var(--size-4-3);
transition: all 0.2s ease;
height: 36px;
max-width: 100%;
@ -645,6 +720,13 @@ const ChatHistoryView = ({
font-size: 12px;
}
.infio-chat-history-workspace {
color: var(--text-muted);
font-size: 11px;
margin-top: 2px;
opacity: 0.8;
}
.infio-chat-history-actions {
display: flex;
gap: 4px;
@ -738,6 +820,38 @@ const ChatHistoryView = ({
.infio-chat-history-cancel-btn: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>
</div>

View File

@ -2,8 +2,8 @@ import * as path from 'path'
import { BaseSerializedNode } from '@lexical/clipboard/clipboard'
import { useMutation } from '@tanstack/react-query'
import { CircleStop, History, NotebookPen, Plus, Search, Server, SquareSlash, Undo } from 'lucide-react'
import { App, Notice } from 'obsidian'
import { Box, Lightbulb, CircleStop, History, NotebookPen, Plus, Search, Server, SquareSlash, Undo } from 'lucide-react'
import { App, Notice, TFile, TFolder, WorkspaceLeaf } from 'obsidian'
import {
forwardRef,
useCallback,
@ -15,14 +15,16 @@ import {
} from 'react'
import { v4 as uuidv4 } from 'uuid'
import { ApplyViewState } from '../../ApplyView'
import { ApplyView, ApplyViewState } from '../../ApplyView'
import { APPLY_VIEW_TYPE } from '../../constants'
import { useApp } from '../../contexts/AppContext'
import { useDataview } from '../../contexts/DataviewContext'
import { useDiffStrategy } from '../../contexts/DiffStrategyContext'
import { useLLM } from '../../contexts/LLMContext'
import { useMcpHub } from '../../contexts/McpHubContext'
import { useRAG } from '../../contexts/RAGContext'
import { useSettings } from '../../contexts/SettingsContext'
import { useTrans } from '../../contexts/TransContext'
import { matchSearchUsingCorePlugin } from '../../core/file-search/match/coreplugin-match'
import { matchSearchUsingOmnisearch } from '../../core/file-search/match/omnisearch-match'
import { regexSearchUsingCorePlugin } from '../../core/file-search/regex/coreplugin-regex'
@ -33,9 +35,13 @@ import {
LLMBaseUrlNotSetException,
LLMModelNotSetException,
} 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 { useCustomModes } from '../../hooks/use-custom-mode'
import { t } from '../../lang/helpers'
import { PreviewView } from '../../PreviewView'
import { ApplyStatus, ToolArgs } from '../../types/apply'
import { ChatMessage, ChatUserMessage } from '../../types/chat'
import {
@ -45,7 +51,7 @@ import {
MentionableCurrentFile,
} from '../../types/mentionable'
import { ApplyEditToFile, SearchAndReplace } from '../../utils/apply'
import { listFilesAndFolders } from '../../utils/glob-utils'
import { listFilesAndFolders, semanticSearchFiles } from '../../utils/glob-utils'
import {
getMentionableKey,
serializeMentionable,
@ -55,8 +61,8 @@ import { openSettingsModalWithError } from '../../utils/open-settings-modal'
import { PromptGenerator, addLineNumbers } from '../../utils/prompt-generator'
// Removed empty line above, added one below for group separation
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 { editorStateToPlainText } from './chat-input/utils/editor-state-to-plain-text'
import ChatHistoryView from './ChatHistoryView'
@ -64,6 +70,7 @@ import CommandsView from './CommandsView'
import CustomModeView from './CustomModeView'
import FileReadResults from './FileReadResults'
import HelloInfo from './HelloInfo'
import InsightView from './InsightView'
import MarkdownReasoningBlock from './Markdown/MarkdownReasoningBlock'
import McpHubView from './McpHubView'; // Moved after MarkdownReasoningBlock
import QueryProgress, { QueryProgressState } from './QueryProgress'
@ -72,6 +79,8 @@ import SearchView from './SearchView'
import SimilaritySearchResults from './SimilaritySearchResults'
import UserMessageView from './UserMessageView'
import WebsiteReadResults from './WebsiteReadResults'
import WorkspaceSelect from './WorkspaceSelect'
import WorkspaceView from './WorkspaceView'
// Add an empty line here
const getNewInputMessage = (app: App, defaultMention: string): ChatUserMessage => {
@ -113,7 +122,9 @@ const Chat = forwardRef<ChatRef, ChatProps>((props, ref) => {
const app = useApp()
const { settings, setSettings } = useSettings()
const { getRAGEngine } = useRAG()
const { getTransEngine } = useTrans()
const diffStrategy = useDiffStrategy()
const dataviewManager = useDataview()
const { getMcpHub } = useMcpHub()
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)
}, [getRAGEngine, app, settings, diffStrategy, customModePrompts, customModeList, getMcpHub])
const workspaceManager = useMemo(() => {
return new WorkspaceManager(app)
}, [app])
const [inputMessage, setInputMessage] = useState<ChatUserMessage>(() => {
const newMessage = getNewInputMessage(app, settings.defaultMention)
if (props.selectedBlock) {
@ -178,7 +193,7 @@ 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[]>([])
@ -414,6 +429,14 @@ const Chat = forwardRef<ChatRef, ChatProps>((props, ref) => {
if (toolArgs.type === 'write_to_file') {
let newFile = false
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, '')
newFile = true
}
@ -602,8 +625,24 @@ const Chat = forwardRef<ChatRef, ChatProps>((props, ref) => {
}
};
} 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 {
type: 'list_files',
applyMsgId,
@ -666,24 +705,25 @@ const Chat = forwardRef<ChatRef, ChatProps>((props, ref) => {
}
}
} else if (toolArgs.type === 'semantic_search_files') {
const scope_folders = toolArgs.filepath
&& toolArgs.filepath !== ''
&& toolArgs.filepath !== '.'
&& toolArgs.filepath !== '/'
? { 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}'`
// 获取当前工作区
let currentWorkspace: Workspace | null = null
if (settings.workspace && settings.workspace !== 'vault') {
currentWorkspace = await workspaceManager.findByName(String(settings.workspace))
}
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 {
type: 'semantic_search_files',
applyMsgId,
@ -791,6 +831,246 @@ const Chat = forwardRef<ChatRef, ChatProps>((props, ref) => {
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) {
console.error('Failed to apply changes', error)
@ -854,6 +1134,8 @@ const Chat = forwardRef<ChatRef, ChatProps>((props, ref) => {
useEffect(() => {
setFocusedMessageId(inputMessage.id)
// 初始化当前活动文件引用
currentActiveFileRef.current = app.workspace.getActiveFile()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
@ -871,10 +1153,27 @@ const Chat = forwardRef<ChatRef, ChatProps>((props, ref) => {
updateConversationAsync()
}, [currentConversationId, chatMessages, createOrUpdateConversation])
// 保存当前活动文件的引用,用于比较是否真的发生了变化
const currentActiveFileRef = useRef<TFile | null>(null)
// Updates the currentFile of the focused message (input or chat history)
// 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()
// 🎯 关键优化:只有当活动文件真正发生变化时才更新
if (activeFile === currentActiveFileRef.current) {
return // 文件没有变化,不需要更新
}
// 更新文件引用
currentActiveFileRef.current = activeFile
if (!activeFile) return
const mentionable: Omit<MentionableCurrentFile, 'id'> = {
@ -986,7 +1285,9 @@ const Chat = forwardRef<ChatRef, ChatProps>((props, ref) => {
<div className="infio-chat-container">
{/* header view */}
<div className="infio-chat-header">
<ModeSelect />
<div className="infio-chat-header-title">
<WorkspaceSelect />
</div>
<div className="infio-chat-header-buttons">
<button
onClick={() => {
@ -1021,6 +1322,30 @@ const Chat = forwardRef<ChatRef, ChatProps>((props, ref) => {
>
<Search size={18} color={tab === 'search' ? 'var(--text-accent)' : 'var(--text-color)'} />
</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
onClick={() => {
// switch between chat and prompts
@ -1128,18 +1453,20 @@ const Chat = forwardRef<ChatRef, ChatProps>((props, ref) => {
/>
</div>
) : (
<UserMessageView
content={message.content}
mentionables={message.mentionables}
onEdit={() => {
setEditingMessageId(message.id)
setFocusedMessageId(message.id)
// 延迟聚焦,确保组件已渲染
setTimeout(() => {
chatUserInputRefs.current.get(message.id)?.focus()
}, 0)
}}
/>
<ErrorBoundary>
<UserMessageView
content={message.content}
mentionables={message.mentionables}
onEdit={() => {
setEditingMessageId(message.id)
setFocusedMessageId(message.id)
// 延迟聚焦,确保组件已渲染
setTimeout(() => {
chatUserInputRefs.current.get(message.id)?.focus()
}, 0)
}}
/>
</ErrorBoundary>
)}
{message.fileReadResults && (
<FileReadResults
@ -1253,6 +1580,14 @@ const Chat = forwardRef<ChatRef, ChatProps>((props, ref) => {
}}
/>
</div>
) : tab === 'workspace' ? (
<div className="infio-chat-commands">
<WorkspaceView />
</div>
) : tab === 'insights' ? (
<div className="infio-chat-commands">
<InsightView />
</div>
) : (
<div className="infio-chat-commands">
<McpHubView />

View File

@ -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 { t } from '../../lang/helpers';
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 navigationItems = [
{
label: '语义搜索',
description: '使用 RAG 在笔记库中进行语义搜索',
label: t('chat.navigation.history'),
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} />,
action: () => onNavigate('search'),
},
{
label: t('chat.navigation.commands'),
description: t('chat.navigation.commandsDesc'),
icon: <SquareSlash size={20} />,
action: () => onNavigate('commands'),
label: t('chat.navigation.insights'),
description: t('chat.navigation.insightsDesc'),
icon: <Lightbulb size={20} />,
action: () => onNavigate('insights'),
},
{
label: t('chat.navigation.customMode'),
description: t('chat.navigation.customModeDesc'),
icon: <NotebookPen size={20} />,
action: () => onNavigate('custom-mode'),
},
{
label: t('chat.navigation.mcp'),
description: t('chat.navigation.mcpDesc'),
icon: <Server size={20} />,
action: () => onNavigate('mcp'),
}
// {
// label: t('chat.navigation.commands'),
// description: t('chat.navigation.commandsDesc'),
// icon: <SquareSlash size={20} />,
// action: () => onNavigate('commands'),
// },
// {
// label: t('chat.navigation.customMode'),
// description: t('chat.navigation.customModeDesc'),
// icon: <NotebookPen size={20} />,
// action: () => onNavigate('custom-mode'),
// },
// {
// label: t('chat.navigation.mcp'),
// description: t('chat.navigation.mcpDesc'),
// icon: <Server size={20} />,
// action: () => onNavigate('mcp'),
// }
];
return (

File diff suppressed because it is too large Load Diff

View File

@ -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>
)
}

View 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>
)
}

View 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>
)
)
}

View File

@ -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 { useDarkModeContext } from "../../../contexts/DarkModeContext"

View File

@ -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>
)
}

View File

@ -1,13 +1,13 @@
import * as Tooltip from '@radix-ui/react-tooltip';
import { Check, CircleCheckBig, CircleHelp, CopyIcon, FilePlus2 } from 'lucide-react';
import { ReactNode, useState } from 'react';
import ReactMarkdown from 'react-markdown';
import rehypeRaw from 'rehype-raw';
import { useApp } from 'src/contexts/AppContext';
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 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 [created, setCreated] = useState(false)
@ -138,12 +138,10 @@ const MarkdownWithIcons = ({
<>
<div className={`${className}`}>
<span>{iconName && renderIcon()} {renderTitle()}</span>
<ReactMarkdown
<RawMarkdownBlock
content={markdownContent}
className={`${className}`}
rehypePlugins={[rehypeRaw]}
>
{markdownContent}
</ReactMarkdown>
/>
</div>
{markdownContent && finish && iconName === "attempt_completion" &&
<div className="infio-chat-message-actions">

View 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)

View 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>
)
}

View File

@ -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 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) => {
setExpandedServers(prev => ({ ...prev, [serverKey]: !prev[serverKey] }));
if (!expandedServers[serverKey] && !activeServerDetailTab[serverKey]) {
@ -196,7 +207,15 @@ const McpHubView = () => {
<div className="infio-mcp-hub-container">
{/* Header Section */}
<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>
{/* MCP Settings */}
@ -218,6 +237,15 @@ const McpHubView = () => {
</a>
</p>
</div>
{/* Configuration File Access */}
<button
onClick={handleOpenConfigFile}
className="infio-mcp-config-button"
>
<ExternalLink size={16} />
<span>{t('mcpHub.openConfigFile')}</span>
</button>
</div>
{/* Create New Server Section */}
@ -273,6 +301,11 @@ const McpHubView = () => {
</div>
) : (
mcpServers.map(server => {
// Add null check for server object
if (!server || !server.name) {
return null;
}
const serverKey = `${server.name}-${server.source || 'global'}`;
const isExpanded = !!expandedServers[serverKey];
const currentDetailTab = activeServerDetailTab[serverKey] || 'tools';
@ -285,7 +318,7 @@ const McpHubView = () => {
{isExpanded ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
</div>
<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 className="infio-mcp-hub-actions" onClick={(e) => e.stopPropagation()}>
@ -352,7 +385,7 @@ const McpHubView = () => {
<div className="infio-mcp-tab-content">
{currentDetailTab === 'tools' && (
<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>
)}
{currentDetailTab === 'resources' && (
@ -443,6 +476,57 @@ const McpHubView = () => {
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 */
.infio-mcp-search-section {
margin-bottom: 16px;

View File

@ -1,5 +1,4 @@
import React, { useMemo } from 'react'
import Markdown from 'react-markdown'
import { ApplyStatus, ToolArgs } from '../../types/apply'
import {
@ -8,19 +7,23 @@ import {
} from '../../utils/parse-infio-block'
import MarkdownApplyDiffBlock from './Markdown/MarkdownApplyDiffBlock'
import MarkdownDataviewQueryBlock from './Markdown/MarkdownDataviewQueryBlock'
import MarkdownEditFileBlock from './Markdown/MarkdownEditFileBlock'
import MarkdownFetchUrlsContentBlock from './Markdown/MarkdownFetchUrlsContentBlock'
import MarkdownListFilesBlock from './Markdown/MarkdownListFilesBlock'
import MarkdownManageFilesBlock from './Markdown/MarkdownManageFilesBlock'
import MarkdownMatchSearchFilesBlock from './Markdown/MarkdownMatchSearchFilesBlock'
import MarkdownReadFileBlock from './Markdown/MarkdownReadFileBlock'
import MarkdownReasoningBlock from './Markdown/MarkdownReasoningBlock'
import MarkdownMatchSearchFilesBlock from './Markdown/MarkdownMatchSearchFilesBlock'
import MarkdownRegexSearchFilesBlock from './Markdown/MarkdownRegexSearchFilesBlock'
import MarkdownSearchAndReplace from './Markdown/MarkdownSearchAndReplace'
import MarkdownSearchWebBlock from './Markdown/MarkdownSearchWebBlock'
import MarkdownSemanticSearchFilesBlock from './Markdown/MarkdownSemanticSearchFilesBlock'
import MarkdownSwitchModeBlock from './Markdown/MarkdownSwitchModeBlock'
import MarkdownToolResult from './Markdown/MarkdownToolResult'
import MarkdownTransformationToolBlock from './Markdown/MarkdownTransformationToolBlock'
import MarkdownWithIcons from './Markdown/MarkdownWithIcon'
import RawMarkdownBlock from './Markdown/RawMarkdownBlock'
import UseMcpToolBlock from './Markdown/UseMcpToolBlock'
function ReactMarkdown({
@ -41,15 +44,16 @@ function ReactMarkdown({
return (
<>
{blocks.map((block, index) =>
block.type === 'thinking' ? (
<Markdown key={"markdown-" + index} className="infio-markdown">
{block.content}
</Markdown>
) : block.type === 'think' ? (
block.type === 'think' ? (
<MarkdownReasoningBlock
key={"reasoning-" + index}
reasoningContent={block.content}
/>
) : block.type === 'thinking' ? (
<RawMarkdownBlock
key={"plan-" + index}
content={block.content}
/>
) : block.type === 'write_to_file' ? (
<MarkdownEditFileBlock
key={"write-to-file-" + index}
@ -200,15 +204,44 @@ function ReactMarkdown({
parameters={block.parameters}
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' ? (
<MarkdownToolResult
key={"tool-result-" + index}
content={block.content}
/>
) : (
<Markdown key={"markdown-" + index} className="infio-markdown">
{block.content}
</Markdown>
<RawMarkdownBlock
key={"markdown-" + index}
content={block.content}
className="infio-markdown"
/>
),
)}
</>

File diff suppressed because it is too large Load Diff

View File

@ -49,25 +49,25 @@ const UserMessageView: React.FC<UserMessageViewProps> = ({
<span key={index} className="infio-mention-tag">
{Icon && <Icon size={12} />}
{mentionable.type === 'current-file' && (
<span>{mentionable.file.name}</span>
<span>{mentionable.file?.name || 'Not Found'}</span>
)}
{mentionable.type === 'vault' && (
<span>Vault</span>
)}
{mentionable.type === 'block' && (
<span>{mentionable.file.name}</span>
<span>{mentionable.file?.name || 'Not Found'}</span>
)}
{mentionable.type === 'file' && (
<span>{mentionable.file.name}</span>
<span>{mentionable.file?.name || 'Not Found'}</span>
)}
{mentionable.type === 'folder' && (
<span>{mentionable.folder.name}</span>
<span>{mentionable.folder?.name || 'Not Found'}</span>
)}
{mentionable.type === 'url' && (
<span>{mentionable.url}</span>
)}
{mentionable.type === 'image' && (
<span>{mentionable.name}</span>
<span>{mentionable.name || 'Image'}</span>
)}
</span>
)
@ -120,7 +120,6 @@ const UserMessageView: React.FC<UserMessageViewProps> = ({
border: 2px solid var(--background-modifier-border);
border-radius: var(--radius-s);
padding: calc(var(--size-2-2) + 1px);
min-height: 62px;
gap: var(--size-2-2);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
transition: all 0.15s ease-in-out;

View 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

View 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

View 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

View File

@ -1,8 +1,7 @@
import { ImageIcon } from 'lucide-react'
import { ImageUp } from 'lucide-react'
import { TFile } from 'obsidian'
import { useApp } from '../../../contexts/AppContext'
import { t } from '../../../lang/helpers'
import { ImageSelectorModal } from '../../modals/ImageSelectorModal'
export function ImageUploadButton({
@ -33,9 +32,9 @@ export function ImageUploadButton({
onClick={handleClick}
>
<div className="infio-chat-user-input-submit-button-icons">
<ImageIcon size={12} />
<ImageUp size={14} />
</div>
<div>{t('chat.input.image')}</div>
{/* <div>{t('chat.input.image')}</div> */}
</button>
)
}

View File

@ -31,6 +31,7 @@ import OnMutationPlugin, {
} from './plugins/on-mutation/OnMutationPlugin'
export type LexicalContentEditableProps = {
rootTheme?: string
editorRef: RefObject<LexicalEditor>
contentEditableRef: RefObject<HTMLDivElement>
onChange?: (content: SerializedEditorState) => void
@ -52,6 +53,7 @@ export type LexicalContentEditableProps = {
}
export default function LexicalContentEditable({
rootTheme,
editorRef,
contentEditableRef,
onChange,
@ -68,7 +70,7 @@ export default function LexicalContentEditable({
const initialConfig: InitialConfigType = {
namespace: 'LexicalContentEditable',
theme: {
root: 'infio-chat-lexical-content-editable-root',
root: rootTheme || 'infio-chat-lexical-content-editable-root',
paragraph: 'infio-chat-lexical-content-editable-paragraph',
},
nodes: [MentionNode],

View File

@ -64,7 +64,7 @@ function FileBadge({
className="infio-chat-user-input-file-badge-name-icon"
/>
)}
<span>{mentionable.file.name}</span>
<span>{mentionable.file?.name || 'Unknown File'}</span>
</div>
</BadgeBase>
)
@ -91,7 +91,7 @@ function FolderBadge({
className="infio-chat-user-input-file-badge-name-icon"
/>
)}
<span>{mentionable.folder.name}</span>
<span>{mentionable.folder?.name || 'Unknown Folder'}</span>
</div>
</BadgeBase>
)
@ -147,7 +147,7 @@ function CurrentFileBadge({
className="infio-chat-user-input-file-badge-name-icon"
/>
)}
<span>{mentionable.file.name}</span>
<span>{mentionable.file?.name || 'Unknown File'}</span>
</div>
<div className="infio-chat-user-input-file-badge-name-block-suffix">
{' (Current file)'}
@ -177,7 +177,7 @@ function BlockBadge({
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 className="infio-chat-user-input-file-badge-name-block-suffix">
{` (${mentionable.startLine}:${mentionable.endLine})`}
@ -234,7 +234,7 @@ function ImageBadge({
className="infio-chat-user-input-file-badge-name-icon"
/>
)}
<span>{mentionable.name}</span>
<span>{mentionable.name || 'Unknown Image'}</span>
</div>
</BadgeBase>
)

View File

@ -1,5 +1,5 @@
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 { useSettings } from '../../../contexts/SettingsContext'
@ -21,39 +21,179 @@ export function ModeSelect() {
setMode(settings.mode)
}, [settings.mode])
return (
<DropdownMenu.Root open={isOpen} onOpenChange={setIsOpen}>
<DropdownMenu.Trigger className="infio-chat-input-model-select">
<div className="infio-chat-input-model-select__icon">
{isOpen ? <ChevronUp size={12} /> : <ChevronDown size={12} />}
</div>
<div className="infio-chat-input-model-select__model-name">
{allModes.find((m) => m.slug === mode)?.name}
</div>
</DropdownMenu.Trigger>
// 为默认模式定义快捷键提示
const getShortcutText = (slug: string) => {
switch (slug) {
case 'write':
return 'Ctrl+Shift+.'
case 'ask':
return 'Ctrl+Shift+,'
case 'research':
return 'Ctrl+Shift+/'
default:
return null
}
}
<DropdownMenu.Portal>
<DropdownMenu.Content
className="infio-popover">
<ul>
{allModes.map((mode) => (
<DropdownMenu.Item
key={mode.slug}
onSelect={() => {
setMode(mode.slug)
setSettings({
...settings,
mode: mode.slug,
})
}}
asChild
>
<li>{mode.name}</li>
</DropdownMenu.Item>
))}
</ul>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu.Root>
// 为默认模式定义图标
const getModeIcon = (slug: string) => {
switch (slug) {
case 'ask':
return <MessageSquare size={14} />
case 'write':
return <SquarePen size={14} />
case 'research':
return <Search size={14} />
default:
return null
}
}
return (
<>
<DropdownMenu.Root open={isOpen} onOpenChange={setIsOpen}>
<DropdownMenu.Trigger className="infio-chat-input-mode-select">
<span className="infio-mode-icon">{getModeIcon(mode)}</span>
<div className="infio-chat-input-mode-select__model-name">
{allModes.find((m) => m.slug === mode)?.name}
</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>
</>
)
}

View File

@ -1,12 +1,24 @@
import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
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 { useSettings } from '../../../contexts/SettingsContext'
import { t } from '../../../lang/helpers'
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 = {
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 [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 [isLoading, setIsLoading] = useState(true)
const [searchTerm, setSearchTerm] = useState("")
const [selectedIndex, setSelectedIndex] = useState(0)
const inputRef = useRef<HTMLInputElement>(null)
const providers = GetAllProviders()
const providers = useMemo(() => {
if (modelType === 'embedding') {
return GetEmbeddingProviders()
}
return GetAllProviders()
}, [modelType])
useEffect(() => {
const fetchModels = async () => {
setIsLoading(true)
try {
const models = await GetProviderModelIds(modelProvider, settings)
setModelIds(models)
if (modelType === 'embedding') {
const models = GetEmbeddingProviderModelIds(modelProvider)
setModelIds(models)
} else {
const models = await GetProviderModelsWithSettings(modelProvider, settings)
setModelIds(Object.keys(models))
}
} catch (error) {
console.error('Failed to fetch provider models:', error)
setModelIds([])
@ -164,16 +220,30 @@ export function ModelSelect() {
fetchModels()
}, [modelProvider, settings])
// Sync chat model id & chat model provider
// Sync model id & model provider based on modelType
useEffect(() => {
setModelProvider(settings.chatModelProvider)
setChatModelId(settings.chatModelId)
}, [settings.chatModelProvider, settings.chatModelId])
setModelProvider(currentModelProvider)
setChatModelId(currentModelId)
}, [currentModelProvider, currentModelId])
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) => {
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) => ({
@ -182,17 +252,17 @@ export function ModelSelect() {
provider: modelProvider,
isCollected: isInCollected(id),
}))
}, [modelIds, modelProvider, settings.collectedChatModels])
}, [modelIds, modelProvider, modelType, settings.collectedChatModels, settings.collectedInsightModels, settings.collectedApplyModels, settings.collectedEmbeddingModels])
const fuse = useMemo(() => {
return new Fuse<SearchableItem>(searchableItems, {
keys: ["html"],
threshold: 0.6,
threshold: 1,
shouldSort: true,
isCaseSensitive: false,
ignoreLocation: false,
includeMatches: true,
minMatchCharLength: 1,
minMatchCharLength: 2,
})
}, [searchableItems])
@ -217,11 +287,26 @@ export function ModelSelect() {
e.stopPropagation();
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
);
let newCollectedModels = settings.collectedChatModels || [];
let newCollectedModels = [...currentCollectedModels];
if (isCurrentlyCollected) {
// remove
@ -233,83 +318,178 @@ export function ModelSelect() {
newCollectedModels = [...newCollectedModels, { provider: modelProvider, modelId: id }];
}
setSettings({
...settings,
collectedChatModels: newCollectedModels,
});
// 根据模型类型更新相应的设置
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;
}
};
return (
<>
<DropdownMenu.Root open={isOpen} onOpenChange={setIsOpen}>
<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">
{isOpen ? <ChevronUp size={12} /> : <ChevronDown size={12} />}
</div>
<div className="infio-chat-input-model-select__model-name">
{chatModelId}
</div>
</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content className="infio-popover infio-llm-setting-combobox-dropdown">
{/* collected models */}
{settings.collectedChatModels?.length > 0 && (
<div className="infio-model-section">
<div className="infio-model-section-title">
<Star size={12} className="infio-star-active" /> {t('chat.input.collectedModels')}
</div>
<ul className="infio-collected-models-list">
{settings.collectedChatModels.map((collectedModel, index) => (
<DropdownMenu.Item
key={`${collectedModel.provider}-${collectedModel.modelId}`}
onSelect={() => {
setSettings({
...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)
);
{(() => {
const getCollectedModels = () => {
switch (modelType) {
case 'insight':
return settings.collectedInsightModels || []
case 'apply':
return settings.collectedApplyModels || []
case 'embedding':
return settings.collectedEmbeddingModels || []
default:
return settings.collectedChatModels || []
}
}
setSettings({
...settings,
collectedChatModels: newCollectedModels,
});
}} />
</div>
</li>
</DropdownMenu.Item>
))}
</ul>
<div className="infio-model-separator"></div>
</div>
)}
const collectedModels = getCollectedModels()
return collectedModels.length > 0 ? (
<div className="infio-model-section">
<div className="infio-model-section-title">
<Star size={12} className="infio-star-active" /> {t('chat.input.collectedModels')}
</div>
<ul className="infio-collected-models-list">
{collectedModels.map((collectedModel, index) => (
<DropdownMenu.Item
key={`${collectedModel.provider}-${collectedModel.modelId}`}
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-provider-container">
@ -366,11 +546,37 @@ export function ModelSelect() {
e.preventDefault()
const selectedOption = filteredOptions[selectedIndex]
if (selectedOption) {
setSettings({
...settings,
chatModelProvider: modelProvider,
chatModelId: selectedOption.id,
})
// 根据模型类型更新相应的设置
switch (modelType) {
case 'insight':
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)
setSearchTerm("")
setIsOpen(false)
@ -403,11 +609,37 @@ export function ModelSelect() {
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault()
setSettings({
...settings,
chatModelProvider: modelProvider,
chatModelId: searchTerm,
})
// 根据模型类型更新相应的设置
switch (modelType) {
case 'insight':
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)
setIsOpen(false)
}
@ -430,11 +662,37 @@ export function ModelSelect() {
<DropdownMenu.Item
key={option.id}
onSelect={() => {
setSettings({
...settings,
chatModelProvider: modelProvider,
chatModelId: option.id,
})
// 根据模型类型更新相应的设置
switch (modelType) {
case 'insight':
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)
setSearchTerm("")
setIsOpen(false)
@ -442,9 +700,21 @@ export function ModelSelect() {
className={`infio-llm-setting-combobox-option ${isSelected ? 'is-selected' : ''}`}
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
? index
: index + settings.collectedChatModels?.length;
: index + getCollectedModels().length;
setSelectedIndex(hoverIndex);
}}
asChild
@ -454,7 +724,11 @@ export function ModelSelect() {
title={option.id}
>
<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
className="infio-model-item-star"
@ -503,6 +777,15 @@ export function ModelSelect() {
display: block;
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 {
display: flex;
flex-direction: row;
@ -586,9 +869,8 @@ export function ModelSelect() {
text-align: left;
appearance: none;
-webkit-appearance: none;
background-color: var(--background-primary);
border: 1px solid var(--background-modifier-border);
border-radius: 6px;
border-radius: 4px;
font-weight: 500;
font-size: 0.9em;
transition: all 0.2s ease;
@ -603,7 +885,6 @@ export function ModelSelect() {
.infio-llm-setting-provider-switch:hover {
border-color: var(--interactive-accent);
background-color: var(--background-primary-alt);
}
.infio-llm-setting-provider-switch:focus {
@ -697,12 +978,9 @@ export function ModelSelect() {
.infio-provider-badge {
font-size: 10px;
padding: 2px 6px;
background-color: var(--background-modifier-hover);
border-radius: 4px;
margin-right: 6px;
color: var(--text-muted);
font-weight: 500;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
}
`}
</style>

View File

@ -13,6 +13,7 @@ import {
import { useApp } from '../../../contexts/AppContext'
import { useDarkModeContext } from '../../../contexts/DarkModeContext'
import { useSettings } from '../../../contexts/SettingsContext'
import {
Mentionable,
MentionableImage,
@ -31,11 +32,10 @@ import { ImageUploadButton } from './ImageUploadButton'
import LexicalContentEditable from './LexicalContentEditable'
import MentionableBadge from './MentionableBadge'
import { ModelSelect } from './ModelSelect'
// import { ModeSelect } from './ModeSelect'
import { ModeSelect } from './ModeSelect'
import { MentionNode } from './plugins/mention/MentionNode'
import { NodeMutations } from './plugins/on-mutation/OnMutationPlugin'
import { SubmitButton } from './SubmitButton'
export type ChatUserInputRef = {
focus: () => void
}
@ -68,6 +68,7 @@ const PromptInputWithActions = forwardRef<ChatUserInputRef, ChatUserInputProps>(
ref,
) => {
const app = useApp()
const { settings, setSettings } = useSettings()
const editorRef = useRef<LexicalEditor | null>(null)
const contentEditableRef = useRef<HTMLDivElement>(null)
@ -83,6 +84,50 @@ const PromptInputWithActions = forwardRef<ChatUserInputRef, ChatUserInputProps>(
}
}, [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, () => ({
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__model-select-container">
<ModeSelect />
<ModelSelect />
<ImageUploadButton onUpload={handleUploadImages} />
</div>
<div className="infio-chat-user-input-controls__buttons">
<ImageUploadButton onUpload={handleUploadImages} />
<SubmitButton onClick={() => handleSubmit()} />
</div>
</div>

View File

@ -6,10 +6,12 @@ import {
useState
} from 'react'
import { Mentionable } from '../../../types/mentionable'
import LexicalContentEditable from './LexicalContentEditable'
import { SearchButton } from './SearchButton'
import { SearchModeSelect } from './SearchModeSelect'
export type SearchInputRef = {
focus: () => void
@ -25,26 +27,28 @@ export type SearchInputProps = {
placeholder?: string
autoFocus?: boolean
disabled?: boolean
searchMode?: 'notes' | 'insights' | 'all'
onSearchModeChange?: (mode: 'notes' | 'insights' | 'all') => void
}
// 检查编辑器状态是否为空的辅助函数
// 检查编辑器状态是否为空
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
}
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>(
@ -56,6 +60,8 @@ const SearchInputWithActions = forwardRef<SearchInputRef, SearchInputProps>(
placeholder = '',
autoFocus = false,
disabled = false,
searchMode = 'all',
onSearchModeChange,
},
ref
) => {
@ -112,6 +118,7 @@ const SearchInputWithActions = forwardRef<SearchInputRef, SearchInputProps>(
</div>
)}
<LexicalContentEditable
rootTheme="infio-search-lexical-content-editable-root"
initialEditorState={(editor) => {
if (initialSerializedEditorState) {
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__model-select-container">
{/* TODO: add model select */}
{onSearchModeChange && (
<SearchModeSelect
searchMode={searchMode}
onSearchModeChange={onSearchModeChange}
/>
)}
</div>
<div className="infio-chat-user-input-controls__buttons">
<SearchButton onClick={() => handleSubmit()} />

View 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>
</>
)
}

View File

@ -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 }) {
return (
<button className="infio-chat-user-input-submit-button" onClick={onClick}>
{t('chat.input.submit')}
<div className="infio-chat-user-input-submit-button-icons">
<CornerDownLeftIcon size={12} />
</div>
</button>
)
return (
<>
<button className="infio-chat-user-input-submit1-button" onClick={onClick}>
{/* {t('chat.input.submit')} */}
<div className="infio-chat-user-input-submit1-button-icons">
<ArrowUpIcon size={14} />
</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>
</>
)
}

View 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

View 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()
}
}

View 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()
}
}

View File

@ -14,19 +14,36 @@ export default function PreviewViewRoot({
const closeIcon = getIcon('x')
const contentRef = useRef<HTMLDivElement>(null)
// 显示原始文本内容
// 显示内容 - 支持 HTML 和纯文本
useEffect(() => {
if (contentRef.current && state.content) {
// 清空现有内容
contentRef.current.empty()
contentRef.current.innerHTML = ''
// 创建预格式化文本元素
const preElement = document.createElement('pre')
preElement.className = 'infio-raw-content'
preElement.textContent = state.content
// 判断是否为 HTML 内容(包含 SVG
const isHtmlContent = state.content.trim().startsWith('<') &&
(state.content.includes('<svg') || state.content.includes('<div') ||
state.content.includes('<span') || state.content.includes('<pre'))
// 添加到容器
contentRef.current.appendChild(preElement)
if (isHtmlContent) {
// 如果是 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])
@ -61,7 +78,7 @@ export default function PreviewViewRoot({
</div>
<div
ref={contentRef}
className="markdown-preview-section"
className="markdown-preview-section infio-preview-content"
></div>
</div>
</div>
@ -92,6 +109,19 @@ export default function PreviewViewRoot({
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 {
white-space: pre-wrap;
word-break: break-word;
@ -99,6 +129,7 @@ export default function PreviewViewRoot({
padding: 10px;
background-color: var(--background-secondary);
border-radius: 4px;
text-align: left;
}
`}</style>
</div>

View File

@ -3,22 +3,10 @@ import { LLMModel } from './types/llm/model'
export const CHAT_VIEW_TYPE = 'infio-chat-view'
export const APPLY_VIEW_TYPE = 'infio-apply-view'
export const PREVIEW_VIEW_TYPE = 'infio-preview-view'
export const JSON_VIEW_TYPE = 'infio-json-view'
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[] = [
384,
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 SILICONFLOW_BASE_URL = 'https://api.siliconflow.cn/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_PLATFORM_URL = 'https://platform.infio.app'
export const JINA_BASE_URL = 'https://r.jina.ai'
export const SERPER_BASE_URL = 'https://serpapi.com/search'
// Pricing in dollars per million tokens

View File

@ -22,7 +22,14 @@ export function DarkModeProvider({ children }: { children: ReactNode }) {
useEffect(() => {
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()
app.workspace.on('css-change', handleDarkMode)

View 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
}

View 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
}

View File

@ -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.
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.
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:
- 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 format:
@ -72,72 +77,58 @@ Diff format:
[new content to replace with]
>>>>>>> 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:
<apply_diff>
<path>File path here</path>
<diff>
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, because multiple '=======' will corrupt the file.
Only use a single line of '=======' between search and replacement content.
</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(
@ -146,7 +137,7 @@ Only use a single line of '=======' between search and replacement content, beca
_paramStartLine?: number,
_paramEndLine?: number,
): Promise<DiffResult> {
let matches = [
const matches = [
...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,
),
@ -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"
let resultLines = originalContent.split(/\r?\n/)
let delta = 0
let diffResults: DiffResult[] = []
const diffResults: DiffResult[] = []
let appliedCount = 0
const replacements = matches
.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)
// Get the exact indentation (preserving tabs/spaces) of each line
const indentRegex = /^[\t ]*/
const originalIndents = matchedLines.map((line) => {
const match = line.match(/^[\t ]*/)
const match = indentRegex.exec(line)
return match ? match[0] : ""
})
// Get the exact indentation of each line in the search block
const searchIndents = searchLines.map((line) => {
const match = line.match(/^[\t ]*/)
const match = indentRegex.exec(line)
return match ? match[0] : ""
})
// 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
const matchedIndent = originalIndents[0] || ""
// 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 searchBaseIndent = searchIndents[0] || ""

View File

@ -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 {
LLMOptions,
@ -39,6 +39,7 @@ class LLMManager implements LLMManagerInterface {
private googleProvider: GeminiProvider
private groqProvider: GroqProvider
private grokProvider: OpenAICompatibleProvider
private moonshotProvider: OpenAICompatibleProvider
private infioProvider: OpenAICompatibleProvider
private openrouterProvider: OpenAICompatibleProvider
private siliconflowProvider: OpenAICompatibleProvider
@ -85,6 +86,12 @@ class LLMManager implements LLMManagerInterface {
settings.grokProvider.baseUrl
: 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.openaiCompatibleProvider = new OpenAICompatibleProvider(settings.openaicompatibleProvider.apiKey, settings.openaicompatibleProvider.baseUrl)
this.isInfioEnabled = !!settings.infioProvider.apiKey
@ -158,6 +165,12 @@ class LLMManager implements LLMManagerInterface {
request,
options,
)
case ApiProvider.Moonshot:
return await this.moonshotProvider.generateResponse(
model,
request,
options,
)
case ApiProvider.OpenAICompatible:
return await this.openaiCompatibleProvider.generateResponse(model, request, options)
default:
@ -195,6 +208,8 @@ class LLMManager implements LLMManagerInterface {
return await this.groqProvider.streamResponse(model, request, options)
case ApiProvider.Grok:
return await this.grokProvider.streamResponse(model, request, options)
case ApiProvider.Moonshot:
return await this.moonshotProvider.streamResponse(model, request, options)
case ApiProvider.Ollama:
return await this.ollamaProvider.streamResponse(model, request, options)
case ApiProvider.OpenAICompatible:

View File

@ -24,7 +24,10 @@ import { OpenAIMessageAdapter } from './openai-message-adapter'
export class NoStainlessOpenAI extends OpenAI {
defaultHeaders() {
// 获取父类的默认头部,包含 Authorization
const parentHeaders = super.defaultHeaders()
return {
...parentHeaders,
Accept: 'application/json',
'Content-Type': 'application/json',
}

View File

@ -1,6 +1,6 @@
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 {
LLMOptions,
@ -14,39 +14,60 @@ import {
import { BaseLLMProvider } from './base'
import { LLMBaseUrlNotSetException } from './exception'
import { NoStainlessOpenAI } from './ollama'
import { OpenAIMessageAdapter } from './openai-message-adapter'
export class OpenAICompatibleProvider implements BaseLLMProvider {
private adapter: OpenAIMessageAdapter
private client: OpenAI
private client: OpenAI | NoStainlessOpenAI
private apiKey: string
private baseURL: string
constructor(apiKey: string, baseURL: string) {
this.adapter = new OpenAIMessageAdapter()
this.client = new OpenAI({
apiKey: apiKey,
baseURL: baseURL,
dangerouslyAllowBrowser: true,
})
this.adapter = new OpenAIMessageAdapter()
this.apiKey = apiKey
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
private isAlibabaQwen(): boolean {
return this.baseURL === ALIBABA_QWEN_BASE_URL ||
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> {
const extraParams: Record<string, any> = {}
private getExtraParams(isStreaming: boolean, modelName: string): Record<string, unknown> {
const extraParams: Record<string, unknown> = {}
// 阿里云Qwen API需要在非流式调用中设置 enable_thinking: false
if (this.isAlibabaQwen() && !isStreaming) {
extraParams.enable_thinking = false
}
}
if (this.isGemini(modelName)) {
extraParams.reasoning_effort = 'low';
}
return extraParams
}
@ -62,8 +83,8 @@ export class OpenAICompatibleProvider implements BaseLLMProvider {
)
}
const extraParams = this.getExtraParams(false) // 非流式调用
return this.adapter.generateResponse(this.client, request, options, extraParams)
const extraParams = this.getExtraParams(false, model.modelId) // 非流式调用
return this.adapter.generateResponse(this.client as OpenAI, request, options, extraParams)
}
async streamResponse(
@ -77,7 +98,7 @@ export class OpenAICompatibleProvider implements BaseLLMProvider {
)
}
const extraParams = this.getExtraParams(true) // 流式调用
return this.adapter.streamResponse(this.client, request, options, extraParams)
const extraParams = this.getExtraParams(true, model.modelId) // 流式调用
return this.adapter.streamResponse(this.client as OpenAI, request, options, extraParams)
}
}

View File

@ -22,7 +22,7 @@ export class OpenAIMessageAdapter {
client: OpenAI,
request: LLMRequestNonStreaming,
options?: LLMOptions,
extraParams?: Record<string, any>,
extraParams?: Record<string, unknown>,
): Promise<LLMResponseNonStreaming> {
const response = await client.chat.completions.create(
{
@ -50,7 +50,7 @@ export class OpenAIMessageAdapter {
client: OpenAI,
request: LLMRequestStreaming,
options?: LLMOptions,
extraParams?: Record<string, any>,
extraParams?: Record<string, unknown>,
): Promise<AsyncIterable<LLMResponseStreaming>> {
const stream = await client.chat.completions.create(
{

View File

@ -1,7 +1,4 @@
// Obsidian
import { App, EventRef, Notice, TFile, normalizePath } from 'obsidian';
// Node built-in
import * as path from "path";
// SDK / External Libraries
@ -18,17 +15,17 @@ import {
import chokidar, { FSWatcher } from "chokidar"; // Keep chokidar
import delay from "delay"; // Keep delay
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 { EnvironmentVariables, shellEnvSync } from 'shell-env';
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 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 { ROOT_DIR } from '../prompts/constants';
import {
McpResource,
@ -39,7 +36,6 @@ import {
McpToolCallResponse,
} from "./type";
export type McpConnection = {
server: McpServer
client: Client
@ -123,6 +119,21 @@ const McpSettingsSchema = z.object({
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 响应类型
interface BuiltInToolResponse {
name: string
@ -139,6 +150,7 @@ export class McpHub {
private mcpSettingsFilePath: string | null = null
// private globalMcpFilePath: string | null = null
private fileWatchers: Map<string, FSWatcher[]> = new Map()
private configFileChangeTimeout: NodeJS.Timeout | null = null
private isDisposed: boolean = false
connections: McpConnection[] = []
// 添加内置服务器连接
@ -203,8 +215,13 @@ export class McpHub {
throw new Error("Server configuration must be an object.");
}
// 使用类型保护而不是类型断言
const configObj = config as Record<string, unknown>;
// Use type guard to ensure config is an object
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
const hasStdioFields = configObj.command !== undefined
@ -215,7 +232,7 @@ export class McpHub {
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
if (!mutableConfig.type) {
@ -329,26 +346,72 @@ export class McpHub {
}
async ensureMcpFileExists(): Promise<void> {
const mcpFolderPath = ".infio_json_db/mcp"
if (!await this.app.vault.adapter.exists(normalizePath(mcpFolderPath))) {
await this.app.vault.createFolder(mcpFolderPath);
// 新的配置目录和文件路径
const newMcpFolderPath = ROOT_DIR
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) {
await this.app.vault.adapter.write(
this.mcpSettingsFilePath,
JSON.stringify({ mcpServers: {} }, null, 2)
);
// 设置新的配置文件路径
this.mcpSettingsFilePath = newMcpSettingsFilePath
// 检查新的配置文件是否存在
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"))
// 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)
// );
// }
// 情况4只有新配置文件存在什么都不做
}
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
private async initializeGlobalMcpServers(): Promise<void> {
try {
@ -391,8 +511,14 @@ export class McpHub {
try {
// 安全地处理未验证的配置
const serversToConnect = config.mcpServers;
if (serversToConnect && typeof serversToConnect === 'object') {
await this.updateServerConnections(serversToConnect);
if (serversToConnect && typeof serversToConnect === 'object' &&
!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 {
await this.updateServerConnections({});
}
@ -438,8 +564,8 @@ export class McpHub {
// Inject environment variables to the config
let configInjected = { ...config };
try {
// injectEnv might return a modified structure, so we re-validate.
const tempConfigAfterInject = await injectEnv(config as Record<string, unknown>);
// injectEnv might return a modified structure, so we re-validate.
const tempConfigAfterInject = await injectEnv(config);
const validatedInjectedConfig = ServerConfigSchema.safeParse(tempConfigAfterInject);
if (validatedInjectedConfig.success) {
configInjected = validatedInjectedConfig.data;
@ -658,7 +784,7 @@ export class McpHub {
try {
if (actualSource === "project") {
// 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)) {
configPath = projectMcpPath
const content = await this.app.vault.adapter.read(configPath)
@ -667,7 +793,7 @@ export class McpHub {
}
} else {
// 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 config = JSON.parse(content)
alwaysAllowConfig = config.mcpServers?.[serverName]?.alwaysAllow || []
@ -916,53 +1042,7 @@ export class McpHub {
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(
serverName: string,
@ -1027,7 +1107,7 @@ export class McpHub {
// Determine which config file to update
let configPath: string
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)) {
throw new Error("Project MCP configuration file not found")
}
@ -1111,7 +1191,7 @@ export class McpHub {
if (isProjectServer) {
// 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)) {
throw new Error("Project MCP configuration file not found")
}
@ -1135,8 +1215,10 @@ export class McpHub {
// Remove the server from the settings
if (config.mcpServers[serverName]) {
// 使用 Reflect.deleteProperty 而不是 delete 操作符
Reflect.deleteProperty(config.mcpServers, serverName)
// Use delete operator safely with type guard
if (config.mcpServers && typeof config.mcpServers === 'object' && !Array.isArray(config.mcpServers)) {
delete config.mcpServers[serverName];
}
// Write the entire config back
const updatedConfig = {
@ -1146,7 +1228,13 @@ export class McpHub {
await this.app.vault.adapter.write(configPath, JSON.stringify(updatedConfig, null, 2))
// 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 }))
} else {
@ -1184,7 +1272,7 @@ export class McpHub {
// Determine which config file to update
let configPath: string
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)) {
// Create project config file if it doesn't exist
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))
// 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}`)
} catch (error) {
@ -1332,6 +1427,7 @@ export class McpHub {
method: 'POST',
headers: {
'Content-Type': 'application/json',
// @ts-ignore
'Authorization': `Bearer ${this.plugin.settings.infioProvider.apiKey}`,
},
body: JSON.stringify({
@ -1410,7 +1506,7 @@ export class McpHub {
let configPath: string
if (source === "project") {
// 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)) {
throw new Error("Project MCP configuration file not found")
}
@ -1512,7 +1608,7 @@ export class McpHub {
name: this.BUILTIN_SERVER_NAME,
config: JSON.stringify({ type: "builtin" }),
status: "connected",
disabled: false,
disabled: true,
source: "global",
tools: tools,
resources: [], // 内置服务器暂不支持资源
@ -1545,6 +1641,7 @@ export class McpHub {
const response = await fetch(`${INFIO_BASE_URL}/mcp/tools/list`, {
headers: {
'Content-Type': 'application/json',
// @ts-ignore
'Authorization': `Bearer ${this.plugin.settings.infioProvider.apiKey}`,
},
})

View File

@ -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(
cwd: 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 {
let searchFilesInstructions: string;
switch (searchFilesTool) {
@ -27,9 +66,13 @@ function getObsidianCapabilitiesSection(
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.${searchFilesInstructions}
`
- You are a specialized learning assistant with access to powerful transformation tools designed to enhance learning and comprehension within Obsidian vaults.
- 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 {
@ -46,10 +89,13 @@ CAPABILITIES
export function getCapabilitiesSection(
mode: string,
cwd: string,
searchWebTool: string,
searchFileTool: string,
): string {
if (mode === 'research') {
return getDeepResearchCapabilitiesSection();
}
return getObsidianCapabilitiesSection(cwd, searchWebTool);
if (mode === 'learn') {
return getLearnModeCapabilitiesSection(cwd, searchFileTool);
}
return getObsidianCapabilitiesSection(cwd, searchFileTool);
}

View File

@ -1,5 +1,4 @@
export { getRulesSection } from "./rules"
export { getSystemInfoSection } from "./system-info"
export { getObjectiveSection } from "./objective"
export { addCustomInstructions } from "./custom-instructions"
export { getSharedToolUseSection } from "./tool-use"

View File

@ -1,3 +1,23 @@
function getLearnModeObjectiveSection(): string {
return `====
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 `====
@ -33,5 +53,8 @@ export function getObjectiveSection(mode: string): string {
if (mode === 'research') {
return getDeepResearchObjectiveSection();
}
if (mode === 'learn') {
return getLearnModeObjectiveSection();
}
return getObsidianObjectiveSection();
}

View File

@ -1,72 +1,60 @@
import { DiffStrategy } from "../../diff/DiffStrategy"
function getEditingInstructions(diffStrategy?: DiffStrategy): string {
const instructions: string[] = []
const availableTools: string[] = []
const experiments = {
insert_content: true,
search_and_replace: true,
function getEditingInstructions(mode: string): string {
if (mode !== 'write') {
return ""
}
// Collect available editing tools
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)")
}
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:
// Base editing instruction mentioning all available tools
if (availableTools.length > 1) {
instructions.push(`- For editing documents, you have access to these tools: ${availableTools.join(", ")}.`)
}
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.
// Additional details for experimental features
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.",
)
}
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?.["search_and_replace"]) {
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.",
)
}
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.
if (availableTools.length > 1) {
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")
- 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.`
}
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') {
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') {
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') {
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 ""
}
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 {
return `====
@ -84,20 +72,18 @@ RULES
}
function getObsidianRulesSection(
mode: string,
cwd: string,
searchTool: string,
supportsComputerUse: boolean,
diffStrategy?: DiffStrategy,
experiments?: Record<string, boolean> | undefined,
): string {
return `====
RULES
- Your current obsidian directory is: ${cwd.toPosix()}
- Your current working directory is: ${cwd.toPosix()}
${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.
${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.
- 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.
@ -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.
- 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.
- 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.`
}
@ -123,5 +109,8 @@ export function getRulesSection(
if (mode === 'research') {
return getDeepResearchRulesSection();
}
return getObsidianRulesSection(cwd, searchTool, supportsComputerUse, diffStrategy, experiments);
if (mode === 'learn') {
return getLearnModeRulesSection(cwd, searchTool);
}
return getObsidianRulesSection(mode, cwd, searchTool);
}

View File

@ -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
}

View File

@ -1,4 +1,5 @@
export function getToolUseGuidelinesSection(): string {
function getDefaultToolUseGuidelines(): string {
return `# Tool Use Guidelines
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.`
}
export function getToolUseGuidelinesSection(mode?: string): string {
return getDefaultToolUseGuidelines();
}

View File

@ -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.
# 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:

View File

@ -1,4 +1,3 @@
import * as path from 'path'
import { App, normalizePath } from 'obsidian'
@ -10,9 +9,9 @@ import {
ModeConfig,
PromptComponent,
defaultModeSlug,
defaultModes,
getGroupName,
getModeBySlug,
defaultModes
getModeBySlug
} from "../../utils/modes"
import { DiffStrategy } from "../diff/DiffStrategy"
import { McpHub } from "../mcp/McpHub"
@ -27,8 +26,7 @@ import {
getObjectiveSection,
getRulesSection,
getSharedToolUseSection,
getSystemInfoSection,
getToolUseGuidelinesSection,
getToolUseGuidelinesSection
} from "./sections"
// import { loadSystemPromptFile } from "./sections/custom-system-prompt"
import { getToolDescriptionsForMode } from "./tools"
@ -82,12 +80,6 @@ export class SystemPrompt {
experiments?: Record<string, boolean>,
enableMcpServerCreation?: boolean,
): 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
const modeConfig = getModeBySlug(mode, customModeConfigs) || defaultModes.find((m) => m.slug === mode) || defaultModes[0]
@ -117,7 +109,7 @@ ${getToolDescriptionsForMode(
experiments,
)}
${getToolUseGuidelinesSection()}
${getToolUseGuidelinesSection(mode)}
${mcpServersSection}
@ -138,8 +130,6 @@ ${getRulesSection(
experiments,
)}
${getSystemInfoSection(cwd)}
${getObjectiveSection(mode)}
${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 => {
if (typeof value === "object" && value !== null) {
return value as PromptComponent
return value
}
return undefined
}

View File

@ -1,13 +1,20 @@
export function getAttemptCompletionDescription(): string {
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.
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:
- 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:
<attempt_completion>
<result>
Your final result description here
</result>
</attempt_completion>
Example: Requesting to attempt completion with a result
<attempt_completion>
<result>
I've updated the CSS
</result>
</attempt_completion>`
}

View 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>`
}

View 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}`
}

View File

@ -1,14 +1,17 @@
import { FilesSearchSettings } from "../../../types/settings"
import { Mode, ModeConfig, getGroupName, getModeConfig, isToolAllowedForMode } from "../../../utils/modes"
import { DiffStrategy } from "../../diff/DiffStrategy"
import { McpHub } from "../../mcp/McpHub"
import { FilesSearchSettings } from "../../../types/settings"
import { getAccessMcpResourceDescription } from "./access-mcp-resource"
import { getAskFollowupQuestionDescription } from "./ask-followup-question"
import { getAttemptCompletionDescription } from "./attempt-completion"
import { getCallInsightsDescription } from "./call-insights"
import { getDataviewQueryDescription } from "./dataview-query"
import { getFetchUrlsContentDescription } from "./fetch-url-content"
import { getInsertContentDescription } from "./insert-content"
import { getListFilesDescription } from "./list-files"
import { getManageFilesDescription } from "./manage-files"
import { getReadFileDescription } from "./read-file"
import { getSearchAndReplaceDescription } from "./search-and-replace"
import { getSearchFilesDescription } from "./search-files"
@ -25,6 +28,8 @@ const toolDescriptionMap: Record<string, (args: ToolArgs) => string | undefined>
write_to_file: (args) => getWriteToFileDescription(args),
search_files: (args) => getSearchFilesDescription(args),
list_files: (args) => getListFilesDescription(args),
insights: (args) => getCallInsightsDescription(args),
dataview_query: (args) => getDataviewQueryDescription(args),
ask_followup_question: () => getAskFollowupQuestionDescription(),
attempt_completion: () => getAttemptCompletionDescription(),
switch_mode: () => getSwitchModeDescription(),
@ -32,6 +37,7 @@ const toolDescriptionMap: Record<string, (args: ToolArgs) => string | undefined>
use_mcp_tool: (args) => getUseMcpToolDescription(args),
access_mcp_resource: (args) => getAccessMcpResourceDescription(args),
search_and_replace: (args) => getSearchAndReplaceDescription(args),
manage_files: (args) => getManageFilesDescription(args),
apply_diff: (args) =>
args.diffStrategy ? args.diffStrategy.getToolDescription({ cwd: args.cwd, toolOptions: args.toolOptions }) : "",
search_web: (args): string | undefined => getSearchWebDescription(args),
@ -50,7 +56,9 @@ export function getToolDescriptionsForMode(
customModes?: ModeConfig[],
experiments?: Record<string, boolean>,
): string {
// console.log("getToolDescriptionsForMode", mode, customModes)
const config = getModeConfig(mode, customModes)
// console.log("config", config)
const args: ToolArgs = {
cwd,
searchSettings,
@ -67,6 +75,7 @@ export function getToolDescriptionsForMode(
config.groups.forEach((groupEntry) => {
const groupName = getGroupName(groupEntry)
const toolGroup = TOOL_GROUPS[groupName]
console.log("toolGroup", toolGroup)
if (toolGroup) {
toolGroup.tools.forEach((tool) => {
if (isToolAllowedForMode(tool, mode, customModes ?? [], experiments ?? {})) {
@ -78,10 +87,11 @@ export function getToolDescriptionsForMode(
// Add always available tools
ALWAYS_AVAILABLE_TOOLS.forEach((tool) => tools.add(tool))
// console.log("tools", tools)
// Map tool descriptions for allowed tools
const descriptions = Array.from(tools).map((toolName) => {
const descriptionFn = toolDescriptionMap[toolName]
// console.log("descriptionFn", descriptionFn)
if (!descriptionFn) {
return undefined
}
@ -97,6 +107,8 @@ export function getToolDescriptionsForMode(
// Export individual description functions for backward compatibility
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
}

View 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>`
}

View File

@ -67,6 +67,13 @@ Example: Requesting to search for all Markdown files in the current directory
export function getSemanticSearchFilesDescription(args: ToolArgs): string {
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.
**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:
- 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.

View File

@ -12,20 +12,19 @@ export const TOOL_DISPLAY_NAMES = {
apply_diff: "apply changes",
list_files: "list files",
search_files: "search files",
// list_code_definition_names: "list definitions",
// browser_action: "use a browser",
// use_mcp_tool: "use mcp tools",
// access_mcp_resource: "access mcp resources",
dataview_query: "query dataview",
use_mcp_tool: "use mcp tools",
access_mcp_resource: "access mcp resources",
insights: "call insights",
ask_followup_question: "ask questions",
attempt_completion: "complete tasks",
switch_mode: "switch modes",
// new_task: "create new task",
} as const
// Define available tool groups
export const TOOL_GROUPS: Record<string, ToolGroupConfig> = {
read: {
tools: ["read_file", "list_files", "search_files"],
tools: ["read_file", "list_files", "search_files", "dataview_query"],
},
edit: {
tools: ["apply_diff", "write_to_file", "insert_content", "search_and_replace"],
@ -33,12 +32,12 @@ export const TOOL_GROUPS: Record<string, ToolGroupConfig> = {
research: {
tools: ["search_web", "fetch_urls_content"],
},
// browser: {
// tools: ["browser_action"],
// },
// command: {
// tools: ["execute_command"],
// },
insights: {
tools: ["insights"],
},
manage_files: {
tools: ["manage_files"],
},
mcp: {
tools: ["use_mcp_tool", "access_mcp_resource"],
},
@ -61,11 +60,11 @@ export const ALWAYS_AVAILABLE_TOOLS = [
export type ToolName = keyof typeof TOOL_DISPLAY_NAMES
// Tool helper functions
export function getToolName(toolConfig: string | readonly [ToolName, ...any[]]): ToolName {
return typeof toolConfig === "string" ? (toolConfig as ToolName) : toolConfig[0]
export function getToolName(toolConfig: string | readonly [ToolName, ...unknown[]]): ToolName {
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]
}
@ -73,7 +72,8 @@ export function getToolOptions(toolConfig: string | readonly [ToolName, ...any[]
export const GROUP_DISPLAY_NAMES: Record<ToolGroup, string> = {
read: "Read Files",
edit: "Edit Files",
// browser: "Use Browser",
// command: "Run Commands",
// mcp: "Use MCP",
research: "Research",
insights: "insights",
mcp: "MCP Tools",
modes: "Modes",
}

View 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";

View 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";

View 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";

View 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";

View 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";

View 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";

View 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";

View File

@ -16,10 +16,66 @@ import {
} from '../llm/exception'
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 = (
settings: InfioSettings,
embeddingManager?: EmbeddingManager,
): EmbeddingModel => {
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: {
const openai = new OpenAI({
apiKey: settings.infioProvider.apiKey,
@ -27,6 +83,9 @@ export const getEmbeddingModel = (
dangerouslyAllowBrowser: true,
})
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,
@ -89,6 +148,9 @@ export const getEmbeddingModel = (
dangerouslyAllowBrowser: true,
})
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,
@ -151,6 +213,9 @@ export const getEmbeddingModel = (
dangerouslyAllowBrowser: true,
})
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,
@ -213,6 +278,9 @@ export const getEmbeddingModel = (
dangerouslyAllowBrowser: true,
})
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,
@ -271,6 +339,9 @@ export const getEmbeddingModel = (
const client = new GoogleGenerativeAI(settings.googleProvider.apiKey)
const model = client.getGenerativeModel({ model: 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 {
id: settings.embeddingModelId,
dimension: modelInfo.dimensions,

View File

@ -2,17 +2,29 @@ import { App, TFile } from 'obsidian'
import { QueryProgressState } from '../../components/chat-view/QueryProgress'
import { DBManager } from '../../database/database-manager'
import { Workspace } from '../../database/json/workspace/types'
import { VectorManager } from '../../database/modules/vector/vector-manager'
import { SelectVector } from '../../database/schema'
import { EmbeddingModel } from '../../types/embedding'
import { ApiProvider } from '../../types/llm/model'
import { InfioSettings } from '../../types/settings'
import { getFilesWithTag } from '../../utils/glob-utils'
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 {
private app: App
private settings: InfioSettings
private embeddingManager?: EmbeddingManager
private vectorManager: VectorManager | null = null
private embeddingModel: EmbeddingModel | null = null
private initialized = false
@ -21,11 +33,22 @@ export class RAGEngine {
app: App,
settings: InfioSettings,
dbManager: DBManager,
embeddingManager?: EmbeddingManager,
) {
this.app = app
this.settings = settings
this.embeddingManager = embeddingManager
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() {
@ -35,7 +58,16 @@ export class RAGEngine {
setSettings(settings: InfioSettings) {
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> {
@ -58,6 +90,37 @@ export class RAGEngine {
this.embeddingModel,
{
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,
includePatterns: this.settings.ragOptions.includePatterns,
reindexAll: options.reindexAll,
@ -82,6 +145,7 @@ export class RAGEngine {
await this.vectorManager.UpdateFileVectorIndex(
this.embeddingModel,
this.settings.ragOptions.chunkSize,
this.settings.ragOptions.batchSize,
file,
)
}
@ -99,7 +163,7 @@ export class RAGEngine {
)
}
async processQuery({
async processSimilarityQuery({
query,
scope,
limit,
@ -147,10 +211,285 @@ export class RAGEngine {
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[]> {
if (!this.embeddingModel) {
throw new Error('Embedding model is not set')
}
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)
}
}
}

View 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. **特定转换效果不佳**:尝试其他转换类型或检查内容是否适合该转换

File diff suppressed because it is too large Load Diff

View File

@ -6,6 +6,7 @@ import { createAndInitDb } from '../pgworker'
import { CommandManager } from './modules/command/command-manager'
import { ConversationManager } from './modules/conversation/conversation-manager'
import { InsightManager } from './modules/insight/insight-manager'
import { VectorManager } from './modules/vector/vector-manager'
export class DBManager {
@ -14,18 +15,20 @@ export class DBManager {
private vectorManager: VectorManager
private CommandManager: CommandManager
private conversationManager: ConversationManager
private insightManager: InsightManager
constructor(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)
dbManager.db = await createAndInitDb()
dbManager.db = await createAndInitDb(filesystem)
dbManager.vectorManager = new VectorManager(app, dbManager)
dbManager.CommandManager = new CommandManager(app, dbManager)
dbManager.conversationManager = new ConversationManager(app, dbManager)
dbManager.insightManager = new InsightManager(app, dbManager)
return dbManager
}
@ -46,6 +49,10 @@ export class DBManager {
return this.conversationManager
}
getInsightManager(): InsightManager {
return this.insightManager
}
async cleanup() {
this.db?.close()
this.db = null

View File

@ -1,49 +1,114 @@
import { App } from 'obsidian'
import { v4 as uuidv4 } from 'uuid'
import sanitize from 'sanitize-basename'
import unsanitize from 'unsanitize-basename'
import { ChatConversationMeta } from '../../../types/chat'
import { AbstractJsonRepository } from '../base'
import { CHAT_DIR, ROOT_DIR } from '../constants'
import { EmptyChatTitleException } from '../exception'
import { WorkspaceManager } from '../workspace/WorkspaceManager'
import {
CHAT_SCHEMA_VERSION,
ChatConversation
} from './types'
export class ChatManager extends AbstractJsonRepository<
ChatConversation,
ChatConversationMeta
> {
constructor(app: App) {
private workspaceManager?: WorkspaceManager
constructor(app: App, workspaceManager?: WorkspaceManager) {
super(app, `${ROOT_DIR}/${CHAT_DIR}`)
this.workspaceManager = workspaceManager
}
protected generateFileName(chat: ChatConversation): string {
// Format: v{schemaVersion}_{title}_{updatedAt}_{id}.json
const encodedTitle = encodeURIComponent(chat.title)
return `v${chat.schemaVersion}_${encodedTitle}_${chat.updatedAt}_${chat.id}.json`
// 新格式 v2: v{schemaVersion}_{sanitizedTitle}_{updatedAt}_{id}_{workspaceId}.json
const sanitizedTitle = sanitize(chat.title, { maxLength: 100 })
// 如果没有工作区,使用 'vault' 作为默认值
const workspaceId = chat.workspace || 'vault'
return `v${chat.schemaVersion}_${sanitizedTitle}_${chat.updatedAt}_${chat.id}_${workspaceId}.json`
}
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(
`^v${CHAT_SCHEMA_VERSION}_(.+)_(\\d+)_([0-9a-f-]+)\\.json$`,
`^v2_(.+)_(\\d+)_([0-9a-f-]+)(?:_([^_]+))?\\.json$`,
)
const match = fileName.match(regex)
if (!match) return null
const title = decodeURIComponent(match[1])
const updatedAt = parseInt(match[2], 10)
const id = match[3]
try {
// 使用 unsanitize-basename 还原原始标题
const title = unsanitize(match[1])
const updatedAt = parseInt(match[2], 10)
const id = match[3]
const workspaceId = match[4] // 可能为undefined老格式
return {
id,
schemaVersion: CHAT_SCHEMA_VERSION,
title,
updatedAt,
createdAt: 0,
return {
id,
schemaVersion: 2,
title,
updatedAt,
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)
// 如果有工作区信息,添加到工作区的聊天历史中
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
}
public async findById(id: string): Promise<ChatConversation | null> {
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(
@ -98,21 +181,124 @@ export class ChatManager extends AbstractJsonRepository<
}
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
}
public async deleteChat(id: string): Promise<boolean> {
const allMetadata = await this.listMetadata()
const targetMetadata = allMetadata.find((meta) => meta.id === id)
if (!targetMetadata) return false
const targetsToDelete = allMetadata.filter((meta) => meta.id === id)
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
}
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 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
}
}

View File

@ -1,6 +1,6 @@
import { SerializedChatMessage } from '../../../types/chat'
export const CHAT_SCHEMA_VERSION = 1
export const CHAT_SCHEMA_VERSION = 2
export type ChatConversation = {
id: string
@ -9,6 +9,7 @@ export type ChatConversation = {
createdAt: number
updatedAt: number
schemaVersion: number
workspace?: string // 工作区ID可选字段用于向后兼容
}
export type ChatConversationMetadata = {
@ -16,4 +17,5 @@ export type ChatConversationMetadata = {
title: string
updatedAt: number
schemaVersion: number
workspace?: string // 工作区ID可选字段用于向后兼容
}

View File

@ -3,4 +3,5 @@ export const COMMAND_DIR = 'commands'
export const CHAT_DIR = 'chats'
export const CUSTOM_MODE_DIR = 'custom_modes'
export const CONVERT_DATA_DIR = 'convert_data'
export const WORKSPACE_DIR = 'workspaces'
export const INITIAL_MIGRATION_MARKER = '.initial_migration_completed'

View 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
}
}

View File

@ -0,0 +1,2 @@
export * from './types'
export * from './WorkspaceManager'

View 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
}

View File

@ -0,0 +1,2 @@
export { InsightRepository } from './insight-repository'
export { InsightManager } from './insight-manager'

View 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)
// }
}

View 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

View File

@ -6,163 +6,208 @@ import { DatabaseNotInitializedException } from '../../exception'
import { InsertVector, SelectVector, vectorTables } from '../../schema'
export class VectorRepository {
private app: App
private db: PGliteInterface | null
private app: App
private db: PGliteInterface | null
private stopWords: Set<string>
constructor(app: App, pgClient: PGliteInterface | null) {
this.app = app
this.db = pgClient
}
constructor(app: App, pgClient: PGliteInterface | null) {
this.app = app
this.db = pgClient
this.stopWords = new Set([
// Chinese stop words
'的', '在', '是', '了', '我', '你', '他', '她', '它', '请问', '如何', '一个', '什么', '怎么',
'这', '那', '和', '与', '或', '但', '因为', '所以', '如果', '虽然', '可是', '不过',
'也', '都', '还', '就', '又', '很', '最', '更', '非常', '特别', '比较', '相当',
'对', '于', '把', '被', '让', '使', '给', '为', '从', '到', '向', '往', '朝',
'上', '下', '里', '外', '前', '后', '左', '右', '中', '间', '内', '以', '及',
private getTableName(embeddingModel: EmbeddingModel): string {
const tableDefinition = vectorTables[embeddingModel.dimension]
if (!tableDefinition) {
throw new Error(`No table definition found for model: ${embeddingModel.id}`)
}
return tableDefinition.name
}
// English stop words
'a', 'an', 'and', 'are', 'as', 'at', 'be', 'by', 'for', 'from', 'has', 'he',
'in', 'is', 'it', 'its', 'of', 'on', 'that', 'the', 'to', 'was', 'were', 'will',
'with', 'would', 'could', 'should', 'can', 'may', 'might', 'must', 'shall',
'this', 'that', 'these', 'those', 'i', 'you', 'we', 'they', 'me', 'him', 'her',
'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[]> {
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)
}
private getTableName(embeddingModel: EmbeddingModel): string {
const tableDefinition = vectorTables[embeddingModel.dimension]
if (!tableDefinition) {
throw new Error(`No table definition found for model: ${embeddingModel.id}`)
}
return tableDefinition.name
}
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()
}
async getAllIndexedFilePaths(embeddingModel: EmbeddingModel): Promise<string[]> {
if (!this.db) {
throw new DatabaseNotInitializedException()
}
const tableName = this.getTableName(embeddingModel)
await this.db.query(`DELETE FROM "${tableName}"`)
}
const result = await this.db.query<{ path: string }>(
`SELECT DISTINCT path FROM "${tableName}"`
)
return result.rows.map((row: { path: string }) => row.path)
}
async insertVectors(
data: InsertVector[],
embeddingModel: EmbeddingModel,
): Promise<void> {
if (!this.db) {
throw new DatabaseNotInitializedException()
}
const tableName = this.getTableName(embeddingModel)
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
}
// 构建批量插入的 SQL
const values = data.map((vector, index) => {
const offset = index * 5
return `($${offset + 1}, $${offset + 2}, $${offset + 3}, $${offset + 4}, $${offset + 5})`
}).join(',')
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
}
const params = data.flatMap(vector => [
vector.path,
vector.mtime,
vector.content.replace(/\0/g, ''), // 清理null字节
`[${vector.embedding.join(',')}]`, // 转换为PostgreSQL vector格式
vector.metadata
])
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]
)
}
await this.db.query(
`INSERT INTO "${tableName}" (path, mtime, content, embedding, metadata)
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
)
}
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)
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
let scopeCondition = ''
const params: unknown[] = [`[${queryVector.join(',')}]`, options.minSimilarity, options.limit]
let paramIndex = 4
if (options.scope) {
const conditions: string[] = []
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.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 (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 ')})`
}
if (conditions.length > 0) {
scopeCondition = `AND (${conditions.join(' OR ')})`
}
}
const query = `
const query = `
SELECT
id, path, mtime, content, metadata,
1 - (embedding <=> $1::vector) as similarity
@ -173,8 +218,259 @@ export class VectorRepository {
LIMIT $3
`
type SearchResult = Omit<SelectVector, 'embedding'> & { similarity: number }
const result = await this.db.query<SearchResult>(query, params)
return result.rows
type SearchResult = Omit<SelectVector, 'embedding'> & { similarity: number }
const result = await this.db.query<SearchResult>(query, params)
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)
}
}
}

View File

@ -1,7 +1,6 @@
import { SerializedLexicalNode } from 'lexical'
import { SUPPORT_EMBEDDING_SIMENTION } from '../constants'
import { ApplyStatus } from '../types/apply'
// import { EmbeddingModelId } from '../types/embedding'
// PostgreSQL column types
@ -176,3 +175,65 @@ export type SelectMessage = {
similarity_search_results?: string | null
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
}, {})

View File

@ -94,6 +94,124 @@ export const migrations: Record<string, SqlMigration> = {
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: {
description: "Creates template table with UUID support",
sql: `
@ -132,5 +250,119 @@ export const migrations: Record<string, SqlMigration> = {
"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