chore: remove OpenMemory from the monorepo (#6530)
This commit is contained in:
@@ -17,10 +17,6 @@
|
||||
"claude code", "opencode", "pi agent", "mem0-plugin",
|
||||
"cursor plugin", "codex plugin", "editor plugin"
|
||||
],
|
||||
"openmemory": [
|
||||
"openmemory", "open memory", "localhost:8765", "localhost:3000",
|
||||
"openmemory ui", "openmemory/api", "openmemory/ui"
|
||||
],
|
||||
"cli": ["mem0-cli", "@mem0/cli", "npx mem0", "command line"],
|
||||
"vector-store": [
|
||||
"pgvector", "pinecone", "chroma", "chromadb", "weaviate",
|
||||
|
||||
@@ -23,10 +23,6 @@ rest-api:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file: 'server/**'
|
||||
|
||||
openmemory:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file: 'openmemory/**'
|
||||
|
||||
integrations:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file: 'integrations/**'
|
||||
|
||||
@@ -35,12 +35,6 @@ const cases = [
|
||||
body: "### 🐛 Describe the bug\n\nI'm using docker compose to deploy a REST API server. When adding memory, I'm unable to set the expiration_date. Is this feature not supported?",
|
||||
expected: ["rest-api"],
|
||||
},
|
||||
{
|
||||
number: 3444,
|
||||
title: "Fix: Openmemory run.sh non-existent vector-store route",
|
||||
body: "### 🐛 Describe the bug\n\n# Vector_store not implemented\nThere is many references to ` ${NEXT_PUBLIC_API_URL}/api/v1/config/mem0/vector_store` in lines 280, 293, 306, 319, 332, 345, 358, and 371. \n```bash\ncurl -fsS -X PUT \"${NEXT_PUBLIC_API_URL}/api/v1/config/mem0/vector_store\" # Line 280 and for each vector store\n```\nBut the api route is not implemented in `api/app/routers/config.py`.\n# Suggested solution\nI would implement `vector_store` route or remove and use `update_configuration` for all config updates. Also Create class with all config keys for vector_store",
|
||||
expected: ["openmemory"],
|
||||
},
|
||||
{
|
||||
number: 6252,
|
||||
title: "cursor: on_file_read_cursor.sh ignores auto_search / MEM0_AUTO_SEARCH",
|
||||
|
||||
@@ -27,7 +27,7 @@ jobs:
|
||||
script: |
|
||||
const allowed = new Set([
|
||||
'sdk-python', 'sdk-typescript', 'vector-store', 'plugin',
|
||||
'rest-api', 'openmemory', 'documentation', 'ci', 'cli', 'integrations',
|
||||
'rest-api', 'documentation', 'ci', 'cli', 'integrations',
|
||||
]);
|
||||
const umbrella = { plugin: 'integrations' };
|
||||
const { repository } = await github.graphql(
|
||||
|
||||
@@ -28,7 +28,6 @@ This is a **polyglot monorepo** containing Python and TypeScript packages, CLIs,
|
||||
| `integrations/pi-agent-plugin/` | `@mem0/pi-agent-plugin` — Pi Agent plugin |
|
||||
| `integrations/vercel-ai-sdk/` | `@mem0/vercel-ai-provider` — Vercel AI SDK memory provider |
|
||||
| `server/` | FastAPI REST server for self-hosted Mem0 (Docker: FastAPI + PostgreSQL/pgvector + Neo4j) |
|
||||
| `openmemory/` | Self-hosted memory platform — `api/` (FastAPI + Alembic + MCP server) and `ui/` (Next.js 15 + React 19) |
|
||||
| `skills/` | Claude Code skill definitions. Reference skills (SDK knowledge, always-on): `mem0/`, `mem0-cli/`, `mem0-vercel-ai-sdk/`. Pipeline skills (run on demand): `mem0-integrate/`, `mem0-test-integration/`, `mem0-oss-to-platform/` |
|
||||
| `docs/` | Documentation site (Mintlify) |
|
||||
| `tests/` | Python SDK tests (pytest) |
|
||||
@@ -62,7 +61,7 @@ integrations/openclaw/ ──▶ mem0ai (npm)
|
||||
- **Node.js**: v18+ (v20 or v22 recommended)
|
||||
- **pnpm**: v10+ (`npm install -g pnpm@10`) — used for all TypeScript packages
|
||||
- **Hatch**: Python build/environment tool (`pip install hatch`)
|
||||
- **Docker**: Required for `server/` and `openmemory/` development
|
||||
- **Docker**: Required for `server/` development
|
||||
|
||||
### Initial Setup
|
||||
|
||||
@@ -214,28 +213,6 @@ docker-compose up # starts all 3 services
|
||||
- **Services:** PostgreSQL with pgvector, Neo4j 5.x with APOC plugin
|
||||
- **Hot reload:** Dev Dockerfile mounts `server/` and `mem0/` for live changes
|
||||
|
||||
### OpenMemory (`openmemory/`)
|
||||
|
||||
```bash
|
||||
# Full stack via Docker Compose
|
||||
cd openmemory
|
||||
docker-compose up
|
||||
# Qdrant: localhost:6333
|
||||
# API (MCP): localhost:8765
|
||||
# UI: localhost:3000
|
||||
|
||||
# Individual development
|
||||
cd openmemory/api && uvicorn main:app --reload # FastAPI backend
|
||||
cd openmemory/ui && npm run dev # Next.js frontend
|
||||
|
||||
# Tests
|
||||
cd openmemory/api && pytest tests/ # API tests (e.g., test_mcp_server.py)
|
||||
```
|
||||
|
||||
- **API:** FastAPI + Alembic (DB migrations) + MCP server (Model Context Protocol)
|
||||
- **UI:** Next.js 15, React 19, Radix UI, Redux Toolkit, TailwindCSS, Recharts
|
||||
- **Vector store:** Qdrant
|
||||
|
||||
### Documentation (`docs/`)
|
||||
|
||||
```bash
|
||||
@@ -331,7 +308,6 @@ python -m benchmarks.beam.run --project-name my-test --backend cloud --mem0-api-
|
||||
- Root SDK: line length **120**
|
||||
- Python CLI: line length **100** with extended rule set (UP, B, SIM, RUF)
|
||||
- **isort** with `profile = "black"` for import sorting.
|
||||
- Ruff excludes `openmemory/` from root config.
|
||||
|
||||
### TypeScript Conventions
|
||||
|
||||
@@ -382,7 +358,6 @@ Optional layer on top of vector memory for relationship-aware retrieval. Configu
|
||||
Model Context Protocol support in multiple places:
|
||||
|
||||
- **Remote:** MCP server at `mcp.mem0.ai`
|
||||
- **Local:** MCP server in `openmemory/api/` (FastAPI-based)
|
||||
- **Plugin:** MCP tools in `integrations/mem0-plugin/` — 9 tools: `add_memory`, `search_memories`, `get_memories`, `get_memory`, `update_memory`, `delete_memory`, `delete_all_memories`, `delete_entities`, `list_entities`
|
||||
|
||||
### Plugin & Skills System
|
||||
@@ -585,7 +560,7 @@ N/A
|
||||
|
||||
- Follow existing code patterns — don't introduce new frameworks or abstractions without discussion.
|
||||
- Version bumps go in `pyproject.toml` (Python) or `package.json` (TypeScript).
|
||||
- For `server/` and `openmemory/` work, use Docker Compose for local development.
|
||||
- For `server/` work, use Docker Compose for local development.
|
||||
- Do NOT use `pip` or `conda` for dependency management — use `hatch` (see `docs/contributing/development.mdx`).
|
||||
|
||||
### Contributing Guides
|
||||
@@ -608,5 +583,4 @@ N/A
|
||||
- Use npm or yarn in TypeScript packages — this repo uses pnpm exclusively.
|
||||
- Use `require()` for imports in TypeScript — use ES module `import` syntax.
|
||||
- Mix up linter configs: root Python SDK uses line-length 120, Python CLI uses 100, Node CLI uses Biome (not ESLint/Ruff).
|
||||
- Modify `openmemory/` database migrations without understanding the Alembic migration chain.
|
||||
- Change public APIs without updating documentation in `docs/`.
|
||||
|
||||
+1
-1
@@ -45,7 +45,7 @@ The two most common contribution targets are the SDKs:
|
||||
| TypeScript SDK (`mem0ai`) | `mem0-ts/` | TypeScript | `pnpm` |
|
||||
|
||||
Other packages include the CLIs (`cli/python/`, `cli/node/`), integrations
|
||||
(`integrations/`), the self-hosted `server/`, `openmemory/`, and the docs site
|
||||
(`integrations/`), the self-hosted `server/`, and the docs site
|
||||
(`docs/`). See [AGENTS.md](./AGENTS.md) for a full map of the repository.
|
||||
|
||||
## Development Workflow
|
||||
|
||||
+1
-1
@@ -21,7 +21,7 @@ privately through one of the following channels:
|
||||
To help us triage and resolve the issue quickly, please include as much of the
|
||||
following as you can:
|
||||
|
||||
- Affected component or package (e.g. Python SDK, TypeScript SDK, server, OpenMemory)
|
||||
- Affected component or package (e.g. Python SDK, TypeScript SDK, server, CLI)
|
||||
- Affected version, tag, or commit
|
||||
- Clear, step-by-step reproduction instructions
|
||||
- The security impact and a proof of concept, if available
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 92 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 66 KiB |
@@ -416,7 +416,6 @@ Editor-specific setup docs (already listed above under `## Integrations > AI Cod
|
||||
### MCP Endpoints
|
||||
|
||||
- Hosted MCP server: `https://mcp.mem0.ai` - requires Platform API key. See `platform/mem0-mcp`.
|
||||
- Self-hosted MCP server: ships with `openmemory/api/` (FastAPI) - runs against your own Qdrant + LLM stack.
|
||||
|
||||
## Community & Support
|
||||
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
<svg width="307" height="307" viewBox="0 0 307 307" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M162.496 25.3505C165.003 25.3505 167.453 24.6071 169.538 23.2144C171.622 21.8216 173.247 19.8419 174.206 17.5258C175.165 15.2097 175.416 12.6612 174.927 10.2024C174.438 7.74365 173.231 5.48516 171.458 3.71249C169.686 1.93983 167.427 0.73263 164.968 0.243552C162.51 -0.245525 159.961 0.00550576 157.645 0.964866C155.329 1.92423 153.349 3.54885 151.956 5.63328C150.564 7.71772 149.82 10.1683 149.82 12.6753C149.818 14.3404 150.145 15.9895 150.781 17.5283C151.417 19.0671 152.351 20.4653 153.528 21.6427C154.706 22.8201 156.104 23.7537 157.643 24.39C159.181 25.0262 160.83 25.3526 162.496 25.3505Z" fill="#9C58FA"/>
|
||||
<path d="M69.3342 56.559C71.1066 54.7862 72.3135 52.5277 72.8024 50.069C73.2913 47.6103 73.0401 45.0619 72.0807 42.7459C71.1213 40.43 69.4967 38.4505 67.4123 37.0579C65.3279 35.6652 62.8774 34.9219 60.3706 34.9219C57.8637 34.9219 55.4132 35.6652 53.3288 37.0579C51.2444 38.4505 49.6198 40.43 48.6604 42.7459C47.701 45.0619 47.4498 47.6103 47.9387 50.069C48.4276 52.5277 49.6345 54.7862 51.4069 56.559C52.5839 57.7363 53.9813 58.6701 55.5193 59.3073C57.0573 59.9444 58.7058 60.2724 60.3706 60.2724C62.0353 60.2724 63.6838 59.9444 65.2218 59.3073C66.7598 58.6701 68.1572 57.7363 69.3342 56.559Z" fill="#9C58FA"/>
|
||||
<path d="M25.3505 144.504C25.3505 141.997 24.6071 139.547 23.2143 137.462C21.8216 135.378 19.842 133.753 17.5259 132.794C15.2098 131.835 12.6612 131.584 10.2024 132.073C7.74368 132.562 5.48513 133.769 3.71247 135.542C1.9398 137.314 0.732655 139.573 0.243578 142.032C-0.2455 144.49 0.00543354 147.039 0.964793 149.355C1.92415 151.671 3.54877 153.651 5.63321 155.044C7.71764 156.436 10.1683 157.18 12.6752 157.18C16.0369 157.18 19.261 155.844 21.638 153.467C24.0151 151.09 25.3505 147.866 25.3505 144.504Z" fill="#9C58FA"/>
|
||||
<path d="M56.5589 237.749C54.7862 235.976 52.5277 234.769 50.069 234.28C47.6103 233.792 45.0619 234.043 42.7459 235.002C40.43 235.962 38.4505 237.586 37.0579 239.671C35.6652 241.755 34.9219 244.206 34.9219 246.712C34.9219 249.219 35.6652 251.67 37.0579 253.754C38.4505 255.838 40.43 257.463 42.7459 258.423C45.0619 259.382 47.6103 259.633 50.069 259.144C52.5277 258.655 54.7862 257.448 56.5589 255.676C57.7362 254.499 58.6701 253.102 59.3073 251.564C59.9444 250.026 60.2724 248.377 60.2724 246.712C60.2724 245.048 59.9444 243.399 59.3073 241.861C58.6701 240.323 57.7362 238.926 56.5589 237.749Z" fill="#9C58FA"/>
|
||||
<path d="M144.488 281.648C141.981 281.648 139.53 282.392 137.446 283.785C135.361 285.177 133.737 287.157 132.777 289.473C131.818 291.789 131.567 294.338 132.056 296.797C132.545 299.255 133.752 301.514 135.525 303.286C137.298 305.059 139.556 306.266 142.015 306.755C144.474 307.244 147.022 306.993 149.338 306.034C151.655 305.075 153.634 303.45 155.027 301.366C156.42 299.281 157.163 296.831 157.163 294.324C157.159 290.963 155.822 287.742 153.446 285.366C151.07 282.989 147.848 281.653 144.488 281.648Z" fill="#9C58FA"/>
|
||||
<path d="M237.751 250.487C235.978 252.26 234.771 254.518 234.282 256.977C233.794 259.435 234.045 261.984 235.004 264.3C235.964 266.616 237.588 268.595 239.673 269.988C241.757 271.381 244.207 272.124 246.714 272.124C249.221 272.124 251.672 271.381 253.756 269.988C255.84 268.595 257.465 266.616 258.424 264.3C259.384 261.984 259.635 259.435 259.146 256.977C258.657 254.518 257.45 252.26 255.678 250.487C254.501 249.31 253.104 248.376 251.566 247.739C250.028 247.101 248.379 246.773 246.714 246.773C245.05 246.773 243.401 247.101 241.863 247.739C240.325 248.376 238.928 249.31 237.751 250.487Z" fill="#9C58FA"/>
|
||||
<path d="M281.648 162.512C281.648 165.019 282.392 167.469 283.785 169.554C285.177 171.638 287.157 173.263 289.473 174.222C291.789 175.181 294.338 175.432 296.797 174.943C299.255 174.454 301.514 173.247 303.286 171.474C305.059 169.702 306.266 167.443 306.755 164.984C307.244 162.526 306.993 159.977 306.034 157.661C305.075 155.345 303.45 153.365 301.366 151.973C299.281 150.58 296.831 149.836 294.324 149.836C290.962 149.836 287.738 151.172 285.361 153.549C282.984 155.926 281.648 159.15 281.648 162.512Z" fill="#9C58FA"/>
|
||||
<path d="M250.471 69.3303C252.244 71.1027 254.503 72.3097 256.961 72.7985C259.42 73.2874 261.968 73.0363 264.284 72.0768C266.6 71.1174 268.58 69.4928 269.972 67.4084C271.365 65.324 272.108 62.8735 272.108 60.3667C272.108 57.8599 271.365 55.4093 269.972 53.3249C268.58 51.2406 266.6 49.616 264.284 48.6565C261.968 47.6971 259.42 47.4459 256.961 47.9348C254.503 48.4236 252.244 49.6306 250.471 51.403C249.294 52.58 248.36 53.9775 247.723 55.5155C247.086 57.0535 246.758 58.7019 246.758 60.3667C246.758 62.0314 247.086 63.6799 247.723 65.2179C248.36 66.7559 249.294 68.1533 250.471 69.3303Z" fill="#9C58FA"/>
|
||||
<path d="M184.782 60.8054C180.168 63.4713 178.3 69.0427 177.63 74.3267C177.047 78.9358 175.033 83.2457 171.87 86.6488C168.707 90.052 164.556 92.3766 160.002 93.2951C155.448 94.2136 150.721 93.6796 146.487 91.7683C142.252 89.857 138.724 86.6649 136.401 82.642C134.077 78.6192 133.075 73.9684 133.535 69.3455C133.995 64.7226 135.895 60.3607 138.966 56.8748C142.037 53.389 146.125 50.9549 150.653 49.9159C155.181 48.8768 159.921 49.2852 164.204 51.0834C169.121 53.1428 174.884 54.2762 179.514 51.6422C184.143 49.0082 185.995 43.4049 186.665 38.1208C187.245 33.5107 189.257 29.1987 192.418 25.7931C195.579 22.3876 199.73 20.0603 204.284 19.1394C208.838 18.2185 213.567 18.7505 217.803 20.6605C222.039 22.5704 225.568 25.7618 227.893 29.7846C230.218 33.8075 231.222 38.4587 230.763 43.0824C230.303 47.7062 228.404 52.0691 225.333 55.5559C222.262 59.0426 218.173 61.4773 213.645 62.5165C209.116 63.5557 204.375 63.147 200.091 61.3481C195.174 59.3048 189.411 58.1554 184.782 60.8054Z" fill="#9C58FA"/>
|
||||
<path d="M110.073 65.8178C108.7 70.9742 111.318 76.2422 114.575 80.4567C117.417 84.1261 119.036 88.595 119.204 93.2335C119.372 97.872 118.08 102.446 115.51 106.311C112.941 110.177 109.223 113.138 104.881 114.778C100.538 116.419 95.7912 116.655 91.3077 115.454C86.8242 114.253 82.8306 111.675 79.8898 108.084C76.9489 104.493 75.2091 100.07 74.9155 95.4379C74.6219 90.8057 75.7894 86.1981 78.2533 82.2645C80.7173 78.331 84.3534 75.2698 88.6494 73.5124C93.5822 71.485 98.4991 68.2444 99.8241 63.0881C101.149 57.9317 98.579 52.6637 95.3224 48.4493C92.4827 44.7781 90.8665 40.3083 90.7018 35.6699C90.537 31.0315 91.8319 26.4583 94.4039 22.5949C96.976 18.7314 100.695 15.7724 105.038 14.1349C109.381 12.4974 114.128 12.2639 118.611 13.4673C123.094 14.6708 127.085 17.2505 130.024 20.8429C132.963 24.4354 134.7 28.8594 134.991 33.4916C135.283 38.1237 134.113 42.7305 131.647 46.6627C129.182 50.5948 125.544 53.6541 121.248 55.4095C116.363 57.4209 111.462 60.6775 110.073 65.8178Z" fill="#9C58FA"/>
|
||||
<path d="M60.7892 122.218C63.4552 126.831 69.0425 128.699 74.3265 129.37C78.9361 129.955 83.2455 131.973 86.6471 135.138C90.0487 138.304 92.3707 142.457 93.2857 147.013C94.2006 151.569 93.6625 156.296 91.747 160.53C89.8314 164.763 86.6353 168.288 82.6093 170.608C78.5833 172.928 73.9305 173.926 69.3073 173.46C64.6841 172.995 60.3236 171.09 56.841 168.014C53.3583 164.938 50.9292 160.846 49.8962 156.316C48.8631 151.785 49.2783 147.045 51.0832 142.763C53.1426 137.846 54.2759 132.083 51.6419 127.454C49.0079 122.824 43.4046 120.973 38.1047 120.302C33.4951 119.717 29.1856 117.699 25.7841 114.533C22.3825 111.368 20.0604 107.214 19.1454 102.659C18.2304 98.1032 18.7687 93.3752 20.6842 89.1418C22.5997 84.9084 25.7959 81.3832 29.8219 79.0632C33.8479 76.7433 38.5006 75.7457 43.1238 76.2113C47.7471 76.6768 52.1075 78.582 55.5902 81.658C59.0728 84.7341 61.502 88.8258 62.535 93.3561C63.568 97.8865 63.1528 102.627 61.3479 106.908C59.2886 111.825 58.1552 117.588 60.7892 122.218Z" fill="#9C58FA"/>
|
||||
<path d="M65.8204 196.93C70.9767 198.303 76.2287 195.685 80.4592 192.428C84.1286 189.586 88.5975 187.967 93.236 187.799C97.8745 187.631 102.449 188.923 106.314 191.493C110.179 194.062 113.141 197.78 114.781 202.122C116.421 206.464 116.657 211.212 115.457 215.695C114.256 220.179 111.678 224.172 108.087 227.113C104.496 230.054 100.073 231.794 95.4404 232.087C90.8082 232.381 86.2006 231.214 82.2671 228.75C78.3335 226.286 75.2723 222.649 73.5149 218.353C71.4875 213.421 68.231 208.504 63.0906 207.179C57.9503 205.854 52.6662 208.424 48.4518 211.681C44.7804 214.528 40.308 216.151 35.6652 216.32C31.0224 216.49 26.4435 215.199 22.5738 212.628C18.7042 210.057 15.7391 206.336 14.0968 201.99C12.4544 197.644 12.2176 192.892 13.4197 188.404C14.6218 183.917 17.2021 179.919 20.7969 176.976C24.3917 174.033 28.8195 172.293 33.4562 172C38.0929 171.708 42.7045 172.878 46.6407 175.345C50.577 177.813 53.6394 181.454 55.3961 185.755C57.4235 190.656 60.6641 195.541 65.8204 196.93Z" fill="#9C58FA"/>
|
||||
<path d="M122.205 246.21C126.818 243.544 128.686 237.956 129.373 232.672C129.96 228.068 131.978 223.763 135.142 220.366C138.306 216.969 142.456 214.651 147.008 213.738C151.559 212.825 156.283 213.364 160.512 215.278C164.741 217.192 168.263 220.385 170.58 224.408C172.898 228.43 173.895 233.078 173.43 237.697C172.966 242.316 171.064 246.673 167.991 250.153C164.919 253.633 160.832 256.061 156.306 257.095C151.781 258.129 147.045 257.717 142.766 255.916C137.833 253.856 132.07 252.723 127.457 255.357C122.843 257.991 120.96 263.594 120.289 268.894C119.7 273.498 117.681 277.8 114.517 281.196C111.353 284.591 107.204 286.908 102.653 287.821C98.1027 288.733 93.3808 288.194 89.1525 286.281C84.9243 284.367 81.4031 281.175 79.085 277.154C76.767 273.134 75.7689 268.487 76.2316 263.869C76.6942 259.251 78.5942 254.895 81.6638 251.414C84.7334 247.933 88.8179 245.503 93.3417 244.466C97.8655 243.429 102.601 243.838 106.88 245.635C111.828 247.694 117.591 248.876 122.205 246.21Z" fill="#9C58FA"/>
|
||||
<path d="M196.915 241.18C198.304 236.024 195.686 230.756 192.414 226.542C189.567 222.87 187.944 218.398 187.774 213.755C187.604 209.112 188.896 204.533 191.467 200.664C194.038 196.794 197.759 193.829 202.104 192.187C206.45 190.544 211.202 190.307 215.69 191.509C220.178 192.712 224.175 195.292 227.118 198.887C230.061 202.481 231.802 206.909 232.094 211.546C232.387 216.183 231.217 220.794 228.749 224.731C226.281 228.667 222.64 231.729 218.339 233.486C213.406 235.513 208.505 238.77 207.164 243.91C205.823 249.051 208.393 254.335 211.666 258.549C214.513 262.22 216.136 266.693 216.306 271.335C216.476 275.978 215.184 280.557 212.613 284.427C210.042 288.297 206.321 291.262 201.975 292.904C197.629 294.546 192.877 294.783 188.39 293.581C183.902 292.379 179.905 289.799 176.962 286.204C174.019 282.609 172.278 278.181 171.985 273.545C171.693 268.908 172.863 264.296 175.331 260.36C177.799 256.424 181.44 253.361 185.741 251.605C190.658 249.577 195.543 246.337 196.915 241.18Z" fill="#9C58FA"/>
|
||||
<path d="M246.195 184.797C243.529 180.184 237.957 178.316 232.673 177.629C228.069 177.045 223.764 175.03 220.365 171.869C216.967 168.708 214.646 164.56 213.729 160.01C212.813 155.46 213.348 150.737 215.258 146.507C217.168 142.277 220.357 138.753 224.376 136.431C228.395 134.11 233.041 133.108 237.66 133.567C242.279 134.026 246.637 135.923 250.12 138.991C253.604 142.058 256.037 146.141 257.077 150.664C258.117 155.188 257.711 159.923 255.917 164.204C253.857 169.137 252.724 174.9 255.342 179.513C257.96 184.127 263.595 186.01 268.879 186.681C273.484 187.267 277.789 189.283 281.186 192.446C284.584 195.608 286.904 199.757 287.819 204.308C288.733 208.859 288.197 213.582 286.285 217.811C284.372 222.041 281.181 225.564 277.16 227.884C273.14 230.203 268.492 231.203 263.874 230.741C259.255 230.279 254.897 228.379 251.416 225.31C247.934 222.24 245.503 218.155 244.466 213.63C243.429 209.106 243.838 204.37 245.636 200.09C247.695 195.173 248.861 189.411 246.195 184.797Z" fill="#9C58FA"/>
|
||||
<path d="M241.18 110.07C236.024 108.697 230.756 111.315 226.542 114.588C222.87 117.435 218.398 119.058 213.755 119.228C209.112 119.398 204.533 118.106 200.664 115.535C196.794 112.964 193.829 109.243 192.187 104.897C190.544 100.551 190.307 95.7994 191.509 91.3117C192.712 86.824 195.292 82.8268 198.887 79.8837C202.481 76.9405 206.909 75.2 211.546 74.9073C216.183 74.6147 220.794 75.7849 224.731 78.2527C228.667 80.7206 231.729 84.3617 233.486 88.6627C235.513 93.5955 238.754 98.4964 243.91 99.8374C249.066 101.178 254.335 98.6082 258.549 95.3356C262.22 92.4959 266.69 90.8798 271.328 90.7151C275.967 90.5503 280.54 91.8452 284.403 94.4172C288.267 96.9892 291.226 100.709 292.863 105.052C294.501 109.394 294.734 114.142 293.531 118.624C292.327 123.107 289.748 127.099 286.155 130.037C282.563 132.976 278.139 134.714 273.507 135.005C268.875 135.296 264.268 134.126 260.336 131.661C256.403 129.195 253.344 125.557 251.589 121.261C249.577 116.36 246.321 111.459 241.18 110.07Z" fill="#9C58FA"/>
|
||||
<path d="M153.491 191.533C174.501 191.533 191.533 174.501 191.533 153.491C191.533 132.482 174.501 115.45 153.491 115.45C132.481 115.45 115.449 132.482 115.449 153.491C115.449 174.501 132.481 191.533 153.491 191.533Z" fill="#9C58FA"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 13 KiB |
@@ -62,10 +62,6 @@ SECTION_MAP = {
|
||||
"/open-source/features/rest-api",
|
||||
"/open-source/configure-components",
|
||||
],
|
||||
"openmemory": [
|
||||
"/openmemory/overview",
|
||||
"/openmemory/quickstart",
|
||||
],
|
||||
"sdks": [
|
||||
"/sdks/python",
|
||||
"/sdks/js",
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
*.db
|
||||
.env*
|
||||
!.env.example
|
||||
!.env.dev
|
||||
!ui/lib
|
||||
.venv/
|
||||
__pycache__
|
||||
.DS_Store
|
||||
node_modules/
|
||||
*.log
|
||||
api/.openmemory*
|
||||
**/.next
|
||||
.openmemory/
|
||||
ui/package-lock.json
|
||||
@@ -1,70 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,52 +0,0 @@
|
||||
.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
|
||||
@@ -1,168 +0,0 @@
|
||||
# OpenMemory
|
||||
|
||||
> **⚠️ Sunsetting Notice:** OpenMemory is being sunset. For local self-hosted memory with a dashboard, please use the [Mem0 self-hosted server](https://docs.mem0.ai/open-source/overview) instead. Get started with `cd server && make bootstrap`. See the [self-hosted docs](https://docs.mem0.ai/open-source/setup) for configuration details.
|
||||
|
||||
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
|
||||
|
||||
### 1. Set Up Environment Variables
|
||||
|
||||
Before running the project, you need to configure environment variables for both the API and the UI.
|
||||
|
||||
You can do this in one of the following ways:
|
||||
|
||||
- **Manually**:
|
||||
Create a `.env` file in each of the following directories:
|
||||
- `/api/.env`
|
||||
- `/ui/.env`
|
||||
|
||||
- **Using `.env.example` files**:
|
||||
Copy and rename the example files:
|
||||
|
||||
```bash
|
||||
cp api/.env.example api/.env
|
||||
cp ui/.env.example ui/.env
|
||||
```
|
||||
|
||||
- **Using Makefile** (if supported):
|
||||
Run:
|
||||
|
||||
```bash
|
||||
make env
|
||||
```
|
||||
- #### Example `/api/.env`
|
||||
|
||||
```env
|
||||
OPENAI_API_KEY=sk-xxx
|
||||
USER=<user-id> # The User Id you want to associate the memories with
|
||||
```
|
||||
|
||||
- #### LLM Configuration (optional)
|
||||
|
||||
By default, OpenMemory uses OpenAI (`gpt-4o-mini`) for the LLM and embedder. You can configure a different provider using these environment variables in `/api/.env`:
|
||||
|
||||
| Variable | Description | Default |
|
||||
|---|---|---|
|
||||
| `LLM_PROVIDER` | LLM provider (`openai`, `ollama`, `anthropic`, `groq`, `together`, `deepseek`, etc.) | `openai` |
|
||||
| `LLM_MODEL` | Model name for the LLM provider | `gpt-4o-mini` (OpenAI) / `llama3.1:latest` (Ollama) |
|
||||
| `LLM_API_KEY` | API key for the LLM provider | `OPENAI_API_KEY` env var |
|
||||
| `LLM_BASE_URL` | Custom base URL for the LLM API | Provider default |
|
||||
| `OLLAMA_BASE_URL` | Ollama-specific base URL (takes precedence over `LLM_BASE_URL` for Ollama) | `http://localhost:11434` |
|
||||
| `EMBEDDER_PROVIDER` | Embedder provider (defaults to `ollama` when LLM is Ollama, otherwise `openai`) | `openai` |
|
||||
| `EMBEDDER_MODEL` | Model name for the embedder | `text-embedding-3-small` (OpenAI) / `nomic-embed-text` (Ollama) |
|
||||
| `EMBEDDER_API_KEY` | API key for the embedder provider | `OPENAI_API_KEY` env var |
|
||||
| `EMBEDDER_BASE_URL` | Custom base URL for the embedder API | Provider default |
|
||||
|
||||
**Example: Using Ollama (fully local)**
|
||||
```env
|
||||
LLM_PROVIDER=ollama
|
||||
LLM_MODEL=llama3.1:latest
|
||||
EMBEDDER_PROVIDER=ollama
|
||||
EMBEDDER_MODEL=nomic-embed-text
|
||||
OLLAMA_BASE_URL=http://localhost:11434
|
||||
```
|
||||
|
||||
**Example: Using Anthropic**
|
||||
```env
|
||||
LLM_PROVIDER=anthropic
|
||||
LLM_MODEL=claude-sonnet-4-20250514
|
||||
LLM_API_KEY=sk-ant-xxx
|
||||
```
|
||||
- #### Example `/ui/.env`
|
||||
|
||||
```env
|
||||
NEXT_PUBLIC_API_URL=http://localhost:8765
|
||||
NEXT_PUBLIC_USER_ID=<user-id> # Same as the user id for environment variable in api
|
||||
```
|
||||
|
||||
### 2. Build and Run the Project
|
||||
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
|
||||
|
||||
#### UI not working on `localhost:3000`?
|
||||
|
||||
If the UI does not start properly on [http://localhost:3000](http://localhost:3000), try running it manually:
|
||||
|
||||
```bash
|
||||
cd ui
|
||||
pnpm install
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
### MCP Client Setup
|
||||
|
||||
Use the following one step command to configure OpenMemory Local MCP to a client. The general command format is as follows:
|
||||
|
||||
```bash
|
||||
npx @openmemory/install local http://localhost:8765/mcp/<client-name>/sse/<user-id> --client <client-name>
|
||||
```
|
||||
|
||||
Replace `<client-name>` with the desired client name and `<user-id>` with the value specified in your environment variables.
|
||||
|
||||
|
||||
## 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.
|
||||
@@ -1,23 +0,0 @@
|
||||
# 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
|
||||
@@ -1,15 +0,0 @@
|
||||
OPENAI_API_KEY=sk-xxx
|
||||
USER=user
|
||||
|
||||
# LLM Configuration (optional - defaults to openai/gpt-4o-mini)
|
||||
# LLM_PROVIDER=ollama
|
||||
# LLM_MODEL=llama3.1:latest
|
||||
# LLM_API_KEY=
|
||||
# LLM_BASE_URL=
|
||||
# OLLAMA_BASE_URL=http://localhost:11434
|
||||
|
||||
# Embedder Configuration (optional - defaults to openai/text-embedding-3-small)
|
||||
# EMBEDDER_PROVIDER=ollama
|
||||
# EMBEDDER_MODEL=nomic-embed-text
|
||||
# EMBEDDER_API_KEY=
|
||||
# EMBEDDER_BASE_URL=
|
||||
@@ -1 +0,0 @@
|
||||
3.12
|
||||
@@ -1,14 +0,0 @@
|
||||
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 config.json .
|
||||
COPY . .
|
||||
|
||||
EXPOSE 8765
|
||||
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8765"]
|
||||
@@ -1,60 +0,0 @@
|
||||
# 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
|
||||
@@ -1,114 +0,0 @@
|
||||
# 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
|
||||
@@ -1 +0,0 @@
|
||||
Generic single-database configuration.
|
||||
@@ -1,88 +0,0 @@
|
||||
import os
|
||||
import sys
|
||||
from logging.config import fileConfig
|
||||
|
||||
from alembic import context
|
||||
from dotenv import load_dotenv
|
||||
from sqlalchemy import engine_from_config, pool
|
||||
|
||||
# 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 - moved after path setup
|
||||
from app.database import Base # noqa: E402
|
||||
|
||||
# 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()
|
||||
@@ -1,28 +0,0 @@
|
||||
"""${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"}
|
||||
@@ -1,225 +0,0 @@
|
||||
"""Initial migration
|
||||
|
||||
Revision ID: 0b53c747049a
|
||||
Revises:
|
||||
Create Date: 2025-04-19 00:59:56.244203
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# 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 ###
|
||||
@@ -1,40 +0,0 @@
|
||||
"""add_config_table
|
||||
|
||||
Revision ID: add_config_table
|
||||
Revises: 0b53c747049a
|
||||
Create Date: 2023-06-01 10:00:00.000000
|
||||
|
||||
"""
|
||||
import uuid
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'add_config_table'
|
||||
down_revision = '0b53c747049a'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
# Create configs table if it doesn't exist
|
||||
op.create_table(
|
||||
'configs',
|
||||
sa.Column('id', sa.UUID(), nullable=False, default=lambda: uuid.uuid4()),
|
||||
sa.Column('key', sa.String(), nullable=False),
|
||||
sa.Column('value', sa.JSON(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=True),
|
||||
sa.Column('updated_at', sa.DateTime(), nullable=True),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('key')
|
||||
)
|
||||
|
||||
# Create index for key lookups
|
||||
op.create_index('idx_configs_key', 'configs', ['key'])
|
||||
|
||||
|
||||
def downgrade():
|
||||
# Drop the configs table
|
||||
op.drop_index('idx_configs_key', 'configs')
|
||||
op.drop_table('configs')
|
||||
@@ -1,34 +0,0 @@
|
||||
"""remove_global_unique_constraint_on_app_name_add_composite_unique
|
||||
|
||||
Revision ID: afd00efbd06b
|
||||
Revises: add_config_table
|
||||
Create Date: 2025-06-04 01:59:41.637440
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'afd00efbd06b'
|
||||
down_revision: Union[str, None] = 'add_config_table'
|
||||
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.drop_index('ix_apps_name', table_name='apps')
|
||||
op.create_index(op.f('ix_apps_name'), 'apps', ['name'], unique=False)
|
||||
op.create_index('idx_app_owner_name', 'apps', ['owner_id', 'name'], unique=True)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_index('idx_app_owner_name', table_name='apps')
|
||||
op.drop_index(op.f('ix_apps_name'), table_name='apps')
|
||||
op.create_index('ix_apps_name', 'apps', ['name'], unique=True)
|
||||
# ### end Alembic commands ###
|
||||
@@ -1 +0,0 @@
|
||||
# This file makes the app directory a Python package
|
||||
@@ -1,4 +0,0 @@
|
||||
import os
|
||||
|
||||
USER_ID = os.getenv("USER", "default_user")
|
||||
DEFAULT_APP_ID = "openmemory"
|
||||
@@ -1,30 +0,0 @@
|
||||
import os
|
||||
|
||||
from dotenv import load_dotenv
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import declarative_base, sessionmaker
|
||||
|
||||
# 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()
|
||||
@@ -1,574 +0,0 @@
|
||||
"""
|
||||
MCP Server for OpenMemory with resilient memory client handling.
|
||||
|
||||
This module implements an MCP (Model Context Protocol) server that provides
|
||||
memory operations for OpenMemory. The memory client is initialized lazily
|
||||
to prevent server crashes when external dependencies (like Ollama) are
|
||||
unavailable. If the memory client cannot be initialized, the server will
|
||||
continue running with limited functionality and appropriate error messages.
|
||||
|
||||
Key features:
|
||||
- Lazy memory client initialization
|
||||
- Graceful error handling for unavailable dependencies
|
||||
- Fallback to database-only mode when vector store is unavailable
|
||||
- Proper logging for debugging connection issues
|
||||
- Environment variable parsing for API keys
|
||||
"""
|
||||
|
||||
import contextvars
|
||||
import datetime
|
||||
import json
|
||||
import logging
|
||||
import uuid
|
||||
|
||||
import anyio
|
||||
|
||||
from app.database import SessionLocal
|
||||
from app.models import Memory, MemoryAccessLog, MemoryState, MemoryStatusHistory
|
||||
from app.utils.db import get_user_and_app
|
||||
from app.utils.memory import get_memory_client
|
||||
from app.utils.permissions import check_memory_access_permissions
|
||||
from dotenv import load_dotenv
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.routing import APIRouter
|
||||
from mcp.server.fastmcp import FastMCP
|
||||
from mcp.server.sse import SseServerTransport
|
||||
from mcp.server.streamable_http import StreamableHTTPServerTransport
|
||||
from starlette.responses import Response
|
||||
|
||||
# Load environment variables
|
||||
load_dotenv()
|
||||
|
||||
# Initialize MCP
|
||||
mcp = FastMCP("mem0-mcp-server")
|
||||
|
||||
# Don't initialize memory client at import time - do it lazily when needed
|
||||
def get_memory_client_safe():
|
||||
"""Get memory client with error handling. Returns None if client cannot be initialized."""
|
||||
try:
|
||||
return get_memory_client()
|
||||
except Exception as e:
|
||||
logging.warning(f"Failed to get memory client: {e}")
|
||||
return None
|
||||
|
||||
# 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 relevant information which can be useful in the future conversation. This can also be called when the user asks you to remember something. Set infer to False to store the memory verbatim without LLM fact extraction.")
|
||||
async def add_memories(text: str, infer: bool = True) -> 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"
|
||||
|
||||
# Get memory client safely
|
||||
memory_client = get_memory_client_safe()
|
||||
if not memory_client:
|
||||
return "Error: Memory system is currently unavailable. Please try again later."
|
||||
|
||||
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,
|
||||
},
|
||||
infer=infer)
|
||||
|
||||
# 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 json.dumps(response)
|
||||
finally:
|
||||
db.close()
|
||||
except Exception as e:
|
||||
logging.exception(f"Error adding to memory: {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"
|
||||
|
||||
# Get memory client safely
|
||||
memory_client = get_memory_client_safe()
|
||||
if not memory_client:
|
||||
return "Error: Memory system is currently unavailable. Please try again later."
|
||||
|
||||
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)]
|
||||
|
||||
filters = {
|
||||
"user_id": uid
|
||||
}
|
||||
|
||||
embeddings = memory_client.embedding_model.embed(query, "search")
|
||||
|
||||
hits = memory_client.vector_store.search(
|
||||
query=query,
|
||||
vectors=embeddings,
|
||||
limit=10,
|
||||
filters=filters,
|
||||
)
|
||||
|
||||
allowed = set(str(mid) for mid in accessible_memory_ids) if accessible_memory_ids else None
|
||||
|
||||
results = []
|
||||
for h in hits:
|
||||
# All vector db search functions return OutputData class
|
||||
id, score, payload = h.id, h.score, h.payload
|
||||
if allowed and (h.id is None or h.id not in allowed):
|
||||
continue
|
||||
|
||||
results.append({
|
||||
"id": id,
|
||||
"memory": payload.get("data"),
|
||||
"hash": payload.get("hash"),
|
||||
"created_at": payload.get("created_at"),
|
||||
"updated_at": payload.get("updated_at"),
|
||||
"score": score,
|
||||
})
|
||||
|
||||
for r in results:
|
||||
if r.get("id"):
|
||||
access_log = MemoryAccessLog(
|
||||
memory_id=uuid.UUID(r["id"]),
|
||||
app_id=app.id,
|
||||
access_type="search",
|
||||
metadata_={
|
||||
"query": query,
|
||||
"score": r.get("score"),
|
||||
"hash": r.get("hash"),
|
||||
},
|
||||
)
|
||||
db.add(access_log)
|
||||
db.commit()
|
||||
|
||||
return json.dumps({"results": results}, 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"
|
||||
|
||||
# Get memory client safely
|
||||
memory_client = get_memory_client_safe()
|
||||
if not memory_client:
|
||||
return "Error: Memory system is currently unavailable. Please try again later."
|
||||
|
||||
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:
|
||||
logging.exception(f"Error getting memories: {e}")
|
||||
return f"Error getting memories: {e}"
|
||||
|
||||
|
||||
@mcp.tool(description="Delete specific memories by their IDs")
|
||||
async def delete_memories(memory_ids: list[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"
|
||||
|
||||
# Get memory client safely
|
||||
memory_client = get_memory_client_safe()
|
||||
if not memory_client:
|
||||
return "Error: Memory system is currently unavailable. Please try again later."
|
||||
|
||||
try:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
# Get or create user and app
|
||||
user, app = get_user_and_app(db, user_id=uid, app_id=client_name)
|
||||
|
||||
# Convert string IDs to UUIDs and filter accessible ones
|
||||
requested_ids = [uuid.UUID(mid) for mid in memory_ids]
|
||||
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)]
|
||||
|
||||
# Only delete memories that are both requested and accessible
|
||||
ids_to_delete = [mid for mid in requested_ids if mid in accessible_memory_ids]
|
||||
|
||||
if not ids_to_delete:
|
||||
return "Error: No accessible memories found with provided IDs"
|
||||
|
||||
# Delete from vector store
|
||||
for memory_id in ids_to_delete:
|
||||
try:
|
||||
memory_client.delete(str(memory_id))
|
||||
except Exception as delete_error:
|
||||
logging.warning(f"Failed to delete memory {memory_id} from vector store: {delete_error}")
|
||||
|
||||
# Update each memory's state and create history entries
|
||||
now = datetime.datetime.now(datetime.UTC)
|
||||
for memory_id in ids_to_delete:
|
||||
memory = db.query(Memory).filter(Memory.id == memory_id).first()
|
||||
if memory:
|
||||
# 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",
|
||||
metadata_={"operation": "delete_by_id"}
|
||||
)
|
||||
db.add(access_log)
|
||||
|
||||
db.commit()
|
||||
return f"Successfully deleted {len(ids_to_delete)} memories"
|
||||
finally:
|
||||
db.close()
|
||||
except Exception as e:
|
||||
logging.exception(f"Error deleting memories: {e}")
|
||||
return f"Error deleting 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"
|
||||
|
||||
# Get memory client safely
|
||||
memory_client = get_memory_client_safe()
|
||||
if not memory_client:
|
||||
return "Error: Memory system is currently unavailable. Please try again later."
|
||||
|
||||
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:
|
||||
try:
|
||||
memory_client.delete(str(memory_id))
|
||||
except Exception as delete_error:
|
||||
logging.warning(f"Failed to delete memory {memory_id} from vector store: {delete_error}")
|
||||
|
||||
# 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:
|
||||
logging.exception(f"Error deleting memories: {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:
|
||||
# NOTE: request._send is the raw ASGI `send` callable. Starlette does not
|
||||
# expose it publicly, but the MCP SDK transports require the raw ASGI
|
||||
# interface (scope, receive, send). This is the standard pattern from the
|
||||
# MCP Python SDK examples.
|
||||
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
|
||||
|
||||
|
||||
@mcp_router.api_route("/{client_name}/http/{user_id}", methods=["POST", "GET", "DELETE"])
|
||||
async def handle_streamable_http(request: Request):
|
||||
"""Handle Streamable HTTP connections for a specific user and client.
|
||||
|
||||
Uses the Streamable HTTP transport (MCP spec 2025-03-26+) which replaces
|
||||
the deprecated SSE transport. Runs in stateless mode — each request is
|
||||
handled independently with no persistent session.
|
||||
|
||||
The transport writes its response directly to the ASGI ``send`` callable.
|
||||
We intercept it via ``capture_send`` so we can return a proper ``Response``
|
||||
to FastAPI — otherwise FastAPI would also try to send its own response,
|
||||
causing a "double-response" bug.
|
||||
"""
|
||||
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 "")
|
||||
|
||||
# Intercept the ASGI messages the transport sends so we can return them
|
||||
# as a single Response to FastAPI. Without this, FastAPI would attempt to
|
||||
# write its own response after the transport already wrote one.
|
||||
response_started = False
|
||||
response_status = 200
|
||||
response_headers: list[tuple[bytes, bytes]] = []
|
||||
response_body = bytearray()
|
||||
|
||||
async def capture_send(message):
|
||||
nonlocal response_started, response_status
|
||||
if message["type"] == "http.response.start":
|
||||
response_started = True
|
||||
response_status = message["status"]
|
||||
response_headers.extend(message.get("headers", []))
|
||||
elif message["type"] == "http.response.body":
|
||||
response_body.extend(message.get("body", b""))
|
||||
|
||||
try:
|
||||
transport = StreamableHTTPServerTransport(
|
||||
mcp_session_id=None,
|
||||
is_json_response_enabled=True,
|
||||
)
|
||||
|
||||
async with anyio.create_task_group() as tg:
|
||||
|
||||
async def run_server(*, task_status=anyio.TASK_STATUS_IGNORED):
|
||||
async with transport.connect() as (read_stream, write_stream):
|
||||
task_status.started()
|
||||
await mcp._mcp_server.run(
|
||||
read_stream,
|
||||
write_stream,
|
||||
mcp._mcp_server.create_initialization_options(),
|
||||
stateless=True,
|
||||
)
|
||||
|
||||
await tg.start(run_server)
|
||||
await transport.handle_request(request.scope, request.receive, capture_send)
|
||||
await transport.terminate()
|
||||
tg.cancel_scope.cancel()
|
||||
finally:
|
||||
user_id_var.reset(user_token)
|
||||
client_name_var.reset(client_token)
|
||||
|
||||
if not response_started:
|
||||
return Response(status_code=500, content=b"Transport did not produce a response")
|
||||
|
||||
# Header dict conversion is safe here: the MCP transport in stateless JSON
|
||||
# mode only emits single-valued headers (Content-Type, Content-Length).
|
||||
return Response(
|
||||
content=bytes(response_body),
|
||||
status_code=response_status,
|
||||
headers={k.decode(): v.decode() for k, v in response_headers},
|
||||
)
|
||||
|
||||
|
||||
def setup_mcp_server(app: FastAPI):
|
||||
"""Setup MCP server with the FastAPI application"""
|
||||
mcp._mcp_server.name = "mem0-mcp-server"
|
||||
|
||||
# Include MCP router in the FastAPI app
|
||||
app.include_router(mcp_router)
|
||||
@@ -1,243 +0,0 @@
|
||||
import datetime
|
||||
import enum
|
||||
import uuid
|
||||
|
||||
import sqlalchemy as sa
|
||||
from app.database import Base
|
||||
from app.utils.categorization import get_categories_for_memory
|
||||
from sqlalchemy import (
|
||||
JSON,
|
||||
UUID,
|
||||
Boolean,
|
||||
Column,
|
||||
DateTime,
|
||||
Enum,
|
||||
ForeignKey,
|
||||
Index,
|
||||
Integer,
|
||||
String,
|
||||
Table,
|
||||
event,
|
||||
)
|
||||
from sqlalchemy.orm import Session, relationship
|
||||
|
||||
|
||||
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, 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")
|
||||
|
||||
__table_args__ = (
|
||||
sa.UniqueConstraint('owner_id', 'name', name='idx_app_owner_name'),
|
||||
)
|
||||
|
||||
|
||||
class Config(Base):
|
||||
__tablename__ = "configs"
|
||||
id = Column(UUID, primary_key=True, default=lambda: uuid.uuid4())
|
||||
key = Column(String, unique=True, nullable=False, index=True)
|
||||
value = Column(JSON, nullable=False)
|
||||
created_at = Column(DateTime, default=get_current_utc_time)
|
||||
updated_at = Column(DateTime,
|
||||
default=get_current_utc_time,
|
||||
onupdate=get_current_utc_time)
|
||||
|
||||
|
||||
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()
|
||||
@@ -1,7 +0,0 @@
|
||||
from .apps import router as apps_router
|
||||
from .backup import router as backup_router
|
||||
from .config import router as config_router
|
||||
from .memories import router as memories_router
|
||||
from .stats import router as stats_router
|
||||
|
||||
__all__ = ["memories_router", "apps_router", "stats_router", "config_router", "backup_router"]
|
||||
@@ -1,223 +0,0 @@
|
||||
from typing import Optional
|
||||
from uuid import UUID
|
||||
|
||||
from app.database import get_db
|
||||
from app.models import App, Memory, MemoryAccessLog, MemoryState
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy import desc, func
|
||||
from sqlalchemy.orm import Session, joinedload
|
||||
|
||||
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"}
|
||||
@@ -1,499 +0,0 @@
|
||||
from datetime import UTC, datetime
|
||||
import io
|
||||
import json
|
||||
import gzip
|
||||
import zipfile
|
||||
from typing import Optional, List, Dict, Any
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Query, Form
|
||||
from fastapi.responses import StreamingResponse
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.orm import Session, joinedload
|
||||
from sqlalchemy import and_
|
||||
|
||||
from app.database import get_db
|
||||
from app.models import (
|
||||
User, App, Memory, MemoryState, Category, memory_categories,
|
||||
MemoryStatusHistory, AccessControl
|
||||
)
|
||||
from app.utils.memory import get_memory_client
|
||||
|
||||
from uuid import uuid4
|
||||
|
||||
router = APIRouter(prefix="/api/v1/backup", tags=["backup"])
|
||||
|
||||
class ExportRequest(BaseModel):
|
||||
user_id: str
|
||||
app_id: Optional[UUID] = None
|
||||
from_date: Optional[int] = None
|
||||
to_date: Optional[int] = None
|
||||
include_vectors: bool = True
|
||||
|
||||
def _iso(dt: Optional[datetime]) -> Optional[str]:
|
||||
if isinstance(dt, datetime):
|
||||
try:
|
||||
return dt.astimezone(UTC).isoformat()
|
||||
except:
|
||||
return dt.replace(tzinfo=UTC).isoformat()
|
||||
return None
|
||||
|
||||
def _parse_iso(dt: Optional[str]) -> Optional[datetime]:
|
||||
if not dt:
|
||||
return None
|
||||
try:
|
||||
return datetime.fromisoformat(dt)
|
||||
except Exception:
|
||||
try:
|
||||
return datetime.fromisoformat(dt.replace("Z", "+00:00"))
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def _export_sqlite(db: Session, req: ExportRequest) -> Dict[str, Any]:
|
||||
user = db.query(User).filter(User.user_id == req.user_id).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
time_filters = []
|
||||
if req.from_date:
|
||||
time_filters.append(Memory.created_at >= datetime.fromtimestamp(req.from_date, tz=UTC))
|
||||
if req.to_date:
|
||||
time_filters.append(Memory.created_at <= datetime.fromtimestamp(req.to_date, tz=UTC))
|
||||
|
||||
mem_q = (
|
||||
db.query(Memory)
|
||||
.options(joinedload(Memory.categories), joinedload(Memory.app))
|
||||
.filter(
|
||||
Memory.user_id == user.id,
|
||||
*(time_filters or []),
|
||||
* ( [Memory.app_id == req.app_id] if req.app_id else [] ),
|
||||
)
|
||||
)
|
||||
|
||||
memories = mem_q.all()
|
||||
memory_ids = [m.id for m in memories]
|
||||
|
||||
app_ids = sorted({m.app_id for m in memories if m.app_id})
|
||||
apps = db.query(App).filter(App.id.in_(app_ids)).all() if app_ids else []
|
||||
|
||||
cats = sorted({c for m in memories for c in m.categories}, key = lambda c: str(c.id))
|
||||
|
||||
mc_rows = db.execute(
|
||||
memory_categories.select().where(memory_categories.c.memory_id.in_(memory_ids))
|
||||
).fetchall() if memory_ids else []
|
||||
|
||||
history = db.query(MemoryStatusHistory).filter(MemoryStatusHistory.memory_id.in_(memory_ids)).all() if memory_ids else []
|
||||
|
||||
acls = db.query(AccessControl).filter(
|
||||
AccessControl.subject_type == "app",
|
||||
AccessControl.subject_id.in_(app_ids) if app_ids else False
|
||||
).all() if app_ids else []
|
||||
|
||||
return {
|
||||
"user": {
|
||||
"id": str(user.id),
|
||||
"user_id": user.user_id,
|
||||
"name": user.name,
|
||||
"email": user.email,
|
||||
"metadata": user.metadata_,
|
||||
"created_at": _iso(user.created_at),
|
||||
"updated_at": _iso(user.updated_at)
|
||||
},
|
||||
"apps": [
|
||||
{
|
||||
"id": str(a.id),
|
||||
"owner_id": str(a.owner_id),
|
||||
"name": a.name,
|
||||
"description": a.description,
|
||||
"metadata": a.metadata_,
|
||||
"is_active": a.is_active,
|
||||
"created_at": _iso(a.created_at),
|
||||
"updated_at": _iso(a.updated_at),
|
||||
}
|
||||
for a in apps
|
||||
],
|
||||
"categories": [
|
||||
{
|
||||
"id": str(c.id),
|
||||
"name": c.name,
|
||||
"description": c.description,
|
||||
"created_at": _iso(c.created_at),
|
||||
"updated_at": _iso(c.updated_at),
|
||||
}
|
||||
for c in cats
|
||||
],
|
||||
"memories": [
|
||||
{
|
||||
"id": str(m.id),
|
||||
"user_id": str(m.user_id),
|
||||
"app_id": str(m.app_id) if m.app_id else None,
|
||||
"content": m.content,
|
||||
"metadata": m.metadata_,
|
||||
"state": m.state.value,
|
||||
"created_at": _iso(m.created_at),
|
||||
"updated_at": _iso(m.updated_at),
|
||||
"archived_at": _iso(m.archived_at),
|
||||
"deleted_at": _iso(m.deleted_at),
|
||||
"category_ids": [str(c.id) for c in m.categories], #TODO: figure out a way to add category names simply to this
|
||||
}
|
||||
for m in memories
|
||||
],
|
||||
"memory_categories": [
|
||||
{"memory_id": str(r.memory_id), "category_id": str(r.category_id)}
|
||||
for r in mc_rows
|
||||
],
|
||||
"status_history": [
|
||||
{
|
||||
"id": str(h.id),
|
||||
"memory_id": str(h.memory_id),
|
||||
"changed_by": str(h.changed_by),
|
||||
"old_state": h.old_state.value,
|
||||
"new_state": h.new_state.value,
|
||||
"changed_at": _iso(h.changed_at),
|
||||
}
|
||||
for h in history
|
||||
],
|
||||
"access_controls": [
|
||||
{
|
||||
"id": str(ac.id),
|
||||
"subject_type": ac.subject_type,
|
||||
"subject_id": str(ac.subject_id) if ac.subject_id else None,
|
||||
"object_type": ac.object_type,
|
||||
"object_id": str(ac.object_id) if ac.object_id else None,
|
||||
"effect": ac.effect,
|
||||
"created_at": _iso(ac.created_at),
|
||||
}
|
||||
for ac in acls
|
||||
],
|
||||
"export_meta": {
|
||||
"app_id_filter": str(req.app_id) if req.app_id else None,
|
||||
"from_date": req.from_date,
|
||||
"to_date": req.to_date,
|
||||
"version": "1",
|
||||
"generated_at": datetime.now(UTC).isoformat(),
|
||||
},
|
||||
}
|
||||
|
||||
def _export_logical_memories_gz(
|
||||
db: Session,
|
||||
*,
|
||||
user_id: str,
|
||||
app_id: Optional[UUID] = None,
|
||||
from_date: Optional[int] = None,
|
||||
to_date: Optional[int] = None
|
||||
) -> bytes:
|
||||
"""
|
||||
Export a provider-agnostic backup of memories so they can be restored to any vector DB
|
||||
by re-embedding content. One JSON object per line, gzip-compressed.
|
||||
|
||||
Schema (per line):
|
||||
{
|
||||
"id": "<uuid>",
|
||||
"content": "<text>",
|
||||
"metadata": {...},
|
||||
"created_at": "<iso8601 or null>",
|
||||
"updated_at": "<iso8601 or null>",
|
||||
"state": "active|paused|archived|deleted",
|
||||
"app": "<app name or null>",
|
||||
"categories": ["catA", "catB", ...]
|
||||
}
|
||||
"""
|
||||
|
||||
user = db.query(User).filter(User.user_id == user_id).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
time_filters = []
|
||||
if from_date:
|
||||
time_filters.append(Memory.created_at >= datetime.fromtimestamp(from_date, tz=UTC))
|
||||
if to_date:
|
||||
time_filters.append(Memory.created_at <= datetime.fromtimestamp(to_date, tz=UTC))
|
||||
|
||||
q = (
|
||||
db.query(Memory)
|
||||
.options(joinedload(Memory.categories), joinedload(Memory.app))
|
||||
.filter(
|
||||
Memory.user_id == user.id,
|
||||
*(time_filters or []),
|
||||
)
|
||||
)
|
||||
if app_id:
|
||||
q = q.filter(Memory.app_id == app_id)
|
||||
|
||||
buf = io.BytesIO()
|
||||
with gzip.GzipFile(fileobj=buf, mode="wb") as gz:
|
||||
for m in q.all():
|
||||
record = {
|
||||
"id": str(m.id),
|
||||
"content": m.content,
|
||||
"metadata": m.metadata_ or {},
|
||||
"created_at": _iso(m.created_at),
|
||||
"updated_at": _iso(m.updated_at),
|
||||
"state": m.state.value,
|
||||
"app": m.app.name if m.app else None,
|
||||
"categories": [c.name for c in m.categories],
|
||||
}
|
||||
gz.write((json.dumps(record) + "\n").encode("utf-8"))
|
||||
return buf.getvalue()
|
||||
|
||||
@router.post("/export")
|
||||
async def export_backup(req: ExportRequest, db: Session = Depends(get_db)):
|
||||
sqlite_payload = _export_sqlite(db=db, req=req)
|
||||
memories_blob = _export_logical_memories_gz(
|
||||
db=db,
|
||||
user_id=req.user_id,
|
||||
app_id=req.app_id,
|
||||
from_date=req.from_date,
|
||||
to_date=req.to_date,
|
||||
|
||||
)
|
||||
|
||||
#TODO: add vector store specific exports in future for speed
|
||||
|
||||
zip_buf = io.BytesIO()
|
||||
with zipfile.ZipFile(zip_buf, "w", compression=zipfile.ZIP_DEFLATED) as zf:
|
||||
zf.writestr("memories.json", json.dumps(sqlite_payload, indent=2))
|
||||
zf.writestr("memories.jsonl.gz", memories_blob)
|
||||
|
||||
zip_buf.seek(0)
|
||||
return StreamingResponse(
|
||||
zip_buf,
|
||||
media_type="application/zip",
|
||||
headers={"Content-Disposition": f'attachment; filename="memories_export_{req.user_id}.zip"'},
|
||||
)
|
||||
|
||||
@router.post("/import")
|
||||
async def import_backup(
|
||||
file: UploadFile = File(..., description="Zip with memories.json and memories.jsonl.gz"),
|
||||
user_id: str = Form(..., description="Import memories into this user_id"),
|
||||
mode: str = Query("overwrite"),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
if not file.filename.endswith(".zip"):
|
||||
raise HTTPException(status_code=400, detail="Expected a zip file.")
|
||||
|
||||
if mode not in {"skip", "overwrite"}:
|
||||
raise HTTPException(status_code=400, detail="Invalid mode. Must be 'skip' or 'overwrite'.")
|
||||
|
||||
user = db.query(User).filter(User.user_id == user_id).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
content = await file.read()
|
||||
try:
|
||||
with zipfile.ZipFile(io.BytesIO(content), "r") as zf:
|
||||
names = zf.namelist()
|
||||
|
||||
def find_member(filename: str) -> Optional[str]:
|
||||
for name in names:
|
||||
# Skip directory entries
|
||||
if name.endswith('/'):
|
||||
continue
|
||||
if name.rsplit('/', 1)[-1] == filename:
|
||||
return name
|
||||
return None
|
||||
|
||||
sqlite_member = find_member("memories.json")
|
||||
if not sqlite_member:
|
||||
raise HTTPException(status_code=400, detail="memories.json missing in zip")
|
||||
|
||||
memories_member = find_member("memories.jsonl.gz")
|
||||
|
||||
sqlite_data = json.loads(zf.read(sqlite_member))
|
||||
memories_blob = zf.read(memories_member) if memories_member else None
|
||||
except Exception:
|
||||
raise HTTPException(status_code=400, detail="Invalid zip file")
|
||||
|
||||
default_app = db.query(App).filter(App.owner_id == user.id, App.name == "openmemory").first()
|
||||
if not default_app:
|
||||
default_app = App(owner_id=user.id, name="openmemory", is_active=True, metadata_={})
|
||||
db.add(default_app)
|
||||
db.commit()
|
||||
db.refresh(default_app)
|
||||
|
||||
cat_id_map: Dict[str, UUID] = {}
|
||||
for c in sqlite_data.get("categories", []):
|
||||
cat = db.query(Category).filter(Category.name == c["name"]).first()
|
||||
if not cat:
|
||||
cat = Category(name=c["name"], description=c.get("description"))
|
||||
db.add(cat)
|
||||
db.commit()
|
||||
db.refresh(cat)
|
||||
cat_id_map[c["id"]] = cat.id
|
||||
|
||||
old_to_new_id: Dict[str, UUID] = {}
|
||||
for m in sqlite_data.get("memories", []):
|
||||
incoming_id = UUID(m["id"])
|
||||
existing = db.query(Memory).filter(Memory.id == incoming_id).first()
|
||||
|
||||
# Cross-user collision: always mint a new UUID and import as a new memory
|
||||
if existing and existing.user_id != user.id:
|
||||
target_id = uuid4()
|
||||
else:
|
||||
target_id = incoming_id
|
||||
|
||||
old_to_new_id[m["id"]] = target_id
|
||||
|
||||
# Same-user collision + skip mode: leave existing row untouched
|
||||
if existing and (existing.user_id == user.id) and mode == "skip":
|
||||
continue
|
||||
|
||||
# Same-user collision + overwrite mode: treat import as ground truth
|
||||
if existing and (existing.user_id == user.id) and mode == "overwrite":
|
||||
incoming_state = m.get("state", "active")
|
||||
existing.user_id = user.id
|
||||
existing.app_id = default_app.id
|
||||
existing.content = m.get("content") or ""
|
||||
existing.metadata_ = m.get("metadata") or {}
|
||||
try:
|
||||
existing.state = MemoryState(incoming_state)
|
||||
except Exception:
|
||||
existing.state = MemoryState.active
|
||||
# Update state-related timestamps from import (ground truth)
|
||||
existing.archived_at = _parse_iso(m.get("archived_at"))
|
||||
existing.deleted_at = _parse_iso(m.get("deleted_at"))
|
||||
existing.created_at = _parse_iso(m.get("created_at")) or existing.created_at
|
||||
existing.updated_at = _parse_iso(m.get("updated_at")) or existing.updated_at
|
||||
db.add(existing)
|
||||
db.commit()
|
||||
continue
|
||||
|
||||
new_mem = Memory(
|
||||
id=target_id,
|
||||
user_id=user.id,
|
||||
app_id=default_app.id,
|
||||
content=m.get("content") or "",
|
||||
metadata_=m.get("metadata") or {},
|
||||
state=MemoryState(m.get("state", "active")) if m.get("state") else MemoryState.active,
|
||||
created_at=_parse_iso(m.get("created_at")) or datetime.now(UTC),
|
||||
updated_at=_parse_iso(m.get("updated_at")) or datetime.now(UTC),
|
||||
archived_at=_parse_iso(m.get("archived_at")),
|
||||
deleted_at=_parse_iso(m.get("deleted_at")),
|
||||
)
|
||||
db.add(new_mem)
|
||||
db.commit()
|
||||
|
||||
for link in sqlite_data.get("memory_categories", []):
|
||||
mid = old_to_new_id.get(link["memory_id"])
|
||||
cid = cat_id_map.get(link["category_id"])
|
||||
if not (mid and cid):
|
||||
continue
|
||||
exists = db.execute(
|
||||
memory_categories.select().where(
|
||||
(memory_categories.c.memory_id == mid) & (memory_categories.c.category_id == cid)
|
||||
)
|
||||
).first()
|
||||
|
||||
if not exists:
|
||||
db.execute(memory_categories.insert().values(memory_id=mid, category_id=cid))
|
||||
db.commit()
|
||||
|
||||
for h in sqlite_data.get("status_history", []):
|
||||
hid = UUID(h["id"])
|
||||
mem_id = old_to_new_id.get(h["memory_id"], UUID(h["memory_id"]))
|
||||
exists = db.query(MemoryStatusHistory).filter(MemoryStatusHistory.id == hid).first()
|
||||
if exists and mode == "skip":
|
||||
continue
|
||||
rec = exists if exists else MemoryStatusHistory(id=hid)
|
||||
rec.memory_id = mem_id
|
||||
rec.changed_by = user.id
|
||||
try:
|
||||
rec.old_state = MemoryState(h.get("old_state", "active"))
|
||||
rec.new_state = MemoryState(h.get("new_state", "active"))
|
||||
except Exception:
|
||||
rec.old_state = MemoryState.active
|
||||
rec.new_state = MemoryState.active
|
||||
rec.changed_at = _parse_iso(h.get("changed_at")) or datetime.now(UTC)
|
||||
db.add(rec)
|
||||
db.commit()
|
||||
|
||||
memory_client = get_memory_client()
|
||||
vector_store = getattr(memory_client, "vector_store", None) if memory_client else None
|
||||
|
||||
if vector_store and memory_client and hasattr(memory_client, "embedding_model"):
|
||||
def iter_logical_records():
|
||||
if memories_blob:
|
||||
gz_buf = io.BytesIO(memories_blob)
|
||||
with gzip.GzipFile(fileobj=gz_buf, mode="rb") as gz:
|
||||
for raw in gz:
|
||||
yield json.loads(raw.decode("utf-8"))
|
||||
else:
|
||||
for m in sqlite_data.get("memories", []):
|
||||
yield {
|
||||
"id": m["id"],
|
||||
"content": m.get("content"),
|
||||
"metadata": m.get("metadata") or {},
|
||||
"created_at": m.get("created_at"),
|
||||
"updated_at": m.get("updated_at"),
|
||||
}
|
||||
|
||||
for rec in iter_logical_records():
|
||||
old_id = rec["id"]
|
||||
new_id = old_to_new_id.get(old_id, UUID(old_id))
|
||||
content = rec.get("content") or ""
|
||||
metadata = rec.get("metadata") or {}
|
||||
created_at = rec.get("created_at")
|
||||
updated_at = rec.get("updated_at")
|
||||
|
||||
if mode == "skip":
|
||||
try:
|
||||
get_fn = getattr(vector_store, "get", None)
|
||||
if callable(get_fn) and vector_store.get(str(new_id)):
|
||||
continue
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
payload = dict(metadata)
|
||||
payload["data"] = content
|
||||
if created_at:
|
||||
payload["created_at"] = created_at
|
||||
if updated_at:
|
||||
payload["updated_at"] = updated_at
|
||||
payload["user_id"] = user_id
|
||||
payload.setdefault("source_app", "openmemory")
|
||||
|
||||
try:
|
||||
vec = memory_client.embedding_model.embed(content, "add")
|
||||
vector_store.insert(vectors=[vec], payloads=[payload], ids=[str(new_id)])
|
||||
except Exception as e:
|
||||
print(f"Vector upsert failed for memory {new_id}: {e}")
|
||||
continue
|
||||
|
||||
return {"message": f'Import completed into user "{user_id}"'}
|
||||
|
||||
return {"message": f'Import completed into user "{user_id}"'}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1,291 +0,0 @@
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from app.database import get_db
|
||||
from app.models import Config as ConfigModel
|
||||
from app.utils.memory import reset_memory_client
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
router = APIRouter(prefix="/api/v1/config", tags=["config"])
|
||||
|
||||
class LLMConfig(BaseModel):
|
||||
model: str = Field(..., description="LLM model name")
|
||||
temperature: float = Field(..., description="Temperature setting for the model")
|
||||
max_tokens: int = Field(..., description="Maximum tokens to generate")
|
||||
api_key: Optional[str] = Field(None, description="API key or 'env:API_KEY' to use environment variable")
|
||||
ollama_base_url: Optional[str] = Field(None, description="Base URL for Ollama server (e.g., http://host.docker.internal:11434)")
|
||||
|
||||
class LLMProvider(BaseModel):
|
||||
provider: str = Field(..., description="LLM provider name")
|
||||
config: LLMConfig
|
||||
|
||||
class EmbedderConfig(BaseModel):
|
||||
model: str = Field(..., description="Embedder model name")
|
||||
api_key: Optional[str] = Field(None, description="API key or 'env:API_KEY' to use environment variable")
|
||||
ollama_base_url: Optional[str] = Field(None, description="Base URL for Ollama server (e.g., http://host.docker.internal:11434)")
|
||||
|
||||
class EmbedderProvider(BaseModel):
|
||||
provider: str = Field(..., description="Embedder provider name")
|
||||
config: EmbedderConfig
|
||||
|
||||
class VectorStoreProvider(BaseModel):
|
||||
provider: str = Field(..., description="Vector store provider name")
|
||||
# Below config can vary widely based on the vector store used. Refer https://docs.mem0.ai/components/vectordbs/config
|
||||
config: Dict[str, Any] = Field(..., description="Vector store-specific configuration")
|
||||
|
||||
class OpenMemoryConfig(BaseModel):
|
||||
custom_instructions: Optional[str] = Field(None, description="Custom instructions for memory management and fact extraction")
|
||||
|
||||
class Mem0Config(BaseModel):
|
||||
llm: Optional[LLMProvider] = None
|
||||
embedder: Optional[EmbedderProvider] = None
|
||||
vector_store: Optional[VectorStoreProvider] = None
|
||||
|
||||
class ConfigSchema(BaseModel):
|
||||
openmemory: Optional[OpenMemoryConfig] = None
|
||||
mem0: Optional[Mem0Config] = None
|
||||
|
||||
def get_default_configuration():
|
||||
"""Get the default configuration with sensible defaults for LLM and embedder."""
|
||||
return {
|
||||
"openmemory": {
|
||||
"custom_instructions": None
|
||||
},
|
||||
"mem0": {
|
||||
"llm": {
|
||||
"provider": "openai",
|
||||
"config": {
|
||||
"model": "gpt-4o-mini",
|
||||
"temperature": 0.1,
|
||||
"max_tokens": 2000,
|
||||
"api_key": "env:OPENAI_API_KEY"
|
||||
}
|
||||
},
|
||||
"embedder": {
|
||||
"provider": "openai",
|
||||
"config": {
|
||||
"model": "text-embedding-3-small",
|
||||
"api_key": "env:OPENAI_API_KEY"
|
||||
}
|
||||
},
|
||||
"vector_store": None
|
||||
}
|
||||
}
|
||||
|
||||
def get_config_from_db(db: Session, key: str = "main"):
|
||||
"""Get configuration from database."""
|
||||
config = db.query(ConfigModel).filter(ConfigModel.key == key).first()
|
||||
|
||||
if not config:
|
||||
# Create default config with proper provider configurations
|
||||
default_config = get_default_configuration()
|
||||
db_config = ConfigModel(key=key, value=default_config)
|
||||
db.add(db_config)
|
||||
db.commit()
|
||||
db.refresh(db_config)
|
||||
return default_config
|
||||
|
||||
# Ensure the config has all required sections with defaults
|
||||
config_value = config.value
|
||||
default_config = get_default_configuration()
|
||||
|
||||
# Merge with defaults to ensure all required fields exist
|
||||
if "openmemory" not in config_value:
|
||||
config_value["openmemory"] = default_config["openmemory"]
|
||||
|
||||
if "mem0" not in config_value:
|
||||
config_value["mem0"] = default_config["mem0"]
|
||||
else:
|
||||
# Ensure LLM config exists with defaults
|
||||
if "llm" not in config_value["mem0"] or config_value["mem0"]["llm"] is None:
|
||||
config_value["mem0"]["llm"] = default_config["mem0"]["llm"]
|
||||
|
||||
# Ensure embedder config exists with defaults
|
||||
if "embedder" not in config_value["mem0"] or config_value["mem0"]["embedder"] is None:
|
||||
config_value["mem0"]["embedder"] = default_config["mem0"]["embedder"]
|
||||
|
||||
# Ensure vector_store config exists with defaults
|
||||
if "vector_store" not in config_value["mem0"]:
|
||||
config_value["mem0"]["vector_store"] = default_config["mem0"]["vector_store"]
|
||||
|
||||
# Save the updated config back to database if it was modified
|
||||
if config_value != config.value:
|
||||
config.value = config_value
|
||||
db.commit()
|
||||
db.refresh(config)
|
||||
|
||||
return config_value
|
||||
|
||||
def save_config_to_db(db: Session, config: Dict[str, Any], key: str = "main"):
|
||||
"""Save configuration to database."""
|
||||
db_config = db.query(ConfigModel).filter(ConfigModel.key == key).first()
|
||||
|
||||
if db_config:
|
||||
db_config.value = config
|
||||
db_config.updated_at = None # Will trigger the onupdate to set current time
|
||||
else:
|
||||
db_config = ConfigModel(key=key, value=config)
|
||||
db.add(db_config)
|
||||
|
||||
db.commit()
|
||||
db.refresh(db_config)
|
||||
return db_config.value
|
||||
|
||||
@router.get("/", response_model=ConfigSchema)
|
||||
async def get_configuration(db: Session = Depends(get_db)):
|
||||
"""Get the current configuration."""
|
||||
config = get_config_from_db(db)
|
||||
return config
|
||||
|
||||
@router.put("/", response_model=ConfigSchema)
|
||||
async def update_configuration(config: ConfigSchema, db: Session = Depends(get_db)):
|
||||
"""Update the configuration."""
|
||||
current_config = get_config_from_db(db)
|
||||
|
||||
# Convert to dict for processing
|
||||
updated_config = current_config.copy()
|
||||
|
||||
# Update openmemory settings if provided
|
||||
if config.openmemory is not None:
|
||||
if "openmemory" not in updated_config:
|
||||
updated_config["openmemory"] = {}
|
||||
updated_config["openmemory"].update(config.openmemory.dict(exclude_none=True))
|
||||
|
||||
# Update mem0 settings
|
||||
updated_config["mem0"] = config.mem0.dict(exclude_none=True)
|
||||
|
||||
|
||||
@router.patch("/", response_model=ConfigSchema)
|
||||
async def patch_configuration(config_update: ConfigSchema, db: Session = Depends(get_db)):
|
||||
"""Update parts of the configuration."""
|
||||
current_config = get_config_from_db(db)
|
||||
|
||||
def deep_update(source, overrides):
|
||||
for key, value in overrides.items():
|
||||
if isinstance(value, dict) and key in source and isinstance(source[key], dict):
|
||||
source[key] = deep_update(source[key], value)
|
||||
else:
|
||||
source[key] = value
|
||||
return source
|
||||
|
||||
update_data = config_update.dict(exclude_unset=True)
|
||||
updated_config = deep_update(current_config, update_data)
|
||||
|
||||
save_config_to_db(db, updated_config)
|
||||
reset_memory_client()
|
||||
return updated_config
|
||||
|
||||
|
||||
@router.post("/reset", response_model=ConfigSchema)
|
||||
async def reset_configuration(db: Session = Depends(get_db)):
|
||||
"""Reset the configuration to default values."""
|
||||
try:
|
||||
# Get the default configuration with proper provider setups
|
||||
default_config = get_default_configuration()
|
||||
|
||||
# Save it as the current configuration in the database
|
||||
save_config_to_db(db, default_config)
|
||||
reset_memory_client()
|
||||
return default_config
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Failed to reset configuration: {str(e)}"
|
||||
)
|
||||
|
||||
@router.get("/mem0/llm", response_model=LLMProvider)
|
||||
async def get_llm_configuration(db: Session = Depends(get_db)):
|
||||
"""Get only the LLM configuration."""
|
||||
config = get_config_from_db(db)
|
||||
llm_config = config.get("mem0", {}).get("llm", {})
|
||||
return llm_config
|
||||
|
||||
@router.put("/mem0/llm", response_model=LLMProvider)
|
||||
async def update_llm_configuration(llm_config: LLMProvider, db: Session = Depends(get_db)):
|
||||
"""Update only the LLM configuration."""
|
||||
current_config = get_config_from_db(db)
|
||||
|
||||
# Ensure mem0 key exists
|
||||
if "mem0" not in current_config:
|
||||
current_config["mem0"] = {}
|
||||
|
||||
# Update the LLM configuration
|
||||
current_config["mem0"]["llm"] = llm_config.dict(exclude_none=True)
|
||||
|
||||
# Save the configuration to database
|
||||
save_config_to_db(db, current_config)
|
||||
reset_memory_client()
|
||||
return current_config["mem0"]["llm"]
|
||||
|
||||
@router.get("/mem0/embedder", response_model=EmbedderProvider)
|
||||
async def get_embedder_configuration(db: Session = Depends(get_db)):
|
||||
"""Get only the Embedder configuration."""
|
||||
config = get_config_from_db(db)
|
||||
embedder_config = config.get("mem0", {}).get("embedder", {})
|
||||
return embedder_config
|
||||
|
||||
@router.put("/mem0/embedder", response_model=EmbedderProvider)
|
||||
async def update_embedder_configuration(embedder_config: EmbedderProvider, db: Session = Depends(get_db)):
|
||||
"""Update only the Embedder configuration."""
|
||||
current_config = get_config_from_db(db)
|
||||
|
||||
# Ensure mem0 key exists
|
||||
if "mem0" not in current_config:
|
||||
current_config["mem0"] = {}
|
||||
|
||||
# Update the Embedder configuration
|
||||
current_config["mem0"]["embedder"] = embedder_config.dict(exclude_none=True)
|
||||
|
||||
# Save the configuration to database
|
||||
save_config_to_db(db, current_config)
|
||||
reset_memory_client()
|
||||
return current_config["mem0"]["embedder"]
|
||||
|
||||
@router.get("/mem0/vector_store", response_model=Optional[VectorStoreProvider])
|
||||
async def get_vector_store_configuration(db: Session = Depends(get_db)):
|
||||
"""Get only the Vector Store configuration."""
|
||||
config = get_config_from_db(db)
|
||||
vector_store_config = config.get("mem0", {}).get("vector_store", None)
|
||||
return vector_store_config
|
||||
|
||||
@router.put("/mem0/vector_store", response_model=VectorStoreProvider)
|
||||
async def update_vector_store_configuration(vector_store_config: VectorStoreProvider, db: Session = Depends(get_db)):
|
||||
"""Update only the Vector Store configuration."""
|
||||
current_config = get_config_from_db(db)
|
||||
|
||||
# Ensure mem0 key exists
|
||||
if "mem0" not in current_config:
|
||||
current_config["mem0"] = {}
|
||||
|
||||
# Update the Vector Store configuration
|
||||
current_config["mem0"]["vector_store"] = vector_store_config.dict(exclude_none=True)
|
||||
|
||||
# Save the configuration to database
|
||||
save_config_to_db(db, current_config)
|
||||
reset_memory_client()
|
||||
return current_config["mem0"]["vector_store"]
|
||||
|
||||
@router.get("/openmemory", response_model=OpenMemoryConfig)
|
||||
async def get_openmemory_configuration(db: Session = Depends(get_db)):
|
||||
"""Get only the OpenMemory configuration."""
|
||||
config = get_config_from_db(db)
|
||||
openmemory_config = config.get("openmemory", {})
|
||||
return openmemory_config
|
||||
|
||||
@router.put("/openmemory", response_model=OpenMemoryConfig)
|
||||
async def update_openmemory_configuration(openmemory_config: OpenMemoryConfig, db: Session = Depends(get_db)):
|
||||
"""Update only the OpenMemory configuration."""
|
||||
current_config = get_config_from_db(db)
|
||||
|
||||
# Ensure openmemory key exists
|
||||
if "openmemory" not in current_config:
|
||||
current_config["openmemory"] = {}
|
||||
|
||||
# Update the OpenMemory configuration
|
||||
current_config["openmemory"].update(openmemory_config.dict(exclude_none=True))
|
||||
|
||||
# Save the configuration to database
|
||||
save_config_to_db(db, current_config)
|
||||
reset_memory_client()
|
||||
return current_config["openmemory"]
|
||||
@@ -1,694 +0,0 @@
|
||||
import logging
|
||||
from datetime import UTC, datetime
|
||||
from typing import List, Optional, Set
|
||||
from uuid import UUID
|
||||
|
||||
from app.database import get_db
|
||||
from app.models import (
|
||||
AccessControl,
|
||||
App,
|
||||
Category,
|
||||
Memory,
|
||||
MemoryAccessLog,
|
||||
MemoryState,
|
||||
MemoryStatusHistory,
|
||||
User,
|
||||
)
|
||||
from app.schemas import MemoryResponse
|
||||
from app.utils.memory import get_memory_client
|
||||
from app.utils.permissions import check_memory_access_permissions
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from fastapi_pagination import Page, Params
|
||||
from fastapi_pagination.ext.sqlalchemy import paginate as sqlalchemy_paginate
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy.orm import Session, joinedload
|
||||
|
||||
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())
|
||||
|
||||
# Add eager loading for app and categories
|
||||
query = query.options(
|
||||
joinedload(Memory.app),
|
||||
joinedload(Memory.categories)
|
||||
).distinct(Memory.id)
|
||||
|
||||
# Get paginated results with transformer
|
||||
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
|
||||
if check_memory_access_permissions(db, memory, app_id)
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
# 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,
|
||||
App.owner_id == user.id).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}")
|
||||
|
||||
# Try to get memory client safely
|
||||
try:
|
||||
memory_client = get_memory_client()
|
||||
if not memory_client:
|
||||
raise Exception("Memory client is not available")
|
||||
except Exception as client_error:
|
||||
logging.warning(f"Memory client unavailable: {client_error}. Creating memory in database only.")
|
||||
# Return a json response with the error
|
||||
return {
|
||||
"error": str(client_error)
|
||||
}
|
||||
|
||||
# Try to save to Qdrant via memory_client
|
||||
try:
|
||||
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,
|
||||
},
|
||||
infer=request.infer
|
||||
)
|
||||
|
||||
# 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:
|
||||
created_memories = []
|
||||
|
||||
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)
|
||||
|
||||
created_memories.append(memory)
|
||||
|
||||
# Commit all changes at once
|
||||
if created_memories:
|
||||
db.commit()
|
||||
for memory in created_memories:
|
||||
db.refresh(memory)
|
||||
|
||||
# Return the first memory (for API compatibility)
|
||||
# but all memories are now saved to the database
|
||||
return created_memories[0]
|
||||
except Exception as qdrant_error:
|
||||
logging.warning(f"Qdrant operation failed: {qdrant_error}.")
|
||||
# Return a json response with the error
|
||||
return {
|
||||
"error": str(qdrant_error)
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
# 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")
|
||||
|
||||
# Get memory client to delete from vector store
|
||||
try:
|
||||
memory_client = get_memory_client()
|
||||
if not memory_client:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Memory client is not available"
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as client_error:
|
||||
logging.error(f"Memory client initialization failed: {client_error}")
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail=f"Memory service unavailable: {str(client_error)}"
|
||||
)
|
||||
|
||||
# Delete from vector store then mark as deleted in database
|
||||
for memory_id in request.memory_ids:
|
||||
try:
|
||||
memory_client.delete(str(memory_id))
|
||||
except Exception as delete_error:
|
||||
logging.warning(f"Failed to delete memory {memory_id} from vector store: {delete_error}")
|
||||
|
||||
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": "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
|
||||
]
|
||||
)
|
||||
@@ -1,29 +0,0 @@
|
||||
from app.database import get_db
|
||||
from app.models import App, Memory, MemoryState, User
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
from datetime import datetime
|
||||
from typing import List, Optional
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, 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
|
||||
|
||||
model_config = ConfigDict(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
|
||||
@@ -1,43 +0,0 @@
|
||||
import logging
|
||||
from typing import List
|
||||
|
||||
from app.utils.prompts import MEMORY_CATEGORIZATION_PROMPT
|
||||
from dotenv import load_dotenv
|
||||
from openai import OpenAI
|
||||
from pydantic import BaseModel
|
||||
from tenacity import retry, stop_after_attempt, wait_exponential
|
||||
|
||||
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]:
|
||||
try:
|
||||
messages = [
|
||||
{"role": "system", "content": MEMORY_CATEGORIZATION_PROMPT},
|
||||
{"role": "user", "content": memory}
|
||||
]
|
||||
|
||||
# Let OpenAI handle the pydantic parsing directly
|
||||
completion = openai_client.beta.chat.completions.parse(
|
||||
model="gpt-4o-mini",
|
||||
messages=messages,
|
||||
response_format=MemoryCategories,
|
||||
temperature=0
|
||||
)
|
||||
|
||||
parsed: MemoryCategories = completion.choices[0].message.parsed
|
||||
return [cat.strip().lower() for cat in parsed.categories]
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"[ERROR] Failed to get categories: {e}")
|
||||
try:
|
||||
logging.debug(f"[DEBUG] Raw response: {completion.choices[0].message.content}")
|
||||
except Exception as debug_e:
|
||||
logging.debug(f"[DEBUG] Could not extract raw response: {debug_e}")
|
||||
raise
|
||||
@@ -1,33 +0,0 @@
|
||||
from typing import Tuple
|
||||
|
||||
from app.models import App, User
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
|
||||
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
|
||||
@@ -1,504 +0,0 @@
|
||||
"""
|
||||
Memory client utilities for OpenMemory.
|
||||
|
||||
This module provides functionality to initialize and manage the Mem0 memory client
|
||||
with automatic configuration management and Docker environment support.
|
||||
|
||||
Docker Ollama Configuration:
|
||||
When running inside a Docker container and using Ollama as the LLM or embedder provider,
|
||||
the system automatically detects the Docker environment and adjusts localhost URLs
|
||||
to properly reach the host machine where Ollama is running.
|
||||
|
||||
Supported Docker host resolution (in order of preference):
|
||||
1. OLLAMA_HOST environment variable (if set)
|
||||
2. host.docker.internal (Docker Desktop for Mac/Windows)
|
||||
3. Docker bridge gateway IP (typically 172.17.0.1 on Linux)
|
||||
4. Fallback to 172.17.0.1
|
||||
|
||||
Example configuration that will be automatically adjusted:
|
||||
{
|
||||
"llm": {
|
||||
"provider": "ollama",
|
||||
"config": {
|
||||
"model": "llama3.1:latest",
|
||||
"ollama_base_url": "http://localhost:11434" # Auto-adjusted in Docker
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import socket
|
||||
|
||||
from app.database import SessionLocal
|
||||
from app.models import Config as ConfigModel
|
||||
|
||||
from mem0 import Memory
|
||||
|
||||
_memory_client = None
|
||||
_config_hash = None
|
||||
|
||||
|
||||
def _get_config_hash(config_dict):
|
||||
"""Generate a hash of the config to detect changes."""
|
||||
config_str = json.dumps(config_dict, sort_keys=True)
|
||||
return hashlib.md5(config_str.encode()).hexdigest()
|
||||
|
||||
|
||||
def _get_docker_host_url():
|
||||
"""
|
||||
Determine the appropriate host URL to reach host machine from inside Docker container.
|
||||
Returns the best available option for reaching the host from inside a container.
|
||||
"""
|
||||
# Check for custom environment variable first
|
||||
custom_host = os.environ.get('OLLAMA_HOST')
|
||||
if custom_host:
|
||||
print(f"Using custom Ollama host from OLLAMA_HOST: {custom_host}")
|
||||
return custom_host.replace('http://', '').replace('https://', '').split(':')[0]
|
||||
|
||||
# Check if we're running inside Docker
|
||||
if not os.path.exists('/.dockerenv'):
|
||||
# Not in Docker, return localhost as-is
|
||||
return "localhost"
|
||||
|
||||
print("Detected Docker environment, adjusting host URL for Ollama...")
|
||||
|
||||
# Try different host resolution strategies
|
||||
host_candidates = []
|
||||
|
||||
# 1. host.docker.internal (works on Docker Desktop for Mac/Windows)
|
||||
try:
|
||||
socket.gethostbyname('host.docker.internal')
|
||||
host_candidates.append('host.docker.internal')
|
||||
print("Found host.docker.internal")
|
||||
except socket.gaierror:
|
||||
pass
|
||||
|
||||
# 2. Docker bridge gateway (typically 172.17.0.1 on Linux)
|
||||
try:
|
||||
with open('/proc/net/route', 'r') as f:
|
||||
for line in f:
|
||||
fields = line.strip().split()
|
||||
if fields[1] == '00000000': # Default route
|
||||
gateway_hex = fields[2]
|
||||
gateway_ip = socket.inet_ntoa(bytes.fromhex(gateway_hex)[::-1])
|
||||
host_candidates.append(gateway_ip)
|
||||
print(f"Found Docker gateway: {gateway_ip}")
|
||||
break
|
||||
except (FileNotFoundError, IndexError, ValueError):
|
||||
pass
|
||||
|
||||
# 3. Fallback to common Docker bridge IP
|
||||
if not host_candidates:
|
||||
host_candidates.append('172.17.0.1')
|
||||
print("Using fallback Docker bridge IP: 172.17.0.1")
|
||||
|
||||
# Return the first available candidate
|
||||
return host_candidates[0]
|
||||
|
||||
|
||||
def _fix_ollama_urls(config_section):
|
||||
"""
|
||||
Fix Ollama URLs for Docker environment.
|
||||
Replaces localhost URLs with appropriate Docker host URLs.
|
||||
Sets default ollama_base_url if not provided.
|
||||
"""
|
||||
if not config_section or "config" not in config_section:
|
||||
return config_section
|
||||
|
||||
ollama_config = config_section["config"]
|
||||
|
||||
# Set default ollama_base_url if not provided
|
||||
if "ollama_base_url" not in ollama_config:
|
||||
ollama_config["ollama_base_url"] = "http://host.docker.internal:11434"
|
||||
else:
|
||||
# Check for ollama_base_url and fix if it's localhost
|
||||
url = ollama_config["ollama_base_url"]
|
||||
if "localhost" in url or "127.0.0.1" in url:
|
||||
docker_host = _get_docker_host_url()
|
||||
if docker_host != "localhost":
|
||||
new_url = url.replace("localhost", docker_host).replace("127.0.0.1", docker_host)
|
||||
ollama_config["ollama_base_url"] = new_url
|
||||
print(f"Adjusted Ollama URL from {url} to {new_url}")
|
||||
|
||||
return config_section
|
||||
|
||||
|
||||
def reset_memory_client():
|
||||
"""Reset the global memory client to force reinitialization with new config."""
|
||||
global _memory_client, _config_hash
|
||||
_memory_client = None
|
||||
_config_hash = None
|
||||
|
||||
|
||||
# --- LLM provider config factories ---
|
||||
|
||||
def _build_ollama_llm_config(model, api_key, base_url, ollama_base_url):
|
||||
config = {"model": model or "llama3.1:latest"}
|
||||
# OLLAMA_BASE_URL takes precedence, then LLM_BASE_URL, then default
|
||||
config["ollama_base_url"] = ollama_base_url or base_url or "http://localhost:11434"
|
||||
return config
|
||||
|
||||
|
||||
def _build_openai_llm_config(model, api_key, base_url, ollama_base_url):
|
||||
config = {
|
||||
"model": model or "gpt-4o-mini",
|
||||
"api_key": api_key or "env:OPENAI_API_KEY",
|
||||
}
|
||||
if base_url:
|
||||
config["openai_base_url"] = base_url
|
||||
return config
|
||||
|
||||
|
||||
_LLM_CONFIG_FACTORIES = {
|
||||
"ollama": _build_ollama_llm_config,
|
||||
"openai": _build_openai_llm_config,
|
||||
}
|
||||
|
||||
|
||||
def _create_llm_config(provider, model, api_key, base_url, ollama_base_url):
|
||||
"""Build LLM config using registered provider factory or generic fallback."""
|
||||
base_config = {
|
||||
"temperature": 0.1,
|
||||
"max_tokens": 2000,
|
||||
}
|
||||
|
||||
factory = _LLM_CONFIG_FACTORIES.get(provider)
|
||||
if factory:
|
||||
base_config.update(factory(model, api_key, base_url, ollama_base_url))
|
||||
else:
|
||||
# Generic provider (anthropic, groq, together, deepseek, etc.)
|
||||
if not model:
|
||||
raise ValueError(
|
||||
f"LLM_MODEL environment variable is required when using LLM_PROVIDER='{provider}'. "
|
||||
f"Set LLM_MODEL to a valid model name for the '{provider}' provider."
|
||||
)
|
||||
base_config["model"] = model
|
||||
if api_key:
|
||||
base_config["api_key"] = api_key
|
||||
|
||||
return base_config
|
||||
|
||||
|
||||
# --- Embedder provider config factories ---
|
||||
|
||||
def _build_ollama_embedder_config(model, api_key, base_url, ollama_base_url, llm_base_url):
|
||||
config = {"model": model or "nomic-embed-text"}
|
||||
config["ollama_base_url"] = base_url or ollama_base_url or llm_base_url or "http://localhost:11434"
|
||||
return config
|
||||
|
||||
|
||||
def _build_openai_embedder_config(model, api_key, base_url, ollama_base_url, llm_base_url):
|
||||
config = {
|
||||
"model": model or "text-embedding-3-small",
|
||||
"api_key": api_key or "env:OPENAI_API_KEY",
|
||||
}
|
||||
if base_url:
|
||||
config["openai_base_url"] = base_url
|
||||
return config
|
||||
|
||||
|
||||
_EMBEDDER_CONFIG_FACTORIES = {
|
||||
"ollama": _build_ollama_embedder_config,
|
||||
"openai": _build_openai_embedder_config,
|
||||
}
|
||||
|
||||
|
||||
def _create_embedder_config(provider, model, api_key, base_url, ollama_base_url, llm_base_url):
|
||||
"""Build embedder config using registered provider factory or generic fallback."""
|
||||
factory = _EMBEDDER_CONFIG_FACTORIES.get(provider)
|
||||
if factory:
|
||||
config = factory(model, api_key, base_url, ollama_base_url, llm_base_url)
|
||||
else:
|
||||
if not model:
|
||||
raise ValueError(
|
||||
f"EMBEDDER_MODEL environment variable is required when using EMBEDDER_PROVIDER='{provider}'. "
|
||||
f"Set EMBEDDER_MODEL to a valid model name for the '{provider}' provider."
|
||||
)
|
||||
config = {"model": model}
|
||||
if api_key:
|
||||
config["api_key"] = api_key
|
||||
|
||||
return config
|
||||
|
||||
|
||||
def get_default_memory_config():
|
||||
"""Get default memory client configuration with sensible defaults."""
|
||||
# Detect vector store based on environment variables
|
||||
vector_store_config = {
|
||||
"collection_name": "openmemory",
|
||||
"host": "mem0_store",
|
||||
}
|
||||
|
||||
# Check for different vector store configurations based on environment variables
|
||||
if os.environ.get('CHROMA_HOST') and os.environ.get('CHROMA_PORT'):
|
||||
vector_store_provider = "chroma"
|
||||
vector_store_config.update({
|
||||
"host": os.environ.get('CHROMA_HOST'),
|
||||
"port": int(os.environ.get('CHROMA_PORT'))
|
||||
})
|
||||
elif os.environ.get('QDRANT_HOST') and os.environ.get('QDRANT_PORT'):
|
||||
vector_store_provider = "qdrant"
|
||||
vector_store_config.update({
|
||||
"host": os.environ.get('QDRANT_HOST'),
|
||||
"port": int(os.environ.get('QDRANT_PORT'))
|
||||
})
|
||||
elif os.environ.get('WEAVIATE_CLUSTER_URL') or (os.environ.get('WEAVIATE_HOST') and os.environ.get('WEAVIATE_PORT')):
|
||||
vector_store_provider = "weaviate"
|
||||
# Prefer an explicit cluster URL if provided; otherwise build from host/port
|
||||
cluster_url = os.environ.get('WEAVIATE_CLUSTER_URL')
|
||||
if not cluster_url:
|
||||
weaviate_host = os.environ.get('WEAVIATE_HOST')
|
||||
weaviate_port = int(os.environ.get('WEAVIATE_PORT'))
|
||||
cluster_url = f"http://{weaviate_host}:{weaviate_port}"
|
||||
vector_store_config = {
|
||||
"collection_name": "openmemory",
|
||||
"cluster_url": cluster_url
|
||||
}
|
||||
elif os.environ.get('REDIS_URL'):
|
||||
vector_store_provider = "redis"
|
||||
vector_store_config = {
|
||||
"collection_name": "openmemory",
|
||||
"redis_url": os.environ.get('REDIS_URL')
|
||||
}
|
||||
elif os.environ.get('PG_HOST') and os.environ.get('PG_PORT'):
|
||||
vector_store_provider = "pgvector"
|
||||
vector_store_config.update({
|
||||
"host": os.environ.get('PG_HOST'),
|
||||
"port": int(os.environ.get('PG_PORT')),
|
||||
"dbname": os.environ.get('PG_DB', 'mem0'),
|
||||
"user": os.environ.get('PG_USER', 'mem0'),
|
||||
"password": os.environ.get('PG_PASSWORD', 'mem0')
|
||||
})
|
||||
elif os.environ.get('MILVUS_HOST') and os.environ.get('MILVUS_PORT'):
|
||||
vector_store_provider = "milvus"
|
||||
# Construct the full URL as expected by MilvusDBConfig
|
||||
milvus_host = os.environ.get('MILVUS_HOST')
|
||||
milvus_port = int(os.environ.get('MILVUS_PORT'))
|
||||
milvus_url = f"http://{milvus_host}:{milvus_port}"
|
||||
|
||||
vector_store_config = {
|
||||
"collection_name": "openmemory",
|
||||
"url": milvus_url,
|
||||
"token": os.environ.get('MILVUS_TOKEN', ''), # Always include, empty string for local setup
|
||||
"db_name": os.environ.get('MILVUS_DB_NAME', ''),
|
||||
"embedding_model_dims": 1536,
|
||||
"metric_type": "COSINE" # Using COSINE for better semantic similarity
|
||||
}
|
||||
elif os.environ.get('ELASTICSEARCH_HOST') and os.environ.get('ELASTICSEARCH_PORT'):
|
||||
vector_store_provider = "elasticsearch"
|
||||
# Construct the full URL with scheme since Elasticsearch client expects it
|
||||
elasticsearch_host = os.environ.get('ELASTICSEARCH_HOST')
|
||||
elasticsearch_port = int(os.environ.get('ELASTICSEARCH_PORT'))
|
||||
# Use http:// scheme since we're not using SSL
|
||||
full_host = f"http://{elasticsearch_host}"
|
||||
|
||||
vector_store_config.update({
|
||||
"host": full_host,
|
||||
"port": elasticsearch_port,
|
||||
"user": os.environ.get('ELASTICSEARCH_USER', 'elastic'),
|
||||
"password": os.environ.get('ELASTICSEARCH_PASSWORD', 'changeme'),
|
||||
"verify_certs": False,
|
||||
"use_ssl": False,
|
||||
"embedding_model_dims": 1536
|
||||
})
|
||||
elif os.environ.get('OPENSEARCH_HOST') and os.environ.get('OPENSEARCH_PORT'):
|
||||
vector_store_provider = "opensearch"
|
||||
vector_store_config.update({
|
||||
"host": os.environ.get('OPENSEARCH_HOST'),
|
||||
"port": int(os.environ.get('OPENSEARCH_PORT'))
|
||||
})
|
||||
elif os.environ.get('FAISS_PATH'):
|
||||
vector_store_provider = "faiss"
|
||||
vector_store_config = {
|
||||
"collection_name": "openmemory",
|
||||
"path": os.environ.get('FAISS_PATH'),
|
||||
"embedding_model_dims": 1536,
|
||||
"distance_strategy": "cosine"
|
||||
}
|
||||
else:
|
||||
# Default fallback to Qdrant
|
||||
vector_store_provider = "qdrant"
|
||||
vector_store_config.update({
|
||||
"port": 6333,
|
||||
})
|
||||
|
||||
print(f"Auto-detected vector store: {vector_store_provider} with config: {vector_store_config}")
|
||||
|
||||
# Detect LLM provider from environment variables
|
||||
llm_provider = os.environ.get('LLM_PROVIDER', 'openai').lower()
|
||||
llm_model = os.environ.get('LLM_MODEL')
|
||||
llm_api_key = os.environ.get('LLM_API_KEY')
|
||||
llm_base_url = os.environ.get('LLM_BASE_URL')
|
||||
ollama_base_url = os.environ.get('OLLAMA_BASE_URL')
|
||||
|
||||
llm_config = _create_llm_config(
|
||||
provider=llm_provider,
|
||||
model=llm_model,
|
||||
api_key=llm_api_key,
|
||||
base_url=llm_base_url,
|
||||
ollama_base_url=ollama_base_url,
|
||||
)
|
||||
print(f"Auto-detected LLM provider: {llm_provider}")
|
||||
|
||||
# Detect embedder provider from environment variables
|
||||
embedder_provider = os.environ.get('EMBEDDER_PROVIDER', llm_provider if llm_provider == 'ollama' else 'openai').lower()
|
||||
embedder_model = os.environ.get('EMBEDDER_MODEL')
|
||||
embedder_api_key = os.environ.get('EMBEDDER_API_KEY')
|
||||
embedder_base_url = os.environ.get('EMBEDDER_BASE_URL')
|
||||
|
||||
embedder_config = _create_embedder_config(
|
||||
provider=embedder_provider,
|
||||
model=embedder_model,
|
||||
api_key=embedder_api_key,
|
||||
base_url=embedder_base_url,
|
||||
ollama_base_url=ollama_base_url,
|
||||
llm_base_url=llm_base_url,
|
||||
)
|
||||
print(f"Auto-detected embedder provider: {embedder_provider}")
|
||||
|
||||
return {
|
||||
"vector_store": {
|
||||
"provider": vector_store_provider,
|
||||
"config": vector_store_config
|
||||
},
|
||||
"llm": {
|
||||
"provider": llm_provider,
|
||||
"config": llm_config
|
||||
},
|
||||
"embedder": {
|
||||
"provider": embedder_provider,
|
||||
"config": embedder_config
|
||||
},
|
||||
"version": "v1.1"
|
||||
}
|
||||
|
||||
|
||||
def _parse_environment_variables(config_dict):
|
||||
"""
|
||||
Parse environment variables in config values.
|
||||
Converts 'env:VARIABLE_NAME' to actual environment variable values.
|
||||
"""
|
||||
if isinstance(config_dict, dict):
|
||||
parsed_config = {}
|
||||
for key, value in config_dict.items():
|
||||
if isinstance(value, str) and value.startswith("env:"):
|
||||
env_var = value.split(":", 1)[1]
|
||||
env_value = os.environ.get(env_var)
|
||||
if env_value:
|
||||
parsed_config[key] = env_value
|
||||
print(f"Loaded {env_var} from environment for {key}")
|
||||
else:
|
||||
print(f"Warning: Environment variable {env_var} not found, keeping original value")
|
||||
parsed_config[key] = value
|
||||
elif isinstance(value, dict):
|
||||
parsed_config[key] = _parse_environment_variables(value)
|
||||
else:
|
||||
parsed_config[key] = value
|
||||
return parsed_config
|
||||
return config_dict
|
||||
|
||||
|
||||
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 or None if initialization fails.
|
||||
|
||||
Raises:
|
||||
Exception: If required API keys are not set or critical configuration is missing.
|
||||
"""
|
||||
global _memory_client, _config_hash
|
||||
|
||||
try:
|
||||
# Start with default configuration
|
||||
config = get_default_memory_config()
|
||||
|
||||
# Variable to track custom instructions
|
||||
db_custom_instructions = None
|
||||
|
||||
# Load configuration from database
|
||||
try:
|
||||
db = SessionLocal()
|
||||
db_config = db.query(ConfigModel).filter(ConfigModel.key == "main").first()
|
||||
|
||||
if db_config:
|
||||
json_config = db_config.value
|
||||
|
||||
# Extract custom instructions from openmemory settings
|
||||
if "openmemory" in json_config and "custom_instructions" in json_config["openmemory"]:
|
||||
db_custom_instructions = json_config["openmemory"]["custom_instructions"]
|
||||
|
||||
# Override defaults with configurations from the database
|
||||
if "mem0" in json_config:
|
||||
mem0_config = json_config["mem0"]
|
||||
|
||||
# Update LLM configuration if available
|
||||
if "llm" in mem0_config and mem0_config["llm"] is not None:
|
||||
config["llm"] = mem0_config["llm"]
|
||||
|
||||
# Update Embedder configuration if available
|
||||
if "embedder" in mem0_config and mem0_config["embedder"] is not None:
|
||||
config["embedder"] = mem0_config["embedder"]
|
||||
|
||||
if "vector_store" in mem0_config and mem0_config["vector_store"] is not None:
|
||||
config["vector_store"] = mem0_config["vector_store"]
|
||||
else:
|
||||
print("No configuration found in database, using defaults")
|
||||
|
||||
db.close()
|
||||
|
||||
except Exception as e:
|
||||
print(f"Warning: Error loading configuration from database: {e}")
|
||||
print("Using default configuration")
|
||||
# Continue with default configuration if database config can't be loaded
|
||||
|
||||
# Use custom_instructions parameter first, then fall back to database value
|
||||
instructions_to_use = custom_instructions or db_custom_instructions
|
||||
if instructions_to_use:
|
||||
config["custom_fact_extraction_prompt"] = instructions_to_use
|
||||
|
||||
# Fix Ollama URLs for Docker environment (applies to both env-var defaults and DB overrides)
|
||||
if config.get("llm", {}).get("provider") == "ollama":
|
||||
config["llm"] = _fix_ollama_urls(config["llm"])
|
||||
if config.get("embedder", {}).get("provider") == "ollama":
|
||||
config["embedder"] = _fix_ollama_urls(config["embedder"])
|
||||
|
||||
# ALWAYS parse environment variables in the final config
|
||||
# This ensures that even default config values like "env:OPENAI_API_KEY" get parsed
|
||||
print("Parsing environment variables in final config...")
|
||||
config = _parse_environment_variables(config)
|
||||
|
||||
# Check if config has changed by comparing hashes
|
||||
current_config_hash = _get_config_hash(config)
|
||||
|
||||
# Only reinitialize if config changed or client doesn't exist
|
||||
if _memory_client is None or _config_hash != current_config_hash:
|
||||
print(f"Initializing memory client with config hash: {current_config_hash}")
|
||||
try:
|
||||
_memory_client = Memory.from_config(config_dict=config)
|
||||
_config_hash = current_config_hash
|
||||
print("Memory client initialized successfully")
|
||||
except Exception as init_error:
|
||||
print(f"Warning: Failed to initialize memory client: {init_error}")
|
||||
print("Server will continue running with limited memory functionality")
|
||||
_memory_client = None
|
||||
_config_hash = None
|
||||
return None
|
||||
|
||||
return _memory_client
|
||||
|
||||
except Exception as e:
|
||||
print(f"Warning: Exception occurred while initializing memory client: {e}")
|
||||
print("Server will continue running with limited memory functionality")
|
||||
return None
|
||||
|
||||
|
||||
def get_default_user_id():
|
||||
return "default_user"
|
||||
@@ -1,53 +0,0 @@
|
||||
from typing import Optional
|
||||
from uuid import UUID
|
||||
|
||||
from app.models import App, Memory, MemoryState
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
|
||||
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
|
||||
@@ -1,28 +0,0 @@
|
||||
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.
|
||||
"""
|
||||
@@ -1,20 +0,0 @@
|
||||
{
|
||||
"mem0": {
|
||||
"llm": {
|
||||
"provider": "openai",
|
||||
"config": {
|
||||
"model": "gpt-4o-mini",
|
||||
"temperature": 0.1,
|
||||
"max_tokens": 2000,
|
||||
"api_key": "env:API_KEY"
|
||||
}
|
||||
},
|
||||
"embedder": {
|
||||
"provider": "openai",
|
||||
"config": {
|
||||
"model": "text-embedding-3-small",
|
||||
"api_key": "env:API_KEY"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
{
|
||||
"mem0": {
|
||||
"llm": {
|
||||
"provider": "openai",
|
||||
"config": {
|
||||
"model": "gpt-4o-mini",
|
||||
"temperature": 0.1,
|
||||
"max_tokens": 2000,
|
||||
"api_key": "env:OPENAI_API_KEY"
|
||||
}
|
||||
},
|
||||
"embedder": {
|
||||
"provider": "openai",
|
||||
"config": {
|
||||
"model": "text-embedding-3-small",
|
||||
"api_key": "env:OPENAI_API_KEY"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,89 +0,0 @@
|
||||
import datetime
|
||||
from uuid import uuid4
|
||||
|
||||
from app.config import DEFAULT_APP_ID, USER_ID
|
||||
from app.database import Base, SessionLocal, engine
|
||||
from app.mcp_server import setup_mcp_server
|
||||
from app.models import App, User
|
||||
from app.routers import apps_router, backup_router, config_router, memories_router, stats_router
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi_pagination import add_pagination
|
||||
|
||||
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)
|
||||
app.include_router(config_router)
|
||||
app.include_router(backup_router)
|
||||
|
||||
# Add pagination support
|
||||
add_pagination(app)
|
||||
@@ -1,20 +0,0 @@
|
||||
fastapi>=0.68.0
|
||||
uvicorn>=0.15.0
|
||||
sqlalchemy>=1.4.0
|
||||
python-dotenv>=1.2.2
|
||||
alembic>=1.7.0
|
||||
psycopg2-binary>=2.9.0
|
||||
python-multipart>=0.0.27
|
||||
urllib3>=2.7.0
|
||||
fastapi-pagination>=0.12.0
|
||||
mem0ai>=0.1.92
|
||||
openai>=1.40.0
|
||||
mcp[cli]>=1.25.4
|
||||
starlette>=0.40.0
|
||||
pytest>=7.0.0
|
||||
pytest-asyncio>=0.21.0
|
||||
httpx>=0.24.0
|
||||
pytest-cov>=4.0.0
|
||||
tenacity==9.1.2
|
||||
anthropic==0.51.0
|
||||
ollama==0.4.8
|
||||
@@ -1,396 +0,0 @@
|
||||
"""Tests for the MCP server endpoints (SSE and Streamable HTTP transports).
|
||||
|
||||
Covers the Streamable HTTP transport (MCP spec 2025-03-26+) and the legacy SSE
|
||||
transport. Tests exercise the full JSON-RPC flow — initialize, tools/list,
|
||||
tools/call — as well as error handling and context-variable isolation.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
# Set dummy keys before any imports that trigger client initialization
|
||||
os.environ.setdefault("OPENAI_API_KEY", "test-key")
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
from app.mcp_server import client_name_var, mcp, mcp_router, user_id_var
|
||||
|
||||
# MCP Streamable HTTP requires the Accept header to include application/json.
|
||||
# Including text/event-stream as well satisfies GET (SSE) requests.
|
||||
MCP_HEADERS = {"Accept": "application/json, text/event-stream"}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.fixture
|
||||
def test_app():
|
||||
"""Create a minimal FastAPI app with just the MCP router for testing."""
|
||||
from fastapi import FastAPI
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(mcp_router)
|
||||
return app
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def client(test_app):
|
||||
"""Async HTTP client wired to the test app via ASGI transport."""
|
||||
transport = ASGITransport(app=test_app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as ac:
|
||||
yield ac
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _jsonrpc(method: str, params: dict | None = None, req_id: int = 1) -> dict:
|
||||
"""Build a JSON-RPC 2.0 request envelope."""
|
||||
return {
|
||||
"jsonrpc": "2.0",
|
||||
"id": req_id,
|
||||
"method": method,
|
||||
"params": params or {},
|
||||
}
|
||||
|
||||
|
||||
def _initialize_payload(req_id: int = 1) -> dict:
|
||||
return _jsonrpc(
|
||||
"initialize",
|
||||
{
|
||||
"protocolVersion": "2025-03-26",
|
||||
"capabilities": {},
|
||||
"clientInfo": {"name": "test-client", "version": "0.1.0"},
|
||||
},
|
||||
req_id=req_id,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Streamable HTTP — route existence & basic protocol
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestStreamableHTTPBasic:
|
||||
"""Verify the Streamable HTTP route is registered and responds."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_post_initialize(self, client):
|
||||
"""POST initialize should return a valid JSON-RPC result."""
|
||||
resp = await client.post(
|
||||
"/mcp/testclient/http/user1",
|
||||
json=_initialize_payload(),
|
||||
headers=MCP_HEADERS,
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["jsonrpc"] == "2.0"
|
||||
assert data["id"] == 1
|
||||
assert "result" in data
|
||||
result = data["result"]
|
||||
assert "serverInfo" in result
|
||||
assert "capabilities" in result
|
||||
assert result["protocolVersion"] == "2025-03-26"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_returns_method_not_allowed(self, client):
|
||||
"""DELETE in stateless mode should return 405 (no session to terminate)."""
|
||||
resp = await client.delete(
|
||||
"/mcp/testclient/http/user1",
|
||||
headers=MCP_HEADERS,
|
||||
)
|
||||
assert resp.status_code == 405
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_missing_accept_header_returns_406(self, client):
|
||||
"""POST without the required Accept header should return 406."""
|
||||
resp = await client.post(
|
||||
"/mcp/testclient/http/user1",
|
||||
json=_initialize_payload(),
|
||||
)
|
||||
assert resp.status_code == 406
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invalid_json_returns_400(self, client):
|
||||
"""POST with unparseable body should return 400."""
|
||||
resp = await client.post(
|
||||
"/mcp/testclient/http/user1",
|
||||
content=b"not json",
|
||||
headers={**MCP_HEADERS, "Content-Type": "application/json"},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_route_not_found_for_wrong_path(self, client):
|
||||
"""Requests to a non-existent path should 404."""
|
||||
resp = await client.post(
|
||||
"/mcp/testclient/nonexistent/user1",
|
||||
json=_initialize_payload(),
|
||||
headers=MCP_HEADERS,
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Streamable HTTP — full protocol flow
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestStreamableHTTPProtocol:
|
||||
"""End-to-end JSON-RPC flows over Streamable HTTP."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tools_list(self, client):
|
||||
"""tools/list should return all registered MCP tools."""
|
||||
init_resp = await client.post(
|
||||
"/mcp/testclient/http/user1",
|
||||
json=_initialize_payload(),
|
||||
headers=MCP_HEADERS,
|
||||
)
|
||||
assert init_resp.status_code == 200
|
||||
|
||||
resp = await client.post(
|
||||
"/mcp/testclient/http/user1",
|
||||
json=_jsonrpc("tools/list", req_id=2),
|
||||
headers=MCP_HEADERS,
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert "result" in data
|
||||
tool_names = {t["name"] for t in data["result"]["tools"]}
|
||||
expected = {"add_memories", "search_memory", "list_memories",
|
||||
"delete_memories", "delete_all_memories"}
|
||||
assert expected.issubset(tool_names), f"Missing tools: {expected - tool_names}"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tools_list_has_descriptions(self, client):
|
||||
"""Every tool returned by tools/list should have a non-empty description."""
|
||||
await client.post(
|
||||
"/mcp/testclient/http/user1",
|
||||
json=_initialize_payload(),
|
||||
headers=MCP_HEADERS,
|
||||
)
|
||||
resp = await client.post(
|
||||
"/mcp/testclient/http/user1",
|
||||
json=_jsonrpc("tools/list", req_id=2),
|
||||
headers=MCP_HEADERS,
|
||||
)
|
||||
for tool in resp.json()["result"]["tools"]:
|
||||
assert tool.get("description"), f"Tool {tool['name']} has no description"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tools_list_has_input_schemas(self, client):
|
||||
"""Every tool should declare an inputSchema."""
|
||||
await client.post(
|
||||
"/mcp/testclient/http/user1",
|
||||
json=_initialize_payload(),
|
||||
headers=MCP_HEADERS,
|
||||
)
|
||||
resp = await client.post(
|
||||
"/mcp/testclient/http/user1",
|
||||
json=_jsonrpc("tools/list", req_id=2),
|
||||
headers=MCP_HEADERS,
|
||||
)
|
||||
for tool in resp.json()["result"]["tools"]:
|
||||
assert "inputSchema" in tool, f"Tool {tool['name']} missing inputSchema"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_call_unknown_tool_returns_error(self, client):
|
||||
"""Calling a non-existent tool should return a JSON-RPC error."""
|
||||
await client.post(
|
||||
"/mcp/testclient/http/user1",
|
||||
json=_initialize_payload(),
|
||||
headers=MCP_HEADERS,
|
||||
)
|
||||
resp = await client.post(
|
||||
"/mcp/testclient/http/user1",
|
||||
json=_jsonrpc("tools/call", {"name": "no_such_tool", "arguments": {}}, req_id=2),
|
||||
headers=MCP_HEADERS,
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert "error" in data or (
|
||||
"result" in data and data["result"].get("isError")
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unknown_jsonrpc_method(self, client):
|
||||
"""An unknown JSON-RPC method should return an error."""
|
||||
resp = await client.post(
|
||||
"/mcp/testclient/http/user1",
|
||||
json=_jsonrpc("nonexistent/method"),
|
||||
headers=MCP_HEADERS,
|
||||
)
|
||||
assert resp.status_code in (200, 400)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_response_content_type_is_json(self, client):
|
||||
"""Responses should have Content-Type: application/json."""
|
||||
resp = await client.post(
|
||||
"/mcp/testclient/http/user1",
|
||||
json=_initialize_payload(),
|
||||
headers=MCP_HEADERS,
|
||||
)
|
||||
ct = resp.headers.get("content-type", "")
|
||||
assert "application/json" in ct
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Streamable HTTP — context variable handling
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestStreamableHTTPContext:
|
||||
"""Verify that user_id and client_name context variables are set correctly."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_context_vars_set_during_tool_call(self, client):
|
||||
"""Context vars should reflect the path parameters during tool execution."""
|
||||
captured = {}
|
||||
|
||||
@mcp.tool(name="__test_ctx", description="test only")
|
||||
async def _capture(query: str = "") -> str:
|
||||
captured["user_id"] = user_id_var.get(None)
|
||||
captured["client_name"] = client_name_var.get(None)
|
||||
return "ok"
|
||||
|
||||
try:
|
||||
await client.post(
|
||||
"/mcp/my-app/http/alice",
|
||||
json=_initialize_payload(),
|
||||
headers=MCP_HEADERS,
|
||||
)
|
||||
resp = await client.post(
|
||||
"/mcp/my-app/http/alice",
|
||||
json=_jsonrpc("tools/call", {"name": "__test_ctx", "arguments": {}}, req_id=2),
|
||||
headers=MCP_HEADERS,
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert captured.get("user_id") == "alice"
|
||||
assert captured.get("client_name") == "my-app"
|
||||
finally:
|
||||
mcp._tool_manager._tools.pop("__test_ctx", None)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_different_users_are_isolated(self, client):
|
||||
"""Sequential requests with different user_ids must not leak state."""
|
||||
results = []
|
||||
|
||||
@mcp.tool(name="__test_uid_iso", description="test only")
|
||||
async def _capture_uid(query: str = "") -> str:
|
||||
results.append(user_id_var.get(None))
|
||||
return "ok"
|
||||
|
||||
try:
|
||||
for uid in ("userA", "userB", "userC"):
|
||||
await client.post(
|
||||
f"/mcp/app1/http/{uid}",
|
||||
json=_initialize_payload(),
|
||||
headers=MCP_HEADERS,
|
||||
)
|
||||
await client.post(
|
||||
f"/mcp/app1/http/{uid}",
|
||||
json=_jsonrpc("tools/call", {"name": "__test_uid_iso", "arguments": {}}, req_id=2),
|
||||
headers=MCP_HEADERS,
|
||||
)
|
||||
|
||||
assert results == ["userA", "userB", "userC"]
|
||||
finally:
|
||||
mcp._tool_manager._tools.pop("__test_uid_iso", None)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_different_clients_are_isolated(self, client):
|
||||
"""Sequential requests with different client_names must not leak state."""
|
||||
results = []
|
||||
|
||||
@mcp.tool(name="__test_cn_iso", description="test only")
|
||||
async def _capture_cn(query: str = "") -> str:
|
||||
results.append(client_name_var.get(None))
|
||||
return "ok"
|
||||
|
||||
try:
|
||||
for cn in ("cursor", "windsurf", "claude"):
|
||||
await client.post(
|
||||
f"/mcp/{cn}/http/user1",
|
||||
json=_initialize_payload(),
|
||||
headers=MCP_HEADERS,
|
||||
)
|
||||
await client.post(
|
||||
f"/mcp/{cn}/http/user1",
|
||||
json=_jsonrpc("tools/call", {"name": "__test_cn_iso", "arguments": {}}, req_id=2),
|
||||
headers=MCP_HEADERS,
|
||||
)
|
||||
|
||||
assert results == ["cursor", "windsurf", "claude"]
|
||||
finally:
|
||||
mcp._tool_manager._tools.pop("__test_cn_iso", None)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Streamable HTTP — response correctness
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestStreamableHTTPResponses:
|
||||
"""Verify that captured responses are returned correctly to the caller."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_error_status_codes_are_preserved(self, client):
|
||||
"""Transport error codes (e.g. 406) must be forwarded, not masked as 200."""
|
||||
resp = await client.post(
|
||||
"/mcp/testclient/http/user1",
|
||||
json=_initialize_payload(),
|
||||
)
|
||||
assert resp.status_code == 406
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_status_code_preserved(self, client):
|
||||
"""DELETE 405 from stateless transport must not be masked."""
|
||||
resp = await client.delete(
|
||||
"/mcp/testclient/http/user1",
|
||||
headers=MCP_HEADERS,
|
||||
)
|
||||
assert resp.status_code == 405
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_multiple_sequential_requests(self, client):
|
||||
"""Multiple requests in sequence should each get independent responses."""
|
||||
for i in range(5):
|
||||
resp = await client.post(
|
||||
"/mcp/testclient/http/user1",
|
||||
json=_initialize_payload(req_id=i + 1),
|
||||
headers=MCP_HEADERS,
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["id"] == i + 1
|
||||
assert "result" in data
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_wrong_content_type_returns_error(self, client):
|
||||
"""POST with wrong Content-Type should return an error status."""
|
||||
resp = await client.post(
|
||||
"/mcp/testclient/http/user1",
|
||||
content=b'{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}',
|
||||
headers={**MCP_HEADERS, "Content-Type": "text/plain"},
|
||||
)
|
||||
assert resp.status_code in (400, 415)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Route registration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestRouteRegistration:
|
||||
"""Verify all expected routes are registered in the router."""
|
||||
|
||||
def test_sse_route_is_registered(self, test_app):
|
||||
routes = [r.path for r in test_app.routes if hasattr(r, "path")]
|
||||
assert "/mcp/{client_name}/sse/{user_id}" in routes
|
||||
|
||||
def test_sse_post_messages_route_is_registered(self, test_app):
|
||||
routes = [r.path for r in test_app.routes if hasattr(r, "path")]
|
||||
assert "/mcp/messages/" in routes or "/mcp/{client_name}/sse/{user_id}/messages/" in routes
|
||||
|
||||
def test_streamable_http_route_is_registered(self, test_app):
|
||||
routes = [r.path for r in test_app.routes if hasattr(r, "path")]
|
||||
assert "/mcp/{client_name}/http/{user_id}" in routes
|
||||
@@ -1,393 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Export OpenMemory data from a running Docker container without relying on API endpoints.
|
||||
# Produces: memories.json + memories.jsonl.gz zipped as memories_export_<USER_ID>.zip
|
||||
#
|
||||
# Requirements:
|
||||
# - docker available locally
|
||||
# - The target container has Python + SQLAlchemy and access to the same DATABASE_URL it uses in prod
|
||||
#
|
||||
# Usage:
|
||||
# ./export_openmemory.sh --user-id <USER_ID> [--container <NAME_OR_ID>] [--app-id <UUID>] [--from-date <epoch_secs>] [--to-date <epoch_secs>]
|
||||
#
|
||||
# Notes:
|
||||
# - USER_ID is the external user identifier (e.g., "vikramiyer"), not the internal UUID.
|
||||
# - If --container is omitted, the script uses container name "openmemory-openmemory-mcp-1".
|
||||
# - The script writes intermediate files to /tmp inside the container, then docker cp's them out and zips locally.
|
||||
|
||||
usage() {
|
||||
echo "Usage: $0 --user-id <USER_ID> [--container <NAME_OR_ID>] [--app-id <UUID>] [--from-date <epoch_secs>] [--to-date <epoch_secs>]"
|
||||
exit 1
|
||||
}
|
||||
|
||||
USER_ID=""
|
||||
CONTAINER=""
|
||||
APP_ID=""
|
||||
FROM_DATE=""
|
||||
TO_DATE=""
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--user-id) USER_ID="${2:-}"; shift 2 ;;
|
||||
--container) CONTAINER="${2:-}"; shift 2 ;;
|
||||
--app-id) APP_ID="${2:-}"; shift 2 ;;
|
||||
--from-date) FROM_DATE="${2:-}"; shift 2 ;;
|
||||
--to-date) TO_DATE="${2:-}"; shift 2 ;;
|
||||
-h|--help) usage ;;
|
||||
*) echo "Unknown arg: $1"; usage ;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ -z "${USER_ID}" ]]; then
|
||||
echo "ERROR: --user-id is required"
|
||||
usage
|
||||
fi
|
||||
|
||||
if [[ -z "${CONTAINER}" ]]; then
|
||||
CONTAINER="openmemory-openmemory-mcp-1"
|
||||
fi
|
||||
|
||||
# Verify the container exists and is running
|
||||
if ! docker ps --format '{{.Names}}' | grep -qx "${CONTAINER}"; then
|
||||
echo "ERROR: Container '${CONTAINER}' not found/running. Pass --container <NAME_OR_ID> if different."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Verify python is available inside the container
|
||||
if ! docker exec "${CONTAINER}" sh -lc 'command -v python3 >/dev/null 2>&1 || command -v python >/dev/null 2>&1'; then
|
||||
echo "ERROR: Python is not available in container ${CONTAINER}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
PY_BIN="python3"
|
||||
if ! docker exec "${CONTAINER}" sh -lc 'command -v python3 >/dev/null 2>&1'; then
|
||||
PY_BIN="python"
|
||||
fi
|
||||
|
||||
echo "Using container: ${CONTAINER}"
|
||||
echo "Exporting data for user_id: ${USER_ID}"
|
||||
|
||||
# Run Python inside the container to generate memories.json and memories.jsonl.gz in /tmp
|
||||
set +e
|
||||
cat <<'PYCODE' | docker exec -i \
|
||||
-e EXPORT_USER_ID="${USER_ID}" \
|
||||
-e EXPORT_APP_ID="${APP_ID}" \
|
||||
-e EXPORT_FROM_DATE="${FROM_DATE}" \
|
||||
-e EXPORT_TO_DATE="${TO_DATE}" \
|
||||
"${CONTAINER}" "${PY_BIN}" -
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import gzip
|
||||
import uuid
|
||||
import datetime
|
||||
from typing import Any, Dict, List
|
||||
|
||||
try:
|
||||
from sqlalchemy import create_engine, text
|
||||
except Exception as e:
|
||||
print(f"ERROR: SQLAlchemy not available inside the container: {e}", file=sys.stderr)
|
||||
sys.exit(3)
|
||||
|
||||
def _iso(dt):
|
||||
if dt is None:
|
||||
return None
|
||||
try:
|
||||
if isinstance(dt, str):
|
||||
try:
|
||||
dt_obj = datetime.datetime.fromisoformat(dt.replace("Z", "+00:00"))
|
||||
except Exception:
|
||||
return dt
|
||||
else:
|
||||
dt_obj = dt
|
||||
if dt_obj.tzinfo is None:
|
||||
dt_obj = dt_obj.replace(tzinfo=datetime.timezone.utc)
|
||||
else:
|
||||
dt_obj = dt_obj.astimezone(datetime.timezone.utc)
|
||||
return dt_obj.isoformat()
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def _json_load_maybe(val):
|
||||
if isinstance(val, (dict, list)) or val is None:
|
||||
return val
|
||||
if isinstance(val, (bytes, bytearray)):
|
||||
try:
|
||||
return json.loads(val.decode("utf-8"))
|
||||
except Exception:
|
||||
try:
|
||||
return val.decode("utf-8", "ignore")
|
||||
except Exception:
|
||||
return None
|
||||
if isinstance(val, str):
|
||||
try:
|
||||
return json.loads(val)
|
||||
except Exception:
|
||||
return val
|
||||
return val
|
||||
|
||||
def _named_in_clause(prefix: str, items: List[Any]):
|
||||
names = [f":{prefix}{i}" for i in range(len(items))]
|
||||
params = {f"{prefix}{i}": items[i] for i in range(len(items))}
|
||||
return ", ".join(names), params
|
||||
|
||||
DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///./openmemory.db")
|
||||
user_id_str = os.getenv("EXPORT_USER_ID")
|
||||
app_id_filter = os.getenv("EXPORT_APP_ID") or None
|
||||
from_date = os.getenv("EXPORT_FROM_DATE")
|
||||
to_date = os.getenv("EXPORT_TO_DATE")
|
||||
|
||||
if not user_id_str:
|
||||
print("Missing EXPORT_USER_ID", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
|
||||
from_ts = None
|
||||
to_ts = None
|
||||
try:
|
||||
if from_date:
|
||||
from_ts = int(from_date)
|
||||
if to_date:
|
||||
to_ts = int(to_date)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
engine = create_engine(DATABASE_URL)
|
||||
|
||||
with engine.connect() as conn:
|
||||
user_row = conn.execute(
|
||||
text("SELECT id, user_id, name, email, metadata, created_at, updated_at FROM users WHERE user_id = :uid"),
|
||||
{"uid": user_id_str}
|
||||
).mappings().first()
|
||||
if not user_row:
|
||||
print(f'User not found for user_id "{user_id_str}"', file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
user_uuid = user_row["id"]
|
||||
|
||||
# Build memories filter
|
||||
params = {"user_id": user_uuid}
|
||||
conditions = ["user_id = :user_id"]
|
||||
if from_ts is not None:
|
||||
params["from_dt"] = datetime.datetime.fromtimestamp(from_ts, tz=datetime.timezone.utc)
|
||||
conditions.append("created_at >= :from_dt")
|
||||
if to_ts is not None:
|
||||
params["to_dt"] = datetime.datetime.fromtimestamp(to_ts, tz=datetime.timezone.utc)
|
||||
conditions.append("created_at <= :to_dt")
|
||||
if app_id_filter:
|
||||
try:
|
||||
# Accept UUID or raw DB value
|
||||
app_uuid = uuid.UUID(app_id_filter)
|
||||
params["app_id"] = str(app_uuid)
|
||||
except Exception:
|
||||
params["app_id"] = app_id_filter
|
||||
conditions.append("app_id = :app_id")
|
||||
|
||||
mem_sql = f"""
|
||||
SELECT id, user_id, app_id, content, metadata, state, created_at, updated_at, archived_at, deleted_at
|
||||
FROM memories
|
||||
WHERE {' AND '.join(conditions)}
|
||||
"""
|
||||
mem_rows = list(conn.execute(text(mem_sql), params).mappings())
|
||||
memory_ids = [r["id"] for r in mem_rows]
|
||||
app_ids = sorted({r["app_id"] for r in mem_rows if r["app_id"] is not None})
|
||||
|
||||
# memory_categories
|
||||
mc_rows = []
|
||||
if memory_ids:
|
||||
names, in_params = _named_in_clause("mid", memory_ids)
|
||||
mc_rows = list(conn.execute(
|
||||
text(f"SELECT memory_id, category_id FROM memory_categories WHERE memory_id IN ({names})"),
|
||||
in_params
|
||||
).mappings())
|
||||
|
||||
# categories for referenced category_ids
|
||||
cats = []
|
||||
cat_ids = sorted({r["category_id"] for r in mc_rows})
|
||||
if cat_ids:
|
||||
names, in_params = _named_in_clause("cid", cat_ids)
|
||||
cats = list(conn.execute(
|
||||
text(f"SELECT id, name, description, created_at, updated_at FROM categories WHERE id IN ({names})"),
|
||||
in_params
|
||||
).mappings())
|
||||
|
||||
# apps for referenced app_ids
|
||||
apps = []
|
||||
if app_ids:
|
||||
names, in_params = _named_in_clause("aid", app_ids)
|
||||
apps = list(conn.execute(
|
||||
text(f"SELECT id, owner_id, name, description, metadata, is_active, created_at, updated_at FROM apps WHERE id IN ({names})"),
|
||||
in_params
|
||||
).mappings())
|
||||
|
||||
# status history for selected memories
|
||||
history = []
|
||||
if memory_ids:
|
||||
names, in_params = _named_in_clause("hid", memory_ids)
|
||||
history = list(conn.execute(
|
||||
text(f"SELECT id, memory_id, changed_by, old_state, new_state, changed_at FROM memory_status_history WHERE memory_id IN ({names})"),
|
||||
in_params
|
||||
).mappings())
|
||||
|
||||
# access_controls for the apps
|
||||
acls = []
|
||||
if app_ids:
|
||||
names, in_params = _named_in_clause("sid", app_ids)
|
||||
acls = list(conn.execute(
|
||||
text(f"""SELECT id, subject_type, subject_id, object_type, object_id, effect, created_at
|
||||
FROM access_controls
|
||||
WHERE subject_type = 'app' AND subject_id IN ({names})"""),
|
||||
in_params
|
||||
).mappings())
|
||||
|
||||
# Build helper maps
|
||||
app_name_by_id = {r["id"]: r["name"] for r in apps}
|
||||
app_rec_by_id = {r["id"]: r for r in apps}
|
||||
cat_name_by_id = {r["id"]: r["name"] for r in cats}
|
||||
mem_cat_ids_map: Dict[Any, List[Any]] = {}
|
||||
mem_cat_names_map: Dict[Any, List[str]] = {}
|
||||
for r in mc_rows:
|
||||
mem_cat_ids_map.setdefault(r["memory_id"], []).append(r["category_id"])
|
||||
mem_cat_names_map.setdefault(r["memory_id"], []).append(cat_name_by_id.get(r["category_id"], ""))
|
||||
|
||||
# Build sqlite-like payload
|
||||
sqlite_payload = {
|
||||
"user": {
|
||||
"id": str(user_row["id"]),
|
||||
"user_id": user_row["user_id"],
|
||||
"name": user_row.get("name"),
|
||||
"email": user_row.get("email"),
|
||||
"metadata": _json_load_maybe(user_row.get("metadata")),
|
||||
"created_at": _iso(user_row.get("created_at")),
|
||||
"updated_at": _iso(user_row.get("updated_at")),
|
||||
},
|
||||
"apps": [
|
||||
{
|
||||
"id": str(a["id"]),
|
||||
"owner_id": str(a["owner_id"]) if a.get("owner_id") else None,
|
||||
"name": a["name"],
|
||||
"description": a.get("description"),
|
||||
"metadata": _json_load_maybe(a.get("metadata")),
|
||||
"is_active": bool(a.get("is_active")),
|
||||
"created_at": _iso(a.get("created_at")),
|
||||
"updated_at": _iso(a.get("updated_at")),
|
||||
}
|
||||
for a in apps
|
||||
],
|
||||
"categories": [
|
||||
{
|
||||
"id": str(c["id"]),
|
||||
"name": c["name"],
|
||||
"description": c.get("description"),
|
||||
"created_at": _iso(c.get("created_at")),
|
||||
"updated_at": _iso(c.get("updated_at")),
|
||||
}
|
||||
for c in cats
|
||||
],
|
||||
"memories": [
|
||||
{
|
||||
"id": str(m["id"]),
|
||||
"user_id": str(m["user_id"]),
|
||||
"app_id": str(m["app_id"]) if m.get("app_id") else None,
|
||||
"content": m.get("content") or "",
|
||||
"metadata": _json_load_maybe(m.get("metadata")) or {},
|
||||
"state": m.get("state"),
|
||||
"created_at": _iso(m.get("created_at")),
|
||||
"updated_at": _iso(m.get("updated_at")),
|
||||
"archived_at": _iso(m.get("archived_at")),
|
||||
"deleted_at": _iso(m.get("deleted_at")),
|
||||
"category_ids": [str(cid) for cid in mem_cat_ids_map.get(m["id"], [])],
|
||||
}
|
||||
for m in mem_rows
|
||||
],
|
||||
"memory_categories": [
|
||||
{"memory_id": str(r["memory_id"]), "category_id": str(r["category_id"])}
|
||||
for r in mc_rows
|
||||
],
|
||||
"status_history": [
|
||||
{
|
||||
"id": str(h["id"]),
|
||||
"memory_id": str(h["memory_id"]),
|
||||
"changed_by": str(h["changed_by"]),
|
||||
"old_state": h.get("old_state"),
|
||||
"new_state": h.get("new_state"),
|
||||
"changed_at": _iso(h.get("changed_at")),
|
||||
}
|
||||
for h in history
|
||||
],
|
||||
"access_controls": [
|
||||
{
|
||||
"id": str(ac["id"]),
|
||||
"subject_type": ac.get("subject_type"),
|
||||
"subject_id": str(ac["subject_id"]) if ac.get("subject_id") else None,
|
||||
"object_type": ac.get("object_type"),
|
||||
"object_id": str(ac["object_id"]) if ac.get("object_id") else None,
|
||||
"effect": ac.get("effect"),
|
||||
"created_at": _iso(ac.get("created_at")),
|
||||
}
|
||||
for ac in acls
|
||||
],
|
||||
"export_meta": {
|
||||
"app_id_filter": str(app_id_filter) if app_id_filter else None,
|
||||
"from_date": from_ts,
|
||||
"to_date": to_ts,
|
||||
"version": "1",
|
||||
"generated_at": datetime.datetime.now(datetime.timezone.utc).isoformat(),
|
||||
},
|
||||
}
|
||||
|
||||
# Write memories.json
|
||||
out_json = "/tmp/memories.json"
|
||||
with open(out_json, "w", encoding="utf-8") as f:
|
||||
json.dump(sqlite_payload, f, indent=2, ensure_ascii=False)
|
||||
|
||||
# Write logical jsonl.gz
|
||||
out_jsonl_gz = "/tmp/memories.jsonl.gz"
|
||||
with gzip.open(out_jsonl_gz, "wb") as gz:
|
||||
for m in mem_rows:
|
||||
record = {
|
||||
"id": str(m["id"]),
|
||||
"content": m.get("content") or "",
|
||||
"metadata": _json_load_maybe(m.get("metadata")) or {},
|
||||
"created_at": _iso(m.get("created_at")),
|
||||
"updated_at": _iso(m.get("updated_at")),
|
||||
"state": m.get("state"),
|
||||
"app": app_name_by_id.get(m.get("app_id")) if m.get("app_id") else None,
|
||||
"categories": [c for c in mem_cat_names_map.get(m["id"], []) if c],
|
||||
}
|
||||
gz.write((json.dumps(record, ensure_ascii=False) + "\n").encode("utf-8"))
|
||||
|
||||
print(out_json)
|
||||
print(out_jsonl_gz)
|
||||
PYCODE
|
||||
PY_EXIT=$?
|
||||
set -e
|
||||
if [[ $PY_EXIT -ne 0 ]]; then
|
||||
echo "ERROR: Export failed inside container (exit code $PY_EXIT)"
|
||||
exit $PY_EXIT
|
||||
fi
|
||||
|
||||
# Copy files out of the container
|
||||
TMPDIR="$(mktemp -d)"
|
||||
docker cp "${CONTAINER}:/tmp/memories.json" "${TMPDIR}/memories.json"
|
||||
docker cp "${CONTAINER}:/tmp/memories.jsonl.gz" "${TMPDIR}/memories.jsonl.gz"
|
||||
|
||||
# Create zip on host
|
||||
ZIP_NAME="memories_export_${USER_ID}.zip"
|
||||
if command -v zip >/dev/null 2>&1; then
|
||||
(cd "${TMPDIR}" && zip -q -r "../${ZIP_NAME}" "memories.json" "memories.jsonl.gz")
|
||||
mv "${TMPDIR}/../${ZIP_NAME}" "./${ZIP_NAME}"
|
||||
else
|
||||
# Fallback: use Python zipfile
|
||||
python3 - <<PYFALLBACK
|
||||
import sys, zipfile
|
||||
zf = zipfile.ZipFile("${ZIP_NAME}", "w", compression=zipfile.ZIP_DEFLATED)
|
||||
zf.write("${TMPDIR}/memories.json", arcname="memories.json")
|
||||
zf.write("${TMPDIR}/memories.jsonl.gz", arcname="memories.jsonl.gz")
|
||||
zf.close()
|
||||
print("${ZIP_NAME}")
|
||||
PYFALLBACK
|
||||
fi
|
||||
|
||||
echo "Wrote ./${ZIP_NAME}"
|
||||
echo "Done."
|
||||
@@ -1,11 +0,0 @@
|
||||
services:
|
||||
mem0_store:
|
||||
image: ghcr.io/chroma-core/chroma:latest
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- CHROMA_SERVER_HOST=0.0.0.0
|
||||
- CHROMA_SERVER_HTTP_PORT=8000
|
||||
ports:
|
||||
- "8000:8000"
|
||||
volumes:
|
||||
- mem0_storage:/data
|
||||
@@ -1,15 +0,0 @@
|
||||
services:
|
||||
mem0_store:
|
||||
image: docker.elastic.co/elasticsearch/elasticsearch:8.13.4
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- discovery.type=single-node
|
||||
- xpack.security.enabled=false
|
||||
- ES_JAVA_OPTS=-Xms512m -Xmx512m
|
||||
ulimits:
|
||||
memlock: { soft: -1, hard: -1 }
|
||||
nofile: { soft: 65536, hard: 65536 }
|
||||
ports:
|
||||
- "9200:9200"
|
||||
volumes:
|
||||
- mem0_storage:/usr/share/elasticsearch/data
|
||||
@@ -1,3 +0,0 @@
|
||||
services:
|
||||
# FAISS is a local file-based vector store, so no separate container is needed
|
||||
# Data will be persisted through volume mounts in the main application
|
||||
@@ -1,43 +0,0 @@
|
||||
services:
|
||||
etcd:
|
||||
image: quay.io/coreos/etcd:v3.5.5
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- ETCD_AUTO_COMPACTION_MODE=revision
|
||||
- ETCD_QUOTA_BACKEND_BYTES=4294967296
|
||||
- ETCD_SNAPSHOT_COUNT=50000
|
||||
- ETCD_LISTEN_CLIENT_URLS=http://0.0.0.0:2379
|
||||
- ETCD_ADVERTISE_CLIENT_URLS=http://etcd:2379
|
||||
- ETCD_LISTEN_PEER_URLS=http://0.0.0.0:2380
|
||||
- ETCD_INITIAL_ADVERTISE_PEER_URLS=http://etcd:2380
|
||||
- ETCD_INITIAL_CLUSTER=default=http://etcd:2380
|
||||
- ETCD_NAME=default
|
||||
- ETCD_DATA_DIR=/etcd
|
||||
volumes:
|
||||
- ./data/milvus/etcd:/etcd
|
||||
|
||||
minio:
|
||||
image: minio/minio:RELEASE.2023-10-25T06-33-25Z
|
||||
restart: unless-stopped
|
||||
command: server /minio_data
|
||||
environment:
|
||||
- MINIO_ACCESS_KEY=minioadmin
|
||||
- MINIO_SECRET_KEY=minioadmin
|
||||
volumes:
|
||||
- ./data/milvus/minio:/minio_data
|
||||
|
||||
mem0_store:
|
||||
image: milvusdb/milvus:v2.4.7
|
||||
restart: unless-stopped
|
||||
command: ["milvus", "run", "standalone"]
|
||||
depends_on:
|
||||
- etcd
|
||||
- minio
|
||||
environment:
|
||||
- ETCD_ENDPOINTS=etcd:2379
|
||||
- MINIO_ADDRESS=minio:9000
|
||||
ports:
|
||||
- "19530:19530"
|
||||
- "9091:9091"
|
||||
volumes:
|
||||
- ./data/milvus/milvus:/var/lib/milvus
|
||||
@@ -1,19 +0,0 @@
|
||||
services:
|
||||
mem0_store:
|
||||
image: opensearchproject/opensearch:2.13.0
|
||||
restart: unless-stopped
|
||||
user: "1000:1000"
|
||||
environment:
|
||||
- discovery.type=single-node
|
||||
- plugins.security.disabled=true
|
||||
- OPENSEARCH_JAVA_OPTS=-Xms512m -Xmx512m
|
||||
- OPENSEARCH_INITIAL_ADMIN_PASSWORD=Openmemory123!
|
||||
- bootstrap.memory_lock=true
|
||||
ulimits:
|
||||
memlock: { soft: -1, hard: -1 }
|
||||
nofile: { soft: 65536, hard: 65536 }
|
||||
ports:
|
||||
- "9200:9200"
|
||||
- "9600:9600"
|
||||
volumes:
|
||||
- mem0_storage:/usr/share/opensearch/data
|
||||
@@ -1,12 +0,0 @@
|
||||
services:
|
||||
mem0_store:
|
||||
image: pgvector/pgvector:pg16
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- POSTGRES_DB=mem0
|
||||
- POSTGRES_USER=mem0
|
||||
- POSTGRES_PASSWORD=mem0
|
||||
ports:
|
||||
- "5432:5432"
|
||||
volumes:
|
||||
- mem0_storage:/var/lib/postgresql/data
|
||||
@@ -1,8 +0,0 @@
|
||||
services:
|
||||
mem0_store:
|
||||
image: qdrant/qdrant:latest
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "6333:6333"
|
||||
volumes:
|
||||
- mem0_storage:/mem0/storage
|
||||
@@ -1,13 +0,0 @@
|
||||
services:
|
||||
mem0_store:
|
||||
image: redis/redis-stack-server:latest
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "6379:6379"
|
||||
volumes:
|
||||
- mem0_storage:/var/lib/redis-stack
|
||||
command: >
|
||||
redis-stack-server
|
||||
--appendonly yes
|
||||
--appendfsync everysec
|
||||
--save 900 1 300 10 60 10000
|
||||
@@ -1,14 +0,0 @@
|
||||
services:
|
||||
mem0_store:
|
||||
image: semitechnologies/weaviate:latest
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- QUERY_DEFAULTS_LIMIT=25
|
||||
- AUTHENTICATION_ANONYMOUS_ACCESS_ENABLED=true
|
||||
- PERSISTENCE_DATA_PATH=/var/lib/weaviate
|
||||
- CLUSTER_HOSTNAME=node1
|
||||
- WEAVIATE_CLUSTER_URL=http://mem0_store:8080
|
||||
ports:
|
||||
- "8080:8080"
|
||||
volumes:
|
||||
- mem0_storage:/var/lib/weaviate
|
||||
@@ -1,36 +0,0 @@
|
||||
services:
|
||||
mem0_store:
|
||||
image: qdrant/qdrant
|
||||
ports:
|
||||
- "6333:6333"
|
||||
volumes:
|
||||
- mem0_storage:/mem0/storage
|
||||
openmemory-mcp:
|
||||
image: mem0/openmemory-mcp
|
||||
build: api/
|
||||
environment:
|
||||
- USER
|
||||
- API_KEY
|
||||
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:
|
||||
@@ -1,400 +0,0 @@
|
||||
#!/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
|
||||
|
||||
# Parse vector store selection (env var or flag). Default: qdrant
|
||||
VECTOR_STORE="${VECTOR_STORE:-qdrant}"
|
||||
EMBEDDING_DIMS="${EMBEDDING_DIMS:-1536}"
|
||||
|
||||
for arg in "$@"; do
|
||||
case $arg in
|
||||
--vector-store=*)
|
||||
VECTOR_STORE="${arg#*=}"
|
||||
shift
|
||||
;;
|
||||
--vector-store)
|
||||
VECTOR_STORE="$2"
|
||||
shift 2
|
||||
;;
|
||||
*)
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
export VECTOR_STORE
|
||||
echo "🧰 Using vector store: $VECTOR_STORE"
|
||||
|
||||
# Function to create compose file by merging vector store config with openmemory-mcp service
|
||||
create_compose_file() {
|
||||
local vector_store=$1
|
||||
local compose_file="compose/${vector_store}.yml"
|
||||
local volume_name="${vector_store}_data" # Vector-store-specific volume name
|
||||
|
||||
# Check if the compose file exists
|
||||
if [ ! -f "$compose_file" ]; then
|
||||
echo "❌ Compose file not found: $compose_file"
|
||||
echo "Available vector stores: $(ls compose/*.yml | sed 's/compose\///g' | sed 's/\.yml//g' | tr '\n' ' ')"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "📝 Creating docker-compose.yml using $compose_file..."
|
||||
echo "💾 Using volume: $volume_name"
|
||||
|
||||
# Start the compose file with services section
|
||||
echo "services:" > docker-compose.yml
|
||||
|
||||
# Extract services from the compose file and replace volume name
|
||||
# First get everything except the last volumes section
|
||||
tail -n +2 "$compose_file" | sed '/^volumes:/,$d' | sed "s/mem0_storage/${volume_name}/g" >> docker-compose.yml
|
||||
|
||||
# Add a newline to ensure proper YAML formatting
|
||||
echo "" >> docker-compose.yml
|
||||
|
||||
# Add the openmemory-mcp service
|
||||
cat >> docker-compose.yml <<EOF
|
||||
openmemory-mcp:
|
||||
image: mem0/openmemory-mcp:latest
|
||||
environment:
|
||||
- OPENAI_API_KEY=${OPENAI_API_KEY}
|
||||
- USER=${USER}
|
||||
EOF
|
||||
|
||||
# Add vector store specific environment variables
|
||||
case "$vector_store" in
|
||||
weaviate)
|
||||
cat >> docker-compose.yml <<EOF
|
||||
- WEAVIATE_HOST=mem0_store
|
||||
- WEAVIATE_PORT=8080
|
||||
EOF
|
||||
;;
|
||||
redis)
|
||||
cat >> docker-compose.yml <<EOF
|
||||
- REDIS_URL=redis://mem0_store:6379
|
||||
EOF
|
||||
;;
|
||||
pgvector)
|
||||
cat >> docker-compose.yml <<EOF
|
||||
- PG_HOST=mem0_store
|
||||
- PG_PORT=5432
|
||||
- PG_DB=mem0
|
||||
- PG_USER=mem0
|
||||
- PG_PASSWORD=mem0
|
||||
EOF
|
||||
;;
|
||||
qdrant)
|
||||
cat >> docker-compose.yml <<EOF
|
||||
- QDRANT_HOST=mem0_store
|
||||
- QDRANT_PORT=6333
|
||||
EOF
|
||||
;;
|
||||
chroma)
|
||||
cat >> docker-compose.yml <<EOF
|
||||
- CHROMA_HOST=mem0_store
|
||||
- CHROMA_PORT=8000
|
||||
EOF
|
||||
;;
|
||||
milvus)
|
||||
cat >> docker-compose.yml <<EOF
|
||||
- MILVUS_HOST=mem0_store
|
||||
- MILVUS_PORT=19530
|
||||
EOF
|
||||
;;
|
||||
elasticsearch)
|
||||
cat >> docker-compose.yml <<EOF
|
||||
- ELASTICSEARCH_HOST=mem0_store
|
||||
- ELASTICSEARCH_PORT=9200
|
||||
- ELASTICSEARCH_USER=elastic
|
||||
- ELASTICSEARCH_PASSWORD=changeme
|
||||
EOF
|
||||
;;
|
||||
faiss)
|
||||
cat >> docker-compose.yml <<EOF
|
||||
- FAISS_PATH=/tmp/faiss
|
||||
EOF
|
||||
;;
|
||||
*)
|
||||
echo "⚠️ Unknown vector store: $vector_store. Using default Qdrant configuration."
|
||||
cat >> docker-compose.yml <<EOF
|
||||
- QDRANT_HOST=mem0_store
|
||||
- QDRANT_PORT=6333
|
||||
EOF
|
||||
;;
|
||||
esac
|
||||
|
||||
# Add common openmemory-mcp service configuration
|
||||
if [ "$vector_store" = "faiss" ]; then
|
||||
# FAISS doesn't need a separate service, just volume mounts
|
||||
cat >> docker-compose.yml <<EOF
|
||||
ports:
|
||||
- "8765:8765"
|
||||
volumes:
|
||||
- openmemory_db:/usr/src/openmemory
|
||||
- ${volume_name}:/tmp/faiss
|
||||
|
||||
volumes:
|
||||
${volume_name}:
|
||||
openmemory_db:
|
||||
EOF
|
||||
else
|
||||
cat >> docker-compose.yml <<EOF
|
||||
depends_on:
|
||||
- mem0_store
|
||||
ports:
|
||||
- "8765:8765"
|
||||
volumes:
|
||||
- openmemory_db:/usr/src/openmemory
|
||||
|
||||
volumes:
|
||||
${volume_name}:
|
||||
openmemory_db:
|
||||
EOF
|
||||
fi
|
||||
}
|
||||
|
||||
# Create docker-compose.yml file based on selected vector store
|
||||
echo "📝 Creating docker-compose.yml..."
|
||||
create_compose_file "$VECTOR_STORE"
|
||||
|
||||
# Ensure local data directories exist for bind-mounted vector stores
|
||||
if [ "$VECTOR_STORE" = "milvus" ]; then
|
||||
echo "🗂️ Ensuring local data directories for Milvus exist..."
|
||||
mkdir -p ./data/milvus/etcd ./data/milvus/minio ./data/milvus/milvus
|
||||
fi
|
||||
|
||||
# Function to install vector store specific packages
|
||||
install_vector_store_packages() {
|
||||
local vector_store=$1
|
||||
echo "📦 Installing packages for vector store: $vector_store..."
|
||||
|
||||
case "$vector_store" in
|
||||
qdrant)
|
||||
docker exec openmemory-openmemory-mcp-1 pip install "qdrant-client>=1.9.1" || echo "⚠️ Failed to install qdrant packages"
|
||||
;;
|
||||
chroma)
|
||||
docker exec openmemory-openmemory-mcp-1 pip install "chromadb>=0.4.24" || echo "⚠️ Failed to install chroma packages"
|
||||
;;
|
||||
weaviate)
|
||||
docker exec openmemory-openmemory-mcp-1 pip install "weaviate-client>=4.4.0,<4.15.0" || echo "⚠️ Failed to install weaviate packages"
|
||||
;;
|
||||
faiss)
|
||||
docker exec openmemory-openmemory-mcp-1 pip install "faiss-cpu>=1.7.4" || echo "⚠️ Failed to install faiss packages"
|
||||
;;
|
||||
pgvector)
|
||||
docker exec openmemory-openmemory-mcp-1 pip install "vecs>=0.4.0" "psycopg>=3.2.8" || echo "⚠️ Failed to install pgvector packages"
|
||||
;;
|
||||
redis)
|
||||
docker exec openmemory-openmemory-mcp-1 pip install "redis>=5.0.0,<6.0.0" "redisvl>=0.1.0,<1.0.0" || echo "⚠️ Failed to install redis packages"
|
||||
;;
|
||||
elasticsearch)
|
||||
docker exec openmemory-openmemory-mcp-1 pip install "elasticsearch>=8.0.0,<9.0.0" || echo "⚠️ Failed to install elasticsearch packages"
|
||||
;;
|
||||
milvus)
|
||||
docker exec openmemory-openmemory-mcp-1 pip install "pymilvus>=2.4.0,<2.6.0" || echo "⚠️ Failed to install milvus packages"
|
||||
;;
|
||||
*)
|
||||
echo "⚠️ Unknown vector store: $vector_store. Installing default qdrant packages."
|
||||
docker exec openmemory-openmemory-mcp-1 pip install "qdrant-client>=1.9.1" || echo "⚠️ Failed to install qdrant packages"
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
# Start services
|
||||
echo "🚀 Starting backend services..."
|
||||
docker compose up -d
|
||||
|
||||
# Wait for container to be ready before installing packages
|
||||
echo "⏳ Waiting for container to be ready..."
|
||||
for i in {1..30}; do
|
||||
if docker exec openmemory-openmemory-mcp-1 python -c "import sys; print('ready')" >/dev/null 2>&1; then
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
# Install vector store specific packages
|
||||
install_vector_store_packages "$VECTOR_STORE"
|
||||
|
||||
# If a specific vector store is selected, seed the backend config accordingly
|
||||
if [ "$VECTOR_STORE" = "milvus" ]; then
|
||||
echo "⏳ Waiting for API to be ready at ${NEXT_PUBLIC_API_URL}..."
|
||||
for i in {1..60}; do
|
||||
if curl -fsS "${NEXT_PUBLIC_API_URL}/api/v1/config" >/dev/null 2>&1; then
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
echo "🧩 Configuring vector store (milvus) in backend..."
|
||||
curl -fsS -X PUT "${NEXT_PUBLIC_API_URL}/api/v1/config/mem0/vector_store" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d "{\"provider\":\"milvus\",\"config\":{\"collection_name\":\"openmemory\",\"embedding_model_dims\":${EMBEDDING_DIMS},\"url\":\"http://mem0_store:19530\",\"token\":\"\",\"db_name\":\"\",\"metric_type\":\"COSINE\"}}" >/dev/null || true
|
||||
elif [ "$VECTOR_STORE" = "weaviate" ]; then
|
||||
echo "⏳ Waiting for API to be ready at ${NEXT_PUBLIC_API_URL}..."
|
||||
for i in {1..60}; do
|
||||
if curl -fsS "${NEXT_PUBLIC_API_URL}/api/v1/config" >/dev/null 2>&1; then
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
echo "🧩 Configuring vector store (weaviate) in backend..."
|
||||
curl -fsS -X PUT "${NEXT_PUBLIC_API_URL}/api/v1/config/mem0/vector_store" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d "{\"provider\":\"weaviate\",\"config\":{\"collection_name\":\"openmemory\",\"embedding_model_dims\":${EMBEDDING_DIMS},\"cluster_url\":\"http://mem0_store:8080\"}}" >/dev/null || true
|
||||
elif [ "$VECTOR_STORE" = "redis" ]; then
|
||||
echo "⏳ Waiting for API to be ready at ${NEXT_PUBLIC_API_URL}..."
|
||||
for i in {1..60}; do
|
||||
if curl -fsS "${NEXT_PUBLIC_API_URL}/api/v1/config" >/dev/null 2>&1; then
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
echo "🧩 Configuring vector store (redis) in backend..."
|
||||
curl -fsS -X PUT "${NEXT_PUBLIC_API_URL}/api/v1/config/mem0/vector_store" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d "{\"provider\":\"redis\",\"config\":{\"collection_name\":\"openmemory\",\"embedding_model_dims\":${EMBEDDING_DIMS},\"redis_url\":\"redis://mem0_store:6379\"}}" >/dev/null || true
|
||||
elif [ "$VECTOR_STORE" = "pgvector" ]; then
|
||||
echo "⏳ Waiting for API to be ready at ${NEXT_PUBLIC_API_URL}..."
|
||||
for i in {1..60}; do
|
||||
if curl -fsS "${NEXT_PUBLIC_API_URL}/api/v1/config" >/dev/null 2>&1; then
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
echo "🧩 Configuring vector store (pgvector) in backend..."
|
||||
curl -fsS -X PUT "${NEXT_PUBLIC_API_URL}/api/v1/config/mem0/vector_store" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d "{\"provider\":\"pgvector\",\"config\":{\"collection_name\":\"openmemory\",\"embedding_model_dims\":${EMBEDDING_DIMS},\"dbname\":\"mem0\",\"user\":\"mem0\",\"password\":\"mem0\",\"host\":\"mem0_store\",\"port\":5432,\"diskann\":false,\"hnsw\":true}}" >/dev/null || true
|
||||
elif [ "$VECTOR_STORE" = "qdrant" ]; then
|
||||
echo "⏳ Waiting for API to be ready at ${NEXT_PUBLIC_API_URL}..."
|
||||
for i in {1..60}; do
|
||||
if curl -fsS "${NEXT_PUBLIC_API_URL}/api/v1/config" >/dev/null 2>&1; then
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
echo "🧩 Configuring vector store (qdrant) in backend..."
|
||||
curl -fsS -X PUT "${NEXT_PUBLIC_API_URL}/api/v1/config/mem0/vector_store" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d "{\"provider\":\"qdrant\",\"config\":{\"collection_name\":\"openmemory\",\"embedding_model_dims\":${EMBEDDING_DIMS},\"host\":\"mem0_store\",\"port\":6333}}" >/dev/null || true
|
||||
elif [ "$VECTOR_STORE" = "chroma" ]; then
|
||||
echo "⏳ Waiting for API to be ready at ${NEXT_PUBLIC_API_URL}..."
|
||||
for i in {1..60}; do
|
||||
if curl -fsS "${NEXT_PUBLIC_API_URL}/api/v1/config" >/dev/null 2>&1; then
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
echo "🧩 Configuring vector store (chroma) in backend..."
|
||||
curl -fsS -X PUT "${NEXT_PUBLIC_API_URL}/api/v1/config/mem0/vector_store" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d "{\"provider\":\"chroma\",\"config\":{\"collection_name\":\"openmemory\",\"host\":\"mem0_store\",\"port\":8000}}" >/dev/null || true
|
||||
elif [ "$VECTOR_STORE" = "elasticsearch" ]; then
|
||||
echo "⏳ Waiting for API to be ready at ${NEXT_PUBLIC_API_URL}..."
|
||||
for i in {1..60}; do
|
||||
if curl -fsS "${NEXT_PUBLIC_API_URL}/api/v1/config" >/dev/null 2>&1; then
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
echo "🧩 Configuring vector store (elasticsearch) in backend..."
|
||||
curl -fsS -X PUT "${NEXT_PUBLIC_API_URL}/api/v1/config/mem0/vector_store" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d "{\"provider\":\"elasticsearch\",\"config\":{\"collection_name\":\"openmemory\",\"embedding_model_dims\":${EMBEDDING_DIMS},\"host\":\"http://mem0_store\",\"port\":9200,\"user\":\"elastic\",\"password\":\"changeme\",\"verify_certs\":false,\"use_ssl\":false}}" >/dev/null || true
|
||||
elif [ "$VECTOR_STORE" = "faiss" ]; then
|
||||
echo "⏳ Waiting for API to be ready at ${NEXT_PUBLIC_API_URL}..."
|
||||
for i in {1..60}; do
|
||||
if curl -fsS "${NEXT_PUBLIC_API_URL}/api/v1/config" >/dev/null 2>&1; then
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
echo "🧩 Configuring vector store (faiss) in backend..."
|
||||
curl -fsS -X PUT "${NEXT_PUBLIC_API_URL}/api/v1/config/mem0/vector_store" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d "{\"provider\":\"faiss\",\"config\":{\"collection_name\":\"openmemory\",\"embedding_model_dims\":${EMBEDDING_DIMS},\"path\":\"/tmp/faiss\",\"distance_strategy\":\"cosine\"}}" >/dev/null || true
|
||||
fi
|
||||
|
||||
# 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
|
||||
@@ -1,23 +0,0 @@
|
||||
# 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
|
||||
@@ -1,2 +0,0 @@
|
||||
NEXT_PUBLIC_API_URL=NEXT_PUBLIC_API_URL
|
||||
NEXT_PUBLIC_USER_ID=NEXT_PUBLIC_USER_ID
|
||||
@@ -1,52 +0,0 @@
|
||||
# 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"]
|
||||
@@ -1,166 +0,0 @@
|
||||
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;
|
||||
@@ -1,115 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,219 +0,0 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,150 +0,0 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
@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;
|
||||
}
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
export default function Loading() {
|
||||
return null;
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -1,411 +0,0 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -1,145 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,154 +0,0 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,302 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
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;
|
||||
@@ -1,45 +0,0 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -1,116 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,114 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,151 +0,0 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Provider } from "react-redux";
|
||||
import { store } from "../store/store";
|
||||
|
||||
export function Providers({ children }: { children: React.ReactNode }) {
|
||||
return <Provider store={store}>{children}</Provider>;
|
||||
}
|
||||
@@ -1,165 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { SaveIcon, RotateCcw } from "lucide-react"
|
||||
import { FormView } from "@/components/form-view"
|
||||
import { JsonEditor } from "@/components/json-editor"
|
||||
import { useConfig } from "@/hooks/useConfig"
|
||||
import { useSelector } from "react-redux"
|
||||
import { RootState } from "@/store/store"
|
||||
import { useToast } from "@/components/ui/use-toast"
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog"
|
||||
|
||||
export default function SettingsPage() {
|
||||
const { toast } = useToast()
|
||||
const configState = useSelector((state: RootState) => state.config)
|
||||
const [settings, setSettings] = useState({
|
||||
openmemory: configState.openmemory || {
|
||||
custom_instructions: null
|
||||
},
|
||||
mem0: configState.mem0
|
||||
})
|
||||
const [viewMode, setViewMode] = useState<"form" | "json">("form")
|
||||
const { fetchConfig, saveConfig, resetConfig, isLoading, error } = useConfig()
|
||||
|
||||
useEffect(() => {
|
||||
// Load config from API on component mount
|
||||
const loadConfig = async () => {
|
||||
try {
|
||||
await fetchConfig()
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "Error",
|
||||
description: "Failed to load configuration",
|
||||
variant: "destructive",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
loadConfig()
|
||||
}, [])
|
||||
|
||||
// Update local state when redux state changes
|
||||
useEffect(() => {
|
||||
setSettings(prev => ({
|
||||
...prev,
|
||||
openmemory: configState.openmemory || { custom_instructions: null },
|
||||
mem0: configState.mem0
|
||||
}))
|
||||
}, [configState.openmemory, configState.mem0])
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
await saveConfig({
|
||||
openmemory: settings.openmemory,
|
||||
mem0: settings.mem0
|
||||
})
|
||||
toast({
|
||||
title: "Settings saved",
|
||||
description: "Your configuration has been updated successfully.",
|
||||
})
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "Error",
|
||||
description: "Failed to save configuration",
|
||||
variant: "destructive",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const handleReset = async () => {
|
||||
try {
|
||||
await resetConfig()
|
||||
toast({
|
||||
title: "Settings reset",
|
||||
description: "Configuration has been reset to default values.",
|
||||
})
|
||||
await fetchConfig()
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "Error",
|
||||
description: "Failed to reset configuration",
|
||||
variant: "destructive",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="text-white py-6">
|
||||
<div className="container mx-auto py-10 max-w-4xl">
|
||||
<div className="flex justify-between items-center mb-8">
|
||||
<div className="animate-fade-slide-down">
|
||||
<h1 className="text-3xl font-bold tracking-tight">Settings</h1>
|
||||
<p className="text-muted-foreground mt-1">Manage your OpenMemory and Mem0 configuration</p>
|
||||
</div>
|
||||
<div className="flex space-x-2">
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="outline" className="border-zinc-800 text-zinc-200 hover:bg-zinc-700 hover:text-zinc-50 animate-fade-slide-down" disabled={isLoading}>
|
||||
<RotateCcw className="mr-2 h-4 w-4" />
|
||||
Reset Defaults
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Reset Configuration?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will reset all settings to the system defaults. Any custom configuration will be lost.
|
||||
API keys will be set to use environment variables.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleReset} className="bg-red-600 hover:bg-red-700">
|
||||
Reset
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
<Button onClick={handleSave} className="bg-primary hover:bg-primary/90 animate-fade-slide-down" disabled={isLoading}>
|
||||
<SaveIcon className="mr-2 h-4 w-4" />
|
||||
{isLoading ? "Saving..." : "Save Configuration"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Tabs value={viewMode} onValueChange={(value) => setViewMode(value as "form" | "json")} className="w-full animate-fade-slide-down delay-1">
|
||||
<TabsList className="grid w-full grid-cols-2 mb-8">
|
||||
<TabsTrigger value="form">Form View</TabsTrigger>
|
||||
<TabsTrigger value="json">JSON Editor</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="form">
|
||||
<FormView settings={settings} onChange={setSettings} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="json">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>JSON Configuration</CardTitle>
|
||||
<CardDescription>Edit the entire configuration directly as JSON</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<JsonEditor value={settings} onChange={setSettings} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "default",
|
||||
"rsc": true,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "tailwind.config.ts",
|
||||
"css": "app/globals.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"iconLibrary": "lucide"
|
||||
}
|
||||
@@ -1,165 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { HiHome, HiMiniRectangleStack } from "react-icons/hi2";
|
||||
import { RiApps2AddFill } from "react-icons/ri";
|
||||
import { FiRefreshCcw } from "react-icons/fi";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { CreateMemoryDialog } from "@/app/memories/components/CreateMemoryDialog";
|
||||
import { useMemoriesApi } from "@/hooks/useMemoriesApi";
|
||||
import Image from "next/image";
|
||||
import { useStats } from "@/hooks/useStats";
|
||||
import { useAppsApi } from "@/hooks/useAppsApi";
|
||||
import { Settings } from "lucide-react";
|
||||
import { useConfig } from "@/hooks/useConfig";
|
||||
|
||||
export function Navbar() {
|
||||
const pathname = usePathname();
|
||||
|
||||
const memoriesApi = useMemoriesApi();
|
||||
const appsApi = useAppsApi();
|
||||
const statsApi = useStats();
|
||||
const configApi = useConfig();
|
||||
|
||||
// Define route matchers with typed parameter extraction
|
||||
const routeBasedFetchMapping: {
|
||||
match: RegExp;
|
||||
getFetchers: (params: Record<string, string>) => (() => Promise<any>)[];
|
||||
}[] = [
|
||||
{
|
||||
match: /^\/memory\/([^/]+)$/,
|
||||
getFetchers: ({ memory_id }) => [
|
||||
() => memoriesApi.fetchMemoryById(memory_id),
|
||||
() => memoriesApi.fetchAccessLogs(memory_id),
|
||||
() => memoriesApi.fetchRelatedMemories(memory_id),
|
||||
],
|
||||
},
|
||||
{
|
||||
match: /^\/apps\/([^/]+)$/,
|
||||
getFetchers: ({ app_id }) => [
|
||||
() => appsApi.fetchAppMemories(app_id),
|
||||
() => appsApi.fetchAppAccessedMemories(app_id),
|
||||
() => appsApi.fetchAppDetails(app_id),
|
||||
],
|
||||
},
|
||||
{
|
||||
match: /^\/memories$/,
|
||||
getFetchers: () => [memoriesApi.fetchMemories],
|
||||
},
|
||||
{
|
||||
match: /^\/apps$/,
|
||||
getFetchers: () => [appsApi.fetchApps],
|
||||
},
|
||||
{
|
||||
match: /^\/$/,
|
||||
getFetchers: () => [statsApi.fetchStats, memoriesApi.fetchMemories],
|
||||
},
|
||||
{
|
||||
match: /^\/settings$/,
|
||||
getFetchers: () => [configApi.fetchConfig],
|
||||
},
|
||||
];
|
||||
|
||||
const getFetchersForPath = (path: string) => {
|
||||
for (const route of routeBasedFetchMapping) {
|
||||
const match = path.match(route.match);
|
||||
if (match) {
|
||||
if (route.match.source.includes("memory")) {
|
||||
return route.getFetchers({ memory_id: match[1] });
|
||||
}
|
||||
if (route.match.source.includes("app")) {
|
||||
return route.getFetchers({ app_id: match[1] });
|
||||
}
|
||||
return route.getFetchers({});
|
||||
}
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
||||
const handleRefresh = async () => {
|
||||
const fetchers = getFetchersForPath(pathname);
|
||||
await Promise.allSettled(fetchers.map((fn) => fn()));
|
||||
};
|
||||
|
||||
const isActive = (href: string) => {
|
||||
if (href === "/") return pathname === href;
|
||||
return pathname.startsWith(href.substring(0, 5));
|
||||
};
|
||||
|
||||
const activeClass = "bg-zinc-800 text-white border-zinc-600";
|
||||
const inactiveClass = "text-zinc-300";
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-50 w-full border-b border-zinc-800 bg-zinc-950/95 backdrop-blur supports-[backdrop-filter]:bg-zinc-950/60">
|
||||
<div className="container flex h-14 items-center justify-between">
|
||||
<Link href="/" className="flex items-center gap-2">
|
||||
<Image src="/logo.svg" alt="OpenMemory" width={26} height={26} />
|
||||
<span className="text-xl font-medium">OpenMemory</span>
|
||||
</Link>
|
||||
<div className="flex items-center gap-2">
|
||||
<Link href="/">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={`flex items-center gap-2 border-none ${
|
||||
isActive("/") ? activeClass : inactiveClass
|
||||
}`}
|
||||
>
|
||||
<HiHome />
|
||||
Dashboard
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href="/memories">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={`flex items-center gap-2 border-none ${
|
||||
isActive("/memories") ? activeClass : inactiveClass
|
||||
}`}
|
||||
>
|
||||
<HiMiniRectangleStack />
|
||||
Memories
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href="/apps">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={`flex items-center gap-2 border-none ${
|
||||
isActive("/apps") ? activeClass : inactiveClass
|
||||
}`}
|
||||
>
|
||||
<RiApps2AddFill />
|
||||
Apps
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href="/settings">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={`flex items-center gap-2 border-none ${
|
||||
isActive("/settings") ? activeClass : inactiveClass
|
||||
}`}
|
||||
>
|
||||
<Settings />
|
||||
Settings
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<Button
|
||||
onClick={handleRefresh}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="border-zinc-700/50 bg-zinc-900 hover:bg-zinc-800"
|
||||
>
|
||||
<FiRefreshCcw className="transition-transform duration-300 group-hover:rotate-180" />
|
||||
Refresh
|
||||
</Button>
|
||||
<CreateMemoryDialog />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -1,197 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Copy, Check } from "lucide-react";
|
||||
import Image from "next/image";
|
||||
|
||||
const clientTabs = [
|
||||
{ key: "claude", label: "Claude", icon: "/images/claude.webp" },
|
||||
{ key: "cursor", label: "Cursor", icon: "/images/cursor.png" },
|
||||
{ key: "cline", label: "Cline", icon: "/images/cline.png" },
|
||||
{ key: "roocline", label: "Roo Cline", icon: "/images/roocline.png" },
|
||||
{ key: "windsurf", label: "Windsurf", icon: "/images/windsurf.png" },
|
||||
{ key: "witsy", label: "Witsy", icon: "/images/witsy.png" },
|
||||
{ key: "enconvo", label: "Enconvo", icon: "/images/enconvo.png" },
|
||||
{ key: "augment", label: "Augment", icon: "/images/augment.png" },
|
||||
];
|
||||
|
||||
const colorGradientMap: { [key: string]: string } = {
|
||||
claude:
|
||||
"data-[state=active]:bg-[linear-gradient(to_top,_rgba(239,108,60,0.3),_rgba(239,108,60,0))] data-[state=active]:border-[#EF6C3C]",
|
||||
cline:
|
||||
"data-[state=active]:bg-[linear-gradient(to_top,_rgba(112,128,144,0.3),_rgba(112,128,144,0))] data-[state=active]:border-[#708090]",
|
||||
cursor:
|
||||
"data-[state=active]:bg-[linear-gradient(to_top,_rgba(255,255,255,0.08),_rgba(255,255,255,0))] data-[state=active]:border-[#708090]",
|
||||
roocline:
|
||||
"data-[state=active]:bg-[linear-gradient(to_top,_rgba(45,32,92,0.8),_rgba(45,32,92,0))] data-[state=active]:border-[#7E3FF2]",
|
||||
windsurf:
|
||||
"data-[state=active]:bg-[linear-gradient(to_top,_rgba(0,176,137,0.3),_rgba(0,176,137,0))] data-[state=active]:border-[#00B089]",
|
||||
witsy:
|
||||
"data-[state=active]:bg-[linear-gradient(to_top,_rgba(33,135,255,0.3),_rgba(33,135,255,0))] data-[state=active]:border-[#2187FF]",
|
||||
enconvo:
|
||||
"data-[state=active]:bg-[linear-gradient(to_top,_rgba(126,63,242,0.3),_rgba(126,63,242,0))] data-[state=active]:border-[#7E3FF2]",
|
||||
};
|
||||
|
||||
const getColorGradient = (color: string) => {
|
||||
if (colorGradientMap[color]) {
|
||||
return colorGradientMap[color];
|
||||
}
|
||||
return "data-[state=active]:bg-[linear-gradient(to_top,_rgba(126,63,242,0.3),_rgba(126,63,242,0))] data-[state=active]:border-[#7E3FF2]";
|
||||
};
|
||||
|
||||
const allTabs = [{ key: "mcp", label: "MCP Link", icon: "🔗" }, ...clientTabs];
|
||||
|
||||
export const Install = () => {
|
||||
const [copiedTab, setCopiedTab] = useState<string | null>(null);
|
||||
const user = process.env.NEXT_PUBLIC_USER_ID || "user";
|
||||
|
||||
const URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8765";
|
||||
|
||||
const handleCopy = async (tab: string, isMcp: boolean = false) => {
|
||||
const text = isMcp
|
||||
? `${URL}/mcp/openmemory/sse/${user}`
|
||||
: `npx @openmemory/install local ${URL}/mcp/${tab}/sse/${user} --client ${tab}`;
|
||||
|
||||
try {
|
||||
// Try using the Clipboard API first
|
||||
if (navigator?.clipboard?.writeText) {
|
||||
await navigator.clipboard.writeText(text);
|
||||
} else {
|
||||
// Fallback: Create a temporary textarea element
|
||||
const textarea = document.createElement("textarea");
|
||||
textarea.value = text;
|
||||
textarea.style.position = "fixed";
|
||||
textarea.style.opacity = "0";
|
||||
document.body.appendChild(textarea);
|
||||
textarea.select();
|
||||
document.execCommand("copy");
|
||||
document.body.removeChild(textarea);
|
||||
}
|
||||
|
||||
// Update UI to show success
|
||||
setCopiedTab(tab);
|
||||
setTimeout(() => setCopiedTab(null), 1500); // Reset after 1.5s
|
||||
} catch (error) {
|
||||
console.error("Failed to copy text:", error);
|
||||
// You might want to add a toast notification here to show the error
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold mb-6">Install OpenMemory</h2>
|
||||
|
||||
<div className="hidden">
|
||||
<div className="data-[state=active]:bg-[linear-gradient(to_top,_rgba(239,108,60,0.3),_rgba(239,108,60,0))] data-[state=active]:border-[#EF6C3C]"></div>
|
||||
<div className="data-[state=active]:bg-[linear-gradient(to_top,_rgba(112,128,144,0.3),_rgba(112,128,144,0))] data-[state=active]:border-[#708090]"></div>
|
||||
<div className="data-[state=active]:bg-[linear-gradient(to_top,_rgba(45,32,92,0.3),_rgba(45,32,92,0))] data-[state=active]:border-[#2D205C]"></div>
|
||||
<div className="data-[state=active]:bg-[linear-gradient(to_top,_rgba(0,176,137,0.3),_rgba(0,176,137,0))] data-[state=active]:border-[#00B089]"></div>
|
||||
<div className="data-[state=active]:bg-[linear-gradient(to_top,_rgba(33,135,255,0.3),_rgba(33,135,255,0))] data-[state=active]:border-[#2187FF]"></div>
|
||||
<div className="data-[state=active]:bg-[linear-gradient(to_top,_rgba(126,63,242,0.3),_rgba(126,63,242,0))] data-[state=active]:border-[#7E3FF2]"></div>
|
||||
<div className="data-[state=active]:bg-[linear-gradient(to_top,_rgba(239,108,60,0.3),_rgba(239,108,60,0))] data-[state=active]:border-[#EF6C3C]"></div>
|
||||
<div className="data-[state=active]:bg-[linear-gradient(to_top,_rgba(107,33,168,0.3),_rgba(107,33,168,0))] data-[state=active]:border-primary"></div>
|
||||
<div className="data-[state=active]:bg-[linear-gradient(to_top,_rgba(255,255,255,0.08),_rgba(255,255,255,0))] data-[state=active]:border-[#708090]"></div>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="claude" className="w-full">
|
||||
<TabsList className="bg-transparent border-b border-zinc-800 rounded-none w-full justify-start gap-0 p-0 grid grid-cols-9">
|
||||
{allTabs.map(({ key, label, icon }) => (
|
||||
<TabsTrigger
|
||||
key={key}
|
||||
value={key}
|
||||
className={`flex-1 px-0 pb-2 rounded-none ${getColorGradient(
|
||||
key
|
||||
)} data-[state=active]:border-b-2 data-[state=active]:shadow-none text-zinc-400 data-[state=active]:text-white flex items-center justify-center gap-2 text-sm`}
|
||||
>
|
||||
{icon.startsWith("/") ? (
|
||||
<div>
|
||||
<div className="w-6 h-6 rounded-full bg-zinc-700 flex items-center justify-center overflow-hidden">
|
||||
<Image src={icon} alt={label} width={40} height={40} />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-6">
|
||||
<span className="relative top-1">{icon}</span>
|
||||
</div>
|
||||
)}
|
||||
<span>{label}</span>
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
|
||||
{/* MCP Tab Content */}
|
||||
<TabsContent value="mcp" className="mt-6">
|
||||
<Card className="bg-zinc-900 border-zinc-800">
|
||||
<CardHeader className="py-4">
|
||||
<CardTitle className="text-white text-xl">MCP Link</CardTitle>
|
||||
</CardHeader>
|
||||
<hr className="border-zinc-800" />
|
||||
<CardContent className="py-4">
|
||||
<div className="relative">
|
||||
<pre className="bg-zinc-800 px-4 py-3 rounded-md overflow-x-auto text-sm">
|
||||
<code className="text-gray-300">
|
||||
{URL}/mcp/openmemory/sse/{user}
|
||||
</code>
|
||||
</pre>
|
||||
<div>
|
||||
<button
|
||||
className="absolute top-0 right-0 py-3 px-4 rounded-md hover:bg-zinc-600 bg-zinc-700"
|
||||
aria-label="Copy to clipboard"
|
||||
onClick={() => handleCopy("mcp", true)}
|
||||
>
|
||||
{copiedTab === "mcp" ? (
|
||||
<Check className="h-5 w-5 text-green-400" />
|
||||
) : (
|
||||
<Copy className="h-5 w-5 text-zinc-400" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
{/* Client Tabs Content */}
|
||||
{clientTabs.map(({ key }) => (
|
||||
<TabsContent key={key} value={key} className="mt-6">
|
||||
<Card className="bg-zinc-900 border-zinc-800">
|
||||
<CardHeader className="py-4">
|
||||
<CardTitle className="text-white text-xl">
|
||||
{key.charAt(0).toUpperCase() + key.slice(1)} Installation
|
||||
Command
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<hr className="border-zinc-800" />
|
||||
<CardContent className="py-4">
|
||||
<div className="relative">
|
||||
<pre className="bg-zinc-800 px-4 py-3 rounded-md overflow-x-auto text-sm">
|
||||
<code className="text-gray-300">
|
||||
{`npx @openmemory/install local ${URL}/mcp/${key}/sse/${user} --client ${key}`}
|
||||
</code>
|
||||
</pre>
|
||||
<div>
|
||||
<button
|
||||
className="absolute top-0 right-0 py-3 px-4 rounded-md hover:bg-zinc-600 bg-zinc-700"
|
||||
aria-label="Copy to clipboard"
|
||||
onClick={() => handleCopy(key)}
|
||||
>
|
||||
{copiedTab === key ? (
|
||||
<Check className="h-5 w-5 text-green-400" />
|
||||
) : (
|
||||
<Copy className="h-5 w-5 text-zinc-400" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
))}
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Install;
|
||||
@@ -1,69 +0,0 @@
|
||||
import React, { useEffect } from "react";
|
||||
import { useSelector } from "react-redux";
|
||||
import { RootState } from "@/store/store";
|
||||
import { useStats } from "@/hooks/useStats";
|
||||
import Image from "next/image";
|
||||
import { constants } from "@/components/shared/source-app";
|
||||
const Stats = () => {
|
||||
const totalMemories = useSelector(
|
||||
(state: RootState) => state.profile.totalMemories
|
||||
);
|
||||
const totalApps = useSelector((state: RootState) => state.profile.totalApps);
|
||||
const apps = useSelector((state: RootState) => state.profile.apps).slice(
|
||||
0,
|
||||
4
|
||||
);
|
||||
const { fetchStats } = useStats();
|
||||
|
||||
useEffect(() => {
|
||||
fetchStats();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="bg-zinc-900 rounded-lg border border-zinc-800">
|
||||
<div className="bg-zinc-800 border-b border-zinc-800 rounded-t-lg p-4">
|
||||
<div className="text-white text-xl font-semibold">Memories Stats</div>
|
||||
</div>
|
||||
<div className="space-y-3 p-4">
|
||||
<div>
|
||||
<p className="text-zinc-400">Total Memories</p>
|
||||
<h3 className="text-lg font-bold text-white">
|
||||
{totalMemories} Memories
|
||||
</h3>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-zinc-400">Total Apps Connected</p>
|
||||
<div className="flex flex-col items-start gap-1 mt-2">
|
||||
<div className="flex -space-x-2">
|
||||
{apps.map((app) => (
|
||||
<div
|
||||
key={app.id}
|
||||
className={`h-8 w-8 rounded-full bg-primary flex items-center justify-center text-xs`}
|
||||
>
|
||||
<div>
|
||||
<div className="w-7 h-7 rounded-full bg-zinc-700 flex items-center justify-center overflow-hidden">
|
||||
<Image
|
||||
src={
|
||||
constants[app.name as keyof typeof constants]
|
||||
?.iconImage || ""
|
||||
}
|
||||
alt={
|
||||
constants[app.name as keyof typeof constants]?.name
|
||||
}
|
||||
width={32}
|
||||
height={32}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<h3 className="text-lg font-bold text-white">{totalApps} Apps</h3>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Stats;
|
||||
@@ -1,461 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { Eye, EyeOff, Download, Upload } from "lucide-react"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "./ui/card"
|
||||
import { Input } from "./ui/input"
|
||||
import { Label } from "./ui/label"
|
||||
import { Slider } from "./ui/slider"
|
||||
import { Switch } from "./ui/switch"
|
||||
import { Button } from "./ui/button"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select"
|
||||
import { Textarea } from "./ui/textarea"
|
||||
import { useRef, useState as useReactState } from "react"
|
||||
import { useSelector } from "react-redux"
|
||||
import { RootState } from "@/store/store"
|
||||
|
||||
interface FormViewProps {
|
||||
settings: any
|
||||
onChange: (settings: any) => void
|
||||
}
|
||||
|
||||
export function FormView({ settings, onChange }: FormViewProps) {
|
||||
const [showLlmAdvanced, setShowLlmAdvanced] = useState(false)
|
||||
const [showLlmApiKey, setShowLlmApiKey] = useState(false)
|
||||
const [showEmbedderApiKey, setShowEmbedderApiKey] = useState(false)
|
||||
const [isUploading, setIsUploading] = useReactState(false)
|
||||
const [selectedImportFileName, setSelectedImportFileName] = useReactState("")
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8765"
|
||||
const userId = useSelector((state: RootState) => state.profile.userId)
|
||||
|
||||
const handleOpenMemoryChange = (key: string, value: any) => {
|
||||
onChange({
|
||||
...settings,
|
||||
openmemory: {
|
||||
...settings.openmemory,
|
||||
[key]: value,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const handleLlmProviderChange = (value: string) => {
|
||||
onChange({
|
||||
...settings,
|
||||
mem0: {
|
||||
...settings.mem0,
|
||||
llm: {
|
||||
...settings.mem0.llm,
|
||||
provider: value,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const handleLlmConfigChange = (key: string, value: any) => {
|
||||
onChange({
|
||||
...settings,
|
||||
mem0: {
|
||||
...settings.mem0,
|
||||
llm: {
|
||||
...settings.mem0.llm,
|
||||
config: {
|
||||
...settings.mem0.llm.config,
|
||||
[key]: value,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const handleEmbedderProviderChange = (value: string) => {
|
||||
onChange({
|
||||
...settings,
|
||||
mem0: {
|
||||
...settings.mem0,
|
||||
embedder: {
|
||||
...settings.mem0.embedder,
|
||||
provider: value,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const handleEmbedderConfigChange = (key: string, value: any) => {
|
||||
onChange({
|
||||
...settings,
|
||||
mem0: {
|
||||
...settings.mem0,
|
||||
embedder: {
|
||||
...settings.mem0.embedder,
|
||||
config: {
|
||||
...settings.mem0.embedder.config,
|
||||
[key]: value,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const needsLlmApiKey = settings.mem0?.llm?.provider?.toLowerCase() !== "ollama"
|
||||
const needsEmbedderApiKey = settings.mem0?.embedder?.provider?.toLowerCase() !== "ollama"
|
||||
const isLlmOllama = settings.mem0?.llm?.provider?.toLowerCase() === "ollama"
|
||||
const isEmbedderOllama = settings.mem0?.embedder?.provider?.toLowerCase() === "ollama"
|
||||
|
||||
const LLM_PROVIDERS = {
|
||||
"OpenAI": "openai",
|
||||
"Anthropic": "anthropic",
|
||||
"Azure OpenAI": "azure_openai",
|
||||
"Ollama": "ollama",
|
||||
"Together": "together",
|
||||
"Groq": "groq",
|
||||
"Litellm": "litellm",
|
||||
"Mistral AI": "mistralai",
|
||||
"Google AI": "google_ai",
|
||||
"AWS Bedrock": "aws_bedrock",
|
||||
"Gemini": "gemini",
|
||||
"DeepSeek": "deepseek",
|
||||
"xAI": "xai",
|
||||
"LM Studio": "lmstudio",
|
||||
"LangChain": "langchain",
|
||||
}
|
||||
|
||||
const EMBEDDER_PROVIDERS = {
|
||||
"OpenAI": "openai",
|
||||
"Azure OpenAI": "azure_openai",
|
||||
"Ollama": "ollama",
|
||||
"Hugging Face": "huggingface",
|
||||
"Vertex AI": "vertexai",
|
||||
"Gemini": "gemini",
|
||||
"LM Studio": "lmstudio",
|
||||
"Together": "together",
|
||||
"LangChain": "langchain",
|
||||
"AWS Bedrock": "aws_bedrock",
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* OpenMemory Settings */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>OpenMemory Settings</CardTitle>
|
||||
<CardDescription>Configure your OpenMemory instance settings</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="custom-instructions">Custom Instructions</Label>
|
||||
<Textarea
|
||||
id="custom-instructions"
|
||||
placeholder="Enter custom instructions for memory management..."
|
||||
value={settings.openmemory?.custom_instructions || ""}
|
||||
onChange={(e) => handleOpenMemoryChange("custom_instructions", e.target.value)}
|
||||
className="min-h-[100px]"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Custom instructions that will be used to guide memory processing and fact extraction.
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* LLM Settings */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>LLM Settings</CardTitle>
|
||||
<CardDescription>Configure your Large Language Model provider and settings</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="llm-provider">LLM Provider</Label>
|
||||
<Select
|
||||
value={settings.mem0?.llm?.provider || ""}
|
||||
onValueChange={handleLlmProviderChange}
|
||||
>
|
||||
<SelectTrigger id="llm-provider">
|
||||
<SelectValue placeholder="Select a provider" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.entries(LLM_PROVIDERS).map(([provider, value]) => (
|
||||
<SelectItem key={value} value={value}>
|
||||
{provider}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="llm-model">Model</Label>
|
||||
<Input
|
||||
id="llm-model"
|
||||
placeholder="Enter model name"
|
||||
value={settings.mem0?.llm?.config?.model || ""}
|
||||
onChange={(e) => handleLlmConfigChange("model", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isLlmOllama && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="llm-ollama-url">Ollama Base URL</Label>
|
||||
<Input
|
||||
id="llm-ollama-url"
|
||||
placeholder="http://host.docker.internal:11434"
|
||||
value={settings.mem0?.llm?.config?.ollama_base_url || ""}
|
||||
onChange={(e) => handleLlmConfigChange("ollama_base_url", e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Leave empty to use default: http://host.docker.internal:11434
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{needsLlmApiKey && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="llm-api-key">API Key</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="llm-api-key"
|
||||
type={showLlmApiKey ? "text" : "password"}
|
||||
placeholder="env:API_KEY"
|
||||
value={settings.mem0?.llm?.config?.api_key || ""}
|
||||
onChange={(e) => handleLlmConfigChange("api_key", e.target.value)}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
type="button"
|
||||
className="absolute right-2 top-1/2 transform -translate-y-1/2 h-7 w-7"
|
||||
onClick={() => setShowLlmApiKey(!showLlmApiKey)}
|
||||
>
|
||||
{showLlmApiKey ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Use "env:API_KEY" to load from environment variable, or enter directly
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center space-x-2 pt-2">
|
||||
<Switch id="llm-advanced-settings" checked={showLlmAdvanced} onCheckedChange={setShowLlmAdvanced} />
|
||||
<Label htmlFor="llm-advanced-settings">Show advanced settings</Label>
|
||||
</div>
|
||||
|
||||
{showLlmAdvanced && (
|
||||
<div className="space-y-6 pt-2">
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between">
|
||||
<Label htmlFor="temperature">Temperature: {settings.mem0?.llm?.config?.temperature}</Label>
|
||||
</div>
|
||||
<Slider
|
||||
id="temperature"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.1}
|
||||
value={[settings.mem0?.llm?.config?.temperature || 0.7]}
|
||||
onValueChange={(value) => handleLlmConfigChange("temperature", value[0])}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="max-tokens">Max Tokens</Label>
|
||||
<Input
|
||||
id="max-tokens"
|
||||
type="number"
|
||||
placeholder="2000"
|
||||
value={settings.mem0?.llm?.config?.max_tokens || ""}
|
||||
onChange={(e) => handleLlmConfigChange("max_tokens", Number.parseInt(e.target.value) || "")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Embedder Settings */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Embedder Settings</CardTitle>
|
||||
<CardDescription>Configure your Embedding Model provider and settings</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="embedder-provider">Embedder Provider</Label>
|
||||
<Select
|
||||
value={settings.mem0?.embedder?.provider || ""}
|
||||
onValueChange={handleEmbedderProviderChange}
|
||||
>
|
||||
<SelectTrigger id="embedder-provider">
|
||||
<SelectValue placeholder="Select a provider" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.entries(EMBEDDER_PROVIDERS).map(([provider, value]) => (
|
||||
<SelectItem key={value} value={value}>
|
||||
{provider}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="embedder-model">Model</Label>
|
||||
<Input
|
||||
id="embedder-model"
|
||||
placeholder="Enter model name"
|
||||
value={settings.mem0?.embedder?.config?.model || ""}
|
||||
onChange={(e) => handleEmbedderConfigChange("model", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isEmbedderOllama && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="embedder-ollama-url">Ollama Base URL</Label>
|
||||
<Input
|
||||
id="embedder-ollama-url"
|
||||
placeholder="http://host.docker.internal:11434"
|
||||
value={settings.mem0?.embedder?.config?.ollama_base_url || ""}
|
||||
onChange={(e) => handleEmbedderConfigChange("ollama_base_url", e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Leave empty to use default: http://host.docker.internal:11434
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{needsEmbedderApiKey && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="embedder-api-key">API Key</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="embedder-api-key"
|
||||
type={showEmbedderApiKey ? "text" : "password"}
|
||||
placeholder="env:API_KEY"
|
||||
value={settings.mem0?.embedder?.config?.api_key || ""}
|
||||
onChange={(e) => handleEmbedderConfigChange("api_key", e.target.value)}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
type="button"
|
||||
className="absolute right-2 top-1/2 transform -translate-y-1/2 h-7 w-7"
|
||||
onClick={() => setShowEmbedderApiKey(!showEmbedderApiKey)}
|
||||
>
|
||||
{showEmbedderApiKey ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Use "env:API_KEY" to load from environment variable, or enter directly
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Backup (Export / Import) */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Backup</CardTitle>
|
||||
<CardDescription>Export or import your memories</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{/* Export Section */}
|
||||
<div className="p-4 border border-zinc-800 rounded-lg space-y-2">
|
||||
<div className="text-sm font-medium">Export</div>
|
||||
<p className="text-xs text-muted-foreground">Download a ZIP containing your memories.</p>
|
||||
<div>
|
||||
<Button
|
||||
type="button"
|
||||
className="bg-zinc-800 hover:bg-zinc-700"
|
||||
onClick={async () => {
|
||||
try {
|
||||
const res = await fetch(`${API_URL}/api/v1/backup/export`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", Accept: "application/zip" },
|
||||
body: JSON.stringify({ user_id: userId }),
|
||||
})
|
||||
if (!res.ok) throw new Error(`Export failed with status ${res.status}`)
|
||||
const blob = await res.blob()
|
||||
const url = window.URL.createObjectURL(blob)
|
||||
const a = document.createElement("a")
|
||||
a.href = url
|
||||
a.download = `memories_export.zip`
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
a.remove()
|
||||
window.URL.revokeObjectURL(url)
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
alert("Export failed. Check console for details.")
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Download className="h-4 w-4 mr-2" /> Export Memories
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Import Section */}
|
||||
<div className="p-4 border border-zinc-800 rounded-lg space-y-2">
|
||||
<div className="text-sm font-medium">Import</div>
|
||||
<p className="text-xs text-muted-foreground">Upload a ZIP exported by OpenMemory. Default settings will be used.</p>
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".zip"
|
||||
className="hidden"
|
||||
onChange={(evt) => {
|
||||
const f = evt.target.files?.[0]
|
||||
if (!f) return
|
||||
setSelectedImportFileName(f.name)
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
className="bg-zinc-800 hover:bg-zinc-700"
|
||||
onClick={() => {
|
||||
if (fileInputRef.current) fileInputRef.current.click()
|
||||
}}
|
||||
>
|
||||
<Upload className="h-4 w-4 mr-2" /> Choose ZIP
|
||||
</Button>
|
||||
<span className="text-xs text-muted-foreground truncate max-w-[220px]">
|
||||
{selectedImportFileName || "No file selected"}
|
||||
</span>
|
||||
<div className="ml-auto">
|
||||
<Button
|
||||
type="button"
|
||||
disabled={isUploading || !fileInputRef.current}
|
||||
className="bg-primary hover:bg-primary/80 disabled:opacity-50"
|
||||
onClick={async () => {
|
||||
const file = fileInputRef.current?.files?.[0]
|
||||
if (!file) return
|
||||
try {
|
||||
setIsUploading(true)
|
||||
const form = new FormData()
|
||||
form.append("file", file)
|
||||
form.append("user_id", String(userId))
|
||||
const res = await fetch(`${API_URL}/api/v1/backup/import`, { method: "POST", body: form })
|
||||
if (!res.ok) throw new Error(`Import failed with status ${res.status}`)
|
||||
await res.json()
|
||||
if (fileInputRef.current) fileInputRef.current.value = ""
|
||||
setSelectedImportFileName("")
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
alert("Import failed. Check console for details.")
|
||||
} finally {
|
||||
setIsUploading(false)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isUploading ? "Uploading..." : "Import"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import type React from "react"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { AlertCircle, CheckCircle2 } from "lucide-react"
|
||||
import { Alert, AlertDescription } from "./ui/alert"
|
||||
import { Button } from "./ui/button"
|
||||
import { Textarea } from "./ui/textarea"
|
||||
|
||||
interface JsonEditorProps {
|
||||
value: any
|
||||
onChange: (value: any) => void
|
||||
}
|
||||
|
||||
export function JsonEditor({ value, onChange }: JsonEditorProps) {
|
||||
const [jsonString, setJsonString] = useState("")
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [isValid, setIsValid] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
setJsonString(JSON.stringify(value, null, 2))
|
||||
setIsValid(true)
|
||||
setError(null)
|
||||
} catch (err) {
|
||||
setError("Invalid JSON object")
|
||||
setIsValid(false)
|
||||
}
|
||||
}, [value])
|
||||
|
||||
const handleTextChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
setJsonString(e.target.value)
|
||||
try {
|
||||
JSON.parse(e.target.value)
|
||||
setIsValid(true)
|
||||
setError(null)
|
||||
} catch (err) {
|
||||
setError("Invalid JSON syntax")
|
||||
setIsValid(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleApply = () => {
|
||||
try {
|
||||
const parsed = JSON.parse(jsonString)
|
||||
onChange(parsed)
|
||||
setIsValid(true)
|
||||
setError(null)
|
||||
} catch (err) {
|
||||
setError("Failed to apply changes: Invalid JSON")
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="relative">
|
||||
<Textarea value={jsonString} onChange={handleTextChange} className="font-mono h-[600px] resize-none" />
|
||||
<div className="absolute top-3 right-3">
|
||||
{isValid ? (
|
||||
<CheckCircle2 className="h-5 w-5 text-green-500" />
|
||||
) : (
|
||||
<AlertCircle className="h-5 w-5 text-red-500" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Button onClick={handleApply} disabled={!isValid} className="w-full">
|
||||
Apply Changes
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user