mirror of
https://github.com/tcsenpai/pensieve.git
synced 2025-06-06 19:25:24 +00:00
Compare commits
40 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 |
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
|
||||
@ -839,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(
|
||||
@ -399,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
|
||||
@ -447,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()
|
||||
@ -494,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.")
|
||||
|
||||
@ -535,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}")
|
||||
|
@ -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
|
||||
|
@ -74,7 +74,7 @@ def get_embeddings(texts: List[str]) -> List[List[float]]:
|
||||
def get_remote_embeddings(texts: List[str]) -> List[List[float]]:
|
||||
payload = {"model": settings.embedding.model, "input": texts}
|
||||
|
||||
with httpx.Client() as client:
|
||||
with httpx.Client(timeout=60) as client:
|
||||
try:
|
||||
response = client.post(settings.embedding.endpoint, json=payload)
|
||||
response.raise_for_status()
|
||||
|
@ -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
|
||||
|
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)
|
||||
|
||||
|
@ -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
|
||||
@ -53,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)
|
||||
@ -90,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"))
|
||||
|
@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "memos"
|
||||
version = "0.18.0"
|
||||
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