mirror of
https://github.com/tcsenpai/pensieve.git
synced 2025-06-06 03:05:25 +00:00
Compare commits
43 Commits
Author | SHA1 | Date | |
---|---|---|---|
![]() |
8c9c45c0e8 | ||
![]() |
7b578196bb | ||
![]() |
c34b6da14d | ||
![]() |
36af2346c8 | ||
![]() |
66aaec4150 | ||
![]() |
2ca70ab541 | ||
![]() |
9a0cb78ed4 | ||
![]() |
aa961a64bf | ||
![]() |
7dd22b27f1 | ||
![]() |
4e7577c6c4 | ||
![]() |
f42cd2f315 | ||
![]() |
820009ac06 | ||
![]() |
e9e83306c1 | ||
![]() |
86dcd79923 | ||
![]() |
fe5f754c23 | ||
![]() |
da8aff7e63 | ||
![]() |
ffef599bf3 | ||
![]() |
8730be2a95 | ||
![]() |
af4ea49f24 | ||
![]() |
e81057d5be | ||
![]() |
23889fac68 | ||
![]() |
7a9ce05350 | ||
![]() |
58a8bf0192 | ||
![]() |
38d50e1e79 | ||
![]() |
78fedfabb6 | ||
![]() |
5808648f66 | ||
![]() |
7c3956109e | ||
![]() |
f1820d0d93 | ||
![]() |
05bfb82fe2 | ||
![]() |
aa0974e037 | ||
![]() |
602d4fa955 | ||
![]() |
e889aa86de | ||
![]() |
bdac92fb34 | ||
![]() |
a6a905387a | ||
![]() |
6ea7dd9e53 | ||
![]() |
517bcda311 | ||
![]() |
7a5e458145 | ||
![]() |
aa484ec906 | ||
![]() |
5966b784bc | ||
![]() |
e540657868 | ||
![]() |
ef1ffa9468 | ||
![]() |
7adb08e6d9 | ||
![]() |
22c2158a58 |
5
.gitignore
vendored
5
.gitignore
vendored
@ -10,3 +10,8 @@ test-data/
|
||||
memos/static/
|
||||
db/
|
||||
memos/plugins/ocr/temp_ppocr.yaml
|
||||
memos.spec
|
||||
memosexec
|
||||
screenshots
|
||||
screenshots/
|
||||
yarn.lock
|
||||
|
111
README.md
111
README.md
@ -4,33 +4,41 @@
|
||||
|
||||
English | [简体中文](README_ZH.md)
|
||||
|
||||
# Memos
|
||||

