Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c3f3f82a3e | ||
|
|
70af43c08c | ||
|
|
1786d907f7 | ||
|
|
12a268da30 | ||
|
|
0aefdf5251 | ||
|
|
df72245b6b | ||
|
|
fe872d0776 | ||
|
|
052d31939d | ||
|
|
1c44b675d9 | ||
|
|
a1c9a63074 | ||
|
|
931df14e25 | ||
|
|
1b0d8bdd2e | ||
|
|
5c67a5e6bc | ||
|
|
267e5b13ea | ||
|
|
a22287a3ba | ||
|
|
da59412150 | ||
|
|
c41719ff9a | ||
|
|
f51b39db91 | ||
|
|
8d61d73d2f | ||
|
|
10acf78618 | ||
|
|
caeae60dda | ||
|
|
d7b8497b24 |
@@ -18,20 +18,17 @@ jobs:
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Install Poetry
|
||||
- name: Install Hatch
|
||||
run: |
|
||||
curl -sSL https://install.python-poetry.org | python3 -
|
||||
echo "$HOME/.local/bin" >> $GITHUB_PATH
|
||||
pip install hatch
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
cd mem0
|
||||
poetry install
|
||||
hatch env create
|
||||
|
||||
- name: Build a binary wheel and a source tarball
|
||||
run: |
|
||||
cd mem0
|
||||
poetry build
|
||||
hatch build --clean
|
||||
|
||||
# TODO: Needs to setup mem0 repo on Test PyPI
|
||||
# - name: Publish distribution 📦 to Test PyPI
|
||||
|
||||
+16
-22
@@ -44,25 +44,23 @@ jobs:
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
- name: Install poetry
|
||||
uses: snok/install-poetry@v1
|
||||
with:
|
||||
version: 1.4.2
|
||||
virtualenvs-create: true
|
||||
virtualenvs-in-project: true
|
||||
- name: Install Hatch
|
||||
run: pip install hatch
|
||||
- name: Load cached venv
|
||||
id: cached-poetry-dependencies
|
||||
id: cached-hatch-dependencies
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: .venv
|
||||
key: venv-mem0-${{ runner.os }}-${{ hashFiles('**/poetry.lock') }}
|
||||
key: venv-mem0-${{ runner.os }}-${{ hashFiles('**/pyproject.toml') }}
|
||||
- name: Install dependencies
|
||||
run: make install_all
|
||||
if: steps.cached-poetry-dependencies.outputs.cache-hit != 'true'
|
||||
run: |
|
||||
make install_all
|
||||
pip install -e ".[test]"
|
||||
if: steps.cached-hatch-dependencies.outputs.cache-hit != 'true'
|
||||
- name: Run Formatting
|
||||
run: |
|
||||
mkdir -p mem0/.ruff_cache && chmod -R 777 mem0/.ruff_cache
|
||||
cd mem0 && poetry run ruff check . --select F
|
||||
mkdir -p .ruff_cache && chmod -R 777 .ruff_cache
|
||||
hatch run format
|
||||
- name: Run tests and generate coverage report
|
||||
run: make test
|
||||
|
||||
@@ -79,25 +77,21 @@ jobs:
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
- name: Install poetry
|
||||
uses: snok/install-poetry@v1
|
||||
with:
|
||||
version: 1.4.2
|
||||
virtualenvs-create: true
|
||||
virtualenvs-in-project: true
|
||||
- name: Install Hatch
|
||||
run: pip install hatch
|
||||
- name: Load cached venv
|
||||
id: cached-poetry-dependencies
|
||||
id: cached-hatch-dependencies
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: .venv
|
||||
key: venv-embedchain-${{ runner.os }}-${{ hashFiles('**/poetry.lock') }}
|
||||
key: venv-embedchain-${{ runner.os }}-${{ hashFiles('**/pyproject.toml') }}
|
||||
- name: Install dependencies
|
||||
run: cd embedchain && make install_all
|
||||
if: steps.cached-poetry-dependencies.outputs.cache-hit != 'true'
|
||||
if: steps.cached-hatch-dependencies.outputs.cache-hit != 'true'
|
||||
- name: Run Formatting
|
||||
run: |
|
||||
mkdir -p embedchain/.ruff_cache && chmod -R 777 embedchain/.ruff_cache
|
||||
cd embedchain && poetry run ruff check . --select F
|
||||
cd embedchain && hatch run format
|
||||
- name: Lint with ruff
|
||||
run: cd embedchain && make lint
|
||||
- name: Run tests and generate coverage report
|
||||
|
||||
@@ -8,37 +8,36 @@ PROJECT_NAME := mem0ai
|
||||
all: format sort lint
|
||||
|
||||
install:
|
||||
poetry install
|
||||
hatch env create
|
||||
|
||||
install_all:
|
||||
poetry install
|
||||
poetry run pip install ruff==0.6.9 groq together boto3 litellm ollama chromadb weaviate weaviate-client sentence_transformers vertexai \
|
||||
pip install ruff==0.6.9 groq together boto3 litellm ollama chromadb weaviate weaviate-client sentence_transformers vertexai \
|
||||
google-generativeai elasticsearch opensearch-py vecs pinecone pinecone-text faiss-cpu langchain-community \
|
||||
upstash-vector azure-search-documents langchain-memgraph
|
||||
upstash-vector azure-search-documents langchain-memgraph langchain-neo4j rank-bm25
|
||||
|
||||
# Format code with ruff
|
||||
format:
|
||||
poetry run ruff format mem0/
|
||||
hatch run format
|
||||
|
||||
# Sort imports with isort
|
||||
sort:
|
||||
poetry run isort mem0/
|
||||
hatch run isort mem0/
|
||||
|
||||
# Lint code with ruff
|
||||
lint:
|
||||
poetry run ruff check mem0/
|
||||
hatch run lint
|
||||
|
||||
docs:
|
||||
cd docs && mintlify dev
|
||||
|
||||
build:
|
||||
poetry build
|
||||
hatch build
|
||||
|
||||
publish:
|
||||
poetry publish
|
||||
hatch publish
|
||||
|
||||
clean:
|
||||
poetry run rm -rf dist
|
||||
rm -rf dist
|
||||
|
||||
test:
|
||||
poetry run pytest tests
|
||||
hatch run test
|
||||
|
||||
@@ -15,6 +15,8 @@
|
||||
<a href="https://mem0.dev/DiG">Join Discord</a>
|
||||
·
|
||||
<a href="https://mem0.dev/demo">Demo</a>
|
||||
·
|
||||
<a href="https://mem0.dev/openmemory">OpenMemory</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
|
||||
@@ -8,6 +8,85 @@ mode: "wide"
|
||||
<Tabs>
|
||||
<Tab title="Python">
|
||||
|
||||
<Update label="2025-05-10" description="v0.1.100">
|
||||
|
||||
**New Features:**
|
||||
- **Memory:** Added Group Chat Memory Feature support
|
||||
- **Examples:** Added Healthcare assistant using Mem0 and Google ADK
|
||||
|
||||
**Bug Fixes:**
|
||||
- **SSE:** Fixed SSE connection issues
|
||||
- **MCP:** Fixed memories not appearing in MCP clients added from Dashboard
|
||||
|
||||
</Update>
|
||||
|
||||
<Update label="2025-05-07" description="v0.1.99">
|
||||
|
||||
**New Features:**
|
||||
- **OpenMemory:** Added OpenMemory support
|
||||
- **Neo4j:** Added weights to Neo4j model
|
||||
- **AWS:** Added support for Opsearch Serverless
|
||||
- **Examples:** Added ElizaOS Example
|
||||
|
||||
**Improvements:**
|
||||
- **Documentation:** Updated Azure AI documentation
|
||||
- **AI SDK:** Added missing parameters and updated demo application
|
||||
- **OSS:** Fixed AOSS and AWS BedRock LLM
|
||||
|
||||
</Update>
|
||||
|
||||
<Update label="2025-04-30" description="v0.1.98">
|
||||
|
||||
**New Features:**
|
||||
- **Neo4j:** Added support for Neo4j database
|
||||
- **AWS:** Added support for AWS Bedrock Embeddings
|
||||
|
||||
**Improvements:**
|
||||
- **Client:** Updated delete_users() to use V2 API endpoints
|
||||
- **Documentation:** Updated timestamp and dual-identity memory management docs
|
||||
- **Neo4j:** Improved Neo4j queries and removed warnings
|
||||
- **AI SDK:** Added support for graceful failure when services are down
|
||||
|
||||
**Bug Fixes:**
|
||||
- Fixed AI SDK filters
|
||||
- Fixed new memories wrong type
|
||||
- Fixed duplicated metadata issue while adding/updating memories
|
||||
|
||||
</Update>
|
||||
|
||||
<Update label="2025-04-23" description="v0.1.97">
|
||||
|
||||
**New Features:**
|
||||
- **HuggingFace:** Added support for HF Inference
|
||||
|
||||
**Bug Fixes:**
|
||||
- Fixed proxy for Mem0
|
||||
|
||||
</Update>
|
||||
|
||||
<Update label="2025-04-16" description="v0.1.96">
|
||||
|
||||
**New Features:**
|
||||
- **Vercel AI SDK:** Added Graph Memory support
|
||||
|
||||
**Improvements:**
|
||||
- **Documentation:** Fixed timestamp and README links
|
||||
- **Client:** Updated TS client to use proper types for deleteUsers
|
||||
- **Dependencies:** Removed unnecessary dependencies from base package
|
||||
|
||||
</Update>
|
||||
|
||||
<Update label="2025-04-09" description="v0.1.95">
|
||||
|
||||
**Improvements:**
|
||||
- **Client:** Fixed Ping Method for using default org_id and project_id
|
||||
- **Documentation:** Updated documentation
|
||||
|
||||
**Bug Fixes:**
|
||||
- Fixed mem0-migrations issue
|
||||
|
||||
</Update>
|
||||
|
||||
<Update label="2025-04-26" description="v0.1.94">
|
||||
|
||||
**New Features:**
|
||||
@@ -330,6 +409,104 @@ mode: "wide"
|
||||
|
||||
<Tab title="Platform">
|
||||
|
||||
<Update label="2025-05-19" description="">
|
||||
|
||||
**Bug Fixes:**
|
||||
- **Core:** Fixed unicode error in user_id, agent_id, run_id and app_id
|
||||
|
||||
</Update>
|
||||
|
||||
<Update label="2025-05-17" description="">
|
||||
|
||||
**New Features:**
|
||||
- **Graph:** Added Neo4J Graph Migration
|
||||
- **API:** Added API to set custom instructions
|
||||
|
||||
</Update>
|
||||
|
||||
<Update label="2025-05-16" description="">
|
||||
|
||||
**New Features:**
|
||||
- **API:** Added Org-wide API Limit and Usage
|
||||
|
||||
**Improvements:**
|
||||
- **Database:** Added migration for "is_deleted" column
|
||||
- **Graph:** Improved graph queries
|
||||
|
||||
</Update>
|
||||
|
||||
<Update label="2025-05-15" description="">
|
||||
|
||||
**New Features:**
|
||||
- **Lambda:** Added actions to lambda
|
||||
- **Core:** Added background runs support
|
||||
- **Models:** Added o4-mini for pro users
|
||||
|
||||
</Update>
|
||||
|
||||
<Update label="2025-05-10" description="">
|
||||
|
||||
**New Features:**
|
||||
- **Integrations:** Added Intercom Events integration
|
||||
- **Billing:** Added prefilled email for payments
|
||||
- **Organizations:** Added Pro organization marking
|
||||
|
||||
**Improvements:**
|
||||
- **UI:** Fixed loading jitter for organization selection
|
||||
- **Infrastructure:** Improved production scaling
|
||||
|
||||
</Update>
|
||||
|
||||
<Update label="2025-05-09" description="">
|
||||
|
||||
**Improvements:**
|
||||
- **Memory:** Fixed filters in Memory Page
|
||||
- **Deployment:** Added custom categories for on-premise
|
||||
|
||||
</Update>
|
||||
|
||||
<Update label="2025-05-08" description="">
|
||||
|
||||
**Improvements:**
|
||||
- **Backend:** Updated Django settings for metrics
|
||||
- **Memory:** Added retries to memory filtering
|
||||
- **Search:** Added scoring mechanism
|
||||
|
||||
</Update>
|
||||
|
||||
<Update label="2025-05-07" description="">
|
||||
|
||||
**Improvements:**
|
||||
- **Deployment:** Updated deployment scripts
|
||||
- **Testing:** Added code coverage tracking
|
||||
- **Memory:** Added background cron job for memory quality
|
||||
|
||||
</Update>
|
||||
|
||||
<Update label="2025-05-06" description="">
|
||||
|
||||
**New Features:**
|
||||
- **Models:** Added support for 4.1-mini model
|
||||
|
||||
**Improvements:**
|
||||
- **Infrastructure:** Increased instance count
|
||||
- **API:** Added V2 for Manage Entities
|
||||
|
||||
</Update>
|
||||
|
||||
<Update label="2025-05-04" description="">
|
||||
|
||||
**New Features:**
|
||||
- **Testing:** Added code coverage tracking
|
||||
- **AI:** Added Keywords AI integration
|
||||
|
||||
**Improvements:**
|
||||
- **UI:** Updated UI with tabs
|
||||
- **Database:** Added migrations for custom instructions
|
||||
- **Search:** Added criteria filtering
|
||||
|
||||
</Update>
|
||||
|
||||
<Update label="2025-04-26" description="">
|
||||
|
||||
**Improvements:**
|
||||
@@ -487,6 +664,11 @@ mode: "wide"
|
||||
|
||||
<Tab title="Vercel AI SDK">
|
||||
|
||||
<Update label="2025-05-10" description="v1.0.4">
|
||||
**New Features:**
|
||||
- **Vercel AI SDK:** Added support for new param `output_format`.
|
||||
</Update>
|
||||
|
||||
<Update label="2025-05-08" description="v1.0.3">
|
||||
**Improvements:**
|
||||
- **Vercel AI SDK:** Added support for graceful failure in cases services are down.
|
||||
|
||||
@@ -25,7 +25,7 @@ from mem0 import Memory
|
||||
os.environ["OPENAI_API_KEY"] = "your-openai-api-key"
|
||||
|
||||
# AWS credentials
|
||||
os.environ["AWS_REGION"] = "us-east-1"
|
||||
os.environ["AWS_REGION"] = "us-west-2"
|
||||
os.environ["AWS_ACCESS_KEY_ID"] = "your-access-key"
|
||||
os.environ["AWS_SECRET_ACCESS_KEY"] = "your-secret-key"
|
||||
|
||||
@@ -33,7 +33,7 @@ config = {
|
||||
"embedder": {
|
||||
"provider": "aws_bedrock",
|
||||
"config": {
|
||||
"model": "amazon.titan-embed-text-v1"
|
||||
"model": "amazon.titan-embed-text-v2:0"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,16 +15,15 @@ title: AWS Bedrock
|
||||
import os
|
||||
from mem0 import Memory
|
||||
|
||||
os.environ["OPENAI_API_KEY"] = "your-api-key" # used for embedding model
|
||||
os.environ['AWS_REGION'] = 'us-east-1'
|
||||
os.environ["AWS_ACCESS_KEY"] = "xx"
|
||||
os.environ['AWS_REGION'] = 'us-west-2'
|
||||
os.environ["AWS_ACCESS_KEY_ID"] = "xx"
|
||||
os.environ["AWS_SECRET_ACCESS_KEY"] = "xx"
|
||||
|
||||
config = {
|
||||
"llm": {
|
||||
"provider": "aws_bedrock",
|
||||
"config": {
|
||||
"model": "arn:aws:bedrock:us-east-1:123456789012:model/your-model-name",
|
||||
"model": "anthropic.claude-3-5-haiku-20241022-v1:0",
|
||||
"temperature": 0.2,
|
||||
"max_tokens": 2000,
|
||||
}
|
||||
|
||||
@@ -18,6 +18,8 @@ To use Azure OpenAI models, you have to set the `LLM_AZURE_OPENAI_API_KEY`, `LLM
|
||||
import os
|
||||
from mem0 import Memory
|
||||
|
||||
os.environ["OPENAI_API_KEY"] = "your-api-key" # used for embedding model
|
||||
|
||||
os.environ["LLM_AZURE_OPENAI_API_KEY"] = "your-api-key"
|
||||
os.environ["LLM_AZURE_DEPLOYMENT"] = "your-deployment-name"
|
||||
os.environ["LLM_AZURE_ENDPOINT"] = "your-api-base-url"
|
||||
|
||||
@@ -1,59 +1,75 @@
|
||||
[OpenSearch](https://opensearch.org/) is an open-source, enterprise-grade search and observability suite that brings order to unstructured data at scale. OpenSearch supports k-NN (k-Nearest Neighbors) and allows you to store and retrieve high-dimensional vector embeddings efficiently.
|
||||
[OpenSearch](https://opensearch.org/) is an enterprise-grade search and observability suite that brings order to unstructured data at scale. OpenSearch supports k-NN (k-Nearest Neighbors) and allows you to store and retrieve high-dimensional vector embeddings efficiently.
|
||||
|
||||
### Installation
|
||||
|
||||
OpenSearch support requires additional dependencies. Install them with:
|
||||
|
||||
```bash
|
||||
pip install opensearch>=2.8.0
|
||||
pip install opensearch-py
|
||||
```
|
||||
|
||||
### Prerequisites
|
||||
|
||||
Before using OpenSearch with Mem0, you need to set up a collection in AWS OpenSearch Service.
|
||||
|
||||
#### AWS OpenSearch Service
|
||||
You can create a collection through the AWS Console:
|
||||
- Navigate to [OpenSearch Service Console](https://console.aws.amazon.com/aos/home)
|
||||
- Click "Create collection"
|
||||
- Select "Serverless collection" and then enable "Vector search" capabilities
|
||||
- Once created, note the endpoint URL (host) for your configuration
|
||||
|
||||
|
||||
### Usage
|
||||
|
||||
```python
|
||||
import os
|
||||
from mem0 import Memory
|
||||
import boto3
|
||||
from opensearchpy import OpenSearch, RequestsHttpConnection, AWSV4SignerAuth
|
||||
|
||||
os.environ["OPENAI_API_KEY"] = "sk-xx"
|
||||
# For AWS OpenSearch Service with IAM authentication
|
||||
region = 'us-west-2'
|
||||
service = 'aoss'
|
||||
credentials = boto3.Session().get_credentials()
|
||||
auth = AWSV4SignerAuth(credentials, region, service)
|
||||
|
||||
config = {
|
||||
"vector_store": {
|
||||
"provider": "opensearch",
|
||||
"config": {
|
||||
"collection_name": "mem0",
|
||||
"host": "localhost",
|
||||
"port": 9200,
|
||||
"embedding_model_dims": 1536
|
||||
"host": "your-domain.us-west-2.aoss.amazonaws.com",
|
||||
"port": 443,
|
||||
"http_auth": auth,
|
||||
"embedding_model_dims": 1024,
|
||||
"connection_class": RequestsHttpConnection,
|
||||
"pool_maxsize": 20,
|
||||
"use_ssl": True,
|
||||
"verify_certs": True
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Add Memories
|
||||
|
||||
```python
|
||||
m = Memory.from_config(config)
|
||||
messages = [
|
||||
{"role": "user", "content": "I'm planning to watch a movie tonight. Any recommendations?"},
|
||||
{"role": "assistant", "content": "How about a thriller movies? They can be quite engaging."},
|
||||
{"role": "user", "content": "I’m not a big fan of thriller movies but I love sci-fi movies."},
|
||||
{"role": "user", "content": "I'm not a big fan of thriller movies but I love sci-fi movies."},
|
||||
{"role": "assistant", "content": "Got it! I'll avoid thriller recommendations and suggest sci-fi movies in the future."}
|
||||
]
|
||||
m.add(messages, user_id="alice", metadata={"category": "movies"})
|
||||
```
|
||||
|
||||
### Config
|
||||
### Search Memories
|
||||
|
||||
Let's see the available parameters for the `opensearch` config:
|
||||
|
||||
| Parameter | Description | Default Value |
|
||||
| ---------------------- | -------------------------------------------------- | ------------- |
|
||||
| `collection_name` | The name of the index to store the vectors | `mem0` |
|
||||
| `embedding_model_dims` | Dimensions of the embedding model | `1536` |
|
||||
| `host` | The host where the OpenSearch server is running | `localhost` |
|
||||
| `port` | The port where the OpenSearch server is running | `9200` |
|
||||
| `api_key` | API key for authentication | `None` |
|
||||
| `user` | Username for basic authentication | `None` |
|
||||
| `password` | Password for basic authentication | `None` |
|
||||
| `verify_certs` | Whether to verify SSL certificates | `False` |
|
||||
| `auto_create_index` | Whether to automatically create the index | `True` |
|
||||
| `use_ssl` | Whether to use SSL for connection | `False` |
|
||||
```python
|
||||
results = m.search("What kind of movies does Alice like?", user_id="alice")
|
||||
```
|
||||
|
||||
### Features
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ For detailed guidance on pull requests, refer to [GitHub's documentation](https:
|
||||
|
||||
## 📦 Dependency Management
|
||||
|
||||
We use `poetry` as our package manager. Install it by following the [official instructions](https://python-poetry.org/docs/#installation).
|
||||
We use `hatch` as our package manager. Install it by following the [official instructions](https://hatch.pypa.io/latest/install/).
|
||||
|
||||
⚠️ **Do NOT use `pip` or `conda` for dependency management.** Instead, run:
|
||||
|
||||
@@ -37,7 +37,7 @@ We use `poetry` as our package manager. Install it by following the [official in
|
||||
make install_all
|
||||
|
||||
# Activate virtual environment
|
||||
poetry shell
|
||||
hatch shell
|
||||
```
|
||||
|
||||
---
|
||||
@@ -60,9 +60,9 @@ Run the linter and fix any reported issues before submitting your PR:
|
||||
make lint
|
||||
```
|
||||
|
||||
### 🎨 Code Formatting with `black`
|
||||
### 🎨 Code Formatting
|
||||
|
||||
To maintain a consistent code style, format your code using `black`:
|
||||
To maintain a consistent code style, format your code:
|
||||
|
||||
```bash
|
||||
make format
|
||||
@@ -76,7 +76,7 @@ Run tests to verify functionality before submitting your PR:
|
||||
make test
|
||||
```
|
||||
|
||||
💡 **Note:** Some dependencies have been removed from Poetry to reduce package size. Run `make install_all` to install necessary dependencies before running tests.
|
||||
💡 **Note:** Some dependencies have been removed from the main dependencies to reduce package size. Run `make install_all` to install necessary dependencies before running tests.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -47,6 +47,7 @@
|
||||
"pages": [
|
||||
"features/platform-overview",
|
||||
"features/advanced-retrieval",
|
||||
"features/criteria-retrieval",
|
||||
"features/contextual-add",
|
||||
"features/multimodal-support",
|
||||
"features/timestamp",
|
||||
@@ -184,6 +185,14 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tab": "OpenMemory",
|
||||
"icon": "square-terminal",
|
||||
"pages": [
|
||||
"openmemory/overview",
|
||||
"openmemory/quickstart"
|
||||
]
|
||||
},
|
||||
{
|
||||
"tab": "Examples",
|
||||
"groups": [
|
||||
@@ -192,8 +201,11 @@
|
||||
"icon": "lightbulb",
|
||||
"pages": [
|
||||
"examples",
|
||||
"examples/aws_example",
|
||||
"examples/mem0-demo",
|
||||
"examples/ai_companion_js",
|
||||
"examples/collaborative-task-agent",
|
||||
"examples/eliza_os",
|
||||
"examples/mem0-mastra",
|
||||
"examples/mem0-with-ollama",
|
||||
"examples/personal-ai-tutor",
|
||||
@@ -207,6 +219,7 @@
|
||||
"examples/mem0-agentic-tool",
|
||||
"examples/openai-inbuilt-tools",
|
||||
"examples/mem0-openai-voice-demo",
|
||||
"examples/mem0-google-adk-healthcare-assistant",
|
||||
"examples/email_processing",
|
||||
"examples/youtube-assistant"
|
||||
]
|
||||
|
||||
+5
-1
@@ -75,7 +75,11 @@ Explore how **Mem0** can power real-world applications and bring personalized, i
|
||||
|
||||
<Card title="Mem0 OpenAI Voice Demo" icon="microphone" href="/examples/mem0-openai-voice-demo">
|
||||
Use Mem0's memory capabilities with OpenAI's Inbuilt Tools to create AI agents with persistent memory.
|
||||
</Card>
|
||||
</Card>
|
||||
|
||||
<Card title="Healthcare Assistant Google ADK" icon="microphone" href="/examples/mem0-google-adk-healthcare-assistant">
|
||||
Build a personalized healthcare assistant with persistent memory using Google's ADK and Mem0.
|
||||
</Card>
|
||||
|
||||
<Card title="Email Processing" icon="envelope" href="/examples/email_processing">
|
||||
Use Mem0's memory capabilities to process emails and create AI agents with persistent memory.
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
---
|
||||
title: AWS Bedrock and AOSS
|
||||
---
|
||||
|
||||
<Snippet file="paper-release.mdx" />
|
||||
|
||||
This example demonstrates how to configure and use the `mem0ai` SDK with **AWS Bedrock** and **OpenSearch Service (AOSS)** for persistent memory capabilities in Python.
|
||||
|
||||
## Installation
|
||||
|
||||
Install the required dependencies:
|
||||
|
||||
```bash
|
||||
pip install mem0ai boto3 opensearch-py
|
||||
```
|
||||
|
||||
## Environment Setup
|
||||
|
||||
Set your AWS environment variables:
|
||||
|
||||
```python
|
||||
import os
|
||||
|
||||
# Set these in your environment or notebook
|
||||
os.environ['AWS_REGION'] = 'us-west-2'
|
||||
os.environ['AWS_ACCESS_KEY_ID'] = 'AK00000000000000000'
|
||||
os.environ['AWS_SECRET_ACCESS_KEY'] = 'AS00000000000000000'
|
||||
|
||||
# Confirm they are set
|
||||
print(os.environ['AWS_REGION'])
|
||||
print(os.environ['AWS_ACCESS_KEY_ID'])
|
||||
print(os.environ['AWS_SECRET_ACCESS_KEY'])
|
||||
```
|
||||
|
||||
## Configuration and Usage
|
||||
|
||||
This sets up Mem0 with AWS Bedrock for embeddings and LLM, and OpenSearch as the vector store.
|
||||
|
||||
```python
|
||||
import boto3
|
||||
from opensearchpy import OpenSearch, RequestsHttpConnection, AWSV4SignerAuth
|
||||
from mem0.memory.main import Memory
|
||||
|
||||
region = 'us-west-2'
|
||||
service = 'aoss'
|
||||
credentials = boto3.Session().get_credentials()
|
||||
auth = AWSV4SignerAuth(credentials, region, service)
|
||||
|
||||
config = {
|
||||
"embedder": {
|
||||
"provider": "aws_bedrock",
|
||||
"config": {
|
||||
"model": "amazon.titan-embed-text-v2:0"
|
||||
}
|
||||
},
|
||||
"llm": {
|
||||
"provider": "aws_bedrock",
|
||||
"config": {
|
||||
"model": "anthropic.claude-3-5-haiku-20241022-v1:0",
|
||||
"temperature": 0.1,
|
||||
"max_tokens": 2000
|
||||
}
|
||||
},
|
||||
"vector_store": {
|
||||
"provider": "opensearch",
|
||||
"config": {
|
||||
"collection_name": "mem0",
|
||||
"host": "your-opensearch-domain.us-west-2.es.amazonaws.com",
|
||||
"port": 443,
|
||||
"http_auth": auth,
|
||||
"embedding_model_dims": 1024,
|
||||
"connection_class": RequestsHttpConnection,
|
||||
"pool_maxsize": 20,
|
||||
"use_ssl": True,
|
||||
"verify_certs": True
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Initialize memory system
|
||||
m = Memory.from_config(config)
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
#### Add a memory:
|
||||
|
||||
```python
|
||||
messages = [
|
||||
{"role": "user", "content": "I'm planning to watch a movie tonight. Any recommendations?"},
|
||||
{"role": "assistant", "content": "How about a thriller movies? They can be quite engaging."},
|
||||
{"role": "user", "content": "I'm not a big fan of thriller movies but I love sci-fi movies."},
|
||||
{"role": "assistant", "content": "Got it! I'll avoid thriller recommendations and suggest sci-fi movies in the future."}
|
||||
]
|
||||
|
||||
# Store inferred memories (default behavior)
|
||||
result = m.add(messages, user_id="alice", metadata={"category": "movie_recommendations"})
|
||||
```
|
||||
|
||||
#### Search a memory:
|
||||
```python
|
||||
relevant_memories = m.search(query, user_id="alice")
|
||||
```
|
||||
|
||||
#### Get all memories:
|
||||
```python
|
||||
all_memories = m.get_all(user_id="alice")
|
||||
```
|
||||
|
||||
#### Get a specific memory:
|
||||
```python
|
||||
memory = m.get(memory_id)
|
||||
```
|
||||
|
||||
|
||||
---
|
||||
|
||||
## Conclusion
|
||||
|
||||
With Mem0 and AWS services like Bedrock and OpenSearch, you can build intelligent AI companions that remember, adapt, and personalize their responses over time. This makes them ideal for long-term assistants, tutors, or support bots with persistent memory and natural conversation abilities.
|
||||
@@ -0,0 +1,125 @@
|
||||
---
|
||||
title: Multi-User Collaboration with Mem0
|
||||
---
|
||||
|
||||
<Snippet file="paper-release.mdx" />
|
||||
|
||||
## Overview
|
||||
|
||||
Build a multi-user collaborative chat or task management system with Mem0. Each message is attributed to its author, and all messages are stored in a shared project space. Mem0 makes it easy to track contributions, sort and group messages, and collaborate in real time.
|
||||
|
||||
## Setup
|
||||
|
||||
Install the required packages:
|
||||
|
||||
```bash
|
||||
pip install openai mem0ai
|
||||
```
|
||||
|
||||
## Full Code Example
|
||||
|
||||
```python
|
||||
from openai import OpenAI
|
||||
from mem0 import Memory
|
||||
import os
|
||||
from datetime import datetime
|
||||
from collections import defaultdict
|
||||
|
||||
# Set your OpenAI API key
|
||||
os.environ["OPENAI_API_KEY"] = "sk-your-key"
|
||||
|
||||
# Shared project context
|
||||
RUN_ID = "project-demo"
|
||||
|
||||
# Initialize Mem0
|
||||
mem = Memory()
|
||||
|
||||
class CollaborativeAgent:
|
||||
def __init__(self, run_id):
|
||||
self.run_id = run_id
|
||||
self.mem = mem
|
||||
|
||||
def add_message(self, role, name, content):
|
||||
msg = {"role": role, "name": name, "content": content}
|
||||
self.mem.add([msg], run_id=self.run_id, infer=False)
|
||||
|
||||
def brainstorm(self, prompt):
|
||||
# Get recent messages for context
|
||||
memories = self.mem.search(prompt, run_id=self.run_id, limit=5)["results"]
|
||||
context = "\n".join(f"- {m['memory']} (by {m.get('actor_id', 'Unknown')})" for m in memories)
|
||||
client = OpenAI()
|
||||
messages = [
|
||||
{"role": "system", "content": "You are a helpful project assistant."},
|
||||
{"role": "user", "content": f"Prompt: {prompt}\nContext:\n{context}"}
|
||||
]
|
||||
reply = client.chat.completions.create(
|
||||
model="gpt-4o-mini",
|
||||
messages=messages
|
||||
).choices[0].message.content.strip()
|
||||
self.add_message("assistant", "assistant", reply)
|
||||
return reply
|
||||
|
||||
def get_all_messages(self):
|
||||
return self.mem.get_all(run_id=self.run_id)["results"]
|
||||
|
||||
def print_sorted_by_time(self):
|
||||
messages = self.get_all_messages()
|
||||
messages.sort(key=lambda m: m.get('created_at', ''))
|
||||
print("\n--- Messages (sorted by time) ---")
|
||||
for m in messages:
|
||||
who = m.get("actor_id") or "Unknown"
|
||||
ts = m.get('created_at', 'Timestamp N/A')
|
||||
try:
|
||||
dt = datetime.fromisoformat(ts.replace('Z', '+00:00'))
|
||||
ts_fmt = dt.strftime('%Y-%m-%d %H:%M:%S')
|
||||
except Exception:
|
||||
ts_fmt = ts
|
||||
print(f"[{ts_fmt}] [{who}] {m['memory']}")
|
||||
|
||||
def print_grouped_by_actor(self):
|
||||
messages = self.get_all_messages()
|
||||
grouped = defaultdict(list)
|
||||
for m in messages:
|
||||
grouped[m.get("actor_id") or "Unknown"].append(m)
|
||||
print("\n--- Messages (grouped by actor) ---")
|
||||
for actor, mems in grouped.items():
|
||||
print(f"\n=== {actor} ===")
|
||||
for m in mems:
|
||||
ts = m.get('created_at', 'Timestamp N/A')
|
||||
try:
|
||||
dt = datetime.fromisoformat(ts.replace('Z', '+00:00'))
|
||||
ts_fmt = dt.strftime('%Y-%m-%d %H:%M:%S')
|
||||
except Exception:
|
||||
ts_fmt = ts
|
||||
print(f"[{ts_fmt}] {m['memory']}")
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
```python
|
||||
# Example usage
|
||||
agent = CollaborativeAgent(RUN_ID)
|
||||
agent.add_message("user", "alice", "Let's list tasks for the new landing page.")
|
||||
agent.add_message("user", "bob", "I'll own the hero section copy.")
|
||||
agent.add_message("user", "carol", "I'll choose product screenshots.")
|
||||
|
||||
# Brainstorm with context
|
||||
print("\nAssistant reply:\n", agent.brainstorm("What are the current open tasks?"))
|
||||
|
||||
# Print all messages sorted by time
|
||||
agent.print_sorted_by_time()
|
||||
|
||||
# Print all messages grouped by actor
|
||||
agent.print_grouped_by_actor()
|
||||
```
|
||||
|
||||
## Key Points
|
||||
|
||||
- Each message is attributed to a user or agent (actor)
|
||||
- All messages are stored in a shared project space (`run_id`)
|
||||
- You can sort messages by time, group by actor, and format timestamps for clarity
|
||||
- Mem0 makes it easy to build collaborative, attributed chat/task systems
|
||||
|
||||
## Conclusion
|
||||
|
||||
Mem0 enables fast, transparent collaboration for teams and agents, with full attribution, flexible memory search, and easy message organization.
|
||||
@@ -0,0 +1,75 @@
|
||||
---
|
||||
title: Eliza OS Character
|
||||
---
|
||||
|
||||
<Snippet file="paper-release.mdx" />
|
||||
|
||||
You can create a personalised Eliza OS Character using Mem0. This guide will walk you through the necessary steps and provide the complete code to get you started.
|
||||
|
||||
## Overview
|
||||
|
||||
ElizaOS is a powerful AI agent framework for autonomy & personality. It is a collection of tools that help you create a personalised AI agent.
|
||||
|
||||
## Setup
|
||||
You can start by cloning the eliza-os repository:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/elizaOS/eliza.git
|
||||
```
|
||||
|
||||
Change the directory to the eliza-os repository:
|
||||
|
||||
```bash
|
||||
cd eliza
|
||||
```
|
||||
|
||||
Install the dependencies:
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
```
|
||||
|
||||
Build the project:
|
||||
|
||||
```bash
|
||||
pnpm build
|
||||
```
|
||||
|
||||
## Setup ENVs
|
||||
|
||||
Create a `.env` file in the root of the project and add the following ( You can use the `.env.example` file as a reference):
|
||||
|
||||
```bash
|
||||
# Mem0 Configuration
|
||||
MEM0_API_KEY= # Mem0 API Key ( Get from https://app.mem0.ai/dashboard/api-keys )
|
||||
MEM0_USER_ID= # Default: eliza-os-user
|
||||
MEM0_PROVIDER= # Default: openai
|
||||
MEM0_PROVIDER_API_KEY= # API Key for the provider (openai, anthropic, etc.)
|
||||
SMALL_MEM0_MODEL= # Default: gpt-4o-mini
|
||||
MEDIUM_MEM0_MODEL= # Default: gpt-4o
|
||||
LARGE_MEM0_MODEL= # Default: gpt-4o
|
||||
```
|
||||
|
||||
## Make the default character use Mem0
|
||||
|
||||
By default, there is a character called `eliza` that uses the `ollama` model. You can make this character use Mem0 by changing the config in the `agent/src/defaultCharacter.ts` file.
|
||||
|
||||
```ts
|
||||
modelProvider: ModelProviderName.MEM0,
|
||||
```
|
||||
|
||||
This will make the character use Mem0 to generate responses.
|
||||
|
||||
## Run the project
|
||||
|
||||
```bash
|
||||
pnpm start
|
||||
```
|
||||
|
||||
## Conclusion
|
||||
|
||||
You have now created a personalised Eliza OS Character using Mem0. You can now start interacting with the character by running the project and talking to the character.
|
||||
|
||||
This is a simple example of how to use Mem0 to create a personalised AI agent. You can use this as a starting point to create your own AI agent.
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ You can create a personalized AI Companion using Mem0. This guide will walk you
|
||||
src="https://github.com/user-attachments/assets/cebc4f8e-bdb9-4837-868d-13c5ab7bb433"
|
||||
></video>
|
||||
|
||||
You can try the [Mem0 Demo](https://mem0.dev/demo) live here.
|
||||
You can try the [Mem0 Demo](https://mem0-4vmi.vercel.app) live here.
|
||||
|
||||
## Overview
|
||||
|
||||
|
||||
@@ -0,0 +1,291 @@
|
||||
---
|
||||
title: 'Healthcare Assistant with Mem0 and Google ADK'
|
||||
description: 'Build a personalized healthcare agent that remembers patient information across conversations using Mem0 and Google ADK'
|
||||
---
|
||||
|
||||
<Snippet file="paper-release.mdx" />
|
||||
|
||||
# Healthcare Assistant with Memory
|
||||
|
||||
This example demonstrates how to build a healthcare assistant that remembers patient information across conversations using Google ADK and Mem0.
|
||||
|
||||
## Overview
|
||||
|
||||
The Healthcare Assistant helps patients by:
|
||||
- Remembering their medical history and symptoms
|
||||
- Providing general health information
|
||||
- Scheduling appointment reminders
|
||||
- Maintaining a personalized experience across conversations
|
||||
|
||||
By integrating Mem0's memory layer with Google ADK, the assistant maintains context about the patient without requiring them to repeat information.
|
||||
|
||||
## Setup
|
||||
|
||||
Before you begin, make sure you have:
|
||||
|
||||
Installed Google ADK and Mem0 SDK:
|
||||
```bash
|
||||
pip install google-adk
|
||||
pip install mem0ai
|
||||
```
|
||||
|
||||
## Code Breakdown
|
||||
|
||||
Let's get started and understand the different components required in building a healthcare assistant powered by memory
|
||||
|
||||
```python
|
||||
# Import dependencies
|
||||
import os
|
||||
from google.adk.agents import Agent
|
||||
from google.adk.sessions import InMemorySessionService
|
||||
from google.adk.runners import Runner
|
||||
from google.genai import types
|
||||
from mem0 import MemoryClient
|
||||
|
||||
# Set up API keys (replace with your actual keys)
|
||||
os.environ["GOOGLE_API_KEY"] = "your-google-api-key"
|
||||
os.environ["MEM0_API_KEY"] = "your-mem0-api-key"
|
||||
|
||||
# Define a global user ID for simplicity
|
||||
USER_ID = "Alex"
|
||||
|
||||
# Initialize Mem0 client
|
||||
mem0_client = MemoryClient()
|
||||
```
|
||||
|
||||
## Define Memory Tools
|
||||
|
||||
First, we'll create tools that allow our agent to store and retrieve information using Mem0:
|
||||
|
||||
```python
|
||||
def save_patient_info(information: str) -> dict:
|
||||
"""Saves important patient information to memory."""
|
||||
|
||||
# Store in Mem0
|
||||
response = mem0_client.add(
|
||||
[{"role": "user", "content": information}],
|
||||
user_id=USER_ID,
|
||||
run_id="healthcare_session",
|
||||
metadata={"type": "patient_information"}
|
||||
)
|
||||
|
||||
|
||||
def retrieve_patient_info(query: str) -> dict:
|
||||
"""Retrieves relevant patient information from memory."""
|
||||
|
||||
# Search Mem0
|
||||
results = mem0_client.search(
|
||||
query,
|
||||
user_id=USER_ID,
|
||||
limit=5,
|
||||
threshold=0.7, # Higher threshold for more relevant results
|
||||
output_format="v1.1"
|
||||
)
|
||||
|
||||
# Format and return the results
|
||||
if results and len(results) > 0:
|
||||
memories = [memory["memory"] for memory in results.get('results', [])]
|
||||
return {
|
||||
"status": "success",
|
||||
"memories": memories,
|
||||
"count": len(memories)
|
||||
}
|
||||
else:
|
||||
return {
|
||||
"status": "no_results",
|
||||
"memories": [],
|
||||
"count": 0
|
||||
}
|
||||
```
|
||||
|
||||
## Define Healthcare Tools
|
||||
|
||||
Next, we'll add tools specific to healthcare assistance:
|
||||
|
||||
```python
|
||||
def schedule_appointment(date: str, time: str, reason: str) -> dict:
|
||||
"""Schedules a doctor's appointment."""
|
||||
# In a real app, this would connect to a scheduling system
|
||||
appointment_id = f"APT-{hash(date + time) % 10000}"
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"appointment_id": appointment_id,
|
||||
"confirmation": f"Appointment scheduled for {date} at {time} for {reason}",
|
||||
"message": "Please arrive 15 minutes early to complete paperwork."
|
||||
}
|
||||
```
|
||||
|
||||
## Create the Healthcare Assistant Agent
|
||||
|
||||
Now we'll create our main agent with all the tools:
|
||||
|
||||
```python
|
||||
# Create the agent
|
||||
healthcare_agent = Agent(
|
||||
name="healthcare_assistant",
|
||||
model="gemini-1.5-flash", # Using Gemini for healthcare assistant
|
||||
description="Healthcare assistant that helps patients with health information and appointment scheduling.",
|
||||
instruction="""You are a helpful Healthcare Assistant with memory capabilities.
|
||||
|
||||
Your primary responsibilities are to:
|
||||
1. Remember patient information using the 'save_patient_info' tool when they share symptoms, conditions, or preferences.
|
||||
2. Retrieve past patient information using the 'retrieve_patient_info' tool when relevant to the current conversation.
|
||||
3. Help schedule appointments using the 'schedule_appointment' tool.
|
||||
|
||||
IMPORTANT GUIDELINES:
|
||||
- Always be empathetic, professional, and helpful.
|
||||
- Save important patient information like symptoms, conditions, allergies, and preferences.
|
||||
- Check if you have relevant patient information before asking for details they may have shared previously.
|
||||
- Make it clear you are not a doctor and cannot provide medical diagnosis or treatment.
|
||||
- For serious symptoms, always recommend consulting a healthcare professional.
|
||||
- Keep all patient information confidential.
|
||||
""",
|
||||
tools=[save_patient_info, retrieve_patient_info, schedule_appointment]
|
||||
)
|
||||
```
|
||||
|
||||
## Set Up Session and Runner
|
||||
|
||||
```python
|
||||
# Set up Session Service and Runner
|
||||
session_service = InMemorySessionService()
|
||||
|
||||
# Define constants for the conversation
|
||||
APP_NAME = "healthcare_assistant_app"
|
||||
USER_ID = "Alex"
|
||||
SESSION_ID = "session_001"
|
||||
|
||||
# Create a session
|
||||
session = session_service.create_session(
|
||||
app_name=APP_NAME,
|
||||
user_id=USER_ID,
|
||||
session_id=SESSION_ID
|
||||
)
|
||||
|
||||
# Create the runner
|
||||
runner = Runner(
|
||||
agent=healthcare_agent,
|
||||
app_name=APP_NAME,
|
||||
session_service=session_service
|
||||
)
|
||||
```
|
||||
|
||||
## Interact with the Healthcare Assistant
|
||||
|
||||
```python
|
||||
# Function to interact with the agent
|
||||
async def call_agent_async(query, runner, user_id, session_id):
|
||||
"""Sends a query to the agent and returns the final response."""
|
||||
print(f"\n>>> Patient: {query}")
|
||||
|
||||
# Format the user's message
|
||||
content = types.Content(
|
||||
role='user',
|
||||
parts=[types.Part(text=query)]
|
||||
)
|
||||
|
||||
# Set user_id for tools to access
|
||||
save_patient_info.user_id = user_id
|
||||
retrieve_patient_info.user_id = user_id
|
||||
|
||||
# Run the agent
|
||||
async for event in runner.run_async(
|
||||
user_id=user_id,
|
||||
session_id=session_id,
|
||||
new_message=content
|
||||
):
|
||||
if event.is_final_response():
|
||||
if event.content and event.content.parts:
|
||||
response = event.content.parts[0].text
|
||||
print(f"<<< Assistant: {response}")
|
||||
return response
|
||||
|
||||
return "No response received."
|
||||
|
||||
# Example conversation flow
|
||||
async def run_conversation():
|
||||
# First interaction - patient introduces themselves with key information
|
||||
await call_agent_async(
|
||||
"Hi, I'm Alex. I've been having headaches for the past week, and I have a penicillin allergy.",
|
||||
runner=runner,
|
||||
user_id=USER_ID,
|
||||
session_id=SESSION_ID
|
||||
)
|
||||
|
||||
# Request for health information
|
||||
await call_agent_async(
|
||||
"Can you tell me more about what might be causing my headaches?",
|
||||
runner=runner,
|
||||
user_id=USER_ID,
|
||||
session_id=SESSION_ID
|
||||
)
|
||||
|
||||
# Schedule an appointment
|
||||
await call_agent_async(
|
||||
"I think I should see a doctor. Can you help me schedule an appointment for next Monday at 2pm?",
|
||||
runner=runner,
|
||||
user_id=USER_ID,
|
||||
session_id=SESSION_ID
|
||||
)
|
||||
|
||||
# Test memory - should remember patient name, symptoms, and allergy
|
||||
await call_agent_async(
|
||||
"What medications should I avoid for my headaches?",
|
||||
runner=runner,
|
||||
user_id=USER_ID,
|
||||
session_id=SESSION_ID
|
||||
)
|
||||
|
||||
# Run the conversation example
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(run_conversation())
|
||||
```
|
||||
|
||||
## How It Works
|
||||
|
||||
This healthcare assistant demonstrates several key capabilities:
|
||||
|
||||
1. **Memory Storage**: When Alex mentions her headaches and penicillin allergy, the agent stores this information in Mem0 using the `save_patient_info` tool.
|
||||
|
||||
2. **Contextual Retrieval**: When Alex asks about headache causes, the agent uses the `retrieve_patient_info` tool to recall her specific situation.
|
||||
|
||||
3. **Memory Application**: When discussing medications, the agent remembers Alex's penicillin allergy without her needing to repeat it, providing safer and more personalized advice.
|
||||
|
||||
4. **Conversation Continuity**: The agent maintains context across the entire conversation session, creating a more natural and efficient interaction.
|
||||
|
||||
## Key Implementation Details
|
||||
|
||||
### User ID Management
|
||||
|
||||
Instead of passing the user ID as a parameter to the memory tools (which would require modifying the ADK's tool calling system), we attach it directly to the function object:
|
||||
|
||||
```python
|
||||
# Set user_id for tools to access
|
||||
save_patient_info.user_id = user_id
|
||||
retrieve_patient_info.user_id = user_id
|
||||
```
|
||||
|
||||
Inside the tool functions, we retrieve this attribute:
|
||||
|
||||
```python
|
||||
# Get user_id from session state or use default
|
||||
user_id = getattr(save_patient_info, 'user_id', 'default_user')
|
||||
```
|
||||
|
||||
This approach allows our tools to maintain user context without complicating their parameter signatures.
|
||||
|
||||
### Mem0 Integration
|
||||
|
||||
The integration with Mem0 happens through two primary functions:
|
||||
|
||||
1. `mem0_client.add()` - Stores new information with appropriate metadata
|
||||
2. `mem0_client.search()` - Retrieves relevant memories using semantic search
|
||||
|
||||
The `threshold` parameter in the search function ensures that only highly relevant memories are returned.
|
||||
|
||||
## Conclusion
|
||||
|
||||
This example demonstrates how to build a healthcare assistant with persistent memory using Google ADK and Mem0. The integration allows for a more personalized patient experience by maintaining context across conversation turns, which is particularly valuable in healthcare scenarios where continuity of information is crucial.
|
||||
|
||||
By storing and retrieving patient information intelligently, the assistant provides more relevant responses without requiring the patient to repeat their medical history, symptoms, or preferences.
|
||||
@@ -0,0 +1,184 @@
|
||||
---
|
||||
title: Criteria Retrieval
|
||||
icon: "magnifying-glass-plus"
|
||||
iconType: "solid"
|
||||
---
|
||||
|
||||
<Snippet file="paper-release.mdx" />
|
||||
|
||||
Mem0's **Criteria Retrieval** feature allows you to retrieve memories based on specific criteria. This is useful when you need to find memories that match certain conditions or criteria, such as emotional content, sentiment, or other custom attributes.
|
||||
|
||||
## Setting Up Custom Criteria
|
||||
|
||||
You can define custom criteria at the project level, assigning weights to each criterion. These weights will be normalized during memory retrieval.
|
||||
|
||||
```python
|
||||
from mem0 import MemoryClient
|
||||
|
||||
client = MemoryClient(
|
||||
api_key="mem0_api_key",
|
||||
org_id="mem0_organization_id",
|
||||
project_id="mem0_project_id"
|
||||
)
|
||||
|
||||
# Define custom criteria with weights
|
||||
retrieval_criteria = [
|
||||
{
|
||||
"name": "joy",
|
||||
"description": "Measure the intensity of positive emotions such as happiness, excitement, or amusement expressed in the sentence. A higher score reflects greater joy.",
|
||||
"weight": 3
|
||||
},
|
||||
{
|
||||
"name": "curiosity",
|
||||
"description": "Assess the extent to which the sentence reflects inquisitiveness, interest in exploring new information, or asking questions. A higher score reflects stronger curiosity.",
|
||||
"weight": 2
|
||||
},
|
||||
{
|
||||
"name": "emotion",
|
||||
"description": "Evaluate the presence and depth of sadness or negative emotional tone, including expressions of disappointment, frustration, or sorrow. A higher score reflects greater sadness.",
|
||||
"weight": 1
|
||||
}
|
||||
]
|
||||
|
||||
# Update project with custom criteria
|
||||
client.update_project(
|
||||
retrieval_criteria=retrieval_criteria
|
||||
)
|
||||
```
|
||||
|
||||
## Using Criteria Retrieval
|
||||
|
||||
After setting up your criteria, you can use them to filter and retrieve memories. Here's an example:
|
||||
|
||||
```python
|
||||
# Add some example memories
|
||||
messages = [
|
||||
{"role": "user", "content": "What a beautiful sunny day! I feel so refreshed and ready to take on anything!"},
|
||||
{"role": "user", "content": "I've always wondered how storms form—what triggers them in the atmosphere?"},
|
||||
{"role": "user", "content": "It's been raining for days, and it just makes everything feel heavier."},
|
||||
{"role": "user", "content": "Finally I get time to draw something today, after a long time!! I am super happy today."}
|
||||
]
|
||||
|
||||
client.add(messages, user_id="alice")
|
||||
|
||||
# Search with criteria-based filtering
|
||||
filters = {
|
||||
"AND": [
|
||||
{"user_id": "alice"}
|
||||
]
|
||||
}
|
||||
results_with_criteria = client.search(
|
||||
query="Why I am feeling happy today?",
|
||||
filters=filters,
|
||||
version="v2"
|
||||
)
|
||||
|
||||
# Standard search without criteria filtering
|
||||
results_without_criteria = client.search(
|
||||
query="Why I am feeling happy today?",
|
||||
user_id="alice"
|
||||
)
|
||||
```
|
||||
|
||||
## Search Results Comparison
|
||||
|
||||
Let's compare the results from criteria-based retrieval versus standard retrieval to see how the emotional criteria affects ranking:
|
||||
|
||||
### Search Results (with Criteria)
|
||||
```python
|
||||
[
|
||||
{
|
||||
"memory": "User feels refreshed and ready to take on anything on a beautiful sunny day",
|
||||
"score": 0.666,
|
||||
...
|
||||
},
|
||||
{
|
||||
"memory": "User finally has time to draw something after a long time",
|
||||
"score": 0.616,
|
||||
...
|
||||
},
|
||||
{
|
||||
"memory": "User is happy today",
|
||||
"score": 0.500,
|
||||
...
|
||||
},
|
||||
{
|
||||
"memory": "User is curious about how storms form and what triggers them in the atmosphere.",
|
||||
"score": 0.400,
|
||||
...
|
||||
},
|
||||
{
|
||||
"memory": "It has been raining for days, making everything feel heavier.",
|
||||
"score": 0.116,
|
||||
...
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
### Search Results (without Criteria)
|
||||
```python
|
||||
[
|
||||
{
|
||||
"memory": "User is happy today",
|
||||
"score": 0.607,
|
||||
...
|
||||
},
|
||||
{
|
||||
"memory": "User feels refreshed and ready to take on anything on a beautiful sunny day",
|
||||
"score": 0.512,
|
||||
...
|
||||
},
|
||||
{
|
||||
"memory": "It has been raining for days, making everything feel heavier.",
|
||||
"score": 0.4617,
|
||||
...
|
||||
},
|
||||
{
|
||||
"memory": "User is curious about how storms form and what triggers them in the atmosphere.",
|
||||
"score": 0.340,
|
||||
...
|
||||
},
|
||||
{
|
||||
"memory": "User finally has time to draw something after a long time",
|
||||
"score": 0.336,
|
||||
...
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
Looking at the example results above, we can see how criteria-based filtering affects the output:
|
||||
|
||||
1. **Memory Ordering**: With criteria, memories with high joy scores (like feeling refreshed and drawing) are ranked higher, while without criteria, the most relevant memory ("User is happy today") comes first.
|
||||
|
||||
2. **Score Distribution**: With criteria, scores are more spread out (0.116 to 0.666) and reflect the criteria weights, while without criteria, scores are more clustered (0.336 to 0.607) and based purely on relevance.
|
||||
|
||||
3. **Negative Content**: With criteria, the negative memory about rain has a much lower score (0.116) due to the emotion criteria, while without criteria it maintains a relatively high score (0.4617) due to its relevance.
|
||||
|
||||
4. **Curiosity Content**: The storm-related memory gets a moderate score (0.400) with criteria due to the curiosity weighting, while without criteria it's ranked lower (0.340) as it's less relevant to the happiness query.
|
||||
|
||||
## Key Differences
|
||||
|
||||
1. **Scoring**: With criteria, normalized scores (0-1) are used based on custom criteria weights, while without criteria, standard relevance scoring is used
|
||||
|
||||
2. **Ordering**: With criteria, memories are first retrieved by relevance, then criteria-based filtering and prioritization is applied, while without criteria, ordering is solely by relevance
|
||||
|
||||
3. **Filtering**: With criteria, post-retrieval filtering based on custom criteria (joy, curiosity, etc.) is available, which isn't available without criteria
|
||||
|
||||
<Note>
|
||||
When no custom criteria are specified, the search will default to standard relevance-based retrieval. In this case, results are returned based solely on their relevance to the query, without any additional filtering or prioritization that would normally be applied through criteria.
|
||||
</Note>
|
||||
|
||||
## How It Works
|
||||
|
||||
1. **Criteria Definition**: Define custom criteria with names, descriptions, and weights
|
||||
2. **Project Configuration**: Apply these criteria at the project level
|
||||
3. **Memory Retrieval**: Use v2 search with filters to retrieve memories based on your criteria
|
||||
4. **Weighted Scoring**: Memories are scored based on the defined criteria weights
|
||||
|
||||
<Note>
|
||||
Criteria retrieval is currently supported only in search v2. Make sure to use `version="v2"` when performing searches with custom criteria.
|
||||
</Note>
|
||||
|
||||
If you have any questions, please feel free to reach out to us using one of the following methods:
|
||||
|
||||
<Snippet file="get-help.mdx" />
|
||||
@@ -0,0 +1,108 @@
|
||||
---
|
||||
title: Overview
|
||||
icon: "info"
|
||||
iconType: "solid"
|
||||
---
|
||||
|
||||
<Snippet file="paper-release.mdx" />
|
||||
|
||||
OpenMemory is a local memory infrastructure powered by Mem0 that lets you carry your memory accross any AI app. It provides a unified memory layer that stays with you, enabling agents and assistants to remember what matters across applications.
|
||||
|
||||
<img src="https://github.com/user-attachments/assets/3c701757-ad82-4afa-bfbe-e049c2b4320b" alt="OpenMemory UI" />
|
||||
|
||||
## What is the OpenMemory MCP Server
|
||||
|
||||
The OpenMemory MCP Server is a private, local-first memory server that creates a shared, persistent memory layer for your MCP-compatible tools. This runs entirely on your machine, enabling seamless context handoff across tools. Whether you're switching between development, planning, or debugging environments, your AI assistants can access relevant memory without needing repeated instructions.
|
||||
|
||||
The OpenMemory MCP Server ensures all memory stays local, structured, and under your control with no cloud sync or external storage.
|
||||
|
||||
## OpenMemory Easy Setup
|
||||
|
||||
### Prerequisites
|
||||
- Docker
|
||||
- OpenAI API Key
|
||||
|
||||
You can quickly run OpenMemory by running the following command:
|
||||
|
||||
```bash
|
||||
curl -sL https://raw.githubusercontent.com/mem0ai/mem0/main/openmemory/run.sh | bash
|
||||
```
|
||||
|
||||
You should set the `OPENAI_API_KEY` as a global environment variable:
|
||||
|
||||
```bash
|
||||
export OPENAI_API_KEY=your_api_key
|
||||
```
|
||||
|
||||
You can also set the `OPENAI_API_KEY` as a parameter to the script:
|
||||
|
||||
```bash
|
||||
curl -sL https://raw.githubusercontent.com/mem0ai/mem0/main/openmemory/run.sh | OPENAI_API_KEY=your_api_key bash
|
||||
```
|
||||
|
||||
This will start the OpenMemory server and the OpenMemory UI. Deleting the container will lead to the deletion of the memory store.
|
||||
We suggest you follow the instructions [here](/openmemory/quickstart#setting-up-openmemory) to set up OpenMemory on your local machine, with more persistant memory store.
|
||||
|
||||
## How the OpenMemory MCP Server Works
|
||||
|
||||
Built around the Model Context Protocol (MCP), the OpenMemory MCP Server exposes a standardized set of memory tools:
|
||||
- `add_memories`: Store new memory objects
|
||||
- `search_memory`: Retrieve relevant memories
|
||||
- `list_memories`: View all stored memory
|
||||
- `delete_all_memories`: Clear memory entirely
|
||||
|
||||
Any MCP-compatible tool can connect to the server and use these APIs to persist and access memory.
|
||||
|
||||
## What It Enables
|
||||
|
||||
### Cross-Client Memory Access
|
||||
Store context in Cursor and retrieve it later in Claude or Windsurf without repeating yourself.
|
||||
|
||||
### Fully Local Memory Store
|
||||
All memory is stored on your machine. Nothing goes to the cloud. You maintain full ownership and control.
|
||||
|
||||
### Unified Memory UI
|
||||
The built-in OpenMemory dashboard provides a central view of everything stored. Add, browse, delete and control memory access to clients directly from the dashboard.
|
||||
|
||||
## Supported Clients
|
||||
|
||||
The OpenMemory MCP Server is compatible with any client that supports the Model Context Protocol. This includes:
|
||||
- Cursor
|
||||
- Claude Desktop
|
||||
- Windsurf
|
||||
- Cline, and more.
|
||||
|
||||
As more AI systems adopt MCP, your private memory becomes more valuable.
|
||||
|
||||
## Real-World Examples
|
||||
|
||||
### Scenario 1: Cross-Tool Project Flow
|
||||
Define technical requirements of a project in Claude Desktop. Build in Cursor. Debug issues in Windsurf - all with shared context passed through OpenMemory.
|
||||
|
||||
### Scenario 2: Preferences That Persist
|
||||
Set your preferred code style or tone in one tool. When you switch to another MCP client, it can access those same preferences without redefining them.
|
||||
|
||||
### Scenario 3: Project Knowledge
|
||||
Save important project details once, then access them from any compatible AI tool, no more repetitive explanations.
|
||||
|
||||
## Conclusion
|
||||
|
||||
The OpenMemory MCP Server brings memory to MCP-compatible tools without giving up control or privacy. It solves a foundational limitation in modern LLM workflows: the loss of context across tools, sessions, and environments.
|
||||
|
||||
By standardizing memory operations and keeping all data local, it reduces token overhead, improves performance, and unlocks more intelligent interactions across the growing ecosystem of AI assistants.
|
||||
|
||||
This is just the beginning. The MCP server is the first core layer in the OpenMemory platform - a broader effort to make memory portable, private, and interoperable across AI systems.
|
||||
|
||||
## Getting Started Today
|
||||
|
||||
- Github Repository: https://github.com/mem0ai/mem0
|
||||
- Read the documentation: [Docs Link]
|
||||
- Join our community: [Discord link]
|
||||
|
||||
With OpenMemory, your AI memories stay private, portable, and under your control, exactly where they belong.
|
||||
|
||||
OpenMemory: Your memories, your control.
|
||||
|
||||
## Contributing
|
||||
|
||||
OpenMemory is open source and we welcome contributions. Please see the [CONTRIBUTING.md](https://github.com/mem0ai/mem0/blob/main/openmemory/CONTRIBUTING.md) file for more information.
|
||||
@@ -0,0 +1,69 @@
|
||||
---
|
||||
title: Quickstart
|
||||
icon: "terminal"
|
||||
iconType: "solid"
|
||||
---
|
||||
|
||||
<Snippet file="paper-release.mdx" />
|
||||
|
||||
## OpenMemory Easy Setup
|
||||
|
||||
### Prerequisites
|
||||
- Docker
|
||||
- OpenAI API Key
|
||||
|
||||
You can quickly run OpenMemory by running the following command:
|
||||
|
||||
```bash
|
||||
curl -sL https://raw.githubusercontent.com/mem0ai/mem0/main/openmemory/run.sh | bash
|
||||
```
|
||||
|
||||
You should set the `OPENAI_API_KEY` as a global environment variable:
|
||||
|
||||
```bash
|
||||
export OPENAI_API_KEY=your_api_key
|
||||
```
|
||||
|
||||
You can also set the `OPENAI_API_KEY` as a parameter to the script:
|
||||
|
||||
```bash
|
||||
curl -sL https://raw.githubusercontent.com/mem0ai/mem0/main/openmemory/run.sh | OPENAI_API_KEY=your_api_key bash
|
||||
```
|
||||
|
||||
This will start the OpenMemory server and the OpenMemory UI. Deleting the container will lead to the deletion of the memory store.
|
||||
We suggest you follow the instructions below to set up OpenMemory on your local machine, with more persistant memory store.
|
||||
|
||||
## Setting Up OpenMemory
|
||||
|
||||
Getting started with OpenMemory is straight forward and takes just a few minutes to set up on your local machine. Follow these steps:
|
||||
|
||||
### Getting started
|
||||
First clone the repository and then follow the instructions:
|
||||
```bash
|
||||
# Clone the repository
|
||||
git clone https://github.com/mem0ai/mem0.git
|
||||
cd mem0/openmemory
|
||||
|
||||
# Create the backend .env file with your OpenAI key
|
||||
make env
|
||||
|
||||
# Build the Docker images
|
||||
make build
|
||||
|
||||
# Start all services (API server, vector database, and MCP server components)
|
||||
make up
|
||||
```
|
||||
|
||||
You can configure the MCP client using the following command (replace username with your username):
|
||||
|
||||
```bash
|
||||
npx install-mcp i "http://localhost:8765/mcp/cursor/sse/username" --client cursor
|
||||
```
|
||||
|
||||
The OpenMemory dashboard will be available at http://localhost:3000. From here, you can view and manage your memories, as well as check connection status with your MCP clients.
|
||||
|
||||
Once set up, OpenMemory runs locally on your machine, ensuring all your AI memories remain private and secure while being accessible across any compatible MCP client.
|
||||
|
||||
### Getting Started Today
|
||||
|
||||
- Github Repository: https://github.com/mem0ai/mem0/openmemory
|
||||
@@ -70,7 +70,7 @@ const retrieveMemories = (memories: any) => {
|
||||
export async function POST(req: Request) {
|
||||
const { messages, system, tools, userId } = await req.json();
|
||||
|
||||
const memories = await getMemories(messages, { user_id: userId, rerank: true, threshold: 0.1 });
|
||||
const memories = await getMemories(messages, { user_id: userId, rerank: true, threshold: 0.1, output_format: "v1.0" });
|
||||
const mem0Instructions = retrieveMemories(memories);
|
||||
|
||||
const result = streamText({
|
||||
|
||||
@@ -45,7 +45,7 @@ const useUserId = () => {
|
||||
export const Assistant = () => {
|
||||
const { userId, resetUserId } = useUserId();
|
||||
const runtime = useChatRuntime({
|
||||
api: "https://demo.mem0.ai/api/chat",
|
||||
api: "/api/chat",
|
||||
body: { userId },
|
||||
});
|
||||
|
||||
@@ -91,8 +91,8 @@ export const Assistant = () => {
|
||||
</button>
|
||||
<GithubButton url="https://github.com/mem0ai/mem0/tree/main/examples" />
|
||||
|
||||
<Link href={"https://app.mem0.ai/"} target="_blank" className="py-2 ml-2 px-4 font-semibold dark:bg-zinc-100 dark:hover:bg-zinc-200 bg-zinc-800 text-white rounded-full hover:bg-zinc-900 dark:text-[#475569]">
|
||||
Save Memories
|
||||
<Link href={"https://app.mem0.ai/"} target="_blank" className="py-1 ml-2 px-4 font-semibold dark:bg-zinc-100 dark:hover:bg-zinc-200 bg-zinc-800 text-white rounded-full hover:bg-zinc-900 dark:text-[#475569]">
|
||||
Playground
|
||||
</Link>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -2,24 +2,6 @@ import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
/* config options here */
|
||||
assetPrefix: "https://demo.mem0.ai",
|
||||
images: {
|
||||
path: "https://demo.mem0.ai",
|
||||
},
|
||||
async headers() {
|
||||
return [
|
||||
{
|
||||
// matching all API routes
|
||||
source: "/api/:path*",
|
||||
headers: [
|
||||
{ key: "Access-Control-Allow-Credentials", value: "true" },
|
||||
{ key: "Access-Control-Allow-Origin", value: "*" },
|
||||
{ key: "Access-Control-Allow-Methods", value: "GET,DELETE,PATCH,POST,PUT" },
|
||||
{ key: "Access-Control-Allow-Headers", value: "X-CSRF-Token, X-Requested-With, Accept, Accept-Version, Content-Length, Content-MD5, Content-Type, Date, X-Api-Version" },
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
"@assistant-ui/react": "^0.8.2",
|
||||
"@assistant-ui/react-ai-sdk": "^0.8.0",
|
||||
"@assistant-ui/react-markdown": "^0.8.0",
|
||||
"@mem0/vercel-ai-provider": "^1.0.0",
|
||||
"@mem0/vercel-ai-provider": "^1.0.4",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.6",
|
||||
"@radix-ui/react-avatar": "^1.1.3",
|
||||
"@radix-ui/react-popover": "^1.1.6",
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
import asyncio
|
||||
import warnings
|
||||
from google.adk.agents import Agent
|
||||
from google.adk.sessions import InMemorySessionService
|
||||
from google.adk.runners import Runner
|
||||
from google.genai import types
|
||||
from mem0 import MemoryClient
|
||||
|
||||
warnings.filterwarnings("ignore", category=DeprecationWarning)
|
||||
|
||||
|
||||
# Initialize Mem0 client
|
||||
mem0_client = MemoryClient()
|
||||
|
||||
|
||||
# Define Memory Tools
|
||||
def save_patient_info(information: str) -> dict:
|
||||
"""Saves important patient information to memory."""
|
||||
print(f"Storing patient information: {information[:30]}...")
|
||||
|
||||
# Get user_id from session state or use default
|
||||
user_id = getattr(save_patient_info, 'user_id', 'default_user')
|
||||
|
||||
# Store in Mem0
|
||||
response = mem0_client.add(
|
||||
[{"role": "user", "content": information}],
|
||||
user_id=user_id,
|
||||
run_id="healthcare_session",
|
||||
metadata={"type": "patient_information"}
|
||||
)
|
||||
|
||||
return {"status": "success", "message": "Information saved"}
|
||||
|
||||
|
||||
def retrieve_patient_info(query: str) -> str:
|
||||
"""Retrieves relevant patient information from memory."""
|
||||
print(f"Searching for patient information: {query}")
|
||||
|
||||
# Get user_id from session state or use default
|
||||
user_id = getattr(retrieve_patient_info, 'user_id', 'default_user')
|
||||
|
||||
# Search Mem0
|
||||
results = mem0_client.search(
|
||||
query,
|
||||
user_id=user_id,
|
||||
run_id="healthcare_session",
|
||||
limit=5,
|
||||
threshold=0.7 # Higher threshold for more relevant results
|
||||
)
|
||||
|
||||
if not results:
|
||||
return "I don't have any relevant memories about this topic."
|
||||
|
||||
memories = [f"• {result['memory']}" for result in results]
|
||||
return "Here's what I remember that might be relevant:\n" + "\n".join(memories)
|
||||
|
||||
|
||||
# Define Healthcare Tools
|
||||
def schedule_appointment(date: str, time: str, reason: str) -> dict:
|
||||
"""Schedules a doctor's appointment."""
|
||||
# In a real app, this would connect to a scheduling system
|
||||
appointment_id = f"APT-{hash(date + time) % 10000}"
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"appointment_id": appointment_id,
|
||||
"confirmation": f"Appointment scheduled for {date} at {time} for {reason}",
|
||||
"message": "Please arrive 15 minutes early to complete paperwork."
|
||||
}
|
||||
|
||||
|
||||
# Create the Healthcare Assistant Agent
|
||||
healthcare_agent = Agent(
|
||||
name="healthcare_assistant",
|
||||
model="gemini-1.5-flash", # Using Gemini for healthcare assistant
|
||||
description="Healthcare assistant that helps patients with health information and appointment scheduling.",
|
||||
instruction="""You are a helpful Healthcare Assistant with memory capabilities.
|
||||
|
||||
Your primary responsibilities are to:
|
||||
1. Remember patient information using the 'save_patient_info' tool when they share symptoms, conditions, or preferences.
|
||||
2. Retrieve past patient information using the 'retrieve_patient_info' tool when relevant to the current conversation.
|
||||
3. Help schedule appointments using the 'schedule_appointment' tool.
|
||||
|
||||
IMPORTANT GUIDELINES:
|
||||
- Always be empathetic, professional, and helpful.
|
||||
- Save important patient information like symptoms, conditions, allergies, and preferences.
|
||||
- Check if you have relevant patient information before asking for details they may have shared previously.
|
||||
- Make it clear you are not a doctor and cannot provide medical diagnosis or treatment.
|
||||
- For serious symptoms, always recommend consulting a healthcare professional.
|
||||
- Keep all patient information confidential.
|
||||
""",
|
||||
tools=[save_patient_info, retrieve_patient_info, schedule_appointment]
|
||||
)
|
||||
|
||||
# Set Up Session and Runner
|
||||
session_service = InMemorySessionService()
|
||||
|
||||
# Define constants for the conversation
|
||||
APP_NAME = "healthcare_assistant_app"
|
||||
USER_ID = "Alex"
|
||||
SESSION_ID = "session_001"
|
||||
|
||||
# Create a session
|
||||
session = session_service.create_session(
|
||||
app_name=APP_NAME,
|
||||
user_id=USER_ID,
|
||||
session_id=SESSION_ID
|
||||
)
|
||||
|
||||
# Create the runner
|
||||
runner = Runner(
|
||||
agent=healthcare_agent,
|
||||
app_name=APP_NAME,
|
||||
session_service=session_service
|
||||
)
|
||||
|
||||
|
||||
# Interact with the Healthcare Assistant
|
||||
async def call_agent_async(query, runner, user_id, session_id):
|
||||
"""Sends a query to the agent and returns the final response."""
|
||||
print(f"\n>>> Patient: {query}")
|
||||
|
||||
# Format the user's message
|
||||
content = types.Content(
|
||||
role='user',
|
||||
parts=[types.Part(text=query)]
|
||||
)
|
||||
|
||||
# Set user_id for tools to access
|
||||
save_patient_info.user_id = user_id
|
||||
retrieve_patient_info.user_id = user_id
|
||||
|
||||
# Run the agent
|
||||
async for event in runner.run_async(
|
||||
user_id=user_id,
|
||||
session_id=session_id,
|
||||
new_message=content
|
||||
):
|
||||
if event.is_final_response():
|
||||
if event.content and event.content.parts:
|
||||
response = event.content.parts[0].text
|
||||
print(f"<<< Assistant: {response}")
|
||||
return response
|
||||
|
||||
return "No response received."
|
||||
|
||||
|
||||
# Example conversation flow
|
||||
async def run_conversation():
|
||||
# First interaction - patient introduces themselves with key information
|
||||
await call_agent_async(
|
||||
"Hi, I'm Alex. I've been having headaches for the past week, and I have a penicillin allergy.",
|
||||
runner=runner,
|
||||
user_id=USER_ID,
|
||||
session_id=SESSION_ID
|
||||
)
|
||||
|
||||
# Request for health information
|
||||
await call_agent_async(
|
||||
"Can you tell me more about what might be causing my headaches?",
|
||||
runner=runner,
|
||||
user_id=USER_ID,
|
||||
session_id=SESSION_ID
|
||||
)
|
||||
|
||||
# Schedule an appointment
|
||||
await call_agent_async(
|
||||
"I think I should see a doctor. Can you help me schedule an appointment for next Monday at 2pm?",
|
||||
runner=runner,
|
||||
user_id=USER_ID,
|
||||
session_id=SESSION_ID
|
||||
)
|
||||
|
||||
# Test memory - should remember patient name, symptoms, and allergy
|
||||
await call_agent_async(
|
||||
"What medications should I avoid for my headaches?",
|
||||
runner=runner,
|
||||
user_id=USER_ID,
|
||||
session_id=SESSION_ID
|
||||
)
|
||||
|
||||
|
||||
# Interactive mode
|
||||
async def interactive_mode():
|
||||
"""Run an interactive chat session with the healthcare assistant."""
|
||||
print("=== Healthcare Assistant Interactive Mode ===")
|
||||
print("Enter 'exit' to quit at any time.")
|
||||
|
||||
# Get user information
|
||||
patient_id = input("Enter patient ID (or press Enter for default): ").strip() or USER_ID
|
||||
session_id = f"session_{hash(patient_id) % 1000:03d}"
|
||||
|
||||
# Create session for this user
|
||||
session = session_service.create_session(
|
||||
app_name=APP_NAME,
|
||||
user_id=patient_id,
|
||||
session_id=session_id
|
||||
)
|
||||
|
||||
print(f"\nStarting conversation with patient ID: {patient_id}")
|
||||
print("Type your message and press Enter.")
|
||||
|
||||
while True:
|
||||
user_input = input("\n>>> Patient: ").strip()
|
||||
if user_input.lower() in ['exit', 'quit', 'bye']:
|
||||
print("Ending conversation. Thank you!")
|
||||
break
|
||||
|
||||
await call_agent_async(
|
||||
user_input,
|
||||
runner=runner,
|
||||
user_id=patient_id,
|
||||
session_id=session_id
|
||||
)
|
||||
|
||||
|
||||
# Main execution
|
||||
if __name__ == "__main__":
|
||||
import argparse
|
||||
|
||||
parser = argparse.ArgumentParser(description='Healthcare Assistant with Memory')
|
||||
parser.add_argument('--demo', action='store_true', help='Run the demo conversation')
|
||||
parser.add_argument('--interactive', action='store_true', help='Run in interactive mode')
|
||||
parser.add_argument('--patient-id', type=str, default=USER_ID, help='Patient ID for the conversation')
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.demo:
|
||||
asyncio.run(run_conversation())
|
||||
elif args.interactive:
|
||||
asyncio.run(interactive_mode())
|
||||
else:
|
||||
# Default to demo mode if no arguments provided
|
||||
asyncio.run(run_conversation())
|
||||
|
||||
|
||||
@@ -33,6 +33,10 @@ class BaseEmbedderConfig(ABC):
|
||||
memory_search_embedding_type: Optional[str] = None,
|
||||
# LM Studio specific
|
||||
lmstudio_base_url: Optional[str] = "http://localhost:1234/v1",
|
||||
# AWS Bedrock specific
|
||||
aws_access_key_id: Optional[str] = None,
|
||||
aws_secret_access_key: Optional[str] = None,
|
||||
aws_region: Optional[str] = "us-west-2",
|
||||
):
|
||||
"""
|
||||
Initializes a configuration class instance for the Embeddings.
|
||||
@@ -92,3 +96,8 @@ class BaseEmbedderConfig(ABC):
|
||||
|
||||
# LM Studio specific
|
||||
self.lmstudio_base_url = lmstudio_base_url
|
||||
|
||||
# AWS Bedrock specific
|
||||
self.aws_access_key_id = aws_access_key_id
|
||||
self.aws_secret_access_key = aws_secret_access_key
|
||||
self.aws_region = aws_region
|
||||
|
||||
@@ -41,6 +41,10 @@ class BaseLlmConfig(ABC):
|
||||
xai_base_url: Optional[str] = None,
|
||||
# LM Studio specific
|
||||
lmstudio_base_url: Optional[str] = "http://localhost:1234/v1",
|
||||
# AWS Bedrock specific
|
||||
aws_access_key_id: Optional[str] = None,
|
||||
aws_secret_access_key: Optional[str] = None,
|
||||
aws_region: Optional[str] = "us-west-2",
|
||||
):
|
||||
"""
|
||||
Initializes a configuration class instance for the LLM.
|
||||
@@ -123,3 +127,8 @@ class BaseLlmConfig(ABC):
|
||||
|
||||
# LM Studio specific
|
||||
self.lmstudio_base_url = lmstudio_base_url
|
||||
|
||||
# AWS Bedrock specific
|
||||
self.aws_access_key_id = aws_access_key_id
|
||||
self.aws_secret_access_key = aws_secret_access_key
|
||||
self.aws_region = aws_region
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from typing import Any, Dict, Optional
|
||||
from typing import Any, Dict, Optional, Union, Type
|
||||
|
||||
from pydantic import BaseModel, Field, model_validator
|
||||
|
||||
@@ -7,14 +7,33 @@ class OpenSearchConfig(BaseModel):
|
||||
collection_name: str = Field("mem0", description="Name of the index")
|
||||
host: str = Field("localhost", description="OpenSearch host")
|
||||
port: int = Field(9200, description="OpenSearch port")
|
||||
user: Optional[str] = Field(None, description="Username for authentication")
|
||||
password: Optional[str] = Field(None, description="Password for authentication")
|
||||
api_key: Optional[str] = Field(None, description="API key for authentication (if applicable)")
|
||||
embedding_model_dims: int = Field(1536, description="Dimension of the embedding vector")
|
||||
verify_certs: bool = Field(False, description="Verify SSL certificates (default False for OpenSearch)")
|
||||
use_ssl: bool = Field(False, description="Use SSL for connection (default False for OpenSearch)")
|
||||
auto_create_index: bool = Field(True, description="Automatically create index during initialization")
|
||||
http_auth: Optional[object] = Field(None, description="HTTP authentication method / AWS SigV4")
|
||||
user: Optional[str] = Field(
|
||||
None, description="Username for authentication"
|
||||
)
|
||||
password: Optional[str] = Field(
|
||||
None, description="Password for authentication"
|
||||
)
|
||||
api_key: Optional[str] = Field(
|
||||
None, description="API key for authentication (if applicable)"
|
||||
)
|
||||
embedding_model_dims: int = Field(
|
||||
1536, description="Dimension of the embedding vector"
|
||||
)
|
||||
verify_certs: bool = Field(
|
||||
False, description="Verify SSL certificates (default False for OpenSearch)"
|
||||
)
|
||||
use_ssl: bool = Field(
|
||||
False, description="Use SSL for connection (default False for OpenSearch)"
|
||||
)
|
||||
http_auth: Optional[object] = Field(
|
||||
None, description="HTTP authentication method / AWS SigV4"
|
||||
)
|
||||
connection_class: Optional[Union[str, Type]] = Field(
|
||||
"RequestsHttpConnection", description="Connection class for OpenSearch"
|
||||
)
|
||||
pool_maxsize: int = Field(
|
||||
20, description="Maximum number of connections in the pool"
|
||||
)
|
||||
|
||||
@model_validator(mode="before")
|
||||
@classmethod
|
||||
@@ -22,11 +41,7 @@ class OpenSearchConfig(BaseModel):
|
||||
# Check if host is provided
|
||||
if not values.get("host"):
|
||||
raise ValueError("Host must be provided for OpenSearch")
|
||||
|
||||
# Authentication: Either API key or user/password must be provided
|
||||
if not any([values.get("api_key"), (values.get("user") and values.get("password")), values.get("http_auth")]):
|
||||
raise ValueError("Either api_key or user/password must be provided for OpenSearch authentication")
|
||||
|
||||
|
||||
return values
|
||||
|
||||
@model_validator(mode="before")
|
||||
@@ -37,6 +52,7 @@ class OpenSearchConfig(BaseModel):
|
||||
extra_fields = input_fields - allowed_fields
|
||||
if extra_fields:
|
||||
raise ValueError(
|
||||
f"Extra fields not allowed: {', '.join(extra_fields)}. " f"Allowed fields: {', '.join(allowed_fields)}"
|
||||
f"Extra fields not allowed: {', '.join(extra_fields)}. "
|
||||
f"Allowed fields: {', '.join(allowed_fields)}"
|
||||
)
|
||||
return values
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import json
|
||||
import os
|
||||
from typing import Literal, Optional
|
||||
|
||||
try:
|
||||
@@ -22,7 +23,26 @@ class AWSBedrockEmbedding(EmbeddingBase):
|
||||
super().__init__(config)
|
||||
|
||||
self.config.model = self.config.model or "amazon.titan-embed-text-v1"
|
||||
self.client = boto3.client("bedrock-runtime")
|
||||
|
||||
# Get AWS config from environment variables or use defaults
|
||||
aws_access_key = os.environ.get("AWS_ACCESS_KEY_ID", "")
|
||||
aws_secret_key = os.environ.get("AWS_SECRET_ACCESS_KEY", "")
|
||||
aws_region = os.environ.get("AWS_REGION", "us-west-2")
|
||||
|
||||
# Check if AWS config is provided in the config
|
||||
if hasattr(self.config, "aws_access_key_id"):
|
||||
aws_access_key = self.config.aws_access_key_id
|
||||
if hasattr(self.config, "aws_secret_access_key"):
|
||||
aws_secret_key = self.config.aws_secret_access_key
|
||||
if hasattr(self.config, "aws_region"):
|
||||
aws_region = self.config.aws_region
|
||||
|
||||
self.client = boto3.client(
|
||||
"bedrock-runtime",
|
||||
region_name=aws_region,
|
||||
aws_access_key_id=aws_access_key if aws_access_key else None,
|
||||
aws_secret_access_key=aws_secret_key if aws_secret_key else None,
|
||||
)
|
||||
|
||||
def _normalize_vector(self, embeddings):
|
||||
"""Normalize the embedding to a unit vector."""
|
||||
|
||||
@@ -10,6 +10,7 @@ class Neo4jConfig(BaseModel):
|
||||
username: Optional[str] = Field(None, description="Username for the graph database")
|
||||
password: Optional[str] = Field(None, description="Password for the graph database")
|
||||
database: Optional[str] = Field(None, description="Database for the graph database")
|
||||
base_label: Optional[bool] = Field(None, description="Whether to use base node label __Entity__ for all entities")
|
||||
|
||||
@model_validator(mode="before")
|
||||
def check_host_port_or_path(cls, values):
|
||||
|
||||
+71
-14
@@ -1,4 +1,6 @@
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
try:
|
||||
@@ -9,6 +11,14 @@ except ImportError:
|
||||
from mem0.configs.llms.base import BaseLlmConfig
|
||||
from mem0.llms.base import LLMBase
|
||||
|
||||
PROVIDERS = ["ai21", "amazon", "anthropic", "cohere", "meta", "mistral", "stability", "writer"]
|
||||
|
||||
|
||||
def extract_provider(model: str) -> str:
|
||||
for provider in PROVIDERS:
|
||||
if re.search(rf"\b{re.escape(provider)}\b", model):
|
||||
return provider
|
||||
raise ValueError(f"Unknown provider in model: {model}")
|
||||
|
||||
class AWSBedrockLLM(LLMBase):
|
||||
def __init__(self, config: Optional[BaseLlmConfig] = None):
|
||||
@@ -16,7 +26,27 @@ class AWSBedrockLLM(LLMBase):
|
||||
|
||||
if not self.config.model:
|
||||
self.config.model = "anthropic.claude-3-5-sonnet-20240620-v1:0"
|
||||
self.client = boto3.client("bedrock-runtime")
|
||||
|
||||
# Get AWS config from environment variables or use defaults
|
||||
aws_access_key = os.environ.get("AWS_ACCESS_KEY_ID", "")
|
||||
aws_secret_key = os.environ.get("AWS_SECRET_ACCESS_KEY", "")
|
||||
aws_region = os.environ.get("AWS_REGION", "us-west-2")
|
||||
|
||||
# Check if AWS config is provided in the config
|
||||
if hasattr(self.config, "aws_access_key_id"):
|
||||
aws_access_key = self.config.aws_access_key_id
|
||||
if hasattr(self.config, "aws_secret_access_key"):
|
||||
aws_secret_key = self.config.aws_secret_access_key
|
||||
if hasattr(self.config, "aws_region"):
|
||||
aws_region = self.config.aws_region
|
||||
|
||||
self.client = boto3.client(
|
||||
"bedrock-runtime",
|
||||
region_name=aws_region,
|
||||
aws_access_key_id=aws_access_key if aws_access_key else None,
|
||||
aws_secret_access_key=aws_secret_key if aws_secret_key else None,
|
||||
)
|
||||
|
||||
self.model_kwargs = {
|
||||
"temperature": self.config.temperature,
|
||||
"max_tokens_to_sample": self.config.max_tokens,
|
||||
@@ -34,13 +64,14 @@ class AWSBedrockLLM(LLMBase):
|
||||
Returns:
|
||||
str: A formatted string combining all messages, structured with roles capitalized and separated by newlines.
|
||||
"""
|
||||
|
||||
formatted_messages = []
|
||||
for message in messages:
|
||||
role = message["role"].capitalize()
|
||||
content = message["content"]
|
||||
formatted_messages.append(f"\n\n{role}: {content}")
|
||||
|
||||
return "".join(formatted_messages) + "\n\nAssistant:"
|
||||
return "\n\nHuman: " + "".join(formatted_messages) + "\n\nAssistant:"
|
||||
|
||||
def _parse_response(self, response, tools) -> str:
|
||||
"""
|
||||
@@ -68,8 +99,9 @@ class AWSBedrockLLM(LLMBase):
|
||||
|
||||
return processed_response
|
||||
|
||||
response_body = json.loads(response["body"].read().decode())
|
||||
return response_body.get("completion", "")
|
||||
response_body = response.get("body").read().decode()
|
||||
response_json = json.loads(response_body)
|
||||
return response_json.get("content", [{"text": ""}])[0].get("text", "")
|
||||
|
||||
def _prepare_input(
|
||||
self,
|
||||
@@ -113,9 +145,9 @@ class AWSBedrockLLM(LLMBase):
|
||||
input_body = {
|
||||
"inputText": prompt,
|
||||
"textGenerationConfig": {
|
||||
"maxTokenCount": model_kwargs.get("max_tokens_to_sample"),
|
||||
"topP": model_kwargs.get("top_p"),
|
||||
"temperature": model_kwargs.get("temperature"),
|
||||
"maxTokenCount": self.model_kwargs["max_tokens_to_sample"] or self.model_kwargs["max_tokens"] or 5000,
|
||||
"topP": self.model_kwargs["top_p"] or 0.9,
|
||||
"temperature": self.model_kwargs["temperature"] or 0.1,
|
||||
},
|
||||
}
|
||||
input_body["textGenerationConfig"] = {
|
||||
@@ -206,15 +238,40 @@ class AWSBedrockLLM(LLMBase):
|
||||
else:
|
||||
# Use invoke_model method when no tools are provided
|
||||
prompt = self._format_messages(messages)
|
||||
provider = self.model.split(".")[0]
|
||||
input_body = self._prepare_input(provider, self.config.model, prompt, **self.model_kwargs)
|
||||
provider = extract_provider(self.config.model)
|
||||
input_body = self._prepare_input(provider, self.config.model, prompt, model_kwargs=self.model_kwargs)
|
||||
body = json.dumps(input_body)
|
||||
|
||||
response = self.client.invoke_model(
|
||||
body=body,
|
||||
modelId=self.model,
|
||||
accept="application/json",
|
||||
contentType="application/json",
|
||||
if provider == "anthropic" or provider == "deepseek":
|
||||
|
||||
input_body = {
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [{"type": "text", "text": prompt}]
|
||||
}
|
||||
],
|
||||
"max_tokens": self.model_kwargs["max_tokens_to_sample"] or self.model_kwargs["max_tokens"] or 5000,
|
||||
"temperature": self.model_kwargs["temperature"] or 0.1,
|
||||
"top_p": self.model_kwargs["top_p"] or 0.9,
|
||||
"anthropic_version": "bedrock-2023-05-31",
|
||||
}
|
||||
|
||||
body = json.dumps(input_body)
|
||||
|
||||
|
||||
response = self.client.invoke_model(
|
||||
body=body,
|
||||
modelId=self.config.model,
|
||||
accept="application/json",
|
||||
contentType="application/json",
|
||||
)
|
||||
else:
|
||||
response = self.client.invoke_model(
|
||||
body=body,
|
||||
modelId=self.config.model,
|
||||
accept="application/json",
|
||||
contentType="application/json",
|
||||
)
|
||||
|
||||
return self._parse_response(response, tools)
|
||||
|
||||
+83
-37
@@ -35,11 +35,25 @@ class MemoryGraph:
|
||||
self.config.graph_store.config.password,
|
||||
self.config.graph_store.config.database,
|
||||
refresh_schema=False,
|
||||
driver_config={"notifications_min_severity":"OFF"},
|
||||
driver_config={"notifications_min_severity": "OFF"},
|
||||
)
|
||||
self.embedding_model = EmbedderFactory.create(
|
||||
self.config.embedder.provider, self.config.embedder.config, self.config.vector_store.config
|
||||
)
|
||||
self.node_label = ":`__Entity__`" if self.config.graph_store.config.base_label else ""
|
||||
|
||||
if self.config.graph_store.config.base_label:
|
||||
# Safely add user_id index
|
||||
try:
|
||||
self.graph.query(f"CREATE INDEX entity_single IF NOT EXISTS FOR (n {self.node_label}) ON (n.user_id)")
|
||||
except Exception:
|
||||
pass
|
||||
try: # Safely try to add composite index (Enterprise only)
|
||||
self.graph.query(
|
||||
f"CREATE INDEX entity_composite IF NOT EXISTS FOR (n {self.node_label}) ON (n.name, n.user_id)"
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
self.llm_provider = "openai_structured"
|
||||
if self.config.llm.provider:
|
||||
@@ -108,8 +122,8 @@ class MemoryGraph:
|
||||
return search_results
|
||||
|
||||
def delete_all(self, filters):
|
||||
cypher = """
|
||||
MATCH (n {user_id: $user_id})
|
||||
cypher = f"""
|
||||
MATCH (n {self.node_label} {{user_id: $user_id}})
|
||||
DETACH DELETE n
|
||||
"""
|
||||
params = {"user_id": filters["user_id"]}
|
||||
@@ -127,10 +141,9 @@ class MemoryGraph:
|
||||
- 'contexts': The base data store response for each memory.
|
||||
- 'entities': A list of strings representing the nodes and relationships
|
||||
"""
|
||||
|
||||
# return all nodes and relationships
|
||||
query = """
|
||||
MATCH (n {user_id: $user_id})-[r]->(m {user_id: $user_id})
|
||||
query = f"""
|
||||
MATCH (n {self.node_label} {{user_id: $user_id}})-[r]->(m {self.node_label} {{user_id: $user_id}})
|
||||
RETURN n.name AS source, type(r) AS relationship, m.name AS target
|
||||
LIMIT $limit
|
||||
"""
|
||||
@@ -224,22 +237,21 @@ class MemoryGraph:
|
||||
def _search_graph_db(self, node_list, filters, limit=100):
|
||||
"""Search similar nodes among and their respective incoming and outgoing relations."""
|
||||
result_relations = []
|
||||
|
||||
for node in node_list:
|
||||
n_embedding = self.embedding_model.embed(node)
|
||||
|
||||
cypher_query = """
|
||||
MATCH (n)
|
||||
cypher_query = f"""
|
||||
MATCH (n {self.node_label})
|
||||
WHERE n.embedding IS NOT NULL AND n.user_id = $user_id
|
||||
WITH n, round(2 * vector.similarity.cosine(n.embedding, $n_embedding) - 1, 4) AS similarity // denormalize for backward compatibility
|
||||
WHERE similarity >= $threshold
|
||||
CALL (n) {
|
||||
CALL (n) {{
|
||||
MATCH (n)-[r]->(m)
|
||||
RETURN n.name AS source, elementId(n) AS source_id, type(r) AS relationship, elementId(r) AS relation_id, m.name AS destination, elementId(m) AS destination_id
|
||||
UNION
|
||||
MATCH (m)-[r]->(n)
|
||||
RETURN m.name AS source, elementId(m) AS source_id, type(r) AS relationship, elementId(r) AS relation_id, n.name AS destination, elementId(n) AS destination_id
|
||||
}
|
||||
}}
|
||||
WITH distinct source, source_id, relationship, relation_id, destination, destination_id, similarity //deduplicate
|
||||
RETURN source, source_id, relationship, relation_id, destination, destination_id, similarity
|
||||
ORDER BY similarity DESC
|
||||
@@ -294,9 +306,9 @@ class MemoryGraph:
|
||||
|
||||
# Delete the specific relationship between nodes
|
||||
cypher = f"""
|
||||
MATCH (n {{name: $source_name, user_id: $user_id}})
|
||||
MATCH (n {self.node_label} {{name: $source_name, user_id: $user_id}})
|
||||
-[r:{relationship}]->
|
||||
(m {{name: $dest_name, user_id: $user_id}})
|
||||
(m {self.node_label} {{name: $dest_name, user_id: $user_id}})
|
||||
DELETE r
|
||||
RETURN
|
||||
n.name AS source,
|
||||
@@ -323,7 +335,11 @@ class MemoryGraph:
|
||||
|
||||
# types
|
||||
source_type = entity_type_map.get(source, "__User__")
|
||||
source_label = self.node_label if self.node_label else f":`{source_type}`"
|
||||
source_extra_set = f", source:`{source_type}`" if self.node_label else ""
|
||||
destination_type = entity_type_map.get(destination, "__User__")
|
||||
destination_label = self.node_label if self.node_label else f":`{destination_type}`"
|
||||
destination_extra_set = f", destination:`{destination_type}`" if self.node_label else ""
|
||||
|
||||
# embeddings
|
||||
source_embedding = self.embedding_model.embed(source)
|
||||
@@ -338,15 +354,24 @@ class MemoryGraph:
|
||||
cypher = f"""
|
||||
MATCH (source)
|
||||
WHERE elementId(source) = $source_id
|
||||
MERGE (destination:{destination_type} {{name: $destination_name, user_id: $user_id}})
|
||||
SET source.mentions = coalesce(source.mentions, 0) + 1
|
||||
WITH source
|
||||
MERGE (destination {destination_label} {{name: $destination_name, user_id: $user_id}})
|
||||
ON CREATE SET
|
||||
destination.created = timestamp()
|
||||
destination.created = timestamp(),
|
||||
destination.mentions = 1
|
||||
{destination_extra_set}
|
||||
ON MATCH SET
|
||||
destination.mentions = coalesce(destination.mentions, 0) + 1
|
||||
WITH source, destination
|
||||
CALL db.create.setNodeVectorProperty(destination, 'embedding', $destination_embedding)
|
||||
WITH source, destination
|
||||
MERGE (source)-[r:{relationship}]->(destination)
|
||||
ON CREATE SET
|
||||
r.created = timestamp()
|
||||
r.created = timestamp(),
|
||||
r.mentions = 1
|
||||
ON MATCH SET
|
||||
r.mentions = coalesce(r.mentions, 0) + 1
|
||||
RETURN source.name AS source, type(r) AS relationship, destination.name AS target
|
||||
"""
|
||||
|
||||
@@ -360,15 +385,24 @@ class MemoryGraph:
|
||||
cypher = f"""
|
||||
MATCH (destination)
|
||||
WHERE elementId(destination) = $destination_id
|
||||
MERGE (source:{source_type} {{name: $source_name, user_id: $user_id}})
|
||||
SET destination.mentions = coalesce(destination.mentions, 0) + 1
|
||||
WITH destination
|
||||
MERGE (source {source_label} {{name: $source_name, user_id: $user_id}})
|
||||
ON CREATE SET
|
||||
source.created = timestamp()
|
||||
source.created = timestamp(),
|
||||
source.mentions = 1
|
||||
{source_extra_set}
|
||||
ON MATCH SET
|
||||
source.mentions = coalesce(source.mentions, 0) + 1
|
||||
WITH source, destination
|
||||
CALL db.create.setNodeVectorProperty(source, 'embedding', $source_embedding)
|
||||
WITH source, destination
|
||||
MERGE (source)-[r:{relationship}]->(destination)
|
||||
ON CREATE SET
|
||||
r.created = timestamp()
|
||||
r.created = timestamp(),
|
||||
r.mentions = 1
|
||||
ON MATCH SET
|
||||
r.mentions = coalesce(r.mentions, 0) + 1
|
||||
RETURN source.name AS source, type(r) AS relationship, destination.name AS target
|
||||
"""
|
||||
|
||||
@@ -382,12 +416,17 @@ class MemoryGraph:
|
||||
cypher = f"""
|
||||
MATCH (source)
|
||||
WHERE elementId(source) = $source_id
|
||||
SET source.mentions = coalesce(source.mentions, 0) + 1
|
||||
WITH source
|
||||
MATCH (destination)
|
||||
WHERE elementId(destination) = $destination_id
|
||||
SET destination.mentions = coalesce(destination.mentions) + 1
|
||||
MERGE (source)-[r:{relationship}]->(destination)
|
||||
ON CREATE SET
|
||||
r.created_at = timestamp(),
|
||||
r.updated_at = timestamp()
|
||||
r.updated_at = timestamp(),
|
||||
r.mentions = 1
|
||||
ON MATCH SET r.mentions = coalesce(r.mentions, 0) + 1
|
||||
|
||||
|
||||
RETURN source.name AS source, type(r) AS relationship, destination.name AS target
|
||||
@@ -399,19 +438,26 @@ class MemoryGraph:
|
||||
}
|
||||
else:
|
||||
cypher = f"""
|
||||
MERGE (n:{source_type} {{name: $source_name, user_id: $user_id}})
|
||||
ON CREATE SET n.created = timestamp()
|
||||
WITH n
|
||||
CALL db.create.setNodeVectorProperty(n, 'embedding', $source_embedding)
|
||||
WITH n
|
||||
MERGE (m:{destination_type} {{name: $dest_name, user_id: $user_id}})
|
||||
ON CREATE SET m.created = timestamp()
|
||||
WITH n, m
|
||||
CALL db.create.setNodeVectorProperty(m, 'embedding', $source_embedding)
|
||||
WITH n, m
|
||||
MERGE (n)-[rel:{relationship}]->(m)
|
||||
ON CREATE SET rel.created = timestamp()
|
||||
RETURN n.name AS source, type(rel) AS relationship, m.name AS target
|
||||
MERGE (source {source_label} {{name: $source_name, user_id: $user_id}})
|
||||
ON CREATE SET source.created = timestamp(),
|
||||
source.mentions = 1
|
||||
{source_extra_set}
|
||||
ON MATCH SET source.mentions = coalesce(source.mentions, 0) + 1
|
||||
WITH source
|
||||
CALL db.create.setNodeVectorProperty(source, 'embedding', $source_embedding)
|
||||
WITH source
|
||||
MERGE (destination {destination_label} {{name: $dest_name, user_id: $user_id}})
|
||||
ON CREATE SET destination.created = timestamp(),
|
||||
destination.mentions = 1
|
||||
{destination_extra_set}
|
||||
ON MATCH SET destination.mentions = coalesce(destination.mentions, 0) + 1
|
||||
WITH source, destination
|
||||
CALL db.create.setNodeVectorProperty(destination, 'embedding', $source_embedding)
|
||||
WITH source, destination
|
||||
MERGE (source)-[rel:{relationship}]->(destination)
|
||||
ON CREATE SET rel.created = timestamp(), rel.mentions = 1
|
||||
ON MATCH SET rel.mentions = coalesce(rel.mentions, 0) + 1
|
||||
RETURN source.name AS source, type(rel) AS relationship, destination.name AS target
|
||||
"""
|
||||
params = {
|
||||
"source_name": source,
|
||||
@@ -432,8 +478,8 @@ class MemoryGraph:
|
||||
return entity_list
|
||||
|
||||
def _search_source_node(self, source_embedding, user_id, threshold=0.9):
|
||||
cypher = """
|
||||
MATCH (source_candidate)
|
||||
cypher = f"""
|
||||
MATCH (source_candidate {self.node_label})
|
||||
WHERE source_candidate.embedding IS NOT NULL
|
||||
AND source_candidate.user_id = $user_id
|
||||
|
||||
@@ -458,8 +504,8 @@ class MemoryGraph:
|
||||
return result
|
||||
|
||||
def _search_destination_node(self, destination_embedding, user_id, threshold=0.9):
|
||||
cypher = """
|
||||
MATCH (destination_candidate)
|
||||
cypher = f"""
|
||||
MATCH (destination_candidate {self.node_label})
|
||||
WHERE destination_candidate.embedding IS NOT NULL
|
||||
AND destination_candidate.user_id = $user_id
|
||||
|
||||
|
||||
+715
-480
File diff suppressed because it is too large
Load Diff
@@ -38,7 +38,7 @@ def get_or_create_user_id(vector_store):
|
||||
|
||||
# Try to get existing user_id from vector store
|
||||
try:
|
||||
existing = vector_store.get(vector_id=VECTOR_ID)
|
||||
existing = vector_store.get(vector_id=user_id)
|
||||
if existing and hasattr(existing, "payload") and existing.payload and "user_id" in existing.payload:
|
||||
return existing.payload["user_id"]
|
||||
except Exception:
|
||||
@@ -48,7 +48,7 @@ def get_or_create_user_id(vector_store):
|
||||
try:
|
||||
dims = getattr(vector_store, "embedding_model_dims", 1536)
|
||||
vector_store.insert(
|
||||
vectors=[[0.0] * dims], payloads=[{"user_id": user_id, "type": "user_identity"}], ids=[VECTOR_ID]
|
||||
vectors=[[0.1] * dims], payloads=[{"user_id": user_id, "type": "user_identity"}], ids=[user_id]
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
+132
-116
@@ -1,144 +1,160 @@
|
||||
import sqlite3
|
||||
import threading
|
||||
import uuid
|
||||
import logging
|
||||
from typing import List, Dict, Any, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SQLiteManager:
|
||||
def __init__(self, db_path=":memory:"):
|
||||
self.connection = sqlite3.connect(db_path, check_same_thread=False)
|
||||
def __init__(self, db_path: str = ":memory:"):
|
||||
self.db_path = db_path
|
||||
self.connection = sqlite3.connect(self.db_path, check_same_thread=False)
|
||||
self._lock = threading.Lock()
|
||||
self._migrate_history_table()
|
||||
self._create_history_table()
|
||||
|
||||
def _migrate_history_table(self):
|
||||
with self._lock:
|
||||
with self.connection:
|
||||
cursor = self.connection.cursor()
|
||||
def _migrate_history_table(self) -> None:
|
||||
"""
|
||||
If a pre-existing history table had the old group-chat columns,
|
||||
rename it, create the new schema, copy the intersecting data, then
|
||||
drop the old table.
|
||||
"""
|
||||
with self._lock, self.connection:
|
||||
cur = self.connection.cursor()
|
||||
cur.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name='history'"
|
||||
)
|
||||
if cur.fetchone() is None:
|
||||
return # nothing to migrate
|
||||
|
||||
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='history'")
|
||||
table_exists = cursor.fetchone() is not None
|
||||
cur.execute("PRAGMA table_info(history)")
|
||||
old_cols = {row[1] for row in cur.fetchall()}
|
||||
|
||||
if table_exists:
|
||||
# Get the current schema of the history table
|
||||
cursor.execute("PRAGMA table_info(history)")
|
||||
current_schema = {row[1]: row[2] for row in cursor.fetchall()}
|
||||
expected_cols = {
|
||||
"id",
|
||||
"memory_id",
|
||||
"old_memory",
|
||||
"new_memory",
|
||||
"event",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"is_deleted",
|
||||
"actor_id",
|
||||
"role",
|
||||
}
|
||||
|
||||
# Define the expected schema
|
||||
expected_schema = {
|
||||
"id": "TEXT",
|
||||
"memory_id": "TEXT",
|
||||
"old_memory": "TEXT",
|
||||
"new_memory": "TEXT",
|
||||
"new_value": "TEXT",
|
||||
"event": "TEXT",
|
||||
"created_at": "DATETIME",
|
||||
"updated_at": "DATETIME",
|
||||
"is_deleted": "INTEGER",
|
||||
}
|
||||
if old_cols == expected_cols:
|
||||
return
|
||||
|
||||
# Check if the schemas are the same
|
||||
if current_schema != expected_schema:
|
||||
# Rename the old table
|
||||
cursor.execute("ALTER TABLE history RENAME TO old_history")
|
||||
logger.info("Migrating history table to new schema (no convo columns).")
|
||||
cur.execute("ALTER TABLE history RENAME TO history_old")
|
||||
|
||||
cursor.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS history (
|
||||
id TEXT PRIMARY KEY,
|
||||
memory_id TEXT,
|
||||
old_memory TEXT,
|
||||
new_memory TEXT,
|
||||
new_value TEXT,
|
||||
event TEXT,
|
||||
created_at DATETIME,
|
||||
updated_at DATETIME,
|
||||
is_deleted INTEGER
|
||||
)
|
||||
"""
|
||||
)
|
||||
self._create_history_table()
|
||||
|
||||
# Copy data from the old table to the new table
|
||||
cursor.execute(
|
||||
"""
|
||||
INSERT INTO history (id, memory_id, old_memory, new_memory, new_value, event, created_at, updated_at, is_deleted)
|
||||
SELECT id, memory_id, prev_value, new_value, new_value, event, timestamp, timestamp, is_deleted
|
||||
FROM old_history
|
||||
""" # noqa: E501
|
||||
)
|
||||
intersecting = list(expected_cols & old_cols)
|
||||
cols_csv = ", ".join(intersecting)
|
||||
cur.execute(
|
||||
f"INSERT INTO history ({cols_csv}) SELECT {cols_csv} FROM history_old"
|
||||
)
|
||||
cur.execute("DROP TABLE history_old")
|
||||
|
||||
cursor.execute("DROP TABLE old_history")
|
||||
|
||||
self.connection.commit()
|
||||
|
||||
def _create_history_table(self):
|
||||
with self._lock:
|
||||
with self.connection:
|
||||
self.connection.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS history (
|
||||
id TEXT PRIMARY KEY,
|
||||
memory_id TEXT,
|
||||
old_memory TEXT,
|
||||
new_memory TEXT,
|
||||
new_value TEXT,
|
||||
event TEXT,
|
||||
created_at DATETIME,
|
||||
updated_at DATETIME,
|
||||
is_deleted INTEGER
|
||||
)
|
||||
def _create_history_table(self) -> None:
|
||||
with self._lock, self.connection:
|
||||
self.connection.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS history (
|
||||
id TEXT PRIMARY KEY,
|
||||
memory_id TEXT,
|
||||
old_memory TEXT,
|
||||
new_memory TEXT,
|
||||
event TEXT,
|
||||
created_at DATETIME,
|
||||
updated_at DATETIME,
|
||||
is_deleted INTEGER,
|
||||
actor_id TEXT,
|
||||
role TEXT
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
def add_history(
|
||||
self,
|
||||
memory_id,
|
||||
old_memory,
|
||||
new_memory,
|
||||
event,
|
||||
created_at=None,
|
||||
updated_at=None,
|
||||
is_deleted=0,
|
||||
):
|
||||
with self._lock:
|
||||
with self.connection:
|
||||
self.connection.execute(
|
||||
"""
|
||||
INSERT INTO history (id, memory_id, old_memory, new_memory, event, created_at, updated_at, is_deleted)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
str(uuid.uuid4()),
|
||||
memory_id,
|
||||
old_memory,
|
||||
new_memory,
|
||||
event,
|
||||
created_at,
|
||||
updated_at,
|
||||
is_deleted,
|
||||
),
|
||||
)
|
||||
|
||||
def get_history(self, memory_id):
|
||||
with self._lock:
|
||||
cursor = self.connection.execute(
|
||||
memory_id: str,
|
||||
old_memory: Optional[str],
|
||||
new_memory: Optional[str],
|
||||
event: str,
|
||||
*,
|
||||
created_at: Optional[str] = None,
|
||||
updated_at: Optional[str] = None,
|
||||
is_deleted: int = 0,
|
||||
actor_id: Optional[str] = None,
|
||||
role: Optional[str] = None,
|
||||
) -> None:
|
||||
with self._lock, self.connection:
|
||||
self.connection.execute(
|
||||
"""
|
||||
SELECT id, memory_id, old_memory, new_memory, event, created_at, updated_at
|
||||
INSERT INTO history (
|
||||
id, memory_id, old_memory, new_memory, event,
|
||||
created_at, updated_at, is_deleted, actor_id, role
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
str(uuid.uuid4()),
|
||||
memory_id,
|
||||
old_memory,
|
||||
new_memory,
|
||||
event,
|
||||
created_at,
|
||||
updated_at,
|
||||
is_deleted,
|
||||
actor_id,
|
||||
role,
|
||||
),
|
||||
)
|
||||
|
||||
def get_history(self, memory_id: str) -> List[Dict[str, Any]]:
|
||||
with self._lock:
|
||||
cur = self.connection.execute(
|
||||
"""
|
||||
SELECT id, memory_id, old_memory, new_memory, event,
|
||||
created_at, updated_at, is_deleted, actor_id, role
|
||||
FROM history
|
||||
WHERE memory_id = ?
|
||||
ORDER BY updated_at ASC
|
||||
ORDER BY created_at ASC, DATETIME(updated_at) ASC
|
||||
""",
|
||||
(memory_id,),
|
||||
)
|
||||
rows = cursor.fetchall()
|
||||
return [
|
||||
{
|
||||
"id": row[0],
|
||||
"memory_id": row[1],
|
||||
"old_memory": row[2],
|
||||
"new_memory": row[3],
|
||||
"event": row[4],
|
||||
"created_at": row[5],
|
||||
"updated_at": row[6],
|
||||
}
|
||||
for row in rows
|
||||
]
|
||||
rows = cur.fetchall()
|
||||
|
||||
return [
|
||||
{
|
||||
"id": r[0],
|
||||
"memory_id": r[1],
|
||||
"old_memory": r[2],
|
||||
"new_memory": r[3],
|
||||
"event": r[4],
|
||||
"created_at": r[5],
|
||||
"updated_at": r[6],
|
||||
"is_deleted": bool(r[7]),
|
||||
"actor_id": r[8],
|
||||
"role": r[9],
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
def reset(self) -> None:
|
||||
"""Drop and recreate the history table."""
|
||||
with self._lock, self.connection:
|
||||
self.connection.execute("DROP TABLE IF EXISTS history")
|
||||
self._create_history_table()
|
||||
|
||||
def close(self) -> None:
|
||||
if self.connection:
|
||||
self.connection.close()
|
||||
self.connection = None
|
||||
|
||||
def __del__(self):
|
||||
self.close()
|
||||
|
||||
@@ -25,7 +25,7 @@ def format_entities(entities):
|
||||
|
||||
formatted_lines = []
|
||||
for entity in entities:
|
||||
simplified = f"{entity['source']} -- {entity['relatationship']} -- {entity['destination']}"
|
||||
simplified = f"{entity['source']} -- {entity['relationship']} -- {entity['destination']}"
|
||||
formatted_lines.append(simplified)
|
||||
|
||||
return "\n".join(formatted_lines)
|
||||
|
||||
@@ -143,7 +143,6 @@ class Langchain(VectorStoreBase):
|
||||
elif hasattr(self.client, "reset_collection"):
|
||||
self.client.reset_collection()
|
||||
else:
|
||||
# Fallback to the generic delete method
|
||||
self.client.delete(ids=None)
|
||||
|
||||
def col_info(self):
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import logging
|
||||
from typing import Any, Dict, List, Optional
|
||||
import time
|
||||
|
||||
try:
|
||||
from opensearchpy import OpenSearch, RequestsHttpConnection
|
||||
from opensearchpy.helpers import bulk
|
||||
except ImportError:
|
||||
raise ImportError("OpenSearch requires extra dependencies. Install with `pip install opensearch-py`") from None
|
||||
|
||||
@@ -34,28 +34,26 @@ class OpenSearchDB(VectorStoreBase):
|
||||
use_ssl=config.use_ssl,
|
||||
verify_certs=config.verify_certs,
|
||||
connection_class=RequestsHttpConnection,
|
||||
pool_maxsize=20
|
||||
)
|
||||
|
||||
self.collection_name = config.collection_name
|
||||
self.embedding_model_dims = config.embedding_model_dims
|
||||
|
||||
# Create index only if auto_create_index is True
|
||||
if config.auto_create_index:
|
||||
self.create_index()
|
||||
self.create_col(self.collection_name, self.embedding_model_dims)
|
||||
|
||||
def create_index(self) -> None:
|
||||
"""Create OpenSearch index with proper mappings if it doesn't exist."""
|
||||
index_settings = {
|
||||
"settings": {
|
||||
"index": {"number_of_replicas": 1, "number_of_shards": 5, "refresh_interval": "1s", "knn": True}
|
||||
"index": {"number_of_replicas": 1, "number_of_shards": 5, "refresh_interval": "10s", "knn": True}
|
||||
},
|
||||
"mappings": {
|
||||
"properties": {
|
||||
"text": {"type": "text"},
|
||||
"vector": {
|
||||
"vector_field": {
|
||||
"type": "knn_vector",
|
||||
"dimension": self.embedding_model_dims,
|
||||
"method": {"engine": "lucene", "name": "hnsw", "space_type": "cosinesimil"},
|
||||
"method": {"engine": "nmslib", "name": "hnsw", "space_type": "cosinesimil"},
|
||||
},
|
||||
"metadata": {"type": "object", "properties": {"user_id": {"type": "keyword"}}},
|
||||
}
|
||||
@@ -71,12 +69,15 @@ class OpenSearchDB(VectorStoreBase):
|
||||
def create_col(self, name: str, vector_size: int) -> None:
|
||||
"""Create a new collection (index in OpenSearch)."""
|
||||
index_settings = {
|
||||
"settings": {
|
||||
"index.knn": True
|
||||
},
|
||||
"mappings": {
|
||||
"properties": {
|
||||
"vector": {
|
||||
"vector_field": {
|
||||
"type": "knn_vector",
|
||||
"dimension": vector_size,
|
||||
"method": {"engine": "lucene", "name": "hnsw", "space_type": "cosinesimil"},
|
||||
"method": {"engine": "nmslib", "name": "hnsw", "space_type": "cosinesimil"},
|
||||
},
|
||||
"payload": {"type": "object"},
|
||||
"id": {"type": "keyword"},
|
||||
@@ -88,6 +89,24 @@ class OpenSearchDB(VectorStoreBase):
|
||||
self.client.indices.create(index=name, body=index_settings)
|
||||
logger.info(f"Created index {name}")
|
||||
|
||||
# Wait for index to be ready
|
||||
max_retries = 60 # 60 seconds timeout
|
||||
retry_count = 0
|
||||
while retry_count < max_retries:
|
||||
try:
|
||||
# Check if index is ready by attempting a simple search
|
||||
self.client.search(index=name, body={"query": {"match_all": {}}})
|
||||
logger.info(f"Index {name} is ready")
|
||||
time.sleep(1)
|
||||
return
|
||||
except Exception:
|
||||
retry_count += 1
|
||||
if retry_count == max_retries:
|
||||
raise TimeoutError(
|
||||
f"Index {name} creation timed out after {max_retries} seconds"
|
||||
)
|
||||
time.sleep(0.5)
|
||||
|
||||
def insert(
|
||||
self, vectors: List[List[float]], payloads: Optional[List[Dict]] = None, ids: Optional[List[str]] = None
|
||||
) -> List[OutputData]:
|
||||
@@ -98,74 +117,161 @@ class OpenSearchDB(VectorStoreBase):
|
||||
if payloads is None:
|
||||
payloads = [{} for _ in range(len(vectors))]
|
||||
|
||||
actions = []
|
||||
for i, (vec, id_) in enumerate(zip(vectors, ids)):
|
||||
action = {
|
||||
"_index": self.collection_name,
|
||||
"_id": id_,
|
||||
"_source": {
|
||||
"vector": vec,
|
||||
"metadata": payloads[i], # Store metadata in the metadata field
|
||||
},
|
||||
body = {
|
||||
"vector_field": vec,
|
||||
"payload": payloads[i],
|
||||
"id": id_,
|
||||
}
|
||||
actions.append(action)
|
||||
|
||||
bulk(self.client, actions)
|
||||
self.client.index(index=self.collection_name, body=body)
|
||||
|
||||
results = []
|
||||
for i, id_ in enumerate(ids):
|
||||
results.append(OutputData(id=id_, score=1.0, payload=payloads[i]))
|
||||
|
||||
return results
|
||||
|
||||
def search(
|
||||
self, query: str, vectors: List[float], limit: int = 5, filters: Optional[Dict] = None
|
||||
) -> List[OutputData]:
|
||||
"""Search for similar vectors using OpenSearch k-NN search with pre-filtering."""
|
||||
search_query = {
|
||||
"size": limit,
|
||||
"query": {
|
||||
"knn": {
|
||||
"vector": {
|
||||
"vector": vectors,
|
||||
"k": limit,
|
||||
}
|
||||
"""Search for similar vectors using OpenSearch k-NN search with optional filters."""
|
||||
|
||||
# Base KNN query
|
||||
knn_query = {
|
||||
"knn": {
|
||||
"vector_field": {
|
||||
"vector": vectors,
|
||||
"k": limit * 2,
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
# Start building the full query
|
||||
query_body = {
|
||||
"size": limit * 2,
|
||||
"query": None
|
||||
}
|
||||
|
||||
# Prepare filter conditions if applicable
|
||||
filter_clauses = []
|
||||
if filters:
|
||||
filter_conditions = [{"term": {f"metadata.{key}": value}} for key, value in filters.items()]
|
||||
search_query["query"]["knn"]["vector"]["filter"] = {"bool": {"filter": filter_conditions}}
|
||||
for key in ["user_id", "run_id", "agent_id"]:
|
||||
value = filters.get(key)
|
||||
if value:
|
||||
filter_clauses.append({
|
||||
"term": {f"payload.{key}.keyword": value}
|
||||
})
|
||||
|
||||
response = self.client.search(index=self.collection_name, body=search_query)
|
||||
# Combine knn with filters if needed
|
||||
if filter_clauses:
|
||||
query_body["query"] = {
|
||||
"bool": {
|
||||
"must": knn_query,
|
||||
"filter": filter_clauses
|
||||
}
|
||||
}
|
||||
else:
|
||||
query_body["query"] = knn_query
|
||||
|
||||
# Execute search
|
||||
response = self.client.search(index=self.collection_name, body=query_body)
|
||||
|
||||
hits = response["hits"]["hits"]
|
||||
results = [
|
||||
OutputData(id=hit["_id"], score=hit["_score"], payload=hit["_source"].get("metadata", {}))
|
||||
for hit in response["hits"]["hits"]
|
||||
OutputData(
|
||||
id=hit["_source"].get("id"),
|
||||
score=hit["_score"],
|
||||
payload=hit["_source"].get("payload", {})
|
||||
)
|
||||
for hit in hits
|
||||
]
|
||||
return results
|
||||
|
||||
def delete(self, vector_id: str) -> None:
|
||||
"""Delete a vector by ID."""
|
||||
self.client.delete(index=self.collection_name, id=vector_id)
|
||||
"""Delete a vector by custom ID."""
|
||||
# First, find the document by custom ID
|
||||
search_query = {
|
||||
"query": {
|
||||
"term": {
|
||||
"id": vector_id
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
response = self.client.search(index=self.collection_name, body=search_query)
|
||||
hits = response.get("hits", {}).get("hits", [])
|
||||
|
||||
if not hits:
|
||||
return
|
||||
|
||||
opensearch_id = hits[0]["_id"]
|
||||
|
||||
# Delete using the actual document ID
|
||||
self.client.delete(index=self.collection_name, id=opensearch_id)
|
||||
|
||||
|
||||
def update(self, vector_id: str, vector: Optional[List[float]] = None, payload: Optional[Dict] = None) -> None:
|
||||
"""Update a vector and its payload."""
|
||||
"""Update a vector and its payload using the custom 'id' field."""
|
||||
|
||||
# First, find the document by custom ID
|
||||
search_query = {
|
||||
"query": {
|
||||
"term": {
|
||||
"id": vector_id
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
response = self.client.search(index=self.collection_name, body=search_query)
|
||||
hits = response.get("hits", {}).get("hits", [])
|
||||
|
||||
if not hits:
|
||||
return
|
||||
|
||||
opensearch_id = hits[0]["_id"] # The actual document ID in OpenSearch
|
||||
|
||||
# Prepare updated fields
|
||||
doc = {}
|
||||
if vector is not None:
|
||||
doc["vector"] = vector
|
||||
doc["vector_field"] = vector
|
||||
if payload is not None:
|
||||
doc["metadata"] = payload
|
||||
doc["payload"] = payload
|
||||
|
||||
if doc:
|
||||
try:
|
||||
response = self.client.update(index=self.collection_name, id=opensearch_id, body={"doc": doc})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
self.client.update(index=self.collection_name, id=vector_id, body={"doc": doc})
|
||||
|
||||
def get(self, vector_id: str) -> Optional[OutputData]:
|
||||
"""Retrieve a vector by ID."""
|
||||
try:
|
||||
response = self.client.get(index=self.collection_name, id=vector_id)
|
||||
return OutputData(id=response["_id"], score=1.0, payload=response["_source"].get("metadata", {}))
|
||||
# First check if index exists
|
||||
if not self.client.indices.exists(index=self.collection_name):
|
||||
logger.info(f"Index {self.collection_name} does not exist, creating it...")
|
||||
self.create_col(self.collection_name, self.embedding_model_dims)
|
||||
return None
|
||||
|
||||
search_query = {
|
||||
"query": {
|
||||
"term": {
|
||||
"id": vector_id
|
||||
}
|
||||
}
|
||||
}
|
||||
response = self.client.search(index=self.collection_name, body=search_query)
|
||||
|
||||
hits = response["hits"]["hits"]
|
||||
|
||||
if not hits:
|
||||
return None
|
||||
|
||||
return OutputData(
|
||||
id=hits[0]["_source"].get("id"),
|
||||
score=1.0,
|
||||
payload=hits[0]["_source"].get("payload", {})
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error retrieving vector {vector_id}: {e}")
|
||||
logger.error(f"Error retrieving vector {vector_id}: {str(e)}")
|
||||
return None
|
||||
|
||||
def list_cols(self) -> List[str]:
|
||||
@@ -180,28 +286,52 @@ class OpenSearchDB(VectorStoreBase):
|
||||
"""Get information about a collection (index)."""
|
||||
return self.client.indices.get(index=name)
|
||||
|
||||
def list(self, filters: Optional[Dict] = None, limit: Optional[int] = None) -> List[List[OutputData]]:
|
||||
"""List all memories."""
|
||||
query = {"query": {"match_all": {}}}
|
||||
def list(self, filters: Optional[Dict] = None, limit: Optional[int] = None) -> List[OutputData]:
|
||||
|
||||
if filters:
|
||||
query["query"] = {
|
||||
"bool": {"must": [{"term": {f"metadata.{key}": value}} for key, value in filters.items()]}
|
||||
try:
|
||||
"""List all memories with optional filters."""
|
||||
query: Dict = {
|
||||
"query": {
|
||||
"match_all": {}
|
||||
}
|
||||
}
|
||||
|
||||
if limit:
|
||||
query["size"] = limit
|
||||
filter_clauses = []
|
||||
if filters:
|
||||
for key in ["user_id", "run_id", "agent_id"]:
|
||||
value = filters.get(key)
|
||||
if value:
|
||||
filter_clauses.append({
|
||||
"term": {f"payload.{key}.keyword": value}
|
||||
})
|
||||
|
||||
if filter_clauses:
|
||||
query["query"] = {
|
||||
"bool": {
|
||||
"filter": filter_clauses
|
||||
}
|
||||
}
|
||||
|
||||
if limit:
|
||||
query["size"] = limit
|
||||
|
||||
response = self.client.search(index=self.collection_name, body=query)
|
||||
hits = response["hits"]["hits"]
|
||||
|
||||
return [[
|
||||
OutputData(
|
||||
id=hit["_source"].get("id"),
|
||||
score=1.0,
|
||||
payload=hit["_source"].get("payload", {})
|
||||
)
|
||||
for hit in hits
|
||||
]]
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
response = self.client.search(index=self.collection_name, body=query)
|
||||
return [
|
||||
[
|
||||
OutputData(id=hit["_id"], score=1.0, payload=hit["_source"].get("metadata", {}))
|
||||
for hit in response["hits"]["hits"]
|
||||
]
|
||||
]
|
||||
|
||||
def reset(self):
|
||||
"""Reset the index by deleting and recreating it."""
|
||||
logger.warning(f"Resetting index {self.collection_name}...")
|
||||
self.delete_col()
|
||||
self.create_index()
|
||||
self.create_col(self.collection_name, self.embedding_model_dims)
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
*.db
|
||||
.env*
|
||||
!.env.example
|
||||
!.env.dev
|
||||
!ui/lib
|
||||
.venv/
|
||||
__pycache__
|
||||
.DS_Store
|
||||
node_modules/
|
||||
*.log
|
||||
api/.openmemory*
|
||||
**/.next
|
||||
.openmemory/
|
||||
@@ -0,0 +1,70 @@
|
||||
# Contributing to OpenMemory
|
||||
|
||||
We are a team of developers passionate about the future of AI and open-source software. With years of experience in both fields, we believe in the power of community-driven development and are excited to build tools that make AI more accessible and personalized.
|
||||
|
||||
## Ways to Contribute
|
||||
|
||||
We welcome all forms of contributions:
|
||||
- Bug reports and feature requests through GitHub Issues
|
||||
- Documentation improvements
|
||||
- Code contributions
|
||||
- Testing and feedback
|
||||
- Community support and discussions
|
||||
|
||||
## Development Workflow
|
||||
|
||||
1. Fork the repository
|
||||
2. Create your feature branch (`git checkout -b openmemory/feature/amazing-feature`)
|
||||
3. Commit your changes (`git commit -m 'Add some amazing feature'`)
|
||||
4. Push to the branch (`git push origin openmemory/feature/amazing-feature`)
|
||||
5. Open a Pull Request
|
||||
|
||||
## Development Setup
|
||||
|
||||
### Backend Setup
|
||||
|
||||
```bash
|
||||
# Copy environment file and edit file to update OPENAI_API_KEY and other secrets
|
||||
make env
|
||||
|
||||
# Build the containers
|
||||
make build
|
||||
|
||||
# Start the services
|
||||
make up
|
||||
```
|
||||
|
||||
### Frontend Setup
|
||||
|
||||
The frontend is a React application. To start the frontend:
|
||||
|
||||
```bash
|
||||
# Install dependencies and start the development server
|
||||
make ui-dev
|
||||
```
|
||||
|
||||
### Prerequisites
|
||||
- Docker and Docker Compose
|
||||
- Python 3.9+ (for backend development)
|
||||
- Node.js (for frontend development)
|
||||
- OpenAI API Key (for LLM interactions)
|
||||
|
||||
### Getting Started
|
||||
Follow the setup instructions in the README.md file to set up your development environment.
|
||||
|
||||
## Code Standards
|
||||
|
||||
We value:
|
||||
- Clean, well-documented code
|
||||
- Thoughtful discussions about features and improvements
|
||||
- Respectful and constructive feedback
|
||||
- A welcoming environment for all contributors
|
||||
|
||||
## Pull Request Process
|
||||
|
||||
1. Ensure your code follows the project's coding standards
|
||||
2. Update documentation as needed
|
||||
3. Include tests for new features
|
||||
4. Make sure all tests pass before submitting
|
||||
|
||||
Join us in building the future of AI memory management! Your contributions help make OpenMemory better for everyone.
|
||||
@@ -0,0 +1,52 @@
|
||||
.PHONY: help up down logs shell migrate test test-clean env ui-install ui-start ui-dev ui-build ui-dev-start
|
||||
|
||||
NEXT_PUBLIC_USER_ID=$(USER)
|
||||
NEXT_PUBLIC_API_URL=http://localhost:8765
|
||||
|
||||
# Default target
|
||||
help:
|
||||
@echo "Available commands:"
|
||||
@echo " make env - Copy .env.example to .env"
|
||||
@echo " make up - Start the containers"
|
||||
@echo " make down - Stop the containers"
|
||||
@echo " make logs - Show container logs"
|
||||
@echo " make shell - Open a shell in the api container"
|
||||
@echo " make migrate - Run database migrations"
|
||||
@echo " make test - Run tests in a new container"
|
||||
@echo " make test-clean - Run tests and clean up volumes"
|
||||
@echo " make ui-install - Install frontend dependencies"
|
||||
@echo " make ui-start - Start the frontend development server"
|
||||
@echo " make ui-dev - Install dependencies and start the frontend in dev mode"
|
||||
@echo " make ui - Install dependencies and start the frontend in production mode"
|
||||
|
||||
env:
|
||||
cd api && cp .env.example .env
|
||||
cd ui && cp .env.example .env
|
||||
|
||||
build:
|
||||
docker compose build
|
||||
|
||||
up:
|
||||
NEXT_PUBLIC_USER_ID=$(USER) NEXT_PUBLIC_API_URL=$(NEXT_PUBLIC_API_URL) docker compose up
|
||||
|
||||
down:
|
||||
docker compose down -v
|
||||
rm -f api/openmemory.db
|
||||
|
||||
logs:
|
||||
docker compose logs -f
|
||||
|
||||
shell:
|
||||
docker compose exec api bash
|
||||
|
||||
upgrade:
|
||||
docker compose exec api alembic upgrade head
|
||||
|
||||
migrate:
|
||||
docker compose exec api alembic upgrade head
|
||||
|
||||
downgrade:
|
||||
docker compose exec api alembic downgrade -1
|
||||
|
||||
ui-dev:
|
||||
cd ui && NEXT_PUBLIC_USER_ID=$(USER) NEXT_PUBLIC_API_URL=$(NEXT_PUBLIC_API_URL) pnpm install && pnpm dev
|
||||
@@ -0,0 +1,74 @@
|
||||
# OpenMemory
|
||||
|
||||
OpenMemory is your personal memory layer for LLMs - private, portable, and open-source. Your memories live locally, giving you complete control over your data. Build AI applications with personalized memories while keeping your data secure.
|
||||
|
||||

|
||||
|
||||
## Easy Setup
|
||||
|
||||
### Prerequisites
|
||||
- Docker
|
||||
- OpenAI API Key
|
||||
|
||||
You can quickly run OpenMemory by running the following command:
|
||||
|
||||
```bash
|
||||
curl -sL https://raw.githubusercontent.com/mem0ai/mem0/main/openmemory/run.sh | bash
|
||||
```
|
||||
|
||||
You should set the `OPENAI_API_KEY` as a global environment variable:
|
||||
|
||||
```bash
|
||||
export OPENAI_API_KEY=your_api_key
|
||||
```
|
||||
|
||||
You can also set the `OPENAI_API_KEY` as a parameter to the script:
|
||||
|
||||
```bash
|
||||
curl -sL https://raw.githubusercontent.com/mem0ai/mem0/main/openmemory/run.sh | OPENAI_API_KEY=your_api_key bash
|
||||
```
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Docker and Docker Compose
|
||||
- Python 3.9+ (for backend development)
|
||||
- Node.js (for frontend development)
|
||||
- OpenAI API Key (required for LLM interactions, run `cp api/.env.example api/.env` then change **OPENAI_API_KEY** to yours)
|
||||
|
||||
## Quickstart
|
||||
|
||||
You can run the project using the following two commands:
|
||||
```bash
|
||||
make build # builds the mcp server and ui
|
||||
make up # runs openmemory mcp server and ui
|
||||
```
|
||||
|
||||
After running these commands, you will have:
|
||||
- OpenMemory MCP server running at: http://localhost:8765 (API documentation available at http://localhost:8765/docs)
|
||||
- OpenMemory UI running at: http://localhost:3000
|
||||
|
||||
## Project Structure
|
||||
|
||||
- `api/` - Backend APIs + MCP server
|
||||
- `ui/` - Frontend React application
|
||||
|
||||
## Contributing
|
||||
|
||||
We are a team of developers passionate about the future of AI and open-source software. With years of experience in both fields, we believe in the power of community-driven development and are excited to build tools that make AI more accessible and personalized.
|
||||
|
||||
We welcome all forms of contributions:
|
||||
- Bug reports and feature requests
|
||||
- Documentation improvements
|
||||
- Code contributions
|
||||
- Testing and feedback
|
||||
- Community support
|
||||
|
||||
How to contribute:
|
||||
|
||||
1. Fork the repository
|
||||
2. Create your feature branch (`git checkout -b openmemory/feature/amazing-feature`)
|
||||
3. Commit your changes (`git commit -m 'Add some amazing feature'`)
|
||||
4. Push to the branch (`git push origin openmemory/feature/amazing-feature`)
|
||||
5. Open a Pull Request
|
||||
|
||||
Join us in building the future of AI memory management! Your contributions help make OpenMemory better for everyone.
|
||||
@@ -0,0 +1,23 @@
|
||||
# Ignore all .env files
|
||||
**/.env
|
||||
**/.env.*
|
||||
|
||||
# Ignore all database files
|
||||
**/*.db
|
||||
**/*.sqlite
|
||||
**/*.sqlite3
|
||||
|
||||
# Ignore logs
|
||||
**/*.log
|
||||
|
||||
# Ignore runtime data
|
||||
**/node_modules
|
||||
**/__pycache__
|
||||
**/.pytest_cache
|
||||
**/.coverage
|
||||
**/coverage
|
||||
|
||||
# Ignore Docker runtime files
|
||||
**/.dockerignore
|
||||
**/Dockerfile
|
||||
**/docker-compose*.yml
|
||||
@@ -0,0 +1 @@
|
||||
OPENAI_API_KEY=sk-xxx
|
||||
@@ -0,0 +1 @@
|
||||
3.12
|
||||
@@ -0,0 +1,13 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
LABEL org.opencontainers.image.name="mem0/openmemory-mcp"
|
||||
|
||||
WORKDIR /usr/src/openmemory
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install -r requirements.txt
|
||||
|
||||
COPY . .
|
||||
|
||||
EXPOSE 8765
|
||||
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8765"]
|
||||
@@ -0,0 +1,60 @@
|
||||
# OpenMemory API
|
||||
|
||||
This directory contains the backend API for OpenMemory, built with FastAPI and SQLAlchemy. This also runs the Mem0 MCP Server that you can use with MCP clients to remember things.
|
||||
|
||||
## Quick Start with Docker (Recommended)
|
||||
|
||||
The easiest way to get started is using Docker. Make sure you have Docker and Docker Compose installed.
|
||||
|
||||
1. Build the containers:
|
||||
```bash
|
||||
make build
|
||||
```
|
||||
|
||||
2. Create `.env` file:
|
||||
```bash
|
||||
make env
|
||||
```
|
||||
|
||||
Once you run this command, edit the file `api/.env` and enter the `OPENAI_API_KEY`.
|
||||
|
||||
3. Start the services:
|
||||
```bash
|
||||
make up
|
||||
```
|
||||
|
||||
The API will be available at `http://localhost:8765`
|
||||
|
||||
### Common Docker Commands
|
||||
|
||||
- View logs: `make logs`
|
||||
- Open shell in container: `make shell`
|
||||
- Run database migrations: `make migrate`
|
||||
- Run tests: `make test`
|
||||
- Run tests and clean up: `make test-clean`
|
||||
- Stop containers: `make down`
|
||||
|
||||
## API Documentation
|
||||
|
||||
Once the server is running, you can access the API documentation at:
|
||||
- Swagger UI: `http://localhost:8765/docs`
|
||||
- ReDoc: `http://localhost:8765/redoc`
|
||||
|
||||
## Project Structure
|
||||
|
||||
- `app/`: Main application code
|
||||
- `models.py`: Database models
|
||||
- `database.py`: Database configuration
|
||||
- `routers/`: API route handlers
|
||||
- `migrations/`: Database migration files
|
||||
- `tests/`: Test files
|
||||
- `alembic/`: Alembic migration configuration
|
||||
- `main.py`: Application entry point
|
||||
|
||||
## Development Guidelines
|
||||
|
||||
- Follow PEP 8 style guide
|
||||
- Use type hints
|
||||
- Write tests for new features
|
||||
- Update documentation when making changes
|
||||
- Run migrations for database changes
|
||||
@@ -0,0 +1,114 @@
|
||||
# A generic, single database configuration.
|
||||
|
||||
[alembic]
|
||||
# path to migration scripts
|
||||
# Use forward slashes (/) also on windows to provide an os agnostic path
|
||||
script_location = alembic
|
||||
|
||||
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
|
||||
# Uncomment the line below if you want the files to be prepended with date and time
|
||||
# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
|
||||
# for all available tokens
|
||||
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
|
||||
|
||||
# sys.path path, will be prepended to sys.path if present.
|
||||
# defaults to the current working directory.
|
||||
prepend_sys_path = .
|
||||
|
||||
# timezone to use when rendering the date within the migration file
|
||||
# as well as the filename.
|
||||
# If specified, requires the python-dateutil library that can be
|
||||
# installed by adding `alembic[tz]` to the pip requirements
|
||||
# timezone =
|
||||
|
||||
# max length of characters to apply to the "slug" field
|
||||
# truncate_slug_length = 40
|
||||
|
||||
# set to 'true' to run the environment during
|
||||
# the 'revision' command, regardless of autogenerate
|
||||
# revision_environment = false
|
||||
|
||||
# set to 'true' to allow .pyc and .pyo files without
|
||||
# a source .py file to be detected as revisions in the
|
||||
# versions/ directory
|
||||
# sourceless = false
|
||||
|
||||
# version location specification; This defaults
|
||||
# to alembic/versions. When using multiple version
|
||||
# directories, initial revisions must be specified with --version-path.
|
||||
# The path separator used here should be the separator specified by "version_path_separator" below.
|
||||
# version_locations = %(here)s/bar:%(here)s/bat:alembic/versions
|
||||
|
||||
# version path separator; As mentioned above, this is the character used to split
|
||||
# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep.
|
||||
# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or colons.
|
||||
# Valid values for version_path_separator are:
|
||||
#
|
||||
# version_path_separator = :
|
||||
# version_path_separator = ;
|
||||
# version_path_separator = space
|
||||
version_path_separator = os # Use os.pathsep. Default configuration used for new projects.
|
||||
|
||||
# set to 'true' to search source files recursively
|
||||
# in each "version_locations" directory
|
||||
# new in Alembic version 1.10
|
||||
# recursive_version_locations = false
|
||||
|
||||
# the output encoding used when revision files
|
||||
# are written from script.py.mako
|
||||
# output_encoding = utf-8
|
||||
|
||||
sqlalchemy.url = sqlite:///./openmemory.db
|
||||
|
||||
|
||||
[post_write_hooks]
|
||||
# post_write_hooks defines scripts or Python functions that are run
|
||||
# on newly generated revision scripts. See the documentation for further
|
||||
# detail and examples
|
||||
|
||||
# format using "black" - use the console_scripts runner, against the "black" entrypoint
|
||||
# hooks = black
|
||||
# black.type = console_scripts
|
||||
# black.entrypoint = black
|
||||
# black.options = -l 79 REVISION_SCRIPT_FILENAME
|
||||
|
||||
# lint with attempts to fix using "ruff" - use the exec runner, execute a binary
|
||||
# hooks = ruff
|
||||
# ruff.type = exec
|
||||
# ruff.executable = %(here)s/.venv/bin/ruff
|
||||
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
|
||||
|
||||
# Logging configuration
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic
|
||||
|
||||
[handlers]
|
||||
keys = console
|
||||
|
||||
[formatters]
|
||||
keys = generic
|
||||
|
||||
[logger_root]
|
||||
level = WARN
|
||||
handlers = console
|
||||
qualname =
|
||||
|
||||
[logger_sqlalchemy]
|
||||
level = WARN
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
|
||||
[logger_alembic]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = alembic
|
||||
|
||||
[handler_console]
|
||||
class = StreamHandler
|
||||
args = (sys.stderr,)
|
||||
level = NOTSET
|
||||
formatter = generic
|
||||
|
||||
[formatter_generic]
|
||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||
datefmt = %H:%M:%S
|
||||
@@ -0,0 +1 @@
|
||||
Generic single-database configuration.
|
||||
@@ -0,0 +1,92 @@
|
||||
from logging.config import fileConfig
|
||||
|
||||
from sqlalchemy import engine_from_config
|
||||
from sqlalchemy import pool
|
||||
|
||||
from alembic import context
|
||||
|
||||
import os
|
||||
import sys
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# Add the parent directory to the Python path
|
||||
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
# Load environment variables
|
||||
load_dotenv()
|
||||
|
||||
# Import your models here
|
||||
from app.database import Base
|
||||
from app.models import * # Import all your models
|
||||
|
||||
# this is the Alembic Config object, which provides
|
||||
# access to the values within the .ini file in use.
|
||||
config = context.config
|
||||
|
||||
# Interpret the config file for Python logging.
|
||||
# This line sets up loggers basically.
|
||||
if config.config_file_name is not None:
|
||||
fileConfig(config.config_file_name)
|
||||
|
||||
# add your model's MetaData object here
|
||||
# for 'autogenerate' support
|
||||
target_metadata = Base.metadata
|
||||
|
||||
# other values from the config, defined by the needs of env.py,
|
||||
# can be acquired:
|
||||
# my_important_option = config.get_main_option("my_important_option")
|
||||
# ... etc.
|
||||
|
||||
|
||||
def run_migrations_offline() -> None:
|
||||
"""Run migrations in 'offline' mode.
|
||||
|
||||
This configures the context with just a URL
|
||||
and not an Engine, though an Engine is acceptable
|
||||
here as well. By skipping the Engine creation
|
||||
we don't even need a DBAPI to be available.
|
||||
|
||||
Calls to context.execute() here emit the given string to the
|
||||
script output.
|
||||
|
||||
"""
|
||||
url = os.getenv("DATABASE_URL", "sqlite:///./openmemory.db")
|
||||
context.configure(
|
||||
url=url,
|
||||
target_metadata=target_metadata,
|
||||
literal_binds=True,
|
||||
dialect_opts={"paramstyle": "named"},
|
||||
)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
def run_migrations_online() -> None:
|
||||
"""Run migrations in 'online' mode.
|
||||
|
||||
In this scenario we need to create an Engine
|
||||
and associate a connection with the context.
|
||||
|
||||
"""
|
||||
configuration = config.get_section(config.config_ini_section)
|
||||
configuration["sqlalchemy.url"] = os.getenv("DATABASE_URL", "sqlite:///./openmemory.db")
|
||||
connectable = engine_from_config(
|
||||
configuration,
|
||||
prefix="sqlalchemy.",
|
||||
poolclass=pool.NullPool,
|
||||
)
|
||||
|
||||
with connectable.connect() as connection:
|
||||
context.configure(
|
||||
connection=connection, target_metadata=target_metadata
|
||||
)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
run_migrations_online()
|
||||
@@ -0,0 +1,28 @@
|
||||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
${imports if imports else ""}
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = ${repr(up_revision)}
|
||||
down_revision: Union[str, None] = ${repr(down_revision)}
|
||||
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
||||
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
${downgrades if downgrades else "pass"}
|
||||
@@ -0,0 +1,226 @@
|
||||
"""Initial migration
|
||||
|
||||
Revision ID: 0b53c747049a
|
||||
Revises:
|
||||
Create Date: 2025-04-19 00:59:56.244203
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '0b53c747049a'
|
||||
down_revision: Union[str, None] = None
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table('access_controls',
|
||||
sa.Column('id', sa.UUID(), nullable=False),
|
||||
sa.Column('subject_type', sa.String(), nullable=False),
|
||||
sa.Column('subject_id', sa.UUID(), nullable=True),
|
||||
sa.Column('object_type', sa.String(), nullable=False),
|
||||
sa.Column('object_id', sa.UUID(), nullable=True),
|
||||
sa.Column('effect', sa.String(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=True),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index('idx_access_object', 'access_controls', ['object_type', 'object_id'], unique=False)
|
||||
op.create_index('idx_access_subject', 'access_controls', ['subject_type', 'subject_id'], unique=False)
|
||||
op.create_index(op.f('ix_access_controls_created_at'), 'access_controls', ['created_at'], unique=False)
|
||||
op.create_index(op.f('ix_access_controls_effect'), 'access_controls', ['effect'], unique=False)
|
||||
op.create_index(op.f('ix_access_controls_object_id'), 'access_controls', ['object_id'], unique=False)
|
||||
op.create_index(op.f('ix_access_controls_object_type'), 'access_controls', ['object_type'], unique=False)
|
||||
op.create_index(op.f('ix_access_controls_subject_id'), 'access_controls', ['subject_id'], unique=False)
|
||||
op.create_index(op.f('ix_access_controls_subject_type'), 'access_controls', ['subject_type'], unique=False)
|
||||
op.create_table('archive_policies',
|
||||
sa.Column('id', sa.UUID(), nullable=False),
|
||||
sa.Column('criteria_type', sa.String(), nullable=False),
|
||||
sa.Column('criteria_id', sa.UUID(), nullable=True),
|
||||
sa.Column('days_to_archive', sa.Integer(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=True),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index('idx_policy_criteria', 'archive_policies', ['criteria_type', 'criteria_id'], unique=False)
|
||||
op.create_index(op.f('ix_archive_policies_created_at'), 'archive_policies', ['created_at'], unique=False)
|
||||
op.create_index(op.f('ix_archive_policies_criteria_id'), 'archive_policies', ['criteria_id'], unique=False)
|
||||
op.create_index(op.f('ix_archive_policies_criteria_type'), 'archive_policies', ['criteria_type'], unique=False)
|
||||
op.create_table('categories',
|
||||
sa.Column('id', sa.UUID(), nullable=False),
|
||||
sa.Column('name', sa.String(), nullable=False),
|
||||
sa.Column('description', sa.String(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=True),
|
||||
sa.Column('updated_at', sa.DateTime(), nullable=True),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_categories_created_at'), 'categories', ['created_at'], unique=False)
|
||||
op.create_index(op.f('ix_categories_name'), 'categories', ['name'], unique=True)
|
||||
op.create_table('users',
|
||||
sa.Column('id', sa.UUID(), nullable=False),
|
||||
sa.Column('user_id', sa.String(), nullable=False),
|
||||
sa.Column('name', sa.String(), nullable=True),
|
||||
sa.Column('email', sa.String(), nullable=True),
|
||||
sa.Column('metadata', sa.JSON(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=True),
|
||||
sa.Column('updated_at', sa.DateTime(), nullable=True),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_users_created_at'), 'users', ['created_at'], unique=False)
|
||||
op.create_index(op.f('ix_users_email'), 'users', ['email'], unique=True)
|
||||
op.create_index(op.f('ix_users_name'), 'users', ['name'], unique=False)
|
||||
op.create_index(op.f('ix_users_user_id'), 'users', ['user_id'], unique=True)
|
||||
op.create_table('apps',
|
||||
sa.Column('id', sa.UUID(), nullable=False),
|
||||
sa.Column('owner_id', sa.UUID(), nullable=False),
|
||||
sa.Column('name', sa.String(), nullable=False),
|
||||
sa.Column('description', sa.String(), nullable=True),
|
||||
sa.Column('metadata', sa.JSON(), nullable=True),
|
||||
sa.Column('is_active', sa.Boolean(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=True),
|
||||
sa.Column('updated_at', sa.DateTime(), nullable=True),
|
||||
sa.ForeignKeyConstraint(['owner_id'], ['users.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_apps_created_at'), 'apps', ['created_at'], unique=False)
|
||||
op.create_index(op.f('ix_apps_is_active'), 'apps', ['is_active'], unique=False)
|
||||
op.create_index(op.f('ix_apps_name'), 'apps', ['name'], unique=True)
|
||||
op.create_index(op.f('ix_apps_owner_id'), 'apps', ['owner_id'], unique=False)
|
||||
op.create_table('memories',
|
||||
sa.Column('id', sa.UUID(), nullable=False),
|
||||
sa.Column('user_id', sa.UUID(), nullable=False),
|
||||
sa.Column('app_id', sa.UUID(), nullable=False),
|
||||
sa.Column('content', sa.String(), nullable=False),
|
||||
sa.Column('vector', sa.String(), nullable=True),
|
||||
sa.Column('metadata', sa.JSON(), nullable=True),
|
||||
sa.Column('state', sa.Enum('active', 'paused', 'archived', 'deleted', name='memorystate'), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=True),
|
||||
sa.Column('updated_at', sa.DateTime(), nullable=True),
|
||||
sa.Column('archived_at', sa.DateTime(), nullable=True),
|
||||
sa.Column('deleted_at', sa.DateTime(), nullable=True),
|
||||
sa.ForeignKeyConstraint(['app_id'], ['apps.id'], ),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index('idx_memory_app_state', 'memories', ['app_id', 'state'], unique=False)
|
||||
op.create_index('idx_memory_user_app', 'memories', ['user_id', 'app_id'], unique=False)
|
||||
op.create_index('idx_memory_user_state', 'memories', ['user_id', 'state'], unique=False)
|
||||
op.create_index(op.f('ix_memories_app_id'), 'memories', ['app_id'], unique=False)
|
||||
op.create_index(op.f('ix_memories_archived_at'), 'memories', ['archived_at'], unique=False)
|
||||
op.create_index(op.f('ix_memories_created_at'), 'memories', ['created_at'], unique=False)
|
||||
op.create_index(op.f('ix_memories_deleted_at'), 'memories', ['deleted_at'], unique=False)
|
||||
op.create_index(op.f('ix_memories_state'), 'memories', ['state'], unique=False)
|
||||
op.create_index(op.f('ix_memories_user_id'), 'memories', ['user_id'], unique=False)
|
||||
op.create_table('memory_access_logs',
|
||||
sa.Column('id', sa.UUID(), nullable=False),
|
||||
sa.Column('memory_id', sa.UUID(), nullable=False),
|
||||
sa.Column('app_id', sa.UUID(), nullable=False),
|
||||
sa.Column('accessed_at', sa.DateTime(), nullable=True),
|
||||
sa.Column('access_type', sa.String(), nullable=False),
|
||||
sa.Column('metadata', sa.JSON(), nullable=True),
|
||||
sa.ForeignKeyConstraint(['app_id'], ['apps.id'], ),
|
||||
sa.ForeignKeyConstraint(['memory_id'], ['memories.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index('idx_access_app_time', 'memory_access_logs', ['app_id', 'accessed_at'], unique=False)
|
||||
op.create_index('idx_access_memory_time', 'memory_access_logs', ['memory_id', 'accessed_at'], unique=False)
|
||||
op.create_index(op.f('ix_memory_access_logs_access_type'), 'memory_access_logs', ['access_type'], unique=False)
|
||||
op.create_index(op.f('ix_memory_access_logs_accessed_at'), 'memory_access_logs', ['accessed_at'], unique=False)
|
||||
op.create_index(op.f('ix_memory_access_logs_app_id'), 'memory_access_logs', ['app_id'], unique=False)
|
||||
op.create_index(op.f('ix_memory_access_logs_memory_id'), 'memory_access_logs', ['memory_id'], unique=False)
|
||||
op.create_table('memory_categories',
|
||||
sa.Column('memory_id', sa.UUID(), nullable=False),
|
||||
sa.Column('category_id', sa.UUID(), nullable=False),
|
||||
sa.ForeignKeyConstraint(['category_id'], ['categories.id'], ),
|
||||
sa.ForeignKeyConstraint(['memory_id'], ['memories.id'], ),
|
||||
sa.PrimaryKeyConstraint('memory_id', 'category_id')
|
||||
)
|
||||
op.create_index('idx_memory_category', 'memory_categories', ['memory_id', 'category_id'], unique=False)
|
||||
op.create_index(op.f('ix_memory_categories_category_id'), 'memory_categories', ['category_id'], unique=False)
|
||||
op.create_index(op.f('ix_memory_categories_memory_id'), 'memory_categories', ['memory_id'], unique=False)
|
||||
op.create_table('memory_status_history',
|
||||
sa.Column('id', sa.UUID(), nullable=False),
|
||||
sa.Column('memory_id', sa.UUID(), nullable=False),
|
||||
sa.Column('changed_by', sa.UUID(), nullable=False),
|
||||
sa.Column('old_state', sa.Enum('active', 'paused', 'archived', 'deleted', name='memorystate'), nullable=False),
|
||||
sa.Column('new_state', sa.Enum('active', 'paused', 'archived', 'deleted', name='memorystate'), nullable=False),
|
||||
sa.Column('changed_at', sa.DateTime(), nullable=True),
|
||||
sa.ForeignKeyConstraint(['changed_by'], ['users.id'], ),
|
||||
sa.ForeignKeyConstraint(['memory_id'], ['memories.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index('idx_history_memory_state', 'memory_status_history', ['memory_id', 'new_state'], unique=False)
|
||||
op.create_index('idx_history_user_time', 'memory_status_history', ['changed_by', 'changed_at'], unique=False)
|
||||
op.create_index(op.f('ix_memory_status_history_changed_at'), 'memory_status_history', ['changed_at'], unique=False)
|
||||
op.create_index(op.f('ix_memory_status_history_changed_by'), 'memory_status_history', ['changed_by'], unique=False)
|
||||
op.create_index(op.f('ix_memory_status_history_memory_id'), 'memory_status_history', ['memory_id'], unique=False)
|
||||
op.create_index(op.f('ix_memory_status_history_new_state'), 'memory_status_history', ['new_state'], unique=False)
|
||||
op.create_index(op.f('ix_memory_status_history_old_state'), 'memory_status_history', ['old_state'], unique=False)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_index(op.f('ix_memory_status_history_old_state'), table_name='memory_status_history')
|
||||
op.drop_index(op.f('ix_memory_status_history_new_state'), table_name='memory_status_history')
|
||||
op.drop_index(op.f('ix_memory_status_history_memory_id'), table_name='memory_status_history')
|
||||
op.drop_index(op.f('ix_memory_status_history_changed_by'), table_name='memory_status_history')
|
||||
op.drop_index(op.f('ix_memory_status_history_changed_at'), table_name='memory_status_history')
|
||||
op.drop_index('idx_history_user_time', table_name='memory_status_history')
|
||||
op.drop_index('idx_history_memory_state', table_name='memory_status_history')
|
||||
op.drop_table('memory_status_history')
|
||||
op.drop_index(op.f('ix_memory_categories_memory_id'), table_name='memory_categories')
|
||||
op.drop_index(op.f('ix_memory_categories_category_id'), table_name='memory_categories')
|
||||
op.drop_index('idx_memory_category', table_name='memory_categories')
|
||||
op.drop_table('memory_categories')
|
||||
op.drop_index(op.f('ix_memory_access_logs_memory_id'), table_name='memory_access_logs')
|
||||
op.drop_index(op.f('ix_memory_access_logs_app_id'), table_name='memory_access_logs')
|
||||
op.drop_index(op.f('ix_memory_access_logs_accessed_at'), table_name='memory_access_logs')
|
||||
op.drop_index(op.f('ix_memory_access_logs_access_type'), table_name='memory_access_logs')
|
||||
op.drop_index('idx_access_memory_time', table_name='memory_access_logs')
|
||||
op.drop_index('idx_access_app_time', table_name='memory_access_logs')
|
||||
op.drop_table('memory_access_logs')
|
||||
op.drop_index(op.f('ix_memories_user_id'), table_name='memories')
|
||||
op.drop_index(op.f('ix_memories_state'), table_name='memories')
|
||||
op.drop_index(op.f('ix_memories_deleted_at'), table_name='memories')
|
||||
op.drop_index(op.f('ix_memories_created_at'), table_name='memories')
|
||||
op.drop_index(op.f('ix_memories_archived_at'), table_name='memories')
|
||||
op.drop_index(op.f('ix_memories_app_id'), table_name='memories')
|
||||
op.drop_index('idx_memory_user_state', table_name='memories')
|
||||
op.drop_index('idx_memory_user_app', table_name='memories')
|
||||
op.drop_index('idx_memory_app_state', table_name='memories')
|
||||
op.drop_table('memories')
|
||||
op.drop_index(op.f('ix_apps_owner_id'), table_name='apps')
|
||||
op.drop_index(op.f('ix_apps_name'), table_name='apps')
|
||||
op.drop_index(op.f('ix_apps_is_active'), table_name='apps')
|
||||
op.drop_index(op.f('ix_apps_created_at'), table_name='apps')
|
||||
op.drop_table('apps')
|
||||
op.drop_index(op.f('ix_users_user_id'), table_name='users')
|
||||
op.drop_index(op.f('ix_users_name'), table_name='users')
|
||||
op.drop_index(op.f('ix_users_email'), table_name='users')
|
||||
op.drop_index(op.f('ix_users_created_at'), table_name='users')
|
||||
op.drop_table('users')
|
||||
op.drop_index(op.f('ix_categories_name'), table_name='categories')
|
||||
op.drop_index(op.f('ix_categories_created_at'), table_name='categories')
|
||||
op.drop_table('categories')
|
||||
op.drop_index(op.f('ix_archive_policies_criteria_type'), table_name='archive_policies')
|
||||
op.drop_index(op.f('ix_archive_policies_criteria_id'), table_name='archive_policies')
|
||||
op.drop_index(op.f('ix_archive_policies_created_at'), table_name='archive_policies')
|
||||
op.drop_index('idx_policy_criteria', table_name='archive_policies')
|
||||
op.drop_table('archive_policies')
|
||||
op.drop_index(op.f('ix_access_controls_subject_type'), table_name='access_controls')
|
||||
op.drop_index(op.f('ix_access_controls_subject_id'), table_name='access_controls')
|
||||
op.drop_index(op.f('ix_access_controls_object_type'), table_name='access_controls')
|
||||
op.drop_index(op.f('ix_access_controls_object_id'), table_name='access_controls')
|
||||
op.drop_index(op.f('ix_access_controls_effect'), table_name='access_controls')
|
||||
op.drop_index(op.f('ix_access_controls_created_at'), table_name='access_controls')
|
||||
op.drop_index('idx_access_subject', table_name='access_controls')
|
||||
op.drop_index('idx_access_object', table_name='access_controls')
|
||||
op.drop_table('access_controls')
|
||||
# ### end Alembic commands ###
|
||||
@@ -0,0 +1 @@
|
||||
# This file makes the app directory a Python package
|
||||
@@ -0,0 +1,4 @@
|
||||
import os
|
||||
|
||||
USER_ID = os.getenv("USER", "default_user")
|
||||
DEFAULT_APP_ID = "openmemory"
|
||||
@@ -0,0 +1,29 @@
|
||||
import os
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import declarative_base, sessionmaker
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# load .env file (make sure you have DATABASE_URL set)
|
||||
load_dotenv()
|
||||
|
||||
DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///./openmemory.db")
|
||||
if not DATABASE_URL:
|
||||
raise RuntimeError("DATABASE_URL is not set in environment")
|
||||
|
||||
# SQLAlchemy engine & session
|
||||
engine = create_engine(
|
||||
DATABASE_URL,
|
||||
connect_args={"check_same_thread": False} # Needed for SQLite
|
||||
)
|
||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
|
||||
# Base class for models
|
||||
Base = declarative_base()
|
||||
|
||||
# Dependency for FastAPI
|
||||
def get_db():
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
@@ -0,0 +1,392 @@
|
||||
import logging
|
||||
import json
|
||||
from mcp.server.fastmcp import FastMCP
|
||||
from mcp.server.sse import SseServerTransport
|
||||
from app.utils.memory import get_memory_client
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.routing import APIRouter
|
||||
import contextvars
|
||||
import os
|
||||
from dotenv import load_dotenv
|
||||
from app.database import SessionLocal
|
||||
from app.models import Memory, MemoryState, MemoryStatusHistory, MemoryAccessLog
|
||||
from app.utils.db import get_user_and_app
|
||||
import uuid
|
||||
import datetime
|
||||
from app.utils.permissions import check_memory_access_permissions
|
||||
from qdrant_client import models as qdrant_models
|
||||
|
||||
# Load environment variables
|
||||
load_dotenv()
|
||||
|
||||
# Initialize MCP and memory client
|
||||
mcp = FastMCP("mem0-mcp-server")
|
||||
|
||||
# Check if OpenAI API key is set
|
||||
if not os.getenv("OPENAI_API_KEY"):
|
||||
raise Exception("OPENAI_API_KEY is not set in .env file")
|
||||
|
||||
memory_client = get_memory_client()
|
||||
|
||||
# Context variables for user_id and client_name
|
||||
user_id_var: contextvars.ContextVar[str] = contextvars.ContextVar("user_id")
|
||||
client_name_var: contextvars.ContextVar[str] = contextvars.ContextVar("client_name")
|
||||
|
||||
# Create a router for MCP endpoints
|
||||
mcp_router = APIRouter(prefix="/mcp")
|
||||
|
||||
# Initialize SSE transport
|
||||
sse = SseServerTransport("/mcp/messages/")
|
||||
|
||||
@mcp.tool(description="Add a new memory. This method is called everytime the user informs anything about themselves, their preferences, or anything that has any relevent information whcih can be useful in the future conversation. This can also be called when the user asks you to remember something.")
|
||||
async def add_memories(text: str) -> str:
|
||||
uid = user_id_var.get(None)
|
||||
client_name = client_name_var.get(None)
|
||||
|
||||
if not uid:
|
||||
return "Error: user_id not provided"
|
||||
if not client_name:
|
||||
return "Error: client_name not provided"
|
||||
|
||||
try:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
# Get or create user and app
|
||||
user, app = get_user_and_app(db, user_id=uid, app_id=client_name)
|
||||
|
||||
# Check if app is active
|
||||
if not app.is_active:
|
||||
return f"Error: App {app.name} is currently paused on OpenMemory. Cannot create new memories."
|
||||
|
||||
response = memory_client.add(text,
|
||||
user_id=uid,
|
||||
metadata={
|
||||
"source_app": "openmemory",
|
||||
"mcp_client": client_name,
|
||||
})
|
||||
|
||||
# Process the response and update database
|
||||
if isinstance(response, dict) and 'results' in response:
|
||||
for result in response['results']:
|
||||
memory_id = uuid.UUID(result['id'])
|
||||
memory = db.query(Memory).filter(Memory.id == memory_id).first()
|
||||
|
||||
if result['event'] == 'ADD':
|
||||
if not memory:
|
||||
memory = Memory(
|
||||
id=memory_id,
|
||||
user_id=user.id,
|
||||
app_id=app.id,
|
||||
content=result['memory'],
|
||||
state=MemoryState.active
|
||||
)
|
||||
db.add(memory)
|
||||
else:
|
||||
memory.state = MemoryState.active
|
||||
memory.content = result['memory']
|
||||
|
||||
# Create history entry
|
||||
history = MemoryStatusHistory(
|
||||
memory_id=memory_id,
|
||||
changed_by=user.id,
|
||||
old_state=MemoryState.deleted if memory else None,
|
||||
new_state=MemoryState.active
|
||||
)
|
||||
db.add(history)
|
||||
|
||||
elif result['event'] == 'DELETE':
|
||||
if memory:
|
||||
memory.state = MemoryState.deleted
|
||||
memory.deleted_at = datetime.datetime.now(datetime.UTC)
|
||||
# Create history entry
|
||||
history = MemoryStatusHistory(
|
||||
memory_id=memory_id,
|
||||
changed_by=user.id,
|
||||
old_state=MemoryState.active,
|
||||
new_state=MemoryState.deleted
|
||||
)
|
||||
db.add(history)
|
||||
|
||||
db.commit()
|
||||
|
||||
return response
|
||||
finally:
|
||||
db.close()
|
||||
except Exception as e:
|
||||
return f"Error adding to memory: {e}"
|
||||
|
||||
|
||||
@mcp.tool(description="Search through stored memories. This method is called EVERYTIME the user asks anything.")
|
||||
async def search_memory(query: str) -> str:
|
||||
uid = user_id_var.get(None)
|
||||
client_name = client_name_var.get(None)
|
||||
if not uid:
|
||||
return "Error: user_id not provided"
|
||||
if not client_name:
|
||||
return "Error: client_name not provided"
|
||||
try:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
# Get or create user and app
|
||||
user, app = get_user_and_app(db, user_id=uid, app_id=client_name)
|
||||
|
||||
# Get accessible memory IDs based on ACL
|
||||
user_memories = db.query(Memory).filter(Memory.user_id == user.id).all()
|
||||
accessible_memory_ids = [memory.id for memory in user_memories if check_memory_access_permissions(db, memory, app.id)]
|
||||
|
||||
conditions = [qdrant_models.FieldCondition(key="user_id", match=qdrant_models.MatchValue(value=uid))]
|
||||
|
||||
if accessible_memory_ids:
|
||||
# Convert UUIDs to strings for Qdrant
|
||||
accessible_memory_ids_str = [str(memory_id) for memory_id in accessible_memory_ids]
|
||||
conditions.append(qdrant_models.HasIdCondition(has_id=accessible_memory_ids_str))
|
||||
|
||||
filters = qdrant_models.Filter(must=conditions)
|
||||
embeddings = memory_client.embedding_model.embed(query, "search")
|
||||
|
||||
hits = memory_client.vector_store.client.query_points(
|
||||
collection_name=memory_client.vector_store.collection_name,
|
||||
query=embeddings,
|
||||
query_filter=filters,
|
||||
limit=10,
|
||||
)
|
||||
|
||||
# Process search results
|
||||
memories = hits.points
|
||||
memories = [
|
||||
{
|
||||
"id": memory.id,
|
||||
"memory": memory.payload["data"],
|
||||
"hash": memory.payload.get("hash"),
|
||||
"created_at": memory.payload.get("created_at"),
|
||||
"updated_at": memory.payload.get("updated_at"),
|
||||
"score": memory.score,
|
||||
}
|
||||
for memory in memories
|
||||
]
|
||||
|
||||
# Log memory access for each memory found
|
||||
if isinstance(memories, dict) and 'results' in memories:
|
||||
print(f"Memories: {memories}")
|
||||
for memory_data in memories['results']:
|
||||
if 'id' in memory_data:
|
||||
memory_id = uuid.UUID(memory_data['id'])
|
||||
# Create access log entry
|
||||
access_log = MemoryAccessLog(
|
||||
memory_id=memory_id,
|
||||
app_id=app.id,
|
||||
access_type="search",
|
||||
metadata_={
|
||||
"query": query,
|
||||
"score": memory_data.get('score'),
|
||||
"hash": memory_data.get('hash')
|
||||
}
|
||||
)
|
||||
db.add(access_log)
|
||||
db.commit()
|
||||
else:
|
||||
for memory in memories:
|
||||
memory_id = uuid.UUID(memory['id'])
|
||||
# Create access log entry
|
||||
access_log = MemoryAccessLog(
|
||||
memory_id=memory_id,
|
||||
app_id=app.id,
|
||||
access_type="search",
|
||||
metadata_={
|
||||
"query": query,
|
||||
"score": memory.get('score'),
|
||||
"hash": memory.get('hash')
|
||||
}
|
||||
)
|
||||
db.add(access_log)
|
||||
db.commit()
|
||||
return json.dumps(memories, indent=2)
|
||||
finally:
|
||||
db.close()
|
||||
except Exception as e:
|
||||
logging.exception(e)
|
||||
return f"Error searching memory: {e}"
|
||||
|
||||
|
||||
@mcp.tool(description="List all memories in the user's memory")
|
||||
async def list_memories() -> str:
|
||||
uid = user_id_var.get(None)
|
||||
client_name = client_name_var.get(None)
|
||||
if not uid:
|
||||
return "Error: user_id not provided"
|
||||
if not client_name:
|
||||
return "Error: client_name not provided"
|
||||
try:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
# Get or create user and app
|
||||
user, app = get_user_and_app(db, user_id=uid, app_id=client_name)
|
||||
|
||||
# Get all memories
|
||||
memories = memory_client.get_all(user_id=uid)
|
||||
filtered_memories = []
|
||||
|
||||
# Filter memories based on permissions
|
||||
user_memories = db.query(Memory).filter(Memory.user_id == user.id).all()
|
||||
accessible_memory_ids = [memory.id for memory in user_memories if check_memory_access_permissions(db, memory, app.id)]
|
||||
if isinstance(memories, dict) and 'results' in memories:
|
||||
for memory_data in memories['results']:
|
||||
if 'id' in memory_data:
|
||||
memory_id = uuid.UUID(memory_data['id'])
|
||||
if memory_id in accessible_memory_ids:
|
||||
# Create access log entry
|
||||
access_log = MemoryAccessLog(
|
||||
memory_id=memory_id,
|
||||
app_id=app.id,
|
||||
access_type="list",
|
||||
metadata_={
|
||||
"hash": memory_data.get('hash')
|
||||
}
|
||||
)
|
||||
db.add(access_log)
|
||||
filtered_memories.append(memory_data)
|
||||
db.commit()
|
||||
else:
|
||||
for memory in memories:
|
||||
memory_id = uuid.UUID(memory['id'])
|
||||
memory_obj = db.query(Memory).filter(Memory.id == memory_id).first()
|
||||
if memory_obj and check_memory_access_permissions(db, memory_obj, app.id):
|
||||
# Create access log entry
|
||||
access_log = MemoryAccessLog(
|
||||
memory_id=memory_id,
|
||||
app_id=app.id,
|
||||
access_type="list",
|
||||
metadata_={
|
||||
"hash": memory.get('hash')
|
||||
}
|
||||
)
|
||||
db.add(access_log)
|
||||
filtered_memories.append(memory)
|
||||
db.commit()
|
||||
return json.dumps(filtered_memories, indent=2)
|
||||
finally:
|
||||
db.close()
|
||||
except Exception as e:
|
||||
return f"Error getting memories: {e}"
|
||||
|
||||
|
||||
@mcp.tool(description="Delete all memories in the user's memory")
|
||||
async def delete_all_memories() -> str:
|
||||
uid = user_id_var.get(None)
|
||||
client_name = client_name_var.get(None)
|
||||
if not uid:
|
||||
return "Error: user_id not provided"
|
||||
if not client_name:
|
||||
return "Error: client_name not provided"
|
||||
try:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
# Get or create user and app
|
||||
user, app = get_user_and_app(db, user_id=uid, app_id=client_name)
|
||||
|
||||
user_memories = db.query(Memory).filter(Memory.user_id == user.id).all()
|
||||
accessible_memory_ids = [memory.id for memory in user_memories if check_memory_access_permissions(db, memory, app.id)]
|
||||
|
||||
# delete the accessible memories only
|
||||
for memory_id in accessible_memory_ids:
|
||||
memory_client.delete(memory_id)
|
||||
|
||||
# Update each memory's state and create history entries
|
||||
now = datetime.datetime.now(datetime.UTC)
|
||||
for memory_id in accessible_memory_ids:
|
||||
memory = db.query(Memory).filter(Memory.id == memory_id).first()
|
||||
# Update memory state
|
||||
memory.state = MemoryState.deleted
|
||||
memory.deleted_at = now
|
||||
|
||||
# Create history entry
|
||||
history = MemoryStatusHistory(
|
||||
memory_id=memory_id,
|
||||
changed_by=user.id,
|
||||
old_state=MemoryState.active,
|
||||
new_state=MemoryState.deleted
|
||||
)
|
||||
db.add(history)
|
||||
|
||||
# Create access log entry
|
||||
access_log = MemoryAccessLog(
|
||||
memory_id=memory_id,
|
||||
app_id=app.id,
|
||||
access_type="delete_all",
|
||||
metadata_={"operation": "bulk_delete"}
|
||||
)
|
||||
db.add(access_log)
|
||||
|
||||
db.commit()
|
||||
return "Successfully deleted all memories"
|
||||
finally:
|
||||
db.close()
|
||||
except Exception as e:
|
||||
return f"Error deleting memories: {e}"
|
||||
|
||||
|
||||
@mcp_router.get("/{client_name}/sse/{user_id}")
|
||||
async def handle_sse(request: Request):
|
||||
"""Handle SSE connections for a specific user and client"""
|
||||
# Extract user_id and client_name from path parameters
|
||||
uid = request.path_params.get("user_id")
|
||||
user_token = user_id_var.set(uid or "")
|
||||
client_name = request.path_params.get("client_name")
|
||||
client_token = client_name_var.set(client_name or "")
|
||||
|
||||
try:
|
||||
# Handle SSE connection
|
||||
async with sse.connect_sse(
|
||||
request.scope,
|
||||
request.receive,
|
||||
request._send,
|
||||
) as (read_stream, write_stream):
|
||||
await mcp._mcp_server.run(
|
||||
read_stream,
|
||||
write_stream,
|
||||
mcp._mcp_server.create_initialization_options(),
|
||||
)
|
||||
finally:
|
||||
# Clean up context variables
|
||||
user_id_var.reset(user_token)
|
||||
client_name_var.reset(client_token)
|
||||
|
||||
|
||||
@mcp_router.post("/messages/")
|
||||
async def handle_get_message(request: Request):
|
||||
return await handle_post_message(request)
|
||||
|
||||
|
||||
@mcp_router.post("/{client_name}/sse/{user_id}/messages/")
|
||||
async def handle_post_message(request: Request):
|
||||
return await handle_post_message(request)
|
||||
|
||||
async def handle_post_message(request: Request):
|
||||
"""Handle POST messages for SSE"""
|
||||
try:
|
||||
body = await request.body()
|
||||
|
||||
# Create a simple receive function that returns the body
|
||||
async def receive():
|
||||
return {"type": "http.request", "body": body, "more_body": False}
|
||||
|
||||
# Create a simple send function that does nothing
|
||||
async def send(message):
|
||||
return {}
|
||||
|
||||
# Call handle_post_message with the correct arguments
|
||||
await sse.handle_post_message(request.scope, receive, send)
|
||||
|
||||
# Return a success response
|
||||
return {"status": "ok"}
|
||||
finally:
|
||||
pass
|
||||
# Clean up context variable
|
||||
# client_name_var.reset(client_token)
|
||||
|
||||
def setup_mcp_server(app: FastAPI):
|
||||
"""Setup MCP server with the FastAPI application"""
|
||||
mcp._mcp_server.name = f"mem0-mcp-server"
|
||||
|
||||
# Include MCP router in the FastAPI app
|
||||
app.include_router(mcp_router)
|
||||
@@ -0,0 +1,217 @@
|
||||
import enum
|
||||
import uuid
|
||||
import datetime
|
||||
from sqlalchemy import (
|
||||
Column, String, Boolean, ForeignKey, Enum, Table,
|
||||
DateTime, JSON, Integer, UUID, Index, event
|
||||
)
|
||||
from sqlalchemy.orm import relationship
|
||||
from app.database import Base
|
||||
from sqlalchemy.orm import Session
|
||||
from app.utils.categorization import get_categories_for_memory
|
||||
|
||||
|
||||
def get_current_utc_time():
|
||||
"""Get current UTC time"""
|
||||
return datetime.datetime.now(datetime.UTC)
|
||||
|
||||
|
||||
class MemoryState(enum.Enum):
|
||||
active = "active"
|
||||
paused = "paused"
|
||||
archived = "archived"
|
||||
deleted = "deleted"
|
||||
|
||||
|
||||
class User(Base):
|
||||
__tablename__ = "users"
|
||||
id = Column(UUID, primary_key=True, default=lambda: uuid.uuid4())
|
||||
user_id = Column(String, nullable=False, unique=True, index=True)
|
||||
name = Column(String, nullable=True, index=True)
|
||||
email = Column(String, unique=True, nullable=True, index=True)
|
||||
metadata_ = Column('metadata', JSON, default=dict)
|
||||
created_at = Column(DateTime, default=get_current_utc_time, index=True)
|
||||
updated_at = Column(DateTime,
|
||||
default=get_current_utc_time,
|
||||
onupdate=get_current_utc_time)
|
||||
|
||||
apps = relationship("App", back_populates="owner")
|
||||
memories = relationship("Memory", back_populates="user")
|
||||
|
||||
|
||||
class App(Base):
|
||||
__tablename__ = "apps"
|
||||
id = Column(UUID, primary_key=True, default=lambda: uuid.uuid4())
|
||||
owner_id = Column(UUID, ForeignKey("users.id"), nullable=False, index=True)
|
||||
name = Column(String, unique=True, nullable=False, index=True)
|
||||
description = Column(String)
|
||||
metadata_ = Column('metadata', JSON, default=dict)
|
||||
is_active = Column(Boolean, default=True, index=True)
|
||||
created_at = Column(DateTime, default=get_current_utc_time, index=True)
|
||||
updated_at = Column(DateTime,
|
||||
default=get_current_utc_time,
|
||||
onupdate=get_current_utc_time)
|
||||
|
||||
owner = relationship("User", back_populates="apps")
|
||||
memories = relationship("Memory", back_populates="app")
|
||||
|
||||
|
||||
class Memory(Base):
|
||||
__tablename__ = "memories"
|
||||
id = Column(UUID, primary_key=True, default=lambda: uuid.uuid4())
|
||||
user_id = Column(UUID, ForeignKey("users.id"), nullable=False, index=True)
|
||||
app_id = Column(UUID, ForeignKey("apps.id"), nullable=False, index=True)
|
||||
content = Column(String, nullable=False)
|
||||
vector = Column(String)
|
||||
metadata_ = Column('metadata', JSON, default=dict)
|
||||
state = Column(Enum(MemoryState), default=MemoryState.active, index=True)
|
||||
created_at = Column(DateTime, default=get_current_utc_time, index=True)
|
||||
updated_at = Column(DateTime,
|
||||
default=get_current_utc_time,
|
||||
onupdate=get_current_utc_time)
|
||||
archived_at = Column(DateTime, nullable=True, index=True)
|
||||
deleted_at = Column(DateTime, nullable=True, index=True)
|
||||
|
||||
user = relationship("User", back_populates="memories")
|
||||
app = relationship("App", back_populates="memories")
|
||||
categories = relationship("Category", secondary="memory_categories", back_populates="memories")
|
||||
|
||||
__table_args__ = (
|
||||
Index('idx_memory_user_state', 'user_id', 'state'),
|
||||
Index('idx_memory_app_state', 'app_id', 'state'),
|
||||
Index('idx_memory_user_app', 'user_id', 'app_id'),
|
||||
)
|
||||
|
||||
|
||||
class Category(Base):
|
||||
__tablename__ = "categories"
|
||||
id = Column(UUID, primary_key=True, default=lambda: uuid.uuid4())
|
||||
name = Column(String, unique=True, nullable=False, index=True)
|
||||
description = Column(String)
|
||||
created_at = Column(DateTime, default=datetime.datetime.now(datetime.UTC), index=True)
|
||||
updated_at = Column(DateTime,
|
||||
default=get_current_utc_time,
|
||||
onupdate=get_current_utc_time)
|
||||
|
||||
memories = relationship("Memory", secondary="memory_categories", back_populates="categories")
|
||||
|
||||
memory_categories = Table(
|
||||
"memory_categories", Base.metadata,
|
||||
Column("memory_id", UUID, ForeignKey("memories.id"), primary_key=True, index=True),
|
||||
Column("category_id", UUID, ForeignKey("categories.id"), primary_key=True, index=True),
|
||||
Index('idx_memory_category', 'memory_id', 'category_id')
|
||||
)
|
||||
|
||||
|
||||
class AccessControl(Base):
|
||||
__tablename__ = "access_controls"
|
||||
id = Column(UUID, primary_key=True, default=lambda: uuid.uuid4())
|
||||
subject_type = Column(String, nullable=False, index=True)
|
||||
subject_id = Column(UUID, nullable=True, index=True)
|
||||
object_type = Column(String, nullable=False, index=True)
|
||||
object_id = Column(UUID, nullable=True, index=True)
|
||||
effect = Column(String, nullable=False, index=True)
|
||||
created_at = Column(DateTime, default=get_current_utc_time, index=True)
|
||||
|
||||
__table_args__ = (
|
||||
Index('idx_access_subject', 'subject_type', 'subject_id'),
|
||||
Index('idx_access_object', 'object_type', 'object_id'),
|
||||
)
|
||||
|
||||
|
||||
class ArchivePolicy(Base):
|
||||
__tablename__ = "archive_policies"
|
||||
id = Column(UUID, primary_key=True, default=lambda: uuid.uuid4())
|
||||
criteria_type = Column(String, nullable=False, index=True)
|
||||
criteria_id = Column(UUID, nullable=True, index=True)
|
||||
days_to_archive = Column(Integer, nullable=False)
|
||||
created_at = Column(DateTime, default=get_current_utc_time, index=True)
|
||||
|
||||
__table_args__ = (
|
||||
Index('idx_policy_criteria', 'criteria_type', 'criteria_id'),
|
||||
)
|
||||
|
||||
|
||||
class MemoryStatusHistory(Base):
|
||||
__tablename__ = "memory_status_history"
|
||||
id = Column(UUID, primary_key=True, default=lambda: uuid.uuid4())
|
||||
memory_id = Column(UUID, ForeignKey("memories.id"), nullable=False, index=True)
|
||||
changed_by = Column(UUID, ForeignKey("users.id"), nullable=False, index=True)
|
||||
old_state = Column(Enum(MemoryState), nullable=False, index=True)
|
||||
new_state = Column(Enum(MemoryState), nullable=False, index=True)
|
||||
changed_at = Column(DateTime, default=get_current_utc_time, index=True)
|
||||
|
||||
__table_args__ = (
|
||||
Index('idx_history_memory_state', 'memory_id', 'new_state'),
|
||||
Index('idx_history_user_time', 'changed_by', 'changed_at'),
|
||||
)
|
||||
|
||||
|
||||
class MemoryAccessLog(Base):
|
||||
__tablename__ = "memory_access_logs"
|
||||
id = Column(UUID, primary_key=True, default=lambda: uuid.uuid4())
|
||||
memory_id = Column(UUID, ForeignKey("memories.id"), nullable=False, index=True)
|
||||
app_id = Column(UUID, ForeignKey("apps.id"), nullable=False, index=True)
|
||||
accessed_at = Column(DateTime, default=get_current_utc_time, index=True)
|
||||
access_type = Column(String, nullable=False, index=True)
|
||||
metadata_ = Column('metadata', JSON, default=dict)
|
||||
|
||||
__table_args__ = (
|
||||
Index('idx_access_memory_time', 'memory_id', 'accessed_at'),
|
||||
Index('idx_access_app_time', 'app_id', 'accessed_at'),
|
||||
)
|
||||
|
||||
def categorize_memory(memory: Memory, db: Session) -> None:
|
||||
"""Categorize a memory using OpenAI and store the categories in the database."""
|
||||
try:
|
||||
# Get categories from OpenAI
|
||||
categories = get_categories_for_memory(memory.content)
|
||||
|
||||
# Get or create categories in the database
|
||||
for category_name in categories:
|
||||
category = db.query(Category).filter(Category.name == category_name).first()
|
||||
if not category:
|
||||
category = Category(
|
||||
name=category_name,
|
||||
description=f"Automatically created category for {category_name}"
|
||||
)
|
||||
db.add(category)
|
||||
db.flush() # Flush to get the category ID
|
||||
|
||||
# Check if the memory-category association already exists
|
||||
existing = db.execute(
|
||||
memory_categories.select().where(
|
||||
(memory_categories.c.memory_id == memory.id) &
|
||||
(memory_categories.c.category_id == category.id)
|
||||
)
|
||||
).first()
|
||||
|
||||
if not existing:
|
||||
# Create the association
|
||||
db.execute(
|
||||
memory_categories.insert().values(
|
||||
memory_id=memory.id,
|
||||
category_id=category.id
|
||||
)
|
||||
)
|
||||
|
||||
db.commit()
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
print(f"Error categorizing memory: {e}")
|
||||
|
||||
|
||||
@event.listens_for(Memory, 'after_insert')
|
||||
def after_memory_insert(mapper, connection, target):
|
||||
"""Trigger categorization after a memory is inserted."""
|
||||
db = Session(bind=connection)
|
||||
categorize_memory(target, db)
|
||||
db.close()
|
||||
|
||||
|
||||
@event.listens_for(Memory, 'after_update')
|
||||
def after_memory_update(mapper, connection, target):
|
||||
"""Trigger categorization after a memory is updated."""
|
||||
db = Session(bind=connection)
|
||||
categorize_memory(target, db)
|
||||
db.close()
|
||||
@@ -0,0 +1,5 @@
|
||||
from .memories import router as memories_router
|
||||
from .apps import router as apps_router
|
||||
from .stats import router as stats_router
|
||||
|
||||
__all__ = ["memories_router", "apps_router", "stats_router"]
|
||||
@@ -0,0 +1,223 @@
|
||||
from typing import Optional
|
||||
from uuid import UUID
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.orm import Session, joinedload
|
||||
from sqlalchemy import func, desc
|
||||
|
||||
from app.database import get_db
|
||||
from app.models import App, Memory, MemoryAccessLog, MemoryState
|
||||
|
||||
router = APIRouter(prefix="/api/v1/apps", tags=["apps"])
|
||||
|
||||
# Helper functions
|
||||
def get_app_or_404(db: Session, app_id: UUID) -> App:
|
||||
app = db.query(App).filter(App.id == app_id).first()
|
||||
if not app:
|
||||
raise HTTPException(status_code=404, detail="App not found")
|
||||
return app
|
||||
|
||||
# List all apps with filtering
|
||||
@router.get("/")
|
||||
async def list_apps(
|
||||
name: Optional[str] = None,
|
||||
is_active: Optional[bool] = None,
|
||||
sort_by: str = 'name',
|
||||
sort_direction: str = 'asc',
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(10, ge=1, le=100),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
# Create a subquery for memory counts
|
||||
memory_counts = db.query(
|
||||
Memory.app_id,
|
||||
func.count(Memory.id).label('memory_count')
|
||||
).filter(
|
||||
Memory.state.in_([MemoryState.active, MemoryState.paused, MemoryState.archived])
|
||||
).group_by(Memory.app_id).subquery()
|
||||
|
||||
# Create a subquery for access counts
|
||||
access_counts = db.query(
|
||||
MemoryAccessLog.app_id,
|
||||
func.count(func.distinct(MemoryAccessLog.memory_id)).label('access_count')
|
||||
).group_by(MemoryAccessLog.app_id).subquery()
|
||||
|
||||
# Base query
|
||||
query = db.query(
|
||||
App,
|
||||
func.coalesce(memory_counts.c.memory_count, 0).label('total_memories_created'),
|
||||
func.coalesce(access_counts.c.access_count, 0).label('total_memories_accessed')
|
||||
)
|
||||
|
||||
# Join with subqueries
|
||||
query = query.outerjoin(
|
||||
memory_counts,
|
||||
App.id == memory_counts.c.app_id
|
||||
).outerjoin(
|
||||
access_counts,
|
||||
App.id == access_counts.c.app_id
|
||||
)
|
||||
|
||||
if name:
|
||||
query = query.filter(App.name.ilike(f"%{name}%"))
|
||||
|
||||
if is_active is not None:
|
||||
query = query.filter(App.is_active == is_active)
|
||||
|
||||
# Apply sorting
|
||||
if sort_by == 'name':
|
||||
sort_field = App.name
|
||||
elif sort_by == 'memories':
|
||||
sort_field = func.coalesce(memory_counts.c.memory_count, 0)
|
||||
elif sort_by == 'memories_accessed':
|
||||
sort_field = func.coalesce(access_counts.c.access_count, 0)
|
||||
else:
|
||||
sort_field = App.name # default sort
|
||||
|
||||
if sort_direction == 'desc':
|
||||
query = query.order_by(desc(sort_field))
|
||||
else:
|
||||
query = query.order_by(sort_field)
|
||||
|
||||
total = query.count()
|
||||
apps = query.offset((page - 1) * page_size).limit(page_size).all()
|
||||
|
||||
return {
|
||||
"total": total,
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
"apps": [
|
||||
{
|
||||
"id": app[0].id,
|
||||
"name": app[0].name,
|
||||
"is_active": app[0].is_active,
|
||||
"total_memories_created": app[1],
|
||||
"total_memories_accessed": app[2]
|
||||
}
|
||||
for app in apps
|
||||
]
|
||||
}
|
||||
|
||||
# Get app details
|
||||
@router.get("/{app_id}")
|
||||
async def get_app_details(
|
||||
app_id: UUID,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
app = get_app_or_404(db, app_id)
|
||||
|
||||
# Get memory access statistics
|
||||
access_stats = db.query(
|
||||
func.count(MemoryAccessLog.id).label("total_memories_accessed"),
|
||||
func.min(MemoryAccessLog.accessed_at).label("first_accessed"),
|
||||
func.max(MemoryAccessLog.accessed_at).label("last_accessed")
|
||||
).filter(MemoryAccessLog.app_id == app_id).first()
|
||||
|
||||
return {
|
||||
"is_active": app.is_active,
|
||||
"total_memories_created": db.query(Memory)
|
||||
.filter(Memory.app_id == app_id)
|
||||
.count(),
|
||||
"total_memories_accessed": access_stats.total_memories_accessed or 0,
|
||||
"first_accessed": access_stats.first_accessed,
|
||||
"last_accessed": access_stats.last_accessed
|
||||
}
|
||||
|
||||
# List memories created by app
|
||||
@router.get("/{app_id}/memories")
|
||||
async def list_app_memories(
|
||||
app_id: UUID,
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(10, ge=1, le=100),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
get_app_or_404(db, app_id)
|
||||
query = db.query(Memory).filter(
|
||||
Memory.app_id == app_id,
|
||||
Memory.state.in_([MemoryState.active, MemoryState.paused, MemoryState.archived])
|
||||
)
|
||||
# Add eager loading for categories
|
||||
query = query.options(joinedload(Memory.categories))
|
||||
total = query.count()
|
||||
memories = query.order_by(Memory.created_at.desc()).offset((page - 1) * page_size).limit(page_size).all()
|
||||
|
||||
return {
|
||||
"total": total,
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
"memories": [
|
||||
{
|
||||
"id": memory.id,
|
||||
"content": memory.content,
|
||||
"created_at": memory.created_at,
|
||||
"state": memory.state.value,
|
||||
"app_id": memory.app_id,
|
||||
"categories": [category.name for category in memory.categories],
|
||||
"metadata_": memory.metadata_
|
||||
}
|
||||
for memory in memories
|
||||
]
|
||||
}
|
||||
|
||||
# List memories accessed by app
|
||||
@router.get("/{app_id}/accessed")
|
||||
async def list_app_accessed_memories(
|
||||
app_id: UUID,
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(10, ge=1, le=100),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
|
||||
# Get memories with access counts
|
||||
query = db.query(
|
||||
Memory,
|
||||
func.count(MemoryAccessLog.id).label("access_count")
|
||||
).join(
|
||||
MemoryAccessLog,
|
||||
Memory.id == MemoryAccessLog.memory_id
|
||||
).filter(
|
||||
MemoryAccessLog.app_id == app_id
|
||||
).group_by(
|
||||
Memory.id
|
||||
).order_by(
|
||||
desc("access_count")
|
||||
)
|
||||
|
||||
# Add eager loading for categories
|
||||
query = query.options(joinedload(Memory.categories))
|
||||
|
||||
total = query.count()
|
||||
results = query.offset((page - 1) * page_size).limit(page_size).all()
|
||||
|
||||
return {
|
||||
"total": total,
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
"memories": [
|
||||
{
|
||||
"memory": {
|
||||
"id": memory.id,
|
||||
"content": memory.content,
|
||||
"created_at": memory.created_at,
|
||||
"state": memory.state.value,
|
||||
"app_id": memory.app_id,
|
||||
"app_name": memory.app.name if memory.app else None,
|
||||
"categories": [category.name for category in memory.categories],
|
||||
"metadata_": memory.metadata_
|
||||
},
|
||||
"access_count": count
|
||||
}
|
||||
for memory, count in results
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@router.put("/{app_id}")
|
||||
async def update_app_details(
|
||||
app_id: UUID,
|
||||
is_active: bool,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
app = get_app_or_404(db, app_id)
|
||||
app.is_active = is_active
|
||||
db.commit()
|
||||
return {"status": "success", "message": "Updated app details successfully"}
|
||||
@@ -0,0 +1,665 @@
|
||||
from datetime import datetime, UTC
|
||||
from typing import List, Optional, Set
|
||||
from uuid import UUID, uuid4
|
||||
import logging
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.orm import Session, joinedload
|
||||
from fastapi_pagination import Page, Params
|
||||
from fastapi_pagination.ext.sqlalchemy import paginate as sqlalchemy_paginate
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import or_, func
|
||||
from app.utils.memory import get_memory_client
|
||||
|
||||
from app.database import get_db
|
||||
from app.models import (
|
||||
Memory, MemoryState, MemoryAccessLog, App,
|
||||
MemoryStatusHistory, User, Category, AccessControl
|
||||
)
|
||||
from app.schemas import MemoryResponse, PaginatedMemoryResponse
|
||||
from app.utils.permissions import check_memory_access_permissions
|
||||
|
||||
memory_client = get_memory_client()
|
||||
|
||||
router = APIRouter(prefix="/api/v1/memories", tags=["memories"])
|
||||
|
||||
|
||||
def get_memory_or_404(db: Session, memory_id: UUID) -> Memory:
|
||||
memory = db.query(Memory).filter(Memory.id == memory_id).first()
|
||||
if not memory:
|
||||
raise HTTPException(status_code=404, detail="Memory not found")
|
||||
return memory
|
||||
|
||||
|
||||
def update_memory_state(db: Session, memory_id: UUID, new_state: MemoryState, user_id: UUID):
|
||||
memory = get_memory_or_404(db, memory_id)
|
||||
old_state = memory.state
|
||||
|
||||
# Update memory state
|
||||
memory.state = new_state
|
||||
if new_state == MemoryState.archived:
|
||||
memory.archived_at = datetime.now(UTC)
|
||||
elif new_state == MemoryState.deleted:
|
||||
memory.deleted_at = datetime.now(UTC)
|
||||
|
||||
# Record state change
|
||||
history = MemoryStatusHistory(
|
||||
memory_id=memory_id,
|
||||
changed_by=user_id,
|
||||
old_state=old_state,
|
||||
new_state=new_state
|
||||
)
|
||||
db.add(history)
|
||||
db.commit()
|
||||
return memory
|
||||
|
||||
|
||||
def get_accessible_memory_ids(db: Session, app_id: UUID) -> Set[UUID]:
|
||||
"""
|
||||
Get the set of memory IDs that the app has access to based on app-level ACL rules.
|
||||
Returns all memory IDs if no specific restrictions are found.
|
||||
"""
|
||||
# Get app-level access controls
|
||||
app_access = db.query(AccessControl).filter(
|
||||
AccessControl.subject_type == "app",
|
||||
AccessControl.subject_id == app_id,
|
||||
AccessControl.object_type == "memory"
|
||||
).all()
|
||||
|
||||
# If no app-level rules exist, return None to indicate all memories are accessible
|
||||
if not app_access:
|
||||
return None
|
||||
|
||||
# Initialize sets for allowed and denied memory IDs
|
||||
allowed_memory_ids = set()
|
||||
denied_memory_ids = set()
|
||||
|
||||
# Process app-level rules
|
||||
for rule in app_access:
|
||||
if rule.effect == "allow":
|
||||
if rule.object_id: # Specific memory access
|
||||
allowed_memory_ids.add(rule.object_id)
|
||||
else: # All memories access
|
||||
return None # All memories allowed
|
||||
elif rule.effect == "deny":
|
||||
if rule.object_id: # Specific memory denied
|
||||
denied_memory_ids.add(rule.object_id)
|
||||
else: # All memories denied
|
||||
return set() # No memories accessible
|
||||
|
||||
# Remove denied memories from allowed set
|
||||
if allowed_memory_ids:
|
||||
allowed_memory_ids -= denied_memory_ids
|
||||
|
||||
return allowed_memory_ids
|
||||
|
||||
|
||||
# List all memories with filtering
|
||||
@router.get("/", response_model=Page[MemoryResponse])
|
||||
async def list_memories(
|
||||
user_id: str,
|
||||
app_id: Optional[UUID] = None,
|
||||
from_date: Optional[int] = Query(
|
||||
None,
|
||||
description="Filter memories created after this date (timestamp)",
|
||||
examples=[1718505600]
|
||||
),
|
||||
to_date: Optional[int] = Query(
|
||||
None,
|
||||
description="Filter memories created before this date (timestamp)",
|
||||
examples=[1718505600]
|
||||
),
|
||||
categories: Optional[str] = None,
|
||||
params: Params = Depends(),
|
||||
search_query: Optional[str] = None,
|
||||
sort_column: Optional[str] = Query(None, description="Column to sort by (memory, categories, app_name, created_at)"),
|
||||
sort_direction: Optional[str] = Query(None, description="Sort direction (asc or desc)"),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
user = db.query(User).filter(User.user_id == user_id).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
# Build base query
|
||||
query = db.query(Memory).filter(
|
||||
Memory.user_id == user.id,
|
||||
Memory.state != MemoryState.deleted,
|
||||
Memory.state != MemoryState.archived,
|
||||
Memory.content.ilike(f"%{search_query}%") if search_query else True
|
||||
)
|
||||
|
||||
# Apply filters
|
||||
if app_id:
|
||||
query = query.filter(Memory.app_id == app_id)
|
||||
|
||||
if from_date:
|
||||
from_datetime = datetime.fromtimestamp(from_date, tz=UTC)
|
||||
query = query.filter(Memory.created_at >= from_datetime)
|
||||
|
||||
if to_date:
|
||||
to_datetime = datetime.fromtimestamp(to_date, tz=UTC)
|
||||
query = query.filter(Memory.created_at <= to_datetime)
|
||||
|
||||
# Add joins for app and categories after filtering
|
||||
query = query.outerjoin(App, Memory.app_id == App.id)
|
||||
query = query.outerjoin(Memory.categories)
|
||||
|
||||
# Apply category filter if provided
|
||||
if categories:
|
||||
category_list = [c.strip() for c in categories.split(",")]
|
||||
query = query.filter(Category.name.in_(category_list))
|
||||
|
||||
# Apply sorting if specified
|
||||
if sort_column:
|
||||
sort_field = getattr(Memory, sort_column, None)
|
||||
if sort_field:
|
||||
query = query.order_by(sort_field.desc()) if sort_direction == "desc" else query.order_by(sort_field.asc())
|
||||
|
||||
|
||||
# Get paginated results
|
||||
paginated_results = sqlalchemy_paginate(query, params)
|
||||
|
||||
# Filter results based on permissions
|
||||
filtered_items = []
|
||||
for item in paginated_results.items:
|
||||
if check_memory_access_permissions(db, item, app_id):
|
||||
filtered_items.append(item)
|
||||
|
||||
# Update paginated results with filtered items
|
||||
paginated_results.items = filtered_items
|
||||
paginated_results.total = len(filtered_items)
|
||||
|
||||
return paginated_results
|
||||
|
||||
|
||||
# Get all categories
|
||||
@router.get("/categories")
|
||||
async def get_categories(
|
||||
user_id: str,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
user = db.query(User).filter(User.user_id == user_id).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
# Get unique categories associated with the user's memories
|
||||
# Get all memories
|
||||
memories = db.query(Memory).filter(Memory.user_id == user.id, Memory.state != MemoryState.deleted, Memory.state != MemoryState.archived).all()
|
||||
# Get all categories from memories
|
||||
categories = [category for memory in memories for category in memory.categories]
|
||||
# Get unique categories
|
||||
unique_categories = list(set(categories))
|
||||
|
||||
return {
|
||||
"categories": unique_categories,
|
||||
"total": len(unique_categories)
|
||||
}
|
||||
|
||||
|
||||
class CreateMemoryRequest(BaseModel):
|
||||
user_id: str
|
||||
text: str
|
||||
metadata: dict = {}
|
||||
infer: bool = True
|
||||
app: str = "openmemory"
|
||||
|
||||
|
||||
# Create new memory
|
||||
@router.post("/")
|
||||
async def create_memory(
|
||||
request: CreateMemoryRequest,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
user = db.query(User).filter(User.user_id == request.user_id).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
# Get or create app
|
||||
app_obj = db.query(App).filter(App.name == request.app).first()
|
||||
if not app_obj:
|
||||
app_obj = App(name=request.app, owner_id=user.id)
|
||||
db.add(app_obj)
|
||||
db.commit()
|
||||
db.refresh(app_obj)
|
||||
|
||||
# Check if app is active
|
||||
if not app_obj.is_active:
|
||||
raise HTTPException(status_code=403, detail=f"App {request.app} is currently paused on OpenMemory. Cannot create new memories.")
|
||||
|
||||
# Log what we're about to do
|
||||
logging.info(f"Creating memory for user_id: {request.user_id} with app: {request.app}")
|
||||
|
||||
# Save to Qdrant via memory_client
|
||||
qdrant_response = memory_client.add(
|
||||
request.text,
|
||||
user_id=request.user_id, # Use string user_id to match search
|
||||
metadata={
|
||||
"source_app": "openmemory",
|
||||
"mcp_client": request.app,
|
||||
}
|
||||
)
|
||||
|
||||
# Log the response for debugging
|
||||
logging.info(f"Qdrant response: {qdrant_response}")
|
||||
|
||||
# Process Qdrant response
|
||||
if isinstance(qdrant_response, dict) and 'results' in qdrant_response:
|
||||
for result in qdrant_response['results']:
|
||||
if result['event'] == 'ADD':
|
||||
# Get the Qdrant-generated ID
|
||||
memory_id = UUID(result['id'])
|
||||
|
||||
# Check if memory already exists
|
||||
existing_memory = db.query(Memory).filter(Memory.id == memory_id).first()
|
||||
|
||||
if existing_memory:
|
||||
# Update existing memory
|
||||
existing_memory.state = MemoryState.active
|
||||
existing_memory.content = result['memory']
|
||||
memory = existing_memory
|
||||
else:
|
||||
# Create memory with the EXACT SAME ID from Qdrant
|
||||
memory = Memory(
|
||||
id=memory_id, # Use the same ID that Qdrant generated
|
||||
user_id=user.id,
|
||||
app_id=app_obj.id,
|
||||
content=result['memory'],
|
||||
metadata_=request.metadata,
|
||||
state=MemoryState.active
|
||||
)
|
||||
db.add(memory)
|
||||
|
||||
# Create history entry
|
||||
history = MemoryStatusHistory(
|
||||
memory_id=memory_id,
|
||||
changed_by=user.id,
|
||||
old_state=MemoryState.deleted if existing_memory else MemoryState.deleted,
|
||||
new_state=MemoryState.active
|
||||
)
|
||||
db.add(history)
|
||||
|
||||
db.commit()
|
||||
db.refresh(memory)
|
||||
return memory
|
||||
|
||||
# Fallback to traditional DB-only approach if Qdrant integration fails
|
||||
# Generate a random UUID for the memory
|
||||
memory_id = uuid4()
|
||||
memory = Memory(
|
||||
id=memory_id,
|
||||
user_id=user.id,
|
||||
app_id=app_obj.id,
|
||||
content=request.text,
|
||||
metadata_=request.metadata
|
||||
)
|
||||
db.add(memory)
|
||||
|
||||
# Create history entry
|
||||
history = MemoryStatusHistory(
|
||||
memory_id=memory_id,
|
||||
changed_by=user.id,
|
||||
old_state=MemoryState.deleted,
|
||||
new_state=MemoryState.active
|
||||
)
|
||||
db.add(history)
|
||||
|
||||
db.commit()
|
||||
db.refresh(memory)
|
||||
|
||||
# Attempt to add to Qdrant with the same ID we just created
|
||||
try:
|
||||
# Try to add with our specific ID
|
||||
memory_client.add(
|
||||
request.text,
|
||||
memory_id=str(memory_id), # Specify the ID
|
||||
user_id=request.user_id,
|
||||
metadata={
|
||||
"source_app": "openmemory",
|
||||
"mcp_client": request.app,
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to add to Qdrant in fallback path: {e}")
|
||||
# Continue anyway, the DB record is created
|
||||
|
||||
return memory
|
||||
|
||||
|
||||
# Get memory by ID
|
||||
@router.get("/{memory_id}")
|
||||
async def get_memory(
|
||||
memory_id: UUID,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
memory = get_memory_or_404(db, memory_id)
|
||||
return {
|
||||
"id": memory.id,
|
||||
"text": memory.content,
|
||||
"created_at": int(memory.created_at.timestamp()),
|
||||
"state": memory.state.value,
|
||||
"app_id": memory.app_id,
|
||||
"app_name": memory.app.name if memory.app else None,
|
||||
"categories": [category.name for category in memory.categories],
|
||||
"metadata_": memory.metadata_
|
||||
}
|
||||
|
||||
|
||||
class DeleteMemoriesRequest(BaseModel):
|
||||
memory_ids: List[UUID]
|
||||
user_id: str
|
||||
|
||||
# Delete multiple memories
|
||||
@router.delete("/")
|
||||
async def delete_memories(
|
||||
request: DeleteMemoriesRequest,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
user = db.query(User).filter(User.user_id == request.user_id).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
for memory_id in request.memory_ids:
|
||||
update_memory_state(db, memory_id, MemoryState.deleted, user.id)
|
||||
return {"message": f"Successfully deleted {len(request.memory_ids)} memories"}
|
||||
|
||||
|
||||
# Archive memories
|
||||
@router.post("/actions/archive")
|
||||
async def archive_memories(
|
||||
memory_ids: List[UUID],
|
||||
user_id: UUID,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
for memory_id in memory_ids:
|
||||
update_memory_state(db, memory_id, MemoryState.archived, user_id)
|
||||
return {"message": f"Successfully archived {len(memory_ids)} memories"}
|
||||
|
||||
|
||||
class PauseMemoriesRequest(BaseModel):
|
||||
memory_ids: Optional[List[UUID]] = None
|
||||
category_ids: Optional[List[UUID]] = None
|
||||
app_id: Optional[UUID] = None
|
||||
all_for_app: bool = False
|
||||
global_pause: bool = False
|
||||
state: Optional[MemoryState] = None
|
||||
user_id: str
|
||||
|
||||
# Pause access to memories
|
||||
@router.post("/actions/pause")
|
||||
async def pause_memories(
|
||||
request: PauseMemoriesRequest,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
|
||||
global_pause = request.global_pause
|
||||
all_for_app = request.all_for_app
|
||||
app_id = request.app_id
|
||||
memory_ids = request.memory_ids
|
||||
category_ids = request.category_ids
|
||||
state = request.state or MemoryState.paused
|
||||
|
||||
user = db.query(User).filter(User.user_id == request.user_id).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
user_id = user.id
|
||||
|
||||
if global_pause:
|
||||
# Pause all memories
|
||||
memories = db.query(Memory).filter(
|
||||
Memory.state != MemoryState.deleted,
|
||||
Memory.state != MemoryState.archived
|
||||
).all()
|
||||
for memory in memories:
|
||||
update_memory_state(db, memory.id, state, user_id)
|
||||
return {"message": "Successfully paused all memories"}
|
||||
|
||||
if app_id:
|
||||
# Pause all memories for an app
|
||||
memories = db.query(Memory).filter(
|
||||
Memory.app_id == app_id,
|
||||
Memory.user_id == user.id,
|
||||
Memory.state != MemoryState.deleted,
|
||||
Memory.state != MemoryState.archived
|
||||
).all()
|
||||
for memory in memories:
|
||||
update_memory_state(db, memory.id, state, user_id)
|
||||
return {"message": f"Successfully paused all memories for app {app_id}"}
|
||||
|
||||
if all_for_app and memory_ids:
|
||||
# Pause all memories for an app
|
||||
memories = db.query(Memory).filter(
|
||||
Memory.user_id == user.id,
|
||||
Memory.state != MemoryState.deleted,
|
||||
Memory.id.in_(memory_ids)
|
||||
).all()
|
||||
for memory in memories:
|
||||
update_memory_state(db, memory.id, state, user_id)
|
||||
return {"message": f"Successfully paused all memories"}
|
||||
|
||||
if memory_ids:
|
||||
# Pause specific memories
|
||||
for memory_id in memory_ids:
|
||||
update_memory_state(db, memory_id, state, user_id)
|
||||
return {"message": f"Successfully paused {len(memory_ids)} memories"}
|
||||
|
||||
if category_ids:
|
||||
# Pause memories by category
|
||||
memories = db.query(Memory).join(Memory.categories).filter(
|
||||
Category.id.in_(category_ids),
|
||||
Memory.state != MemoryState.deleted,
|
||||
Memory.state != MemoryState.archived
|
||||
).all()
|
||||
for memory in memories:
|
||||
update_memory_state(db, memory.id, state, user_id)
|
||||
return {"message": f"Successfully paused memories in {len(category_ids)} categories"}
|
||||
|
||||
raise HTTPException(status_code=400, detail="Invalid pause request parameters")
|
||||
|
||||
|
||||
# Get memory access logs
|
||||
@router.get("/{memory_id}/access-log")
|
||||
async def get_memory_access_log(
|
||||
memory_id: UUID,
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(10, ge=1, le=100),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
query = db.query(MemoryAccessLog).filter(MemoryAccessLog.memory_id == memory_id)
|
||||
total = query.count()
|
||||
logs = query.order_by(MemoryAccessLog.accessed_at.desc()).offset((page - 1) * page_size).limit(page_size).all()
|
||||
|
||||
# Get app name
|
||||
for log in logs:
|
||||
app = db.query(App).filter(App.id == log.app_id).first()
|
||||
log.app_name = app.name if app else None
|
||||
|
||||
return {
|
||||
"total": total,
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
"logs": logs
|
||||
}
|
||||
|
||||
|
||||
class UpdateMemoryRequest(BaseModel):
|
||||
memory_content: str
|
||||
user_id: str
|
||||
|
||||
# Update a memory
|
||||
@router.put("/{memory_id}")
|
||||
async def update_memory(
|
||||
memory_id: UUID,
|
||||
request: UpdateMemoryRequest,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
user = db.query(User).filter(User.user_id == request.user_id).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
memory = get_memory_or_404(db, memory_id)
|
||||
memory.content = request.memory_content
|
||||
db.commit()
|
||||
db.refresh(memory)
|
||||
return memory
|
||||
|
||||
class FilterMemoriesRequest(BaseModel):
|
||||
user_id: str
|
||||
page: int = 1
|
||||
size: int = 10
|
||||
search_query: Optional[str] = None
|
||||
app_ids: Optional[List[UUID]] = None
|
||||
category_ids: Optional[List[UUID]] = None
|
||||
sort_column: Optional[str] = None
|
||||
sort_direction: Optional[str] = None
|
||||
from_date: Optional[int] = None
|
||||
to_date: Optional[int] = None
|
||||
show_archived: Optional[bool] = False
|
||||
|
||||
@router.post("/filter", response_model=Page[MemoryResponse])
|
||||
async def filter_memories(
|
||||
request: FilterMemoriesRequest,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
user = db.query(User).filter(User.user_id == request.user_id).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
# Build base query
|
||||
query = db.query(Memory).filter(
|
||||
Memory.user_id == user.id,
|
||||
Memory.state != MemoryState.deleted,
|
||||
)
|
||||
|
||||
# Filter archived memories based on show_archived parameter
|
||||
if not request.show_archived:
|
||||
query = query.filter(Memory.state != MemoryState.archived)
|
||||
|
||||
# Apply search filter
|
||||
if request.search_query:
|
||||
query = query.filter(Memory.content.ilike(f"%{request.search_query}%"))
|
||||
|
||||
# Apply app filter
|
||||
if request.app_ids:
|
||||
query = query.filter(Memory.app_id.in_(request.app_ids))
|
||||
|
||||
# Add joins for app and categories
|
||||
query = query.outerjoin(App, Memory.app_id == App.id)
|
||||
|
||||
# Apply category filter
|
||||
if request.category_ids:
|
||||
query = query.join(Memory.categories).filter(Category.id.in_(request.category_ids))
|
||||
else:
|
||||
query = query.outerjoin(Memory.categories)
|
||||
|
||||
# Apply date filters
|
||||
if request.from_date:
|
||||
from_datetime = datetime.fromtimestamp(request.from_date, tz=UTC)
|
||||
query = query.filter(Memory.created_at >= from_datetime)
|
||||
|
||||
if request.to_date:
|
||||
to_datetime = datetime.fromtimestamp(request.to_date, tz=UTC)
|
||||
query = query.filter(Memory.created_at <= to_datetime)
|
||||
|
||||
# Apply sorting
|
||||
if request.sort_column and request.sort_direction:
|
||||
sort_direction = request.sort_direction.lower()
|
||||
if sort_direction not in ['asc', 'desc']:
|
||||
raise HTTPException(status_code=400, detail="Invalid sort direction")
|
||||
|
||||
sort_mapping = {
|
||||
'memory': Memory.content,
|
||||
'app_name': App.name,
|
||||
'created_at': Memory.created_at
|
||||
}
|
||||
|
||||
if request.sort_column not in sort_mapping:
|
||||
raise HTTPException(status_code=400, detail="Invalid sort column")
|
||||
|
||||
sort_field = sort_mapping[request.sort_column]
|
||||
if sort_direction == 'desc':
|
||||
query = query.order_by(sort_field.desc())
|
||||
else:
|
||||
query = query.order_by(sort_field.asc())
|
||||
else:
|
||||
# Default sorting
|
||||
query = query.order_by(Memory.created_at.desc())
|
||||
|
||||
# Add eager loading for categories and make the query distinct
|
||||
query = query.options(
|
||||
joinedload(Memory.categories)
|
||||
).distinct(Memory.id)
|
||||
|
||||
# Use fastapi-pagination's paginate function
|
||||
return sqlalchemy_paginate(
|
||||
query,
|
||||
Params(page=request.page, size=request.size),
|
||||
transformer=lambda items: [
|
||||
MemoryResponse(
|
||||
id=memory.id,
|
||||
content=memory.content,
|
||||
created_at=memory.created_at,
|
||||
state=memory.state.value,
|
||||
app_id=memory.app_id,
|
||||
app_name=memory.app.name if memory.app else None,
|
||||
categories=[category.name for category in memory.categories],
|
||||
metadata_=memory.metadata_
|
||||
)
|
||||
for memory in items
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{memory_id}/related", response_model=Page[MemoryResponse])
|
||||
async def get_related_memories(
|
||||
memory_id: UUID,
|
||||
user_id: str,
|
||||
params: Params = Depends(),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
# Validate user
|
||||
user = db.query(User).filter(User.user_id == user_id).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
# Get the source memory
|
||||
memory = get_memory_or_404(db, memory_id)
|
||||
|
||||
# Extract category IDs from the source memory
|
||||
category_ids = [category.id for category in memory.categories]
|
||||
|
||||
if not category_ids:
|
||||
return Page.create([], total=0, params=params)
|
||||
|
||||
# Build query for related memories
|
||||
query = db.query(Memory).distinct(Memory.id).filter(
|
||||
Memory.user_id == user.id,
|
||||
Memory.id != memory_id,
|
||||
Memory.state != MemoryState.deleted
|
||||
).join(Memory.categories).filter(
|
||||
Category.id.in_(category_ids)
|
||||
).options(
|
||||
joinedload(Memory.categories),
|
||||
joinedload(Memory.app)
|
||||
).order_by(
|
||||
func.count(Category.id).desc(),
|
||||
Memory.created_at.desc()
|
||||
).group_by(Memory.id)
|
||||
|
||||
# ⚡ Force page size to be 5
|
||||
params = Params(page=params.page, size=5)
|
||||
|
||||
return sqlalchemy_paginate(
|
||||
query,
|
||||
params,
|
||||
transformer=lambda items: [
|
||||
MemoryResponse(
|
||||
id=memory.id,
|
||||
content=memory.content,
|
||||
created_at=memory.created_at,
|
||||
state=memory.state.value,
|
||||
app_id=memory.app_id,
|
||||
app_name=memory.app.name if memory.app else None,
|
||||
categories=[category.name for category in memory.categories],
|
||||
metadata_=memory.metadata_
|
||||
)
|
||||
for memory in items
|
||||
]
|
||||
)
|
||||
@@ -0,0 +1,30 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
from app.database import get_db
|
||||
from app.models import User, Memory, App, MemoryState
|
||||
|
||||
|
||||
router = APIRouter(prefix="/api/v1/stats", tags=["stats"])
|
||||
|
||||
@router.get("/")
|
||||
async def get_profile(
|
||||
user_id: str,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
user = db.query(User).filter(User.user_id == user_id).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
# Get total number of memories
|
||||
total_memories = db.query(Memory).filter(Memory.user_id == user.id, Memory.state != MemoryState.deleted).count()
|
||||
|
||||
# Get total number of apps
|
||||
apps = db.query(App).filter(App.owner == user)
|
||||
total_apps = apps.count()
|
||||
|
||||
return {
|
||||
"total_memories": total_memories,
|
||||
"total_apps": total_apps,
|
||||
"apps": apps.all()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
from datetime import datetime
|
||||
from typing import Optional, List
|
||||
from uuid import UUID
|
||||
from pydantic import BaseModel, Field, validator
|
||||
|
||||
class MemoryBase(BaseModel):
|
||||
content: str
|
||||
metadata_: Optional[dict] = Field(default_factory=dict)
|
||||
|
||||
class MemoryCreate(MemoryBase):
|
||||
user_id: UUID
|
||||
app_id: UUID
|
||||
|
||||
|
||||
class Category(BaseModel):
|
||||
name: str
|
||||
|
||||
|
||||
class App(BaseModel):
|
||||
id: UUID
|
||||
name: str
|
||||
|
||||
|
||||
class Memory(MemoryBase):
|
||||
id: UUID
|
||||
user_id: UUID
|
||||
app_id: UUID
|
||||
created_at: datetime
|
||||
updated_at: Optional[datetime] = None
|
||||
state: str
|
||||
categories: Optional[List[Category]] = None
|
||||
app: App
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
class MemoryUpdate(BaseModel):
|
||||
content: Optional[str] = None
|
||||
metadata_: Optional[dict] = None
|
||||
state: Optional[str] = None
|
||||
|
||||
|
||||
class MemoryResponse(BaseModel):
|
||||
id: UUID
|
||||
content: str
|
||||
created_at: int
|
||||
state: str
|
||||
app_id: UUID
|
||||
app_name: str
|
||||
categories: List[str]
|
||||
metadata_: Optional[dict] = None
|
||||
|
||||
@validator('created_at', pre=True)
|
||||
def convert_to_epoch(cls, v):
|
||||
if isinstance(v, datetime):
|
||||
return int(v.timestamp())
|
||||
return v
|
||||
|
||||
class PaginatedMemoryResponse(BaseModel):
|
||||
items: List[MemoryResponse]
|
||||
total: int
|
||||
page: int
|
||||
size: int
|
||||
pages: int
|
||||
@@ -0,0 +1,37 @@
|
||||
import json
|
||||
import logging
|
||||
|
||||
from openai import OpenAI
|
||||
from typing import List
|
||||
from dotenv import load_dotenv
|
||||
from pydantic import BaseModel
|
||||
from tenacity import retry, stop_after_attempt, wait_exponential
|
||||
from app.utils.prompts import MEMORY_CATEGORIZATION_PROMPT
|
||||
|
||||
load_dotenv()
|
||||
|
||||
openai_client = OpenAI()
|
||||
|
||||
|
||||
class MemoryCategories(BaseModel):
|
||||
categories: List[str]
|
||||
|
||||
|
||||
@retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=4, max=15))
|
||||
def get_categories_for_memory(memory: str) -> List[str]:
|
||||
"""Get categories for a memory."""
|
||||
try:
|
||||
response = openai_client.responses.parse(
|
||||
model="gpt-4o-mini",
|
||||
instructions=MEMORY_CATEGORIZATION_PROMPT,
|
||||
input=memory,
|
||||
temperature=0,
|
||||
text_format=MemoryCategories,
|
||||
)
|
||||
response_json =json.loads(response.output[0].content[0].text)
|
||||
categories = response_json['categories']
|
||||
categories = [cat.strip().lower() for cat in categories]
|
||||
# TODO: Validate categories later may be
|
||||
return categories
|
||||
except Exception as e:
|
||||
raise e
|
||||
@@ -0,0 +1,32 @@
|
||||
from sqlalchemy.orm import Session
|
||||
from app.models import User, App
|
||||
from typing import Tuple
|
||||
|
||||
|
||||
def get_or_create_user(db: Session, user_id: str) -> User:
|
||||
"""Get or create a user with the given user_id"""
|
||||
user = db.query(User).filter(User.user_id == user_id).first()
|
||||
if not user:
|
||||
user = User(user_id=user_id)
|
||||
db.add(user)
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
return user
|
||||
|
||||
|
||||
def get_or_create_app(db: Session, user: User, app_id: str) -> App:
|
||||
"""Get or create an app for the given user"""
|
||||
app = db.query(App).filter(App.owner_id == user.id, App.name == app_id).first()
|
||||
if not app:
|
||||
app = App(owner_id=user.id, name=app_id)
|
||||
db.add(app)
|
||||
db.commit()
|
||||
db.refresh(app)
|
||||
return app
|
||||
|
||||
|
||||
def get_user_and_app(db: Session, user_id: str, app_id: str) -> Tuple[User, App]:
|
||||
"""Get or create both user and their app"""
|
||||
user = get_or_create_user(db, user_id)
|
||||
app = get_or_create_app(db, user, app_id)
|
||||
return user, app
|
||||
@@ -0,0 +1,51 @@
|
||||
import os
|
||||
|
||||
from mem0 import Memory
|
||||
|
||||
|
||||
memory_client = None
|
||||
|
||||
|
||||
def get_memory_client(custom_instructions: str = None):
|
||||
"""
|
||||
Get or initialize the Mem0 client.
|
||||
|
||||
Args:
|
||||
custom_instructions: Optional instructions for the memory project.
|
||||
|
||||
Returns:
|
||||
Initialized Mem0 client instance.
|
||||
|
||||
Raises:
|
||||
Exception: If required API keys are not set.
|
||||
"""
|
||||
global memory_client
|
||||
|
||||
if memory_client is not None:
|
||||
return memory_client
|
||||
|
||||
try:
|
||||
config = {
|
||||
"vector_store": {
|
||||
"provider": "qdrant",
|
||||
"config": {
|
||||
"collection_name": "openmemory",
|
||||
"host": "mem0_store",
|
||||
"port": 6333,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
memory_client = Memory.from_config(config_dict=config)
|
||||
except Exception:
|
||||
raise Exception("Exception occurred while initializing memory client")
|
||||
|
||||
# Update project with custom instructions if provided
|
||||
if custom_instructions:
|
||||
memory_client.update_project(custom_instructions=custom_instructions)
|
||||
|
||||
return memory_client
|
||||
|
||||
|
||||
def get_default_user_id():
|
||||
return "default_user"
|
||||
@@ -0,0 +1,52 @@
|
||||
from typing import Optional
|
||||
from uuid import UUID
|
||||
from sqlalchemy.orm import Session
|
||||
from app.models import Memory, App, MemoryState
|
||||
|
||||
|
||||
def check_memory_access_permissions(
|
||||
db: Session,
|
||||
memory: Memory,
|
||||
app_id: Optional[UUID] = None
|
||||
) -> bool:
|
||||
"""
|
||||
Check if the given app has permission to access a memory based on:
|
||||
1. Memory state (must be active)
|
||||
2. App state (must not be paused)
|
||||
3. App-specific access controls
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
memory: Memory object to check access for
|
||||
app_id: Optional app ID to check permissions for
|
||||
|
||||
Returns:
|
||||
bool: True if access is allowed, False otherwise
|
||||
"""
|
||||
# Check if memory is active
|
||||
if memory.state != MemoryState.active:
|
||||
return False
|
||||
|
||||
# If no app_id provided, only check memory state
|
||||
if not app_id:
|
||||
return True
|
||||
|
||||
# Check if app exists and is active
|
||||
app = db.query(App).filter(App.id == app_id).first()
|
||||
if not app:
|
||||
return False
|
||||
|
||||
# Check if app is paused/inactive
|
||||
if not app.is_active:
|
||||
return False
|
||||
|
||||
# Check app-specific access controls
|
||||
from app.routers.memories import get_accessible_memory_ids
|
||||
accessible_memory_ids = get_accessible_memory_ids(db, app_id)
|
||||
|
||||
# If accessible_memory_ids is None, all memories are accessible
|
||||
if accessible_memory_ids is None:
|
||||
return True
|
||||
|
||||
# Check if memory is in the accessible set
|
||||
return memory.id in accessible_memory_ids
|
||||
@@ -0,0 +1,28 @@
|
||||
MEMORY_CATEGORIZATION_PROMPT = """Your task is to assign each piece of information (or “memory”) to one or more of the following categories. Feel free to use multiple categories per item when appropriate.
|
||||
|
||||
- Personal: family, friends, home, hobbies, lifestyle
|
||||
- Relationships: social network, significant others, colleagues
|
||||
- Preferences: likes, dislikes, habits, favorite media
|
||||
- Health: physical fitness, mental health, diet, sleep
|
||||
- Travel: trips, commutes, favorite places, itineraries
|
||||
- Work: job roles, companies, projects, promotions
|
||||
- Education: courses, degrees, certifications, skills development
|
||||
- Projects: to‑dos, milestones, deadlines, status updates
|
||||
- AI, ML & Technology: infrastructure, algorithms, tools, research
|
||||
- Technical Support: bug reports, error logs, fixes
|
||||
- Finance: income, expenses, investments, billing
|
||||
- Shopping: purchases, wishlists, returns, deliveries
|
||||
- Legal: contracts, policies, regulations, privacy
|
||||
- Entertainment: movies, music, games, books, events
|
||||
- Messages: emails, SMS, alerts, reminders
|
||||
- Customer Support: tickets, inquiries, resolutions
|
||||
- Product Feedback: ratings, bug reports, feature requests
|
||||
- News: articles, headlines, trending topics
|
||||
- Organization: meetings, appointments, calendars
|
||||
- Goals: ambitions, KPIs, long‑term objectives
|
||||
|
||||
Guidelines:
|
||||
- Return only the categories under 'categories' key in the JSON format.
|
||||
- If you cannot categorize the memory, return an empty list with key 'categories'.
|
||||
- Don't limit yourself to the categories listed above only. Feel free to create new categories based on the memory. Make sure that it is a single phrase.
|
||||
"""
|
||||
@@ -0,0 +1,86 @@
|
||||
import datetime
|
||||
from fastapi import FastAPI
|
||||
from app.database import engine, Base, SessionLocal
|
||||
from app.mcp_server import setup_mcp_server
|
||||
from app.routers import memories_router, apps_router, stats_router
|
||||
from fastapi_pagination import add_pagination
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from app.models import User, App
|
||||
from uuid import uuid4
|
||||
from app.config import USER_ID, DEFAULT_APP_ID
|
||||
|
||||
app = FastAPI(title="OpenMemory API")
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# Create all tables
|
||||
Base.metadata.create_all(bind=engine)
|
||||
|
||||
# Check for USER_ID and create default user if needed
|
||||
def create_default_user():
|
||||
db = SessionLocal()
|
||||
try:
|
||||
# Check if user exists
|
||||
user = db.query(User).filter(User.user_id == USER_ID).first()
|
||||
if not user:
|
||||
# Create default user
|
||||
user = User(
|
||||
id=uuid4(),
|
||||
user_id=USER_ID,
|
||||
name="Default User",
|
||||
created_at=datetime.datetime.now(datetime.UTC)
|
||||
)
|
||||
db.add(user)
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def create_default_app():
|
||||
db = SessionLocal()
|
||||
try:
|
||||
user = db.query(User).filter(User.user_id == USER_ID).first()
|
||||
if not user:
|
||||
return
|
||||
|
||||
# Check if app already exists
|
||||
existing_app = db.query(App).filter(
|
||||
App.name == DEFAULT_APP_ID,
|
||||
App.owner_id == user.id
|
||||
).first()
|
||||
|
||||
if existing_app:
|
||||
return
|
||||
|
||||
app = App(
|
||||
id=uuid4(),
|
||||
name=DEFAULT_APP_ID,
|
||||
owner_id=user.id,
|
||||
created_at=datetime.datetime.now(datetime.UTC),
|
||||
updated_at=datetime.datetime.now(datetime.UTC),
|
||||
)
|
||||
db.add(app)
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
# Create default user on startup
|
||||
create_default_user()
|
||||
create_default_app()
|
||||
|
||||
# Setup MCP server
|
||||
setup_mcp_server(app)
|
||||
|
||||
# Include routers
|
||||
app.include_router(memories_router)
|
||||
app.include_router(apps_router)
|
||||
app.include_router(stats_router)
|
||||
|
||||
# Add pagination support
|
||||
add_pagination(app)
|
||||
@@ -0,0 +1,15 @@
|
||||
fastapi>=0.68.0
|
||||
uvicorn>=0.15.0
|
||||
sqlalchemy>=1.4.0
|
||||
python-dotenv>=0.19.0
|
||||
alembic>=1.7.0
|
||||
psycopg2-binary>=2.9.0
|
||||
python-multipart>=0.0.5
|
||||
fastapi-pagination>=0.12.0
|
||||
mem0ai>=0.1.92
|
||||
mcp[cli]>=1.3.0
|
||||
pytest>=7.0.0
|
||||
pytest-asyncio>=0.21.0
|
||||
httpx>=0.24.0
|
||||
pytest-cov>=4.0.0
|
||||
tenacity==9.1.2
|
||||
@@ -0,0 +1,35 @@
|
||||
services:
|
||||
mem0_store:
|
||||
image: qdrant/qdrant
|
||||
ports:
|
||||
- "6333:6333"
|
||||
volumes:
|
||||
- mem0_storage:/mem0/storage
|
||||
openmemory-mcp:
|
||||
image: mem0/openmemory-mcp
|
||||
build: api/
|
||||
environment:
|
||||
- USER
|
||||
env_file:
|
||||
- api/.env
|
||||
depends_on:
|
||||
- mem0_store
|
||||
ports:
|
||||
- "8765:8765"
|
||||
volumes:
|
||||
- ./api:/usr/src/openmemory
|
||||
command: >
|
||||
sh -c "uvicorn main:app --host 0.0.0.0 --port 8765 --reload --workers 4"
|
||||
openmemory-ui:
|
||||
build:
|
||||
context: ui/
|
||||
dockerfile: Dockerfile
|
||||
image: mem0/openmemory-ui:latest
|
||||
ports:
|
||||
- "3000:3000"
|
||||
environment:
|
||||
- NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL}
|
||||
- NEXT_PUBLIC_USER_ID=${USER}
|
||||
|
||||
volumes:
|
||||
mem0_storage:
|
||||
Executable
+111
@@ -0,0 +1,111 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -e
|
||||
|
||||
echo "🚀 Starting OpenMemory installation..."
|
||||
|
||||
# Set environment variables
|
||||
OPENAI_API_KEY="${OPENAI_API_KEY:-}"
|
||||
USER="${USER:-$(whoami)}"
|
||||
NEXT_PUBLIC_API_URL="${NEXT_PUBLIC_API_URL:-http://localhost:8765}"
|
||||
|
||||
if [ -z "$OPENAI_API_KEY" ]; then
|
||||
echo "❌ OPENAI_API_KEY not set. Please run with: curl -sL https://raw.githubusercontent.com/mem0ai/mem0/main/openmemory/run.sh | OPENAI_API_KEY=your_api_key bash"
|
||||
echo "❌ OPENAI_API_KEY not set. You can also set it as global environment variable: export OPENAI_API_KEY=your_api_key"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check if Docker is installed
|
||||
if ! command -v docker &> /dev/null; then
|
||||
echo "❌ Docker not found. Please install Docker first."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check if docker compose is available
|
||||
if ! docker compose version &> /dev/null; then
|
||||
echo "❌ Docker Compose not found. Please install Docker Compose V2."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check if the container "mem0_ui" already exists and remove it if necessary
|
||||
if [ $(docker ps -aq -f name=mem0_ui) ]; then
|
||||
echo "⚠️ Found existing container 'mem0_ui'. Removing it..."
|
||||
docker rm -f mem0_ui
|
||||
fi
|
||||
|
||||
# Find an available port starting from 3000
|
||||
echo "🔍 Looking for available port for frontend..."
|
||||
for port in {3000..3010}; do
|
||||
if ! lsof -i:$port >/dev/null 2>&1; then
|
||||
FRONTEND_PORT=$port
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
if [ -z "$FRONTEND_PORT" ]; then
|
||||
echo "❌ Could not find an available port between 3000 and 3010"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Export required variables for Compose and frontend
|
||||
export OPENAI_API_KEY
|
||||
export USER
|
||||
export NEXT_PUBLIC_API_URL
|
||||
export NEXT_PUBLIC_USER_ID="$USER"
|
||||
export FRONTEND_PORT
|
||||
|
||||
# Create docker-compose.yml file
|
||||
echo "📝 Creating docker-compose.yml..."
|
||||
cat > docker-compose.yml <<EOF
|
||||
services:
|
||||
mem0_store:
|
||||
image: qdrant/qdrant
|
||||
ports:
|
||||
- "6333:6333"
|
||||
volumes:
|
||||
- mem0_storage:/mem0/storage
|
||||
openmemory-mcp:
|
||||
image: mem0/openmemory-mcp:latest
|
||||
environment:
|
||||
- OPENAI_API_KEY=${OPENAI_API_KEY}
|
||||
- USER=${USER}
|
||||
- QDRANT_HOST=mem0_store
|
||||
- QDRANT_PORT=6333
|
||||
depends_on:
|
||||
- mem0_store
|
||||
ports:
|
||||
- "8765:8765"
|
||||
|
||||
volumes:
|
||||
mem0_storage:
|
||||
EOF
|
||||
|
||||
# Start services
|
||||
echo "🚀 Starting backend services..."
|
||||
docker compose up -d
|
||||
|
||||
# Start the frontend
|
||||
echo "🚀 Starting frontend on port $FRONTEND_PORT..."
|
||||
docker run -d \
|
||||
--name mem0_ui \
|
||||
-p ${FRONTEND_PORT}:3000 \
|
||||
-e NEXT_PUBLIC_API_URL="$NEXT_PUBLIC_API_URL" \
|
||||
-e NEXT_PUBLIC_USER_ID="$USER" \
|
||||
mem0/openmemory-ui:latest
|
||||
|
||||
echo "✅ Backend: http://localhost:8765"
|
||||
echo "✅ Frontend: http://localhost:$FRONTEND_PORT"
|
||||
|
||||
# Open the frontend URL in the default web browser
|
||||
echo "🌐 Opening frontend in the default browser..."
|
||||
URL="http://localhost:$FRONTEND_PORT"
|
||||
|
||||
if command -v xdg-open > /dev/null; then
|
||||
xdg-open "$URL" # Linux
|
||||
elif command -v open > /dev/null; then
|
||||
open "$URL" # macOS
|
||||
elif command -v start > /dev/null; then
|
||||
start "$URL" # Windows (if run via Git Bash or similar)
|
||||
else
|
||||
echo "⚠️ Could not detect a method to open the browser. Please open $URL manually."
|
||||
fi
|
||||
@@ -0,0 +1,23 @@
|
||||
# Ignore all .env files
|
||||
**/.env
|
||||
|
||||
|
||||
# Ignore all database files
|
||||
**/*.db
|
||||
**/*.sqlite
|
||||
**/*.sqlite3
|
||||
|
||||
# Ignore logs
|
||||
**/*.log
|
||||
|
||||
# Ignore runtime data
|
||||
**/node_modules
|
||||
**/__pycache__
|
||||
**/.pytest_cache
|
||||
**/.coverage
|
||||
**/coverage
|
||||
|
||||
# Ignore Docker runtime files
|
||||
**/.dockerignore
|
||||
**/Dockerfile
|
||||
**/docker-compose*.yml
|
||||
@@ -0,0 +1,2 @@
|
||||
NEXT_PUBLIC_API_URL=NEXT_PUBLIC_API_URL
|
||||
NEXT_PUBLIC_USER_ID=NEXT_PUBLIC_USER_ID
|
||||
@@ -0,0 +1,52 @@
|
||||
# syntax=docker.io/docker/dockerfile:1
|
||||
|
||||
# Base stage for common setup
|
||||
FROM node:18-alpine AS base
|
||||
|
||||
# Install dependencies for pnpm
|
||||
RUN apk add --no-cache libc6-compat curl && \
|
||||
corepack enable && \
|
||||
corepack prepare pnpm@latest --activate
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
FROM base AS deps
|
||||
|
||||
COPY package.json pnpm-lock.yaml ./
|
||||
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
FROM base AS builder
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY --from=deps /app/pnpm-lock.yaml ./pnpm-lock.yaml
|
||||
COPY . .
|
||||
|
||||
RUN cp next.config.dev.mjs next.config.mjs
|
||||
RUN cp .env.example .env
|
||||
RUN pnpm build
|
||||
|
||||
FROM base AS runner
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV=production
|
||||
|
||||
RUN addgroup --system --gid 1001 nodejs && \
|
||||
adduser --system --uid 1001 nextjs
|
||||
|
||||
COPY --from=builder /app/public ./public
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||
|
||||
COPY --chown=nextjs:nodejs entrypoint.sh /home/nextjs/entrypoint.sh
|
||||
RUN chmod +x /home/nextjs/entrypoint.sh
|
||||
|
||||
USER nextjs
|
||||
|
||||
EXPOSE 3000
|
||||
ENV PORT=3000
|
||||
ENV HOSTNAME="0.0.0.0"
|
||||
|
||||
ENTRYPOINT ["/home/nextjs/entrypoint.sh"]
|
||||
CMD ["node", "server.js"]
|
||||
@@ -0,0 +1,166 @@
|
||||
import React, { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { PauseIcon, Loader2, PlayIcon } from "lucide-react";
|
||||
import { useAppsApi } from "@/hooks/useAppsApi";
|
||||
import Image from "next/image";
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
import { setAppDetails } from "@/store/appsSlice";
|
||||
import { BiEdit } from "react-icons/bi";
|
||||
import { constants } from "@/components/shared/source-app";
|
||||
import { RootState } from "@/store/store";
|
||||
|
||||
const capitalize = (str: string) => {
|
||||
return str.charAt(0).toUpperCase() + str.slice(1);
|
||||
};
|
||||
|
||||
const AppDetailCard = ({
|
||||
appId,
|
||||
selectedApp,
|
||||
}: {
|
||||
appId: string;
|
||||
selectedApp: any;
|
||||
}) => {
|
||||
const { updateAppDetails } = useAppsApi();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const dispatch = useDispatch();
|
||||
const apps = useSelector((state: RootState) => state.apps.apps);
|
||||
const currentApp = apps.find((app: any) => app.id === appId);
|
||||
const appConfig = currentApp
|
||||
? constants[currentApp.name as keyof typeof constants] || constants.default
|
||||
: constants.default;
|
||||
|
||||
const handlePauseAccess = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await updateAppDetails(appId, {
|
||||
is_active: !selectedApp.details.is_active,
|
||||
});
|
||||
dispatch(
|
||||
setAppDetails({ appId, isActive: !selectedApp.details.is_active })
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Failed to toggle app pause state:", error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const buttonText = selectedApp.details.is_active
|
||||
? "Pause Access"
|
||||
: "Unpause Access";
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="bg-zinc-900 border w-[320px] border-zinc-800 rounded-xl mb-6">
|
||||
<div className="flex items-center gap-2 mb-4 bg-zinc-800 rounded-t-xl p-3">
|
||||
<div className="w-5 h-5 flex items-center justify-center">
|
||||
{appConfig.iconImage ? (
|
||||
<div>
|
||||
<div className="w-6 h-6 rounded-full bg-zinc-700 flex items-center justify-center overflow-hidden">
|
||||
<Image
|
||||
src={appConfig.iconImage}
|
||||
alt={appConfig.name}
|
||||
width={40}
|
||||
height={40}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-5 h-5 flex items-center justify-center bg-zinc-700 rounded-full">
|
||||
<BiEdit className="w-4 h-4 text-zinc-400" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<h2 className="text-md font-semibold">{appConfig.name}</h2>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 p-3">
|
||||
<div>
|
||||
<p className="text-xs text-zinc-400">Access Status</p>
|
||||
<p
|
||||
className={`font-medium ${
|
||||
selectedApp.details.is_active
|
||||
? "text-emerald-500"
|
||||
: "text-red-500"
|
||||
}`}
|
||||
>
|
||||
{capitalize(
|
||||
selectedApp.details.is_active ? "active" : "inactive"
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-xs text-zinc-400">Total Memories Created</p>
|
||||
<p className="font-medium">
|
||||
{selectedApp.details.total_memories_created} Memories
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-xs text-zinc-400">Total Memories Accessed</p>
|
||||
<p className="font-medium">
|
||||
{selectedApp.details.total_memories_accessed} Memories
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-xs text-zinc-400">First Accessed</p>
|
||||
<p className="font-medium">
|
||||
{selectedApp.details.first_accessed
|
||||
? new Date(
|
||||
selectedApp.details.first_accessed
|
||||
).toLocaleDateString("en-US", {
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "numeric",
|
||||
})
|
||||
: "Never"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-xs text-zinc-400">Last Accessed</p>
|
||||
<p className="font-medium">
|
||||
{selectedApp.details.last_accessed
|
||||
? new Date(
|
||||
selectedApp.details.last_accessed
|
||||
).toLocaleDateString("en-US", {
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "numeric",
|
||||
})
|
||||
: "Never"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<hr className="border-zinc-800" />
|
||||
|
||||
<div className="flex gap-2 justify-end">
|
||||
<Button
|
||||
onClick={handlePauseAccess}
|
||||
className="flex bg-transparent w-[170px] bg-zinc-800 border-zinc-800 hover:bg-zinc-800 text-white"
|
||||
size="sm"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : buttonText === "Pause Access" ? (
|
||||
<PauseIcon className="h-4 w-4" />
|
||||
) : (
|
||||
<PlayIcon className="h-4 w-4" />
|
||||
)}
|
||||
{buttonText}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AppDetailCard;
|
||||
@@ -0,0 +1,115 @@
|
||||
import { ArrowRight } from "lucide-react";
|
||||
import Categories from "@/components/shared/categories";
|
||||
import Link from "next/link";
|
||||
import { constants } from "@/components/shared/source-app";
|
||||
import Image from "next/image";
|
||||
interface MemoryCardProps {
|
||||
id: string;
|
||||
content: string;
|
||||
created_at: string;
|
||||
metadata?: Record<string, any>;
|
||||
categories?: string[];
|
||||
access_count?: number;
|
||||
app_name: string;
|
||||
state: string;
|
||||
}
|
||||
|
||||
export function MemoryCard({
|
||||
id,
|
||||
content,
|
||||
created_at,
|
||||
metadata,
|
||||
categories,
|
||||
access_count,
|
||||
app_name,
|
||||
state,
|
||||
}: MemoryCardProps) {
|
||||
return (
|
||||
<div className="rounded-lg border border-zinc-800 bg-zinc-900 overflow-hidden">
|
||||
<div className="p-4">
|
||||
<div className="border-l-2 border-primary pl-4 mb-4">
|
||||
<p
|
||||
className={`${state !== "active" ? "text-zinc-400" : "text-white"}`}
|
||||
>
|
||||
{content}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{metadata && Object.keys(metadata).length > 0 && (
|
||||
<div className="mb-4">
|
||||
<p className="text-xs text-zinc-500 uppercase mb-2">METADATA</p>
|
||||
<div className="bg-zinc-800 rounded p-3 text-zinc-400">
|
||||
<pre className="whitespace-pre-wrap">
|
||||
{JSON.stringify(metadata, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mb-2">
|
||||
<Categories
|
||||
categories={categories as any}
|
||||
isPaused={state !== "active"}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-zinc-400 text-sm">
|
||||
{access_count ? (
|
||||
<span className="relative top-1">
|
||||
Accessed {access_count} times
|
||||
</span>
|
||||
) : (
|
||||
new Date(created_at + "Z").toLocaleDateString("en-US", {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "numeric",
|
||||
})
|
||||
)}
|
||||
</span>
|
||||
|
||||
{state !== "active" && (
|
||||
<span className="inline-block px-3 border border-yellow-600 text-yellow-600 font-semibold text-xs rounded-full bg-yellow-400/10 backdrop-blur-sm">
|
||||
{state === "paused" ? "Paused" : "Archived"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!app_name && (
|
||||
<Link
|
||||
href={`/memory/${id}`}
|
||||
className="hover:cursor-pointer bg-zinc-800 hover:bg-zinc-700 flex items-center px-3 py-1 text-sm rounded-lg text-white p-0 hover:text-white"
|
||||
>
|
||||
View Details
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</Link>
|
||||
)}
|
||||
{app_name && (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-1 bg-zinc-700 px-3 py-1 rounded-lg">
|
||||
<span className="text-sm text-zinc-400">Created by:</span>
|
||||
<div className="w-5 h-5 rounded-full bg-zinc-700 flex items-center justify-center overflow-hidden">
|
||||
<Image
|
||||
src={
|
||||
constants[app_name as keyof typeof constants]
|
||||
?.iconImage || ""
|
||||
}
|
||||
alt="OpenMemory"
|
||||
width={24}
|
||||
height={24}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-sm text-zinc-100 font-semibold">
|
||||
{constants[app_name as keyof typeof constants]?.name}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useSelector } from "react-redux";
|
||||
import { RootState } from "@/store/store";
|
||||
import { useAppsApi } from "@/hooks/useAppsApi";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { MemoryCard } from "./components/MemoryCard";
|
||||
import AppDetailCard from "./components/AppDetailCard";
|
||||
import "@/styles/animation.css";
|
||||
import NotFound from "@/app/not-found";
|
||||
import { AppDetailCardSkeleton } from "@/skeleton/AppDetailCardSkeleton";
|
||||
import { MemoryCardSkeleton } from "@/skeleton/MemoryCardSkeleton";
|
||||
|
||||
export default function AppDetailsPage() {
|
||||
const params = useParams();
|
||||
const appId = params.appId as string;
|
||||
const [activeTab, setActiveTab] = useState("created");
|
||||
|
||||
const {
|
||||
fetchAppDetails,
|
||||
fetchAppMemories,
|
||||
fetchAppAccessedMemories,
|
||||
fetchApps,
|
||||
} = useAppsApi();
|
||||
const selectedApp = useSelector((state: RootState) => state.apps.selectedApp);
|
||||
|
||||
useEffect(() => {
|
||||
fetchApps({});
|
||||
}, [fetchApps]);
|
||||
|
||||
useEffect(() => {
|
||||
const loadData = async () => {
|
||||
if (appId) {
|
||||
try {
|
||||
// Load all data in parallel
|
||||
await Promise.all([
|
||||
fetchAppDetails(appId),
|
||||
fetchAppMemories(appId),
|
||||
fetchAppAccessedMemories(appId),
|
||||
]);
|
||||
} catch (error) {
|
||||
console.error("Error loading app data:", error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
loadData();
|
||||
}, [appId, fetchAppDetails, fetchAppMemories, fetchAppAccessedMemories]);
|
||||
|
||||
if (selectedApp.error) {
|
||||
return (
|
||||
<NotFound message={selectedApp.error} title="Error loading app details" />
|
||||
);
|
||||
}
|
||||
|
||||
if (!selectedApp.details) {
|
||||
return (
|
||||
<div className="flex-1 py-6 text-white">
|
||||
<div className="container flex justify-between">
|
||||
<div className="flex-1 p-4 max-w-4xl animate-fade-slide-down">
|
||||
<div className="mb-6">
|
||||
<div className="h-10 w-64 bg-zinc-800 rounded animate-pulse mb-6" />
|
||||
<div className="space-y-6">
|
||||
{[...Array(3)].map((_, i) => (
|
||||
<MemoryCardSkeleton key={i} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-14 animate-fade-slide-down delay-2">
|
||||
<AppDetailCardSkeleton />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const renderCreatedMemories = () => {
|
||||
const memories = selectedApp.memories.created;
|
||||
|
||||
if (memories.loading) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{[...Array(3)].map((_, i) => (
|
||||
<MemoryCardSkeleton key={i} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (memories.error) {
|
||||
return (
|
||||
<NotFound message={memories.error} title="Error loading memories" />
|
||||
);
|
||||
}
|
||||
|
||||
if (memories.items.length === 0) {
|
||||
return (
|
||||
<div className="text-zinc-400 text-center py-8">No memories found</div>
|
||||
);
|
||||
}
|
||||
|
||||
return memories.items.map((memory) => (
|
||||
<MemoryCard
|
||||
key={memory.id + memory.created_at}
|
||||
id={memory.id}
|
||||
content={memory.content}
|
||||
created_at={memory.created_at}
|
||||
metadata={memory.metadata_}
|
||||
categories={memory.categories}
|
||||
app_name={memory.app_name}
|
||||
state={memory.state}
|
||||
/>
|
||||
));
|
||||
};
|
||||
|
||||
const renderAccessedMemories = () => {
|
||||
const memories = selectedApp.memories.accessed;
|
||||
|
||||
if (memories.loading) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{[...Array(3)].map((_, i) => (
|
||||
<MemoryCardSkeleton key={i} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (memories.error) {
|
||||
return (
|
||||
<div className="text-red-400 bg-red-400/10 p-4 rounded-lg">
|
||||
Error loading memories: {memories.error}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (memories.items.length === 0) {
|
||||
return (
|
||||
<div className="text-zinc-400 text-center py-8">
|
||||
No accessed memories found
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return memories.items.map((accessedMemory) => (
|
||||
<div
|
||||
key={accessedMemory.memory.id + accessedMemory.memory.created_at}
|
||||
className="relative"
|
||||
>
|
||||
<MemoryCard
|
||||
id={accessedMemory.memory.id}
|
||||
content={accessedMemory.memory.content}
|
||||
created_at={accessedMemory.memory.created_at}
|
||||
metadata={accessedMemory.memory.metadata_}
|
||||
categories={accessedMemory.memory.categories}
|
||||
access_count={accessedMemory.access_count}
|
||||
app_name={accessedMemory.memory.app_name}
|
||||
state={accessedMemory.memory.state}
|
||||
/>
|
||||
</div>
|
||||
));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex-1 py-6 text-white">
|
||||
<div className="container flex justify-between">
|
||||
{/* Main content area */}
|
||||
<div className="flex-1 p-4 max-w-4xl animate-fade-slide-down">
|
||||
<Tabs
|
||||
defaultValue="created"
|
||||
className="mb-6"
|
||||
onValueChange={setActiveTab}
|
||||
>
|
||||
<TabsList className="bg-transparent border-b border-zinc-800 rounded-none w-full justify-start gap-8 p-0">
|
||||
<TabsTrigger
|
||||
value="created"
|
||||
className={`px-0 pb-2 rounded-none data-[state=active]:border-b-2 data-[state=active]:border-primary data-[state=active]:shadow-none ${
|
||||
activeTab === "created" ? "text-white" : "text-zinc-400"
|
||||
}`}
|
||||
>
|
||||
Created ({selectedApp.memories.created.total})
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="accessed"
|
||||
className={`px-0 pb-2 rounded-none data-[state=active]:border-b-2 data-[state=active]:border-primary data-[state=active]:shadow-none ${
|
||||
activeTab === "accessed" ? "text-white" : "text-zinc-400"
|
||||
}`}
|
||||
>
|
||||
Accessed ({selectedApp.memories.accessed.total})
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent
|
||||
value="created"
|
||||
className="mt-6 space-y-6 animate-fade-slide-down delay-1"
|
||||
>
|
||||
{renderCreatedMemories()}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent
|
||||
value="accessed"
|
||||
className="mt-6 space-y-6 animate-fade-slide-down delay-1"
|
||||
>
|
||||
{renderAccessedMemories()}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
{/* Sidebar */}
|
||||
<div className="p-14 animate-fade-slide-down delay-2">
|
||||
<AppDetailCard appId={appId} selectedApp={selectedApp} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import type React from "react";
|
||||
import { ArrowRight } from "lucide-react";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
} from "@/components/ui/card";
|
||||
|
||||
import { constants } from "@/components/shared/source-app";
|
||||
import { App } from "@/store/appsSlice";
|
||||
import Image from "next/image";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
interface AppCardProps {
|
||||
app: App;
|
||||
}
|
||||
|
||||
export function AppCard({ app }: AppCardProps) {
|
||||
const router = useRouter();
|
||||
const appConfig =
|
||||
constants[app.name as keyof typeof constants] || constants.default;
|
||||
const isActive = app.is_active;
|
||||
|
||||
return (
|
||||
<Card className="bg-zinc-900 text-white border-zinc-800">
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="relative z-10 rounded-full overflow-hidden bg-[#2a2a2a] w-6 h-6 flex items-center justify-center flex-shrink-0">
|
||||
{appConfig.iconImage ? (
|
||||
<div className="w-6 h-6 rounded-full bg-zinc-700 flex items-center justify-center overflow-hidden">
|
||||
<Image
|
||||
src={appConfig.iconImage}
|
||||
alt={appConfig.name}
|
||||
width={28}
|
||||
height={28}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-6 h-6 flex items-center justify-center">
|
||||
{appConfig.icon}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<h2 className="text-xl font-semibold">{appConfig.name}</h2>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="pb-4 my-1">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<p className="text-zinc-400 text-sm mb-1">Memories Created</p>
|
||||
<p className="text-xl font-medium">
|
||||
{app.total_memories_created.toLocaleString()} Memories
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-zinc-400 text-sm mb-1">Memories Accessed</p>
|
||||
<p className="text-xl font-medium">
|
||||
{app.total_memories_accessed.toLocaleString()} Memories
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter className="border-t border-zinc-800 p-0 px-6 py-2 flex justify-between items-center">
|
||||
<div
|
||||
className={`${
|
||||
isActive
|
||||
? "bg-green-800 text-white hover:bg-green-500/20"
|
||||
: "bg-red-500/20 text-red-400 hover:bg-red-500/20"
|
||||
} rounded-lg px-2 py-0.5 flex items-center text-sm`}
|
||||
>
|
||||
<span className="h-2 w-2 my-auto mr-1 rounded-full inline-block bg-current"></span>
|
||||
{isActive ? "Active" : "Inactive"}
|
||||
</div>
|
||||
<div
|
||||
onClick={() => router.push(`/apps/${app.id}`)}
|
||||
className="border hover:cursor-pointer border-zinc-700 bg-zinc-950 flex items-center px-3 py-1 text-sm rounded-lg text-white p-0 hover:bg-zinc-950/50 hover:text-white"
|
||||
>
|
||||
View Details <ArrowRight className="ml-2 h-4 w-4" />
|
||||
</div>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
"use client";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Search, ChevronDown, SortAsc, SortDesc } from "lucide-react";
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
import {
|
||||
setSearchQuery,
|
||||
setActiveFilter,
|
||||
setSortBy,
|
||||
setSortDirection,
|
||||
} from "@/store/appsSlice";
|
||||
import { RootState } from "@/store/store";
|
||||
import { useCallback } from "react";
|
||||
import debounce from "lodash/debounce";
|
||||
import { useAppsApi } from "@/hooks/useAppsApi";
|
||||
import { AppFiltersSkeleton } from "@/skeleton/AppFiltersSkeleton";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuGroup,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
const sortOptions = [
|
||||
{ value: "name", label: "Name" },
|
||||
{ value: "memories", label: "Memories Created" },
|
||||
{ value: "memories_accessed", label: "Memories Accessed" },
|
||||
];
|
||||
|
||||
export function AppFilters() {
|
||||
const dispatch = useDispatch();
|
||||
const filters = useSelector((state: RootState) => state.apps.filters);
|
||||
const [localSearch, setLocalSearch] = useState(filters.searchQuery);
|
||||
const { isLoading } = useAppsApi();
|
||||
|
||||
const debouncedSearch = useCallback(
|
||||
debounce((query: string) => {
|
||||
dispatch(setSearchQuery(query));
|
||||
}, 300),
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
const handleSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const query = e.target.value;
|
||||
setLocalSearch(query);
|
||||
debouncedSearch(query);
|
||||
};
|
||||
|
||||
const handleActiveFilterChange = (value: string) => {
|
||||
dispatch(setActiveFilter(value === "all" ? "all" : value === "true"));
|
||||
};
|
||||
|
||||
const setSorting = (sortBy: "name" | "memories" | "memories_accessed") => {
|
||||
const newDirection =
|
||||
filters.sortBy === sortBy && filters.sortDirection === "asc"
|
||||
? "desc"
|
||||
: "asc";
|
||||
dispatch(setSortBy(sortBy));
|
||||
dispatch(setSortDirection(newDirection));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setLocalSearch(filters.searchQuery);
|
||||
}, [filters.searchQuery]);
|
||||
|
||||
if (isLoading) {
|
||||
return <AppFiltersSkeleton />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-zinc-500" />
|
||||
<Input
|
||||
placeholder="Search Apps..."
|
||||
className="pl-8 bg-zinc-950 border-zinc-800 max-w-[500px]"
|
||||
value={localSearch}
|
||||
onChange={handleSearchChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Select
|
||||
value={String(filters.isActive)}
|
||||
onValueChange={handleActiveFilterChange}
|
||||
>
|
||||
<SelectTrigger className="w-[130px] border-zinc-700/50 bg-zinc-900 hover:bg-zinc-800">
|
||||
<SelectValue placeholder="Status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="border-zinc-700/50 bg-zinc-900 hover:bg-zinc-800">
|
||||
<SelectItem value="all">All Status</SelectItem>
|
||||
<SelectItem value="true">Active</SelectItem>
|
||||
<SelectItem value="false">Inactive</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-9 px-4 border-zinc-700 bg-zinc-900 hover:bg-zinc-800"
|
||||
>
|
||||
{filters.sortDirection === "asc" ? (
|
||||
<SortDesc className="h-4 w-4 mr-2" />
|
||||
) : (
|
||||
<SortAsc className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
Sort: {sortOptions.find((o) => o.value === filters.sortBy)?.label}
|
||||
<ChevronDown className="h-4 w-4 ml-2" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-56 bg-zinc-900 border-zinc-800 text-zinc-100">
|
||||
<DropdownMenuLabel>Sort by</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator className="bg-zinc-800" />
|
||||
<DropdownMenuGroup>
|
||||
{sortOptions.map((option) => (
|
||||
<DropdownMenuItem
|
||||
key={option.value}
|
||||
onClick={() =>
|
||||
setSorting(
|
||||
option.value as "name" | "memories" | "memories_accessed"
|
||||
)
|
||||
}
|
||||
className="cursor-pointer flex justify-between items-center"
|
||||
>
|
||||
{option.label}
|
||||
{filters.sortBy === option.value &&
|
||||
(filters.sortDirection === "asc" ? (
|
||||
<SortAsc className="h-4 w-4 text-primary" />
|
||||
) : (
|
||||
<SortDesc className="h-4 w-4 text-primary" />
|
||||
))}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
"use client";
|
||||
import { useEffect } from "react";
|
||||
import { useSelector } from "react-redux";
|
||||
import { RootState } from "@/store/store";
|
||||
import { useAppsApi } from "@/hooks/useAppsApi";
|
||||
import { AppCard } from "./AppCard";
|
||||
import { AppCardSkeleton } from "@/skeleton/AppCardSkeleton";
|
||||
|
||||
export function AppGrid() {
|
||||
const { fetchApps, isLoading } = useAppsApi();
|
||||
const apps = useSelector((state: RootState) => state.apps.apps);
|
||||
const filters = useSelector((state: RootState) => state.apps.filters);
|
||||
|
||||
useEffect(() => {
|
||||
fetchApps({
|
||||
name: filters.searchQuery,
|
||||
is_active: filters.isActive === "all" ? undefined : filters.isActive,
|
||||
sort_by: filters.sortBy,
|
||||
sort_direction: filters.sortDirection,
|
||||
});
|
||||
}, [fetchApps, filters]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{[...Array(3)].map((_, i) => (
|
||||
<AppCardSkeleton key={i} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (apps.length === 0) {
|
||||
return (
|
||||
<div className="text-center text-zinc-500 py-8">
|
||||
No apps found matching your filters
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{apps.map((app) => (
|
||||
<AppCard key={app.id} app={app} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
"use client";
|
||||
|
||||
import { AppFilters } from "./components/AppFilters";
|
||||
import { AppGrid } from "./components/AppGrid";
|
||||
import "@/styles/animation.css";
|
||||
|
||||
export default function AppsPage() {
|
||||
return (
|
||||
<main className="flex-1 py-6">
|
||||
<div className="container">
|
||||
<div className="mt-1 pb-4 animate-fade-slide-down">
|
||||
<AppFilters />
|
||||
</div>
|
||||
<div className="animate-fade-slide-down delay-1">
|
||||
<AppGrid />
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--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: 260 94% 59%;
|
||||
--primary-foreground: 355.7 100% 97.3%;
|
||||
--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: 260 94% 59%;
|
||||
--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: 260 94% 59%;
|
||||
--primary-foreground: 355.7 100% 97.3%;
|
||||
--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: 260 94% 59%;
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import type React from "react";
|
||||
import "@/app/globals.css";
|
||||
import { ThemeProvider } from "@/components/theme-provider";
|
||||
import { Navbar } from "@/components/Navbar";
|
||||
import { Toaster } from "@/components/ui/toaster";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Providers } from "./providers";
|
||||
|
||||
export const metadata = {
|
||||
title: "OpenMemory - Developer Dashboard",
|
||||
description: "Manage your OpenMemory integration and stored memories",
|
||||
generator: "v0.dev",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<body className="h-screen font-sans antialiased flex flex-col bg-zinc-950">
|
||||
<Providers>
|
||||
<ThemeProvider
|
||||
attribute="class"
|
||||
defaultTheme="dark"
|
||||
enableSystem
|
||||
disableTransitionOnChange
|
||||
>
|
||||
<Navbar />
|
||||
<ScrollArea className="h-[calc(100vh-64px)]">{children}</ScrollArea>
|
||||
<Toaster />
|
||||
</ThemeProvider>
|
||||
</Providers>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export default function Loading() {
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { useState, useRef } from "react";
|
||||
import { GoPlus } from "react-icons/go";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { useMemoriesApi } from "@/hooks/useMemoriesApi";
|
||||
import { toast } from "sonner";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
|
||||
export function CreateMemoryDialog() {
|
||||
const { createMemory, isLoading, fetchMemories } = useMemoriesApi();
|
||||
const [open, setOpen] = useState(false);
|
||||
const textRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
const handleCreateMemory = async (text: string) => {
|
||||
try {
|
||||
await createMemory(text);
|
||||
toast.success("Memory created successfully");
|
||||
// close the dialog
|
||||
setOpen(false);
|
||||
// refetch memories
|
||||
await fetchMemories();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error("Failed to create memory");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="bg-primary hover:bg-primary/90 text-white"
|
||||
>
|
||||
<GoPlus />
|
||||
Create Memory
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-[525px] bg-zinc-900 border-zinc-800">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create New Memory</DialogTitle>
|
||||
<DialogDescription>
|
||||
Add a new memory to your OpenMemory instance
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="memory">Memory</Label>
|
||||
<Textarea
|
||||
ref={textRef}
|
||||
id="memory"
|
||||
placeholder="e.g., Lives in San Francisco"
|
||||
className="bg-zinc-950 border-zinc-800 min-h-[150px]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
disabled={isLoading}
|
||||
onClick={() => handleCreateMemory(textRef?.current?.value || "")}
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
"Save Memory"
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,411 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Filter, X, ChevronDown, SortAsc, SortDesc } from "lucide-react";
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuGroup,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { RootState } from "@/store/store";
|
||||
import { useAppsApi } from "@/hooks/useAppsApi";
|
||||
import { useFiltersApi } from "@/hooks/useFiltersApi";
|
||||
import {
|
||||
setSelectedApps,
|
||||
setSelectedCategories,
|
||||
clearFilters,
|
||||
} from "@/store/filtersSlice";
|
||||
import { useMemoriesApi } from "@/hooks/useMemoriesApi";
|
||||
|
||||
const columns = [
|
||||
{
|
||||
label: "Memory",
|
||||
value: "memory",
|
||||
},
|
||||
{
|
||||
label: "App Name",
|
||||
value: "app_name",
|
||||
},
|
||||
{
|
||||
label: "Created On",
|
||||
value: "created_at",
|
||||
},
|
||||
];
|
||||
|
||||
export default function FilterComponent() {
|
||||
const dispatch = useDispatch();
|
||||
const { fetchApps } = useAppsApi();
|
||||
const { fetchCategories, updateSort } = useFiltersApi();
|
||||
const { fetchMemories } = useMemoriesApi();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [tempSelectedApps, setTempSelectedApps] = useState<string[]>([]);
|
||||
const [tempSelectedCategories, setTempSelectedCategories] = useState<
|
||||
string[]
|
||||
>([]);
|
||||
const [showArchived, setShowArchived] = useState(false);
|
||||
|
||||
const apps = useSelector((state: RootState) => state.apps.apps);
|
||||
const categories = useSelector(
|
||||
(state: RootState) => state.filters.categories.items
|
||||
);
|
||||
const filters = useSelector((state: RootState) => state.filters.apps);
|
||||
|
||||
useEffect(() => {
|
||||
fetchApps();
|
||||
fetchCategories();
|
||||
}, [fetchApps, fetchCategories]);
|
||||
|
||||
useEffect(() => {
|
||||
// Initialize temporary selections with current active filters when dialog opens
|
||||
if (isOpen) {
|
||||
setTempSelectedApps(filters.selectedApps);
|
||||
setTempSelectedCategories(filters.selectedCategories);
|
||||
setShowArchived(filters.showArchived || false);
|
||||
}
|
||||
}, [isOpen, filters]);
|
||||
|
||||
useEffect(() => {
|
||||
handleClearFilters();
|
||||
}, []);
|
||||
|
||||
const toggleAppFilter = (app: string) => {
|
||||
setTempSelectedApps((prev) =>
|
||||
prev.includes(app) ? prev.filter((a) => a !== app) : [...prev, app]
|
||||
);
|
||||
};
|
||||
|
||||
const toggleCategoryFilter = (category: string) => {
|
||||
setTempSelectedCategories((prev) =>
|
||||
prev.includes(category)
|
||||
? prev.filter((c) => c !== category)
|
||||
: [...prev, category]
|
||||
);
|
||||
};
|
||||
|
||||
const toggleAllApps = (checked: boolean) => {
|
||||
setTempSelectedApps(checked ? apps.map((app) => app.id) : []);
|
||||
};
|
||||
|
||||
const toggleAllCategories = (checked: boolean) => {
|
||||
setTempSelectedCategories(checked ? categories.map((cat) => cat.name) : []);
|
||||
};
|
||||
|
||||
const handleClearFilters = async () => {
|
||||
setTempSelectedApps([]);
|
||||
setTempSelectedCategories([]);
|
||||
setShowArchived(false);
|
||||
dispatch(clearFilters());
|
||||
await fetchMemories();
|
||||
};
|
||||
|
||||
const handleApplyFilters = async () => {
|
||||
try {
|
||||
// Get category IDs for selected category names
|
||||
const selectedCategoryIds = categories
|
||||
.filter((cat) => tempSelectedCategories.includes(cat.name))
|
||||
.map((cat) => cat.id);
|
||||
|
||||
// Get app IDs for selected app names
|
||||
const selectedAppIds = apps
|
||||
.filter((app) => tempSelectedApps.includes(app.id))
|
||||
.map((app) => app.id);
|
||||
|
||||
// Update the global state with temporary selections
|
||||
dispatch(setSelectedApps(tempSelectedApps));
|
||||
dispatch(setSelectedCategories(tempSelectedCategories));
|
||||
dispatch({ type: "filters/setShowArchived", payload: showArchived });
|
||||
|
||||
await fetchMemories(undefined, 1, 10, {
|
||||
apps: selectedAppIds,
|
||||
categories: selectedCategoryIds,
|
||||
sortColumn: filters.sortColumn,
|
||||
sortDirection: filters.sortDirection,
|
||||
showArchived: showArchived,
|
||||
});
|
||||
setIsOpen(false);
|
||||
} catch (error) {
|
||||
console.error("Failed to apply filters:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDialogChange = (open: boolean) => {
|
||||
setIsOpen(open);
|
||||
if (!open) {
|
||||
// Reset temporary selections to active filters when dialog closes without applying
|
||||
setTempSelectedApps(filters.selectedApps);
|
||||
setTempSelectedCategories(filters.selectedCategories);
|
||||
setShowArchived(filters.showArchived || false);
|
||||
}
|
||||
};
|
||||
|
||||
const setSorting = async (column: string) => {
|
||||
const newDirection =
|
||||
filters.sortColumn === column && filters.sortDirection === "asc"
|
||||
? "desc"
|
||||
: "asc";
|
||||
updateSort(column, newDirection);
|
||||
|
||||
// Get category IDs for selected category names
|
||||
const selectedCategoryIds = categories
|
||||
.filter((cat) => tempSelectedCategories.includes(cat.name))
|
||||
.map((cat) => cat.id);
|
||||
|
||||
// Get app IDs for selected app names
|
||||
const selectedAppIds = apps
|
||||
.filter((app) => tempSelectedApps.includes(app.id))
|
||||
.map((app) => app.id);
|
||||
|
||||
try {
|
||||
await fetchMemories(undefined, 1, 10, {
|
||||
apps: selectedAppIds,
|
||||
categories: selectedCategoryIds,
|
||||
sortColumn: column,
|
||||
sortDirection: newDirection,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to apply sorting:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const hasActiveFilters =
|
||||
filters.selectedApps.length > 0 ||
|
||||
filters.selectedCategories.length > 0 ||
|
||||
filters.showArchived;
|
||||
|
||||
const hasTempFilters =
|
||||
tempSelectedApps.length > 0 ||
|
||||
tempSelectedCategories.length > 0 ||
|
||||
showArchived;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<Dialog open={isOpen} onOpenChange={handleDialogChange}>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
className={`h-9 px-4 border-zinc-700/50 bg-zinc-900 hover:bg-zinc-800 ${
|
||||
hasActiveFilters ? "border-primary" : ""
|
||||
}`}
|
||||
>
|
||||
<Filter
|
||||
className={`h-4 w-4 ${hasActiveFilters ? "text-primary" : ""}`}
|
||||
/>
|
||||
Filter
|
||||
{hasActiveFilters && (
|
||||
<Badge className="ml-2 bg-primary hover:bg-primary/80 text-xs">
|
||||
{filters.selectedApps.length +
|
||||
filters.selectedCategories.length +
|
||||
(filters.showArchived ? 1 : 0)}
|
||||
</Badge>
|
||||
)}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-[425px] bg-zinc-900 border-zinc-800 text-zinc-100">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-zinc-100 flex justify-between items-center">
|
||||
<span>Filters</span>
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<Tabs defaultValue="apps" className="w-full">
|
||||
<TabsList className="grid grid-cols-3 bg-zinc-800">
|
||||
<TabsTrigger
|
||||
value="apps"
|
||||
className="data-[state=active]:bg-zinc-700"
|
||||
>
|
||||
Apps
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="categories"
|
||||
className="data-[state=active]:bg-zinc-700"
|
||||
>
|
||||
Categories
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="archived"
|
||||
className="data-[state=active]:bg-zinc-700"
|
||||
>
|
||||
Archived
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="apps" className="mt-4">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="select-all-apps"
|
||||
checked={
|
||||
apps.length > 0 && tempSelectedApps.length === apps.length
|
||||
}
|
||||
onCheckedChange={(checked) =>
|
||||
toggleAllApps(checked as boolean)
|
||||
}
|
||||
className="border-zinc-600 data-[state=checked]:bg-primary data-[state=checked]:border-primary"
|
||||
/>
|
||||
<Label
|
||||
htmlFor="select-all-apps"
|
||||
className="text-sm font-normal text-zinc-300 cursor-pointer"
|
||||
>
|
||||
Select All
|
||||
</Label>
|
||||
</div>
|
||||
{apps.map((app) => (
|
||||
<div key={app.id} className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id={`app-${app.id}`}
|
||||
checked={tempSelectedApps.includes(app.id)}
|
||||
onCheckedChange={() => toggleAppFilter(app.id)}
|
||||
className="border-zinc-600 data-[state=checked]:bg-primary data-[state=checked]:border-primary"
|
||||
/>
|
||||
<Label
|
||||
htmlFor={`app-${app.id}`}
|
||||
className="text-sm font-normal text-zinc-300 cursor-pointer"
|
||||
>
|
||||
{app.name}
|
||||
</Label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</TabsContent>
|
||||
<TabsContent value="categories" className="mt-4">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="select-all-categories"
|
||||
checked={
|
||||
categories.length > 0 &&
|
||||
tempSelectedCategories.length === categories.length
|
||||
}
|
||||
onCheckedChange={(checked) =>
|
||||
toggleAllCategories(checked as boolean)
|
||||
}
|
||||
className="border-zinc-600 data-[state=checked]:bg-primary data-[state=checked]:border-primary"
|
||||
/>
|
||||
<Label
|
||||
htmlFor="select-all-categories"
|
||||
className="text-sm font-normal text-zinc-300 cursor-pointer"
|
||||
>
|
||||
Select All
|
||||
</Label>
|
||||
</div>
|
||||
{categories.map((category) => (
|
||||
<div
|
||||
key={category.name}
|
||||
className="flex items-center space-x-2"
|
||||
>
|
||||
<Checkbox
|
||||
id={`category-${category.name}`}
|
||||
checked={tempSelectedCategories.includes(category.name)}
|
||||
onCheckedChange={() =>
|
||||
toggleCategoryFilter(category.name)
|
||||
}
|
||||
className="border-zinc-600 data-[state=checked]:bg-primary data-[state=checked]:border-primary"
|
||||
/>
|
||||
<Label
|
||||
htmlFor={`category-${category.name}`}
|
||||
className="text-sm font-normal text-zinc-300 cursor-pointer"
|
||||
>
|
||||
{category.name}
|
||||
</Label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</TabsContent>
|
||||
<TabsContent value="archived" className="mt-4">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="show-archived"
|
||||
checked={showArchived}
|
||||
onCheckedChange={(checked) =>
|
||||
setShowArchived(checked as boolean)
|
||||
}
|
||||
className="border-zinc-600 data-[state=checked]:bg-primary data-[state=checked]:border-primary"
|
||||
/>
|
||||
<Label
|
||||
htmlFor="show-archived"
|
||||
className="text-sm font-normal text-zinc-300 cursor-pointer"
|
||||
>
|
||||
Show Archived Memories
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
<div className="flex justify-end mt-4 gap-3">
|
||||
{/* Clear all button */}
|
||||
{hasTempFilters && (
|
||||
<Button
|
||||
onClick={handleClearFilters}
|
||||
className="bg-zinc-800 hover:bg-zinc-700 text-zinc-300"
|
||||
>
|
||||
Clear All
|
||||
</Button>
|
||||
)}
|
||||
{/* Apply filters button */}
|
||||
<Button
|
||||
onClick={handleApplyFilters}
|
||||
className="bg-primary hover:bg-primary/80 text-white"
|
||||
>
|
||||
Apply Filters
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-9 px-4 border-zinc-700/50 bg-zinc-900 hover:bg-zinc-800"
|
||||
>
|
||||
{filters.sortDirection === "asc" ? (
|
||||
<SortAsc className="h-4 w-4" />
|
||||
) : (
|
||||
<SortDesc className="h-4 w-4" />
|
||||
)}
|
||||
Sort: {columns.find((c) => c.value === filters.sortColumn)?.label}
|
||||
<ChevronDown className="h-4 w-4 ml-2" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-56 bg-zinc-900 border-zinc-800 text-zinc-100">
|
||||
<DropdownMenuLabel>Sort by</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator className="bg-zinc-800" />
|
||||
<DropdownMenuGroup>
|
||||
{columns.map((column) => (
|
||||
<DropdownMenuItem
|
||||
key={column.value}
|
||||
onClick={() => setSorting(column.value)}
|
||||
className="cursor-pointer flex justify-between items-center"
|
||||
>
|
||||
{column.label}
|
||||
{filters.sortColumn === column.value &&
|
||||
(filters.sortDirection === "asc" ? (
|
||||
<SortAsc className="h-4 w-4 text-primary" />
|
||||
) : (
|
||||
<SortDesc className="h-4 w-4 text-primary" />
|
||||
))}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Category, Client } from "../../../components/types";
|
||||
import { MemoryTable } from "./MemoryTable";
|
||||
import { MemoryPagination } from "./MemoryPagination";
|
||||
import { CreateMemoryDialog } from "./CreateMemoryDialog";
|
||||
import { PageSizeSelector } from "./PageSizeSelector";
|
||||
import { useMemoriesApi } from "@/hooks/useMemoriesApi";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { MemoryTableSkeleton } from "@/skeleton/MemoryTableSkeleton";
|
||||
|
||||
export function MemoriesSection() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const { fetchMemories } = useMemoriesApi();
|
||||
const [memories, setMemories] = useState<any[]>([]);
|
||||
const [totalItems, setTotalItems] = useState(0);
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
const currentPage = Number(searchParams.get("page")) || 1;
|
||||
const itemsPerPage = Number(searchParams.get("size")) || 10;
|
||||
const [selectedCategory, setSelectedCategory] = useState<Category | "all">(
|
||||
"all"
|
||||
);
|
||||
const [selectedClient, setSelectedClient] = useState<Client | "all">("all");
|
||||
|
||||
useEffect(() => {
|
||||
const loadMemories = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const searchQuery = searchParams.get("search") || "";
|
||||
const result = await fetchMemories(
|
||||
searchQuery,
|
||||
currentPage,
|
||||
itemsPerPage
|
||||
);
|
||||
setMemories(result.memories);
|
||||
setTotalItems(result.total);
|
||||
setTotalPages(result.pages);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch memories:", error);
|
||||
}
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
loadMemories();
|
||||
}, [currentPage, itemsPerPage, fetchMemories, searchParams]);
|
||||
|
||||
const setCurrentPage = (page: number) => {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
params.set("page", page.toString());
|
||||
params.set("size", itemsPerPage.toString());
|
||||
router.push(`?${params.toString()}`);
|
||||
};
|
||||
|
||||
const handlePageSizeChange = (size: number) => {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
params.set("page", "1"); // Reset to page 1 when changing page size
|
||||
params.set("size", size.toString());
|
||||
router.push(`?${params.toString()}`);
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="w-full bg-transparent">
|
||||
<MemoryTableSkeleton />
|
||||
<div className="flex items-center justify-between mt-4">
|
||||
<div className="h-8 w-32 bg-zinc-800 rounded animate-pulse" />
|
||||
<div className="h-8 w-48 bg-zinc-800 rounded animate-pulse" />
|
||||
<div className="h-8 w-32 bg-zinc-800 rounded animate-pulse" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full bg-transparent">
|
||||
<div>
|
||||
{memories.length > 0 ? (
|
||||
<>
|
||||
<MemoryTable />
|
||||
<div className="flex items-center justify-between mt-4">
|
||||
<PageSizeSelector
|
||||
pageSize={itemsPerPage}
|
||||
onPageSizeChange={handlePageSizeChange}
|
||||
/>
|
||||
<div className="text-sm text-zinc-500 mr-2">
|
||||
Showing {(currentPage - 1) * itemsPerPage + 1} to{" "}
|
||||
{Math.min(currentPage * itemsPerPage, totalItems)} of{" "}
|
||||
{totalItems} memories
|
||||
</div>
|
||||
<MemoryPagination
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
setCurrentPage={setCurrentPage}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<div className="rounded-full bg-zinc-800 p-3 mb-4">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="h-6 w-6 text-zinc-400"
|
||||
>
|
||||
<path d="M21 9v10a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h7"></path>
|
||||
<path d="M16 2v6h6"></path>
|
||||
<path d="M12 18v-6"></path>
|
||||
<path d="M9 15h6"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-lg font-medium">No memories found</h3>
|
||||
<p className="text-zinc-400 mt-1 mb-4">
|
||||
{selectedCategory !== "all" || selectedClient !== "all"
|
||||
? "Try adjusting your filters"
|
||||
: "Create your first memory to see it here"}
|
||||
</p>
|
||||
{selectedCategory !== "all" || selectedClient !== "all" ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setSelectedCategory("all");
|
||||
setSelectedClient("all");
|
||||
}}
|
||||
>
|
||||
Clear Filters
|
||||
</Button>
|
||||
) : (
|
||||
<CreateMemoryDialog />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
"use client";
|
||||
import { Archive, Pause, Play, Search } from "lucide-react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { FiTrash2 } from "react-icons/fi";
|
||||
import { useSelector, useDispatch } from "react-redux";
|
||||
import { RootState } from "@/store/store";
|
||||
import { clearSelection } from "@/store/memoriesSlice";
|
||||
import { useMemoriesApi } from "@/hooks/useMemoriesApi";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { debounce } from "lodash";
|
||||
import { useEffect, useRef } from "react";
|
||||
import FilterComponent from "./FilterComponent";
|
||||
import { clearFilters } from "@/store/filtersSlice";
|
||||
|
||||
export function MemoryFilters() {
|
||||
const dispatch = useDispatch();
|
||||
const selectedMemoryIds = useSelector(
|
||||
(state: RootState) => state.memories.selectedMemoryIds
|
||||
);
|
||||
const { deleteMemories, updateMemoryState, fetchMemories } = useMemoriesApi();
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const activeFilters = useSelector((state: RootState) => state.filters.apps);
|
||||
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleDeleteSelected = async () => {
|
||||
try {
|
||||
await deleteMemories(selectedMemoryIds);
|
||||
dispatch(clearSelection());
|
||||
} catch (error) {
|
||||
console.error("Failed to delete memories:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleArchiveSelected = async () => {
|
||||
try {
|
||||
await updateMemoryState(selectedMemoryIds, "archived");
|
||||
} catch (error) {
|
||||
console.error("Failed to archive memories:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePauseSelected = async () => {
|
||||
try {
|
||||
await updateMemoryState(selectedMemoryIds, "paused");
|
||||
} catch (error) {
|
||||
console.error("Failed to pause memories:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleResumeSelected = async () => {
|
||||
try {
|
||||
await updateMemoryState(selectedMemoryIds, "active");
|
||||
} catch (error) {
|
||||
console.error("Failed to resume memories:", error);
|
||||
}
|
||||
};
|
||||
|
||||
// add debounce
|
||||
const handleSearch = debounce(async (query: string) => {
|
||||
router.push(`/memories?search=${query}`);
|
||||
}, 500);
|
||||
|
||||
useEffect(() => {
|
||||
// if the url has a search param, set the input value to the search param
|
||||
if (searchParams.get("search")) {
|
||||
if (inputRef.current) {
|
||||
inputRef.current.value = searchParams.get("search") || "";
|
||||
inputRef.current.focus();
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleClearAllFilters = async () => {
|
||||
dispatch(clearFilters());
|
||||
await fetchMemories(); // Fetch memories without any filters
|
||||
};
|
||||
|
||||
const hasActiveFilters =
|
||||
activeFilters.selectedApps.length > 0 ||
|
||||
activeFilters.selectedCategories.length > 0;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col md:flex-row gap-4 mb-4">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-zinc-500" />
|
||||
<Input
|
||||
ref={inputRef}
|
||||
placeholder="Search memories..."
|
||||
className="pl-8 bg-zinc-950 border-zinc-800 max-w-[500px]"
|
||||
onChange={(e) => handleSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<FilterComponent />
|
||||
{hasActiveFilters && (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="bg-zinc-900 text-zinc-300 hover:bg-zinc-800"
|
||||
onClick={handleClearAllFilters}
|
||||
>
|
||||
Clear Filters
|
||||
</Button>
|
||||
)}
|
||||
{selectedMemoryIds.length > 0 && (
|
||||
<>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-zinc-700/50 bg-zinc-900 hover:bg-zinc-800"
|
||||
>
|
||||
Actions
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
className="bg-zinc-900 border-zinc-800"
|
||||
>
|
||||
<DropdownMenuItem onClick={handleArchiveSelected}>
|
||||
<Archive className="mr-2 h-4 w-4" />
|
||||
Archive Selected
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={handlePauseSelected}>
|
||||
<Pause className="mr-2 h-4 w-4" />
|
||||
Pause Selected
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={handleResumeSelected}>
|
||||
<Play className="mr-2 h-4 w-4" />
|
||||
Resume Selected
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={handleDeleteSelected}
|
||||
className="text-red-500"
|
||||
>
|
||||
<FiTrash2 className="mr-2 h-4 w-4" />
|
||||
Delete Selected
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
interface MemoryPaginationProps {
|
||||
currentPage: number;
|
||||
totalPages: number;
|
||||
setCurrentPage: (page: number) => void;
|
||||
}
|
||||
|
||||
export function MemoryPagination({
|
||||
currentPage,
|
||||
totalPages,
|
||||
setCurrentPage,
|
||||
}: MemoryPaginationProps) {
|
||||
return (
|
||||
<div className="flex items-center justify-between my-auto">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => setCurrentPage(Math.max(currentPage - 1, 1))}
|
||||
disabled={currentPage === 1}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div className="text-sm">
|
||||
Page {currentPage} of {totalPages}
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => setCurrentPage(Math.min(currentPage + 1, totalPages))}
|
||||
disabled={currentPage === totalPages}
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,302 @@
|
||||
import {
|
||||
Edit,
|
||||
MoreHorizontal,
|
||||
Trash2,
|
||||
Pause,
|
||||
Archive,
|
||||
Play,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { useMemoriesApi } from "@/hooks/useMemoriesApi";
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
import { RootState } from "@/store/store";
|
||||
import {
|
||||
selectMemory,
|
||||
deselectMemory,
|
||||
selectAllMemories,
|
||||
clearSelection,
|
||||
} from "@/store/memoriesSlice";
|
||||
import SourceApp from "@/components/shared/source-app";
|
||||
import { HiMiniRectangleStack } from "react-icons/hi2";
|
||||
import { PiSwatches } from "react-icons/pi";
|
||||
import { GoPackage } from "react-icons/go";
|
||||
import { CiCalendar } from "react-icons/ci";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Categories from "@/components/shared/categories";
|
||||
import { useUI } from "@/hooks/useUI";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { formatDate } from "@/lib/helpers";
|
||||
|
||||
export function MemoryTable() {
|
||||
const { toast } = useToast();
|
||||
const router = useRouter();
|
||||
const dispatch = useDispatch();
|
||||
const selectedMemoryIds = useSelector(
|
||||
(state: RootState) => state.memories.selectedMemoryIds
|
||||
);
|
||||
const memories = useSelector((state: RootState) => state.memories.memories);
|
||||
|
||||
const { deleteMemories, updateMemoryState, isLoading } = useMemoriesApi();
|
||||
|
||||
const handleDeleteMemory = (id: string) => {
|
||||
deleteMemories([id]);
|
||||
};
|
||||
|
||||
const handleSelectAll = (checked: boolean) => {
|
||||
if (checked) {
|
||||
dispatch(selectAllMemories());
|
||||
} else {
|
||||
dispatch(clearSelection());
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectMemory = (id: string, checked: boolean) => {
|
||||
if (checked) {
|
||||
dispatch(selectMemory(id));
|
||||
} else {
|
||||
dispatch(deselectMemory(id));
|
||||
}
|
||||
};
|
||||
const { handleOpenUpdateMemoryDialog } = useUI();
|
||||
|
||||
const handleEditMemory = (memory_id: string, memory_content: string) => {
|
||||
handleOpenUpdateMemoryDialog(memory_id, memory_content);
|
||||
};
|
||||
|
||||
const handleUpdateMemoryState = async (id: string, newState: string) => {
|
||||
try {
|
||||
await updateMemoryState([id], newState);
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "Error",
|
||||
description: "Failed to update memory state",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const isAllSelected =
|
||||
memories.length > 0 && selectedMemoryIds.length === memories.length;
|
||||
const isPartiallySelected =
|
||||
selectedMemoryIds.length > 0 && selectedMemoryIds.length < memories.length;
|
||||
|
||||
const handleMemoryClick = (id: string) => {
|
||||
router.push(`/memory/${id}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="rounded-md border">
|
||||
<Table className="">
|
||||
<TableHeader>
|
||||
<TableRow className="bg-zinc-800 hover:bg-zinc-800">
|
||||
<TableHead className="w-[50px] pl-4">
|
||||
<Checkbox
|
||||
className="data-[state=checked]:border-primary border-zinc-500/50"
|
||||
checked={isAllSelected}
|
||||
data-state={
|
||||
isPartiallySelected
|
||||
? "indeterminate"
|
||||
: isAllSelected
|
||||
? "checked"
|
||||
: "unchecked"
|
||||
}
|
||||
onCheckedChange={handleSelectAll}
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead className="border-zinc-700">
|
||||
<div className="flex items-center min-w-[600px]">
|
||||
<HiMiniRectangleStack className="mr-1" />
|
||||
Memory
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead className="border-zinc-700">
|
||||
<div className="flex items-center">
|
||||
<PiSwatches className="mr-1" size={15} />
|
||||
Categories
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead className="w-[140px] border-zinc-700">
|
||||
<div className="flex items-center">
|
||||
<GoPackage className="mr-1" />
|
||||
Source App
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead className="w-[140px] border-zinc-700">
|
||||
<div className="flex items-center w-full justify-center">
|
||||
<CiCalendar className="mr-1" size={16} />
|
||||
Created On
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead className="text-right border-zinc-700 flex justify-center">
|
||||
<div className="flex items-center justify-end">
|
||||
<MoreHorizontal className="h-4 w-4 mr-2" />
|
||||
</div>
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{memories.map((memory) => (
|
||||
<TableRow
|
||||
key={memory.id}
|
||||
className={`hover:bg-zinc-900/50 ${
|
||||
memory.state === "paused" || memory.state === "archived"
|
||||
? "text-zinc-400"
|
||||
: ""
|
||||
} ${isLoading ? "animate-pulse opacity-50" : ""}`}
|
||||
>
|
||||
<TableCell className="pl-4">
|
||||
<Checkbox
|
||||
className="data-[state=checked]:border-primary border-zinc-500/50"
|
||||
checked={selectedMemoryIds.includes(memory.id)}
|
||||
onCheckedChange={(checked) =>
|
||||
handleSelectMemory(memory.id, checked as boolean)
|
||||
}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="">
|
||||
{memory.state === "paused" || memory.state === "archived" ? (
|
||||
<TooltipProvider>
|
||||
<Tooltip delayDuration={0}>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
onClick={() => handleMemoryClick(memory.id)}
|
||||
className={`font-medium ${
|
||||
memory.state === "paused" ||
|
||||
memory.state === "archived"
|
||||
? "text-zinc-400"
|
||||
: "text-white"
|
||||
} cursor-pointer`}
|
||||
>
|
||||
{memory.memory}
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>
|
||||
This memory is{" "}
|
||||
<span className="font-bold">
|
||||
{memory.state === "paused" ? "paused" : "archived"}
|
||||
</span>{" "}
|
||||
and <span className="font-bold">disabled</span>.
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
) : (
|
||||
<div
|
||||
onClick={() => handleMemoryClick(memory.id)}
|
||||
className={`font-medium text-white cursor-pointer`}
|
||||
>
|
||||
{memory.memory}
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
<Categories
|
||||
categories={memory.categories}
|
||||
isPaused={
|
||||
memory.state === "paused" || memory.state === "archived"
|
||||
}
|
||||
concat={true}
|
||||
/>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="w-[140px] text-center">
|
||||
<SourceApp source={memory.app_name} />
|
||||
</TableCell>
|
||||
<TableCell className="w-[140px] text-center">
|
||||
{formatDate(memory.created_at)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right flex justify-center">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8">
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
className="bg-zinc-900 border-zinc-800"
|
||||
>
|
||||
<DropdownMenuItem
|
||||
className="cursor-pointer"
|
||||
onClick={() => {
|
||||
const newState =
|
||||
memory.state === "active" ? "paused" : "active";
|
||||
handleUpdateMemoryState(memory.id, newState);
|
||||
}}
|
||||
>
|
||||
{memory?.state === "active" ? (
|
||||
<>
|
||||
<Pause className="mr-2 h-4 w-4" />
|
||||
Pause
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Play className="mr-2 h-4 w-4" />
|
||||
Resume
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="cursor-pointer"
|
||||
onClick={() => {
|
||||
const newState =
|
||||
memory.state === "active" ? "archived" : "active";
|
||||
handleUpdateMemoryState(memory.id, newState);
|
||||
}}
|
||||
>
|
||||
<Archive className="mr-2 h-4 w-4" />
|
||||
{memory?.state !== "archived" ? (
|
||||
<>Archive</>
|
||||
) : (
|
||||
<>Unarchive</>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="cursor-pointer"
|
||||
onClick={() => handleEditMemory(memory.id, memory.memory)}
|
||||
>
|
||||
<Edit className="mr-2 h-4 w-4" />
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
className="cursor-pointer text-red-500 focus:text-red-500"
|
||||
onClick={() => handleDeleteMemory(memory.id)}
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
|
||||
interface PageSizeSelectorProps {
|
||||
pageSize: number;
|
||||
onPageSizeChange: (size: number) => void;
|
||||
}
|
||||
|
||||
export function PageSizeSelector({
|
||||
pageSize,
|
||||
onPageSizeChange,
|
||||
}: PageSizeSelectorProps) {
|
||||
const pageSizeOptions = [10, 20, 50, 100];
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-zinc-500">Show</span>
|
||||
<Select
|
||||
value={pageSize.toString()}
|
||||
onValueChange={(value) => onPageSizeChange(Number(value))}
|
||||
>
|
||||
<SelectTrigger className="w-[70px] h-8">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{pageSizeOptions.map((size) => (
|
||||
<SelectItem key={size} value={size.toString()}>
|
||||
{size}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<span className="text-sm text-zinc-500">items</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default PageSizeSelector;
|
||||
@@ -0,0 +1,45 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { MemoriesSection } from "@/app/memories/components/MemoriesSection";
|
||||
import { MemoryFilters } from "@/app/memories/components/MemoryFilters";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import "@/styles/animation.css";
|
||||
import UpdateMemory from "@/components/shared/update-memory";
|
||||
import { useUI } from "@/hooks/useUI";
|
||||
|
||||
export default function MemoriesPage() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const { updateMemoryDialog, handleCloseUpdateMemoryDialog } = useUI();
|
||||
useEffect(() => {
|
||||
// Set default pagination values if not present in URL
|
||||
if (!searchParams.has("page") || !searchParams.has("size")) {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
if (!searchParams.has("page")) params.set("page", "1");
|
||||
if (!searchParams.has("size")) params.set("size", "10");
|
||||
router.push(`?${params.toString()}`);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="">
|
||||
<UpdateMemory
|
||||
memoryId={updateMemoryDialog.memoryId || ""}
|
||||
memoryContent={updateMemoryDialog.memoryContent || ""}
|
||||
open={updateMemoryDialog.isOpen}
|
||||
onOpenChange={handleCloseUpdateMemoryDialog}
|
||||
/>
|
||||
<main className="flex-1 py-6">
|
||||
<div className="container">
|
||||
<div className="mt-1 pb-4 animate-fade-slide-down">
|
||||
<MemoryFilters />
|
||||
</div>
|
||||
<div className="animate-fade-slide-down delay-1">
|
||||
<MemoriesSection />
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import Image from "next/image";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useMemoriesApi } from "@/hooks/useMemoriesApi";
|
||||
import { constants } from "@/components/shared/source-app";
|
||||
import { useSelector } from "react-redux";
|
||||
import { RootState } from "@/store/store";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
|
||||
interface AccessLogEntry {
|
||||
id: string;
|
||||
app_name: string;
|
||||
accessed_at: string;
|
||||
}
|
||||
|
||||
interface AccessLogProps {
|
||||
memoryId: string;
|
||||
}
|
||||
|
||||
export function AccessLog({ memoryId }: AccessLogProps) {
|
||||
const { fetchAccessLogs } = useMemoriesApi();
|
||||
const accessEntries = useSelector(
|
||||
(state: RootState) => state.memories.accessLogs
|
||||
);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const loadAccessLogs = async () => {
|
||||
try {
|
||||
await fetchAccessLogs(memoryId);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch access logs:", error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadAccessLogs();
|
||||
}, []);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="w-full max-w-md mx-auto rounded-3xl overflow-hidden bg-[#1c1c1c] text-white p-6">
|
||||
<p className="text-center text-zinc-500">Loading access logs...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-md mx-auto rounded-lg overflow-hidden bg-zinc-900 border border-zinc-800 text-white pb-1">
|
||||
<div className="px-6 py-4 flex justify-between items-center bg-zinc-800 border-b border-zinc-800">
|
||||
<h2 className="font-semibold">Access Log</h2>
|
||||
{/* <button className="px-3 py-1 text-sm rounded-lg border border-[#ff5533] text-[#ff5533] flex items-center gap-2 hover:bg-[#ff5533]/10 transition-colors">
|
||||
<PauseIcon size={18} />
|
||||
<span>Pause Access</span>
|
||||
</button> */}
|
||||
</div>
|
||||
|
||||
<ScrollArea className="p-6 max-h-[450px]">
|
||||
{accessEntries.length === 0 && (
|
||||
<div className="w-full max-w-md mx-auto rounded-3xl overflow-hidden min-h-[110px] flex items-center justify-center text-white p-6">
|
||||
<p className="text-center text-zinc-500">
|
||||
No access logs available
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<ul className="space-y-8">
|
||||
{accessEntries.map((entry: AccessLogEntry, index: number) => {
|
||||
const appConfig =
|
||||
constants[entry.app_name as keyof typeof constants] ||
|
||||
constants.default;
|
||||
|
||||
return (
|
||||
<li key={entry.id} className="relative flex items-start gap-4">
|
||||
<div className="relative z-10 rounded-full overflow-hidden bg-[#2a2a2a] w-8 h-8 flex items-center justify-center flex-shrink-0">
|
||||
{appConfig.iconImage ? (
|
||||
<Image
|
||||
src={appConfig.iconImage}
|
||||
alt={`${appConfig.name} icon`}
|
||||
width={30}
|
||||
height={30}
|
||||
className="w-8 h-8 object-contain"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-8 h-8 flex items-center justify-center">
|
||||
{appConfig.icon}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{index < accessEntries.length - 1 && (
|
||||
<div className="absolute left-4 top-6 bottom-0 w-[1px] h-[calc(100%+1rem)] bg-[#333333] transform -translate-x-1/2"></div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">{appConfig.name}</span>
|
||||
<span className="text-zinc-400 text-sm">
|
||||
{new Date(entry.accessed_at + "Z").toLocaleDateString(
|
||||
"en-US",
|
||||
{
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "numeric",
|
||||
}
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Pencil, Archive, Trash, Pause, Play, ChevronDown } from "lucide-react";
|
||||
import { useUI } from "@/hooks/useUI";
|
||||
import { useMemoriesApi } from "@/hooks/useMemoriesApi";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
|
||||
interface MemoryActionsProps {
|
||||
memoryId: string;
|
||||
memoryContent: string;
|
||||
memoryState: string;
|
||||
}
|
||||
|
||||
export function MemoryActions({
|
||||
memoryId,
|
||||
memoryContent,
|
||||
memoryState,
|
||||
}: MemoryActionsProps) {
|
||||
const { handleOpenUpdateMemoryDialog } = useUI();
|
||||
const { updateMemoryState, isLoading } = useMemoriesApi();
|
||||
|
||||
const handleEdit = () => {
|
||||
handleOpenUpdateMemoryDialog(memoryId, memoryContent);
|
||||
};
|
||||
|
||||
const handleStateChange = (newState: string) => {
|
||||
updateMemoryState([memoryId], newState);
|
||||
};
|
||||
|
||||
const getStateLabel = () => {
|
||||
switch (memoryState) {
|
||||
case "archived":
|
||||
return "Archived";
|
||||
case "paused":
|
||||
return "Paused";
|
||||
default:
|
||||
return "Active";
|
||||
}
|
||||
};
|
||||
|
||||
const getStateIcon = () => {
|
||||
switch (memoryState) {
|
||||
case "archived":
|
||||
return <Archive className="h-3 w-3 mr-2" />;
|
||||
case "paused":
|
||||
return <Pause className="h-3 w-3 mr-2" />;
|
||||
default:
|
||||
return <Play className="h-3 w-3 mr-2" />;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex gap-2">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
disabled={isLoading}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="shadow-md bg-zinc-900 border border-zinc-700/50 hover:bg-zinc-950 text-zinc-400"
|
||||
>
|
||||
<span className="font-semibold">{getStateLabel()}</span>
|
||||
<ChevronDown className="h-3 w-3 mt-1 -ml-1" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-40 bg-zinc-900 border-zinc-800 text-zinc-100">
|
||||
<DropdownMenuLabel>Change State</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator className="bg-zinc-800" />
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleStateChange("active")}
|
||||
className="cursor-pointer flex items-center"
|
||||
disabled={memoryState === "active"}
|
||||
>
|
||||
<Play className="h-3 w-3 mr-2" />
|
||||
<span className="font-semibold">Active</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleStateChange("paused")}
|
||||
className="cursor-pointer flex items-center"
|
||||
disabled={memoryState === "paused"}
|
||||
>
|
||||
<Pause className="h-3 w-3 mr-2" />
|
||||
<span className="font-semibold">Pause</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleStateChange("archived")}
|
||||
className="cursor-pointer flex items-center"
|
||||
disabled={memoryState === "archived"}
|
||||
>
|
||||
<Archive className="h-3 w-3 mr-2" />
|
||||
<span className="font-semibold">Archive</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<Button
|
||||
disabled={isLoading}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleEdit}
|
||||
className="shadow-md bg-zinc-900 border border-zinc-700/50 hover:bg-zinc-950 text-zinc-400"
|
||||
>
|
||||
<Pencil className="h-3 w-3 -mr-1" />
|
||||
<span className="font-semibold">Edit</span>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
"use client";
|
||||
import { useMemoriesApi } from "@/hooks/useMemoriesApi";
|
||||
import { MemoryActions } from "./MemoryActions";
|
||||
import { ArrowLeft, Copy, Check } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { AccessLog } from "./AccessLog";
|
||||
import Image from "next/image";
|
||||
import Categories from "@/components/shared/categories";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useSelector } from "react-redux";
|
||||
import { RootState } from "@/store/store";
|
||||
import { constants } from "@/components/shared/source-app";
|
||||
import { RelatedMemories } from "./RelatedMemories";
|
||||
|
||||
interface MemoryDetailsProps {
|
||||
memory_id: string;
|
||||
}
|
||||
|
||||
export function MemoryDetails({ memory_id }: MemoryDetailsProps) {
|
||||
const router = useRouter();
|
||||
const { fetchMemoryById, hasUpdates } = useMemoriesApi();
|
||||
const memory = useSelector(
|
||||
(state: RootState) => state.memories.selectedMemory
|
||||
);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleCopy = async () => {
|
||||
if (memory?.id) {
|
||||
await navigator.clipboard.writeText(memory.id);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchMemoryById(memory_id);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="container mx-auto py-6 px-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="mb-4 text-zinc-400 hover:text-white"
|
||||
onClick={() => router.back()}
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||
Back to Memories
|
||||
</Button>
|
||||
<div className="flex gap-4 w-full">
|
||||
<div className="rounded-lg w-2/3 border h-fit pb-2 border-zinc-800 bg-zinc-900 overflow-hidden">
|
||||
<div className="">
|
||||
<div className="flex px-6 py-3 justify-between items-center mb-6 bg-zinc-800 border-b border-zinc-800">
|
||||
<div className="flex items-center gap-2">
|
||||
<h1 className="font-semibold text-white">
|
||||
Memory{" "}
|
||||
<span className="ml-1 text-zinc-400 text-sm font-normal">
|
||||
#{memory?.id?.slice(0, 6)}
|
||||
</span>
|
||||
</h1>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-4 w-4 text-zinc-400 hover:text-white -ml-[5px] mt-1"
|
||||
onClick={handleCopy}
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="h-3 w-3" />
|
||||
) : (
|
||||
<Copy className="h-3 w-3" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<MemoryActions
|
||||
memoryId={memory?.id || ""}
|
||||
memoryContent={memory?.text || ""}
|
||||
memoryState={memory?.state || ""}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="px-6 py-2">
|
||||
<div className="border-l-2 border-primary pl-4 mb-6">
|
||||
<p
|
||||
className={`${
|
||||
memory?.state === "archived" || memory?.state === "paused"
|
||||
? "text-zinc-400"
|
||||
: "text-white"
|
||||
}`}
|
||||
>
|
||||
{memory?.text}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 pt-4 border-t border-zinc-800">
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="">
|
||||
<Categories
|
||||
categories={memory?.categories || []}
|
||||
isPaused={
|
||||
memory?.state === "archived" ||
|
||||
memory?.state === "paused"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 min-w-[300px] justify-end">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-1 bg-zinc-700 px-3 py-1 rounded-lg">
|
||||
<span className="text-sm text-zinc-400">
|
||||
Created by:
|
||||
</span>
|
||||
<div className="w-4 h-4 rounded-full bg-zinc-700 flex items-center justify-center overflow-hidden">
|
||||
<Image
|
||||
src={
|
||||
constants[
|
||||
memory?.app_name as keyof typeof constants
|
||||
]?.iconImage || ""
|
||||
}
|
||||
alt="OpenMemory"
|
||||
width={24}
|
||||
height={24}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-sm text-zinc-100 font-semibold">
|
||||
{
|
||||
constants[
|
||||
memory?.app_name as keyof typeof constants
|
||||
]?.name
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* <div className="flex justify-end gap-2 w-full mt-2">
|
||||
<p className="text-sm font-semibold text-primary my-auto">
|
||||
{new Date(memory.created_at).toLocaleString()}
|
||||
</p>
|
||||
</div> */}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-1/3 flex flex-col gap-4">
|
||||
<AccessLog memoryId={memory?.id || ""} />
|
||||
<RelatedMemories memoryId={memory?.id || ""} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useMemoriesApi } from "@/hooks/useMemoriesApi";
|
||||
import { useSelector } from "react-redux";
|
||||
import { RootState } from "@/store/store";
|
||||
import { Memory } from "@/components/types";
|
||||
import Categories from "@/components/shared/categories";
|
||||
import Link from "next/link";
|
||||
import { formatDate } from "@/lib/helpers";
|
||||
interface RelatedMemoriesProps {
|
||||
memoryId: string;
|
||||
}
|
||||
|
||||
export function RelatedMemories({ memoryId }: RelatedMemoriesProps) {
|
||||
const { fetchRelatedMemories } = useMemoriesApi();
|
||||
const relatedMemories = useSelector(
|
||||
(state: RootState) => state.memories.relatedMemories
|
||||
);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const loadRelatedMemories = async () => {
|
||||
try {
|
||||
await fetchRelatedMemories(memoryId);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch related memories:", error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadRelatedMemories();
|
||||
}, []);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="w-full max-w-2xl mx-auto rounded-lg overflow-hidden bg-zinc-900 text-white p-6">
|
||||
<p className="text-center text-zinc-500">Loading related memories...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!relatedMemories.length) {
|
||||
return (
|
||||
<div className="w-full max-w-2xl mx-auto rounded-lg overflow-hidden bg-zinc-900 text-white p-6">
|
||||
<p className="text-center text-zinc-500">No related memories found</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-2xl mx-auto rounded-lg overflow-hidden bg-zinc-900 border border-zinc-800 text-white">
|
||||
<div className="px-6 py-4 flex justify-between items-center bg-zinc-800 border-b border-zinc-800">
|
||||
<h2 className="font-semibold">Related Memories</h2>
|
||||
</div>
|
||||
<div className="space-y-6 p-6">
|
||||
{relatedMemories.map((memory: Memory) => (
|
||||
<div
|
||||
key={memory.id}
|
||||
className="border-l-2 border-zinc-800 pl-6 py-1 hover:bg-zinc-700/10 transition-colors cursor-pointer"
|
||||
>
|
||||
<Link href={`/memory/${memory.id}`}>
|
||||
<h3 className="font-medium mb-3">{memory.memory}</h3>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Categories
|
||||
categories={memory.categories}
|
||||
isPaused={
|
||||
memory.state === "paused" || memory.state === "archived"
|
||||
}
|
||||
concat={true}
|
||||
/>
|
||||
{memory.state !== "active" && (
|
||||
<span className="inline-block px-3 border border-yellow-600 text-yellow-600 font-semibold text-xs rounded-full bg-yellow-400/10 backdrop-blur-sm">
|
||||
{memory.state === "paused" ? "Paused" : "Archived"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="text-zinc-400 text-sm">
|
||||
{formatDate(memory.created_at)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
"use client";
|
||||
|
||||
import "@/styles/animation.css";
|
||||
import { useEffect } from "react";
|
||||
import { useMemoriesApi } from "@/hooks/useMemoriesApi";
|
||||
import { use } from "react";
|
||||
import { MemorySkeleton } from "@/skeleton/MemorySkeleton";
|
||||
import { MemoryDetails } from "./components/MemoryDetails";
|
||||
import UpdateMemory from "@/components/shared/update-memory";
|
||||
import { useUI } from "@/hooks/useUI";
|
||||
import { RootState } from "@/store/store";
|
||||
import { useSelector } from "react-redux";
|
||||
import NotFound from "@/app/not-found";
|
||||
|
||||
function MemoryContent({ id }: { id: string }) {
|
||||
const { fetchMemoryById, isLoading, error } = useMemoriesApi();
|
||||
const memory = useSelector(
|
||||
(state: RootState) => state.memories.selectedMemory
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const loadMemory = async () => {
|
||||
try {
|
||||
await fetchMemoryById(id);
|
||||
} catch (err) {
|
||||
console.error("Failed to load memory:", err);
|
||||
}
|
||||
};
|
||||
loadMemory();
|
||||
}, []);
|
||||
|
||||
if (isLoading) {
|
||||
return <MemorySkeleton />;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <NotFound message={error} />;
|
||||
}
|
||||
|
||||
if (!memory) {
|
||||
return <NotFound message="Memory not found" statusCode={404} />;
|
||||
}
|
||||
|
||||
return <MemoryDetails memory_id={memory.id} />;
|
||||
}
|
||||
|
||||
export default function MemoryPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const resolvedParams = use(params);
|
||||
const { updateMemoryDialog, handleCloseUpdateMemoryDialog } = useUI();
|
||||
return (
|
||||
<div>
|
||||
<div className="animate-fade-slide-down delay-1">
|
||||
<UpdateMemory
|
||||
memoryId={updateMemoryDialog.memoryId || ""}
|
||||
memoryContent={updateMemoryDialog.memoryContent || ""}
|
||||
open={updateMemoryDialog.isOpen}
|
||||
onOpenChange={handleCloseUpdateMemoryDialog}
|
||||
/>
|
||||
</div>
|
||||
<div className="animate-fade-slide-down delay-2">
|
||||
<MemoryContent id={resolvedParams.id} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import "@/styles/notfound.scss";
|
||||
import Link from "next/link";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
interface NotFoundProps {
|
||||
statusCode?: number;
|
||||
message?: string;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
const getStatusCode = (message: string) => {
|
||||
const possibleStatusCodes = ["404", "403", "500", "422"];
|
||||
const potentialStatusCode = possibleStatusCodes.find((code) =>
|
||||
message.includes(code)
|
||||
);
|
||||
return potentialStatusCode ? parseInt(potentialStatusCode) : undefined;
|
||||
};
|
||||
|
||||
export default function NotFound({
|
||||
statusCode,
|
||||
message = "Page Not Found",
|
||||
title,
|
||||
}: NotFoundProps) {
|
||||
const potentialStatusCode = getStatusCode(message);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-[calc(100vh-100px)]">
|
||||
<div className="site">
|
||||
<div className="sketch">
|
||||
<div className="bee-sketch red"></div>
|
||||
<div className="bee-sketch blue"></div>
|
||||
</div>
|
||||
<h1>
|
||||
{statusCode
|
||||
? `${statusCode}:`
|
||||
: potentialStatusCode
|
||||
? `${potentialStatusCode}:`
|
||||
: "404"}
|
||||
<small>{title || message || "Page Not Found"}</small>
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div className="">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="bg-primary text-white hover:bg-primary/80"
|
||||
>
|
||||
<Link href="/">Go Home</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
"use client";
|
||||
|
||||
import { Install } from "@/components/dashboard/Install";
|
||||
import Stats from "@/components/dashboard/Stats";
|
||||
import { MemoryFilters } from "@/app/memories/components/MemoryFilters";
|
||||
import { MemoriesSection } from "@/app/memories/components/MemoriesSection";
|
||||
import "@/styles/animation.css";
|
||||
|
||||
export default function DashboardPage() {
|
||||
return (
|
||||
<div className="text-white py-6">
|
||||
<div className="container">
|
||||
<div className="w-full mx-auto space-y-6">
|
||||
<div className="grid grid-cols-3 gap-6">
|
||||
{/* Memory Category Breakdown */}
|
||||
<div className="col-span-2 animate-fade-slide-down">
|
||||
<Install />
|
||||
</div>
|
||||
|
||||
{/* Memories Stats */}
|
||||
<div className="col-span-1 animate-fade-slide-down delay-1">
|
||||
<Stats />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="animate-fade-slide-down delay-2">
|
||||
<MemoryFilters />
|
||||
</div>
|
||||
<div className="animate-fade-slide-down delay-3">
|
||||
<MemoriesSection />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user