|
||||
|
||||
Memos is a privacy-focused passive recording project. It can automatically record screen content, build intelligent indices, and provide a convenient web interface to retrieve historical records.
|
||||
> I changed the name to Pensieve because Memos was already taken.
|
||||
|
||||
This project draws heavily from two other projects: one called [Rewind](https://www.rewind.ai/) and another called [Windows Recall](https://support.microsoft.com/en-us/windows/retrace-your-steps-with-recall-aa03f8a0-a78b-4b3e-b0a1-2eb8ac48701c). However, unlike both of them, Memos allows you to have complete control over your data, avoiding the transfer of data to untrusted data centers.
|
||||
# Pensieve (previously named Memos)
|
||||
|
||||
Pensieve is a privacy-focused passive recording project. It can automatically record screen content, build intelligent indices, and provide a convenient web interface to retrieve historical records.
|
||||
|
||||
This project draws heavily from two other projects: one called [Rewind](https://www.rewind.ai/) and another called [Windows Recall](https://support.microsoft.com/en-us/windows/retrace-your-steps-with-recall-aa03f8a0-a78b-4b3e-b0a1-2eb8ac48701c). However, unlike both of them, Pensieve allows you to have complete control over your data, avoiding the transfer of data to untrusted data centers.
|
||||
|
||||
## Features
|
||||
|
||||
- 🚀 Simple installation: just install dependencies via pip to get started
|
||||
- 🔒 Complete data control: all data is stored locally, allowing for full local operation and self-managed data processing
|
||||
- 🔍 Full-text and vector search support
|
||||
- 🤖 Integrates with Ollama, using it as the machine learning engine for Memos
|
||||
- 🤖 Integrates with Ollama, using it as the machine learning engine for Pensieve
|
||||
- 🌐 Compatible with any OpenAI API models (e.g., OpenAI, Azure OpenAI, vLLM, etc.)
|
||||
- 💻 Supports Mac and Windows (Linux support is in development)
|
||||
- 🔌 Extensible functionality through plugins
|
||||
|
||||
## Quick Start
|
||||
|
||||
### 1. Install Memos
|
||||
### MacOS & Windows
|
||||
|
||||

|
||||
|
||||
#### 1. Install Pensieve
|
||||
|
||||
```sh
|
||||
pip install memos
|
||||
```
|
||||
|
||||
### 2. Initialize
|
||||
#### 2. Initialize
|
||||
|
||||
Initialize the memos configuration file and sqlite database:
|
||||
Initialize the pensieve configuration file and sqlite database:
|
||||
|
||||
```sh
|
||||
memos init
|
||||
@ -38,7 +46,7 @@ memos init
|
||||
|
||||
Data will be stored in the `~/.memos` directory.
|
||||
|
||||
### 3. Start the Service
|
||||
#### 3. Start the Service
|
||||
|
||||
```sh
|
||||
memos enable
|
||||
@ -51,12 +59,43 @@ This command will:
|
||||
- Start the Web service
|
||||
- Set the service to start on boot
|
||||
|
||||
### Linux
|
||||
|
||||
**Note:** Linux support is still under development. At the moment, you can run the app by following the steps below.
|
||||
|
||||
**Important:** You need to have `conda` installed to run the app. Also, if something is not working, check the single commands in the shell files to see if they are working.
|
||||
|
||||
- [x] Tested on Ubuntu 22.04 + KDE Plasma + Wayland
|
||||
|
||||
#### 1. Install Dependencies
|
||||
|
||||
```sh
|
||||
./linuxdeps.sh
|
||||
```
|
||||
|
||||
#### 2. Install Pensieve
|
||||
|
||||
```sh
|
||||
./local_setup.sh
|
||||
```
|
||||
|
||||
#### 3. Start the App
|
||||
|
||||
```sh
|
||||
source start.sh
|
||||
```
|
||||
|
||||
### 4. Access the Web Interface
|
||||
|
||||
Open your browser and visit `http://localhost:8839`
|
||||
|
||||
- Default username: `admin`
|
||||
- Default password: `changeme`
|
||||

|
||||
|
||||
### Mac Permission Issues
|
||||
|
||||
On Mac, Pensieve needs screen recording permission. When the program starts, Mac will prompt for screen recording permission - please allow it to proceed.
|
||||
|
||||

|
||||
|
||||
## User Guide
|
||||
|
||||
@ -64,7 +103,7 @@ Open your browser and visit `http://localhost:8839`
|
||||
|
||||
#### 1. Model Selection
|
||||
|
||||
Memos uses embedding models to extract semantic information and build vector indices. Therefore, choosing an appropriate embedding model is crucial. Depending on the user's primary language, different embedding models should be selected.
|
||||
Pensieve uses embedding models to extract semantic information and build vector indices. Therefore, choosing an appropriate embedding model is crucial. Depending on the user's primary language, different embedding models should be selected.
|
||||
|
||||
- For Chinese scenarios, you can use the [jinaai/jina-embeddings-v2-base-zh](https://huggingface.co/jinaai/jina-embeddings-v2-base-zh) model.
|
||||
- For English scenarios, you can use the [jinaai/jina-embeddings-v2-base-en](https://huggingface.co/jinaai/jina-embeddings-v2-base-en) model.
|
||||
@ -77,9 +116,9 @@ Open the `~/.memos/config.yaml` file with your preferred text editor and modify
|
||||
embedding:
|
||||
enabled: true
|
||||
use_local: true
|
||||
model: jinaai/jina-embeddings-v2-base-en # Model name used
|
||||
num_dim: 768 # Model dimensions
|
||||
use_modelscope: false # Whether to use ModelScope's model
|
||||
model: jinaai/jina-embeddings-v2-base-en # Model name used
|
||||
num_dim: 768 # Model dimensions
|
||||
use_modelscope: false # Whether to use ModelScope's model
|
||||
```
|
||||
|
||||
#### 3. Restart Memos Service
|
||||
@ -89,7 +128,7 @@ memos stop
|
||||
memos start
|
||||
```
|
||||
|
||||
The first time you use the embedding model, Memos will automatically download and load the model.
|
||||
The first time you use the embedding model, Pensieve will automatically download and load the model.
|
||||
|
||||
#### 4. Rebuild Index
|
||||
|
||||
@ -103,7 +142,7 @@ The `--force` parameter indicates rebuilding the index table and deleting previo
|
||||
|
||||
### Using Ollama for Visual Search
|
||||
|
||||
By default, Memos only enables the OCR plugin to extract text from screenshots and build indices. However, this method significantly limits search effectiveness for images without text.
|
||||
By default, Pensieve only enables the OCR plugin to extract text from screenshots and build indices. However, this method significantly limits search effectiveness for images without text.
|
||||
|
||||
To achieve more comprehensive visual search capabilities, we need a multimodal image understanding service compatible with the OpenAI API. Ollama perfectly fits this role.
|
||||
|
||||
@ -136,17 +175,17 @@ ollama run minicpm-v "Describe what this service is"
|
||||
|
||||
This command will download and run the minicpm-v model. If the running speed is too slow, it is not recommended to use this feature.
|
||||
|
||||
#### 3. Configure Memos to Use Ollama
|
||||
#### 3. Configure Pensieve to Use Ollama
|
||||
|
||||
Open the `~/.memos/config.yaml` file with your preferred text editor and modify the `vlm` configuration:
|
||||
|
||||
```yaml
|
||||
vlm:
|
||||
enabled: true # Enable VLM feature
|
||||
endpoint: http://localhost:11434 # Ollama service address
|
||||
modelname: minicpm-v # Model name to use
|
||||
force_jpeg: true # Convert images to JPEG format to ensure compatibility
|
||||
prompt: Please describe the content of this image, including the layout and visual elements # Prompt sent to the model
|
||||
enabled: true # Enable VLM feature
|
||||
endpoint: http://localhost:11434 # Ollama service address
|
||||
modelname: minicpm-v # Model name to use
|
||||
force_jpeg: true # Convert images to JPEG format to ensure compatibility
|
||||
prompt: Please describe the content of this image, including the layout and visual elements # Prompt sent to the model
|
||||
```
|
||||
|
||||
Use the above configuration to overwrite the `vlm` configuration in the `~/.memos/config.yaml` file.
|
||||
@ -155,32 +194,32 @@ Also, modify the `default_plugins` configuration in the `~/.memos/plugins/vlm/co
|
||||
|
||||
```yaml
|
||||
default_plugins:
|
||||
- builtin_ocr
|
||||
- builtin_vlm
|
||||
- builtin_ocr
|
||||
- builtin_vlm
|
||||
```
|
||||
|
||||
This adds the `builtin_vlm` plugin to the default plugin list.
|
||||
|
||||
#### 4. Restart Memos Service
|
||||
#### 4. Restart Pensieve Service
|
||||
|
||||
```sh
|
||||
memos stop
|
||||
memos start
|
||||
```
|
||||
|
||||
After restarting the Memos service, wait a moment to see the data extracted by VLM in the latest screenshots on the Memos web interface:
|
||||
After restarting the Pensieve service, wait a moment to see the data extracted by VLM in the latest screenshots on the Pensieve web interface:
|
||||
|
||||

|
||||
|
||||
If you do not see the VLM results, you can:
|
||||
|
||||
- Use the command `memos ps` to check if the Memos process is running normally
|
||||
- Use the command `memos ps` to check if the Pensieve process is running normally
|
||||
- Check for error messages in `~/.memos/logs/memos.log`
|
||||
- Confirm whether the Ollama model is loaded correctly (`ollama ps`)
|
||||
|
||||
### Full Indexing
|
||||
|
||||
Memos is a compute-intensive application. The indexing process requires the collaboration of OCR, VLM, and embedding models. To minimize the impact on the user's computer, Memos calculates the average processing time for each screenshot and adjusts the indexing frequency accordingly. Therefore, not all screenshots are indexed immediately by default.
|
||||
Pensieve is a compute-intensive application. The indexing process requires the collaboration of OCR, VLM, and embedding models. To minimize the impact on the user's computer, Pensieve calculates the average processing time for each screenshot and adjusts the indexing frequency accordingly. Therefore, not all screenshots are indexed immediately by default.
|
||||
|
||||
If you want to index all screenshots, you can use the following command for full indexing:
|
||||
|
||||
@ -192,22 +231,22 @@ This command will scan and index all recorded screenshots. Note that depending o
|
||||
|
||||
## Privacy and Security
|
||||
|
||||
During the development of Memos, I closely followed the progress of similar products, especially [Rewind](https://www.rewind.ai/) and [Windows Recall](https://support.microsoft.com/en-us/windows/retrace-your-steps-with-recall-aa03f8a0-a78b-4b3e-b0a1-2eb8ac48701c). I greatly appreciate their product philosophy, but they do not do enough in terms of privacy protection, which is a concern for many users (or potential users). Recording the screen of a personal computer may expose extremely sensitive private data, such as bank accounts, passwords, chat records, etc. Therefore, ensuring that data storage and processing are completely controlled by the user to prevent data leakage is particularly important.
|
||||
During the development of Pensieve, I closely followed the progress of similar products, especially [Rewind](https://www.rewind.ai/) and [Windows Recall](https://support.microsoft.com/en-us/windows/retrace-your-steps-with-recall-aa03f8a0-a78b-4b3e-b0a1-2eb8ac48701c). I greatly appreciate their product philosophy, but they do not do enough in terms of privacy protection, which is a concern for many users (or potential users). Recording the screen of a personal computer may expose extremely sensitive private data, such as bank accounts, passwords, chat records, etc. Therefore, ensuring that data storage and processing are completely controlled by the user to prevent data leakage is particularly important.
|
||||
|
||||
The advantages of Memos are:
|
||||
The advantages of Pensieve are:
|
||||
|
||||
1. The code is completely open-source and easy-to-understand Python code, allowing anyone to review the code to ensure there are no backdoors.
|
||||
2. Data is completely localized, all data is stored locally, and data processing is entirely controlled by the user. Data will be stored in the user's `~/.memos` directory.
|
||||
3. Easy to uninstall. If you no longer use Memos, you can close the program with `memos stop && memos disable`, then uninstall it with `pip uninstall memos`, and finally delete the `~/.memos` directory to clean up all databases and screenshot data.
|
||||
4. Data processing is entirely controlled by the user. Memos is an independent project, and the machine learning models used (including VLM and embedding models) are chosen by the user. Due to Memos' operating mode, using smaller models can also achieve good results.
|
||||
3. Easy to uninstall. If you no longer use Pensieve, you can close the program with `memos stop && memos disable`, then uninstall it with `pip uninstall memos`, and finally delete the `~/.memos` directory to clean up all databases and screenshot data.
|
||||
4. Data processing is entirely controlled by the user. Pensieve is an independent project, and the machine learning models used (including VLM and embedding models) are chosen by the user. Due to Pensieve' operating mode, using smaller models can also achieve good results.
|
||||
|
||||
Of course, there is still room for improvement in terms of privacy, and contributions are welcome to make Memos better.
|
||||
Of course, there is still room for improvement in terms of privacy, and contributions are welcome to make Pensieve better.
|
||||
|
||||
## Other Noteworthy Content
|
||||
|
||||
### About Storage Space
|
||||
|
||||
Memos records the screen every 5 seconds and saves the original screenshots in the `~/.memos/screenshots` directory. Storage space usage mainly depends on the following factors:
|
||||
Pensieve records the screen every 5 seconds and saves the original screenshots in the `~/.memos/screenshots` directory. Storage space usage mainly depends on the following factors:
|
||||
|
||||
1. **Screenshot Data**:
|
||||
|
||||
@ -225,7 +264,7 @@ Memos records the screen every 5 seconds and saves the original screenshots in t
|
||||
|
||||
### About Power Consumption
|
||||
|
||||
Memos requires two compute-intensive tasks by default:
|
||||
Pensieve requires two compute-intensive tasks by default:
|
||||
|
||||
- One is the OCR task, used to extract text from screenshots
|
||||
- The other is the embedding task, used to extract semantic information and build vector indices
|
||||
@ -241,7 +280,7 @@ Memos requires two compute-intensive tasks by default:
|
||||
|
||||
#### Performance Optimization Strategy
|
||||
|
||||
To avoid affecting users' daily use, Memos has adopted the following optimization measures:
|
||||
To avoid affecting users' daily use, Pensieve has adopted the following optimization measures:
|
||||
|
||||
- Dynamically adjust the indexing frequency, adapting to system processing speed
|
||||
- Automatically reduce processing frequency when on battery power to save power
|
||||
|
79
README_ZH.md
79
README_ZH.md
@ -1,28 +1,34 @@
|
||||
<div align="center">
|
||||
<!-- <div align="center">
|
||||
<img src="web/static/logos/memos_logo_512.png" width="250"/>
|
||||
</div>
|
||||
</div> -->
|
||||
|
||||
[English](README.md) | 简体中文
|
||||
|
||||
# Memos
|
||||

|
||||
|
||||
Memos 是一个专注于隐私的被动记录项目。它可以自动记录屏幕内容,构建智能索引,并提供便捷的 web 界面来检索历史记录。
|
||||
> 我对名字进行了调整,因为 Memos 这个名字已经被其他人注册了,所以改成了 Pensieve。
|
||||
|
||||
这个项目主要参考了另外两个项目,一个叫做 [Rewind](https://www.rewind.ai/),另一个叫做 [Windows Recall](https://support.microsoft.com/en-us/windows/retrace-your-steps-with-recall-aa03f8a0-a78b-4b3e-b0a1-2eb8ac48701c)。不过,与它们不同的是 Memos 让你可以完全管控自己的数据,避免将数据传递到不信任的数据中心。
|
||||
# Pensieve(原 Memos)
|
||||
|
||||
Pensieve 是一个专注于隐私的被动记录项目。它可以自动记录屏幕内容,构建智能索引,并提供便捷的 web 界面来检索历史记录。
|
||||
|
||||
这个项目主要参考了另外两个项目,一个叫做 [Rewind](https://www.rewind.ai/),另一个叫做 [Windows Recall](https://support.microsoft.com/en-us/windows/retrace-your-steps-with-recall-aa03f8a0-a78b-4b3e-b0a1-2eb8ac48701c)。不过,与它们不同的是 Pensieve 让你可以完全管控自己的数据,避免将数据传递到不信任的数据中心。
|
||||
|
||||
## 功能特性
|
||||
|
||||
- 🚀 安装简单,只需要通过 pip 安装依赖就可以开始使用了
|
||||
- 🔒 数据全掌控,所有数据都存储在本地,可以完全本地化运行,数据处理完全由自己控制
|
||||
- 🔍 支持全文检索和向量检索
|
||||
- 🤖 支持和 Ollama 一起工作,让 Ollama 作为 Memos 的机器学习引擎
|
||||
- 🤖 支持和 Ollama 一起工作,让 Ollama 作为 Pensieve 的机器学习引擎
|
||||
- 🌐 支持任何 OpenAI API 兼容的模型(比如 OpenAI, Azure OpenAI,vLLM 等)
|
||||
- 💻 支持 Mac 和 Windows 系统(Linux 支持正在开发中)
|
||||
- 🔌 支持通过插件扩展出更多数据处理能力
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 1. 安装 Memos
|
||||

|
||||
|
||||
### 1. 安装 Pensieve
|
||||
|
||||
```sh
|
||||
pip install -i https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple memos
|
||||
@ -30,7 +36,7 @@ pip install -i https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple memos
|
||||
|
||||
### 2. 初始化
|
||||
|
||||
初始化 memos 的配置文件和 sqlite 数据库:
|
||||
初始化 pensieve 的配置文件和 sqlite 数据库:
|
||||
|
||||
```sh
|
||||
memos init
|
||||
@ -55,8 +61,13 @@ memos start
|
||||
|
||||
打开浏览器,访问 `http://localhost:8839`
|
||||
|
||||
- 默认用户名:`admin`
|
||||
- 默认密码:`changeme`
|
||||

|
||||
|
||||
### Mac 下的权限问题
|
||||
|
||||
在 Mac 下,Pensieve 需要获取截图权限,程序启动的时候,Mac 就会提示需要录屏的权限,请允许即可。
|
||||
|
||||

|
||||
|
||||
## 使用指南
|
||||
|
||||
@ -64,18 +75,17 @@ memos start
|
||||
|
||||
#### 1. 模型选择
|
||||
|
||||
Memos 通过 embedding 模型来提取语义信息,并构建向量索引。因此,选择一个合适的 embedding 模型非常重要。针对使用者的主语言,需要选择不同的 embedding 模型。
|
||||
Pensieve 通过 embedding 模型来提取语义信息,并构建向量索引。因此,选择一个合适的 embedding 模型非常重要。针对使用者的主语言,需要选择不同的 embedding 模型。
|
||||
|
||||
- 对于中文场景,可以使用 [jinaai/jina-embeddings-v2-base-zh](https://huggingface.co/jinaai/jina-embeddings-v2-base-zh) 模型。
|
||||
- 对于英文场景,可以使用 [jinaai/jina-embeddings-v2-base-en](https://huggingface.co/jinaai/jina-embeddings-v2-base-en) 模型。
|
||||
|
||||
#### 2. 调整 Memos 配置
|
||||
#### 2. 调整 Pensieve 配置
|
||||
|
||||
使用你喜欢的文本编辑器打开 `~/.memos/config.yaml` 文件,并修改 `embedding` 配置:
|
||||
|
||||
```yaml
|
||||
embedding:
|
||||
enabled: true
|
||||
use_local: true
|
||||
model: arkohut/jina-embeddings-v2-base-zh # 使用的模型名称
|
||||
num_dim: 768 # 模型的维度
|
||||
@ -85,14 +95,14 @@ embedding:
|
||||
- 配置这里我使用的模型名称为 `arkohut/jina-embeddings-v2-base-zh`,这是我对原始的模型仓库做了裁剪,删除了一些用不到的模型文件,加速下载的速度。
|
||||
- 如果你无法访问 Hugging Face 的模型仓库,可以设置 `use_modelscope` 为 `true`,通过魔搭(ModelScope)模型仓库下载模型。
|
||||
|
||||
#### 3. 重启 Memos 服务
|
||||
#### 3. 重启 Pensieve 服务
|
||||
|
||||
```sh
|
||||
memos stop
|
||||
memos start
|
||||
```
|
||||
|
||||
第一次使用 embedding 模型时,Memos 会自动下载模型并加载模型。
|
||||
第一次使用 embedding 模型时,Pensieve 会自动下载模型并加载模型。
|
||||
|
||||
#### 4. 重新构建索引
|
||||
|
||||
@ -106,7 +116,7 @@ memos reindex --force
|
||||
|
||||
### 使用 Ollama 支持视觉检索
|
||||
|
||||
默认情况下,Memos 仅启用 OCR 插件来提取截图中的文字并建立索引。然而,对于不包含文字的图像,这种方式会大大限制检索效果。
|
||||
默认情况下,Pensieve 仅启用 OCR 插件来提取截图中的文字并建立索引。然而,对于不包含文字的图像,这种方式会大大限制检索效果。
|
||||
|
||||
为了实现更全面的视觉检索功能,我们需要一个兼容 OpenAI API 的多模态图像理解服务。Ollama 正好可以完美胜任这项工作。
|
||||
|
||||
@ -139,13 +149,12 @@ ollama run minicpm-v "描述一下这是什么服务"
|
||||
|
||||
这条命令会下载并运行 minicpm-v 模型,如果发现运行速度太慢的话,不推荐使用这部分功能。
|
||||
|
||||
#### 3. 配置 Memos 使用 Ollama
|
||||
#### 3. 配置 Pensieve 使用 Ollama
|
||||
|
||||
使用你喜欢的文本编辑器打开 `~/.memos/config.yaml` 文件,并修改 `vlm` 配置:
|
||||
|
||||
```yaml
|
||||
vlm:
|
||||
enabled: true # 启用 VLM 功能
|
||||
endpoint: http://localhost:11434 # Ollama 服务地址
|
||||
modelname: minicpm-v # 使用的模型名称
|
||||
force_jpeg: true # 将图片转换为 JPEG 格式以确保兼容性
|
||||
@ -164,26 +173,26 @@ default_plugins:
|
||||
|
||||
这里就是将 `builtin_vlm` 插件添加到默认的插件列表中。
|
||||
|
||||
#### 4. 重启 Memos 服务
|
||||
#### 4. 重启 Pensieve 服务
|
||||
|
||||
```sh
|
||||
memos stop
|
||||
memos start
|
||||
```
|
||||
|
||||
重启 Memos 服务之后,稍等片刻,就可以在 Memos 的 Web 界面中最新的截图里看到通过 VLM 所提取的数据了:
|
||||
重启 Pensieve 服务之后,稍等片刻,就可以在 Pensieve 的 Web 界面中最新的截图里看到通过 VLM 所提取的数据了:
|
||||
|
||||

|
||||
|
||||
如果没有看到 VLM 的结果,可以:
|
||||
|
||||
- 使用命令 `memos ps` 查看 Memos 进程是否正常运行
|
||||
- 使用命令 `memos ps` 查看 Pensieve 进程是否正常运行
|
||||
- 检查 `~/.memos/logs/memos.log` 中是否有错误信息
|
||||
- 确认 Ollama 模型是否正确加载(`ollama ps`)
|
||||
|
||||
### 全量索引
|
||||
|
||||
Memos 是一个计算密集型的应用,Memos 的索引过程会需要 OCR、VLM 以及 embedding 模型协同工作。为了尽量减少对用户电脑的影响,Memos 会计算每个截图的平均处理时间,并依据这个时间来调整索引的频率。因此,默认情况下并不是所有的截图都会被立即索引。
|
||||
Pensieve 是一个计算密集型的应用,Pensieve 的索引过程会需要 OCR、VLM 以及 embedding 模型协同工作。为了尽量减少对用户电脑的影响,Pensieve 会计算每个截图的平均处理时间,并依据这个时间来调整索引的频率。因此,默认情况下并不是所有的截图都会被立即索引。
|
||||
|
||||
如果希望对所有截图进行索引,可以使用以下命令进行全量索引:
|
||||
|
||||
@ -195,22 +204,22 @@ memos scan
|
||||
|
||||
## 隐私安全
|
||||
|
||||
在开发 Memos 的过程中,我一直密切关注类似产品的进展,特别是 [Rewind](https://www.rewind.ai/) 和 [Windows Recall](https://support.microsoft.com/en-us/windows/retrace-your-steps-with-recall-aa03f8a0-a78b-4b3e-b0a1-2eb8ac48701c)。我非常欣赏它们的产品理念,但它们在隐私保护方面做得不够,这也是许多用户(或潜在用户)所担心的问题。记录个人电脑的屏幕可能会暴露极为敏感的隐私数据,如银行账户、密码、聊天记录等。因此,确保数据的存储和处理完全由用户掌控,防止数据泄露,变得尤为重要。
|
||||
在开发 Pensieve 的过程中,我一直密切关注类似产品的进展,特别是 [Rewind](https://www.rewind.ai/) 和 [Windows Recall](https://support.microsoft.com/en-us/windows/retrace-your-steps-with-recall-aa03f8a0-a78b-4b3e-b0a1-2eb8ac48701c)。我非常欣赏它们的产品理念,但它们在隐私保护方面做得不够,这也是许多用户(或潜在用户)所担心的问题。记录个人电脑的屏幕可能会暴露极为敏感的隐私数据,如银行账户、密码、聊天记录等。因此,确保数据的存储和处理完全由用户掌控,防止数据泄露,变得尤为重要。
|
||||
|
||||
Memos 的优势在于:
|
||||
Pensieve 的优势在于:
|
||||
|
||||
1. 代码完全开源,并且是易于理解的 Python 代码,任何人都可以审查代码,确保没有后门。
|
||||
2. 数据完全本地化,所有数据都存储在本地,数据处理完全由用户控制,数据将被存储在用户的 `~/.memos` 目录中。
|
||||
3. 易于卸载,如果不再使用 Memos,通过 `memos stop && memos disable` 即可关闭程序,然后通过 `pip uninstall memos` 即可卸载,最后删除 `~/.memos` 目录即可清理所有的数据库和截图数据。
|
||||
4. 数据处理完全由用户控制,Memos 是一个独立项目,所使用的机器学习模型(包括 VLM 以及 embedding 模型)都由用户自己选择,并且由于 Memos 的运作模式,使用较小的模型也可以达到不错的效果。
|
||||
3. 易于卸载,如果不再使用 Pensieve,通过 `memos stop && memos disable` 即可关闭程序,然后通过 `pip uninstall memos` 即可卸载,最后删除 `~/.memos` 目录即可清理所有的数据库和截图数据。
|
||||
4. 数据处理完全由用户控制,Pensieve 是一个独立项目,所使用的机器学习模型(包括 VLM 以及 embedding 模型)都由用户自己选择,并且由于 Pensieve 的运作模式,使用较小的模型也可以达到不错的效果。
|
||||
|
||||
当然 Memos 肯定在隐私方面依然有可以改进的地方,欢迎大家贡献代码,一起让 Memos 变得更好。
|
||||
当然 Pensieve 肯定在隐私方面依然有可以改进的地方,欢迎大家贡献代码,一起让 Pensieve 变得更好。
|
||||
|
||||
## 其他值得注意的内容
|
||||
|
||||
### 有关存储空间
|
||||
|
||||
Memos 每 5 秒会记录一次屏幕,并将原始截图保存到 `~/.memos/screenshots` 目录中。存储空间占用主要取决于以下因素:
|
||||
Pensieve 每 5 秒会记录一次屏幕,并将原始截图保存到 `~/.memos/screenshots` 目录中。存储空间占用主要取决于以下因素:
|
||||
|
||||
1. **截图数据**:
|
||||
|
||||
@ -228,7 +237,7 @@ Memos 每 5 秒会记录一次屏幕,并将原始截图保存到 `~/.memos/scr
|
||||
|
||||
### 有关功耗
|
||||
|
||||
Memos 默认需要两个计算密集型的任务:
|
||||
Pensieve 默认需要两个计算密集型的任务:
|
||||
|
||||
- 一个是 OCR 任务,用于提取截图中的文字
|
||||
- 一个是 embedding 任务,用于提取语义信息构建向量索引
|
||||
@ -244,11 +253,19 @@ Memos 默认需要两个计算密集型的任务:
|
||||
|
||||
#### 性能优化策略
|
||||
|
||||
为了避免影响用户日常使用,Memos 采取了以下优化措施:
|
||||
为了避免影响用户日常使用,Pensieve 采取了以下优化措施:
|
||||
|
||||
- 动态调整索引频率,根据系统处理速度自适应
|
||||
- 电池供电时自动降低处理频率,最大程度节省电量
|
||||
|
||||
## 开发指南
|
||||
|
||||
to be continued
|
||||
### 拨开第一层洋葱
|
||||
|
||||
事实上,Pensieve 启动之后,会运行三个程序:
|
||||
|
||||
1. `memos serve` 启动 Web 服务
|
||||
2. `memos record` 启动截图记录程序
|
||||
3. `memos watch` 监听 `memos record` 所生成的图像事件,并结合实际的处理速度动态的向服务器提交索引请求
|
||||
|
||||
所以,如果你是开发者,或者希望更清晰的看到整个项目运行的日志,你完全可以使用这三个命令让每个部分在前台运行,去替代 `memos enable && memos start` 命令。
|
||||
|
BIN
docs/images/init-page-cn.png
Normal file
BIN
docs/images/init-page-cn.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 493 KiB |
BIN
docs/images/init-page-en.png
Normal file
BIN
docs/images/init-page-en.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 484 KiB |
BIN
docs/images/mac-security-permission.jpg
Normal file
BIN
docs/images/mac-security-permission.jpg
Normal file
Binary file not shown.
After Width: | Height: | Size: 273 KiB |
BIN
docs/images/memos-installation.gif
Normal file
BIN
docs/images/memos-installation.gif
Normal file
Binary file not shown.
After Width: | Height: | Size: 9.8 MiB |
BIN
docs/images/memos-search-cn.gif
Normal file
BIN
docs/images/memos-search-cn.gif
Normal file
Binary file not shown.
After Width: | Height: | Size: 6.6 MiB |
BIN
docs/images/memos-search-en.gif
Normal file
BIN
docs/images/memos-search-en.gif
Normal file
Binary file not shown.
After Width: | Height: | Size: 7.8 MiB |
BIN
docs/videos/memos-github-cn.mp4
Normal file
BIN
docs/videos/memos-github-cn.mp4
Normal file
Binary file not shown.
BIN
docs/videos/memos-github.mp4
Normal file
BIN
docs/videos/memos-github.mp4
Normal file
Binary file not shown.
BIN
docs/videos/memos-installation.mp4
Normal file
BIN
docs/videos/memos-installation.mp4
Normal file
Binary file not shown.
55
environment.yml
Normal file
55
environment.yml
Normal file
@ -0,0 +1,55 @@
|
||||
name: memos
|
||||
channels:
|
||||
- defaults
|
||||
dependencies:
|
||||
- _libgcc_mutex=0.1=main
|
||||
- _openmp_mutex=5.1=1_gnu
|
||||
- bzip2=1.0.8=h5eee18b_6
|
||||
- ca-certificates=2024.9.24=h06a4308_0
|
||||
- ld_impl_linux-64=2.40=h12ee557_0
|
||||
- libffi=3.4.4=h6a678d5_1
|
||||
- libgcc-ng=11.2.0=h1234567_1
|
||||
- libgomp=11.2.0=h1234567_1
|
||||
- libstdcxx-ng=11.2.0=h1234567_1
|
||||
- libuuid=1.41.5=h5eee18b_0
|
||||
- ncurses=6.4=h6a678d5_0
|
||||
- openssl=3.0.15=h5eee18b_0
|
||||
- pip=24.2=py310h06a4308_0
|
||||
- python=3.10.15=he870216_1
|
||||
- readline=8.2=h5eee18b_0
|
||||
- setuptools=75.1.0=py310h06a4308_0
|
||||
- sqlite=3.45.3=h5eee18b_0
|
||||
- tk=8.6.14=h39e8969_0
|
||||
- tzdata=2024b=h04d1e81_0
|
||||
- wheel=0.44.0=py310h06a4308_0
|
||||
- xz=5.4.6=h5eee18b_1
|
||||
- zlib=1.2.13=h5eee18b_1
|
||||
- pip:
|
||||
- certifi==2024.8.30
|
||||
- colorama==0.4.6
|
||||
- coloredlogs==15.0.1
|
||||
- dbus-python==1.3.2
|
||||
- einops==0.8.0
|
||||
- fastapi==0.115.5
|
||||
- humanfriendly==10.0
|
||||
- imagehash==4.3.1
|
||||
- magika==0.5.1
|
||||
- memos==0.18.7
|
||||
- modelscope==1.20.1
|
||||
- mss==10.0.0
|
||||
- onnxruntime==1.20.0
|
||||
- piexif==1.1.3
|
||||
- psutil==6.1.0
|
||||
- py-cpuinfo==9.0.0
|
||||
- pydantic-settings==2.6.1
|
||||
- python-xlib==0.33
|
||||
- pywavelets==1.7.0
|
||||
- rapidocr-onnxruntime==1.3.25
|
||||
- shellingham==1.5.4
|
||||
- six==1.16.0
|
||||
- sqlite-vec==0.1.5
|
||||
- starlette==0.41.2
|
||||
- termcolor==2.5.0
|
||||
- timm==1.0.11
|
||||
- typer==0.13.0
|
||||
- uvicorn==0.32.0
|
1
linuxdeps.sh
Executable file
1
linuxdeps.sh
Executable file
@ -0,0 +1 @@
|
||||
sudo apt install -y dbus-python python-xlib slurp grim maim spectacle
|
24
local_setup.sh
Executable file
24
local_setup.sh
Executable file
@ -0,0 +1,24 @@
|
||||
#! /bin/bash
|
||||
|
||||
# Build web app
|
||||
cd web
|
||||
yarn || exit 1
|
||||
yarn build || exit 1
|
||||
cd ..
|
||||
|
||||
# Install linux dependencies
|
||||
./linuxdeps.sh || exit 1
|
||||
|
||||
# Install python dependencies in conda environment
|
||||
conda env create -f environment.yml || exit 1
|
||||
|
||||
# Activate conda environment
|
||||
conda activate memos || exit 1
|
||||
|
||||
# Initialize database
|
||||
python memos_app.py init || exit 1
|
||||
|
||||
# Deactivate and exit
|
||||
conda deactivate
|
||||
echo "Setup complete. Please run 'conda activate memos' to use the environment and then 'python start.py' to start the full app."
|
||||
echo "You can also run 'source start.sh' to start the full app in one go."
|
@ -15,6 +15,8 @@ from functools import lru_cache
|
||||
from collections import defaultdict, deque
|
||||
|
||||
# Third-party imports
|
||||
import platform
|
||||
import subprocess
|
||||
import typer
|
||||
import httpx
|
||||
from tqdm import tqdm
|
||||
@ -507,7 +509,10 @@ async def add_entity(
|
||||
post_response = await client.post(
|
||||
f"{BASE_URL}/libraries/{library_id}/entities",
|
||||
json=new_entity,
|
||||
params={"plugins": plugins} if plugins else {},
|
||||
params={
|
||||
"plugins": plugins,
|
||||
"update_index": "true"
|
||||
} if plugins else {"update_index": "true"},
|
||||
timeout=60,
|
||||
)
|
||||
if 200 <= post_response.status_code < 300:
|
||||
@ -546,6 +551,7 @@ async def update_entity(
|
||||
json=new_entity,
|
||||
params={
|
||||
"trigger_webhooks_flag": "true",
|
||||
"update_index": "true",
|
||||
**({"plugins": plugins} if plugins else {}),
|
||||
},
|
||||
timeout=60,
|
||||
@ -581,6 +587,7 @@ def reindex(
|
||||
force: bool = typer.Option(
|
||||
False, "--force", help="Force recreate FTS and vector tables before reindexing"
|
||||
),
|
||||
batch_size: int = typer.Option(1, "--batch-size", "-bs", help="Batch size for processing entities"),
|
||||
):
|
||||
print(f"Reindexing library {library_id}")
|
||||
|
||||
@ -608,7 +615,7 @@ def reindex(
|
||||
recreate_fts_and_vec_tables()
|
||||
print("FTS and vector tables have been recreated.")
|
||||
|
||||
with httpx.Session() as client:
|
||||
with httpx.Client() as client:
|
||||
total_entities = 0
|
||||
|
||||
# Get total entity count for all folders
|
||||
@ -647,56 +654,34 @@ def reindex(
|
||||
if not entities:
|
||||
break
|
||||
|
||||
# Update last_scan_at for each entity
|
||||
for entity in entities:
|
||||
if entity["id"] in scanned_entities:
|
||||
continue
|
||||
|
||||
update_response = client.post(
|
||||
f"{BASE_URL}/entities/{entity['id']}/last-scan-at"
|
||||
)
|
||||
if update_response.status_code != 204:
|
||||
print(
|
||||
f"Failed to update last_scan_at for entity {entity['id']}: {update_response.status_code} - {update_response.text}"
|
||||
# 收集需要处理的实体 ID
|
||||
entity_ids = [
|
||||
entity["id"]
|
||||
for entity in entities
|
||||
if entity["id"] not in scanned_entities
|
||||
]
|
||||
|
||||
# 按 batch_size 分批处理
|
||||
for i in range(0, len(entity_ids), batch_size):
|
||||
batch_ids = entity_ids[i:i + batch_size]
|
||||
if batch_ids:
|
||||
batch_response = client.post(
|
||||
f"{BASE_URL}/entities/batch-index",
|
||||
json={"entity_ids": batch_ids},
|
||||
timeout=60,
|
||||
)
|
||||
else:
|
||||
scanned_entities.add(entity["id"])
|
||||
|
||||
pbar.update(1)
|
||||
if batch_response.status_code != 204:
|
||||
print(
|
||||
f"Failed to update batch: {batch_response.status_code} - {batch_response.text}"
|
||||
)
|
||||
pbar.update(len(batch_ids))
|
||||
scanned_entities.update(batch_ids)
|
||||
|
||||
offset += limit
|
||||
|
||||
print(f"Reindexing completed for library {library_id}")
|
||||
|
||||
|
||||
async def check_and_index_entity(client, entity_id, entity_last_scan_at):
|
||||
try:
|
||||
index_response = client.get(f"{BASE_URL}/entities/{entity_id}/index")
|
||||
if index_response.status_code == 200:
|
||||
index_data = index_response.json()
|
||||
if index_data["last_scan_at"] is None:
|
||||
return entity_last_scan_at is not None
|
||||
index_last_scan_at = datetime.fromtimestamp(index_data["last_scan_at"])
|
||||
entity_last_scan_at = datetime.fromisoformat(entity_last_scan_at)
|
||||
|
||||
if index_last_scan_at >= entity_last_scan_at:
|
||||
return False # Index is up to date, no need to update
|
||||
return True # Index doesn't exist or needs update
|
||||
except httpx.HTTPStatusError as e:
|
||||
if e.response.status_code == 404:
|
||||
return True # Index doesn't exist, need to create
|
||||
raise # Re-raise other HTTP errors
|
||||
|
||||
|
||||
async def index_batch(client, entity_ids):
|
||||
index_response = client.post(
|
||||
f"{BASE_URL}/entities/batch-index",
|
||||
json=entity_ids,
|
||||
timeout=60,
|
||||
)
|
||||
return index_response
|
||||
|
||||
|
||||
@lib_app.command("sync")
|
||||
def sync(
|
||||
library_id: int,
|
||||
@ -799,7 +784,10 @@ def sync(
|
||||
update_response = httpx.put(
|
||||
f"{BASE_URL}/entities/{existing_entity['id']}",
|
||||
json=new_entity,
|
||||
params={"trigger_webhooks_flag": str(not without_webhooks).lower()},
|
||||
params={
|
||||
"trigger_webhooks_flag": str(not without_webhooks).lower(),
|
||||
"update_index": "true",
|
||||
},
|
||||
timeout=60,
|
||||
)
|
||||
if update_response.status_code == 200:
|
||||
@ -829,7 +817,10 @@ def sync(
|
||||
create_response = httpx.post(
|
||||
f"{BASE_URL}/libraries/{library_id}/entities",
|
||||
json=new_entity,
|
||||
params={"trigger_webhooks_flag": str(not without_webhooks).lower()},
|
||||
params={
|
||||
"trigger_webhooks_flag": str(not without_webhooks).lower(),
|
||||
"update_index": "true",
|
||||
},
|
||||
timeout=60,
|
||||
)
|
||||
|
||||
@ -850,12 +841,31 @@ def sync(
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def is_on_battery():
|
||||
try:
|
||||
battery = psutil.sensors_battery()
|
||||
return battery is not None and not battery.power_plugged
|
||||
except:
|
||||
return False # If unable to detect battery status, assume not on battery
|
||||
|
||||
|
||||
if platform.system() == "Darwin":
|
||||
try:
|
||||
result = subprocess.check_output(['pmset', '-g', 'batt']).decode()
|
||||
return "'Battery Power'" in result
|
||||
except:
|
||||
return False
|
||||
elif platform.system() == "Windows":
|
||||
try:
|
||||
return psutil.sensors_battery().power_plugged == False
|
||||
except:
|
||||
return False
|
||||
elif platform.system() == "Linux":
|
||||
try:
|
||||
# Try using upower
|
||||
result = subprocess.check_output(['upower', '--show-info', '/org/freedesktop/UPower/devices/battery_BAT0']).decode()
|
||||
return 'state: discharging' in result.lower()
|
||||
except:
|
||||
try:
|
||||
# Fallback to checking /sys/class/power_supply
|
||||
with open('/sys/class/power_supply/BAT0/status', 'r') as f:
|
||||
return f.read().strip().lower() == 'discharging'
|
||||
except:
|
||||
return False
|
||||
return False
|
||||
|
||||
# Modify the LibraryFileHandler class
|
||||
class LibraryFileHandler(FileSystemEventHandler):
|
||||
|
@ -1,5 +1,6 @@
|
||||
# Standard library imports
|
||||
import os
|
||||
import time
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from datetime import datetime, timedelta
|
||||
@ -136,7 +137,7 @@ def get_or_create_default_library():
|
||||
# Check if the library is empty
|
||||
if not default_library["folders"]:
|
||||
# Add the screenshots directory to the library
|
||||
screenshots_dir = Path(settings.screenshots_dir).resolve()
|
||||
screenshots_dir = Path(settings.resolved_screenshots_dir).resolve()
|
||||
folder = {
|
||||
"path": str(screenshots_dir),
|
||||
"last_modified_at": datetime.fromtimestamp(
|
||||
@ -191,7 +192,10 @@ def scan_default_library(
|
||||
def reindex_default_library(
|
||||
force: bool = typer.Option(
|
||||
False, "--force", help="Force recreate FTS and vector tables before reindexing"
|
||||
)
|
||||
),
|
||||
batch_size: int = typer.Option(
|
||||
1, "--batch-size", "-bs", help="Batch size for processing files"
|
||||
),
|
||||
):
|
||||
"""
|
||||
Reindex the default library for memos.
|
||||
@ -215,7 +219,7 @@ def reindex_default_library(
|
||||
|
||||
# Reindex the library
|
||||
print(f"Reindexing library: {default_library['name']}")
|
||||
reindex(default_library["id"], force=force, folders=None)
|
||||
reindex(default_library["id"], force=force, folders=None, batch_size=batch_size)
|
||||
|
||||
|
||||
@app.command("record")
|
||||
@ -396,7 +400,9 @@ def generate_plist():
|
||||
</dict>
|
||||
</plist>
|
||||
"""
|
||||
plist_path = Path.home() / "Library/LaunchAgents/com.user.memos.plist"
|
||||
plist_dir = Path.home() / "Library/LaunchAgents"
|
||||
plist_dir.mkdir(parents=True, exist_ok=True)
|
||||
plist_path = plist_dir / "com.user.memos.plist"
|
||||
with open(plist_path, "w") as f:
|
||||
f.write(plist_content)
|
||||
return plist_path
|
||||
@ -444,30 +450,43 @@ def remove_windows_autostart():
|
||||
return False
|
||||
|
||||
|
||||
@app.command()
|
||||
def disable():
|
||||
"""Disable memos from running at startup"""
|
||||
if is_windows():
|
||||
if remove_windows_autostart():
|
||||
typer.echo(
|
||||
"Removed Memos shortcut from startup folder. Memos will no longer run at startup."
|
||||
)
|
||||
else:
|
||||
typer.echo(
|
||||
"Memos shortcut not found in startup folder. Memos is not set to run at startup."
|
||||
)
|
||||
elif is_macos():
|
||||
plist_path = Path.home() / "Library/LaunchAgents/com.user.memos.plist"
|
||||
if plist_path.exists():
|
||||
subprocess.run(["launchctl", "unload", str(plist_path)], check=True)
|
||||
plist_path.unlink()
|
||||
typer.echo(
|
||||
"Unloaded and removed plist file. Memos will no longer run at startup."
|
||||
)
|
||||
else:
|
||||
typer.echo("Plist file does not exist. Memos is not set to run at startup.")
|
||||
else:
|
||||
typer.echo("Unsupported operating system.")
|
||||
def generate_systemd_service():
|
||||
"""Generate systemd service file for Linux."""
|
||||
memos_dir = settings.resolved_base_dir
|
||||
python_path = get_python_path()
|
||||
log_dir = memos_dir / "logs"
|
||||
log_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
service_content = f"""[Unit]
|
||||
Description=Memos Service
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
Environment="PATH={os.environ['PATH']}"
|
||||
ExecStart={python_path} -m memos.commands record
|
||||
ExecStart={python_path} -m memos.commands serve
|
||||
ExecStartPre=/bin/sleep 15
|
||||
ExecStart={python_path} -m memos.commands watch
|
||||
Restart=always
|
||||
User={os.getenv('USER')}
|
||||
StandardOutput=append:{log_dir}/memos.log
|
||||
StandardError=append:{log_dir}/memos.error.log
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
"""
|
||||
|
||||
service_path = Path.home() / ".config/systemd/user"
|
||||
service_path.mkdir(parents=True, exist_ok=True)
|
||||
service_file = service_path / "memos.service"
|
||||
with open(service_file, "w") as f:
|
||||
f.write(service_content)
|
||||
return service_file
|
||||
|
||||
|
||||
def is_linux():
|
||||
return platform.system() == "Linux"
|
||||
|
||||
|
||||
@app.command()
|
||||
@ -491,9 +510,145 @@ def enable():
|
||||
plist_path = generate_plist()
|
||||
typer.echo(f"Generated plist file at {plist_path}")
|
||||
load_plist(plist_path)
|
||||
typer.echo(
|
||||
"Loaded plist file. Memos is started and will run at next startup or when 'start' command is used."
|
||||
)
|
||||
typer.echo("Loaded plist file. Memos will run at next startup.")
|
||||
elif is_linux():
|
||||
service_file = generate_systemd_service()
|
||||
typer.echo(f"Generated systemd service file at {service_file}")
|
||||
# Enable and start the service
|
||||
subprocess.run(["systemctl", "--user", "enable", "memos.service"], check=True)
|
||||
typer.echo("Enabled memos systemd service for current user.")
|
||||
else:
|
||||
typer.echo("Unsupported operating system.")
|
||||
|
||||
|
||||
@app.command()
|
||||
def disable():
|
||||
"""Disable memos from running at startup"""
|
||||
if is_windows():
|
||||
if remove_windows_autostart():
|
||||
typer.echo("Removed Memos shortcut from startup folder.")
|
||||
else:
|
||||
typer.echo("Memos shortcut not found in startup folder.")
|
||||
elif is_macos():
|
||||
plist_path = Path.home() / "Library/LaunchAgents/com.user.memos.plist"
|
||||
if plist_path.exists():
|
||||
subprocess.run(["launchctl", "unload", str(plist_path)], check=True)
|
||||
plist_path.unlink()
|
||||
typer.echo("Unloaded and removed plist file.")
|
||||
else:
|
||||
typer.echo("Plist file does not exist.")
|
||||
elif is_linux():
|
||||
service_file = Path.home() / ".config/systemd/user/memos.service"
|
||||
if service_file.exists():
|
||||
subprocess.run(
|
||||
["systemctl", "--user", "disable", "memos.service"], check=True
|
||||
)
|
||||
subprocess.run(["systemctl", "--user", "stop", "memos.service"], check=True)
|
||||
service_file.unlink()
|
||||
typer.echo("Disabled and removed memos systemd service.")
|
||||
else:
|
||||
typer.echo("Systemd service file does not exist.")
|
||||
else:
|
||||
typer.echo("Unsupported operating system.")
|
||||
|
||||
|
||||
@app.command()
|
||||
def start():
|
||||
"""Start all Memos processes"""
|
||||
memos_dir = settings.resolved_base_dir
|
||||
|
||||
if is_windows():
|
||||
bat_path = memos_dir / "launch.bat"
|
||||
if not bat_path.exists():
|
||||
typer.echo("Launch script not found. Please run 'memos enable' first.")
|
||||
return
|
||||
try:
|
||||
subprocess.Popen(
|
||||
[str(bat_path)], shell=True, creationflags=subprocess.CREATE_NEW_CONSOLE
|
||||
)
|
||||
typer.echo("Started Memos processes.")
|
||||
except Exception as e:
|
||||
typer.echo(f"Failed to start Memos processes: {str(e)}")
|
||||
elif is_macos():
|
||||
service_name = "com.user.memos"
|
||||
subprocess.run(["launchctl", "start", service_name], check=True)
|
||||
typer.echo("Started Memos processes.")
|
||||
elif is_linux():
|
||||
try:
|
||||
subprocess.run(
|
||||
["systemctl", "--user", "start", "memos.service"], check=True
|
||||
)
|
||||
typer.echo("Started Memos processes.")
|
||||
except subprocess.CalledProcessError as e:
|
||||
typer.echo(f"Failed to start Memos processes: {str(e)}")
|
||||
else:
|
||||
typer.echo("Unsupported operating system.")
|
||||
|
||||
|
||||
@app.command()
|
||||
def stop():
|
||||
"""Stop all running Memos processes"""
|
||||
if is_windows():
|
||||
services = ["serve", "watch", "record"]
|
||||
stopped = False
|
||||
for service in services:
|
||||
processes = [
|
||||
p
|
||||
for p in psutil.process_iter(["pid", "name", "cmdline"])
|
||||
if "python" in p.info["name"].lower()
|
||||
and p.info["cmdline"] is not None
|
||||
and "memos.commands" in p.info["cmdline"]
|
||||
and service in p.info["cmdline"]
|
||||
]
|
||||
|
||||
for process in processes:
|
||||
try:
|
||||
os.kill(process.info["pid"], signal.SIGTERM)
|
||||
typer.echo(
|
||||
f"Stopped {service} process (PID: {process.info['pid']})"
|
||||
)
|
||||
stopped = True
|
||||
except ProcessLookupError:
|
||||
typer.echo(
|
||||
f"Process {service} (PID: {process.info['pid']}) not found"
|
||||
)
|
||||
except PermissionError:
|
||||
typer.echo(
|
||||
f"Permission denied to stop {service} process (PID: {process.info['pid']})"
|
||||
)
|
||||
|
||||
if not stopped:
|
||||
typer.echo("No running Memos processes found")
|
||||
elif is_macos():
|
||||
service_name = "com.user.memos"
|
||||
try:
|
||||
subprocess.run(["launchctl", "stop", service_name], check=True)
|
||||
typer.echo("Stopped Memos processes.")
|
||||
except subprocess.CalledProcessError:
|
||||
typer.echo("Failed to stop Memos processes. They may not be running.")
|
||||
elif is_linux():
|
||||
try:
|
||||
subprocess.run(["systemctl", "--user", "stop", "memos.service"], check=True)
|
||||
typer.echo("Stopped Memos processes.")
|
||||
except subprocess.CalledProcessError:
|
||||
# Fallback to manual process killing if systemd service fails
|
||||
services = ["serve", "watch", "record"]
|
||||
stopped = False
|
||||
for service in services:
|
||||
try:
|
||||
output = subprocess.check_output(
|
||||
["pgrep", "-f", f"memos.commands {service}"]
|
||||
)
|
||||
pids = output.decode().strip().split()
|
||||
for pid in pids:
|
||||
os.kill(int(pid), signal.SIGTERM)
|
||||
typer.echo(f"Stopped {service} process (PID: {pid})")
|
||||
stopped = True
|
||||
except (subprocess.CalledProcessError, ProcessLookupError):
|
||||
continue
|
||||
|
||||
if not stopped:
|
||||
typer.echo("No running Memos processes found")
|
||||
else:
|
||||
typer.echo("Unsupported operating system.")
|
||||
|
||||
@ -532,83 +687,6 @@ def ps():
|
||||
typer.echo(tabulate(table_data, headers=headers, tablefmt="plain"))
|
||||
|
||||
|
||||
@app.command()
|
||||
def stop():
|
||||
"""Stop all running Memos processes"""
|
||||
if is_windows():
|
||||
services = ["serve", "watch", "record"]
|
||||
stopped = False
|
||||
|
||||
for service in services:
|
||||
processes = [
|
||||
p
|
||||
for p in psutil.process_iter(["pid", "name", "cmdline"])
|
||||
if "python" in p.info["name"].lower()
|
||||
and p.info["cmdline"] is not None
|
||||
and "memos.commands" in p.info["cmdline"]
|
||||
and service in p.info["cmdline"]
|
||||
]
|
||||
|
||||
for process in processes:
|
||||
try:
|
||||
os.kill(process.info["pid"], signal.SIGTERM)
|
||||
typer.echo(
|
||||
f"Stopped {service} process (PID: {process.info['pid']})"
|
||||
)
|
||||
stopped = True
|
||||
except ProcessLookupError:
|
||||
typer.echo(
|
||||
f"Process {service} (PID: {process.info['pid']}) not found"
|
||||
)
|
||||
except PermissionError:
|
||||
typer.echo(
|
||||
f"Permission denied to stop {service} process (PID: {process.info['pid']})"
|
||||
)
|
||||
|
||||
if not stopped:
|
||||
typer.echo("No running Memos processes found")
|
||||
else:
|
||||
typer.echo("All Memos processes have been stopped")
|
||||
|
||||
elif is_macos():
|
||||
service_name = "com.user.memos"
|
||||
try:
|
||||
subprocess.run(["launchctl", "stop", service_name], check=True)
|
||||
typer.echo("Stopped Memos processes.")
|
||||
except subprocess.CalledProcessError:
|
||||
typer.echo("Failed to stop Memos processes. They may not be running.")
|
||||
|
||||
else:
|
||||
typer.echo("Unsupported operating system.")
|
||||
|
||||
|
||||
@app.command()
|
||||
def start():
|
||||
"""Start all Memos processes"""
|
||||
memos_dir = settings.resolved_base_dir
|
||||
|
||||
if is_windows():
|
||||
bat_path = memos_dir / "launch.bat"
|
||||
if not bat_path.exists():
|
||||
typer.echo("Launch script not found. Please run 'memos enable' first.")
|
||||
return
|
||||
|
||||
try:
|
||||
subprocess.Popen(
|
||||
[str(bat_path)], shell=True, creationflags=subprocess.CREATE_NEW_CONSOLE
|
||||
)
|
||||
typer.echo("Started Memos processes. Check the logs for more information.")
|
||||
except Exception as e:
|
||||
typer.echo(f"Failed to start Memos processes: {str(e)}")
|
||||
|
||||
elif is_macos():
|
||||
service_name = "com.user.memos"
|
||||
subprocess.run(["launchctl", "start", service_name], check=True)
|
||||
typer.echo("Started Memos processes.")
|
||||
else:
|
||||
typer.echo("Unsupported operating system.")
|
||||
|
||||
|
||||
@app.command()
|
||||
def config():
|
||||
"""Show current configuration settings"""
|
||||
|
@ -70,8 +70,8 @@ class Settings(BaseSettings):
|
||||
# Embedding settings
|
||||
embedding: EmbeddingSettings = EmbeddingSettings()
|
||||
|
||||
auth_username: str = "admin"
|
||||
auth_password: SecretStr = SecretStr("changeme")
|
||||
auth_username: str = ""
|
||||
auth_password: SecretStr = SecretStr("")
|
||||
|
||||
default_plugins: List[str] = ["builtin_ocr"]
|
||||
|
||||
@ -153,12 +153,14 @@ def get_database_path():
|
||||
|
||||
|
||||
def format_value(value):
|
||||
if isinstance(value, (VLMSettings, OCRSettings, EmbeddingSettings)):
|
||||
return (
|
||||
"{\n"
|
||||
+ "\n".join(f" {k}: {v}" for k, v in value.model_dump().items())
|
||||
+ "\n }"
|
||||
)
|
||||
if isinstance(value, dict):
|
||||
# Format nested dictionary with proper indentation
|
||||
formatted_items = []
|
||||
for k, v in value.items():
|
||||
# Add proper indentation and alignment for nested items
|
||||
formatted_value = str(v)
|
||||
formatted_items.append(f" {k:<12} : {formatted_value}")
|
||||
return "\n" + "\n".join(formatted_items)
|
||||
elif isinstance(value, (list, tuple)):
|
||||
return f"[{', '.join(map(str, value))}]"
|
||||
elif isinstance(value, SecretStr):
|
||||
@ -178,9 +180,10 @@ def display_config():
|
||||
if key in ["base_dir", "database_path", "screenshots_dir"]:
|
||||
resolved_value = getattr(settings, f"resolved_{key}")
|
||||
formatted_value += f" (resolved: {resolved_value})"
|
||||
|
||||
# 如果值包含换行符,使用多行格式打印
|
||||
if "\n" in formatted_value:
|
||||
typer.echo(f"{key}:")
|
||||
for line in formatted_value.split("\n"):
|
||||
typer.echo(f" {line}")
|
||||
typer.echo(f"{key.ljust(max_key_length)} :{formatted_value}")
|
||||
else:
|
||||
# 对于单行值,在同一行打印
|
||||
typer.echo(f"{key.ljust(max_key_length)} : {formatted_value}")
|
||||
|
243
memos/crud.py
243
memos/crud.py
@ -25,13 +25,12 @@ from .models import (
|
||||
EntityMetadataModel,
|
||||
EntityTagModel,
|
||||
)
|
||||
import numpy as np
|
||||
from collections import defaultdict
|
||||
from .embedding import get_embeddings
|
||||
import logging
|
||||
from sqlite_vec import serialize_float32
|
||||
import time
|
||||
import asyncio
|
||||
import json
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@ -100,7 +99,11 @@ def add_folders(library_id: int, folders: NewFoldersParam, db: Session) -> Libra
|
||||
return Library(**db_library.__dict__)
|
||||
|
||||
|
||||
def create_entity(library_id: int, entity: NewEntityParam, db: Session) -> Entity:
|
||||
def create_entity(
|
||||
library_id: int,
|
||||
entity: NewEntityParam,
|
||||
db: Session,
|
||||
) -> Entity:
|
||||
tags = entity.tags
|
||||
metadata_entries = entity.metadata_entries
|
||||
|
||||
@ -146,6 +149,7 @@ def create_entity(library_id: int, entity: NewEntityParam, db: Session) -> Entit
|
||||
db.add(entity_metadata)
|
||||
db.commit()
|
||||
db.refresh(db_entity)
|
||||
|
||||
return Entity(**db_entity.__dict__)
|
||||
|
||||
|
||||
@ -184,6 +188,13 @@ def get_entities_by_filepaths(filepaths: List[str], db: Session) -> List[Entity]
|
||||
def remove_entity(entity_id: int, db: Session):
|
||||
entity = db.query(EntityModel).filter(EntityModel.id == entity_id).first()
|
||||
if entity:
|
||||
# Delete the entity from FTS and vec tables first
|
||||
db.execute(text("DELETE FROM entities_fts WHERE id = :id"), {"id": entity_id})
|
||||
db.execute(
|
||||
text("DELETE FROM entities_vec WHERE rowid = :id"), {"id": entity_id}
|
||||
)
|
||||
|
||||
# Then delete the entity itself
|
||||
db.delete(entity)
|
||||
db.commit()
|
||||
else:
|
||||
@ -230,7 +241,9 @@ def find_entities_by_ids(entity_ids: List[int], db: Session) -> List[Entity]:
|
||||
|
||||
|
||||
def update_entity(
|
||||
entity_id: int, updated_entity: UpdateEntityParam, db: Session
|
||||
entity_id: int,
|
||||
updated_entity: UpdateEntityParam,
|
||||
db: Session,
|
||||
) -> Entity:
|
||||
db_entity = db.query(EntityModel).filter(EntityModel.id == entity_id).first()
|
||||
|
||||
@ -287,6 +300,7 @@ def update_entity(
|
||||
|
||||
db.commit()
|
||||
db.refresh(db_entity)
|
||||
|
||||
return Entity(**db_entity.__dict__)
|
||||
|
||||
|
||||
@ -301,14 +315,18 @@ def touch_entity(entity_id: int, db: Session) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def update_entity_tags(entity_id: int, tags: List[str], db: Session) -> Entity:
|
||||
def update_entity_tags(
|
||||
entity_id: int,
|
||||
tags: List[str],
|
||||
db: Session,
|
||||
) -> Entity:
|
||||
db_entity = get_entity_by_id(entity_id, db)
|
||||
if not db_entity:
|
||||
raise ValueError(f"Entity with id {entity_id} not found")
|
||||
|
||||
# Clear existing tags
|
||||
db.query(EntityTagModel).filter(EntityTagModel.entity_id == entity_id).delete()
|
||||
|
||||
|
||||
for tag_name in tags:
|
||||
tag = db.query(TagModel).filter(TagModel.name == tag_name).first()
|
||||
if not tag:
|
||||
@ -322,12 +340,13 @@ def update_entity_tags(entity_id: int, tags: List[str], db: Session) -> Entity:
|
||||
source=MetadataSource.PLUGIN_GENERATED,
|
||||
)
|
||||
db.add(entity_tag)
|
||||
|
||||
|
||||
# Update last_scan_at in the same transaction
|
||||
db_entity.last_scan_at = func.now()
|
||||
|
||||
|
||||
db.commit()
|
||||
db.refresh(db_entity)
|
||||
|
||||
return Entity(**db_entity.__dict__)
|
||||
|
||||
|
||||
@ -352,17 +371,20 @@ def add_new_tags(entity_id: int, tags: List[str], db: Session) -> Entity:
|
||||
source=MetadataSource.PLUGIN_GENERATED,
|
||||
)
|
||||
db.add(entity_tag)
|
||||
|
||||
|
||||
# Update last_scan_at in the same transaction
|
||||
db_entity.last_scan_at = func.now()
|
||||
|
||||
|
||||
db.commit()
|
||||
db.refresh(db_entity)
|
||||
|
||||
return Entity(**db_entity.__dict__)
|
||||
|
||||
|
||||
def update_entity_metadata_entries(
|
||||
entity_id: int, updated_metadata: List[EntityMetadataParam], db: Session
|
||||
entity_id: int,
|
||||
updated_metadata: List[EntityMetadataParam],
|
||||
db: Session,
|
||||
) -> Entity:
|
||||
db_entity = get_entity_by_id(entity_id, db)
|
||||
|
||||
@ -410,6 +432,7 @@ def update_entity_metadata_entries(
|
||||
|
||||
db.commit()
|
||||
db.refresh(db_entity)
|
||||
|
||||
return Entity(**db_entity.__dict__)
|
||||
|
||||
|
||||
@ -467,7 +490,9 @@ def full_text_search(
|
||||
params["start"] = str(start)
|
||||
params["end"] = str(end)
|
||||
|
||||
sql_query += " ORDER BY bm25(entities_fts), entities.file_created_at DESC LIMIT :limit"
|
||||
sql_query += (
|
||||
" ORDER BY bm25(entities_fts), entities.file_created_at DESC LIMIT :limit"
|
||||
)
|
||||
|
||||
result = db.execute(text(sql_query), params).fetchall()
|
||||
|
||||
@ -478,7 +503,7 @@ def full_text_search(
|
||||
return ids
|
||||
|
||||
|
||||
async def vec_search(
|
||||
def vec_search(
|
||||
query: str,
|
||||
db: Session,
|
||||
limit: int = 200,
|
||||
@ -486,7 +511,7 @@ async def vec_search(
|
||||
start: Optional[int] = None,
|
||||
end: Optional[int] = None,
|
||||
) -> List[int]:
|
||||
query_embedding = await get_embeddings([query])
|
||||
query_embedding = get_embeddings([query])
|
||||
if not query_embedding:
|
||||
return []
|
||||
|
||||
@ -506,9 +531,7 @@ async def vec_search(
|
||||
sql_query += f" AND entities.library_id IN ({library_ids_str})"
|
||||
|
||||
if start is not None and end is not None:
|
||||
sql_query += (
|
||||
" AND strftime('%s', entities.file_created_at, 'utc') BETWEEN :start AND :end"
|
||||
)
|
||||
sql_query += " AND strftime('%s', entities.file_created_at, 'utc') BETWEEN :start AND :end"
|
||||
params["start"] = str(start)
|
||||
params["end"] = str(end)
|
||||
|
||||
@ -536,7 +559,7 @@ def reciprocal_rank_fusion(
|
||||
return sorted_results
|
||||
|
||||
|
||||
async def hybrid_search(
|
||||
def hybrid_search(
|
||||
query: str,
|
||||
db: Session,
|
||||
limit: int = 200,
|
||||
@ -552,7 +575,7 @@ async def hybrid_search(
|
||||
logger.info(f"Full-text search took {fts_end - fts_start:.4f} seconds")
|
||||
|
||||
vec_start = time.time()
|
||||
vec_results = await vec_search(query, db, limit, library_ids, start, end)
|
||||
vec_results = vec_search(query, db, limit, library_ids, start, end)
|
||||
vec_end = time.time()
|
||||
logger.info(f"Vector search took {vec_end - vec_start:.4f} seconds")
|
||||
|
||||
@ -584,7 +607,7 @@ async def hybrid_search(
|
||||
return result
|
||||
|
||||
|
||||
async def list_entities(
|
||||
def list_entities(
|
||||
db: Session,
|
||||
limit: int = 200,
|
||||
library_ids: Optional[List[int]] = None,
|
||||
@ -598,7 +621,7 @@ async def list_entities(
|
||||
|
||||
if start is not None and end is not None:
|
||||
query = query.filter(
|
||||
func.strftime("%s", EntityModel.file_created_at, 'utc').between(
|
||||
func.strftime("%s", EntityModel.file_created_at, "utc").between(
|
||||
str(start), str(end)
|
||||
)
|
||||
)
|
||||
@ -624,10 +647,10 @@ def get_entity_context(
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
|
||||
if not target_entity:
|
||||
return [], []
|
||||
|
||||
|
||||
# Get previous entities
|
||||
prev_entities = []
|
||||
if prev > 0:
|
||||
@ -635,7 +658,7 @@ def get_entity_context(
|
||||
db.query(EntityModel)
|
||||
.filter(
|
||||
EntityModel.library_id == library_id,
|
||||
EntityModel.file_created_at < target_entity.file_created_at
|
||||
EntityModel.file_created_at < target_entity.file_created_at,
|
||||
)
|
||||
.order_by(EntityModel.file_created_at.desc())
|
||||
.limit(prev)
|
||||
@ -643,7 +666,7 @@ def get_entity_context(
|
||||
)
|
||||
# Reverse the list to get chronological order and convert to Entity models
|
||||
prev_entities = [Entity(**entity.__dict__) for entity in prev_entities][::-1]
|
||||
|
||||
|
||||
# Get next entities
|
||||
next_entities = []
|
||||
if next > 0:
|
||||
@ -651,7 +674,7 @@ def get_entity_context(
|
||||
db.query(EntityModel)
|
||||
.filter(
|
||||
EntityModel.library_id == library_id,
|
||||
EntityModel.file_created_at > target_entity.file_created_at
|
||||
EntityModel.file_created_at > target_entity.file_created_at,
|
||||
)
|
||||
.order_by(EntityModel.file_created_at.asc())
|
||||
.limit(next)
|
||||
@ -659,5 +682,171 @@ def get_entity_context(
|
||||
)
|
||||
# Convert to Entity models
|
||||
next_entities = [Entity(**entity.__dict__) for entity in next_entities]
|
||||
|
||||
|
||||
return prev_entities, next_entities
|
||||
|
||||
|
||||
def process_ocr_result(value, max_length=4096):
|
||||
try:
|
||||
ocr_data = json.loads(value)
|
||||
if isinstance(ocr_data, list) and all(
|
||||
isinstance(item, dict)
|
||||
and "dt_boxes" in item
|
||||
and "rec_txt" in item
|
||||
and "score" in item
|
||||
for item in ocr_data
|
||||
):
|
||||
return " ".join(item["rec_txt"] for item in ocr_data[:max_length])
|
||||
else:
|
||||
return json.dumps(ocr_data, indent=2)
|
||||
except json.JSONDecodeError:
|
||||
return value
|
||||
|
||||
|
||||
def prepare_fts_data(entity: EntityModel) -> tuple[str, str]:
|
||||
tags = ", ".join([tag.name for tag in entity.tags])
|
||||
fts_metadata = "\n".join(
|
||||
[
|
||||
f"{entry.key}: {process_ocr_result(entry.value) if entry.key == 'ocr_result' else entry.value}"
|
||||
for entry in entity.metadata_entries
|
||||
]
|
||||
)
|
||||
return tags, fts_metadata
|
||||
|
||||
|
||||
def prepare_vec_data(entity: EntityModel) -> str:
|
||||
vec_metadata = "\n".join(
|
||||
[
|
||||
f"{entry.key}: {entry.value}"
|
||||
for entry in entity.metadata_entries
|
||||
if entry.key != "ocr_result"
|
||||
]
|
||||
)
|
||||
ocr_result = next(
|
||||
(entry.value for entry in entity.metadata_entries if entry.key == "ocr_result"),
|
||||
"",
|
||||
)
|
||||
vec_metadata += f"\nocr_result: {process_ocr_result(ocr_result, max_length=128)}"
|
||||
return vec_metadata
|
||||
|
||||
|
||||
def update_entity_index(entity: EntityModel, db: Session):
|
||||
"""Update both FTS and vector indexes for an entity"""
|
||||
try:
|
||||
# Update FTS index
|
||||
tags, fts_metadata = prepare_fts_data(entity)
|
||||
db.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT OR REPLACE INTO entities_fts(id, filepath, tags, metadata)
|
||||
VALUES(:id, :filepath, :tags, :metadata)
|
||||
"""
|
||||
),
|
||||
{
|
||||
"id": entity.id,
|
||||
"filepath": entity.filepath,
|
||||
"tags": tags,
|
||||
"metadata": fts_metadata,
|
||||
},
|
||||
)
|
||||
|
||||
# Update vector index
|
||||
vec_metadata = prepare_vec_data(entity)
|
||||
embeddings = get_embeddings([vec_metadata])
|
||||
|
||||
if embeddings and embeddings[0]:
|
||||
db.execute(
|
||||
text("DELETE FROM entities_vec WHERE rowid = :id"), {"id": entity.id}
|
||||
)
|
||||
db.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO entities_vec (rowid, embedding)
|
||||
VALUES (:id, :embedding)
|
||||
"""
|
||||
),
|
||||
{
|
||||
"id": entity.id,
|
||||
"embedding": serialize_float32(embeddings[0]),
|
||||
},
|
||||
)
|
||||
|
||||
db.commit()
|
||||
except Exception as e:
|
||||
logger.error(f"Error updating indexes for entity {entity.id}: {e}")
|
||||
db.rollback()
|
||||
raise
|
||||
|
||||
|
||||
def batch_update_entity_indices(entity_ids: List[int], db: Session):
|
||||
"""Batch update both FTS and vector indexes for multiple entities"""
|
||||
try:
|
||||
# 获取实体
|
||||
entities = db.query(EntityModel).filter(EntityModel.id.in_(entity_ids)).all()
|
||||
found_ids = {entity.id for entity in entities}
|
||||
|
||||
# 检查是否所有请求的实体都找到了
|
||||
missing_ids = set(entity_ids) - found_ids
|
||||
if missing_ids:
|
||||
raise ValueError(f"Entities not found: {missing_ids}")
|
||||
|
||||
# Prepare FTS data for all entities
|
||||
fts_data = []
|
||||
vec_metadata_list = []
|
||||
|
||||
for entity in entities:
|
||||
# Prepare FTS data
|
||||
tags, fts_metadata = prepare_fts_data(entity)
|
||||
fts_data.append((entity.id, entity.filepath, tags, fts_metadata))
|
||||
|
||||
# Prepare vector data
|
||||
vec_metadata = prepare_vec_data(entity)
|
||||
vec_metadata_list.append(vec_metadata)
|
||||
|
||||
# Batch update FTS table
|
||||
for entity_id, filepath, tags, metadata in fts_data:
|
||||
db.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT OR REPLACE INTO entities_fts(id, filepath, tags, metadata)
|
||||
VALUES(:id, :filepath, :tags, :metadata)
|
||||
"""
|
||||
),
|
||||
{
|
||||
"id": entity_id,
|
||||
"filepath": filepath,
|
||||
"tags": tags,
|
||||
"metadata": metadata,
|
||||
},
|
||||
)
|
||||
|
||||
# Batch get embeddings
|
||||
embeddings = get_embeddings(vec_metadata_list)
|
||||
|
||||
# Batch update vector table
|
||||
if embeddings:
|
||||
for entity, embedding in zip(entities, embeddings):
|
||||
if embedding: # Check if embedding is not empty
|
||||
db.execute(
|
||||
text("DELETE FROM entities_vec WHERE rowid = :id"),
|
||||
{"id": entity.id},
|
||||
)
|
||||
db.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO entities_vec (rowid, embedding)
|
||||
VALUES (:id, :embedding)
|
||||
"""
|
||||
),
|
||||
{
|
||||
"id": entity.id,
|
||||
"embedding": serialize_float32(embedding),
|
||||
},
|
||||
)
|
||||
|
||||
db.commit()
|
||||
except Exception as e:
|
||||
logger.error(f"Error batch updating indexes: {e}")
|
||||
db.rollback()
|
||||
raise
|
||||
|
||||
|
@ -6,8 +6,10 @@ screenshots_dir: screenshots
|
||||
server_host: 0.0.0.0
|
||||
server_port: 8839
|
||||
|
||||
auth_username: admin
|
||||
auth_password: changeme
|
||||
# Enable authentication by uncommenting the following lines
|
||||
# auth_username: admin
|
||||
# auth_password: changeme
|
||||
|
||||
default_plugins:
|
||||
- builtin_ocr
|
||||
# - builtin_vlm
|
||||
@ -41,7 +43,6 @@ embedding:
|
||||
|
||||
# using ollama embedding
|
||||
# embedding:
|
||||
# enabled: true
|
||||
# endpoint: http://localhost:11434/api/embed # this is not used
|
||||
# model: arkohut/gte-qwen2-1.5b-instruct:q8_0
|
||||
# num_dim: 1536
|
||||
|
@ -58,11 +58,11 @@ def generate_embeddings(texts: List[str]) -> List[List[float]]:
|
||||
return embeddings.tolist()
|
||||
|
||||
|
||||
async def get_embeddings(texts: List[str]) -> List[List[float]]:
|
||||
def get_embeddings(texts: List[str]) -> List[List[float]]:
|
||||
if settings.embedding.use_local:
|
||||
embeddings = generate_embeddings(texts)
|
||||
else:
|
||||
embeddings = await get_remote_embeddings(texts)
|
||||
embeddings = get_remote_embeddings(texts)
|
||||
|
||||
# Round the embedding values to 5 decimal places
|
||||
return [
|
||||
@ -71,12 +71,12 @@ async def get_embeddings(texts: List[str]) -> List[List[float]]:
|
||||
]
|
||||
|
||||
|
||||
async def get_remote_embeddings(texts: List[str]) -> List[List[float]]:
|
||||
def get_remote_embeddings(texts: List[str]) -> List[List[float]]:
|
||||
payload = {"model": settings.embedding.model, "input": texts}
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
with httpx.Client(timeout=60) as client:
|
||||
try:
|
||||
response = await client.post(settings.embedding.endpoint, json=payload)
|
||||
response = client.post(settings.embedding.endpoint, json=payload)
|
||||
response.raise_for_status()
|
||||
result = response.json()
|
||||
return result["embeddings"]
|
||||
|
153
memos/models.py
153
memos/models.py
@ -21,11 +21,6 @@ from sqlalchemy import text
|
||||
import sqlite_vec
|
||||
import sys
|
||||
from pathlib import Path
|
||||
import json
|
||||
from .embedding import get_embeddings
|
||||
from sqlite_vec import serialize_float32
|
||||
import asyncio
|
||||
import threading
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
@ -246,7 +241,13 @@ def recreate_fts_and_vec_tables():
|
||||
def init_database():
|
||||
"""Initialize the database."""
|
||||
db_path = get_database_path()
|
||||
engine = create_engine(f"sqlite:///{db_path}")
|
||||
engine = create_engine(
|
||||
f"sqlite:///{db_path}",
|
||||
pool_size=3,
|
||||
max_overflow=0,
|
||||
pool_timeout=30,
|
||||
connect_args={"timeout": 30}
|
||||
)
|
||||
|
||||
# Use a single event listener for both extension loading and WAL mode setting
|
||||
event.listen(engine, "connect", load_extension)
|
||||
@ -339,143 +340,3 @@ def init_default_libraries(session, default_plugins):
|
||||
session.add(library_plugin)
|
||||
|
||||
session.commit()
|
||||
|
||||
|
||||
async def update_or_insert_entities_vec(session, target_id, embedding):
|
||||
try:
|
||||
session.execute(
|
||||
text("DELETE FROM entities_vec WHERE rowid = :id"),
|
||||
{"id": target_id}
|
||||
)
|
||||
|
||||
session.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO entities_vec (rowid, embedding)
|
||||
VALUES (:id, :embedding)
|
||||
"""
|
||||
),
|
||||
{
|
||||
"id": target_id,
|
||||
"embedding": serialize_float32(embedding),
|
||||
},
|
||||
)
|
||||
session.commit()
|
||||
except Exception as e:
|
||||
print(f"Error updating entities_vec: {e}")
|
||||
session.rollback()
|
||||
|
||||
|
||||
def update_or_insert_entities_fts(session, target_id, filepath, tags, metadata):
|
||||
try:
|
||||
session.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT OR REPLACE INTO entities_fts(id, filepath, tags, metadata)
|
||||
VALUES(:id, :filepath, :tags, :metadata)
|
||||
"""
|
||||
),
|
||||
{
|
||||
"id": target_id,
|
||||
"filepath": filepath,
|
||||
"tags": tags,
|
||||
"metadata": metadata,
|
||||
},
|
||||
)
|
||||
session.commit()
|
||||
except Exception as e:
|
||||
print(f"Error updating entities_fts: {e}")
|
||||
session.rollback()
|
||||
|
||||
|
||||
async def update_fts_and_vec(mapper, connection, entity: EntityModel):
|
||||
session = Session(bind=connection)
|
||||
|
||||
# Prepare FTS data
|
||||
tags = ", ".join([tag.name for tag in entity.tags])
|
||||
|
||||
# Process metadata entries
|
||||
def process_ocr_result(value, max_length=4096):
|
||||
try:
|
||||
ocr_data = json.loads(value)
|
||||
if isinstance(ocr_data, list) and all(
|
||||
isinstance(item, dict)
|
||||
and "dt_boxes" in item
|
||||
and "rec_txt" in item
|
||||
and "score" in item
|
||||
for item in ocr_data
|
||||
):
|
||||
return " ".join(item["rec_txt"] for item in ocr_data[:max_length])
|
||||
else:
|
||||
return json.dumps(ocr_data, indent=2)
|
||||
except json.JSONDecodeError:
|
||||
return value
|
||||
|
||||
fts_metadata = "\n".join(
|
||||
[
|
||||
f"{entry.key}: {process_ocr_result(entry.value) if entry.key == 'ocr_result' else entry.value}"
|
||||
for entry in entity.metadata_entries
|
||||
]
|
||||
)
|
||||
|
||||
# Update FTS table
|
||||
update_or_insert_entities_fts(
|
||||
session, entity.id, entity.filepath, tags, fts_metadata
|
||||
)
|
||||
|
||||
# Prepare vector data
|
||||
metadata_text = "\n".join(
|
||||
[
|
||||
f"{entry.key}: {entry.value}"
|
||||
for entry in entity.metadata_entries
|
||||
if entry.key != "ocr_result"
|
||||
]
|
||||
)
|
||||
|
||||
# Add ocr_result at the end of metadata_text using process_ocr_result
|
||||
ocr_result = next(
|
||||
(entry.value for entry in entity.metadata_entries if entry.key == "ocr_result"),
|
||||
"",
|
||||
)
|
||||
processed_ocr_result = process_ocr_result(ocr_result, max_length=128)
|
||||
metadata_text += f"\nocr_result: {processed_ocr_result}"
|
||||
|
||||
# Use the new get_embeddings function
|
||||
embeddings = await get_embeddings([metadata_text])
|
||||
if not embeddings:
|
||||
embedding = []
|
||||
else:
|
||||
embedding = embeddings[0]
|
||||
|
||||
# Update vector table
|
||||
if embedding:
|
||||
await update_or_insert_entities_vec(session, entity.id, embedding)
|
||||
|
||||
|
||||
def delete_fts_and_vec(mapper, connection, entity: EntityModel):
|
||||
connection.execute(
|
||||
text("DELETE FROM entities_fts WHERE id = :id"), {"id": entity.id}
|
||||
)
|
||||
connection.execute(
|
||||
text("DELETE FROM entities_vec WHERE rowid = :id"), {"id": entity.id}
|
||||
)
|
||||
|
||||
|
||||
def run_async(coro):
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
return loop.run_until_complete(coro)
|
||||
|
||||
|
||||
def update_fts_and_vec_sync(mapper, connection, entity: EntityModel):
|
||||
def run_in_thread():
|
||||
run_async(update_fts_and_vec(mapper, connection, entity))
|
||||
|
||||
thread = threading.Thread(target=run_in_thread)
|
||||
thread.start()
|
||||
thread.join()
|
||||
|
||||
# Add event listeners for EntityModel
|
||||
event.listen(EntityModel, "after_insert", update_fts_and_vec_sync)
|
||||
event.listen(EntityModel, "after_update", update_fts_and_vec_sync)
|
||||
event.listen(EntityModel, "after_delete", delete_fts_and_vec)
|
@ -1,41 +1,43 @@
|
||||
Global:
|
||||
text_score: 0.5
|
||||
use_det: true
|
||||
use_cls: true
|
||||
use_rec: true
|
||||
print_verbose: false
|
||||
min_height: 30
|
||||
width_height_ratio: 40
|
||||
text_score: 0.5
|
||||
use_det: true
|
||||
use_cls: true
|
||||
use_rec: true
|
||||
print_verbose: false
|
||||
min_height: 30
|
||||
width_height_ratio: 40
|
||||
max_side_len: 1500
|
||||
min_side_len: 30
|
||||
|
||||
Det:
|
||||
use_cuda: true
|
||||
use_cuda: true
|
||||
|
||||
model_path: models/ch_PP-OCRv4_det_infer.onnx
|
||||
model_path: models/ch_PP-OCRv4_det_infer.onnx
|
||||
|
||||
limit_side_len: 1500
|
||||
limit_type: min
|
||||
limit_side_len: 1500
|
||||
limit_type: min
|
||||
|
||||
thresh: 0.3
|
||||
box_thresh: 0.3
|
||||
max_candidates: 1000
|
||||
unclip_ratio: 1.6
|
||||
use_dilation: true
|
||||
score_mode: fast
|
||||
thresh: 0.3
|
||||
box_thresh: 0.3
|
||||
max_candidates: 1000
|
||||
unclip_ratio: 1.6
|
||||
use_dilation: true
|
||||
score_mode: fast
|
||||
|
||||
Cls:
|
||||
use_cuda: true
|
||||
use_cuda: true
|
||||
|
||||
model_path: models/ch_ppocr_mobile_v2.0_cls_train.onnx
|
||||
model_path: models/ch_ppocr_mobile_v2.0_cls_train.onnx
|
||||
|
||||
cls_image_shape: [3, 48, 192]
|
||||
cls_batch_num: 6
|
||||
cls_thresh: 0.9
|
||||
label_list: ['0', '180']
|
||||
cls_image_shape: [3, 48, 192]
|
||||
cls_batch_num: 6
|
||||
cls_thresh: 0.9
|
||||
label_list: ["0", "180"]
|
||||
|
||||
Rec:
|
||||
use_cuda: true
|
||||
use_cuda: true
|
||||
|
||||
model_path: models/ch_PP-OCRv4_rec_infer.onnx
|
||||
model_path: models/ch_PP-OCRv4_rec_infer.onnx
|
||||
|
||||
rec_img_shape: [3, 48, 320]
|
||||
rec_batch_num: 6
|
||||
rec_img_shape: [3, 48, 320]
|
||||
rec_batch_num: 6
|
||||
|
@ -7,6 +7,8 @@ Global:
|
||||
min_height: 30
|
||||
width_height_ratio: 40
|
||||
use_space_char: true
|
||||
max_side_len: 1500
|
||||
min_side_len: 30
|
||||
|
||||
Det:
|
||||
use_cuda: false
|
||||
@ -39,4 +41,4 @@ Rec:
|
||||
model_path: models/ch_PP-OCRv4_rec_infer.onnx
|
||||
|
||||
rec_img_shape: [3, 48, 320]
|
||||
rec_batch_num: 6
|
||||
rec_batch_num: 6
|
||||
|
288
memos/record.py
288
memos/record.py
@ -66,6 +66,64 @@ def save_previous_hashes(base_dir, previous_hashes):
|
||||
json.dump(previous_hashes, f)
|
||||
|
||||
|
||||
def get_wayland_displays():
|
||||
displays = []
|
||||
try:
|
||||
# Try using swaymsg for sway
|
||||
output = subprocess.check_output(["swaymsg", "-t", "get_outputs"], text=True)
|
||||
outputs = json.loads(output)
|
||||
for output in outputs:
|
||||
if output["active"]:
|
||||
displays.append(
|
||||
{
|
||||
"name": output["name"],
|
||||
"geometry": f"{output['rect'].x},{output['rect'].y} {output['rect'].width}x{output['rect'].height}",
|
||||
}
|
||||
)
|
||||
except:
|
||||
try:
|
||||
# Try using wlr-randr for wlroots-based compositors
|
||||
output = subprocess.check_output(["wlr-randr"], text=True)
|
||||
# Parse wlr-randr output
|
||||
current_display = {}
|
||||
for line in output.splitlines():
|
||||
if line.startswith(" "):
|
||||
if "enabled" in line and "yes" in line:
|
||||
current_display["active"] = True
|
||||
else:
|
||||
if current_display and current_display.get("active"):
|
||||
displays.append(current_display)
|
||||
current_display = {"name": line.split()[0]}
|
||||
except:
|
||||
# Fallback to single display
|
||||
displays.append({"name": "", "geometry": ""})
|
||||
|
||||
return displays
|
||||
|
||||
|
||||
def get_x11_displays():
|
||||
displays = []
|
||||
try:
|
||||
output = subprocess.check_output(["xrandr", "--current"], text=True)
|
||||
current_display = None
|
||||
|
||||
for line in output.splitlines():
|
||||
if " connected " in line:
|
||||
parts = line.split()
|
||||
name = parts[0]
|
||||
# Find the geometry in format: 1920x1080+0+0
|
||||
for part in parts:
|
||||
if "x" in part and "+" in part:
|
||||
geometry = part
|
||||
break
|
||||
displays.append({"name": name, "geometry": geometry})
|
||||
except:
|
||||
# Fallback to single display
|
||||
displays.append({"name": "default", "geometry": ""})
|
||||
|
||||
return displays
|
||||
|
||||
|
||||
def get_active_window_info_darwin():
|
||||
active_app = NSWorkspace.sharedWorkspace().activeApplication()
|
||||
app_name = active_app["NSApplicationName"]
|
||||
@ -94,11 +152,68 @@ def get_active_window_info_windows():
|
||||
return "", ""
|
||||
|
||||
|
||||
def get_active_window_info_linux():
|
||||
try:
|
||||
# Try using xdotool for X11
|
||||
window_id = (
|
||||
subprocess.check_output(["xdotool", "getactivewindow"]).decode().strip()
|
||||
)
|
||||
window_name = (
|
||||
subprocess.check_output(["xdotool", "getwindowname", window_id])
|
||||
.decode()
|
||||
.strip()
|
||||
)
|
||||
window_pid = (
|
||||
subprocess.check_output(["xdotool", "getwindowpid", window_id])
|
||||
.decode()
|
||||
.strip()
|
||||
)
|
||||
|
||||
app_name = ""
|
||||
try:
|
||||
with open(f"/proc/{window_pid}/comm", "r") as f:
|
||||
app_name = f.read().strip()
|
||||
except:
|
||||
app_name = window_name.split(" - ")[0]
|
||||
|
||||
return app_name, window_name
|
||||
except:
|
||||
try:
|
||||
# Try using qdbus for Wayland/KDE
|
||||
active_window = (
|
||||
subprocess.check_output(
|
||||
["qdbus", "org.kde.KWin", "/KWin", "org.kde.KWin.activeWindow"]
|
||||
)
|
||||
.decode()
|
||||
.strip()
|
||||
)
|
||||
|
||||
window_title = (
|
||||
subprocess.check_output(
|
||||
[
|
||||
"qdbus",
|
||||
"org.kde.KWin",
|
||||
f"/windows/{active_window}",
|
||||
"org.kde.KWin.caption",
|
||||
]
|
||||
)
|
||||
.decode()
|
||||
.strip()
|
||||
)
|
||||
|
||||
return window_title.split(" - ")[0], window_title
|
||||
except:
|
||||
return "", ""
|
||||
|
||||
|
||||
def get_active_window_info():
|
||||
if platform.system() == "Darwin":
|
||||
return get_active_window_info_darwin()
|
||||
elif platform.system() == "Windows":
|
||||
return get_active_window_info_windows()
|
||||
elif platform.system() == "Linux":
|
||||
return get_active_window_info_linux()
|
||||
return "", ""
|
||||
|
||||
|
||||
def take_screenshot_macos(
|
||||
@ -231,13 +346,144 @@ def take_screenshot_windows(
|
||||
yield safe_monitor_name, webp_filename, "Saved"
|
||||
|
||||
|
||||
def take_screenshot_linux(
|
||||
base_dir,
|
||||
previous_hashes,
|
||||
threshold,
|
||||
screen_sequences,
|
||||
date,
|
||||
timestamp,
|
||||
app_name,
|
||||
window_title,
|
||||
):
|
||||
screenshots = []
|
||||
|
||||
# Check if running under Wayland or X11
|
||||
wayland_display = os.environ.get("WAYLAND_DISPLAY")
|
||||
is_wayland = wayland_display is not None
|
||||
|
||||
if is_wayland:
|
||||
# Try different Wayland screenshot tools in order of preference
|
||||
screenshot_tools = [
|
||||
["spectacle", "-m", "-b", "-n"], # Plasma default
|
||||
["grim"], # Basic Wayland screenshot utility
|
||||
["grimshot", "save"], # sway's screenshot utility
|
||||
["slurp", "-f", "%o"], # Alternative selection tool
|
||||
]
|
||||
|
||||
for tool in screenshot_tools:
|
||||
try:
|
||||
# subprocess.run(["which", tool[0]], check=True, capture_output=True)
|
||||
subprocess.run(["which", tool[0]], check=True, capture_output=True)
|
||||
screenshot_cmd = tool
|
||||
print(screenshot_cmd)
|
||||
break
|
||||
except subprocess.CalledProcessError:
|
||||
continue
|
||||
else:
|
||||
raise RuntimeError(
|
||||
"No supported Wayland screenshot tool found. Please install grim or grimshot."
|
||||
)
|
||||
|
||||
else:
|
||||
# X11 screenshot tools
|
||||
screenshot_tools = [
|
||||
["maim"], # Modern screenshot tool
|
||||
["scrot", "-z"], # Traditional screenshot tool
|
||||
["import", "-window", "root"], # ImageMagick
|
||||
]
|
||||
|
||||
for tool in screenshot_tools:
|
||||
try:
|
||||
subprocess.run(["which", tool[0]], check=True, capture_output=True)
|
||||
screenshot_cmd = tool
|
||||
break
|
||||
except subprocess.CalledProcessError:
|
||||
continue
|
||||
else:
|
||||
raise RuntimeError(
|
||||
"No supported X11 screenshot tool found. Please install maim, scrot, or imagemagick."
|
||||
)
|
||||
|
||||
# Get display information using xrandr or Wayland equivalent
|
||||
if is_wayland:
|
||||
displays = get_wayland_displays()
|
||||
else:
|
||||
displays = get_x11_displays()
|
||||
|
||||
for display_index, display_info in enumerate(displays):
|
||||
screen_name = f"screen_{display_index}"
|
||||
|
||||
temp_filename = os.path.join(
|
||||
base_dir, date, f"temp_screenshot-{timestamp}-of-{screen_name}.png"
|
||||
)
|
||||
|
||||
if is_wayland:
|
||||
# For Wayland, we need to specify the output
|
||||
output_arg = display_info["name"]
|
||||
if output_arg == "":
|
||||
output_arg = "0"
|
||||
cmd = screenshot_cmd + ["-o", temp_filename]
|
||||
print(cmd)
|
||||
else:
|
||||
# For X11, we can specify the geometry
|
||||
geometry = display_info["geometry"]
|
||||
cmd = screenshot_cmd + ["-g", geometry, temp_filename]
|
||||
|
||||
try:
|
||||
subprocess.run(cmd, check=True)
|
||||
except subprocess.CalledProcessError as e:
|
||||
logging.error(f"Failed to capture screenshot: {e}")
|
||||
yield screen_name, None, "Failed to capture"
|
||||
continue
|
||||
|
||||
with Image.open(temp_filename) as img:
|
||||
img = img.convert("RGB")
|
||||
current_hash = str(imagehash.phash(img))
|
||||
|
||||
if (
|
||||
screen_name in previous_hashes
|
||||
and imagehash.hex_to_hash(current_hash)
|
||||
- imagehash.hex_to_hash(previous_hashes[screen_name])
|
||||
< threshold
|
||||
):
|
||||
logging.info(
|
||||
f"Screenshot for {screen_name} is similar to the previous one. Skipping."
|
||||
)
|
||||
os.remove(temp_filename)
|
||||
yield screen_name, None, "Skipped (similar to previous)"
|
||||
continue
|
||||
|
||||
previous_hashes[screen_name] = current_hash
|
||||
screen_sequences[screen_name] = screen_sequences.get(screen_name, 0) + 1
|
||||
|
||||
metadata = {
|
||||
"timestamp": timestamp,
|
||||
"active_app": app_name,
|
||||
"active_window": window_title,
|
||||
"screen_name": screen_name,
|
||||
"sequence": screen_sequences[screen_name],
|
||||
}
|
||||
|
||||
webp_filename = os.path.join(
|
||||
base_dir, date, f"screenshot-{timestamp}-of-{screen_name}.webp"
|
||||
)
|
||||
img.save(webp_filename, format="WebP", quality=85)
|
||||
write_image_metadata(webp_filename, metadata)
|
||||
|
||||
save_screen_sequences(base_dir, screen_sequences, date)
|
||||
|
||||
os.remove(temp_filename)
|
||||
yield screen_name, webp_filename, "Success"
|
||||
|
||||
|
||||
def take_screenshot(
|
||||
base_dir, previous_hashes, threshold, screen_sequences, date, timestamp
|
||||
):
|
||||
app_name, window_title = get_active_window_info()
|
||||
print(app_name, window_title)
|
||||
os.makedirs(os.path.join(base_dir, date), exist_ok=True)
|
||||
worklog_path = os.path.join(base_dir, date, "worklog")
|
||||
|
||||
with open(worklog_path, "a") as worklog:
|
||||
if platform.system() == "Darwin":
|
||||
screenshot_generator = take_screenshot_macos(
|
||||
@ -261,6 +507,18 @@ def take_screenshot(
|
||||
app_name,
|
||||
window_title,
|
||||
)
|
||||
elif platform.system() == "Linux" or platform.system() == "linux":
|
||||
print("Linux")
|
||||
screenshot_generator = take_screenshot_linux(
|
||||
base_dir,
|
||||
previous_hashes,
|
||||
threshold,
|
||||
screen_sequences,
|
||||
date,
|
||||
timestamp,
|
||||
app_name,
|
||||
window_title,
|
||||
)
|
||||
else:
|
||||
raise NotImplementedError(
|
||||
f"Unsupported operating system: {platform.system()}"
|
||||
@ -285,6 +543,29 @@ def is_screen_locked():
|
||||
elif platform.system() == "Windows":
|
||||
user32 = ctypes.windll.User32
|
||||
return user32.GetForegroundWindow() == 0
|
||||
elif platform.system() == "Linux":
|
||||
try:
|
||||
# Check for GNOME screensaver
|
||||
output = subprocess.check_output(
|
||||
["gnome-screensaver-command", "-q"], stderr=subprocess.DEVNULL
|
||||
)
|
||||
return b"is active" in output
|
||||
except:
|
||||
try:
|
||||
# Check for XScreenSaver
|
||||
output = subprocess.check_output(
|
||||
["xscreensaver-command", "-time"], stderr=subprocess.DEVNULL
|
||||
)
|
||||
return b"screen locked" in output
|
||||
except:
|
||||
try:
|
||||
# Check for Light-locker (XFCE, LXDE)
|
||||
output = subprocess.check_output(
|
||||
["light-locker-command", "-q"], stderr=subprocess.DEVNULL
|
||||
)
|
||||
return b"is locked" in output
|
||||
except:
|
||||
return False # If no screensaver utils found, assume not locked
|
||||
|
||||
|
||||
def run_screen_recorder_once(threshold, base_dir, previous_hashes):
|
||||
@ -317,6 +598,7 @@ def run_screen_recorder(threshold, base_dir, previous_hashes):
|
||||
date,
|
||||
timestamp,
|
||||
)
|
||||
print(screenshot_files)
|
||||
for screenshot_file in screenshot_files:
|
||||
logging.info(f"Screenshot saved: {screenshot_file}")
|
||||
else:
|
||||
@ -337,7 +619,9 @@ def main():
|
||||
args = parser.parse_args()
|
||||
|
||||
base_dir = (
|
||||
os.path.expanduser(args.base_dir) if args.base_dir else settings.resolved_screenshots_dir
|
||||
os.path.expanduser(args.base_dir)
|
||||
if args.base_dir
|
||||
else settings.resolved_screenshots_dir
|
||||
)
|
||||
previous_hashes = load_previous_hashes(base_dir)
|
||||
|
||||
|
@ -12,8 +12,8 @@ from enum import Enum
|
||||
|
||||
|
||||
class FolderType(Enum):
|
||||
DEFAULT = "default"
|
||||
DUMMY = "dummy"
|
||||
DEFAULT = "DEFAULT"
|
||||
DUMMY = "DUMMY"
|
||||
|
||||
|
||||
class MetadataSource(Enum):
|
||||
@ -276,3 +276,7 @@ class SearchResult(BaseModel):
|
||||
class EntityContext(BaseModel):
|
||||
prev: List[Entity]
|
||||
next: List[Entity]
|
||||
|
||||
|
||||
class BatchIndexRequest(BaseModel):
|
||||
entity_ids: List[int]
|
||||
|
@ -6,7 +6,6 @@ from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.responses import FileResponse, JSONResponse
|
||||
from fastapi.encoders import jsonable_encoder
|
||||
from fastapi.security import HTTPBasic, HTTPBasicCredentials
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import create_engine, event
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
@ -16,7 +15,6 @@ import asyncio
|
||||
import json
|
||||
import cv2
|
||||
from PIL import Image
|
||||
from secrets import compare_digest
|
||||
import logging
|
||||
|
||||
from .config import get_database_path, settings
|
||||
@ -43,6 +41,7 @@ from .schemas import (
|
||||
SearchHit,
|
||||
RequestParams,
|
||||
EntityContext,
|
||||
BatchIndexRequest,
|
||||
)
|
||||
from .read_metadata import read_metadata
|
||||
from .logging_config import LOGGING_CONFIG
|
||||
@ -52,7 +51,6 @@ from .models import load_extension
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
|
||||
app = FastAPI()
|
||||
security = HTTPBasic()
|
||||
|
||||
engine = create_engine(f"sqlite:///{get_database_path()}")
|
||||
event.listen(engine, "connect", load_extension)
|
||||
@ -89,32 +87,6 @@ async def favicon_ico():
|
||||
return FileResponse(os.path.join(current_dir, "static/favicon.png"))
|
||||
|
||||
|
||||
def is_auth_enabled():
|
||||
return bool(settings.auth_username and settings.auth_password.get_secret_value())
|
||||
|
||||
|
||||
def authenticate(credentials: HTTPBasicCredentials = Depends(security)):
|
||||
if not is_auth_enabled():
|
||||
return None
|
||||
correct_username = compare_digest(credentials.username, settings.auth_username)
|
||||
correct_password = compare_digest(
|
||||
credentials.password, settings.auth_password.get_secret_value()
|
||||
)
|
||||
if not (correct_username and correct_password):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Incorrect username or password",
|
||||
headers={"WWW-Authenticate": "Basic"},
|
||||
)
|
||||
return credentials.username
|
||||
|
||||
|
||||
def optional_auth(credentials: HTTPBasicCredentials = Depends(security)):
|
||||
if is_auth_enabled():
|
||||
return authenticate(credentials)
|
||||
return None
|
||||
|
||||
|
||||
@app.get("/")
|
||||
async def serve_spa():
|
||||
return FileResponse(os.path.join(current_dir, "static/app.html"))
|
||||
@ -239,6 +211,7 @@ async def new_entity(
|
||||
db: Session = Depends(get_db),
|
||||
plugins: Annotated[List[int] | None, Query()] = None,
|
||||
trigger_webhooks_flag: bool = True,
|
||||
update_index: bool = False,
|
||||
):
|
||||
library = crud.get_library_by_id(library_id, db)
|
||||
if library is None:
|
||||
@ -249,6 +222,10 @@ async def new_entity(
|
||||
entity = crud.create_entity(library_id, new_entity, db)
|
||||
if trigger_webhooks_flag:
|
||||
await trigger_webhooks(library, entity, request, plugins)
|
||||
|
||||
if update_index:
|
||||
crud.update_entity_index(entity, db)
|
||||
|
||||
return entity
|
||||
|
||||
|
||||
@ -346,6 +323,7 @@ async def update_entity(
|
||||
db: Session = Depends(get_db),
|
||||
trigger_webhooks_flag: bool = False,
|
||||
plugins: Annotated[List[int] | None, Query()] = None,
|
||||
update_index: bool = False,
|
||||
):
|
||||
entity = crud.find_entity_by_id(entity_id, db)
|
||||
if entity is None:
|
||||
@ -364,6 +342,10 @@ async def update_entity(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Library not found"
|
||||
)
|
||||
await trigger_webhooks(library, entity, request, plugins)
|
||||
|
||||
if update_index:
|
||||
crud.update_entity_index(entity, db)
|
||||
|
||||
return entity
|
||||
|
||||
|
||||
@ -382,6 +364,46 @@ def update_entity_last_scan_at(entity_id: int, db: Session = Depends(get_db)):
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Entity not found",
|
||||
)
|
||||
|
||||
|
||||
@app.post(
|
||||
"/entities/{entity_id}/index",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
tags=["entity"],
|
||||
)
|
||||
def update_index(entity_id: int, db: Session = Depends(get_db)):
|
||||
"""
|
||||
Update the FTS and vector indexes for an entity.
|
||||
"""
|
||||
entity = crud.get_entity_by_id(entity_id, db)
|
||||
if entity is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Entity not found",
|
||||
)
|
||||
|
||||
crud.update_entity_index(entity, db)
|
||||
|
||||
|
||||
@app.post(
|
||||
"/entities/batch-index",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
tags=["entity"],
|
||||
)
|
||||
async def batch_update_index(
|
||||
request: BatchIndexRequest,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Batch update the FTS and vector indexes for multiple entities.
|
||||
"""
|
||||
try:
|
||||
crud.batch_update_entity_indices(request.entity_ids, db)
|
||||
except ValueError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=str(e)
|
||||
)
|
||||
|
||||
|
||||
@app.put("/entities/{entity_id}/tags", response_model=Entity, tags=["entity"])
|
||||
@ -614,12 +636,12 @@ async def search_entities_v2(
|
||||
try:
|
||||
if q.strip() == "":
|
||||
# Use list_entities when q is empty
|
||||
entities = await crud.list_entities(
|
||||
entities = crud.list_entities(
|
||||
db=db, limit=limit, library_ids=library_ids, start=start, end=end
|
||||
)
|
||||
else:
|
||||
# Use hybrid_search when q is not empty
|
||||
entities = await crud.hybrid_search(
|
||||
entities = crud.hybrid_search(
|
||||
query=q,
|
||||
db=db,
|
||||
limit=limit,
|
||||
|
@ -1,9 +1,10 @@
|
||||
import json
|
||||
import os
|
||||
import pytest
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy import create_engine, event, text
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from sqlalchemy.pool import StaticPool
|
||||
from pathlib import Path
|
||||
@ -16,12 +17,15 @@ from memos.schemas import (
|
||||
NewEntityParam,
|
||||
UpdateEntityParam,
|
||||
NewFoldersParam,
|
||||
NewFolderParam,
|
||||
EntityMetadataParam,
|
||||
MetadataType,
|
||||
UpdateEntityTagsParam,
|
||||
UpdateEntityMetadataParam,
|
||||
FolderType,
|
||||
)
|
||||
from memos.models import Base
|
||||
from memos.models import Base, load_extension
|
||||
from memos.config import settings
|
||||
|
||||
|
||||
engine = create_engine(
|
||||
@ -29,6 +33,10 @@ engine = create_engine(
|
||||
connect_args={"check_same_thread": False},
|
||||
poolclass=StaticPool,
|
||||
)
|
||||
|
||||
# 添加扩展加载事件监听器
|
||||
event.listen(engine, "connect", load_extension)
|
||||
|
||||
TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
|
||||
|
||||
@ -47,7 +55,13 @@ def setup_library_with_entity(client):
|
||||
library_id = library_response.json()["id"]
|
||||
|
||||
# Create a new folder in the library
|
||||
new_folder = NewFoldersParam(folders=["/tmp"])
|
||||
new_folder = NewFoldersParam(
|
||||
folders=[
|
||||
NewFolderParam(
|
||||
path="/tmp", last_modified_at=datetime.now(), type=FolderType.DEFAULT
|
||||
)
|
||||
]
|
||||
)
|
||||
folder_response = client.post(
|
||||
f"/libraries/{library_id}/folders", json=new_folder.model_dump(mode="json")
|
||||
)
|
||||
@ -71,6 +85,10 @@ def setup_library_with_entity(client):
|
||||
assert entity_response.status_code == 200
|
||||
entity_id = entity_response.json()["id"]
|
||||
|
||||
# Update the entity's index
|
||||
index_response = client.post(f"/entities/{entity_id}/index")
|
||||
assert index_response.status_code == 204
|
||||
|
||||
return library_id, folder_id, entity_id
|
||||
|
||||
|
||||
@ -88,10 +106,45 @@ app.dependency_overrides[get_db] = override_get_db
|
||||
# Setup a fixture for the FastAPI test client
|
||||
@pytest.fixture
|
||||
def client():
|
||||
# 创建所有基本表
|
||||
Base.metadata.create_all(bind=engine)
|
||||
|
||||
# 创建 FTS 和 Vec 表
|
||||
with engine.connect() as conn:
|
||||
# 创建 FTS 表
|
||||
conn.execute(
|
||||
text(
|
||||
"""
|
||||
CREATE VIRTUAL TABLE IF NOT EXISTS entities_fts USING fts5(
|
||||
id, filepath, tags, metadata,
|
||||
tokenize = 'simple 0'
|
||||
)
|
||||
"""
|
||||
)
|
||||
)
|
||||
|
||||
# 创建 Vec 表
|
||||
conn.execute(
|
||||
text(
|
||||
f"""
|
||||
CREATE VIRTUAL TABLE IF NOT EXISTS entities_vec USING vec0(
|
||||
embedding float[{settings.embedding.num_dim}]
|
||||
)
|
||||
"""
|
||||
)
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
|
||||
with TestClient(app) as client:
|
||||
yield client
|
||||
|
||||
# 清理数据库
|
||||
Base.metadata.drop_all(bind=engine)
|
||||
with engine.connect() as conn:
|
||||
conn.execute(text("DROP TABLE IF EXISTS entities_fts"))
|
||||
conn.execute(text("DROP TABLE IF EXISTS entities_vec"))
|
||||
conn.commit()
|
||||
|
||||
|
||||
# Test the new_library endpoint
|
||||
@ -119,8 +172,15 @@ def test_new_library(client):
|
||||
|
||||
|
||||
def test_list_libraries(client):
|
||||
# Setup data: Create a new library
|
||||
new_library = NewLibraryParam(name="Sample Library", folders=["/tmp"])
|
||||
# Setup data: Create a new library with a folder
|
||||
new_library = NewLibraryParam(
|
||||
name="Sample Library",
|
||||
folders=[
|
||||
NewFolderParam(
|
||||
path="/tmp", last_modified_at=datetime.now(), type=FolderType.DEFAULT
|
||||
)
|
||||
],
|
||||
)
|
||||
client.post("/libraries", json=new_library.model_dump(mode="json"))
|
||||
|
||||
# Make a GET request to the /libraries endpoint
|
||||
@ -130,20 +190,39 @@ def test_list_libraries(client):
|
||||
assert response.status_code == 200
|
||||
|
||||
# Check the response data
|
||||
response_data = response.json()
|
||||
for folder in response_data[0]["folders"]:
|
||||
assert "last_modified_at" in folder
|
||||
assert isinstance(folder["last_modified_at"], str)
|
||||
del folder["last_modified_at"]
|
||||
|
||||
expected_data = [
|
||||
{
|
||||
"id": 1,
|
||||
"name": "Sample Library",
|
||||
"folders": [{"id": 1, "path": "/tmp"}],
|
||||
"folders": [
|
||||
{
|
||||
"id": 1,
|
||||
"path": "/tmp",
|
||||
"type": "DEFAULT",
|
||||
}
|
||||
],
|
||||
"plugins": [],
|
||||
}
|
||||
]
|
||||
assert response.json() == expected_data
|
||||
assert response_data == expected_data
|
||||
|
||||
|
||||
def test_new_entity(client):
|
||||
# Setup data: Create a new library
|
||||
new_library = NewLibraryParam(name="Library for Entity Test", folders=["/tmp"])
|
||||
new_library = NewLibraryParam(
|
||||
name="Library for Entity Test",
|
||||
folders=[
|
||||
NewFolderParam(
|
||||
path="/tmp", last_modified_at=datetime.now(), type=FolderType.DEFAULT
|
||||
)
|
||||
],
|
||||
)
|
||||
library_response = client.post(
|
||||
"/libraries", json=new_library.model_dump(mode="json")
|
||||
)
|
||||
@ -226,7 +305,14 @@ def test_update_entity(client):
|
||||
# Test for getting an entity by filepath
|
||||
def test_get_entity_by_filepath(client):
|
||||
# Setup data: Create a new library and entity
|
||||
new_library = NewLibraryParam(name="Library for Get Entity Test", folders=["/tmp"])
|
||||
new_library = NewLibraryParam(
|
||||
name="Library for Get Entity Test",
|
||||
folders=[
|
||||
NewFolderParam(
|
||||
path="/tmp", last_modified_at=datetime.now(), type=FolderType.DEFAULT
|
||||
)
|
||||
],
|
||||
)
|
||||
library_response = client.post(
|
||||
"/libraries", json=new_library.model_dump(mode="json")
|
||||
)
|
||||
@ -289,7 +375,13 @@ def test_list_entities_in_folder(client):
|
||||
)
|
||||
library_id = library_response.json()["id"]
|
||||
|
||||
new_folder = NewFoldersParam(folders=["/tmp"])
|
||||
new_folder = NewFoldersParam(
|
||||
folders=[
|
||||
NewFolderParam(
|
||||
path="/tmp", last_modified_at=datetime.now(), type=FolderType.DEFAULT
|
||||
)
|
||||
]
|
||||
)
|
||||
folder_response = client.post(
|
||||
f"/libraries/{library_id}/folders", json=new_folder.model_dump(mode="json")
|
||||
)
|
||||
@ -343,15 +435,45 @@ def test_list_entities_in_folder(client):
|
||||
def test_remove_entity(client):
|
||||
library_id, _, entity_id = setup_library_with_entity(client)
|
||||
|
||||
# Verify the entity data was automatically inserted into fts and vec tables by event listeners
|
||||
with engine.connect() as conn:
|
||||
fts_count = conn.execute(
|
||||
text("SELECT COUNT(*) FROM entities_fts WHERE id = :id"),
|
||||
{"id": entity_id}
|
||||
).scalar()
|
||||
assert fts_count == 1, "Entity was not automatically added to entities_fts table"
|
||||
|
||||
vec_count = conn.execute(
|
||||
text("SELECT COUNT(*) FROM entities_vec WHERE rowid = :id"),
|
||||
{"id": entity_id}
|
||||
).scalar()
|
||||
assert vec_count == 1, "Entity was not automatically added to entities_vec table"
|
||||
|
||||
# Delete the entity
|
||||
delete_response = client.delete(f"/libraries/{library_id}/entities/{entity_id}")
|
||||
assert delete_response.status_code == 204
|
||||
|
||||
# Verify the entity is deleted
|
||||
# Verify the entity is deleted from the main table
|
||||
get_response = client.get(f"/libraries/{library_id}/entities/{entity_id}")
|
||||
assert get_response.status_code == 404
|
||||
assert get_response.json() == {"detail": "Entity not found"}
|
||||
|
||||
# Verify the entity is deleted from entities_fts and entities_vec tables
|
||||
with engine.connect() as conn:
|
||||
# Check entities_fts
|
||||
fts_count = conn.execute(
|
||||
text("SELECT COUNT(*) FROM entities_fts WHERE id = :id"),
|
||||
{"id": entity_id}
|
||||
).scalar()
|
||||
assert fts_count == 0, "Entity was not deleted from entities_fts table"
|
||||
|
||||
# Check entities_vec
|
||||
vec_count = conn.execute(
|
||||
text("SELECT COUNT(*) FROM entities_vec WHERE rowid = :id"),
|
||||
{"id": entity_id}
|
||||
).scalar()
|
||||
assert vec_count == 0, "Entity was not deleted from entities_vec table"
|
||||
|
||||
# Test for entity not found in the specified library
|
||||
invalid_delete_response = client.delete(f"/libraries/{library_id}/entities/9999")
|
||||
assert invalid_delete_response.status_code == 404
|
||||
@ -374,7 +496,15 @@ def test_add_folder_to_library(client):
|
||||
library_id = library_response.json()["id"]
|
||||
|
||||
# Add a new folder to the library
|
||||
new_folders = NewFoldersParam(folders=[tmp_folder_path])
|
||||
new_folders = NewFoldersParam(
|
||||
folders=[
|
||||
NewFolderParam(
|
||||
path=tmp_folder_path,
|
||||
last_modified_at=datetime.now(),
|
||||
type=FolderType.DEFAULT,
|
||||
)
|
||||
]
|
||||
)
|
||||
folder_response = client.post(
|
||||
f"/libraries/{library_id}/folders", json=new_folders.model_dump(mode="json")
|
||||
)
|
||||
@ -615,6 +745,17 @@ def test_patch_entity_metadata_entries(client):
|
||||
# Check the response data
|
||||
patched_entity_data = patch_response.json()
|
||||
expected_data = load_fixture("patch_entity_metadata_response.json")
|
||||
|
||||
# 检查并移除 last_scan_at
|
||||
assert "last_scan_at" in patched_entity_data
|
||||
assert isinstance(patched_entity_data["last_scan_at"], str)
|
||||
|
||||
datetime.fromisoformat(patched_entity_data["last_scan_at"].replace("Z", "+00:00"))
|
||||
|
||||
del patched_entity_data["last_scan_at"]
|
||||
if "last_scan_at" in expected_data:
|
||||
del expected_data["last_scan_at"]
|
||||
|
||||
assert patched_entity_data == expected_data
|
||||
|
||||
# Update the "author" attribute of the entity
|
||||
|
@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "memos"
|
||||
version = "0.17.4"
|
||||
version = "0.18.8"
|
||||
description = "A package for memos"
|
||||
readme = "README.md"
|
||||
authors = [{ name = "arkohut" }]
|
||||
@ -31,10 +31,8 @@ dependencies = [
|
||||
"rapidocr_onnxruntime",
|
||||
"rapidocr_openvino; sys_platform == 'win32'",
|
||||
"py-cpuinfo",
|
||||
"screeninfo",
|
||||
"psutil",
|
||||
"pywin32; sys_platform == 'win32'",
|
||||
"pyobjc; sys_platform == 'darwin'",
|
||||
"pyobjc-core; sys_platform == 'darwin'",
|
||||
"pyobjc-framework-Quartz; sys_platform == 'darwin'",
|
||||
"ocrmac; sys_platform == 'darwin'",
|
||||
|
94
start.py
Normal file
94
start.py
Normal file
@ -0,0 +1,94 @@
|
||||
import subprocess
|
||||
import threading
|
||||
import sys
|
||||
import signal
|
||||
from colorama import init, Fore
|
||||
import time
|
||||
|
||||
# Initialize colorama for Windows compatibility
|
||||
init()
|
||||
|
||||
# Define colors for each process
|
||||
COLORS = [Fore.GREEN, Fore.BLUE, Fore.YELLOW]
|
||||
|
||||
|
||||
def run_process(command, color):
|
||||
"""Run a single process with colored output."""
|
||||
try:
|
||||
process = subprocess.Popen(
|
||||
command,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
text=True,
|
||||
bufsize=1,
|
||||
universal_newlines=True,
|
||||
)
|
||||
|
||||
while True:
|
||||
line = process.stdout.readline()
|
||||
if not line and process.poll() is not None:
|
||||
break
|
||||
if line:
|
||||
print(f"{color}{command[0]}: {line.rstrip()}{Fore.RESET}")
|
||||
|
||||
return process.poll()
|
||||
|
||||
except Exception as e:
|
||||
print(f"{Fore.RED}Error in {command[0]}: {str(e)}{Fore.RESET}")
|
||||
return 1
|
||||
|
||||
|
||||
def main():
|
||||
# Define your three commands here
|
||||
commands = [
|
||||
["python", "memos_app.py", "record"],
|
||||
["python", "memos_app.py", "serve"],
|
||||
["python", "memos_app.py", "watch"],
|
||||
]
|
||||
|
||||
# Create threads for each process
|
||||
threads = []
|
||||
processes = []
|
||||
|
||||
def signal_handler(signum, frame):
|
||||
print(f"\n{Fore.RED}Interrupting all processes...{Fore.RESET}")
|
||||
for process in processes:
|
||||
process.terminate()
|
||||
sys.exit(0)
|
||||
|
||||
# Set up signal handlers
|
||||
signal.signal(signal.SIGINT, signal_handler)
|
||||
signal.signal(signal.SIGTERM, signal_handler)
|
||||
|
||||
# Run processes in separate threads
|
||||
for i, command in enumerate(commands):
|
||||
time.sleep(3)
|
||||
color = COLORS[i % len(COLORS)]
|
||||
process = subprocess.Popen(
|
||||
command,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
text=True,
|
||||
bufsize=1,
|
||||
universal_newlines=True,
|
||||
)
|
||||
processes.append(process)
|
||||
thread = threading.Thread(target=run_process, args=(command, color))
|
||||
thread.start()
|
||||
threads.append(thread)
|
||||
print(f"Started {command[0]} with PID {process.pid}")
|
||||
|
||||
# Wait for all threads to complete
|
||||
for thread in threads:
|
||||
thread.join()
|
||||
|
||||
# Check for any failed processes
|
||||
failed_processes = [process for process in processes if process != 0]
|
||||
if failed_processes:
|
||||
print(f"\n{Fore.RED}Some processes failed: {failed_processes}{Fore.RESET}")
|
||||
else:
|
||||
print(f"\n{Fore.GREEN}All processes completed successfully!{Fore.RESET}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
4
start.sh
Executable file
4
start.sh
Executable file
@ -0,0 +1,4 @@
|
||||
#! /usr/bin/env bash
|
||||
|
||||
conda activate memos || exit 1
|
||||
python start.py
|
6
web/package-lock.json
generated
6
web/package-lock.json
generated
@ -2061,9 +2061,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/cross-spawn": {
|
||||
"version": "7.0.3",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz",
|
||||
"integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==",
|
||||
"version": "7.0.5",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.5.tgz",
|
||||
"integrity": "sha512-ZVJrKKYunU38/76t0RMOulHOnUcbU9GbpWKAOZ0mhjr7CX6FVrH+4FrAapSOekrgFQ3f/8gwMEuIft0aKq6Hug==",
|
||||
"dependencies": {
|
||||
"path-key": "^3.1.0",
|
||||
"shebang-command": "^2.0.0",
|
||||
|
@ -3,50 +3,50 @@
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 0 0% 98%;
|
||||
--foreground: 240 10% 3.9%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 240 10% 3.9%;
|
||||
--popover: 0 0% 100%;;
|
||||
--popover-foreground: 240 10% 3.9%;
|
||||
--primary: 240 5.9% 10%;
|
||||
--primary-foreground: 0 0% 98%;
|
||||
--secondary: 240 4.8% 95.9%;
|
||||
--secondary-foreground: 240 5.9% 10%;
|
||||
--muted: 240 4.8% 95.9%;
|
||||
--muted-foreground: 240 3.8% 46.1%;
|
||||
--accent: 240 4.8% 95.9%;
|
||||
--accent-foreground: 240 5.9% 10%;
|
||||
--destructive: 0 72.22% 50.59%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 240 5.9% 90%;
|
||||
--input: 240 5.9% 90%;
|
||||
--ring: 240 5.9% 10%;
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
.dark {
|
||||
--background: 240 10% 3.9%;
|
||||
--foreground: 0 0% 98%;
|
||||
--card: 240 10% 3.9%;
|
||||
--card-foreground: 0 0% 98%;
|
||||
--popover: 240 10% 3.9%;
|
||||
--popover-foreground: 0 0% 98%;
|
||||
--primary: 0 0% 98%;
|
||||
--primary-foreground: 240 5.9% 10%;
|
||||
--secondary: 240 3.7% 15.9%;
|
||||
--secondary-foreground: 0 0% 98%;
|
||||
--muted: 240 3.7% 15.9%;
|
||||
--muted-foreground: 240 5% 64.9%;
|
||||
--accent: 240 3.7% 15.9%;
|
||||
--accent-foreground: 0 0% 98%;
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 240 3.7% 15.9%;
|
||||
--input: 240 3.7% 15.9%;
|
||||
--ring: 240 4.9% 83.9%;
|
||||
}
|
||||
:root {
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 240 10% 3.9%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 240 10% 3.9%;
|
||||
--popover: 0 0% 100%;;
|
||||
--popover-foreground: 240 10% 3.9%;
|
||||
--primary: 240 5.9% 10%;
|
||||
--primary-foreground: 0 0% 98%;
|
||||
--secondary: 240 4.8% 95.9%;
|
||||
--secondary-foreground: 240 5.9% 10%;
|
||||
--muted: 240 4.8% 95.9%;
|
||||
--muted-foreground: 240 3.8% 46.1%;
|
||||
--accent: 240 4.8% 95.9%;
|
||||
--accent-foreground: 240 5.9% 10%;
|
||||
--destructive: 0 72.22% 50.59%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 240 5.9% 90%;
|
||||
--input: 240 5.9% 90%;
|
||||
--ring: 240 5.9% 10%;
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
.dark {
|
||||
--background: 240 10% 3.9%;
|
||||
--foreground: 0 0% 98%;
|
||||
--card: 240 10% 3.9%;
|
||||
--card-foreground: 0 0% 98%;
|
||||
--popover: 240 10% 3.9%;
|
||||
--popover-foreground: 0 0% 98%;
|
||||
--primary: 0 0% 98%;
|
||||
--primary-foreground: 240 5.9% 10%;
|
||||
--secondary: 240 3.7% 15.9%;
|
||||
--secondary-foreground: 0 0% 98%;
|
||||
--muted: 240 3.7% 15.9%;
|
||||
--muted-foreground: 240 5% 64.9%;
|
||||
--accent: 240 3.7% 15.9%;
|
||||
--accent-foreground: 0 0% 98%;
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 240 3.7% 15.9%;
|
||||
--input: 240 3.7% 15.9%;
|
||||
--ring: 240 4.9% 83.9%;
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@ -55,4 +55,9 @@
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
|
||||
::selection {
|
||||
background-color: #b3d4fc; /* Light blue background */
|
||||
color: #000000; /* Black text */
|
||||
}
|
||||
}
|
||||
|
@ -10,12 +10,10 @@
|
||||
];
|
||||
|
||||
onMount(() => {
|
||||
// 尝试从 localStorage 获取保存的语言
|
||||
const savedLocale = localStorage.getItem('selectedLocale');
|
||||
if (savedLocale) {
|
||||
setLocale(savedLocale);
|
||||
} else {
|
||||
// 如果没有保存的语言,则使用浏览器语言
|
||||
const browserLang = navigator.language.split('-')[0];
|
||||
setLocale(browserLang === 'zh' ? 'zh' : 'en');
|
||||
}
|
||||
@ -28,8 +26,12 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<select bind:value={selectedLocale} on:change={() => setLocale(selectedLocale)} class="bg-white text-slate-500">
|
||||
{#each languages as language}
|
||||
<option value={language.value}>{language.label}</option>
|
||||
{/each}
|
||||
<select
|
||||
bind:value={selectedLocale}
|
||||
on:change={() => setLocale(selectedLocale)}
|
||||
class="appearance-none bg-white text-slate-500 px-2 py-1 pr-8 rounded-md border border-slate-200 cursor-pointer hover:border-slate-300 focus:outline-none focus:ring-2 focus:ring-slate-200 focus:border-slate-300 text-sm font-medium relative bg-no-repeat bg-[right_0.5rem_center] bg-[length:1.5em_1.5em] bg-[url('data:image/svg+xml;charset=utf-8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2020%2020%22%20fill%3D%22%236b7280%22%3E%3Cpath%20fill-rule%3D%22evenodd%22%20d%3D%22M5.293%207.293a1%201%200%20011.414%200L10%2010.586l3.293-3.293a1%201%200%20111.414%201.414l-4%204a1%201%200%2001-1.414%200l-4-4a1%201%200%20010-1.414z%22%20clip-rule%3D%22evenodd%22%2F%3E%3C%2Fsvg%3E')]"
|
||||
>
|
||||
{#each languages as language}
|
||||
<option value={language.value}>{language.label}</option>
|
||||
{/each}
|
||||
</select>
|
@ -285,107 +285,134 @@
|
||||
|
||||
<svelte:window on:keydown={handleKeydown} />
|
||||
|
||||
<header
|
||||
class="sticky top-0 z-10 transition-all duration-300"
|
||||
bind:this={headerElement}
|
||||
>
|
||||
<div class="mx-auto max-w-screen-lg flex items-center justify-between p-4 transition-all duration-300"
|
||||
class:flex-col={!isScrolled}
|
||||
class:flex-row={isScrolled}
|
||||
<!-- 添加一个最外层的容器来管理整体布局 -->
|
||||
<div class="min-h-screen flex flex-col">
|
||||
<!-- Header 部分 -->
|
||||
<header
|
||||
class="sticky top-0 z-10 transition-all duration-300"
|
||||
bind:this={headerElement}
|
||||
>
|
||||
<Logo size={isScrolled ? 32 : 128} withBorder={!isScrolled} hasGap={!isScrolled} class_="transition-transform duration-300 ease-in-out mr-4" />
|
||||
<Input
|
||||
type="text"
|
||||
class={inputClasses}
|
||||
bind:value={searchString}
|
||||
placeholder={$_('searchPlaceholder')}
|
||||
on:keydown={handleEnterPress}
|
||||
autofocus
|
||||
/>
|
||||
<div class="mx-auto max-w-screen-lg">
|
||||
<div class="flex space-x-2" class:mt-4={!isScrolled} class:ml-4={isScrolled}>
|
||||
<LibraryFilter bind:selectedLibraryIds={selectedLibraries} />
|
||||
<TimeFilter bind:start={startTimestamp} bind:end={endTimestamp} />
|
||||
<div class="mx-auto max-w-screen-lg flex items-center justify-between p-4 transition-all duration-300"
|
||||
class:flex-col={!isScrolled}
|
||||
class:flex-row={isScrolled}
|
||||
>
|
||||
<Logo size={isScrolled ? 32 : 128} withBorder={!isScrolled} hasGap={!isScrolled} class_="transition-transform duration-300 ease-in-out mr-4" />
|
||||
<Input
|
||||
type="text"
|
||||
class={inputClasses}
|
||||
bind:value={searchString}
|
||||
placeholder={$_('searchPlaceholder')}
|
||||
on:keydown={handleEnterPress}
|
||||
autofocus
|
||||
/>
|
||||
<div class="mx-auto max-w-screen-lg">
|
||||
<div class="flex space-x-2" class:mt-4={!isScrolled} class:ml-4={isScrolled}>
|
||||
<LibraryFilter bind:selectedLibraryIds={selectedLibraries} />
|
||||
<TimeFilter bind:start={startTimestamp} bind:end={endTimestamp} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
</header>
|
||||
|
||||
<!-- 添加一个动态调整高度的空白区域 -->
|
||||
<div style="height: {isScrolled ? '100px' : '0px'}"></div>
|
||||
<!-- 添加一个动态调整高度的空白区域 -->
|
||||
<div style="height: {isScrolled ? '100px' : '0px'}"></div>
|
||||
|
||||
<div class="mx-auto flex flex-col sm:flex-row">
|
||||
<!-- Left panel for tags and created_date -->
|
||||
{#if searchResult && searchResult.facet_counts && searchResult.facet_counts.length > 0}
|
||||
<div class="xl:w-1/7 lg:w-1/6 md:w-1/5 sm:w-full pr-4">
|
||||
{#each searchResult.facet_counts as facet}
|
||||
{#if facet.field_name === 'tags' || facet.field_name === 'created_date'}
|
||||
<FacetFilter
|
||||
{facet}
|
||||
selectedItems={facet.field_name === 'tags' ? selectedTags : selectedDates}
|
||||
onItemChange={facet.field_name === 'tags' ? handleTagChange : handleDateChange}
|
||||
/>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Right panel for search results -->
|
||||
<div class="{searchResult && searchResult.facet_counts && searchResult.facet_counts.length > 0 ? 'xl:w-6/7 lg:w-5/6 md:w-4/5' : 'w-full'}">
|
||||
{#if isLoading}
|
||||
<p class="text-center">{$_('loading')}</p>
|
||||
{:else if searchResult && searchResult.hits.length > 0}
|
||||
{#if searchResult['search_time_ms'] > 0}
|
||||
<p class="search-summary mb-4 text-center">
|
||||
{$_('searchSummary', { values: {
|
||||
found: searchResult['found'].toLocaleString(),
|
||||
outOf: searchResult['out_of'].toLocaleString(),
|
||||
time: searchResult['search_time_ms']
|
||||
}})}
|
||||
</p>
|
||||
{/if}
|
||||
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||
{#each searchResult.hits as hit, index}
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
||||
<div
|
||||
class="bg-white rounded-lg overflow-hidden border border-gray-300 relative"
|
||||
on:click={() => openModal(index)}
|
||||
>
|
||||
<div class="px-4 pt-4">
|
||||
<h2 class="line-clamp-2 h-12">
|
||||
{getEntityTitle(hit.document)}
|
||||
</h2>
|
||||
<p class="text-gray-700 text-xs">
|
||||
{formatDistanceToNow(new Date(hit.document.file_created_at * 1000), {
|
||||
addSuffix: true
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<figure class="px-4 pt-4 mb-4 relative">
|
||||
<img
|
||||
class="w-full h-48 object-cover"
|
||||
src={`${apiEndpoint}/files/${hit.document.filepath}`}
|
||||
alt=""
|
||||
<!-- 主要内容区域 -->
|
||||
<main class="flex-grow">
|
||||
<div class="mx-auto flex flex-col sm:flex-row">
|
||||
<!-- 左侧面板 -->
|
||||
{#if searchResult && searchResult.facet_counts && searchResult.facet_counts.length > 0}
|
||||
<div class="xl:w-1/7 lg:w-1/6 md:w-1/5 sm:w-full pr-4">
|
||||
{#each searchResult.facet_counts as facet}
|
||||
{#if facet.field_name === 'tags' || facet.field_name === 'created_date'}
|
||||
<FacetFilter
|
||||
{facet}
|
||||
selectedItems={facet.field_name === 'tags' ? selectedTags : selectedDates}
|
||||
onItemChange={facet.field_name === 'tags' ? handleTagChange : handleDateChange}
|
||||
/>
|
||||
{#if getAppName(hit.document) !== "unknown"}
|
||||
<div
|
||||
class="absolute bottom-2 left-6 bg-white bg-opacity-75 px-2 py-1 rounded-full text-xs font-semibold border border-gray-200 flex items-center space-x-2"
|
||||
>
|
||||
<LucideIcon name={translateAppName(getAppName(hit.document)) || "Hexagon"} size={16} />
|
||||
<span>{getAppName(hit.document)}</span>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- 右侧面板 -->
|
||||
<div class="{searchResult && searchResult.facet_counts && searchResult.facet_counts.length > 0 ? 'xl:w-6/7 lg:w-5/6 md:w-4/5' : 'w-full'}">
|
||||
{#if isLoading}
|
||||
<p class="text-center">{$_('loading')}</p>
|
||||
{:else if searchResult && searchResult.hits.length > 0}
|
||||
{#if searchResult['search_time_ms'] > 0}
|
||||
<p class="search-summary mb-4 text-center">
|
||||
{$_('searchSummary', { values: {
|
||||
found: searchResult['found'].toLocaleString(),
|
||||
outOf: searchResult['out_of'].toLocaleString(),
|
||||
time: searchResult['search_time_ms']
|
||||
}})}
|
||||
</p>
|
||||
{/if}
|
||||
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||
{#each searchResult.hits as hit, index}
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
||||
<div
|
||||
class="bg-white rounded-lg overflow-hidden border border-gray-300 relative"
|
||||
on:click={() => openModal(index)}
|
||||
>
|
||||
<div class="px-4 pt-4">
|
||||
<h2 class="line-clamp-2 h-12">
|
||||
{getEntityTitle(hit.document)}
|
||||
</h2>
|
||||
<p class="text-gray-700 text-xs">
|
||||
{formatDistanceToNow(new Date(hit.document.file_created_at * 1000), {
|
||||
addSuffix: true
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
</figure>
|
||||
<figure class="px-4 pt-4 mb-4 relative">
|
||||
<img
|
||||
class="w-full h-48 object-cover"
|
||||
src={`${apiEndpoint}/files/${hit.document.filepath}`}
|
||||
alt=""
|
||||
/>
|
||||
{#if getAppName(hit.document) !== "unknown"}
|
||||
<div
|
||||
class="absolute bottom-2 left-6 bg-white bg-opacity-75 px-2 py-1 rounded-full text-xs font-semibold border border-gray-200 flex items-center space-x-2"
|
||||
>
|
||||
<LucideIcon name={translateAppName(getAppName(hit.document)) || "Hexagon"} size={16} />
|
||||
<span>{getAppName(hit.document)}</span>
|
||||
</div>
|
||||
{/if}
|
||||
</figure>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/each}
|
||||
{:else if searchString}
|
||||
<p class="text-center">{$_('noResults')}</p>
|
||||
{:else}
|
||||
<p class="text-center"></p>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if searchString}
|
||||
<p class="text-center">{$_('noResults')}</p>
|
||||
{:else}
|
||||
<p class="text-center"></p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- Footer -->
|
||||
<footer class="w-full mx-auto mt-8">
|
||||
<div class="container mx-auto">
|
||||
<div class="border-t border-slate-900/5 py-10 text-center">
|
||||
<p class="mt-2 text-sm leading-6 text-slate-500">{$_('slogan')}</p>
|
||||
<p class="mt-2 text-sm leading-6 text-slate-500">{$_('copyright')}</p>
|
||||
<div class="mt-2 flex justify-center items-center space-x-4 text-sm font-semibold leading-6 text-slate-700">
|
||||
<a href="https://github.com/arkohut/memos"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="hover:text-slate-900 transition-colors">
|
||||
<Github size={16} />
|
||||
</a>
|
||||
<div class="h-4 w-px bg-slate-500/20" />
|
||||
<LanguageSwitcher />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
{#if searchResult && searchResult.hits.length && showModal}
|
||||
@ -408,17 +435,3 @@
|
||||
openModal((selectedImage - 1 + searchResult.hits.length) % searchResult.hits.length)}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<footer class="mx-auto mt-32 w-full container text-center">
|
||||
<div class="border-t border-slate-900/5 py-10">
|
||||
<p class="mt-2 text-sm leading-6 text-slate-500">{$_('slogan')}</p>
|
||||
<p class="mt-2 text-sm leading-6 text-slate-500">{$_('copyright')}</p>
|
||||
<div class="mt-2 flex justify-center items-center space-x-4 text-sm font-semibold leading-6 text-slate-700">
|
||||
<a href="https://github.com/arkohut/memos" target="_blank" rel="noopener noreferrer">
|
||||
<Github size={16} />
|
||||
</a>
|
||||
<div class="h-4 w-px bg-slate-500/20" />
|
||||
<LanguageSwitcher />
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
Loading…
x
Reference in New Issue
Block a user