mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-05-27 19:25:15 +02:00
Merge remote-tracking branch 'upstream/dev' into feat/opentelemetry
This commit is contained in:
commit
7a0de6d377
79 changed files with 1992 additions and 2296 deletions
39
.github/workflows/backend-tests.yml
vendored
39
.github/workflows/backend-tests.yml
vendored
|
|
@ -4,6 +4,9 @@ on:
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [main, dev]
|
branches: [main, dev]
|
||||||
types: [opened, synchronize, reopened, ready_for_review]
|
types: [opened, synchronize, reopened, ready_for_review]
|
||||||
|
paths:
|
||||||
|
- 'surfsense_backend/**'
|
||||||
|
- '.github/workflows/backend-tests.yml'
|
||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
group: ${{ github.workflow }}-${{ github.ref }}
|
group: ${{ github.workflow }}-${{ github.ref }}
|
||||||
|
|
@ -21,26 +24,15 @@ jobs:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Check if backend files changed
|
|
||||||
id: backend-changes
|
|
||||||
uses: dorny/paths-filter@v3
|
|
||||||
with:
|
|
||||||
filters: |
|
|
||||||
backend:
|
|
||||||
- 'surfsense_backend/**'
|
|
||||||
|
|
||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
if: steps.backend-changes.outputs.backend == 'true'
|
uses: actions/setup-python@v6
|
||||||
uses: actions/setup-python@v5
|
|
||||||
with:
|
with:
|
||||||
python-version: '3.12'
|
python-version: '3.12'
|
||||||
|
|
||||||
- name: Install UV
|
- name: Install UV
|
||||||
if: steps.backend-changes.outputs.backend == 'true'
|
uses: astral-sh/setup-uv@v8.1.0
|
||||||
uses: astral-sh/setup-uv@v7
|
|
||||||
|
|
||||||
- name: Cache dependencies
|
- name: Cache dependencies
|
||||||
if: steps.backend-changes.outputs.backend == 'true'
|
|
||||||
uses: actions/cache@v5
|
uses: actions/cache@v5
|
||||||
with:
|
with:
|
||||||
path: |
|
path: |
|
||||||
|
|
@ -51,19 +43,16 @@ jobs:
|
||||||
python-deps-
|
python-deps-
|
||||||
|
|
||||||
- name: Cache HuggingFace models
|
- name: Cache HuggingFace models
|
||||||
if: steps.backend-changes.outputs.backend == 'true'
|
|
||||||
uses: actions/cache@v5
|
uses: actions/cache@v5
|
||||||
with:
|
with:
|
||||||
path: ~/.cache/huggingface
|
path: ~/.cache/huggingface
|
||||||
key: hf-models-${{ env.EMBEDDING_MODEL }}
|
key: hf-models-${{ env.EMBEDDING_MODEL }}
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
if: steps.backend-changes.outputs.backend == 'true'
|
|
||||||
working-directory: surfsense_backend
|
working-directory: surfsense_backend
|
||||||
run: uv sync
|
run: uv sync
|
||||||
|
|
||||||
- name: Run unit tests
|
- name: Run unit tests
|
||||||
if: steps.backend-changes.outputs.backend == 'true'
|
|
||||||
working-directory: surfsense_backend
|
working-directory: surfsense_backend
|
||||||
run: uv run pytest -m unit
|
run: uv run pytest -m unit
|
||||||
|
|
||||||
|
|
@ -93,26 +82,15 @@ jobs:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Check if backend files changed
|
|
||||||
id: backend-changes
|
|
||||||
uses: dorny/paths-filter@v3
|
|
||||||
with:
|
|
||||||
filters: |
|
|
||||||
backend:
|
|
||||||
- 'surfsense_backend/**'
|
|
||||||
|
|
||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
if: steps.backend-changes.outputs.backend == 'true'
|
uses: actions/setup-python@v6
|
||||||
uses: actions/setup-python@v5
|
|
||||||
with:
|
with:
|
||||||
python-version: '3.12'
|
python-version: '3.12'
|
||||||
|
|
||||||
- name: Install UV
|
- name: Install UV
|
||||||
if: steps.backend-changes.outputs.backend == 'true'
|
uses: astral-sh/setup-uv@v8.1.0
|
||||||
uses: astral-sh/setup-uv@v7
|
|
||||||
|
|
||||||
- name: Cache dependencies
|
- name: Cache dependencies
|
||||||
if: steps.backend-changes.outputs.backend == 'true'
|
|
||||||
uses: actions/cache@v5
|
uses: actions/cache@v5
|
||||||
with:
|
with:
|
||||||
path: |
|
path: |
|
||||||
|
|
@ -123,19 +101,16 @@ jobs:
|
||||||
python-deps-
|
python-deps-
|
||||||
|
|
||||||
- name: Cache HuggingFace models
|
- name: Cache HuggingFace models
|
||||||
if: steps.backend-changes.outputs.backend == 'true'
|
|
||||||
uses: actions/cache@v5
|
uses: actions/cache@v5
|
||||||
with:
|
with:
|
||||||
path: ~/.cache/huggingface
|
path: ~/.cache/huggingface
|
||||||
key: hf-models-${{ env.EMBEDDING_MODEL }}
|
key: hf-models-${{ env.EMBEDDING_MODEL }}
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
if: steps.backend-changes.outputs.backend == 'true'
|
|
||||||
working-directory: surfsense_backend
|
working-directory: surfsense_backend
|
||||||
run: uv sync
|
run: uv sync
|
||||||
|
|
||||||
- name: Run integration tests
|
- name: Run integration tests
|
||||||
if: steps.backend-changes.outputs.backend == 'true'
|
|
||||||
working-directory: surfsense_backend
|
working-directory: surfsense_backend
|
||||||
env:
|
env:
|
||||||
TEST_DATABASE_URL: postgresql+asyncpg://postgres:postgres@localhost:5432/surfsense_test
|
TEST_DATABASE_URL: postgresql+asyncpg://postgres:postgres@localhost:5432/surfsense_test
|
||||||
|
|
|
||||||
47
.github/workflows/code-quality.yml
vendored
47
.github/workflows/code-quality.yml
vendored
|
|
@ -11,13 +11,13 @@ concurrency:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
file-quality:
|
file-quality:
|
||||||
name: File Quality Checks
|
name: File Quality
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
if: github.event.pull_request.draft == false
|
if: github.event.pull_request.draft == false
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
|
|
@ -27,7 +27,7 @@ jobs:
|
||||||
git fetch origin ${{ github.base_ref }}:${{ github.base_ref }} 2>/dev/null || git fetch origin ${{ github.base_ref }} 2>/dev/null || true
|
git fetch origin ${{ github.base_ref }}:${{ github.base_ref }} 2>/dev/null || git fetch origin ${{ github.base_ref }} 2>/dev/null || true
|
||||||
|
|
||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
uses: actions/setup-python@v5
|
uses: actions/setup-python@v6
|
||||||
with:
|
with:
|
||||||
python-version: '3.12'
|
python-version: '3.12'
|
||||||
|
|
||||||
|
|
@ -35,7 +35,7 @@ jobs:
|
||||||
run: pip install pre-commit
|
run: pip install pre-commit
|
||||||
|
|
||||||
- name: Cache pre-commit hooks
|
- name: Cache pre-commit hooks
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@v5
|
||||||
with:
|
with:
|
||||||
path: ~/.cache/pre-commit
|
path: ~/.cache/pre-commit
|
||||||
key: pre-commit-${{ hashFiles('.pre-commit-config.yaml') }}
|
key: pre-commit-${{ hashFiles('.pre-commit-config.yaml') }}
|
||||||
|
|
@ -74,7 +74,7 @@ jobs:
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
|
|
@ -83,7 +83,7 @@ jobs:
|
||||||
git fetch origin ${{ github.base_ref }}:${{ github.base_ref }} 2>/dev/null || git fetch origin ${{ github.base_ref }} 2>/dev/null || true
|
git fetch origin ${{ github.base_ref }}:${{ github.base_ref }} 2>/dev/null || git fetch origin ${{ github.base_ref }} 2>/dev/null || true
|
||||||
|
|
||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
uses: actions/setup-python@v5
|
uses: actions/setup-python@v6
|
||||||
with:
|
with:
|
||||||
python-version: '3.12'
|
python-version: '3.12'
|
||||||
|
|
||||||
|
|
@ -91,7 +91,7 @@ jobs:
|
||||||
run: pip install pre-commit
|
run: pip install pre-commit
|
||||||
|
|
||||||
- name: Cache pre-commit hooks
|
- name: Cache pre-commit hooks
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@v5
|
||||||
with:
|
with:
|
||||||
path: ~/.cache/pre-commit
|
path: ~/.cache/pre-commit
|
||||||
key: pre-commit-security-${{ hashFiles('.pre-commit-config.yaml') }}
|
key: pre-commit-security-${{ hashFiles('.pre-commit-config.yaml') }}
|
||||||
|
|
@ -125,35 +125,36 @@ jobs:
|
||||||
exit ${exit_code:-0}
|
exit ${exit_code:-0}
|
||||||
|
|
||||||
python-backend:
|
python-backend:
|
||||||
name: Python Backend Quality
|
name: Backend Quality
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
if: github.event.pull_request.draft == false
|
if: github.event.pull_request.draft == false
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
uses: actions/setup-python@v5
|
uses: actions/setup-python@v6
|
||||||
with:
|
with:
|
||||||
python-version: '3.12'
|
python-version: '3.12'
|
||||||
|
|
||||||
- name: Install UV
|
- name: Install UV
|
||||||
uses: astral-sh/setup-uv@v3
|
uses: astral-sh/setup-uv@v8.1.0
|
||||||
|
|
||||||
- name: Check if backend files changed
|
- name: Check if backend files changed
|
||||||
id: backend-changes
|
id: backend-changes
|
||||||
uses: dorny/paths-filter@v3
|
uses: dorny/paths-filter@v4
|
||||||
with:
|
with:
|
||||||
filters: |
|
filters: |
|
||||||
backend:
|
backend:
|
||||||
- 'surfsense_backend/**'
|
- 'surfsense_backend/**'
|
||||||
|
- '.github/workflows/code-quality.yml'
|
||||||
|
|
||||||
- name: Cache dependencies
|
- name: Cache dependencies
|
||||||
if: steps.backend-changes.outputs.backend == 'true'
|
if: steps.backend-changes.outputs.backend == 'true'
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@v5
|
||||||
with:
|
with:
|
||||||
path: |
|
path: |
|
||||||
~/.cache/uv
|
~/.cache/uv
|
||||||
|
|
@ -171,7 +172,7 @@ jobs:
|
||||||
|
|
||||||
- name: Cache pre-commit hooks
|
- name: Cache pre-commit hooks
|
||||||
if: steps.backend-changes.outputs.backend == 'true'
|
if: steps.backend-changes.outputs.backend == 'true'
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@v5
|
||||||
with:
|
with:
|
||||||
path: ~/.cache/pre-commit
|
path: ~/.cache/pre-commit
|
||||||
key: pre-commit-backend-${{ hashFiles('.pre-commit-config.yaml') }}
|
key: pre-commit-backend-${{ hashFiles('.pre-commit-config.yaml') }}
|
||||||
|
|
@ -206,13 +207,13 @@ jobs:
|
||||||
exit ${exit_code:-0}
|
exit ${exit_code:-0}
|
||||||
|
|
||||||
typescript-frontend:
|
typescript-frontend:
|
||||||
name: TypeScript/JavaScript Quality
|
name: Frontend Quality
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
if: github.event.pull_request.draft == false
|
if: github.event.pull_request.draft == false
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
|
|
@ -221,24 +222,24 @@ jobs:
|
||||||
git fetch origin ${{ github.base_ref }}:${{ github.base_ref }} 2>/dev/null || git fetch origin ${{ github.base_ref }} 2>/dev/null || true
|
git fetch origin ${{ github.base_ref }}:${{ github.base_ref }} 2>/dev/null || git fetch origin ${{ github.base_ref }} 2>/dev/null || true
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: '18'
|
node-version: '20'
|
||||||
|
|
||||||
- name: Install pnpm
|
- name: Install pnpm
|
||||||
uses: pnpm/action-setup@v4
|
uses: pnpm/action-setup@v6
|
||||||
with:
|
|
||||||
version: latest
|
|
||||||
|
|
||||||
- name: Check if frontend files changed
|
- name: Check if frontend files changed
|
||||||
id: frontend-changes
|
id: frontend-changes
|
||||||
uses: dorny/paths-filter@v3
|
uses: dorny/paths-filter@v4
|
||||||
with:
|
with:
|
||||||
filters: |
|
filters: |
|
||||||
web:
|
web:
|
||||||
- 'surfsense_web/**'
|
- 'surfsense_web/**'
|
||||||
|
- '.github/workflows/code-quality.yml'
|
||||||
extension:
|
extension:
|
||||||
- 'surfsense_browser_extension/**'
|
- 'surfsense_browser_extension/**'
|
||||||
|
- '.github/workflows/code-quality.yml'
|
||||||
|
|
||||||
- name: Install dependencies for web
|
- name: Install dependencies for web
|
||||||
if: steps.frontend-changes.outputs.web == 'true'
|
if: steps.frontend-changes.outputs.web == 'true'
|
||||||
|
|
@ -254,7 +255,7 @@ jobs:
|
||||||
run: pip install pre-commit
|
run: pip install pre-commit
|
||||||
|
|
||||||
- name: Cache pre-commit hooks
|
- name: Cache pre-commit hooks
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@v5
|
||||||
with:
|
with:
|
||||||
path: ~/.cache/pre-commit
|
path: ~/.cache/pre-commit
|
||||||
key: pre-commit-frontend-${{ hashFiles('.pre-commit-config.yaml') }}
|
key: pre-commit-frontend-${{ hashFiles('.pre-commit-config.yaml') }}
|
||||||
|
|
|
||||||
|
|
@ -67,7 +67,7 @@ repos:
|
||||||
# Biome check for surfsense_web
|
# Biome check for surfsense_web
|
||||||
- id: biome-check-web
|
- id: biome-check-web
|
||||||
name: biome-check-web
|
name: biome-check-web
|
||||||
entry: bash -c 'cd surfsense_web && npx @biomejs/biome check --diagnostic-level=error .'
|
entry: bash -c 'cd surfsense_web && npx @biomejs/biome@2.4.6 check --diagnostic-level=error .'
|
||||||
language: system
|
language: system
|
||||||
files: ^surfsense_web/
|
files: ^surfsense_web/
|
||||||
pass_filenames: false
|
pass_filenames: false
|
||||||
|
|
|
||||||
|
|
@ -6,4 +6,10 @@ standing instructions?
|
||||||
If yes, call `update_memory` **alongside** your normal response — don't
|
If yes, call `update_memory` **alongside** your normal response — don't
|
||||||
defer it to a later turn. Skip ephemeral chat noise (one-off Q/A, greetings,
|
defer it to a later turn. Skip ephemeral chat noise (one-off Q/A, greetings,
|
||||||
session logistics). Stay within the budget shown in `<user_memory>`.
|
session logistics). Stay within the budget shown in `<user_memory>`.
|
||||||
|
|
||||||
|
Memory is heading-based markdown. New entries should be under `##` headings
|
||||||
|
such as `## Facts`, `## Preferences`, or `## Instructions`, with bullets like
|
||||||
|
`- YYYY-MM-DD: text`. If existing memory contains legacy
|
||||||
|
`(YYYY-MM-DD) [fact|pref|instr]` markers, preserve the information but write
|
||||||
|
new saves in the heading-based format.
|
||||||
</memory_protocol>
|
</memory_protocol>
|
||||||
|
|
|
||||||
|
|
@ -6,4 +6,12 @@ key facts?
|
||||||
If yes, call `update_memory` **alongside** your normal response — don't
|
If yes, call `update_memory` **alongside** your normal response — don't
|
||||||
defer it to a later turn. Skip ephemeral chat noise (one-off Q/A, greetings,
|
defer it to a later turn. Skip ephemeral chat noise (one-off Q/A, greetings,
|
||||||
session logistics). Stay within the budget shown in `<team_memory>`.
|
session logistics). Stay within the budget shown in `<team_memory>`.
|
||||||
|
|
||||||
|
Team memory is heading-based markdown. New entries should be under `##`
|
||||||
|
headings such as `## Product Decisions`, `## Engineering Conventions`,
|
||||||
|
`## Project Facts`, or `## Open Questions`, with bullets like
|
||||||
|
`- YYYY-MM-DD: text`. If existing memory contains legacy `(YYYY-MM-DD) [fact]`
|
||||||
|
markers, preserve the information but write new saves in the heading-based
|
||||||
|
format. Do not create personal headings such as `## Preferences` or
|
||||||
|
`## Instructions`.
|
||||||
</memory_protocol>
|
</memory_protocol>
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,9 @@
|
||||||
- Skip ephemeral chat noise (one-off Q/A, greetings, session logistics).
|
- Skip ephemeral chat noise (one-off Q/A, greetings, session logistics).
|
||||||
- Args: `updated_memory` — FULL replacement markdown (merge and curate,
|
- Args: `updated_memory` — FULL replacement markdown (merge and curate,
|
||||||
don't only append).
|
don't only append).
|
||||||
- Formatting: bullets `- (YYYY-MM-DD) [marker] text` with markers `[fact]`,
|
- Formatting: heading-based markdown with entries under `##` headings.
|
||||||
`[pref]`, `[instr]` (priority when trimming: `instr > pref > fact`).
|
Recommended headings are `## Facts`, `## Preferences`, `## Instructions`,
|
||||||
Group bullets under short `##` headings; stay under the limit shown in
|
though clearer natural headings are allowed. New bullets should look like
|
||||||
`<user_memory>`.
|
`- YYYY-MM-DD: text`; stay under the limit shown in `<user_memory>`.
|
||||||
|
- If existing memory uses legacy `(YYYY-MM-DD) [fact|pref|instr]` markers,
|
||||||
|
preserve the information but write the updated document in the new format.
|
||||||
|
|
|
||||||
|
|
@ -1,28 +1,28 @@
|
||||||
<example>
|
<example>
|
||||||
<user_name>Alex</user_name>, <user_memory> is empty.
|
<user_name>Alex</user_name>, <user_memory> is empty.
|
||||||
user: "I'm a space enthusiast, explain astrophage to me"
|
user: "I'm a space enthusiast, explain astrophage to me"
|
||||||
→ update_memory(updated_memory="## Interests & background\n- (2025-03-15) [fact] Alex is a space enthusiast\n")
|
→ update_memory(updated_memory="## Facts\n- 2025-03-15: Alex is a space enthusiast\n")
|
||||||
(Casual durable fact; use first name, neutral heading.)
|
(Casual durable fact; use first name, neutral heading.)
|
||||||
</example>
|
</example>
|
||||||
|
|
||||||
<example>
|
<example>
|
||||||
user: "Remember that I prefer concise answers over detailed explanations"
|
user: "Remember that I prefer concise answers over detailed explanations"
|
||||||
→ update_memory(updated_memory="## Interests & background\n- (2025-03-15) [fact] Alex is a space enthusiast\n\n## Response style\n- (2025-03-15) [pref] Alex prefers concise answers over detailed explanations\n")
|
→ update_memory(updated_memory="## Facts\n- 2025-03-15: Alex is a space enthusiast\n\n## Preferences\n- 2025-03-15: Alex prefers concise answers over detailed explanations\n")
|
||||||
(Durable preference; merge with existing memory.)
|
(Durable preference; merge with existing memory.)
|
||||||
</example>
|
</example>
|
||||||
|
|
||||||
<example>
|
<example>
|
||||||
user: "I actually moved to Tokyo last month"
|
user: "I actually moved to Tokyo last month"
|
||||||
→ update_memory(updated_memory="...\n\n## Personal context\n- (2025-03-15) [fact] Alex lives in Tokyo (previously London)\n...")
|
→ update_memory(updated_memory="...\n\n## Facts\n- 2025-03-15: Alex lives in Tokyo (previously London)\n...")
|
||||||
(Updated fact; date reflects when recorded.)
|
(Updated fact; date reflects when recorded.)
|
||||||
</example>
|
</example>
|
||||||
|
|
||||||
<example>
|
<example>
|
||||||
user: "I'm a freelance photographer working on a nature documentary"
|
user: "I'm a freelance photographer working on a nature documentary"
|
||||||
→ update_memory(updated_memory="...\n\n## Current focus\n- (2025-03-15) [fact] Alex is a freelance photographer\n- (2025-03-15) [fact] Alex is working on a nature documentary\n")
|
→ update_memory(updated_memory="...\n\n## Current Focus\n- 2025-03-15: Alex is a freelance photographer\n- 2025-03-15: Alex is working on a nature documentary\n")
|
||||||
</example>
|
</example>
|
||||||
|
|
||||||
<example>
|
<example>
|
||||||
user: "Always respond in bullet points"
|
user: "Always respond in bullet points"
|
||||||
→ update_memory(updated_memory="...\n\n## Response style\n- (2025-03-15) [instr] Always respond to Alex in bullet points\n")
|
→ update_memory(updated_memory="...\n\n## Instructions\n- 2025-03-15: Always respond to Alex in bullet points\n")
|
||||||
</example>
|
</example>
|
||||||
|
|
|
||||||
|
|
@ -9,8 +9,14 @@
|
||||||
- Skip ephemeral chat noise (one-off Q/A, greetings, session logistics).
|
- Skip ephemeral chat noise (one-off Q/A, greetings, session logistics).
|
||||||
- Args: `updated_memory` — FULL replacement markdown (merge and curate,
|
- Args: `updated_memory` — FULL replacement markdown (merge and curate,
|
||||||
don't only append).
|
don't only append).
|
||||||
- Formatting: bullets `- (YYYY-MM-DD) [fact] text`. Team memory uses ONLY
|
- Formatting: heading-based markdown with entries under `##` headings.
|
||||||
the `[fact]` marker (never `[pref]` or `[instr]`). Group bullets under
|
Recommended headings are `## Product Decisions`,
|
||||||
short `##` headings (2-3 words each); stay under the limit shown in
|
`## Engineering Conventions`, `## Project Facts`, and `## Open Questions`.
|
||||||
`<team_memory>`. When trimming, prioritise: decisions/conventions > key
|
New bullets should look like `- YYYY-MM-DD: text`; stay under the limit
|
||||||
facts > current priorities.
|
shown in `<team_memory>`.
|
||||||
|
- If existing memory uses legacy `(YYYY-MM-DD) [fact]` markers, preserve the
|
||||||
|
information but write the updated document in the new format.
|
||||||
|
- Do not create personal headings such as `## Preferences`,
|
||||||
|
`## Instructions`, `## Personal Notes`, or `## Personal Instructions`.
|
||||||
|
When trimming, prioritise: decisions/conventions > key facts > current
|
||||||
|
priorities.
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
<example>
|
<example>
|
||||||
user: "Let's remember that we decided to do weekly standup meetings on Mondays"
|
user: "Let's remember that we decided to do weekly standup meetings on Mondays"
|
||||||
→ update_memory(updated_memory="...\n\n## Team rituals\n- (2025-03-15) [fact] Weekly standup meetings on Mondays\n...")
|
→ update_memory(updated_memory="...\n\n## Product Decisions\n- 2025-03-15: Weekly standup meetings happen on Mondays\n...")
|
||||||
</example>
|
</example>
|
||||||
|
|
||||||
<example>
|
<example>
|
||||||
user: "Our office is in downtown Seattle, 5th floor"
|
user: "Our office is in downtown Seattle, 5th floor"
|
||||||
→ update_memory(updated_memory="...\n\n## Workspace\n- (2025-03-15) [fact] Office location: downtown Seattle, 5th floor\n...")
|
→ update_memory(updated_memory="...\n\n## Project Facts\n- 2025-03-15: Office location is downtown Seattle, 5th floor\n...")
|
||||||
</example>
|
</example>
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,10 @@ Persist durable preferences/facts/instructions with `update_memory` while avoidi
|
||||||
- Do not store transient chatter.
|
- Do not store transient chatter.
|
||||||
- Do not store secrets unless explicitly instructed.
|
- Do not store secrets unless explicitly instructed.
|
||||||
- If memory intent is unclear, return `status=blocked` with the missing intent signal.
|
- If memory intent is unclear, return `status=blocked` with the missing intent signal.
|
||||||
|
- Persisted memory is heading-based markdown. New saved bullets should look like
|
||||||
|
`- YYYY-MM-DD: text` under `##` headings. If existing memory has legacy
|
||||||
|
`(YYYY-MM-DD) [fact|pref|instr]` markers, preserve the information but write
|
||||||
|
the updated document in the heading-based format.
|
||||||
</tool_policy>
|
</tool_policy>
|
||||||
|
|
||||||
<out_of_scope>
|
<out_of_scope>
|
||||||
|
|
@ -53,4 +57,7 @@ Rules:
|
||||||
- `status=success` -> `next_step=null`, `missing_fields=null`.
|
- `status=success` -> `next_step=null`, `missing_fields=null`.
|
||||||
- `status=partial|blocked|error` -> `next_step` must be non-null.
|
- `status=partial|blocked|error` -> `next_step` must be non-null.
|
||||||
- `status=blocked` due to missing required inputs -> `missing_fields` must be non-null.
|
- `status=blocked` due to missing required inputs -> `missing_fields` must be non-null.
|
||||||
|
- `evidence.memory_category` is a semantic classification for supervisor logs
|
||||||
|
only. It is not the persisted storage format and must not force inline
|
||||||
|
`[fact|preference|instruction]` markers into saved memory.
|
||||||
</output_contract>
|
</output_contract>
|
||||||
|
|
|
||||||
|
|
@ -1,280 +1,23 @@
|
||||||
"""Overwrite one markdown memory document per user or team, with size and shrink guards."""
|
"""Memory update tools backed by the canonical memory service."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import re
|
from typing import Any
|
||||||
from typing import Any, Literal
|
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from langchain_core.messages import HumanMessage
|
|
||||||
from langchain_core.tools import tool
|
from langchain_core.tools import tool
|
||||||
from sqlalchemy import select
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.db import SearchSpace, User
|
from app.services.memory import (
|
||||||
|
MEMORY_HARD_LIMIT,
|
||||||
|
MEMORY_SOFT_LIMIT,
|
||||||
|
MemoryScope,
|
||||||
|
save_memory,
|
||||||
|
)
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
MEMORY_SOFT_LIMIT = 18_000
|
|
||||||
MEMORY_HARD_LIMIT = 25_000
|
|
||||||
|
|
||||||
_SECTION_HEADING_RE = re.compile(r"^##\s+(.+)$", re.MULTILINE)
|
|
||||||
_HEADING_NORMALIZE_RE = re.compile(r"\s+")
|
|
||||||
|
|
||||||
_MARKER_RE = re.compile(r"\[(fact|pref|instr)\]")
|
|
||||||
_BULLET_FORMAT_RE = re.compile(r"^- \(\d{4}-\d{2}-\d{2}\) \[(fact|pref|instr)\] .+$")
|
|
||||||
_PERSONAL_ONLY_MARKERS = {"pref", "instr"}
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Diff validation
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def _extract_headings(memory: str) -> set[str]:
|
|
||||||
"""Return all ``## …`` heading texts (without the ``## `` prefix)."""
|
|
||||||
return set(_SECTION_HEADING_RE.findall(memory))
|
|
||||||
|
|
||||||
|
|
||||||
def _normalize_heading(heading: str) -> str:
|
|
||||||
"""Normalize heading text for robust scope checks."""
|
|
||||||
return _HEADING_NORMALIZE_RE.sub(" ", heading.strip().lower())
|
|
||||||
|
|
||||||
|
|
||||||
def _validate_memory_scope(
|
|
||||||
content: str, scope: Literal["user", "team"]
|
|
||||||
) -> dict[str, Any] | None:
|
|
||||||
"""Reject personal-only markers ([pref], [instr]) in team memory."""
|
|
||||||
if scope != "team":
|
|
||||||
return None
|
|
||||||
|
|
||||||
markers = set(_MARKER_RE.findall(content))
|
|
||||||
leaked = sorted(markers & _PERSONAL_ONLY_MARKERS)
|
|
||||||
if leaked:
|
|
||||||
tags = ", ".join(f"[{m}]" for m in leaked)
|
|
||||||
return {
|
|
||||||
"status": "error",
|
|
||||||
"message": (
|
|
||||||
f"Team memory cannot include personal markers: {tags}. "
|
|
||||||
"Use [fact] only in team memory."
|
|
||||||
),
|
|
||||||
}
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _validate_bullet_format(content: str) -> list[str]:
|
|
||||||
"""Return warnings for bullet lines that don't match the required format.
|
|
||||||
|
|
||||||
Expected: ``- (YYYY-MM-DD) [fact|pref|instr] text``
|
|
||||||
"""
|
|
||||||
warnings: list[str] = []
|
|
||||||
for line in content.splitlines():
|
|
||||||
stripped = line.strip()
|
|
||||||
if not stripped.startswith("- "):
|
|
||||||
continue
|
|
||||||
if not _BULLET_FORMAT_RE.match(stripped):
|
|
||||||
short = stripped[:80] + ("..." if len(stripped) > 80 else "")
|
|
||||||
warnings.append(f"Malformed bullet: {short}")
|
|
||||||
return warnings
|
|
||||||
|
|
||||||
|
|
||||||
def _validate_diff(old_memory: str | None, new_memory: str) -> list[str]:
|
|
||||||
"""Return a list of warning strings about suspicious changes."""
|
|
||||||
if not old_memory:
|
|
||||||
return []
|
|
||||||
|
|
||||||
warnings: list[str] = []
|
|
||||||
old_headings = _extract_headings(old_memory)
|
|
||||||
new_headings = _extract_headings(new_memory)
|
|
||||||
dropped = old_headings - new_headings
|
|
||||||
if dropped:
|
|
||||||
names = ", ".join(sorted(dropped))
|
|
||||||
warnings.append(
|
|
||||||
f"Sections removed: {names}. "
|
|
||||||
"If unintentional, the user can restore from the settings page."
|
|
||||||
)
|
|
||||||
|
|
||||||
old_len = len(old_memory)
|
|
||||||
new_len = len(new_memory)
|
|
||||||
if old_len > 0 and new_len < old_len * 0.4:
|
|
||||||
warnings.append(
|
|
||||||
f"Memory shrank significantly ({old_len:,} -> {new_len:,} chars). "
|
|
||||||
"Possible data loss."
|
|
||||||
)
|
|
||||||
return warnings
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Size validation & soft warning
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def _validate_memory_size(content: str) -> dict[str, Any] | None:
|
|
||||||
"""Return an error/warning dict if *content* is too large, else None."""
|
|
||||||
length = len(content)
|
|
||||||
if length > MEMORY_HARD_LIMIT:
|
|
||||||
return {
|
|
||||||
"status": "error",
|
|
||||||
"message": (
|
|
||||||
f"Memory exceeds {MEMORY_HARD_LIMIT:,} character limit "
|
|
||||||
f"({length:,} chars). Consolidate by merging related items, "
|
|
||||||
"removing outdated entries, and shortening descriptions. "
|
|
||||||
"Then call update_memory again."
|
|
||||||
),
|
|
||||||
}
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _soft_warning(content: str) -> str | None:
|
|
||||||
"""Return a warning string if content exceeds the soft limit."""
|
|
||||||
length = len(content)
|
|
||||||
if length > MEMORY_SOFT_LIMIT:
|
|
||||||
return (
|
|
||||||
f"Memory is at {length:,}/{MEMORY_HARD_LIMIT:,} characters. "
|
|
||||||
"Consolidate by merging related items and removing less important "
|
|
||||||
"entries on your next update."
|
|
||||||
)
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Forced rewrite when memory exceeds the hard limit
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
_FORCED_REWRITE_PROMPT = """\
|
|
||||||
You are a memory curator. The following memory document exceeds the character \
|
|
||||||
limit and must be shortened.
|
|
||||||
|
|
||||||
RULES:
|
|
||||||
1. Rewrite the document to be under {target} characters.
|
|
||||||
2. Preserve existing ## headings. Every entry must remain under a heading. You may merge
|
|
||||||
or rename headings to consolidate, but keep names personal and descriptive.
|
|
||||||
3. Priority for keeping content: [instr] > [pref] > [fact].
|
|
||||||
4. Merge duplicate entries, remove outdated entries, shorten verbose descriptions.
|
|
||||||
5. Every bullet MUST have format: - (YYYY-MM-DD) [fact|pref|instr] text
|
|
||||||
6. Preserve the user's first name in entries — do not replace it with "the user".
|
|
||||||
7. Output ONLY the consolidated markdown — no explanations, no wrapping.
|
|
||||||
|
|
||||||
<memory_document>
|
|
||||||
{content}
|
|
||||||
</memory_document>"""
|
|
||||||
|
|
||||||
|
|
||||||
async def _forced_rewrite(content: str, llm: Any) -> str | None:
|
|
||||||
"""Use a focused LLM call to compress *content* under the hard limit.
|
|
||||||
|
|
||||||
Returns the rewritten string, or ``None`` if the call fails.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
prompt = _FORCED_REWRITE_PROMPT.format(
|
|
||||||
target=MEMORY_HARD_LIMIT, content=content
|
|
||||||
)
|
|
||||||
response = await llm.ainvoke(
|
|
||||||
[HumanMessage(content=prompt)],
|
|
||||||
config={"tags": ["surfsense:internal"]},
|
|
||||||
)
|
|
||||||
text = (
|
|
||||||
response.content
|
|
||||||
if isinstance(response.content, str)
|
|
||||||
else str(response.content)
|
|
||||||
)
|
|
||||||
return text.strip()
|
|
||||||
except Exception:
|
|
||||||
logger.exception("Forced rewrite LLM call failed")
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Shared save-and-respond logic
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
async def _save_memory(
|
|
||||||
*,
|
|
||||||
updated_memory: str,
|
|
||||||
old_memory: str | None,
|
|
||||||
llm: Any | None,
|
|
||||||
apply_fn,
|
|
||||||
commit_fn,
|
|
||||||
rollback_fn,
|
|
||||||
label: str,
|
|
||||||
scope: Literal["user", "team"],
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
"""Validate, optionally force-rewrite if over the hard limit, save, and
|
|
||||||
return a response dict.
|
|
||||||
|
|
||||||
Parameters
|
|
||||||
----------
|
|
||||||
updated_memory : str
|
|
||||||
The new document the agent submitted.
|
|
||||||
old_memory : str | None
|
|
||||||
The previously persisted document (for diff checks).
|
|
||||||
llm : Any | None
|
|
||||||
LLM instance for forced rewrite (may be ``None``).
|
|
||||||
apply_fn : callable(str) -> None
|
|
||||||
Callback that sets the new memory on the ORM object.
|
|
||||||
commit_fn : coroutine
|
|
||||||
``session.commit``.
|
|
||||||
rollback_fn : coroutine
|
|
||||||
``session.rollback``.
|
|
||||||
label : str
|
|
||||||
Human label for log messages (e.g. "user memory", "team memory").
|
|
||||||
"""
|
|
||||||
content = updated_memory
|
|
||||||
|
|
||||||
# --- forced rewrite if over the hard limit ---
|
|
||||||
if len(content) > MEMORY_HARD_LIMIT and llm is not None:
|
|
||||||
rewritten = await _forced_rewrite(content, llm)
|
|
||||||
if rewritten is not None and len(rewritten) < len(content):
|
|
||||||
content = rewritten
|
|
||||||
|
|
||||||
# --- hard-limit gate (reject if still too large after rewrite) ---
|
|
||||||
size_err = _validate_memory_size(content)
|
|
||||||
if size_err:
|
|
||||||
return size_err
|
|
||||||
|
|
||||||
scope_err = _validate_memory_scope(content, scope)
|
|
||||||
if scope_err:
|
|
||||||
return scope_err
|
|
||||||
|
|
||||||
# --- persist ---
|
|
||||||
try:
|
|
||||||
apply_fn(content)
|
|
||||||
await commit_fn()
|
|
||||||
except Exception as e:
|
|
||||||
logger.exception("Failed to update %s: %s", label, e)
|
|
||||||
await rollback_fn()
|
|
||||||
return {"status": "error", "message": f"Failed to update {label}: {e}"}
|
|
||||||
|
|
||||||
# --- build response ---
|
|
||||||
resp: dict[str, Any] = {
|
|
||||||
"status": "saved",
|
|
||||||
"message": f"{label.capitalize()} updated.",
|
|
||||||
}
|
|
||||||
|
|
||||||
if content is not updated_memory:
|
|
||||||
resp["notice"] = "Memory was automatically rewritten to fit within limits."
|
|
||||||
|
|
||||||
diff_warnings = _validate_diff(old_memory, content)
|
|
||||||
if diff_warnings:
|
|
||||||
resp["diff_warnings"] = diff_warnings
|
|
||||||
|
|
||||||
format_warnings = _validate_bullet_format(content)
|
|
||||||
if format_warnings:
|
|
||||||
resp["format_warnings"] = format_warnings
|
|
||||||
|
|
||||||
warning = _soft_warning(content)
|
|
||||||
if warning:
|
|
||||||
resp["warning"] = warning
|
|
||||||
|
|
||||||
return resp
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Tool factories
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def create_update_memory_tool(
|
def create_update_memory_tool(
|
||||||
user_id: str | UUID,
|
user_id: str | UUID,
|
||||||
|
|
@ -287,40 +30,22 @@ def create_update_memory_tool(
|
||||||
async def update_memory(updated_memory: str) -> dict[str, Any]:
|
async def update_memory(updated_memory: str) -> dict[str, Any]:
|
||||||
"""Update the user's personal memory document.
|
"""Update the user's personal memory document.
|
||||||
|
|
||||||
Your current memory is shown in <user_memory> in the system prompt.
|
The current memory is shown in <user_memory>. Pass the FULL updated
|
||||||
When the user shares important long-term information (preferences,
|
markdown document, not a diff.
|
||||||
facts, instructions, context), rewrite the memory document to include
|
|
||||||
the new information. Merge new facts with existing ones, update
|
|
||||||
contradictions, remove outdated entries, and keep it concise.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
updated_memory: The FULL updated markdown document (not a diff).
|
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
result = await db_session.execute(select(User).where(User.id == uid))
|
result = await save_memory(
|
||||||
user = result.scalars().first()
|
scope=MemoryScope.USER,
|
||||||
if not user:
|
target_id=uid,
|
||||||
return {"status": "error", "message": "User not found."}
|
content=updated_memory,
|
||||||
|
session=db_session,
|
||||||
old_memory = user.memory_md
|
|
||||||
|
|
||||||
return await _save_memory(
|
|
||||||
updated_memory=updated_memory,
|
|
||||||
old_memory=old_memory,
|
|
||||||
llm=llm,
|
llm=llm,
|
||||||
apply_fn=lambda content: setattr(user, "memory_md", content),
|
|
||||||
commit_fn=db_session.commit,
|
|
||||||
rollback_fn=db_session.rollback,
|
|
||||||
label="memory",
|
|
||||||
scope="user",
|
|
||||||
)
|
)
|
||||||
|
return result.to_dict()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception("Failed to update user memory: %s", e)
|
logger.exception("Failed to update user memory: %s", e)
|
||||||
await db_session.rollback()
|
await db_session.rollback()
|
||||||
return {
|
return {"status": "error", "message": f"Failed to update memory: {e}"}
|
||||||
"status": "error",
|
|
||||||
"message": f"Failed to update memory: {e}",
|
|
||||||
}
|
|
||||||
|
|
||||||
return update_memory
|
return update_memory
|
||||||
|
|
||||||
|
|
@ -334,36 +59,18 @@ def create_update_team_memory_tool(
|
||||||
async def update_memory(updated_memory: str) -> dict[str, Any]:
|
async def update_memory(updated_memory: str) -> dict[str, Any]:
|
||||||
"""Update the team's shared memory document for this search space.
|
"""Update the team's shared memory document for this search space.
|
||||||
|
|
||||||
Your current team memory is shown in <team_memory> in the system
|
The current team memory is shown in <team_memory>. Pass the FULL updated
|
||||||
prompt. When the team shares important long-term information
|
markdown document, not a diff.
|
||||||
(decisions, conventions, key facts, priorities), rewrite the memory
|
|
||||||
document to include the new information. Merge new facts with
|
|
||||||
existing ones, update contradictions, remove outdated entries, and
|
|
||||||
keep it concise.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
updated_memory: The FULL updated markdown document (not a diff).
|
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
result = await db_session.execute(
|
result = await save_memory(
|
||||||
select(SearchSpace).where(SearchSpace.id == search_space_id)
|
scope=MemoryScope.TEAM,
|
||||||
)
|
target_id=search_space_id,
|
||||||
space = result.scalars().first()
|
content=updated_memory,
|
||||||
if not space:
|
session=db_session,
|
||||||
return {"status": "error", "message": "Search space not found."}
|
|
||||||
|
|
||||||
old_memory = space.shared_memory_md
|
|
||||||
|
|
||||||
return await _save_memory(
|
|
||||||
updated_memory=updated_memory,
|
|
||||||
old_memory=old_memory,
|
|
||||||
llm=llm,
|
llm=llm,
|
||||||
apply_fn=lambda content: setattr(space, "shared_memory_md", content),
|
|
||||||
commit_fn=db_session.commit,
|
|
||||||
rollback_fn=db_session.rollback,
|
|
||||||
label="team memory",
|
|
||||||
scope="team",
|
|
||||||
)
|
)
|
||||||
|
return result.to_dict()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception("Failed to update team memory: %s", e)
|
logger.exception("Failed to update team memory: %s", e)
|
||||||
await db_session.rollback()
|
await db_session.rollback()
|
||||||
|
|
@ -373,3 +80,11 @@ def create_update_team_memory_tool(
|
||||||
}
|
}
|
||||||
|
|
||||||
return update_memory
|
return update_memory
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"MEMORY_HARD_LIMIT",
|
||||||
|
"MEMORY_SOFT_LIMIT",
|
||||||
|
"create_update_memory_tool",
|
||||||
|
"create_update_team_memory_tool",
|
||||||
|
]
|
||||||
|
|
|
||||||
|
|
@ -1,232 +0,0 @@
|
||||||
"""Background memory extraction for the SurfSense agent.
|
|
||||||
|
|
||||||
After each agent response, if the agent did not call ``update_memory`` during
|
|
||||||
the turn, this module can run a lightweight LLM call to decide whether the
|
|
||||||
latest message contains long-term information worth persisting.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import logging
|
|
||||||
from typing import Any
|
|
||||||
from uuid import UUID
|
|
||||||
|
|
||||||
from langchain_core.messages import HumanMessage
|
|
||||||
from sqlalchemy import select
|
|
||||||
|
|
||||||
from app.agents.new_chat.tools.update_memory import _save_memory
|
|
||||||
from app.db import SearchSpace, User, shielded_async_session
|
|
||||||
from app.utils.content_utils import extract_text_content
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
_MEMORY_EXTRACT_PROMPT = """\
|
|
||||||
You are a memory extraction assistant. Analyze the user's message and decide \
|
|
||||||
if it contains any long-term information worth persisting to memory.
|
|
||||||
|
|
||||||
Worth remembering: preferences, background/identity, goals, projects, \
|
|
||||||
instructions, tools/languages they use, decisions, expertise, workplace — \
|
|
||||||
durable facts that will matter in future conversations.
|
|
||||||
|
|
||||||
NOT worth remembering: greetings, one-off factual questions, session \
|
|
||||||
logistics, ephemeral requests, follow-up clarifications with no new personal \
|
|
||||||
info, things that only matter for the current task.
|
|
||||||
|
|
||||||
If the message contains memorizable information, output the FULL updated \
|
|
||||||
memory document with the new facts merged into the existing content. Follow \
|
|
||||||
these rules:
|
|
||||||
- Every entry MUST be under a ## heading. Preserve existing headings; create new ones
|
|
||||||
freely. Keep heading names short (2-3 words) and natural. Do NOT include the user's
|
|
||||||
name in headings.
|
|
||||||
- Keep entries as single bullet points. Be descriptive but concise — include relevant
|
|
||||||
details and context rather than just a few words.
|
|
||||||
- Every bullet MUST use format: - (YYYY-MM-DD) [fact|pref|instr] text
|
|
||||||
[fact] = durable facts, [pref] = preferences, [instr] = standing instructions.
|
|
||||||
- Use the user's first name (from <user_name>) in entry text, not "the user".
|
|
||||||
- If a new fact contradicts an existing entry, update the existing entry.
|
|
||||||
- Do not duplicate information that is already present.
|
|
||||||
|
|
||||||
If nothing is worth remembering, output exactly: NO_UPDATE
|
|
||||||
|
|
||||||
<user_name>{user_name}</user_name>
|
|
||||||
|
|
||||||
<current_memory>
|
|
||||||
{current_memory}
|
|
||||||
</current_memory>
|
|
||||||
|
|
||||||
<user_message>
|
|
||||||
{user_message}
|
|
||||||
</user_message>"""
|
|
||||||
|
|
||||||
_TEAM_MEMORY_EXTRACT_PROMPT = """\
|
|
||||||
You are a team-memory extraction assistant. Analyze the latest message and \
|
|
||||||
decide if it contains durable TEAM-level information worth persisting.
|
|
||||||
|
|
||||||
Decision policy:
|
|
||||||
- Prioritize recall for durable team context, while avoiding personal-only facts.
|
|
||||||
- Do NOT require explicit consensus language. A direct team-level statement can
|
|
||||||
be stored if it is stable and broadly useful for future team chats.
|
|
||||||
- If evidence is weak or clearly tentative, output NO_UPDATE.
|
|
||||||
|
|
||||||
Worth remembering (team-level only):
|
|
||||||
- Decisions and defaults that guide future team work
|
|
||||||
- Team conventions/standards (naming, review policy, coding norms)
|
|
||||||
- Stable org/project facts (locations, ownership, constraints)
|
|
||||||
- Long-lived architecture/process facts
|
|
||||||
- Ongoing priorities that are likely relevant beyond this turn
|
|
||||||
|
|
||||||
NOT worth remembering:
|
|
||||||
- Personal preferences or biography of one person
|
|
||||||
- Questions, brainstorming, tentative ideas, or speculation
|
|
||||||
- One-off requests, status updates, TODOs, logistics for this session
|
|
||||||
- Information scoped only to a single ephemeral task
|
|
||||||
|
|
||||||
If the message contains memorizable team information, output the FULL updated \
|
|
||||||
team memory document with new facts merged into existing content. Follow rules:
|
|
||||||
- Every entry MUST be under a ## heading. Preserve existing headings; create new ones
|
|
||||||
freely. Keep heading names short (2-3 words) and natural.
|
|
||||||
- Keep entries as single bullet points. Be descriptive but concise — include relevant
|
|
||||||
details and context rather than just a few words.
|
|
||||||
- Every bullet MUST use format: - (YYYY-MM-DD) [fact] text
|
|
||||||
Team memory uses ONLY the [fact] marker. Never use [pref] or [instr].
|
|
||||||
- If a new fact contradicts an existing entry, update the existing entry.
|
|
||||||
- Do not duplicate existing information.
|
|
||||||
- Preserve neutral team phrasing; avoid person-specific memory unless role-anchored.
|
|
||||||
|
|
||||||
If nothing is worth remembering, output exactly: NO_UPDATE
|
|
||||||
|
|
||||||
<current_team_memory>
|
|
||||||
{current_memory}
|
|
||||||
</current_team_memory>
|
|
||||||
|
|
||||||
<latest_message_author>
|
|
||||||
{author}
|
|
||||||
</latest_message_author>
|
|
||||||
|
|
||||||
<latest_message>
|
|
||||||
{user_message}
|
|
||||||
</latest_message>"""
|
|
||||||
|
|
||||||
|
|
||||||
async def extract_and_save_memory(
|
|
||||||
*,
|
|
||||||
user_message: str,
|
|
||||||
user_id: str | None,
|
|
||||||
llm: Any,
|
|
||||||
) -> None:
|
|
||||||
"""Background task: extract memorizable info and persist it.
|
|
||||||
|
|
||||||
Designed to be fire-and-forget — catches all exceptions internally.
|
|
||||||
"""
|
|
||||||
if not user_id:
|
|
||||||
return
|
|
||||||
|
|
||||||
try:
|
|
||||||
uid = UUID(user_id) if isinstance(user_id, str) else user_id
|
|
||||||
|
|
||||||
async with shielded_async_session() as session:
|
|
||||||
result = await session.execute(select(User).where(User.id == uid))
|
|
||||||
user = result.scalars().first()
|
|
||||||
if not user:
|
|
||||||
return
|
|
||||||
|
|
||||||
old_memory = user.memory_md
|
|
||||||
first_name = (
|
|
||||||
user.display_name.strip().split()[0]
|
|
||||||
if user.display_name and user.display_name.strip()
|
|
||||||
else "The user"
|
|
||||||
)
|
|
||||||
prompt = _MEMORY_EXTRACT_PROMPT.format(
|
|
||||||
current_memory=old_memory or "(empty)",
|
|
||||||
user_message=user_message,
|
|
||||||
user_name=first_name,
|
|
||||||
)
|
|
||||||
response = await llm.ainvoke(
|
|
||||||
[HumanMessage(content=prompt)],
|
|
||||||
config={"tags": ["surfsense:internal", "memory-extraction"]},
|
|
||||||
)
|
|
||||||
text = extract_text_content(response.content).strip()
|
|
||||||
|
|
||||||
if text == "NO_UPDATE" or not text:
|
|
||||||
logger.debug("Memory extraction: no update needed (user %s)", uid)
|
|
||||||
return
|
|
||||||
|
|
||||||
save_result = await _save_memory(
|
|
||||||
updated_memory=text,
|
|
||||||
old_memory=old_memory,
|
|
||||||
llm=llm,
|
|
||||||
apply_fn=lambda content: setattr(user, "memory_md", content),
|
|
||||||
commit_fn=session.commit,
|
|
||||||
rollback_fn=session.rollback,
|
|
||||||
label="memory",
|
|
||||||
scope="user",
|
|
||||||
)
|
|
||||||
logger.info(
|
|
||||||
"Background memory extraction for user %s: %s",
|
|
||||||
uid,
|
|
||||||
save_result.get("status"),
|
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
logger.exception("Background user memory extraction failed")
|
|
||||||
|
|
||||||
|
|
||||||
async def extract_and_save_team_memory(
|
|
||||||
*,
|
|
||||||
user_message: str,
|
|
||||||
search_space_id: int | None,
|
|
||||||
llm: Any,
|
|
||||||
author_display_name: str | None = None,
|
|
||||||
) -> None:
|
|
||||||
"""Background task: extract team-level memory and persist it.
|
|
||||||
|
|
||||||
Runs only for shared threads. Designed to be fire-and-forget and catches
|
|
||||||
exceptions internally.
|
|
||||||
"""
|
|
||||||
if not search_space_id:
|
|
||||||
return
|
|
||||||
|
|
||||||
try:
|
|
||||||
async with shielded_async_session() as session:
|
|
||||||
result = await session.execute(
|
|
||||||
select(SearchSpace).where(SearchSpace.id == search_space_id)
|
|
||||||
)
|
|
||||||
space = result.scalars().first()
|
|
||||||
if not space:
|
|
||||||
return
|
|
||||||
|
|
||||||
old_memory = space.shared_memory_md
|
|
||||||
prompt = _TEAM_MEMORY_EXTRACT_PROMPT.format(
|
|
||||||
current_memory=old_memory or "(empty)",
|
|
||||||
author=author_display_name or "Unknown team member",
|
|
||||||
user_message=user_message,
|
|
||||||
)
|
|
||||||
response = await llm.ainvoke(
|
|
||||||
[HumanMessage(content=prompt)],
|
|
||||||
config={"tags": ["surfsense:internal", "team-memory-extraction"]},
|
|
||||||
)
|
|
||||||
text = extract_text_content(response.content).strip()
|
|
||||||
|
|
||||||
if text == "NO_UPDATE" or not text:
|
|
||||||
logger.debug(
|
|
||||||
"Team memory extraction: no update needed (space %s)",
|
|
||||||
search_space_id,
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
save_result = await _save_memory(
|
|
||||||
updated_memory=text,
|
|
||||||
old_memory=old_memory,
|
|
||||||
llm=llm,
|
|
||||||
apply_fn=lambda content: setattr(space, "shared_memory_md", content),
|
|
||||||
commit_fn=session.commit,
|
|
||||||
rollback_fn=session.rollback,
|
|
||||||
label="team memory",
|
|
||||||
scope="team",
|
|
||||||
)
|
|
||||||
logger.info(
|
|
||||||
"Background team memory extraction for space %s: %s",
|
|
||||||
search_space_id,
|
|
||||||
save_result.get("status"),
|
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
logger.exception("Background team memory extraction failed")
|
|
||||||
|
|
@ -17,8 +17,8 @@ from langgraph.runtime import Runtime
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.agents.new_chat.tools.update_memory import MEMORY_HARD_LIMIT, MEMORY_SOFT_LIMIT
|
|
||||||
from app.db import ChatVisibility, SearchSpace, User, shielded_async_session
|
from app.db import ChatVisibility, SearchSpace, User, shielded_async_session
|
||||||
|
from app.services.memory import MEMORY_HARD_LIMIT, MEMORY_SOFT_LIMIT
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,4 +3,10 @@ IMPORTANT — After understanding each user message, ALWAYS check: does this mes
|
||||||
reveal durable facts about the user (role, interests, preferences, projects,
|
reveal durable facts about the user (role, interests, preferences, projects,
|
||||||
background, or standing instructions)? If yes, you MUST call update_memory
|
background, or standing instructions)? If yes, you MUST call update_memory
|
||||||
alongside your normal response — do not defer this to a later turn.
|
alongside your normal response — do not defer this to a later turn.
|
||||||
|
|
||||||
|
Memory is stored as a heading-based markdown document. New entries should be
|
||||||
|
under `##` headings such as `## Facts`, `## Preferences`, or `## Instructions`
|
||||||
|
with bullets like `- YYYY-MM-DD: text`. If existing memory contains legacy
|
||||||
|
`(YYYY-MM-DD) [fact|pref|instr]` markers, preserve the information but write
|
||||||
|
new saves in the heading-based format.
|
||||||
</memory_protocol>
|
</memory_protocol>
|
||||||
|
|
|
||||||
|
|
@ -3,4 +3,12 @@ IMPORTANT — After understanding each user message, ALWAYS check: does this mes
|
||||||
reveal durable facts about the team (decisions, conventions, architecture, processes,
|
reveal durable facts about the team (decisions, conventions, architecture, processes,
|
||||||
or key facts)? If yes, you MUST call update_memory alongside your normal response —
|
or key facts)? If yes, you MUST call update_memory alongside your normal response —
|
||||||
do not defer this to a later turn.
|
do not defer this to a later turn.
|
||||||
|
|
||||||
|
Team memory is stored as a heading-based markdown document. New entries should
|
||||||
|
be under `##` headings such as `## Product Decisions`,
|
||||||
|
`## Engineering Conventions`, `## Project Facts`, or `## Open Questions` with
|
||||||
|
bullets like `- YYYY-MM-DD: text`. If existing memory contains legacy
|
||||||
|
`(YYYY-MM-DD) [fact]` markers, preserve the information but write new saves in
|
||||||
|
the heading-based format. Do not create personal headings such as
|
||||||
|
`## Preferences` or `## Instructions`.
|
||||||
</memory_protocol>
|
</memory_protocol>
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,16 @@
|
||||||
|
|
||||||
- <user_name>Alex</user_name>, <user_memory> is empty. User: "I'm a space enthusiast, explain astrophage to me"
|
- <user_name>Alex</user_name>, <user_memory> is empty. User: "I'm a space enthusiast, explain astrophage to me"
|
||||||
- The user casually shared a durable fact. Use their first name in the entry, short neutral heading:
|
- The user casually shared a durable fact:
|
||||||
update_memory(updated_memory="## Interests & background\n- (2025-03-15) [fact] Alex is a space enthusiast\n")
|
update_memory(updated_memory="## Facts\n- 2025-03-15: Alex is a space enthusiast\n")
|
||||||
- User: "Remember that I prefer concise answers over detailed explanations"
|
- User: "Remember that I prefer concise answers over detailed explanations"
|
||||||
- Durable preference. Merge with existing memory, add a new heading:
|
- Durable preference. Merge with existing memory:
|
||||||
update_memory(updated_memory="## Interests & background\n- (2025-03-15) [fact] Alex is a space enthusiast\n\n## Response style\n- (2025-03-15) [pref] Alex prefers concise answers over detailed explanations\n")
|
update_memory(updated_memory="## Facts\n- 2025-03-15: Alex is a space enthusiast\n\n## Preferences\n- 2025-03-15: Alex prefers concise answers over detailed explanations\n")
|
||||||
- User: "I actually moved to Tokyo last month"
|
- User: "I actually moved to Tokyo last month"
|
||||||
- Updated fact, date prefix reflects when recorded:
|
- Updated fact, date prefix reflects when recorded:
|
||||||
update_memory(updated_memory="## Interests & background\n...\n\n## Personal context\n- (2025-03-15) [fact] Alex lives in Tokyo (previously London)\n...")
|
update_memory(updated_memory="## Facts\n- 2025-03-15: Alex lives in Tokyo (previously London)\n...")
|
||||||
- User: "I'm a freelance photographer working on a nature documentary"
|
- User: "I'm a freelance photographer working on a nature documentary"
|
||||||
- Durable background info under a fitting heading:
|
- Durable background info under a fitting heading:
|
||||||
update_memory(updated_memory="...\n\n## Current focus\n- (2025-03-15) [fact] Alex is a freelance photographer\n- (2025-03-15) [fact] Alex is working on a nature documentary\n")
|
update_memory(updated_memory="...\n\n## Current Focus\n- 2025-03-15: Alex is a freelance photographer\n- 2025-03-15: Alex is working on a nature documentary\n")
|
||||||
- User: "Always respond in bullet points"
|
- User: "Always respond in bullet points"
|
||||||
- Standing instruction:
|
- Standing instruction:
|
||||||
update_memory(updated_memory="...\n\n## Response style\n- (2025-03-15) [instr] Always respond to Alex in bullet points\n")
|
update_memory(updated_memory="...\n\n## Instructions\n- 2025-03-15: Always respond to Alex in bullet points\n")
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
|
|
||||||
- User: "Let's remember that we decided to do weekly standup meetings on Mondays"
|
- User: "Let's remember that we decided to do weekly standup meetings on Mondays"
|
||||||
- Durable team decision:
|
- Durable team decision:
|
||||||
update_memory(updated_memory="- (2025-03-15) [fact] Weekly standup meetings on Mondays\n...")
|
update_memory(updated_memory="## Product Decisions\n- 2025-03-15: Weekly standup meetings happen on Mondays\n...")
|
||||||
- User: "Our office is in downtown Seattle, 5th floor"
|
- User: "Our office is in downtown Seattle, 5th floor"
|
||||||
- Durable team fact:
|
- Durable team fact:
|
||||||
update_memory(updated_memory="- (2025-03-15) [fact] Office location: downtown Seattle, 5th floor\n...")
|
update_memory(updated_memory="## Project Facts\n- 2025-03-15: Office location is downtown Seattle, 5th floor\n...")
|
||||||
|
|
|
||||||
|
|
@ -1,31 +1,26 @@
|
||||||
|
|
||||||
- update_memory: Update your personal memory document about the user.
|
- update_memory: Update your personal memory document about the user.
|
||||||
- Your current memory is already in <user_memory> in your context. The `chars` and
|
- Your current memory is already in <user_memory> in your context. The `chars`
|
||||||
`limit` attributes show your current usage and the maximum allowed size.
|
and `limit` attributes show current usage and the maximum allowed size.
|
||||||
- This is your curated long-term memory — the distilled essence of what you know about
|
- This is curated long-term memory, not raw conversation logs.
|
||||||
the user, not raw conversation logs.
|
- Call update_memory when the user explicitly asks to remember/forget
|
||||||
- Call update_memory when:
|
something or shares durable facts, preferences, or standing instructions.
|
||||||
* The user explicitly asks to remember or forget something
|
- The user's first name is provided in <user_name>. Use it in entries instead
|
||||||
* The user shares durable facts or preferences that will matter in future conversations
|
of "the user" when helpful. Do not store the name alone as a memory entry.
|
||||||
- The user's first name is provided in <user_name>. Use it in memory entries
|
- Do not store short-lived info: one-off questions, greetings, session
|
||||||
instead of "the user" (e.g. "{name} works at..." not "The user works at...").
|
logistics, or things that only matter for the current task.
|
||||||
Do not store the name itself as a separate memory entry.
|
|
||||||
- Do not store short-lived or ephemeral info: one-off questions, greetings,
|
|
||||||
session logistics, or things that only matter for the current task.
|
|
||||||
- Args:
|
- Args:
|
||||||
- updated_memory: The FULL updated markdown document (not a diff).
|
- updated_memory: The FULL updated markdown document, not a diff. Merge new
|
||||||
Merge new facts with existing ones, update contradictions, remove outdated entries.
|
facts with existing ones, update contradictions, remove outdated entries,
|
||||||
Treat every update as a curation pass — consolidate, don't just append.
|
and consolidate instead of only appending.
|
||||||
- Every bullet MUST use this format: - (YYYY-MM-DD) [marker] text
|
- Use heading-based Markdown:
|
||||||
Markers:
|
* Every entry must be under a `##` heading.
|
||||||
[fact] — durable facts (role, background, projects, tools, expertise)
|
* Recommended headings: `## Facts`, `## Preferences`, `## Instructions`.
|
||||||
[pref] — preferences (response style, languages, formats, tools)
|
Specific natural headings are allowed when clearer.
|
||||||
[instr] — standing instructions (always/never do, response rules)
|
* New bullets should use `- YYYY-MM-DD: text`.
|
||||||
- Keep it concise and well under the character limit shown in <user_memory>.
|
* Each entry should be one concise but descriptive bullet.
|
||||||
- Every entry MUST be under a `##` heading. Keep heading names short (2-3 words) and
|
- If existing memory uses legacy `(YYYY-MM-DD) [fact|pref|instr]` markers,
|
||||||
natural. Do NOT include the user's name in headings. Organize by context — e.g.
|
preserve the information but write the updated document in the new
|
||||||
who they are, what they're focused on, how they prefer things. Create, split, or
|
heading-based format.
|
||||||
merge headings freely as the memory grows.
|
- During consolidation, prioritize durable instructions and preferences before
|
||||||
- Each entry MUST be a single bullet point. Be descriptive but concise — include relevant
|
generic facts.
|
||||||
details and context rather than just a few words.
|
|
||||||
- During consolidation, prioritize keeping: [instr] > [pref] > [fact].
|
|
||||||
|
|
|
||||||
|
|
@ -1,26 +1,28 @@
|
||||||
|
|
||||||
- update_memory: Update the team's shared memory document for this search space.
|
- update_memory: Update the team's shared memory document for this search space.
|
||||||
- Your current team memory is already in <team_memory> in your context. The `chars`
|
- Your current team memory is already in <team_memory> in your context. The
|
||||||
and `limit` attributes show current usage and the maximum allowed size.
|
`chars` and `limit` attributes show current usage and the maximum allowed size.
|
||||||
- This is the team's curated long-term memory — decisions, conventions, key facts.
|
- This is curated long-term team memory: decisions, conventions, architecture,
|
||||||
- NEVER store personal memory in team memory (e.g. personal bio, individual
|
processes, and key shared facts.
|
||||||
preferences, or user-only standing instructions).
|
- NEVER store personal memory in team memory: individual bios, personal
|
||||||
- Call update_memory when:
|
preferences, or user-only standing instructions.
|
||||||
* A team member explicitly asks to remember or forget something
|
- Call update_memory when a team member asks to remember/forget something, or
|
||||||
* The conversation surfaces durable team decisions, conventions, or facts
|
when the conversation surfaces durable team context that matters later.
|
||||||
that will matter in future conversations
|
- Do not store short-lived info: one-off questions, greetings, session
|
||||||
- Do not store short-lived or ephemeral info: one-off questions, greetings,
|
logistics, or things that only matter for the current task.
|
||||||
session logistics, or things that only matter for the current task.
|
|
||||||
- Args:
|
- Args:
|
||||||
- updated_memory: The FULL updated markdown document (not a diff).
|
- updated_memory: The FULL updated markdown document, not a diff. Merge new
|
||||||
Merge new facts with existing ones, update contradictions, remove outdated entries.
|
facts with existing ones, update contradictions, remove outdated entries,
|
||||||
Treat every update as a curation pass — consolidate, don't just append.
|
and consolidate instead of only appending.
|
||||||
- Every bullet MUST use this format: - (YYYY-MM-DD) [fact] text
|
- Use heading-based Markdown:
|
||||||
Team memory uses ONLY the [fact] marker. Never use [pref] or [instr] in team memory.
|
* Every entry must be under a `##` heading.
|
||||||
- Keep it concise and well under the character limit shown in <team_memory>.
|
* Recommended headings: `## Product Decisions`, `## Engineering Conventions`,
|
||||||
- Every entry MUST be under a `##` heading. Keep heading names short (2-3 words) and
|
`## Project Facts`, `## Open Questions`.
|
||||||
natural. Organize by context — e.g. what the team decided, current architecture,
|
* New bullets should use `- YYYY-MM-DD: text`.
|
||||||
active processes. Create, split, or merge headings freely as the memory grows.
|
* Each entry should be one concise but descriptive bullet.
|
||||||
- Each entry MUST be a single bullet point. Be descriptive but concise — include relevant
|
- If existing memory uses legacy `(YYYY-MM-DD) [fact]` markers, preserve the
|
||||||
details and context rather than just a few words.
|
information but write the updated document in the new heading-based format.
|
||||||
- During consolidation, prioritize keeping: decisions/conventions > key facts > current priorities.
|
- Do not create personal headings such as `## Preferences`, `## Instructions`,
|
||||||
|
`## Personal Notes`, or `## Personal Instructions`.
|
||||||
|
- During consolidation, prioritize decisions/conventions, then key facts, then
|
||||||
|
current priorities.
|
||||||
|
|
|
||||||
|
|
@ -1,369 +1,53 @@
|
||||||
"""Markdown-document memory tool for the SurfSense agent.
|
"""Memory update tools backed by the canonical memory service."""
|
||||||
|
|
||||||
Replaces the old row-per-fact save_memory / recall_memory tools with a single
|
|
||||||
update_memory tool that overwrites a freeform markdown TEXT column. The LLM
|
|
||||||
always sees the current memory in <user_memory> / <team_memory> tags injected
|
|
||||||
by MemoryInjectionMiddleware, so it passes the FULL updated document each time.
|
|
||||||
|
|
||||||
Overflow handling:
|
|
||||||
- Soft limit (18K chars): a warning is returned telling the agent to
|
|
||||||
consolidate on the next update.
|
|
||||||
- Hard limit (25K chars): a forced LLM-driven rewrite compresses the document.
|
|
||||||
If it still exceeds the limit after rewriting, the save is rejected.
|
|
||||||
- Diff validation: warns when entire ``##`` sections are dropped or when the
|
|
||||||
document shrinks by more than 60%.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import re
|
from typing import Any
|
||||||
from typing import Any, Literal
|
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from langchain_core.messages import HumanMessage
|
|
||||||
from langchain_core.tools import tool
|
from langchain_core.tools import tool
|
||||||
from sqlalchemy import select
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.db import SearchSpace, User, async_session_maker
|
from app.db import async_session_maker
|
||||||
from app.utils.content_utils import extract_text_content
|
from app.services.memory import MemoryScope, save_memory
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
MEMORY_SOFT_LIMIT = 18_000
|
|
||||||
MEMORY_HARD_LIMIT = 25_000
|
|
||||||
|
|
||||||
_SECTION_HEADING_RE = re.compile(r"^##\s+(.+)$", re.MULTILINE)
|
|
||||||
_HEADING_NORMALIZE_RE = re.compile(r"\s+")
|
|
||||||
|
|
||||||
_MARKER_RE = re.compile(r"\[(fact|pref|instr)\]")
|
|
||||||
_BULLET_FORMAT_RE = re.compile(r"^- \(\d{4}-\d{2}-\d{2}\) \[(fact|pref|instr)\] .+$")
|
|
||||||
_PERSONAL_ONLY_MARKERS = {"pref", "instr"}
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Diff validation
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def _extract_headings(memory: str) -> set[str]:
|
|
||||||
"""Return all ``## …`` heading texts (without the ``## `` prefix)."""
|
|
||||||
return set(_SECTION_HEADING_RE.findall(memory))
|
|
||||||
|
|
||||||
|
|
||||||
def _normalize_heading(heading: str) -> str:
|
|
||||||
"""Normalize heading text for robust scope checks."""
|
|
||||||
return _HEADING_NORMALIZE_RE.sub(" ", heading.strip().lower())
|
|
||||||
|
|
||||||
|
|
||||||
def _validate_memory_scope(
|
|
||||||
content: str, scope: Literal["user", "team"]
|
|
||||||
) -> dict[str, Any] | None:
|
|
||||||
"""Reject personal-only markers ([pref], [instr]) in team memory."""
|
|
||||||
if scope != "team":
|
|
||||||
return None
|
|
||||||
|
|
||||||
markers = set(_MARKER_RE.findall(content))
|
|
||||||
leaked = sorted(markers & _PERSONAL_ONLY_MARKERS)
|
|
||||||
if leaked:
|
|
||||||
tags = ", ".join(f"[{m}]" for m in leaked)
|
|
||||||
return {
|
|
||||||
"status": "error",
|
|
||||||
"message": (
|
|
||||||
f"Team memory cannot include personal markers: {tags}. "
|
|
||||||
"Use [fact] only in team memory."
|
|
||||||
),
|
|
||||||
}
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _validate_bullet_format(content: str) -> list[str]:
|
|
||||||
"""Return warnings for bullet lines that don't match the required format.
|
|
||||||
|
|
||||||
Expected: ``- (YYYY-MM-DD) [fact|pref|instr] text``
|
|
||||||
"""
|
|
||||||
warnings: list[str] = []
|
|
||||||
for line in content.splitlines():
|
|
||||||
stripped = line.strip()
|
|
||||||
if not stripped.startswith("- "):
|
|
||||||
continue
|
|
||||||
if not _BULLET_FORMAT_RE.match(stripped):
|
|
||||||
short = stripped[:80] + ("..." if len(stripped) > 80 else "")
|
|
||||||
warnings.append(f"Malformed bullet: {short}")
|
|
||||||
return warnings
|
|
||||||
|
|
||||||
|
|
||||||
def _validate_diff(old_memory: str | None, new_memory: str) -> list[str]:
|
|
||||||
"""Return a list of warning strings about suspicious changes."""
|
|
||||||
if not old_memory:
|
|
||||||
return []
|
|
||||||
|
|
||||||
warnings: list[str] = []
|
|
||||||
old_headings = _extract_headings(old_memory)
|
|
||||||
new_headings = _extract_headings(new_memory)
|
|
||||||
dropped = old_headings - new_headings
|
|
||||||
if dropped:
|
|
||||||
names = ", ".join(sorted(dropped))
|
|
||||||
warnings.append(
|
|
||||||
f"Sections removed: {names}. "
|
|
||||||
"If unintentional, the user can restore from the settings page."
|
|
||||||
)
|
|
||||||
|
|
||||||
old_len = len(old_memory)
|
|
||||||
new_len = len(new_memory)
|
|
||||||
if old_len > 0 and new_len < old_len * 0.4:
|
|
||||||
warnings.append(
|
|
||||||
f"Memory shrank significantly ({old_len:,} -> {new_len:,} chars). "
|
|
||||||
"Possible data loss."
|
|
||||||
)
|
|
||||||
return warnings
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Size validation & soft warning
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def _validate_memory_size(content: str) -> dict[str, Any] | None:
|
|
||||||
"""Return an error/warning dict if *content* is too large, else None."""
|
|
||||||
length = len(content)
|
|
||||||
if length > MEMORY_HARD_LIMIT:
|
|
||||||
return {
|
|
||||||
"status": "error",
|
|
||||||
"message": (
|
|
||||||
f"Memory exceeds {MEMORY_HARD_LIMIT:,} character limit "
|
|
||||||
f"({length:,} chars). Consolidate by merging related items, "
|
|
||||||
"removing outdated entries, and shortening descriptions. "
|
|
||||||
"Then call update_memory again."
|
|
||||||
),
|
|
||||||
}
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _soft_warning(content: str) -> str | None:
|
|
||||||
"""Return a warning string if content exceeds the soft limit."""
|
|
||||||
length = len(content)
|
|
||||||
if length > MEMORY_SOFT_LIMIT:
|
|
||||||
return (
|
|
||||||
f"Memory is at {length:,}/{MEMORY_HARD_LIMIT:,} characters. "
|
|
||||||
"Consolidate by merging related items and removing less important "
|
|
||||||
"entries on your next update."
|
|
||||||
)
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Forced rewrite when memory exceeds the hard limit
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
_FORCED_REWRITE_PROMPT = """\
|
|
||||||
You are a memory curator. The following memory document exceeds the character \
|
|
||||||
limit and must be shortened.
|
|
||||||
|
|
||||||
RULES:
|
|
||||||
1. Rewrite the document to be under {target} characters.
|
|
||||||
2. Preserve existing ## headings. Every entry must remain under a heading. You may merge
|
|
||||||
or rename headings to consolidate, but keep names personal and descriptive.
|
|
||||||
3. Priority for keeping content: [instr] > [pref] > [fact].
|
|
||||||
4. Merge duplicate entries, remove outdated entries, shorten verbose descriptions.
|
|
||||||
5. Every bullet MUST have format: - (YYYY-MM-DD) [fact|pref|instr] text
|
|
||||||
6. Preserve the user's first name in entries — do not replace it with "the user".
|
|
||||||
7. Output ONLY the consolidated markdown — no explanations, no wrapping.
|
|
||||||
|
|
||||||
<memory_document>
|
|
||||||
{content}
|
|
||||||
</memory_document>"""
|
|
||||||
|
|
||||||
|
|
||||||
async def _forced_rewrite(content: str, llm: Any) -> str | None:
|
|
||||||
"""Use a focused LLM call to compress *content* under the hard limit.
|
|
||||||
|
|
||||||
Returns the rewritten string, or ``None`` if the call fails.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
prompt = _FORCED_REWRITE_PROMPT.format(
|
|
||||||
target=MEMORY_HARD_LIMIT, content=content
|
|
||||||
)
|
|
||||||
response = await llm.ainvoke(
|
|
||||||
[HumanMessage(content=prompt)],
|
|
||||||
config={"tags": ["surfsense:internal"]},
|
|
||||||
)
|
|
||||||
text = extract_text_content(response.content).strip()
|
|
||||||
if not text:
|
|
||||||
logger.warning("Forced rewrite returned empty text; aborting rewrite")
|
|
||||||
return None
|
|
||||||
return text
|
|
||||||
except Exception:
|
|
||||||
logger.exception("Forced rewrite LLM call failed")
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Shared save-and-respond logic
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
async def _save_memory(
|
|
||||||
*,
|
|
||||||
updated_memory: str,
|
|
||||||
old_memory: str | None,
|
|
||||||
llm: Any | None,
|
|
||||||
apply_fn,
|
|
||||||
commit_fn,
|
|
||||||
rollback_fn,
|
|
||||||
label: str,
|
|
||||||
scope: Literal["user", "team"],
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
"""Validate, optionally force-rewrite if over the hard limit, save, and
|
|
||||||
return a response dict.
|
|
||||||
|
|
||||||
Parameters
|
|
||||||
----------
|
|
||||||
updated_memory : str
|
|
||||||
The new document the agent submitted.
|
|
||||||
old_memory : str | None
|
|
||||||
The previously persisted document (for diff checks).
|
|
||||||
llm : Any | None
|
|
||||||
LLM instance for forced rewrite (may be ``None``).
|
|
||||||
apply_fn : callable(str) -> None
|
|
||||||
Callback that sets the new memory on the ORM object.
|
|
||||||
commit_fn : coroutine
|
|
||||||
``session.commit``.
|
|
||||||
rollback_fn : coroutine
|
|
||||||
``session.rollback``.
|
|
||||||
label : str
|
|
||||||
Human label for log messages (e.g. "user memory", "team memory").
|
|
||||||
"""
|
|
||||||
if not isinstance(updated_memory, str):
|
|
||||||
logger.warning(
|
|
||||||
"Refusing non-string memory payload (type=%s)",
|
|
||||||
type(updated_memory).__name__,
|
|
||||||
)
|
|
||||||
return {
|
|
||||||
"status": "error",
|
|
||||||
"message": "Internal error: memory payload must be a string.",
|
|
||||||
}
|
|
||||||
|
|
||||||
content = updated_memory
|
|
||||||
|
|
||||||
# --- forced rewrite if over the hard limit ---
|
|
||||||
if len(content) > MEMORY_HARD_LIMIT and llm is not None:
|
|
||||||
rewritten = await _forced_rewrite(content, llm)
|
|
||||||
if rewritten is not None and len(rewritten) < len(content):
|
|
||||||
content = rewritten
|
|
||||||
|
|
||||||
# --- hard-limit gate (reject if still too large after rewrite) ---
|
|
||||||
size_err = _validate_memory_size(content)
|
|
||||||
if size_err:
|
|
||||||
return size_err
|
|
||||||
|
|
||||||
scope_err = _validate_memory_scope(content, scope)
|
|
||||||
if scope_err:
|
|
||||||
return scope_err
|
|
||||||
|
|
||||||
# --- persist ---
|
|
||||||
try:
|
|
||||||
apply_fn(content)
|
|
||||||
await commit_fn()
|
|
||||||
except Exception as e:
|
|
||||||
logger.exception("Failed to update %s: %s", label, e)
|
|
||||||
await rollback_fn()
|
|
||||||
return {"status": "error", "message": f"Failed to update {label}: {e}"}
|
|
||||||
|
|
||||||
# --- build response ---
|
|
||||||
resp: dict[str, Any] = {
|
|
||||||
"status": "saved",
|
|
||||||
"message": f"{label.capitalize()} updated.",
|
|
||||||
}
|
|
||||||
|
|
||||||
if content is not updated_memory:
|
|
||||||
resp["notice"] = "Memory was automatically rewritten to fit within limits."
|
|
||||||
|
|
||||||
diff_warnings = _validate_diff(old_memory, content)
|
|
||||||
if diff_warnings:
|
|
||||||
resp["diff_warnings"] = diff_warnings
|
|
||||||
|
|
||||||
format_warnings = _validate_bullet_format(content)
|
|
||||||
if format_warnings:
|
|
||||||
resp["format_warnings"] = format_warnings
|
|
||||||
|
|
||||||
warning = _soft_warning(content)
|
|
||||||
if warning:
|
|
||||||
resp["warning"] = warning
|
|
||||||
|
|
||||||
return resp
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Tool factories
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def create_update_memory_tool(
|
def create_update_memory_tool(
|
||||||
user_id: str | UUID,
|
user_id: str | UUID,
|
||||||
db_session: AsyncSession,
|
db_session: AsyncSession,
|
||||||
llm: Any | None = None,
|
llm: Any | None = None,
|
||||||
):
|
):
|
||||||
"""Factory function to create the user-memory update tool.
|
"""Factory for the user-memory update tool.
|
||||||
|
|
||||||
The tool acquires its own short-lived ``AsyncSession`` per call via
|
Uses a fresh short-lived session per call so compiled-agent caches never
|
||||||
:data:`async_session_maker` so the closure is safe to share across
|
retain a stale request-scoped session.
|
||||||
HTTP requests by the compiled-agent cache. Capturing a per-request
|
|
||||||
session here would surface stale/closed sessions on cache hits.
|
|
||||||
The session's bound ``commit``/``rollback`` methods are captured at
|
|
||||||
call time, after ``async with`` has bound ``db_session`` locally.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
user_id: ID of the user whose memory document is being updated.
|
|
||||||
db_session: Reserved for registry compatibility. Per-call sessions
|
|
||||||
are opened via :data:`async_session_maker` inside the tool body.
|
|
||||||
llm: Optional LLM for the forced-rewrite path.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Configured update_memory tool for the user-memory scope.
|
|
||||||
"""
|
"""
|
||||||
del db_session # per-call session — see docstring
|
del db_session
|
||||||
uid = UUID(user_id) if isinstance(user_id, str) else user_id
|
uid = UUID(user_id) if isinstance(user_id, str) else user_id
|
||||||
|
|
||||||
@tool
|
@tool
|
||||||
async def update_memory(updated_memory: str) -> dict[str, Any]:
|
async def update_memory(updated_memory: str) -> dict[str, Any]:
|
||||||
"""Update the user's personal memory document.
|
"""Update the user's personal memory document.
|
||||||
|
|
||||||
Your current memory is shown in <user_memory> in the system prompt.
|
The current memory is shown in <user_memory>. Pass the FULL updated
|
||||||
When the user shares important long-term information (preferences,
|
markdown document, not a diff.
|
||||||
facts, instructions, context), rewrite the memory document to include
|
|
||||||
the new information. Merge new facts with existing ones, update
|
|
||||||
contradictions, remove outdated entries, and keep it concise.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
updated_memory: The FULL updated markdown document (not a diff).
|
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
async with async_session_maker() as db_session:
|
async with async_session_maker() as db_session:
|
||||||
result = await db_session.execute(select(User).where(User.id == uid))
|
result = await save_memory(
|
||||||
user = result.scalars().first()
|
scope=MemoryScope.USER,
|
||||||
if not user:
|
target_id=uid,
|
||||||
return {"status": "error", "message": "User not found."}
|
content=updated_memory,
|
||||||
|
session=db_session,
|
||||||
old_memory = user.memory_md
|
|
||||||
|
|
||||||
return await _save_memory(
|
|
||||||
updated_memory=updated_memory,
|
|
||||||
old_memory=old_memory,
|
|
||||||
llm=llm,
|
llm=llm,
|
||||||
apply_fn=lambda content: setattr(user, "memory_md", content),
|
|
||||||
commit_fn=db_session.commit,
|
|
||||||
rollback_fn=db_session.rollback,
|
|
||||||
label="memory",
|
|
||||||
scope="user",
|
|
||||||
)
|
)
|
||||||
|
return result.to_dict()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception("Failed to update user memory: %s", e)
|
logger.exception("Failed to update user memory: %s", e)
|
||||||
return {
|
return {"status": "error", "message": f"Failed to update memory: {e}"}
|
||||||
"status": "error",
|
|
||||||
"message": f"Failed to update memory: {e}",
|
|
||||||
}
|
|
||||||
|
|
||||||
return update_memory
|
return update_memory
|
||||||
|
|
||||||
|
|
@ -373,64 +57,26 @@ def create_update_team_memory_tool(
|
||||||
db_session: AsyncSession,
|
db_session: AsyncSession,
|
||||||
llm: Any | None = None,
|
llm: Any | None = None,
|
||||||
):
|
):
|
||||||
"""Factory function to create the team-memory update tool.
|
"""Factory for the team-memory update tool."""
|
||||||
|
del db_session
|
||||||
The tool acquires its own short-lived ``AsyncSession`` per call via
|
|
||||||
:data:`async_session_maker` so the closure is safe to share across
|
|
||||||
HTTP requests by the compiled-agent cache. Capturing a per-request
|
|
||||||
session here would surface stale/closed sessions on cache hits.
|
|
||||||
The session's bound ``commit``/``rollback`` methods are captured at
|
|
||||||
call time, after ``async with`` has bound ``db_session`` locally.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
search_space_id: ID of the search space whose team memory is being
|
|
||||||
updated.
|
|
||||||
db_session: Reserved for registry compatibility. Per-call sessions
|
|
||||||
are opened via :data:`async_session_maker` inside the tool body.
|
|
||||||
llm: Optional LLM for the forced-rewrite path.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Configured update_memory tool for the team-memory scope.
|
|
||||||
"""
|
|
||||||
del db_session # per-call session — see docstring
|
|
||||||
|
|
||||||
@tool
|
@tool
|
||||||
async def update_memory(updated_memory: str) -> dict[str, Any]:
|
async def update_memory(updated_memory: str) -> dict[str, Any]:
|
||||||
"""Update the team's shared memory document for this search space.
|
"""Update the team's shared memory document for this search space.
|
||||||
|
|
||||||
Your current team memory is shown in <team_memory> in the system
|
The current team memory is shown in <team_memory>. Pass the FULL updated
|
||||||
prompt. When the team shares important long-term information
|
markdown document, not a diff.
|
||||||
(decisions, conventions, key facts, priorities), rewrite the memory
|
|
||||||
document to include the new information. Merge new facts with
|
|
||||||
existing ones, update contradictions, remove outdated entries, and
|
|
||||||
keep it concise.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
updated_memory: The FULL updated markdown document (not a diff).
|
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
async with async_session_maker() as db_session:
|
async with async_session_maker() as db_session:
|
||||||
result = await db_session.execute(
|
result = await save_memory(
|
||||||
select(SearchSpace).where(SearchSpace.id == search_space_id)
|
scope=MemoryScope.TEAM,
|
||||||
)
|
target_id=search_space_id,
|
||||||
space = result.scalars().first()
|
content=updated_memory,
|
||||||
if not space:
|
session=db_session,
|
||||||
return {"status": "error", "message": "Search space not found."}
|
|
||||||
|
|
||||||
old_memory = space.shared_memory_md
|
|
||||||
|
|
||||||
return await _save_memory(
|
|
||||||
updated_memory=updated_memory,
|
|
||||||
old_memory=old_memory,
|
|
||||||
llm=llm,
|
llm=llm,
|
||||||
apply_fn=lambda content: setattr(
|
|
||||||
space, "shared_memory_md", content
|
|
||||||
),
|
|
||||||
commit_fn=db_session.commit,
|
|
||||||
rollback_fn=db_session.rollback,
|
|
||||||
label="team memory",
|
|
||||||
scope="team",
|
|
||||||
)
|
)
|
||||||
|
return result.to_dict()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception("Failed to update team memory: %s", e)
|
logger.exception("Failed to update team memory: %s", e)
|
||||||
return {
|
return {
|
||||||
|
|
@ -439,3 +85,9 @@ def create_update_team_memory_tool(
|
||||||
}
|
}
|
||||||
|
|
||||||
return update_memory
|
return update_memory
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"create_update_memory_tool",
|
||||||
|
"create_update_team_memory_tool",
|
||||||
|
]
|
||||||
|
|
|
||||||
|
|
@ -54,6 +54,7 @@ from .search_spaces_routes import router as search_spaces_router
|
||||||
from .slack_add_connector_route import router as slack_add_connector_router
|
from .slack_add_connector_route import router as slack_add_connector_router
|
||||||
from .stripe_routes import router as stripe_router
|
from .stripe_routes import router as stripe_router
|
||||||
from .surfsense_docs_routes import router as surfsense_docs_router
|
from .surfsense_docs_routes import router as surfsense_docs_router
|
||||||
|
from .team_memory_routes import router as team_memory_router
|
||||||
from .teams_add_connector_route import router as teams_add_connector_router
|
from .teams_add_connector_route import router as teams_add_connector_router
|
||||||
from .video_presentations_routes import router as video_presentations_router
|
from .video_presentations_routes import router as video_presentations_router
|
||||||
from .vision_llm_routes import router as vision_llm_router
|
from .vision_llm_routes import router as vision_llm_router
|
||||||
|
|
@ -117,3 +118,4 @@ router.include_router(stripe_router) # Stripe checkout for additional page pack
|
||||||
router.include_router(youtube_router) # YouTube playlist resolution
|
router.include_router(youtube_router) # YouTube playlist resolution
|
||||||
router.include_router(prompts_router)
|
router.include_router(prompts_router)
|
||||||
router.include_router(memory_router) # User personal memory (memory.md style)
|
router.include_router(memory_router) # User personal memory (memory.md style)
|
||||||
|
router.include_router(team_memory_router) # Search-space team memory
|
||||||
|
|
|
||||||
|
|
@ -1,75 +1,40 @@
|
||||||
"""Routes for user memory management (personal memory.md)."""
|
"""Routes for user memory management."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
from langchain_core.messages import HumanMessage
|
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.agents.new_chat.llm_config import (
|
|
||||||
create_chat_litellm_from_agent_config,
|
|
||||||
load_agent_llm_config_for_search_space,
|
|
||||||
)
|
|
||||||
from app.agents.new_chat.tools.update_memory import MEMORY_HARD_LIMIT, _save_memory
|
|
||||||
from app.db import User, get_async_session
|
from app.db import User, get_async_session
|
||||||
|
from app.services.memory import (
|
||||||
|
MemoryRead,
|
||||||
|
MemoryScope,
|
||||||
|
memory_limits,
|
||||||
|
read_memory,
|
||||||
|
reset_memory,
|
||||||
|
save_memory,
|
||||||
|
)
|
||||||
from app.users import current_active_user
|
from app.users import current_active_user
|
||||||
from app.utils.content_utils import extract_text_content
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
class MemoryRead(BaseModel):
|
|
||||||
memory_md: str
|
|
||||||
|
|
||||||
|
|
||||||
class MemoryUpdate(BaseModel):
|
class MemoryUpdate(BaseModel):
|
||||||
memory_md: str
|
memory_md: str
|
||||||
|
|
||||||
|
|
||||||
class MemoryEditRequest(BaseModel):
|
|
||||||
query: str
|
|
||||||
search_space_id: int
|
|
||||||
|
|
||||||
|
|
||||||
_MEMORY_EDIT_PROMPT = """\
|
|
||||||
You are a memory editor. The user wants to modify their memory document. \
|
|
||||||
Apply the user's instruction to the existing memory document and output the \
|
|
||||||
FULL updated document.
|
|
||||||
|
|
||||||
RULES:
|
|
||||||
1. If the instruction asks to add something, add it with format: \
|
|
||||||
- (YYYY-MM-DD) [fact|pref|instr] text, under an existing or new ## heading. \
|
|
||||||
Heading names should be personal and descriptive, not generic categories.
|
|
||||||
2. If the instruction asks to remove something, remove the matching entry.
|
|
||||||
3. If the instruction asks to change something, update the matching entry.
|
|
||||||
4. Preserve existing ## headings and all other entries.
|
|
||||||
5. Every bullet must include a marker: [fact], [pref], or [instr].
|
|
||||||
6. Use the user's first name (from <user_name>) in entries instead of "the user".
|
|
||||||
7. Output ONLY the updated markdown — no explanations, no wrapping.
|
|
||||||
|
|
||||||
<user_name>{user_name}</user_name>
|
|
||||||
|
|
||||||
<current_memory>
|
|
||||||
{current_memory}
|
|
||||||
</current_memory>
|
|
||||||
|
|
||||||
<user_instruction>
|
|
||||||
{instruction}
|
|
||||||
</user_instruction>"""
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/users/me/memory", response_model=MemoryRead)
|
@router.get("/users/me/memory", response_model=MemoryRead)
|
||||||
async def get_user_memory(
|
async def get_user_memory(
|
||||||
user: User = Depends(current_active_user),
|
user: User = Depends(current_active_user),
|
||||||
session: AsyncSession = Depends(get_async_session),
|
session: AsyncSession = Depends(get_async_session),
|
||||||
):
|
):
|
||||||
await session.refresh(user, ["memory_md"])
|
memory_md = await read_memory(
|
||||||
return MemoryRead(memory_md=user.memory_md or "")
|
scope=MemoryScope.USER,
|
||||||
|
target_id=user.id,
|
||||||
|
session=session,
|
||||||
|
)
|
||||||
|
return MemoryRead(memory_md=memory_md, limits=memory_limits())
|
||||||
|
|
||||||
|
|
||||||
@router.put("/users/me/memory", response_model=MemoryRead)
|
@router.put("/users/me/memory", response_model=MemoryRead)
|
||||||
|
|
@ -78,73 +43,27 @@ async def update_user_memory(
|
||||||
user: User = Depends(current_active_user),
|
user: User = Depends(current_active_user),
|
||||||
session: AsyncSession = Depends(get_async_session),
|
session: AsyncSession = Depends(get_async_session),
|
||||||
):
|
):
|
||||||
if len(body.memory_md) > MEMORY_HARD_LIMIT:
|
result = await save_memory(
|
||||||
raise HTTPException(
|
scope=MemoryScope.USER,
|
||||||
status_code=400,
|
target_id=user.id,
|
||||||
detail=f"Memory exceeds {MEMORY_HARD_LIMIT:,} character limit ({len(body.memory_md):,} chars).",
|
content=body.memory_md,
|
||||||
|
session=session,
|
||||||
)
|
)
|
||||||
user.memory_md = body.memory_md
|
if result.status == "error":
|
||||||
session.add(user)
|
raise HTTPException(status_code=400, detail=result.message)
|
||||||
await session.commit()
|
return MemoryRead(memory_md=result.memory_md, limits=memory_limits())
|
||||||
await session.refresh(user, ["memory_md"])
|
|
||||||
return MemoryRead(memory_md=user.memory_md or "")
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/users/me/memory/edit", response_model=MemoryRead)
|
@router.post("/users/me/memory/reset", response_model=MemoryRead)
|
||||||
async def edit_user_memory(
|
async def reset_user_memory(
|
||||||
body: MemoryEditRequest,
|
|
||||||
user: User = Depends(current_active_user),
|
user: User = Depends(current_active_user),
|
||||||
session: AsyncSession = Depends(get_async_session),
|
session: AsyncSession = Depends(get_async_session),
|
||||||
):
|
):
|
||||||
"""Apply a natural language edit to the user's personal memory via LLM."""
|
result = await reset_memory(
|
||||||
agent_config = await load_agent_llm_config_for_search_space(
|
scope=MemoryScope.USER,
|
||||||
session, body.search_space_id
|
target_id=user.id,
|
||||||
|
session=session,
|
||||||
)
|
)
|
||||||
if not agent_config:
|
if result.status == "error":
|
||||||
raise HTTPException(status_code=500, detail="No LLM configuration available.")
|
raise HTTPException(status_code=400, detail=result.message)
|
||||||
llm = create_chat_litellm_from_agent_config(agent_config)
|
return MemoryRead(memory_md=result.memory_md, limits=memory_limits())
|
||||||
if not llm:
|
|
||||||
raise HTTPException(status_code=500, detail="Failed to create LLM instance.")
|
|
||||||
|
|
||||||
await session.refresh(user, ["memory_md", "display_name"])
|
|
||||||
current_memory = user.memory_md or ""
|
|
||||||
first_name = (
|
|
||||||
user.display_name.strip().split()[0]
|
|
||||||
if user.display_name and user.display_name.strip()
|
|
||||||
else "The user"
|
|
||||||
)
|
|
||||||
|
|
||||||
prompt = _MEMORY_EDIT_PROMPT.format(
|
|
||||||
current_memory=current_memory or "(empty)",
|
|
||||||
instruction=body.query,
|
|
||||||
user_name=first_name,
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
response = await llm.ainvoke(
|
|
||||||
[HumanMessage(content=prompt)],
|
|
||||||
config={"tags": ["surfsense:internal", "memory-edit"]},
|
|
||||||
)
|
|
||||||
updated = extract_text_content(response.content).strip()
|
|
||||||
except Exception as e:
|
|
||||||
logger.exception("Memory edit LLM call failed: %s", e)
|
|
||||||
raise HTTPException(status_code=500, detail="Memory edit failed.") from e
|
|
||||||
|
|
||||||
if not updated:
|
|
||||||
raise HTTPException(status_code=400, detail="LLM returned empty result.")
|
|
||||||
|
|
||||||
result = await _save_memory(
|
|
||||||
updated_memory=updated,
|
|
||||||
old_memory=current_memory,
|
|
||||||
llm=llm,
|
|
||||||
apply_fn=lambda content: setattr(user, "memory_md", content),
|
|
||||||
commit_fn=session.commit,
|
|
||||||
rollback_fn=session.rollback,
|
|
||||||
label="memory",
|
|
||||||
scope="user",
|
|
||||||
)
|
|
||||||
|
|
||||||
if result.get("status") == "error":
|
|
||||||
raise HTTPException(status_code=400, detail=result["message"])
|
|
||||||
|
|
||||||
await session.refresh(user, ["memory_md"])
|
|
||||||
return MemoryRead(memory_md=user.memory_md or "")
|
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,10 @@
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
from langchain_core.messages import HumanMessage
|
|
||||||
from pydantic import BaseModel as PydanticBaseModel
|
|
||||||
from sqlalchemy import func, update
|
from sqlalchemy import func, update
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy.future import select
|
from sqlalchemy.future import select
|
||||||
|
|
||||||
from app.agents.new_chat.llm_config import (
|
|
||||||
create_chat_litellm_from_agent_config,
|
|
||||||
load_agent_llm_config_for_search_space,
|
|
||||||
)
|
|
||||||
from app.agents.new_chat.tools.update_memory import MEMORY_HARD_LIMIT, _save_memory
|
|
||||||
from app.config import config
|
from app.config import config
|
||||||
from app.db import (
|
from app.db import (
|
||||||
ImageGenerationConfig,
|
ImageGenerationConfig,
|
||||||
|
|
@ -35,7 +28,6 @@ from app.schemas import (
|
||||||
SearchSpaceWithStats,
|
SearchSpaceWithStats,
|
||||||
)
|
)
|
||||||
from app.users import current_active_user
|
from app.users import current_active_user
|
||||||
from app.utils.content_utils import extract_text_content
|
|
||||||
from app.utils.rbac import check_permission, check_search_space_access
|
from app.utils.rbac import check_permission, check_search_space_access
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
@ -43,34 +35,6 @@ logger = logging.getLogger(__name__)
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
class _TeamMemoryEditRequest(PydanticBaseModel):
|
|
||||||
query: str
|
|
||||||
|
|
||||||
|
|
||||||
_TEAM_MEMORY_EDIT_PROMPT = """\
|
|
||||||
You are a memory editor for a team workspace. The user wants to modify the \
|
|
||||||
team's shared memory document. Apply the user's instruction to the existing \
|
|
||||||
memory document and output the FULL updated document.
|
|
||||||
|
|
||||||
RULES:
|
|
||||||
1. If the instruction asks to add something, add it with format: \
|
|
||||||
- (YYYY-MM-DD) [fact] text, under an existing or new ## heading. \
|
|
||||||
Heading names should be descriptive, not generic categories.
|
|
||||||
2. If the instruction asks to remove something, remove the matching entry.
|
|
||||||
3. If the instruction asks to change something, update the matching entry.
|
|
||||||
4. Preserve existing ## headings and all other entries.
|
|
||||||
5. NEVER use [pref] or [instr] markers. Team memory uses [fact] only.
|
|
||||||
6. Output ONLY the updated markdown — no explanations, no wrapping.
|
|
||||||
|
|
||||||
<current_memory>
|
|
||||||
{current_memory}
|
|
||||||
</current_memory>
|
|
||||||
|
|
||||||
<user_instruction>
|
|
||||||
{instruction}
|
|
||||||
</user_instruction>"""
|
|
||||||
|
|
||||||
|
|
||||||
async def create_default_roles_and_membership(
|
async def create_default_roles_and_membership(
|
||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
search_space_id: int,
|
search_space_id: int,
|
||||||
|
|
@ -294,15 +258,6 @@ async def update_search_space(
|
||||||
|
|
||||||
update_data = search_space_update.model_dump(exclude_unset=True)
|
update_data = search_space_update.model_dump(exclude_unset=True)
|
||||||
|
|
||||||
if (
|
|
||||||
"shared_memory_md" in update_data
|
|
||||||
and len(update_data["shared_memory_md"] or "") > MEMORY_HARD_LIMIT
|
|
||||||
):
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=400,
|
|
||||||
detail=f"Team memory exceeds {MEMORY_HARD_LIMIT:,} character limit.",
|
|
||||||
)
|
|
||||||
|
|
||||||
for key, value in update_data.items():
|
for key, value in update_data.items():
|
||||||
setattr(db_search_space, key, value)
|
setattr(db_search_space, key, value)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
@ -317,72 +272,6 @@ async def update_search_space(
|
||||||
) from e
|
) from e
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
|
||||||
"/searchspaces/{search_space_id}/memory/edit",
|
|
||||||
response_model=SearchSpaceRead,
|
|
||||||
)
|
|
||||||
async def edit_team_memory(
|
|
||||||
search_space_id: int,
|
|
||||||
body: _TeamMemoryEditRequest,
|
|
||||||
session: AsyncSession = Depends(get_async_session),
|
|
||||||
user: User = Depends(current_active_user),
|
|
||||||
):
|
|
||||||
"""Apply a natural language edit to the team memory via LLM."""
|
|
||||||
await check_search_space_access(session, user, search_space_id)
|
|
||||||
|
|
||||||
agent_config = await load_agent_llm_config_for_search_space(
|
|
||||||
session, search_space_id
|
|
||||||
)
|
|
||||||
if not agent_config:
|
|
||||||
raise HTTPException(status_code=500, detail="No LLM configuration available.")
|
|
||||||
llm = create_chat_litellm_from_agent_config(agent_config)
|
|
||||||
if not llm:
|
|
||||||
raise HTTPException(status_code=500, detail="Failed to create LLM instance.")
|
|
||||||
|
|
||||||
result = await session.execute(
|
|
||||||
select(SearchSpace).filter(SearchSpace.id == search_space_id)
|
|
||||||
)
|
|
||||||
db_search_space = result.scalars().first()
|
|
||||||
if not db_search_space:
|
|
||||||
raise HTTPException(status_code=404, detail="Search space not found")
|
|
||||||
|
|
||||||
current_memory = db_search_space.shared_memory_md or ""
|
|
||||||
|
|
||||||
prompt = _TEAM_MEMORY_EDIT_PROMPT.format(
|
|
||||||
current_memory=current_memory or "(empty)",
|
|
||||||
instruction=body.query,
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
response = await llm.ainvoke(
|
|
||||||
[HumanMessage(content=prompt)],
|
|
||||||
config={"tags": ["surfsense:internal", "memory-edit"]},
|
|
||||||
)
|
|
||||||
updated = extract_text_content(response.content).strip()
|
|
||||||
except Exception as e:
|
|
||||||
logger.exception("Team memory edit LLM call failed: %s", e)
|
|
||||||
raise HTTPException(status_code=500, detail="Team memory edit failed.") from e
|
|
||||||
|
|
||||||
if not updated:
|
|
||||||
raise HTTPException(status_code=400, detail="LLM returned empty result.")
|
|
||||||
|
|
||||||
save_result = await _save_memory(
|
|
||||||
updated_memory=updated,
|
|
||||||
old_memory=current_memory,
|
|
||||||
llm=llm,
|
|
||||||
apply_fn=lambda content: setattr(db_search_space, "shared_memory_md", content),
|
|
||||||
commit_fn=session.commit,
|
|
||||||
rollback_fn=session.rollback,
|
|
||||||
label="team memory",
|
|
||||||
scope="team",
|
|
||||||
)
|
|
||||||
|
|
||||||
if save_result.get("status") == "error":
|
|
||||||
raise HTTPException(status_code=400, detail=save_result["message"])
|
|
||||||
|
|
||||||
await session.refresh(db_search_space)
|
|
||||||
return db_search_space
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/searchspaces/{search_space_id}/ai-sort")
|
@router.post("/searchspaces/{search_space_id}/ai-sort")
|
||||||
async def trigger_ai_sort(
|
async def trigger_ai_sort(
|
||||||
search_space_id: int,
|
search_space_id: int,
|
||||||
|
|
|
||||||
76
surfsense_backend/app/routes/team_memory_routes.py
Normal file
76
surfsense_backend/app/routes/team_memory_routes.py
Normal file
|
|
@ -0,0 +1,76 @@
|
||||||
|
"""Routes for search-space team memory."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.db import User, get_async_session
|
||||||
|
from app.services.memory import (
|
||||||
|
MemoryRead,
|
||||||
|
MemoryScope,
|
||||||
|
memory_limits,
|
||||||
|
read_memory,
|
||||||
|
reset_memory,
|
||||||
|
save_memory,
|
||||||
|
)
|
||||||
|
from app.users import current_active_user
|
||||||
|
from app.utils.rbac import check_search_space_access
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
class TeamMemoryUpdate(BaseModel):
|
||||||
|
memory_md: str
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/searchspaces/{search_space_id}/memory", response_model=MemoryRead)
|
||||||
|
async def get_team_memory(
|
||||||
|
search_space_id: int,
|
||||||
|
session: AsyncSession = Depends(get_async_session),
|
||||||
|
user: User = Depends(current_active_user),
|
||||||
|
):
|
||||||
|
await check_search_space_access(session, user, search_space_id)
|
||||||
|
memory_md = await read_memory(
|
||||||
|
scope=MemoryScope.TEAM,
|
||||||
|
target_id=search_space_id,
|
||||||
|
session=session,
|
||||||
|
)
|
||||||
|
return MemoryRead(memory_md=memory_md, limits=memory_limits())
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/searchspaces/{search_space_id}/memory", response_model=MemoryRead)
|
||||||
|
async def update_team_memory(
|
||||||
|
search_space_id: int,
|
||||||
|
body: TeamMemoryUpdate,
|
||||||
|
session: AsyncSession = Depends(get_async_session),
|
||||||
|
user: User = Depends(current_active_user),
|
||||||
|
):
|
||||||
|
await check_search_space_access(session, user, search_space_id)
|
||||||
|
result = await save_memory(
|
||||||
|
scope=MemoryScope.TEAM,
|
||||||
|
target_id=search_space_id,
|
||||||
|
content=body.memory_md,
|
||||||
|
session=session,
|
||||||
|
)
|
||||||
|
if result.status == "error":
|
||||||
|
raise HTTPException(status_code=400, detail=result.message)
|
||||||
|
return MemoryRead(memory_md=result.memory_md, limits=memory_limits())
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/searchspaces/{search_space_id}/memory/reset", response_model=MemoryRead)
|
||||||
|
async def reset_team_memory(
|
||||||
|
search_space_id: int,
|
||||||
|
session: AsyncSession = Depends(get_async_session),
|
||||||
|
user: User = Depends(current_active_user),
|
||||||
|
):
|
||||||
|
await check_search_space_access(session, user, search_space_id)
|
||||||
|
result = await reset_memory(
|
||||||
|
scope=MemoryScope.TEAM,
|
||||||
|
target_id=search_space_id,
|
||||||
|
session=session,
|
||||||
|
)
|
||||||
|
if result.status == "error":
|
||||||
|
raise HTTPException(status_code=400, detail=result.message)
|
||||||
|
return MemoryRead(memory_md=result.memory_md, limits=memory_limits())
|
||||||
|
|
@ -21,7 +21,6 @@ class SearchSpaceUpdate(BaseModel):
|
||||||
description: str | None = None
|
description: str | None = None
|
||||||
citations_enabled: bool | None = None
|
citations_enabled: bool | None = None
|
||||||
qna_custom_instructions: str | None = None
|
qna_custom_instructions: str | None = None
|
||||||
shared_memory_md: str | None = None
|
|
||||||
ai_file_sort_enabled: bool | None = None
|
ai_file_sort_enabled: bool | None = None
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
32
surfsense_backend/app/services/memory/__init__.py
Normal file
32
surfsense_backend/app/services/memory/__init__.py
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
"""First-class memory service for user and team markdown memory."""
|
||||||
|
|
||||||
|
from .schemas import MemoryLimits, MemoryRead
|
||||||
|
from .service import (
|
||||||
|
MemoryScope,
|
||||||
|
SaveResult,
|
||||||
|
memory_limits,
|
||||||
|
read_memory,
|
||||||
|
reset_memory,
|
||||||
|
save_memory,
|
||||||
|
)
|
||||||
|
from .validation import (
|
||||||
|
MEMORY_HARD_LIMIT,
|
||||||
|
MEMORY_SOFT_LIMIT,
|
||||||
|
validate_bullet_format,
|
||||||
|
validate_memory_scope,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"MEMORY_HARD_LIMIT",
|
||||||
|
"MEMORY_SOFT_LIMIT",
|
||||||
|
"MemoryLimits",
|
||||||
|
"MemoryRead",
|
||||||
|
"MemoryScope",
|
||||||
|
"SaveResult",
|
||||||
|
"memory_limits",
|
||||||
|
"read_memory",
|
||||||
|
"reset_memory",
|
||||||
|
"save_memory",
|
||||||
|
"validate_bullet_format",
|
||||||
|
"validate_memory_scope",
|
||||||
|
]
|
||||||
200
surfsense_backend/app/services/memory/document.py
Normal file
200
surfsense_backend/app/services/memory/document.py
Normal file
|
|
@ -0,0 +1,200 @@
|
||||||
|
"""Memory-specific markdown document model and canonical renderer.
|
||||||
|
|
||||||
|
This intentionally parses only SurfSense memory's small markdown contract:
|
||||||
|
``##`` sections with dated bullet items. Unknown lines are preserved so user
|
||||||
|
edits are not lost, while legacy marker bullets are normalized on render.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
DEFAULT_LEGACY_SECTION = "Memory"
|
||||||
|
LEGACY_MARKERS = frozenset({"fact", "pref", "instr"})
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class MemoryBullet:
|
||||||
|
entry_date: date
|
||||||
|
text: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class MemoryRawLine:
|
||||||
|
text: str
|
||||||
|
|
||||||
|
|
||||||
|
MemoryLine = MemoryBullet | MemoryRawLine
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class MemorySection:
|
||||||
|
heading: str
|
||||||
|
lines: list[MemoryLine] = field(default_factory=list)
|
||||||
|
explicit_heading: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class MemoryDocument:
|
||||||
|
sections: list[MemorySection] = field(default_factory=list)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def has_explicit_heading(self) -> bool:
|
||||||
|
return any(section.explicit_heading for section in self.sections)
|
||||||
|
|
||||||
|
|
||||||
|
def is_section_heading(line: str) -> bool:
|
||||||
|
return line.startswith("## ") and bool(line[3:].strip())
|
||||||
|
|
||||||
|
|
||||||
|
def heading_text(line: str) -> str:
|
||||||
|
return line[3:].strip()
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_heading(heading: str) -> str:
|
||||||
|
chars: list[str] = []
|
||||||
|
previous_was_space = True
|
||||||
|
for char in heading.strip().lower():
|
||||||
|
if char.isalnum():
|
||||||
|
chars.append(char)
|
||||||
|
previous_was_space = False
|
||||||
|
elif not previous_was_space:
|
||||||
|
chars.append(" ")
|
||||||
|
previous_was_space = True
|
||||||
|
return "".join(chars).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def parse_bullet_line(line: str) -> MemoryBullet | None:
|
||||||
|
stripped = line.strip()
|
||||||
|
if not stripped.startswith("- "):
|
||||||
|
return None
|
||||||
|
|
||||||
|
body = stripped[2:]
|
||||||
|
parsed = _parse_canonical_bullet(body)
|
||||||
|
if parsed is not None:
|
||||||
|
return parsed
|
||||||
|
return _parse_legacy_bullet(body)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_canonical_bullet(body: str) -> MemoryBullet | None:
|
||||||
|
if len(body) < 13 or body[10:12] != ": ":
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
entry_date = date.fromisoformat(body[:10])
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
text = body[12:].strip()
|
||||||
|
if not text:
|
||||||
|
return None
|
||||||
|
return MemoryBullet(entry_date=entry_date, text=text)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_legacy_bullet(body: str) -> MemoryBullet | None:
|
||||||
|
if len(body) < 20 or not body.startswith("("):
|
||||||
|
return None
|
||||||
|
if len(body) < 14 or body[11:14] != ") [":
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
entry_date = date.fromisoformat(body[1:11])
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
marker_end = body.find("] ", 14)
|
||||||
|
if marker_end == -1:
|
||||||
|
return None
|
||||||
|
marker = body[14:marker_end]
|
||||||
|
if marker not in LEGACY_MARKERS:
|
||||||
|
return None
|
||||||
|
|
||||||
|
text = body[marker_end + 2 :].strip()
|
||||||
|
if not text:
|
||||||
|
return None
|
||||||
|
return MemoryBullet(entry_date=entry_date, text=text)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_memory_document(content: str | None) -> MemoryDocument:
|
||||||
|
if not content:
|
||||||
|
return MemoryDocument()
|
||||||
|
|
||||||
|
sections: list[MemorySection] = []
|
||||||
|
current_heading: str | None = None
|
||||||
|
current_explicit = True
|
||||||
|
current_lines: list[MemoryLine] = []
|
||||||
|
|
||||||
|
def flush_current() -> None:
|
||||||
|
nonlocal current_heading, current_explicit, current_lines
|
||||||
|
if current_heading is None:
|
||||||
|
return
|
||||||
|
sections.append(
|
||||||
|
MemorySection(
|
||||||
|
heading=current_heading,
|
||||||
|
lines=current_lines,
|
||||||
|
explicit_heading=current_explicit,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
current_heading = None
|
||||||
|
current_explicit = True
|
||||||
|
current_lines = []
|
||||||
|
|
||||||
|
for raw_line in content.strip().splitlines():
|
||||||
|
line = raw_line.rstrip()
|
||||||
|
if is_section_heading(line):
|
||||||
|
flush_current()
|
||||||
|
current_heading = heading_text(line)
|
||||||
|
current_explicit = True
|
||||||
|
current_lines = []
|
||||||
|
continue
|
||||||
|
|
||||||
|
bullet = parse_bullet_line(line)
|
||||||
|
if current_heading is None:
|
||||||
|
if bullet is None:
|
||||||
|
continue
|
||||||
|
current_heading = DEFAULT_LEGACY_SECTION
|
||||||
|
current_explicit = False
|
||||||
|
current_lines = [bullet]
|
||||||
|
continue
|
||||||
|
|
||||||
|
current_lines.append(bullet if bullet is not None else MemoryRawLine(text=line))
|
||||||
|
|
||||||
|
flush_current()
|
||||||
|
return MemoryDocument(sections=sections)
|
||||||
|
|
||||||
|
|
||||||
|
def render_memory_document(document: MemoryDocument) -> str:
|
||||||
|
rendered_sections: list[str] = []
|
||||||
|
for section in document.sections:
|
||||||
|
section_lines = [f"## {section.heading}"]
|
||||||
|
for line in section.lines:
|
||||||
|
if isinstance(line, MemoryBullet):
|
||||||
|
section_lines.append(f"- {line.entry_date.isoformat()}: {line.text}")
|
||||||
|
else:
|
||||||
|
section_lines.append(line.text)
|
||||||
|
rendered_sections.append("\n".join(section_lines).strip())
|
||||||
|
return "\n\n".join(section for section in rendered_sections if section).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def extract_headings(memory: str | None) -> set[str]:
|
||||||
|
document = parse_memory_document(memory)
|
||||||
|
return {
|
||||||
|
normalize_heading(section.heading)
|
||||||
|
for section in document.sections
|
||||||
|
if section.explicit_heading
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def has_explicit_heading(content: str) -> bool:
|
||||||
|
return parse_memory_document(content).has_explicit_heading
|
||||||
|
|
||||||
|
|
||||||
|
def nonstandard_bullets(content: str) -> list[str]:
|
||||||
|
warnings: list[str] = []
|
||||||
|
for line in content.splitlines():
|
||||||
|
stripped = line.strip()
|
||||||
|
if not stripped.startswith("- "):
|
||||||
|
continue
|
||||||
|
if parse_bullet_line(stripped) is not None:
|
||||||
|
continue
|
||||||
|
short = stripped[:80] + ("..." if len(stripped) > 80 else "")
|
||||||
|
warnings.append(f"Non-standard memory bullet: {short}")
|
||||||
|
return warnings
|
||||||
20
surfsense_backend/app/services/memory/prompts.py
Normal file
20
surfsense_backend/app/services/memory/prompts.py
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
"""Prompts used by the memory service."""
|
||||||
|
|
||||||
|
FORCED_REWRITE_PROMPT = """\
|
||||||
|
You are a memory curator. The following memory document exceeds the character \
|
||||||
|
limit and must be shortened.
|
||||||
|
|
||||||
|
RULES:
|
||||||
|
1. Rewrite the document to be under {target} characters.
|
||||||
|
2. Output Markdown only. Use clear `##` headings and concise bullet points.
|
||||||
|
3. New-format bullets should look like: `- YYYY-MM-DD: memory text`.
|
||||||
|
4. If the input contains legacy markers like `(YYYY-MM-DD) [fact]`, preserve the
|
||||||
|
information but remove the inline marker in the output.
|
||||||
|
5. Preserve durable instructions and preferences before generic facts when
|
||||||
|
compressing personal memory.
|
||||||
|
6. Preserve existing headings when useful; merge duplicate headings and bullets.
|
||||||
|
7. Output ONLY the consolidated markdown — no explanations, no wrapping.
|
||||||
|
|
||||||
|
<memory_document>
|
||||||
|
{content}
|
||||||
|
</memory_document>"""
|
||||||
35
surfsense_backend/app/services/memory/rewrite.py
Normal file
35
surfsense_backend/app/services/memory/rewrite.py
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
"""LLM-backed memory rewrite helpers."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from langchain_core.messages import HumanMessage
|
||||||
|
|
||||||
|
from app.services.memory.prompts import FORCED_REWRITE_PROMPT
|
||||||
|
from app.services.memory.validation import MEMORY_HARD_LIMIT
|
||||||
|
from app.utils.content_utils import extract_text_content
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
async def forced_rewrite(content: str, llm: Any) -> str | None:
|
||||||
|
"""Use a focused LLM call to compress memory under the hard limit."""
|
||||||
|
try:
|
||||||
|
prompt = FORCED_REWRITE_PROMPT.format(
|
||||||
|
target=MEMORY_HARD_LIMIT,
|
||||||
|
content=content,
|
||||||
|
)
|
||||||
|
response = await llm.ainvoke(
|
||||||
|
[HumanMessage(content=prompt)],
|
||||||
|
config={"tags": ["surfsense:internal", "memory-rewrite"]},
|
||||||
|
)
|
||||||
|
text = extract_text_content(response.content).strip()
|
||||||
|
if not text:
|
||||||
|
logger.warning("Forced memory rewrite returned empty text")
|
||||||
|
return None
|
||||||
|
return text
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Forced memory rewrite LLM call failed")
|
||||||
|
return None
|
||||||
19
surfsense_backend/app/services/memory/schemas.py
Normal file
19
surfsense_backend/app/services/memory/schemas.py
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
"""Schemas for memory API responses and structured extraction."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class MemoryLimits(BaseModel):
|
||||||
|
"""Canonical memory size limits exposed to clients."""
|
||||||
|
|
||||||
|
soft: int
|
||||||
|
hard: int
|
||||||
|
|
||||||
|
|
||||||
|
class MemoryRead(BaseModel):
|
||||||
|
"""Memory document payload returned by user and team memory APIs."""
|
||||||
|
|
||||||
|
memory_md: str
|
||||||
|
limits: MemoryLimits
|
||||||
247
surfsense_backend/app/services/memory/service.py
Normal file
247
surfsense_backend/app/services/memory/service.py
Normal file
|
|
@ -0,0 +1,247 @@
|
||||||
|
"""Canonical read/write/reset/extract service for markdown memory."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from enum import StrEnum
|
||||||
|
from typing import Any, Literal
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.db import SearchSpace, User
|
||||||
|
from app.services.memory.document import parse_memory_document, render_memory_document
|
||||||
|
from app.services.memory.rewrite import forced_rewrite
|
||||||
|
from app.services.memory.schemas import MemoryLimits
|
||||||
|
from app.services.memory.validation import (
|
||||||
|
MEMORY_HARD_LIMIT,
|
||||||
|
MEMORY_SOFT_LIMIT,
|
||||||
|
soft_limit_warning,
|
||||||
|
strip_preamble_to_first_heading,
|
||||||
|
validate_bullet_format,
|
||||||
|
validate_diff,
|
||||||
|
validate_heading_sanity,
|
||||||
|
validate_memory_scope,
|
||||||
|
validate_memory_size,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_NO_UPDATE_SENTINELS = frozenset(
|
||||||
|
{
|
||||||
|
"NO_UPDATE",
|
||||||
|
"NO UPDATE",
|
||||||
|
"NO_CHANGE",
|
||||||
|
"NO CHANGE",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class MemoryScope(StrEnum):
|
||||||
|
USER = "user"
|
||||||
|
TEAM = "team"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class SaveResult:
|
||||||
|
status: Literal["saved", "error", "no_op"]
|
||||||
|
message: str
|
||||||
|
memory_md: str = ""
|
||||||
|
warnings: list[str] = field(default_factory=list)
|
||||||
|
diff_warnings: list[str] = field(default_factory=list)
|
||||||
|
format_warnings: list[str] = field(default_factory=list)
|
||||||
|
notice: str | None = None
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, Any]:
|
||||||
|
data: dict[str, Any] = {
|
||||||
|
"status": self.status,
|
||||||
|
"message": self.message,
|
||||||
|
"memory_md": self.memory_md,
|
||||||
|
}
|
||||||
|
if self.notice:
|
||||||
|
data["notice"] = self.notice
|
||||||
|
if self.warnings:
|
||||||
|
data["warnings"] = self.warnings
|
||||||
|
if len(self.warnings) == 1:
|
||||||
|
data["warning"] = self.warnings[0]
|
||||||
|
if self.diff_warnings:
|
||||||
|
data["diff_warnings"] = self.diff_warnings
|
||||||
|
if self.format_warnings:
|
||||||
|
data["format_warnings"] = self.format_warnings
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def memory_limits() -> MemoryLimits:
|
||||||
|
return MemoryLimits(soft=MEMORY_SOFT_LIMIT, hard=MEMORY_HARD_LIMIT)
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_scope(scope: MemoryScope | str) -> MemoryScope:
|
||||||
|
return scope if isinstance(scope, MemoryScope) else MemoryScope(scope)
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_user_id(target_id: str | UUID) -> UUID:
|
||||||
|
return UUID(target_id) if isinstance(target_id, str) else target_id
|
||||||
|
|
||||||
|
|
||||||
|
async def _load_target(
|
||||||
|
*,
|
||||||
|
scope: MemoryScope | str,
|
||||||
|
target_id: str | int | UUID,
|
||||||
|
session: AsyncSession,
|
||||||
|
) -> User | SearchSpace | None:
|
||||||
|
normalized = _normalize_scope(scope)
|
||||||
|
if normalized is MemoryScope.USER:
|
||||||
|
result = await session.execute(
|
||||||
|
select(User).where(User.id == _normalize_user_id(target_id)) # type: ignore[arg-type]
|
||||||
|
)
|
||||||
|
return result.scalars().first()
|
||||||
|
result = await session.execute(
|
||||||
|
select(SearchSpace).where(SearchSpace.id == int(target_id))
|
||||||
|
)
|
||||||
|
return result.scalars().first()
|
||||||
|
|
||||||
|
|
||||||
|
def _get_memory(target: User | SearchSpace, scope: MemoryScope) -> str:
|
||||||
|
if scope is MemoryScope.USER:
|
||||||
|
return getattr(target, "memory_md", None) or ""
|
||||||
|
return getattr(target, "shared_memory_md", None) or ""
|
||||||
|
|
||||||
|
|
||||||
|
def _set_memory(target: User | SearchSpace, scope: MemoryScope, content: str) -> None:
|
||||||
|
if scope is MemoryScope.USER:
|
||||||
|
target.memory_md = content
|
||||||
|
else:
|
||||||
|
target.shared_memory_md = content
|
||||||
|
|
||||||
|
|
||||||
|
async def read_memory(
|
||||||
|
*,
|
||||||
|
scope: MemoryScope | str,
|
||||||
|
target_id: str | int | UUID,
|
||||||
|
session: AsyncSession,
|
||||||
|
) -> str:
|
||||||
|
normalized = _normalize_scope(scope)
|
||||||
|
target = await _load_target(scope=normalized, target_id=target_id, session=session)
|
||||||
|
if target is None:
|
||||||
|
return ""
|
||||||
|
return _get_memory(target, normalized)
|
||||||
|
|
||||||
|
|
||||||
|
async def save_memory(
|
||||||
|
*,
|
||||||
|
scope: MemoryScope | str,
|
||||||
|
target_id: str | int | UUID,
|
||||||
|
content: str,
|
||||||
|
session: AsyncSession,
|
||||||
|
llm: Any | None = None,
|
||||||
|
) -> SaveResult:
|
||||||
|
normalized = _normalize_scope(scope)
|
||||||
|
if not isinstance(content, str):
|
||||||
|
return SaveResult(
|
||||||
|
status="error",
|
||||||
|
message="Internal error: memory payload must be a string.",
|
||||||
|
)
|
||||||
|
|
||||||
|
target = await _load_target(scope=normalized, target_id=target_id, session=session)
|
||||||
|
if target is None:
|
||||||
|
return SaveResult(
|
||||||
|
status="error",
|
||||||
|
message="User not found."
|
||||||
|
if normalized is MemoryScope.USER
|
||||||
|
else "Search space not found.",
|
||||||
|
)
|
||||||
|
|
||||||
|
old_memory = _get_memory(target, normalized)
|
||||||
|
next_content = strip_preamble_to_first_heading(content.strip())
|
||||||
|
notice: str | None = None
|
||||||
|
warnings: list[str] = []
|
||||||
|
|
||||||
|
if next_content.upper() in _NO_UPDATE_SENTINELS:
|
||||||
|
return SaveResult(
|
||||||
|
status="no_op",
|
||||||
|
message="No memory update requested.",
|
||||||
|
memory_md=old_memory,
|
||||||
|
)
|
||||||
|
|
||||||
|
if len(next_content) > MEMORY_HARD_LIMIT and llm is not None:
|
||||||
|
rewritten = await forced_rewrite(next_content, llm)
|
||||||
|
if rewritten is not None and len(rewritten) < len(next_content):
|
||||||
|
next_content = strip_preamble_to_first_heading(rewritten)
|
||||||
|
notice = "Memory was automatically rewritten to fit within limits."
|
||||||
|
|
||||||
|
for validation in (
|
||||||
|
validate_memory_size(next_content),
|
||||||
|
validate_heading_sanity(next_content),
|
||||||
|
):
|
||||||
|
if validation:
|
||||||
|
return SaveResult(
|
||||||
|
status="error",
|
||||||
|
message=validation["message"],
|
||||||
|
memory_md=old_memory,
|
||||||
|
)
|
||||||
|
|
||||||
|
scope_error, scope_warnings = validate_memory_scope(
|
||||||
|
next_content,
|
||||||
|
normalized.value,
|
||||||
|
old_memory=old_memory,
|
||||||
|
)
|
||||||
|
warnings.extend(scope_warnings)
|
||||||
|
if scope_error:
|
||||||
|
return SaveResult(
|
||||||
|
status="error",
|
||||||
|
message=scope_error["message"],
|
||||||
|
memory_md=old_memory,
|
||||||
|
warnings=warnings,
|
||||||
|
)
|
||||||
|
|
||||||
|
next_content = render_memory_document(parse_memory_document(next_content))
|
||||||
|
|
||||||
|
try:
|
||||||
|
_set_memory(target, normalized, next_content)
|
||||||
|
session.add(target)
|
||||||
|
await session.commit()
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("Failed to update %s memory: %s", normalized.value, e)
|
||||||
|
await session.rollback()
|
||||||
|
return SaveResult(
|
||||||
|
status="error",
|
||||||
|
message=f"Failed to update {normalized.value} memory: {e}",
|
||||||
|
memory_md=old_memory,
|
||||||
|
)
|
||||||
|
|
||||||
|
diff_warnings = validate_diff(old_memory, next_content)
|
||||||
|
format_warnings = validate_bullet_format(next_content)
|
||||||
|
warning = soft_limit_warning(next_content)
|
||||||
|
if warning:
|
||||||
|
warnings.append(warning)
|
||||||
|
|
||||||
|
return SaveResult(
|
||||||
|
status="saved",
|
||||||
|
message=(
|
||||||
|
"Memory updated."
|
||||||
|
if normalized is MemoryScope.USER
|
||||||
|
else "Team memory updated."
|
||||||
|
),
|
||||||
|
memory_md=next_content,
|
||||||
|
warnings=warnings,
|
||||||
|
diff_warnings=diff_warnings,
|
||||||
|
format_warnings=format_warnings,
|
||||||
|
notice=notice,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def reset_memory(
|
||||||
|
*,
|
||||||
|
scope: MemoryScope | str,
|
||||||
|
target_id: str | int | UUID,
|
||||||
|
session: AsyncSession,
|
||||||
|
) -> SaveResult:
|
||||||
|
return await save_memory(
|
||||||
|
scope=scope,
|
||||||
|
target_id=target_id,
|
||||||
|
content="",
|
||||||
|
session=session,
|
||||||
|
llm=None,
|
||||||
|
)
|
||||||
140
surfsense_backend/app/services/memory/validation.py
Normal file
140
surfsense_backend/app/services/memory/validation.py
Normal file
|
|
@ -0,0 +1,140 @@
|
||||||
|
"""Validation helpers for markdown-backed memory."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
from app.services.memory.document import (
|
||||||
|
extract_headings,
|
||||||
|
has_explicit_heading,
|
||||||
|
nonstandard_bullets,
|
||||||
|
parse_memory_document,
|
||||||
|
)
|
||||||
|
|
||||||
|
MEMORY_SOFT_LIMIT = 18_000
|
||||||
|
MEMORY_HARD_LIMIT = 25_000
|
||||||
|
|
||||||
|
_FORBIDDEN_TEAM_HEADINGS = {
|
||||||
|
"preferences",
|
||||||
|
"instructions",
|
||||||
|
"personal notes",
|
||||||
|
"personal instructions",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def has_markdown_heading(content: str) -> bool:
|
||||||
|
return has_explicit_heading(content)
|
||||||
|
|
||||||
|
|
||||||
|
def strip_preamble_to_first_heading(content: str) -> str:
|
||||||
|
"""Drop model preamble before the first ``##`` heading, if one exists."""
|
||||||
|
lines = content.splitlines()
|
||||||
|
for index, line in enumerate(lines):
|
||||||
|
if line.startswith("## ") and line[3:].strip():
|
||||||
|
return "\n".join(lines[index:]).strip()
|
||||||
|
return content.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def validate_memory_size(content: str) -> dict[str, str] | None:
|
||||||
|
length = len(content)
|
||||||
|
if length > MEMORY_HARD_LIMIT:
|
||||||
|
return {
|
||||||
|
"status": "error",
|
||||||
|
"message": (
|
||||||
|
f"Memory exceeds {MEMORY_HARD_LIMIT:,} character limit "
|
||||||
|
f"({length:,} chars). Consolidate by merging related items, "
|
||||||
|
"removing outdated entries, and shortening descriptions."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def validate_heading_sanity(content: str) -> dict[str, str] | None:
|
||||||
|
"""Block long prose blobs without headings unless they are legacy bullets."""
|
||||||
|
stripped = content.strip()
|
||||||
|
if not stripped:
|
||||||
|
return None
|
||||||
|
if has_markdown_heading(stripped):
|
||||||
|
return None
|
||||||
|
if len(stripped) <= 40:
|
||||||
|
return None
|
||||||
|
if parse_memory_document(stripped).sections:
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
"status": "error",
|
||||||
|
"message": "Memory must be markdown with at least one ## heading.",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def validate_memory_scope(
|
||||||
|
content: str,
|
||||||
|
scope: Literal["user", "team"],
|
||||||
|
*,
|
||||||
|
old_memory: str | None = None,
|
||||||
|
) -> tuple[dict[str, str] | None, list[str]]:
|
||||||
|
"""Reject new personal headings in team memory, grandfather existing ones."""
|
||||||
|
if scope != "team":
|
||||||
|
return None, []
|
||||||
|
|
||||||
|
old_forbidden = extract_headings(old_memory) & _FORBIDDEN_TEAM_HEADINGS
|
||||||
|
new_forbidden = extract_headings(content) & _FORBIDDEN_TEAM_HEADINGS
|
||||||
|
introduced = sorted(new_forbidden - old_forbidden)
|
||||||
|
grandfathered = sorted(new_forbidden & old_forbidden)
|
||||||
|
|
||||||
|
warnings: list[str] = []
|
||||||
|
if grandfathered:
|
||||||
|
warnings.append(
|
||||||
|
"Team memory contains legacy personal headings: "
|
||||||
|
+ ", ".join(grandfathered)
|
||||||
|
+ ". Please consolidate them into team-safe headings."
|
||||||
|
)
|
||||||
|
if introduced:
|
||||||
|
return (
|
||||||
|
{
|
||||||
|
"status": "error",
|
||||||
|
"message": (
|
||||||
|
"Team memory cannot introduce personal headings: "
|
||||||
|
+ ", ".join(introduced)
|
||||||
|
+ ". Use team-safe headings instead."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
warnings,
|
||||||
|
)
|
||||||
|
return None, warnings
|
||||||
|
|
||||||
|
|
||||||
|
def validate_bullet_format(content: str) -> list[str]:
|
||||||
|
return nonstandard_bullets(content)
|
||||||
|
|
||||||
|
|
||||||
|
def validate_diff(old_memory: str | None, new_memory: str) -> list[str]:
|
||||||
|
if not old_memory:
|
||||||
|
return []
|
||||||
|
|
||||||
|
warnings: list[str] = []
|
||||||
|
old_headings = extract_headings(old_memory)
|
||||||
|
new_headings = extract_headings(new_memory)
|
||||||
|
dropped = old_headings - new_headings
|
||||||
|
if dropped:
|
||||||
|
names = ", ".join(sorted(dropped))
|
||||||
|
warnings.append(
|
||||||
|
f"Sections removed: {names}. If unintentional, restore them from the memory document."
|
||||||
|
)
|
||||||
|
|
||||||
|
old_len = len(old_memory)
|
||||||
|
new_len = len(new_memory)
|
||||||
|
if old_len > 0 and new_len < old_len * 0.4:
|
||||||
|
warnings.append(
|
||||||
|
f"Memory shrank significantly ({old_len:,} -> {new_len:,} chars). Possible data loss."
|
||||||
|
)
|
||||||
|
return warnings
|
||||||
|
|
||||||
|
|
||||||
|
def soft_limit_warning(content: str) -> str | None:
|
||||||
|
length = len(content)
|
||||||
|
if length > MEMORY_SOFT_LIMIT:
|
||||||
|
return (
|
||||||
|
f"Memory is at {length:,}/{MEMORY_HARD_LIMIT:,} characters. "
|
||||||
|
"Consolidate by merging related items and removing less important entries."
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
@ -39,10 +39,6 @@ from app.agents.new_chat.llm_config import (
|
||||||
load_agent_config,
|
load_agent_config,
|
||||||
load_global_llm_config_by_id,
|
load_global_llm_config_by_id,
|
||||||
)
|
)
|
||||||
from app.agents.new_chat.memory_extraction import (
|
|
||||||
extract_and_save_memory,
|
|
||||||
extract_and_save_team_memory,
|
|
||||||
)
|
|
||||||
from app.agents.new_chat.mention_resolver import resolve_mentions, substitute_in_text
|
from app.agents.new_chat.mention_resolver import resolve_mentions, substitute_in_text
|
||||||
from app.agents.new_chat.middleware.busy_mutex import (
|
from app.agents.new_chat.middleware.busy_mutex import (
|
||||||
end_turn,
|
end_turn,
|
||||||
|
|
@ -283,7 +279,6 @@ class StreamResult:
|
||||||
accumulated_text: str = ""
|
accumulated_text: str = ""
|
||||||
is_interrupted: bool = False
|
is_interrupted: bool = False
|
||||||
sandbox_files: list[str] = field(default_factory=list)
|
sandbox_files: list[str] = field(default_factory=list)
|
||||||
agent_called_update_memory: bool = False
|
|
||||||
request_id: str | None = None
|
request_id: str | None = None
|
||||||
turn_id: str = ""
|
turn_id: str = ""
|
||||||
filesystem_mode: str = "cloud"
|
filesystem_mode: str = "cloud"
|
||||||
|
|
@ -2208,36 +2203,6 @@ async def stream_new_chat(
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
# Fire background memory extraction if the agent didn't handle it.
|
|
||||||
# Shared threads write to team memory; private threads write to user memory.
|
|
||||||
if not stream_result.agent_called_update_memory:
|
|
||||||
memory_seed = user_query.strip() or (
|
|
||||||
f"[{len(user_image_data_urls or [])} image(s)]"
|
|
||||||
if user_image_data_urls
|
|
||||||
else "(message)"
|
|
||||||
)
|
|
||||||
if visibility == ChatVisibility.SEARCH_SPACE:
|
|
||||||
task = asyncio.create_task(
|
|
||||||
extract_and_save_team_memory(
|
|
||||||
user_message=memory_seed,
|
|
||||||
search_space_id=search_space_id,
|
|
||||||
llm=llm,
|
|
||||||
author_display_name=current_user_display_name,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
_background_tasks.add(task)
|
|
||||||
task.add_done_callback(_background_tasks.discard)
|
|
||||||
elif user_id:
|
|
||||||
task = asyncio.create_task(
|
|
||||||
extract_and_save_memory(
|
|
||||||
user_message=memory_seed,
|
|
||||||
user_id=user_id,
|
|
||||||
llm=llm,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
_background_tasks.add(task)
|
|
||||||
task.add_done_callback(_background_tasks.discard)
|
|
||||||
|
|
||||||
# Finish the step and message
|
# Finish the step and message
|
||||||
yield streaming_service.format_data("turn-status", {"status": "idle"})
|
yield streaming_service.format_data("turn-status", {"status": "idle"})
|
||||||
yield streaming_service.format_finish_step()
|
yield streaming_service.format_finish_step()
|
||||||
|
|
|
||||||
|
|
@ -48,4 +48,3 @@ async def stream_output(
|
||||||
yield frame
|
yield frame
|
||||||
|
|
||||||
result.accumulated_text = state.accumulated_text
|
result.accumulated_text = state.accumulated_text
|
||||||
result.agent_called_update_memory = state.called_update_memory
|
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,6 @@ class StreamingResult:
|
||||||
accumulated_text: str = ""
|
accumulated_text: str = ""
|
||||||
is_interrupted: bool = False
|
is_interrupted: bool = False
|
||||||
sandbox_files: list[str] = field(default_factory=list)
|
sandbox_files: list[str] = field(default_factory=list)
|
||||||
agent_called_update_memory: bool = False
|
|
||||||
request_id: str | None = None
|
request_id: str | None = None
|
||||||
turn_id: str = ""
|
turn_id: str = ""
|
||||||
filesystem_mode: str = "cloud"
|
filesystem_mode: str = "cloud"
|
||||||
|
|
|
||||||
|
|
@ -36,9 +36,6 @@ def iter_tool_end_frames(
|
||||||
raw_output = event.get("data", {}).get("output", "")
|
raw_output = event.get("data", {}).get("output", "")
|
||||||
staged_file_path = state.file_path_by_run.pop(run_id, None) if run_id else None
|
staged_file_path = state.file_path_by_run.pop(run_id, None) if run_id else None
|
||||||
|
|
||||||
if tool_name == "update_memory":
|
|
||||||
state.called_update_memory = True
|
|
||||||
|
|
||||||
if hasattr(raw_output, "content"):
|
if hasattr(raw_output, "content"):
|
||||||
content = raw_output.content
|
content = raw_output.content
|
||||||
if isinstance(content, str):
|
if isinstance(content, str):
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,6 @@ class AgentEventRelayState:
|
||||||
last_active_step_items: list[str] = field(default_factory=list)
|
last_active_step_items: list[str] = field(default_factory=list)
|
||||||
just_finished_tool: bool = False
|
just_finished_tool: bool = False
|
||||||
active_tool_depth: int = 0
|
active_tool_depth: int = 0
|
||||||
called_update_memory: bool = False
|
|
||||||
current_reasoning_id: str | None = None
|
current_reasoning_id: str | None = None
|
||||||
pending_tool_call_chunks: list[dict[str, Any]] = field(default_factory=list)
|
pending_tool_call_chunks: list[dict[str, Any]] = field(default_factory=list)
|
||||||
lc_tool_call_id_by_run: dict[str, str] = field(default_factory=dict)
|
lc_tool_call_id_by_run: dict[str, str] = field(default_factory=dict)
|
||||||
|
|
|
||||||
|
|
@ -2,28 +2,12 @@
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from app.agents.new_chat.tools.update_memory import _save_memory
|
from app.services.memory import MemoryScope, save_memory
|
||||||
from app.utils.content_utils import extract_text_content
|
from app.utils.content_utils import extract_text_content
|
||||||
|
|
||||||
pytestmark = pytest.mark.unit
|
pytestmark = pytest.mark.unit
|
||||||
|
|
||||||
|
|
||||||
class _Recorder:
|
|
||||||
def __init__(self) -> None:
|
|
||||||
self.applied_content: str | None = None
|
|
||||||
self.commit_calls = 0
|
|
||||||
self.rollback_calls = 0
|
|
||||||
|
|
||||||
def apply(self, content: str) -> None:
|
|
||||||
self.applied_content = content
|
|
||||||
|
|
||||||
async def commit(self) -> None:
|
|
||||||
self.commit_calls += 1
|
|
||||||
|
|
||||||
async def rollback(self) -> None:
|
|
||||||
self.rollback_calls += 1
|
|
||||||
|
|
||||||
|
|
||||||
def test_extract_text_content_keeps_no_update_bare_string_from_content_blocks() -> None:
|
def test_extract_text_content_keeps_no_update_bare_string_from_content_blocks() -> None:
|
||||||
content = [
|
content = [
|
||||||
{"type": "thinking", "thinking": "No"},
|
{"type": "thinking", "thinking": "No"},
|
||||||
|
|
@ -69,21 +53,12 @@ def test_extract_text_content_preserves_plain_string_responses() -> None:
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_save_memory_rejects_non_string_payload_before_commit() -> None:
|
async def test_save_memory_rejects_non_string_payload_before_commit() -> None:
|
||||||
recorder = _Recorder()
|
result = await save_memory(
|
||||||
|
scope=MemoryScope.USER,
|
||||||
result = await _save_memory(
|
target_id="00000000-0000-0000-0000-000000000000",
|
||||||
updated_memory=["NO_UPDATE"], # type: ignore[arg-type]
|
content=["NO_UPDATE"], # type: ignore[arg-type]
|
||||||
old_memory=None,
|
session=None, # type: ignore[arg-type]
|
||||||
llm=None,
|
|
||||||
apply_fn=recorder.apply,
|
|
||||||
commit_fn=recorder.commit,
|
|
||||||
rollback_fn=recorder.rollback,
|
|
||||||
label="memory",
|
|
||||||
scope="user",
|
|
||||||
)
|
)
|
||||||
|
|
||||||
assert result["status"] == "error"
|
assert result.status == "error"
|
||||||
assert "must be a string" in result["message"]
|
assert "must be a string" in result.message
|
||||||
assert recorder.applied_content is None
|
|
||||||
assert recorder.commit_calls == 0
|
|
||||||
assert recorder.rollback_calls == 0
|
|
||||||
|
|
|
||||||
|
|
@ -1,24 +1,24 @@
|
||||||
"""Unit tests for memory scope validation and bullet format validation."""
|
"""Unit tests for heading-based memory validation."""
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from app.agents.new_chat.tools.update_memory import (
|
from app.services.memory import MemoryScope, save_memory
|
||||||
_save_memory,
|
from app.services.memory.validation import (
|
||||||
_validate_bullet_format,
|
validate_bullet_format,
|
||||||
_validate_memory_scope,
|
validate_memory_scope,
|
||||||
)
|
)
|
||||||
|
|
||||||
pytestmark = pytest.mark.unit
|
pytestmark = pytest.mark.unit
|
||||||
|
|
||||||
|
|
||||||
class _Recorder:
|
class _FakeSession:
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self.applied_content: str | None = None
|
self.added = []
|
||||||
self.commit_calls = 0
|
self.commit_calls = 0
|
||||||
self.rollback_calls = 0
|
self.rollback_calls = 0
|
||||||
|
|
||||||
def apply(self, content: str) -> None:
|
def add(self, obj) -> None:
|
||||||
self.applied_content = content
|
self.added.append(obj)
|
||||||
|
|
||||||
async def commit(self) -> None:
|
async def commit(self) -> None:
|
||||||
self.commit_calls += 1
|
self.commit_calls += 1
|
||||||
|
|
@ -27,172 +27,148 @@ class _Recorder:
|
||||||
self.rollback_calls += 1
|
self.rollback_calls += 1
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
def test_validate_memory_scope_rejects_new_personal_heading_in_team() -> None:
|
||||||
# _validate_memory_scope — marker-based
|
content = "## Preferences\n- 2026-04-10: Prefers dark mode\n"
|
||||||
# ---------------------------------------------------------------------------
|
result, _warnings = validate_memory_scope(content, "team")
|
||||||
|
|
||||||
|
|
||||||
def test_validate_memory_scope_rejects_pref_marker_in_team_scope() -> None:
|
|
||||||
content = "- (2026-04-10) [pref] Prefers dark mode\n"
|
|
||||||
result = _validate_memory_scope(content, "team")
|
|
||||||
assert result is not None
|
assert result is not None
|
||||||
assert result["status"] == "error"
|
assert result["status"] == "error"
|
||||||
assert "[pref]" in result["message"]
|
assert "preferences" in result["message"]
|
||||||
|
|
||||||
|
|
||||||
def test_validate_memory_scope_rejects_instr_marker_in_team_scope() -> None:
|
def test_validate_memory_scope_allows_old_marker_payload_in_team_scope() -> None:
|
||||||
content = "- (2026-04-10) [instr] Always respond in Spanish\n"
|
content = "- (2026-04-10) [pref] Legacy personal marker remains readable\n"
|
||||||
result = _validate_memory_scope(content, "team")
|
result, _warnings = validate_memory_scope(content, "team")
|
||||||
assert result is not None
|
assert result is None
|
||||||
assert result["status"] == "error"
|
|
||||||
assert "[instr]" in result["message"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_validate_memory_scope_rejects_both_personal_markers_in_team() -> None:
|
def test_validate_memory_scope_allows_team_headings() -> None:
|
||||||
|
content = "## Engineering Conventions\n- 2026-04-10: Uses PostgreSQL\n"
|
||||||
|
result, _warnings = validate_memory_scope(content, "team")
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_bullet_format_accepts_new_and_legacy_bullets() -> None:
|
||||||
content = (
|
content = (
|
||||||
"- (2026-04-10) [pref] Prefers dark mode\n"
|
"## Facts\n"
|
||||||
"- (2026-04-10) [instr] Always respond in Spanish\n"
|
"- 2026-04-10: Senior Python developer\n"
|
||||||
|
"- (2026-04-10) [fact] Legacy fact is preserved\n"
|
||||||
)
|
)
|
||||||
result = _validate_memory_scope(content, "team")
|
warnings = validate_bullet_format(content)
|
||||||
assert result is not None
|
|
||||||
assert result["status"] == "error"
|
|
||||||
assert "[instr]" in result["message"]
|
|
||||||
assert "[pref]" in result["message"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_validate_memory_scope_allows_fact_in_team_scope() -> None:
|
|
||||||
content = "- (2026-04-10) [fact] Office is in downtown Seattle\n"
|
|
||||||
result = _validate_memory_scope(content, "team")
|
|
||||||
assert result is None
|
|
||||||
|
|
||||||
|
|
||||||
def test_validate_memory_scope_allows_all_markers_in_user_scope() -> None:
|
|
||||||
content = (
|
|
||||||
"- (2026-04-10) [fact] Python developer\n"
|
|
||||||
"- (2026-04-10) [pref] Prefers concise answers\n"
|
|
||||||
"- (2026-04-10) [instr] Always use bullet points\n"
|
|
||||||
)
|
|
||||||
result = _validate_memory_scope(content, "user")
|
|
||||||
assert result is None
|
|
||||||
|
|
||||||
|
|
||||||
def test_validate_memory_scope_allows_any_heading_in_team() -> None:
|
|
||||||
content = "## Architecture\n- (2026-04-10) [fact] Uses PostgreSQL for persistence\n"
|
|
||||||
result = _validate_memory_scope(content, "team")
|
|
||||||
assert result is None
|
|
||||||
|
|
||||||
|
|
||||||
def test_validate_memory_scope_allows_any_heading_in_user() -> None:
|
|
||||||
content = "## My Projects\n- (2026-04-10) [fact] Working on SurfSense\n"
|
|
||||||
result = _validate_memory_scope(content, "user")
|
|
||||||
assert result is None
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# _validate_bullet_format
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def test_validate_bullet_format_passes_valid_bullets() -> None:
|
|
||||||
content = (
|
|
||||||
"## Work\n"
|
|
||||||
"- (2026-04-10) [fact] Senior Python developer\n"
|
|
||||||
"- (2026-04-10) [pref] Prefers dark mode\n"
|
|
||||||
"- (2026-04-10) [instr] Always respond in bullet points\n"
|
|
||||||
)
|
|
||||||
warnings = _validate_bullet_format(content)
|
|
||||||
assert warnings == []
|
assert warnings == []
|
||||||
|
|
||||||
|
|
||||||
def test_validate_bullet_format_warns_on_missing_marker() -> None:
|
def test_validate_bullet_format_warns_on_nonstandard_bullet() -> None:
|
||||||
content = "- (2026-04-10) Senior Python developer\n"
|
content = "## Facts\n- Senior Python developer\n"
|
||||||
warnings = _validate_bullet_format(content)
|
warnings = validate_bullet_format(content)
|
||||||
assert len(warnings) == 1
|
assert len(warnings) == 1
|
||||||
assert "Malformed bullet" in warnings[0]
|
assert "Non-standard memory bullet" in warnings[0]
|
||||||
|
|
||||||
|
|
||||||
def test_validate_bullet_format_warns_on_missing_date() -> None:
|
|
||||||
content = "- [fact] Senior Python developer\n"
|
|
||||||
warnings = _validate_bullet_format(content)
|
|
||||||
assert len(warnings) == 1
|
|
||||||
assert "Malformed bullet" in warnings[0]
|
|
||||||
|
|
||||||
|
|
||||||
def test_validate_bullet_format_warns_on_unknown_marker() -> None:
|
|
||||||
content = "- (2026-04-10) [context] Working on project X\n"
|
|
||||||
warnings = _validate_bullet_format(content)
|
|
||||||
assert len(warnings) == 1
|
|
||||||
assert "Malformed bullet" in warnings[0]
|
|
||||||
|
|
||||||
|
|
||||||
def test_validate_bullet_format_ignores_non_bullet_lines() -> None:
|
|
||||||
content = "## Some Heading\nSome paragraph text\n"
|
|
||||||
warnings = _validate_bullet_format(content)
|
|
||||||
assert warnings == []
|
|
||||||
|
|
||||||
|
|
||||||
def test_validate_bullet_format_warns_on_old_format_without_marker() -> None:
|
|
||||||
content = "## About the user\n- (2026-04-10) Likes cats\n"
|
|
||||||
warnings = _validate_bullet_format(content)
|
|
||||||
assert len(warnings) == 1
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# _save_memory — end-to-end with marker scope check
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_save_memory_blocks_pref_in_team_before_commit() -> None:
|
async def test_save_memory_normalizes_legacy_marker_bullets(monkeypatch) -> None:
|
||||||
recorder = _Recorder()
|
target = type("Target", (), {"memory_md": ""})()
|
||||||
result = await _save_memory(
|
session = _FakeSession()
|
||||||
updated_memory="- (2026-04-10) [pref] Prefers dark mode\n",
|
|
||||||
old_memory=None,
|
async def fake_load_target(**_kwargs):
|
||||||
llm=None,
|
return target
|
||||||
apply_fn=recorder.apply,
|
|
||||||
commit_fn=recorder.commit,
|
monkeypatch.setattr("app.services.memory.service._load_target", fake_load_target)
|
||||||
rollback_fn=recorder.rollback,
|
|
||||||
label="team memory",
|
result = await save_memory(
|
||||||
scope="team",
|
scope=MemoryScope.USER,
|
||||||
|
target_id="00000000-0000-0000-0000-000000000000",
|
||||||
|
content="- (2026-04-10) [fact] Legacy fact is preserved\n",
|
||||||
|
session=session,
|
||||||
)
|
)
|
||||||
assert result["status"] == "error"
|
|
||||||
assert recorder.commit_calls == 0
|
assert result.status == "saved"
|
||||||
assert recorder.applied_content is None
|
assert target.memory_md == "## Memory\n- 2026-04-10: Legacy fact is preserved"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_save_memory_allows_fact_in_team_and_commits() -> None:
|
async def test_save_memory_blocks_new_personal_heading_in_team_before_commit(
|
||||||
recorder = _Recorder()
|
monkeypatch,
|
||||||
content = "- (2026-04-10) [fact] Weekly standup on Mondays\n"
|
) -> None:
|
||||||
result = await _save_memory(
|
target = type("Target", (), {"shared_memory_md": ""})()
|
||||||
updated_memory=content,
|
session = _FakeSession()
|
||||||
old_memory=None,
|
|
||||||
llm=None,
|
async def fake_load_target(**_kwargs):
|
||||||
apply_fn=recorder.apply,
|
return target
|
||||||
commit_fn=recorder.commit,
|
|
||||||
rollback_fn=recorder.rollback,
|
monkeypatch.setattr("app.services.memory.service._load_target", fake_load_target)
|
||||||
label="team memory",
|
|
||||||
scope="team",
|
result = await save_memory(
|
||||||
|
scope=MemoryScope.TEAM,
|
||||||
|
target_id=1,
|
||||||
|
content="## Preferences\n- 2026-04-10: Prefers dark mode\n",
|
||||||
|
session=session,
|
||||||
)
|
)
|
||||||
assert result["status"] == "saved"
|
assert result.status == "error"
|
||||||
assert recorder.commit_calls == 1
|
assert session.commit_calls == 0
|
||||||
assert recorder.applied_content == content
|
assert target.shared_memory_md == ""
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_save_memory_includes_format_warnings() -> None:
|
async def test_save_memory_allows_grandfathered_personal_heading_in_team(
|
||||||
recorder = _Recorder()
|
monkeypatch,
|
||||||
content = "- (2026-04-10) Missing marker text\n"
|
) -> None:
|
||||||
result = await _save_memory(
|
content = "## Preferences\n- 2026-04-10: Prefers dark mode\n"
|
||||||
updated_memory=content,
|
target = type("Target", (), {"shared_memory_md": content})()
|
||||||
old_memory=None,
|
session = _FakeSession()
|
||||||
llm=None,
|
|
||||||
apply_fn=recorder.apply,
|
async def fake_load_target(**_kwargs):
|
||||||
commit_fn=recorder.commit,
|
return target
|
||||||
rollback_fn=recorder.rollback,
|
|
||||||
label="memory",
|
monkeypatch.setattr("app.services.memory.service._load_target", fake_load_target)
|
||||||
scope="user",
|
|
||||||
|
result = await save_memory(
|
||||||
|
scope=MemoryScope.TEAM,
|
||||||
|
target_id=1,
|
||||||
|
content=content,
|
||||||
|
session=session,
|
||||||
)
|
)
|
||||||
assert result["status"] == "saved"
|
assert result.status == "saved"
|
||||||
assert "format_warnings" in result
|
assert session.commit_calls == 1
|
||||||
assert len(result["format_warnings"]) == 1
|
assert target.shared_memory_md == content.strip()
|
||||||
|
assert result.warnings
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_save_memory_strips_preamble_before_heading(monkeypatch) -> None:
|
||||||
|
target = type("Target", (), {"memory_md": ""})()
|
||||||
|
session = _FakeSession()
|
||||||
|
|
||||||
|
async def fake_load_target(**_kwargs):
|
||||||
|
return target
|
||||||
|
|
||||||
|
monkeypatch.setattr("app.services.memory.service._load_target", fake_load_target)
|
||||||
|
|
||||||
|
result = await save_memory(
|
||||||
|
scope=MemoryScope.USER,
|
||||||
|
target_id="00000000-0000-0000-0000-000000000000",
|
||||||
|
content="Sure, here is the update:\n\n## Facts\n- 2026-04-10: Likes cats\n",
|
||||||
|
session=session,
|
||||||
|
)
|
||||||
|
assert result.status == "saved"
|
||||||
|
assert target.memory_md == "## Facts\n- 2026-04-10: Likes cats"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_save_memory_rejects_long_no_heading_payload(monkeypatch) -> None:
|
||||||
|
target = type("Target", (), {"memory_md": ""})()
|
||||||
|
session = _FakeSession()
|
||||||
|
|
||||||
|
async def fake_load_target(**_kwargs):
|
||||||
|
return target
|
||||||
|
|
||||||
|
monkeypatch.setattr("app.services.memory.service._load_target", fake_load_target)
|
||||||
|
|
||||||
|
result = await save_memory(
|
||||||
|
scope=MemoryScope.USER,
|
||||||
|
target_id="00000000-0000-0000-0000-000000000000",
|
||||||
|
content="NO_UPDATE because there is nothing durable to remember.",
|
||||||
|
session=session,
|
||||||
|
)
|
||||||
|
assert result.status == "error"
|
||||||
|
assert "## heading" in result.message
|
||||||
|
assert session.commit_calls == 0
|
||||||
|
|
|
||||||
187
surfsense_backend/tests/unit/services/test_memory_service.py
Normal file
187
surfsense_backend/tests/unit/services/test_memory_service.py
Normal file
|
|
@ -0,0 +1,187 @@
|
||||||
|
"""Unit tests for the first-class memory service."""
|
||||||
|
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.services.memory import (
|
||||||
|
MemoryScope,
|
||||||
|
reset_memory,
|
||||||
|
save_memory,
|
||||||
|
)
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.unit
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeSession:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.commit_calls = 0
|
||||||
|
self.rollback_calls = 0
|
||||||
|
self.added = []
|
||||||
|
|
||||||
|
def add(self, obj) -> None:
|
||||||
|
self.added.append(obj)
|
||||||
|
|
||||||
|
async def commit(self) -> None:
|
||||||
|
self.commit_calls += 1
|
||||||
|
|
||||||
|
async def rollback(self) -> None:
|
||||||
|
self.rollback_calls += 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_save_memory_saves_heading_based_memory(monkeypatch) -> None:
|
||||||
|
target = SimpleNamespace(memory_md="")
|
||||||
|
session = _FakeSession()
|
||||||
|
|
||||||
|
async def fake_load_target(**_kwargs):
|
||||||
|
return target
|
||||||
|
|
||||||
|
monkeypatch.setattr("app.services.memory.service._load_target", fake_load_target)
|
||||||
|
|
||||||
|
result = await save_memory(
|
||||||
|
scope=MemoryScope.USER,
|
||||||
|
target_id="00000000-0000-0000-0000-000000000000",
|
||||||
|
content="## Facts\n- 2026-05-19: Anish works on SurfSense\n",
|
||||||
|
session=session,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.status == "saved"
|
||||||
|
assert target.memory_md.startswith("## Facts")
|
||||||
|
assert session.commit_calls == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_save_memory_accepts_legacy_marker_payload(monkeypatch) -> None:
|
||||||
|
target = SimpleNamespace(memory_md="")
|
||||||
|
session = _FakeSession()
|
||||||
|
|
||||||
|
async def fake_load_target(**_kwargs):
|
||||||
|
return target
|
||||||
|
|
||||||
|
monkeypatch.setattr("app.services.memory.service._load_target", fake_load_target)
|
||||||
|
|
||||||
|
result = await save_memory(
|
||||||
|
scope=MemoryScope.USER,
|
||||||
|
target_id="00000000-0000-0000-0000-000000000000",
|
||||||
|
content="- (2026-05-19) [fact] Legacy marker memory\n",
|
||||||
|
session=session,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.status == "saved"
|
||||||
|
assert target.memory_md == "## Memory\n- 2026-05-19: Legacy marker memory"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_save_memory_rejects_long_no_heading_payload(monkeypatch) -> None:
|
||||||
|
target = SimpleNamespace(memory_md="## Facts\n- 2026-05-19: Existing\n")
|
||||||
|
session = _FakeSession()
|
||||||
|
|
||||||
|
async def fake_load_target(**_kwargs):
|
||||||
|
return target
|
||||||
|
|
||||||
|
monkeypatch.setattr("app.services.memory.service._load_target", fake_load_target)
|
||||||
|
|
||||||
|
result = await save_memory(
|
||||||
|
scope=MemoryScope.USER,
|
||||||
|
target_id="00000000-0000-0000-0000-000000000000",
|
||||||
|
content="reasoning text before NO_UPDATE should not become saved memory",
|
||||||
|
session=session,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.status == "error"
|
||||||
|
assert session.commit_calls == 0
|
||||||
|
assert target.memory_md.startswith("## Facts")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_save_memory_no_update_sentinel_is_no_op(monkeypatch) -> None:
|
||||||
|
existing = "## Preferences\n- 2026-05-20: Existing preference\n"
|
||||||
|
target = SimpleNamespace(memory_md=existing)
|
||||||
|
session = _FakeSession()
|
||||||
|
|
||||||
|
async def fake_load_target(**_kwargs):
|
||||||
|
return target
|
||||||
|
|
||||||
|
monkeypatch.setattr("app.services.memory.service._load_target", fake_load_target)
|
||||||
|
|
||||||
|
result = await save_memory(
|
||||||
|
scope=MemoryScope.USER,
|
||||||
|
target_id="00000000-0000-0000-0000-000000000000",
|
||||||
|
content="NO_UPDATE",
|
||||||
|
session=session,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.status == "no_op"
|
||||||
|
assert result.memory_md == existing
|
||||||
|
assert target.memory_md == existing
|
||||||
|
assert session.commit_calls == 0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_save_memory_no_update_sentinel_is_case_insensitive(monkeypatch) -> None:
|
||||||
|
existing = "## Preferences\n- 2026-05-20: Existing preference\n"
|
||||||
|
target = SimpleNamespace(memory_md=existing)
|
||||||
|
session = _FakeSession()
|
||||||
|
|
||||||
|
async def fake_load_target(**_kwargs):
|
||||||
|
return target
|
||||||
|
|
||||||
|
monkeypatch.setattr("app.services.memory.service._load_target", fake_load_target)
|
||||||
|
|
||||||
|
result = await save_memory(
|
||||||
|
scope=MemoryScope.USER,
|
||||||
|
target_id="00000000-0000-0000-0000-000000000000",
|
||||||
|
content=" no update ",
|
||||||
|
session=session,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.status == "no_op"
|
||||||
|
assert result.memory_md == existing
|
||||||
|
assert target.memory_md == existing
|
||||||
|
assert session.commit_calls == 0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_save_memory_grandfathers_existing_team_personal_heading(
|
||||||
|
monkeypatch,
|
||||||
|
) -> None:
|
||||||
|
content = "## Preferences\n- 2026-05-19: Existing legacy heading\n"
|
||||||
|
target = SimpleNamespace(shared_memory_md=content)
|
||||||
|
session = _FakeSession()
|
||||||
|
|
||||||
|
async def fake_load_target(**_kwargs):
|
||||||
|
return target
|
||||||
|
|
||||||
|
monkeypatch.setattr("app.services.memory.service._load_target", fake_load_target)
|
||||||
|
|
||||||
|
result = await save_memory(
|
||||||
|
scope=MemoryScope.TEAM,
|
||||||
|
target_id=1,
|
||||||
|
content=content,
|
||||||
|
session=session,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.status == "saved"
|
||||||
|
assert result.warnings
|
||||||
|
assert session.commit_calls == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_reset_memory_clears_memory(monkeypatch) -> None:
|
||||||
|
target = SimpleNamespace(memory_md="## Facts\n- 2026-05-19: Existing\n")
|
||||||
|
session = _FakeSession()
|
||||||
|
|
||||||
|
async def fake_load_target(**_kwargs):
|
||||||
|
return target
|
||||||
|
|
||||||
|
monkeypatch.setattr("app.services.memory.service._load_target", fake_load_target)
|
||||||
|
|
||||||
|
result = await reset_memory(
|
||||||
|
scope=MemoryScope.USER,
|
||||||
|
target_id="00000000-0000-0000-0000-000000000000",
|
||||||
|
session=session,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.status == "saved"
|
||||||
|
assert target.memory_md == ""
|
||||||
|
|
@ -89,7 +89,6 @@ async def test_stream_output_emits_text_lifecycle_and_updates_result() -> None:
|
||||||
"text_end:text-1",
|
"text_end:text-1",
|
||||||
]
|
]
|
||||||
assert result.accumulated_text == "Hello world"
|
assert result.accumulated_text == "Hello world"
|
||||||
assert result.agent_called_update_memory is False
|
|
||||||
|
|
||||||
|
|
||||||
async def test_stream_output_passes_runtime_context_to_agent() -> None:
|
async def test_stream_output_passes_runtime_context_to_agent() -> None:
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import { Logo } from "@/components/Logo";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { trackLoginAttempt } from "@/lib/posthog/events";
|
import { trackLoginAttempt } from "@/lib/posthog/events";
|
||||||
import { AmbientBackground } from "./AmbientBackground";
|
import { AmbientBackground } from "./AmbientBackground";
|
||||||
|
<<<<<<< HEAD
|
||||||
|
|
||||||
function GoogleGLogo({ className }: { className?: string }) {
|
function GoogleGLogo({ className }: { className?: string }) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -34,6 +35,9 @@ function GoogleGLogo({ className }: { className?: string }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
=======
|
||||||
|
import { BACKEND_URL } from "@/lib/env-config";
|
||||||
|
>>>>>>> 1127aedb4 (refactor(env): replace inline process.env reads with BACKEND_URL in editor, chat, dashboard and settings)
|
||||||
export function GoogleLoginButton() {
|
export function GoogleLoginButton() {
|
||||||
const t = useTranslations("auth");
|
const t = useTranslations("auth");
|
||||||
const [isRedirecting, setIsRedirecting] = useState(false);
|
const [isRedirecting, setIsRedirecting] = useState(false);
|
||||||
|
|
@ -50,7 +54,7 @@ export function GoogleLoginButton() {
|
||||||
// cross-origin fetch requests may not be sent on subsequent redirects.
|
// cross-origin fetch requests may not be sent on subsequent redirects.
|
||||||
// The authorize-redirect endpoint does a server-side redirect to Google
|
// The authorize-redirect endpoint does a server-side redirect to Google
|
||||||
// and sets the CSRF cookie properly for same-site context.
|
// and sets the CSRF cookie properly for same-site context.
|
||||||
window.location.href = `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/auth/google/authorize-redirect`;
|
window.location.href = `${BACKEND_URL}/auth/google/authorize-redirect`;
|
||||||
};
|
};
|
||||||
return (
|
return (
|
||||||
<div className="relative w-full overflow-hidden">
|
<div className="relative w-full overflow-hidden">
|
||||||
|
|
|
||||||
|
|
@ -4,11 +4,9 @@ import { NextResponse } from "next/server";
|
||||||
import type { Context } from "@/types/zero";
|
import type { Context } from "@/types/zero";
|
||||||
import { queries } from "@/zero/queries";
|
import { queries } from "@/zero/queries";
|
||||||
import { schema } from "@/zero/schema";
|
import { schema } from "@/zero/schema";
|
||||||
|
import { BACKEND_URL } from "@/lib/env-config";
|
||||||
|
|
||||||
const backendURL =
|
const backendURL = BACKEND_URL;
|
||||||
process.env.FASTAPI_BACKEND_INTERNAL_URL ||
|
|
||||||
process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL ||
|
|
||||||
"http://localhost:8000";
|
|
||||||
|
|
||||||
async function authenticateRequest(
|
async function authenticateRequest(
|
||||||
request: Request
|
request: Request
|
||||||
|
|
|
||||||
|
|
@ -118,7 +118,7 @@ import {
|
||||||
trackChatResponseReceived,
|
trackChatResponseReceived,
|
||||||
} from "@/lib/posthog/events";
|
} from "@/lib/posthog/events";
|
||||||
import Loading from "../loading";
|
import Loading from "../loading";
|
||||||
|
import { BACKEND_URL } from "@/lib/env-config";
|
||||||
const MobileEditorPanel = dynamic(
|
const MobileEditorPanel = dynamic(
|
||||||
() =>
|
() =>
|
||||||
import("@/components/editor-panel/editor-panel").then((m) => ({
|
import("@/components/editor-panel/editor-panel").then((m) => ({
|
||||||
|
|
@ -777,7 +777,7 @@ export default function NewChatPage() {
|
||||||
if (threadId) {
|
if (threadId) {
|
||||||
const token = getBearerToken();
|
const token = getBearerToken();
|
||||||
if (token) {
|
if (token) {
|
||||||
const backendUrl = process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL || "http://localhost:8000";
|
const backendUrl = BACKEND_URL;
|
||||||
try {
|
try {
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
`${backendUrl}/api/v1/threads/${threadId}/cancel-active-turn`,
|
`${backendUrl}/api/v1/threads/${threadId}/cancel-active-turn`,
|
||||||
|
|
@ -978,7 +978,7 @@ export default function NewChatPage() {
|
||||||
let streamBatcher: FrameBatchedUpdater | null = null;
|
let streamBatcher: FrameBatchedUpdater | null = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const backendUrl = process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL || "http://localhost:8000";
|
const backendUrl = BACKEND_URL;
|
||||||
const selection = await getAgentFilesystemSelection(searchSpaceId, {
|
const selection = await getAgentFilesystemSelection(searchSpaceId, {
|
||||||
localFilesystemEnabled,
|
localFilesystemEnabled,
|
||||||
});
|
});
|
||||||
|
|
@ -1520,7 +1520,7 @@ export default function NewChatPage() {
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const backendUrl = process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL || "http://localhost:8000";
|
const backendUrl = BACKEND_URL;
|
||||||
const selection = await getAgentFilesystemSelection(searchSpaceId, {
|
const selection = await getAgentFilesystemSelection(searchSpaceId, {
|
||||||
localFilesystemEnabled,
|
localFilesystemEnabled,
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@
|
||||||
import {
|
import {
|
||||||
BookText,
|
BookText,
|
||||||
Bot,
|
Bot,
|
||||||
Brain,
|
|
||||||
CircleUser,
|
CircleUser,
|
||||||
Earth,
|
Earth,
|
||||||
ImageIcon,
|
ImageIcon,
|
||||||
|
|
@ -27,7 +26,6 @@ export type SearchSpaceSettingsTab =
|
||||||
| "vision-models"
|
| "vision-models"
|
||||||
| "team-roles"
|
| "team-roles"
|
||||||
| "prompts"
|
| "prompts"
|
||||||
| "team-memory"
|
|
||||||
| "public-links";
|
| "public-links";
|
||||||
|
|
||||||
const DEFAULT_TAB: SearchSpaceSettingsTab = "general";
|
const DEFAULT_TAB: SearchSpaceSettingsTab = "general";
|
||||||
|
|
@ -89,11 +87,6 @@ export function SearchSpaceSettingsLayoutShell({
|
||||||
label: t("nav_system_instructions"),
|
label: t("nav_system_instructions"),
|
||||||
icon: <BookText className="h-4 w-4" />,
|
icon: <BookText className="h-4 w-4" />,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
value: "team-memory" as const,
|
|
||||||
label: "Team Memory",
|
|
||||||
icon: <Brain className="h-4 w-4" />,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
value: "public-links" as const,
|
value: "public-links" as const,
|
||||||
label: t("nav_public_links"),
|
label: t("nav_public_links"),
|
||||||
|
|
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
import { TeamMemoryManager } from "@/components/settings/team-memory-manager";
|
|
||||||
|
|
||||||
export default async function Page({ params }: { params: Promise<{ search_space_id: string }> }) {
|
|
||||||
const { search_space_id } = await params;
|
|
||||||
return <TeamMemoryManager searchSpaceId={Number(search_space_id)} />;
|
|
||||||
}
|
|
||||||
|
|
@ -1,293 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { useAtomValue } from "jotai";
|
|
||||||
import { ArrowUp, ChevronDown, ClipboardCopy, Download, Info, Pencil } from "lucide-react";
|
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import { z } from "zod";
|
|
||||||
import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms";
|
|
||||||
import { PlateEditor } from "@/components/editor/plate-editor";
|
|
||||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from "@/components/ui/dropdown-menu";
|
|
||||||
import { Spinner } from "@/components/ui/spinner";
|
|
||||||
|
|
||||||
import { baseApiService } from "@/lib/apis/base-api.service";
|
|
||||||
|
|
||||||
const MEMORY_HARD_LIMIT = 25_000;
|
|
||||||
|
|
||||||
const MemoryReadSchema = z.object({
|
|
||||||
memory_md: z.string(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export function MemoryContent() {
|
|
||||||
const activeSearchSpaceId = useAtomValue(activeSearchSpaceIdAtom);
|
|
||||||
const [memory, setMemory] = useState("");
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [saving, setSaving] = useState(false);
|
|
||||||
const [editQuery, setEditQuery] = useState("");
|
|
||||||
const [editing, setEditing] = useState(false);
|
|
||||||
const [showInput, setShowInput] = useState(false);
|
|
||||||
const textareaRef = useRef<HTMLInputElement>(null);
|
|
||||||
const inputContainerRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
const fetchMemory = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
setLoading(true);
|
|
||||||
const data = await baseApiService.get("/api/v1/users/me/memory", MemoryReadSchema);
|
|
||||||
setMemory(data.memory_md);
|
|
||||||
} catch {
|
|
||||||
toast.error("Failed to load memory");
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetchMemory();
|
|
||||||
}, [fetchMemory]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!showInput) return;
|
|
||||||
|
|
||||||
const handlePointerDownOutside = (event: MouseEvent | TouchEvent) => {
|
|
||||||
const target = event.target;
|
|
||||||
if (!(target instanceof Node)) return;
|
|
||||||
if (inputContainerRef.current?.contains(target)) return;
|
|
||||||
|
|
||||||
setShowInput(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
document.addEventListener("mousedown", handlePointerDownOutside);
|
|
||||||
document.addEventListener("touchstart", handlePointerDownOutside, { passive: true });
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
document.removeEventListener("mousedown", handlePointerDownOutside);
|
|
||||||
document.removeEventListener("touchstart", handlePointerDownOutside);
|
|
||||||
};
|
|
||||||
}, [showInput]);
|
|
||||||
|
|
||||||
const handleClear = async () => {
|
|
||||||
try {
|
|
||||||
setSaving(true);
|
|
||||||
const data = await baseApiService.put("/api/v1/users/me/memory", MemoryReadSchema, {
|
|
||||||
body: { memory_md: "" },
|
|
||||||
});
|
|
||||||
setMemory(data.memory_md);
|
|
||||||
toast.success("Memory cleared");
|
|
||||||
} catch {
|
|
||||||
toast.error("Failed to clear memory");
|
|
||||||
} finally {
|
|
||||||
setSaving(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleEdit = async () => {
|
|
||||||
const query = editQuery.trim();
|
|
||||||
if (!query) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
setEditing(true);
|
|
||||||
const data = await baseApiService.post("/api/v1/users/me/memory/edit", MemoryReadSchema, {
|
|
||||||
body: { query, search_space_id: Number(activeSearchSpaceId) },
|
|
||||||
});
|
|
||||||
setMemory(data.memory_md);
|
|
||||||
setEditQuery("");
|
|
||||||
setShowInput(false);
|
|
||||||
toast.success("Memory updated");
|
|
||||||
} catch {
|
|
||||||
toast.error("Failed to edit memory");
|
|
||||||
} finally {
|
|
||||||
setEditing(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const openInput = () => {
|
|
||||||
setShowInput(true);
|
|
||||||
requestAnimationFrame(() => textareaRef.current?.focus());
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDownload = () => {
|
|
||||||
if (!memory) return;
|
|
||||||
try {
|
|
||||||
const blob = new Blob([memory], { type: "text/markdown;charset=utf-8" });
|
|
||||||
const url = URL.createObjectURL(blob);
|
|
||||||
const a = document.createElement("a");
|
|
||||||
a.href = url;
|
|
||||||
a.download = "personal-memory.md";
|
|
||||||
document.body.appendChild(a);
|
|
||||||
a.click();
|
|
||||||
document.body.removeChild(a);
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
} catch {
|
|
||||||
toast.error("Failed to download memory");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCopyMarkdown = async () => {
|
|
||||||
if (!memory) return;
|
|
||||||
try {
|
|
||||||
await navigator.clipboard.writeText(memory);
|
|
||||||
toast.success("Copied to clipboard");
|
|
||||||
} catch {
|
|
||||||
toast.error("Failed to copy memory");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
|
||||||
if (e.key === "Enter" && !e.shiftKey) {
|
|
||||||
e.preventDefault();
|
|
||||||
handleEdit();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const displayMemory = memory.replace(/\(\d{4}-\d{2}-\d{2}\)\s*\[(fact|pref|instr)\]\s*/g, "");
|
|
||||||
const charCount = memory.length;
|
|
||||||
|
|
||||||
const getCounterColor = () => {
|
|
||||||
if (charCount > MEMORY_HARD_LIMIT) return "text-red-500";
|
|
||||||
if (charCount > 15_000) return "text-orange-500";
|
|
||||||
if (charCount > 10_000) return "text-yellow-500";
|
|
||||||
return "text-muted-foreground";
|
|
||||||
};
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-center py-12">
|
|
||||||
<Spinner size="md" className="text-muted-foreground" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!memory) {
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col items-center justify-center py-16 text-center">
|
|
||||||
<h3 className="text-base font-medium text-foreground">What does SurfSense remember?</h3>
|
|
||||||
<p className="mt-2 max-w-sm text-sm text-muted-foreground">
|
|
||||||
Nothing yet. SurfSense picks up on your preferences and context as you chat.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<Alert>
|
|
||||||
<Info />
|
|
||||||
<AlertDescription>
|
|
||||||
<p>
|
|
||||||
SurfSense uses this personal memory to personalize your responses across all
|
|
||||||
conversations.
|
|
||||||
</p>
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
|
|
||||||
<div className="relative h-[380px] rounded-lg border bg-background">
|
|
||||||
<div className="h-full overflow-y-auto scrollbar-thin">
|
|
||||||
<PlateEditor
|
|
||||||
markdown={displayMemory}
|
|
||||||
readOnly
|
|
||||||
preset="readonly"
|
|
||||||
variant="default"
|
|
||||||
editorVariant="none"
|
|
||||||
className="px-5 py-4 text-sm min-h-full"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{showInput ? (
|
|
||||||
<div className="absolute bottom-3 inset-x-3 z-10">
|
|
||||||
<div
|
|
||||||
ref={inputContainerRef}
|
|
||||||
className="relative flex h-[54px] items-center gap-2 rounded-[9999px] border bg-muted/60 backdrop-blur-sm pl-4 pr-1 shadow-sm"
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
ref={textareaRef}
|
|
||||||
type="text"
|
|
||||||
value={editQuery}
|
|
||||||
onChange={(e) => setEditQuery(e.target.value)}
|
|
||||||
onKeyDown={handleKeyDown}
|
|
||||||
placeholder="Tell SurfSense what to remember or forget"
|
|
||||||
disabled={editing}
|
|
||||||
className="flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground/70"
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
size="icon"
|
|
||||||
variant="ghost"
|
|
||||||
onClick={handleEdit}
|
|
||||||
disabled={editing || !editQuery.trim()}
|
|
||||||
className={`h-11 w-11 shrink-0 rounded-full ${
|
|
||||||
editing
|
|
||||||
? ""
|
|
||||||
: "bg-muted-foreground/15 hover:bg-accent hover:text-accent-foreground"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{editing ? (
|
|
||||||
<Spinner size="sm" />
|
|
||||||
) : (
|
|
||||||
<ArrowUp className="!h-5 !w-5 text-foreground" strokeWidth={2.25} />
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
size="icon"
|
|
||||||
variant="secondary"
|
|
||||||
onClick={openInput}
|
|
||||||
className="absolute bottom-3 right-3 z-10 h-[54px] w-[54px] rounded-full border bg-muted/60 backdrop-blur-sm shadow-sm"
|
|
||||||
>
|
|
||||||
<Pencil className="!h-5 !w-5" />
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center justify-between gap-2">
|
|
||||||
<span className={`text-xs shrink-0 ${getCounterColor()}`}>
|
|
||||||
{charCount.toLocaleString()} / {MEMORY_HARD_LIMIT.toLocaleString()}
|
|
||||||
<span className="hidden sm:inline"> characters</span>
|
|
||||||
<span className="sm:hidden"> chars</span>
|
|
||||||
{charCount > 15_000 && charCount <= MEMORY_HARD_LIMIT && " - Approaching limit"}
|
|
||||||
{charCount > MEMORY_HARD_LIMIT && " - Exceeds limit"}
|
|
||||||
</span>
|
|
||||||
<div className="flex items-center gap-1.5 sm:gap-2">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="destructive"
|
|
||||||
size="sm"
|
|
||||||
className="text-xs sm:text-sm"
|
|
||||||
onClick={handleClear}
|
|
||||||
disabled={saving || editing || !memory}
|
|
||||||
>
|
|
||||||
<span className="hidden sm:inline">Reset Memory</span>
|
|
||||||
<span className="sm:hidden">Reset</span>
|
|
||||||
</Button>
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<Button type="button" variant="secondary" size="sm" disabled={!memory}>
|
|
||||||
Export
|
|
||||||
<ChevronDown className="h-3 w-3 opacity-60" />
|
|
||||||
</Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="end">
|
|
||||||
<DropdownMenuItem onClick={handleCopyMarkdown}>
|
|
||||||
<ClipboardCopy className="h-4 w-4 mr-2" />
|
|
||||||
Copy as Markdown
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem onClick={handleDownload}>
|
|
||||||
<Download className="h-4 w-4 mr-2" />
|
|
||||||
Download as Markdown
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Brain,
|
|
||||||
CircleUser,
|
CircleUser,
|
||||||
Keyboard,
|
Keyboard,
|
||||||
KeyRound,
|
KeyRound,
|
||||||
|
|
@ -26,7 +25,6 @@ export type UserSettingsTab =
|
||||||
| "api-key"
|
| "api-key"
|
||||||
| "prompts"
|
| "prompts"
|
||||||
| "community-prompts"
|
| "community-prompts"
|
||||||
| "memory"
|
|
||||||
| "agent-permissions"
|
| "agent-permissions"
|
||||||
| "agent-status"
|
| "agent-status"
|
||||||
| "purchases"
|
| "purchases"
|
||||||
|
|
@ -75,11 +73,6 @@ export function UserSettingsLayoutShell({ searchSpaceId, children }: UserSetting
|
||||||
label: "Community Prompts",
|
label: "Community Prompts",
|
||||||
icon: <Library className="h-4 w-4" />,
|
icon: <Library className="h-4 w-4" />,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
value: "memory" as const,
|
|
||||||
label: "Memory",
|
|
||||||
icon: <Brain className="h-4 w-4" />,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
value: "agent-permissions" as const,
|
value: "agent-permissions" as const,
|
||||||
label: "Agent Permissions",
|
label: "Agent Permissions",
|
||||||
|
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
import { MemoryContent } from "../components/MemoryContent";
|
|
||||||
|
|
||||||
export default function Page() {
|
|
||||||
return <MemoryContent />;
|
|
||||||
}
|
|
||||||
|
|
@ -3,10 +3,11 @@ import { rightPanelCollapsedAtom, rightPanelTabAtom } from "@/atoms/layout/right
|
||||||
|
|
||||||
interface EditorPanelState {
|
interface EditorPanelState {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
kind: "document" | "local_file";
|
kind: "document" | "local_file" | "memory";
|
||||||
documentId: number | null;
|
documentId: number | null;
|
||||||
localFilePath: string | null;
|
localFilePath: string | null;
|
||||||
searchSpaceId: number | null;
|
searchSpaceId: number | null;
|
||||||
|
memoryScope: "user" | "team" | null;
|
||||||
title: string | null;
|
title: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -16,6 +17,7 @@ const initialState: EditorPanelState = {
|
||||||
documentId: null,
|
documentId: null,
|
||||||
localFilePath: null,
|
localFilePath: null,
|
||||||
searchSpaceId: null,
|
searchSpaceId: null,
|
||||||
|
memoryScope: null,
|
||||||
title: null,
|
title: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -38,6 +40,12 @@ export const openEditorPanelAtom = atom(
|
||||||
title?: string;
|
title?: string;
|
||||||
searchSpaceId?: number;
|
searchSpaceId?: number;
|
||||||
}
|
}
|
||||||
|
| {
|
||||||
|
kind: "memory";
|
||||||
|
memoryScope: "user" | "team";
|
||||||
|
title?: string;
|
||||||
|
searchSpaceId?: number;
|
||||||
|
}
|
||||||
) => {
|
) => {
|
||||||
if (!get(editorPanelAtom).isOpen) {
|
if (!get(editorPanelAtom).isOpen) {
|
||||||
set(preEditorCollapsedAtom, get(rightPanelCollapsedAtom));
|
set(preEditorCollapsedAtom, get(rightPanelCollapsedAtom));
|
||||||
|
|
@ -49,6 +57,21 @@ export const openEditorPanelAtom = atom(
|
||||||
documentId: null,
|
documentId: null,
|
||||||
localFilePath: payload.localFilePath,
|
localFilePath: payload.localFilePath,
|
||||||
searchSpaceId: payload.searchSpaceId ?? null,
|
searchSpaceId: payload.searchSpaceId ?? null,
|
||||||
|
memoryScope: null,
|
||||||
|
title: payload.title ?? null,
|
||||||
|
});
|
||||||
|
set(rightPanelTabAtom, "editor");
|
||||||
|
set(rightPanelCollapsedAtom, false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (payload.kind === "memory") {
|
||||||
|
set(editorPanelAtom, {
|
||||||
|
isOpen: true,
|
||||||
|
kind: "memory",
|
||||||
|
documentId: null,
|
||||||
|
localFilePath: null,
|
||||||
|
searchSpaceId: payload.searchSpaceId ?? null,
|
||||||
|
memoryScope: payload.memoryScope,
|
||||||
title: payload.title ?? null,
|
title: payload.title ?? null,
|
||||||
});
|
});
|
||||||
set(rightPanelTabAtom, "editor");
|
set(rightPanelTabAtom, "editor");
|
||||||
|
|
@ -61,6 +84,7 @@ export const openEditorPanelAtom = atom(
|
||||||
documentId: payload.documentId,
|
documentId: payload.documentId,
|
||||||
localFilePath: null,
|
localFilePath: null,
|
||||||
searchSpaceId: payload.searchSpaceId,
|
searchSpaceId: payload.searchSpaceId,
|
||||||
|
memoryScope: null,
|
||||||
title: payload.title ?? null,
|
title: payload.title ?? null,
|
||||||
});
|
});
|
||||||
set(rightPanelTabAtom, "editor");
|
set(rightPanelTabAtom, "editor");
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ import {
|
||||||
ClipboardPaste,
|
ClipboardPaste,
|
||||||
CopyIcon,
|
CopyIcon,
|
||||||
DownloadIcon,
|
DownloadIcon,
|
||||||
|
Dot,
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
Globe,
|
Globe,
|
||||||
MessageCircleReply,
|
MessageCircleReply,
|
||||||
|
|
@ -330,9 +331,14 @@ const MessageInfoDropdown: FC<{ chatTurnId: string | null | undefined }> = ({ ch
|
||||||
{icon}
|
{icon}
|
||||||
{name}
|
{name}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="flex items-center text-xs text-muted-foreground">
|
||||||
{counts.total_tokens.toLocaleString()} tokens
|
<span>{counts.total_tokens.toLocaleString()} tokens</span>
|
||||||
{costMicros && costMicros > 0 ? ` · ${formatTurnCost(costMicros)}` : ""}
|
{costMicros && costMicros > 0 ? (
|
||||||
|
<>
|
||||||
|
<Dot className="size-4 shrink-0" aria-hidden="true" />
|
||||||
|
<span>{formatTurnCost(costMicros)}</span>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
</span>
|
</span>
|
||||||
</ActionBarMorePrimitive.Item>
|
</ActionBarMorePrimitive.Item>
|
||||||
);
|
);
|
||||||
|
|
@ -342,11 +348,14 @@ const MessageInfoDropdown: FC<{ chatTurnId: string | null | undefined }> = ({ ch
|
||||||
className="focus:bg-accent focus:text-accent-foreground relative flex cursor-default flex-col items-start gap-0.5 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none"
|
className="focus:bg-accent focus:text-accent-foreground relative flex cursor-default flex-col items-start gap-0.5 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none"
|
||||||
onSelect={(e) => e.preventDefault()}
|
onSelect={(e) => e.preventDefault()}
|
||||||
>
|
>
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="flex items-center text-xs text-muted-foreground">
|
||||||
{usage.total_tokens.toLocaleString()} tokens
|
<span>{usage.total_tokens.toLocaleString()} tokens</span>
|
||||||
{usage.cost_micros && usage.cost_micros > 0
|
{usage.cost_micros && usage.cost_micros > 0 ? (
|
||||||
? ` · ${formatTurnCost(usage.cost_micros)}`
|
<>
|
||||||
: ""}
|
<Dot className="size-4 shrink-0" aria-hidden="true" />
|
||||||
|
<span>{formatTurnCost(usage.cost_micros)}</span>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
</span>
|
</span>
|
||||||
</ActionBarMorePrimitive.Item>
|
</ActionBarMorePrimitive.Item>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -8,10 +8,12 @@ import { EnumConnectorName } from "@/contracts/enums/connector";
|
||||||
import { useApiKey } from "@/hooks/use-api-key";
|
import { useApiKey } from "@/hooks/use-api-key";
|
||||||
import { getConnectorBenefits } from "../connector-benefits";
|
import { getConnectorBenefits } from "../connector-benefits";
|
||||||
import type { ConnectFormProps } from "../index";
|
import type { ConnectFormProps } from "../index";
|
||||||
|
import { BACKEND_URL } from "@/lib/env-config";
|
||||||
|
|
||||||
const PLUGIN_RELEASES_URL =
|
const PLUGIN_RELEASES_URL =
|
||||||
"https://github.com/MODSetter/SurfSense/releases?q=obsidian&expanded=true";
|
"https://github.com/MODSetter/SurfSense/releases?q=obsidian&expanded=true";
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Obsidian connect form for the plugin-only architecture.
|
* Obsidian connect form for the plugin-only architecture.
|
||||||
*
|
*
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { authenticatedFetch } from "@/lib/auth-utils";
|
import { authenticatedFetch } from "@/lib/auth-utils";
|
||||||
import type { ConnectorConfigProps } from "../index";
|
import type { ConnectorConfigProps } from "../index";
|
||||||
|
import { BACKEND_URL } from "@/lib/env-config";
|
||||||
export interface CirclebackConfigProps extends ConnectorConfigProps {
|
export interface CirclebackConfigProps extends ConnectorConfigProps {
|
||||||
onNameChange?: (name: string) => void;
|
onNameChange?: (name: string) => void;
|
||||||
}
|
}
|
||||||
|
|
@ -42,7 +42,7 @@ export const CirclebackConfig: FC<CirclebackConfigProps> = ({ connector, onNameC
|
||||||
const doFetch = async () => {
|
const doFetch = async () => {
|
||||||
if (!connector.search_space_id) return;
|
if (!connector.search_space_id) return;
|
||||||
|
|
||||||
const baseUrl = process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL;
|
const baseUrl = BACKEND_URL;
|
||||||
if (!baseUrl) {
|
if (!baseUrl) {
|
||||||
console.error("NEXT_PUBLIC_FASTAPI_BACKEND_URL is not configured");
|
console.error("NEXT_PUBLIC_FASTAPI_BACKEND_URL is not configured");
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@ import { getReauthEndpoint, LIVE_CONNECTOR_TYPES } from "../../constants/connect
|
||||||
import { getConnectorDisplayName } from "../../tabs/all-connectors-tab";
|
import { getConnectorDisplayName } from "../../tabs/all-connectors-tab";
|
||||||
import { MCPServiceConfig } from "../components/mcp-service-config";
|
import { MCPServiceConfig } from "../components/mcp-service-config";
|
||||||
import { getConnectorConfigComponent } from "../index";
|
import { getConnectorConfigComponent } from "../index";
|
||||||
|
import { BACKEND_URL } from "@/lib/env-config";
|
||||||
const VISION_LLM_CONNECTOR_TYPES = new Set<SearchSourceConnector["connector_type"]>([
|
const VISION_LLM_CONNECTOR_TYPES = new Set<SearchSourceConnector["connector_type"]>([
|
||||||
EnumConnectorName.GOOGLE_DRIVE_CONNECTOR,
|
EnumConnectorName.GOOGLE_DRIVE_CONNECTOR,
|
||||||
EnumConnectorName.COMPOSIO_GOOGLE_DRIVE_CONNECTOR,
|
EnumConnectorName.COMPOSIO_GOOGLE_DRIVE_CONNECTOR,
|
||||||
|
|
@ -94,7 +94,7 @@ export const ConnectorEditView: FC<ConnectorEditViewProps> = ({
|
||||||
if (!spaceId || !reauthEndpoint) return;
|
if (!spaceId || !reauthEndpoint) return;
|
||||||
setReauthing(true);
|
setReauthing(true);
|
||||||
try {
|
try {
|
||||||
const backendUrl = process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL || "http://localhost:8000";
|
const backendUrl = BACKEND_URL;
|
||||||
const url = new URL(`${backendUrl}${reauthEndpoint}`);
|
const url = new URL(`${backendUrl}${reauthEndpoint}`);
|
||||||
url.searchParams.set("connector_id", String(connector.id));
|
url.searchParams.set("connector_id", String(connector.id));
|
||||||
url.searchParams.set("space_id", String(spaceId));
|
url.searchParams.set("space_id", String(spaceId));
|
||||||
|
|
|
||||||
|
|
@ -43,7 +43,7 @@ import {
|
||||||
parseOAuthAuthResponse,
|
parseOAuthAuthResponse,
|
||||||
validateIndexingConfigState,
|
validateIndexingConfigState,
|
||||||
} from "../constants/connector-popup.schemas";
|
} from "../constants/connector-popup.schemas";
|
||||||
|
import { BACKEND_URL } from "@/lib/env-config";
|
||||||
const OAUTH_RESULT_COOKIE = "connector_oauth_result";
|
const OAUTH_RESULT_COOKIE = "connector_oauth_result";
|
||||||
|
|
||||||
function readOAuthResultCookie(): string | null {
|
function readOAuthResultCookie(): string | null {
|
||||||
|
|
@ -364,7 +364,7 @@ export const useConnectorDialog = () => {
|
||||||
try {
|
try {
|
||||||
// Check if authEndpoint already has query parameters
|
// Check if authEndpoint already has query parameters
|
||||||
const separator = connector.authEndpoint.includes("?") ? "&" : "?";
|
const separator = connector.authEndpoint.includes("?") ? "&" : "?";
|
||||||
const url = `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}${connector.authEndpoint}${separator}space_id=${searchSpaceId}`;
|
const url = `${BACKEND_URL}${connector.authEndpoint}${separator}space_id=${searchSpaceId}`;
|
||||||
|
|
||||||
const response = await authenticatedFetch(url, { method: "GET" });
|
const response = await authenticatedFetch(url, { method: "GET" });
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ import { cn } from "@/lib/utils";
|
||||||
import { getReauthEndpoint, LIVE_CONNECTOR_TYPES } from "../constants/connector-constants";
|
import { getReauthEndpoint, LIVE_CONNECTOR_TYPES } from "../constants/connector-constants";
|
||||||
import { useConnectorStatus } from "../hooks/use-connector-status";
|
import { useConnectorStatus } from "../hooks/use-connector-status";
|
||||||
import { getConnectorDisplayName } from "../tabs/all-connectors-tab";
|
import { getConnectorDisplayName } from "../tabs/all-connectors-tab";
|
||||||
|
import { BACKEND_URL } from "@/lib/env-config";
|
||||||
interface ConnectorAccountsListViewProps {
|
interface ConnectorAccountsListViewProps {
|
||||||
connectorType: string;
|
connectorType: string;
|
||||||
connectorTitle: string;
|
connectorTitle: string;
|
||||||
|
|
@ -59,7 +59,7 @@ export const ConnectorAccountsListView: FC<ConnectorAccountsListViewProps> = ({
|
||||||
if (!searchSpaceId || !endpoint) return;
|
if (!searchSpaceId || !endpoint) return;
|
||||||
setReauthingId(connector.id);
|
setReauthingId(connector.id);
|
||||||
try {
|
try {
|
||||||
const backendUrl = process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL || "http://localhost:8000";
|
const backendUrl = BACKEND_URL;
|
||||||
const url = new URL(`${backendUrl}${endpoint}`);
|
const url = new URL(`${backendUrl}${endpoint}`);
|
||||||
url.searchParams.set("connector_id", String(connector.id));
|
url.searchParams.set("connector_id", String(connector.id));
|
||||||
url.searchParams.set("space_id", String(searchSpaceId));
|
url.searchParams.set("space_id", String(searchSpaceId));
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import {
|
||||||
MoreHorizontal,
|
MoreHorizontal,
|
||||||
Move,
|
Move,
|
||||||
Pencil,
|
Pencil,
|
||||||
|
RotateCcw,
|
||||||
Trash2,
|
Trash2,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import React, { useCallback, useRef, useState } from "react";
|
import React, { useCallback, useRef, useState } from "react";
|
||||||
|
|
@ -61,8 +62,13 @@ interface DocumentNodeProps {
|
||||||
onEdit: (doc: DocumentNodeDoc) => void;
|
onEdit: (doc: DocumentNodeDoc) => void;
|
||||||
onDelete: (doc: DocumentNodeDoc) => void;
|
onDelete: (doc: DocumentNodeDoc) => void;
|
||||||
onMove: (doc: DocumentNodeDoc) => void;
|
onMove: (doc: DocumentNodeDoc) => void;
|
||||||
|
onReset?: (doc: DocumentNodeDoc) => void;
|
||||||
onExport?: (doc: DocumentNodeDoc, format: string) => void;
|
onExport?: (doc: DocumentNodeDoc, format: string) => void;
|
||||||
onVersionHistory?: (doc: DocumentNodeDoc) => void;
|
onVersionHistory?: (doc: DocumentNodeDoc) => void;
|
||||||
|
canDelete?: boolean;
|
||||||
|
canMove?: boolean;
|
||||||
|
canMention?: boolean;
|
||||||
|
canEdit?: boolean;
|
||||||
contextMenuOpen?: boolean;
|
contextMenuOpen?: boolean;
|
||||||
onContextMenuOpenChange?: (open: boolean) => void;
|
onContextMenuOpenChange?: (open: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
@ -76,8 +82,13 @@ export const DocumentNode = React.memo(function DocumentNode({
|
||||||
onEdit,
|
onEdit,
|
||||||
onDelete,
|
onDelete,
|
||||||
onMove,
|
onMove,
|
||||||
|
onReset,
|
||||||
onExport,
|
onExport,
|
||||||
onVersionHistory,
|
onVersionHistory,
|
||||||
|
canDelete = true,
|
||||||
|
canMove = true,
|
||||||
|
canMention = true,
|
||||||
|
canEdit = true,
|
||||||
contextMenuOpen,
|
contextMenuOpen,
|
||||||
onContextMenuOpenChange,
|
onContextMenuOpenChange,
|
||||||
}: DocumentNodeProps) {
|
}: DocumentNodeProps) {
|
||||||
|
|
@ -85,8 +96,13 @@ export const DocumentNode = React.memo(function DocumentNode({
|
||||||
const isFailed = statusState === "failed";
|
const isFailed = statusState === "failed";
|
||||||
const isProcessing = statusState === "pending" || statusState === "processing";
|
const isProcessing = statusState === "pending" || statusState === "processing";
|
||||||
const isUnavailable = isProcessing || isFailed;
|
const isUnavailable = isProcessing || isFailed;
|
||||||
const isSelectable = !isUnavailable;
|
const isMemoryDocument =
|
||||||
const isEditable = EDITABLE_DOCUMENT_TYPES.has(doc.document_type) && !isUnavailable;
|
doc.document_type === "USER_MEMORY" || doc.document_type === "TEAM_MEMORY";
|
||||||
|
const isSelectable = canMention && !isUnavailable;
|
||||||
|
const isEditable =
|
||||||
|
canEdit &&
|
||||||
|
(isMemoryDocument || EDITABLE_DOCUMENT_TYPES.has(doc.document_type)) &&
|
||||||
|
!isUnavailable;
|
||||||
|
|
||||||
const handleCheckChange = useCallback(() => {
|
const handleCheckChange = useCallback(() => {
|
||||||
if (isSelectable) {
|
if (isSelectable) {
|
||||||
|
|
@ -94,13 +110,22 @@ export const DocumentNode = React.memo(function DocumentNode({
|
||||||
}
|
}
|
||||||
}, [doc, isMentioned, isSelectable, onToggleChatMention]);
|
}, [doc, isMentioned, isSelectable, onToggleChatMention]);
|
||||||
|
|
||||||
|
const handlePrimaryClick = useCallback(() => {
|
||||||
|
if (canMention) {
|
||||||
|
handleCheckChange();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onPreview(doc);
|
||||||
|
}, [canMention, doc, handleCheckChange, onPreview]);
|
||||||
|
|
||||||
const [{ isDragging }, drag] = useDrag(
|
const [{ isDragging }, drag] = useDrag(
|
||||||
() => ({
|
() => ({
|
||||||
type: DND_TYPES.DOCUMENT,
|
type: DND_TYPES.DOCUMENT,
|
||||||
item: { id: doc.id },
|
item: { id: doc.id },
|
||||||
|
canDrag: canMove,
|
||||||
collect: (monitor) => ({ isDragging: monitor.isDragging() }),
|
collect: (monitor) => ({ isDragging: monitor.isDragging() }),
|
||||||
}),
|
}),
|
||||||
[doc.id]
|
[canMove, doc.id]
|
||||||
);
|
);
|
||||||
|
|
||||||
const [dropdownOpen, setDropdownOpen] = useState(false);
|
const [dropdownOpen, setDropdownOpen] = useState(false);
|
||||||
|
|
@ -130,9 +155,11 @@ export const DocumentNode = React.memo(function DocumentNode({
|
||||||
const attachRef = useCallback(
|
const attachRef = useCallback(
|
||||||
(node: HTMLDivElement | null) => {
|
(node: HTMLDivElement | null) => {
|
||||||
(rowRef as React.MutableRefObject<HTMLDivElement | null>).current = node;
|
(rowRef as React.MutableRefObject<HTMLDivElement | null>).current = node;
|
||||||
|
if (canMove) {
|
||||||
drag(node);
|
drag(node);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[drag]
|
[canMove, drag]
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -187,12 +214,32 @@ export const DocumentNode = React.memo(function DocumentNode({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
|
{isMemoryDocument ? (
|
||||||
|
<span aria-disabled="true" className="h-3.5 w-3.5 shrink-0 cursor-default">
|
||||||
|
<Checkbox
|
||||||
|
checked={false}
|
||||||
|
disabled
|
||||||
|
aria-disabled
|
||||||
|
className="h-3.5 w-3.5 pointer-events-none"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
) : canMention ? (
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={isMentioned}
|
checked={isMentioned}
|
||||||
onCheckedChange={handleCheckChange}
|
onCheckedChange={handleCheckChange}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
className="h-3.5 w-3.5 shrink-0"
|
className="h-3.5 w-3.5 shrink-0"
|
||||||
/>
|
/>
|
||||||
|
) : (
|
||||||
|
<span className="flex h-3.5 w-3.5 shrink-0 items-center justify-center">
|
||||||
|
{getDocumentTypeIcon(
|
||||||
|
doc.document_type as DocumentTypeEnum,
|
||||||
|
"h-3.5 w-3.5 text-muted-foreground"
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
|
|
||||||
|
|
@ -205,8 +252,8 @@ export const DocumentNode = React.memo(function DocumentNode({
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
aria-disabled={!isSelectable}
|
aria-disabled={canMention ? !isSelectable : false}
|
||||||
onClick={handleCheckChange}
|
onClick={handlePrimaryClick}
|
||||||
className="h-full min-w-0 flex-1 justify-start bg-transparent px-0 py-0 text-left font-normal text-inherit hover:bg-transparent hover:text-inherit"
|
className="h-full min-w-0 flex-1 justify-start bg-transparent px-0 py-0 text-left font-normal text-inherit hover:bg-transparent hover:text-inherit"
|
||||||
>
|
>
|
||||||
<span ref={titleRef} className="min-w-0 flex-1 truncate">
|
<span ref={titleRef} className="min-w-0 flex-1 truncate">
|
||||||
|
|
@ -268,11 +315,18 @@ export const DocumentNode = React.memo(function DocumentNode({
|
||||||
Edit
|
Edit
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
|
{canMove && (
|
||||||
<DropdownMenuItem onClick={() => onMove(doc)}>
|
<DropdownMenuItem onClick={() => onMove(doc)}>
|
||||||
<Move className="mr-2 h-4 w-4" />
|
<Move className="mr-2 h-4 w-4" />
|
||||||
Move to...
|
Move to...
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
{onExport && (
|
)}
|
||||||
|
{onExport && isMemoryDocument ? (
|
||||||
|
<DropdownMenuItem disabled={isUnavailable} onClick={() => handleExport("md")}>
|
||||||
|
<Download className="mr-2 h-4 w-4" />
|
||||||
|
Export as MD
|
||||||
|
</DropdownMenuItem>
|
||||||
|
) : onExport ? (
|
||||||
<DropdownMenuSub>
|
<DropdownMenuSub>
|
||||||
<DropdownMenuSubTrigger disabled={isUnavailable}>
|
<DropdownMenuSubTrigger disabled={isUnavailable}>
|
||||||
<Download className="mr-2 h-4 w-4" />
|
<Download className="mr-2 h-4 w-4" />
|
||||||
|
|
@ -282,17 +336,25 @@ export const DocumentNode = React.memo(function DocumentNode({
|
||||||
<ExportDropdownItems onExport={handleExport} exporting={exporting} />
|
<ExportDropdownItems onExport={handleExport} exporting={exporting} />
|
||||||
</DropdownMenuSubContent>
|
</DropdownMenuSubContent>
|
||||||
</DropdownMenuSub>
|
</DropdownMenuSub>
|
||||||
)}
|
) : null}
|
||||||
{onVersionHistory && isVersionableType(doc.document_type) && (
|
{onVersionHistory && isVersionableType(doc.document_type) && (
|
||||||
<DropdownMenuItem disabled={isUnavailable} onClick={() => onVersionHistory(doc)}>
|
<DropdownMenuItem disabled={isUnavailable} onClick={() => onVersionHistory(doc)}>
|
||||||
<History className="mr-2 h-4 w-4" />
|
<History className="mr-2 h-4 w-4" />
|
||||||
Versions
|
Versions
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
|
{isMemoryDocument && onReset && (
|
||||||
|
<DropdownMenuItem onClick={() => onReset(doc)}>
|
||||||
|
<RotateCcw className="mr-2 h-4 w-4" />
|
||||||
|
Reset
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
{canDelete && (
|
||||||
<DropdownMenuItem disabled={isProcessing} onClick={() => onDelete(doc)}>
|
<DropdownMenuItem disabled={isProcessing} onClick={() => onDelete(doc)}>
|
||||||
<Trash2 className="mr-2 h-4 w-4" />
|
<Trash2 className="mr-2 h-4 w-4" />
|
||||||
Delete
|
Delete
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -311,11 +373,18 @@ export const DocumentNode = React.memo(function DocumentNode({
|
||||||
Edit
|
Edit
|
||||||
</ContextMenuItem>
|
</ContextMenuItem>
|
||||||
)}
|
)}
|
||||||
|
{canMove && (
|
||||||
<ContextMenuItem onClick={() => onMove(doc)}>
|
<ContextMenuItem onClick={() => onMove(doc)}>
|
||||||
<Move className="mr-2 h-4 w-4" />
|
<Move className="mr-2 h-4 w-4" />
|
||||||
Move to...
|
Move to...
|
||||||
</ContextMenuItem>
|
</ContextMenuItem>
|
||||||
{onExport && (
|
)}
|
||||||
|
{onExport && isMemoryDocument ? (
|
||||||
|
<ContextMenuItem disabled={isUnavailable} onClick={() => handleExport("md")}>
|
||||||
|
<Download className="mr-2 h-4 w-4" />
|
||||||
|
Export as MD
|
||||||
|
</ContextMenuItem>
|
||||||
|
) : onExport ? (
|
||||||
<ContextMenuSub>
|
<ContextMenuSub>
|
||||||
<ContextMenuSubTrigger disabled={isUnavailable}>
|
<ContextMenuSubTrigger disabled={isUnavailable}>
|
||||||
<Download className="mr-2 h-4 w-4" />
|
<Download className="mr-2 h-4 w-4" />
|
||||||
|
|
@ -325,17 +394,25 @@ export const DocumentNode = React.memo(function DocumentNode({
|
||||||
<ExportContextItems onExport={handleExport} exporting={exporting} />
|
<ExportContextItems onExport={handleExport} exporting={exporting} />
|
||||||
</ContextMenuSubContent>
|
</ContextMenuSubContent>
|
||||||
</ContextMenuSub>
|
</ContextMenuSub>
|
||||||
)}
|
) : null}
|
||||||
{onVersionHistory && isVersionableType(doc.document_type) && (
|
{onVersionHistory && isVersionableType(doc.document_type) && (
|
||||||
<ContextMenuItem disabled={isUnavailable} onClick={() => onVersionHistory(doc)}>
|
<ContextMenuItem disabled={isUnavailable} onClick={() => onVersionHistory(doc)}>
|
||||||
<History className="mr-2 h-4 w-4" />
|
<History className="mr-2 h-4 w-4" />
|
||||||
Versions
|
Versions
|
||||||
</ContextMenuItem>
|
</ContextMenuItem>
|
||||||
)}
|
)}
|
||||||
|
{isMemoryDocument && onReset && (
|
||||||
|
<ContextMenuItem onClick={() => onReset(doc)}>
|
||||||
|
<RotateCcw className="mr-2 h-4 w-4" />
|
||||||
|
Reset
|
||||||
|
</ContextMenuItem>
|
||||||
|
)}
|
||||||
|
{canDelete && (
|
||||||
<ContextMenuItem disabled={isProcessing} onClick={() => onDelete(doc)}>
|
<ContextMenuItem disabled={isProcessing} onClick={() => onDelete(doc)}>
|
||||||
<Trash2 className="mr-2 h-4 w-4" />
|
<Trash2 className="mr-2 h-4 w-4" />
|
||||||
Delete
|
Delete
|
||||||
</ContextMenuItem>
|
</ContextMenuItem>
|
||||||
|
)}
|
||||||
</ContextMenuContent>
|
</ContextMenuContent>
|
||||||
)}
|
)}
|
||||||
</ContextMenu>
|
</ContextMenu>
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,7 @@ interface FolderTreeViewProps {
|
||||||
onEditDocument: (doc: DocumentNodeDoc) => void;
|
onEditDocument: (doc: DocumentNodeDoc) => void;
|
||||||
onDeleteDocument: (doc: DocumentNodeDoc) => void;
|
onDeleteDocument: (doc: DocumentNodeDoc) => void;
|
||||||
onMoveDocument: (doc: DocumentNodeDoc) => void;
|
onMoveDocument: (doc: DocumentNodeDoc) => void;
|
||||||
|
onResetDocument?: (doc: DocumentNodeDoc) => void;
|
||||||
onExportDocument?: (doc: DocumentNodeDoc, format: string) => void;
|
onExportDocument?: (doc: DocumentNodeDoc, format: string) => void;
|
||||||
onVersionHistory?: (doc: DocumentNodeDoc) => void;
|
onVersionHistory?: (doc: DocumentNodeDoc) => void;
|
||||||
activeTypes: DocumentTypeEnum[];
|
activeTypes: DocumentTypeEnum[];
|
||||||
|
|
@ -74,6 +75,7 @@ export function FolderTreeView({
|
||||||
onEditDocument,
|
onEditDocument,
|
||||||
onDeleteDocument,
|
onDeleteDocument,
|
||||||
onMoveDocument,
|
onMoveDocument,
|
||||||
|
onResetDocument,
|
||||||
onExportDocument,
|
onExportDocument,
|
||||||
onVersionHistory,
|
onVersionHistory,
|
||||||
activeTypes,
|
activeTypes,
|
||||||
|
|
@ -236,6 +238,47 @@ export function FolderTreeView({
|
||||||
return states;
|
return states;
|
||||||
}, [folders, docsByFolder, foldersByParent, folderMap]);
|
}, [folders, docsByFolder, foldersByParent, folderMap]);
|
||||||
|
|
||||||
|
const renderDocumentNode = useCallback(
|
||||||
|
(d: DocumentNodeDoc, depth: number) => {
|
||||||
|
const isMemoryDocument =
|
||||||
|
d.document_type === "USER_MEMORY" || d.document_type === "TEAM_MEMORY";
|
||||||
|
return (
|
||||||
|
<DocumentNode
|
||||||
|
key={`doc-${d.id}`}
|
||||||
|
doc={d}
|
||||||
|
depth={depth}
|
||||||
|
isMentioned={!isMemoryDocument && mentionedDocKeys.has(getMentionDocKey(d))}
|
||||||
|
onToggleChatMention={onToggleChatMention}
|
||||||
|
onPreview={onPreviewDocument}
|
||||||
|
onEdit={onEditDocument}
|
||||||
|
onDelete={onDeleteDocument}
|
||||||
|
onMove={onMoveDocument}
|
||||||
|
onReset={onResetDocument}
|
||||||
|
onExport={onExportDocument}
|
||||||
|
onVersionHistory={isMemoryDocument ? undefined : onVersionHistory}
|
||||||
|
canDelete={!isMemoryDocument}
|
||||||
|
canMove={!isMemoryDocument}
|
||||||
|
canMention={!isMemoryDocument}
|
||||||
|
canEdit
|
||||||
|
contextMenuOpen={openContextMenuId === `doc-${d.id}`}
|
||||||
|
onContextMenuOpenChange={(open) => setOpenContextMenuId(open ? `doc-${d.id}` : null)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[
|
||||||
|
mentionedDocKeys,
|
||||||
|
onDeleteDocument,
|
||||||
|
onEditDocument,
|
||||||
|
onExportDocument,
|
||||||
|
onMoveDocument,
|
||||||
|
onPreviewDocument,
|
||||||
|
onResetDocument,
|
||||||
|
onToggleChatMention,
|
||||||
|
onVersionHistory,
|
||||||
|
openContextMenuId,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
function renderLevel(parentId: number | null, depth: number): React.ReactNode[] {
|
function renderLevel(parentId: number | null, depth: number): React.ReactNode[] {
|
||||||
const key = parentId ?? "root";
|
const key = parentId ?? "root";
|
||||||
const childFolders = (foldersByParent[key] ?? []).slice().sort((a, b) => {
|
const childFolders = (foldersByParent[key] ?? []).slice().sort((a, b) => {
|
||||||
|
|
@ -263,23 +306,7 @@ export function FolderTreeView({
|
||||||
return state === "pending" || state === "processing";
|
return state === "pending" || state === "processing";
|
||||||
});
|
});
|
||||||
for (const d of processingDocs) {
|
for (const d of processingDocs) {
|
||||||
nodes.push(
|
nodes.push(renderDocumentNode(d, depth));
|
||||||
<DocumentNode
|
|
||||||
key={`doc-${d.id}`}
|
|
||||||
doc={d}
|
|
||||||
depth={depth}
|
|
||||||
isMentioned={mentionedDocKeys.has(getMentionDocKey(d))}
|
|
||||||
onToggleChatMention={onToggleChatMention}
|
|
||||||
onPreview={onPreviewDocument}
|
|
||||||
onEdit={onEditDocument}
|
|
||||||
onDelete={onDeleteDocument}
|
|
||||||
onMove={onMoveDocument}
|
|
||||||
onExport={onExportDocument}
|
|
||||||
onVersionHistory={onVersionHistory}
|
|
||||||
contextMenuOpen={openContextMenuId === `doc-${d.id}`}
|
|
||||||
onContextMenuOpenChange={(open) => setOpenContextMenuId(open ? `doc-${d.id}` : null)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -343,23 +370,7 @@ export function FolderTreeView({
|
||||||
: childDocs;
|
: childDocs;
|
||||||
|
|
||||||
for (const d of remainingDocs) {
|
for (const d of remainingDocs) {
|
||||||
nodes.push(
|
nodes.push(renderDocumentNode(d, depth));
|
||||||
<DocumentNode
|
|
||||||
key={`doc-${d.id}`}
|
|
||||||
doc={d}
|
|
||||||
depth={depth}
|
|
||||||
isMentioned={mentionedDocKeys.has(getMentionDocKey(d))}
|
|
||||||
onToggleChatMention={onToggleChatMention}
|
|
||||||
onPreview={onPreviewDocument}
|
|
||||||
onEdit={onEditDocument}
|
|
||||||
onDelete={onDeleteDocument}
|
|
||||||
onMove={onMoveDocument}
|
|
||||||
onExport={onExportDocument}
|
|
||||||
onVersionHistory={onVersionHistory}
|
|
||||||
contextMenuOpen={openContextMenuId === `doc-${d.id}`}
|
|
||||||
onContextMenuOpenChange={(open) => setOpenContextMenuId(open ? `doc-${d.id}` : null)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return nodes;
|
return nodes;
|
||||||
|
|
|
||||||
|
|
@ -17,16 +17,23 @@ import { toast } from "sonner";
|
||||||
import { closeEditorPanelAtom, editorPanelAtom } from "@/atoms/editor/editor-panel.atom";
|
import { closeEditorPanelAtom, editorPanelAtom } from "@/atoms/editor/editor-panel.atom";
|
||||||
import { VersionHistoryButton } from "@/components/documents/version-history";
|
import { VersionHistoryButton } from "@/components/documents/version-history";
|
||||||
import { SourceCodeEditor } from "@/components/editor/source-code-editor";
|
import { SourceCodeEditor } from "@/components/editor/source-code-editor";
|
||||||
|
import {
|
||||||
|
fetchMemoryEditorDocument,
|
||||||
|
getMemoryLimitState,
|
||||||
|
type MemoryLimits,
|
||||||
|
saveMemoryMarkdown,
|
||||||
|
} from "@/components/editor-panel/memory";
|
||||||
import { MarkdownViewer } from "@/components/markdown-viewer";
|
import { MarkdownViewer } from "@/components/markdown-viewer";
|
||||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Drawer, DrawerContent, DrawerHandle, DrawerTitle } from "@/components/ui/drawer";
|
import { Drawer, DrawerContent, DrawerHandle, DrawerTitle } from "@/components/ui/drawer";
|
||||||
|
import { Separator } from "@/components/ui/separator";
|
||||||
import { Spinner } from "@/components/ui/spinner";
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
import { useMediaQuery } from "@/hooks/use-media-query";
|
import { useMediaQuery } from "@/hooks/use-media-query";
|
||||||
import { useElectronAPI } from "@/hooks/use-platform";
|
import { useElectronAPI } from "@/hooks/use-platform";
|
||||||
import { authenticatedFetch, getBearerToken, redirectToLogin } from "@/lib/auth-utils";
|
import { authenticatedFetch, getBearerToken, redirectToLogin } from "@/lib/auth-utils";
|
||||||
import { inferMonacoLanguageFromPath } from "@/lib/editor-language";
|
import { inferMonacoLanguageFromPath } from "@/lib/editor-language";
|
||||||
|
import { BACKEND_URL } from "@/lib/env-config";
|
||||||
const PlateEditor = dynamic(
|
const PlateEditor = dynamic(
|
||||||
() => import("@/components/editor/plate-editor").then((m) => ({ default: m.PlateEditor })),
|
() => import("@/components/editor/plate-editor").then((m) => ({ default: m.PlateEditor })),
|
||||||
{ ssr: false, loading: () => <EditorPanelSkeleton /> }
|
{ ssr: false, loading: () => <EditorPanelSkeleton /> }
|
||||||
|
|
@ -107,13 +114,15 @@ export function EditorPanelContent({
|
||||||
kind = "document",
|
kind = "document",
|
||||||
documentId,
|
documentId,
|
||||||
localFilePath,
|
localFilePath,
|
||||||
|
memoryScope,
|
||||||
searchSpaceId,
|
searchSpaceId,
|
||||||
title,
|
title,
|
||||||
onClose,
|
onClose,
|
||||||
}: {
|
}: {
|
||||||
kind?: "document" | "local_file";
|
kind?: "document" | "local_file" | "memory";
|
||||||
documentId?: number;
|
documentId?: number;
|
||||||
localFilePath?: string;
|
localFilePath?: string;
|
||||||
|
memoryScope?: "user" | "team";
|
||||||
searchSpaceId?: number;
|
searchSpaceId?: number;
|
||||||
title: string | null;
|
title: string | null;
|
||||||
onClose?: () => void;
|
onClose?: () => void;
|
||||||
|
|
@ -125,6 +134,7 @@ export function EditorPanelContent({
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [downloading, setDownloading] = useState(false);
|
const [downloading, setDownloading] = useState(false);
|
||||||
const [isEditing, setIsEditing] = useState(false);
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
|
const [memoryLimits, setMemoryLimits] = useState<MemoryLimits | null>(null);
|
||||||
|
|
||||||
const [editedMarkdown, setEditedMarkdown] = useState<string | null>(null);
|
const [editedMarkdown, setEditedMarkdown] = useState<string | null>(null);
|
||||||
const [localFileContent, setLocalFileContent] = useState("");
|
const [localFileContent, setLocalFileContent] = useState("");
|
||||||
|
|
@ -135,6 +145,7 @@ export function EditorPanelContent({
|
||||||
const changeCountRef = useRef(0);
|
const changeCountRef = useRef(0);
|
||||||
const [displayTitle, setDisplayTitle] = useState(title || "Untitled");
|
const [displayTitle, setDisplayTitle] = useState(title || "Untitled");
|
||||||
const isLocalFileMode = kind === "local_file";
|
const isLocalFileMode = kind === "local_file";
|
||||||
|
const isMemoryMode = kind === "memory";
|
||||||
const editorRenderMode: EditorRenderMode = isLocalFileMode ? "source_code" : "rich_markdown";
|
const editorRenderMode: EditorRenderMode = isLocalFileMode ? "source_code" : "rich_markdown";
|
||||||
|
|
||||||
const resolveLocalVirtualPath = useCallback(
|
const resolveLocalVirtualPath = useCallback(
|
||||||
|
|
@ -165,6 +176,7 @@ export function EditorPanelContent({
|
||||||
setLocalFileContent("");
|
setLocalFileContent("");
|
||||||
setHasCopied(false);
|
setHasCopied(false);
|
||||||
setIsEditing(false);
|
setIsEditing(false);
|
||||||
|
setMemoryLimits(null);
|
||||||
initialLoadDone.current = false;
|
initialLoadDone.current = false;
|
||||||
changeCountRef.current = 0;
|
changeCountRef.current = 0;
|
||||||
|
|
||||||
|
|
@ -199,6 +211,24 @@ export function EditorPanelContent({
|
||||||
initialLoadDone.current = true;
|
initialLoadDone.current = true;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (isMemoryMode) {
|
||||||
|
if (!memoryScope) throw new Error("Missing memory context");
|
||||||
|
const { document, limits } = await fetchMemoryEditorDocument({
|
||||||
|
scope: memoryScope,
|
||||||
|
searchSpaceId,
|
||||||
|
title,
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
if (controller.signal.aborted) return;
|
||||||
|
setMemoryLimits(limits);
|
||||||
|
const content: EditorContent = document;
|
||||||
|
markdownRef.current = content.source_markdown;
|
||||||
|
setDisplayTitle(content.title);
|
||||||
|
setEditorDoc(content);
|
||||||
|
initialLoadDone.current = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!documentId || !searchSpaceId) {
|
if (!documentId || !searchSpaceId) {
|
||||||
throw new Error("Missing document context");
|
throw new Error("Missing document context");
|
||||||
}
|
}
|
||||||
|
|
@ -209,7 +239,7 @@ export function EditorPanelContent({
|
||||||
}
|
}
|
||||||
|
|
||||||
const url = new URL(
|
const url = new URL(
|
||||||
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/search-spaces/${searchSpaceId}/documents/${documentId}/editor-content`
|
`${BACKEND_URL}/api/v1/search-spaces/${searchSpaceId}/documents/${documentId}/editor-content`
|
||||||
);
|
);
|
||||||
url.searchParams.set("max_length", String(LARGE_DOCUMENT_THRESHOLD));
|
url.searchParams.set("max_length", String(LARGE_DOCUMENT_THRESHOLD));
|
||||||
|
|
||||||
|
|
@ -253,7 +283,9 @@ export function EditorPanelContent({
|
||||||
documentId,
|
documentId,
|
||||||
electronAPI,
|
electronAPI,
|
||||||
isLocalFileMode,
|
isLocalFileMode,
|
||||||
|
isMemoryMode,
|
||||||
localFilePath,
|
localFilePath,
|
||||||
|
memoryScope,
|
||||||
resolveLocalVirtualPath,
|
resolveLocalVirtualPath,
|
||||||
searchSpaceId,
|
searchSpaceId,
|
||||||
title,
|
title,
|
||||||
|
|
@ -267,13 +299,20 @@ export function EditorPanelContent({
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleMarkdownChange = useCallback((md: string) => {
|
const handleMarkdownChange = useCallback(
|
||||||
|
(md: string) => {
|
||||||
|
if (!isEditing) return;
|
||||||
|
|
||||||
markdownRef.current = md;
|
markdownRef.current = md;
|
||||||
if (!initialLoadDone.current) return;
|
if (!initialLoadDone.current) return;
|
||||||
changeCountRef.current += 1;
|
changeCountRef.current += 1;
|
||||||
if (changeCountRef.current <= 1) return;
|
if (changeCountRef.current <= 1) return;
|
||||||
setEditedMarkdown(md);
|
|
||||||
}, []);
|
const savedContent = editorDoc?.source_markdown ?? "";
|
||||||
|
setEditedMarkdown(md === savedContent ? null : md);
|
||||||
|
},
|
||||||
|
[editorDoc?.source_markdown, isEditing]
|
||||||
|
);
|
||||||
|
|
||||||
const handleCopy = useCallback(async () => {
|
const handleCopy = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -316,6 +355,23 @@ export function EditorPanelContent({
|
||||||
setEditedMarkdown(markdownRef.current === contentToSave ? null : markdownRef.current);
|
setEditedMarkdown(markdownRef.current === contentToSave ? null : markdownRef.current);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
if (isMemoryMode) {
|
||||||
|
if (!memoryScope) throw new Error("Missing memory context");
|
||||||
|
const { markdown: savedContent, limits } = await saveMemoryMarkdown({
|
||||||
|
scope: memoryScope,
|
||||||
|
searchSpaceId,
|
||||||
|
markdown: markdownRef.current,
|
||||||
|
});
|
||||||
|
markdownRef.current = savedContent;
|
||||||
|
setMemoryLimits(limits ?? memoryLimits);
|
||||||
|
setEditorDoc((prev) => (prev ? { ...prev, source_markdown: savedContent } : prev));
|
||||||
|
setEditedMarkdown(null);
|
||||||
|
if (!options?.silent) {
|
||||||
|
toast.success("Memory saved");
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
if (!searchSpaceId || !documentId) {
|
if (!searchSpaceId || !documentId) {
|
||||||
throw new Error("Missing document context");
|
throw new Error("Missing document context");
|
||||||
}
|
}
|
||||||
|
|
@ -326,7 +382,7 @@ export function EditorPanelContent({
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const response = await authenticatedFetch(
|
const response = await authenticatedFetch(
|
||||||
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/search-spaces/${searchSpaceId}/documents/${documentId}/save`,
|
`${BACKEND_URL}/api/v1/search-spaces/${searchSpaceId}/documents/${documentId}/save`,
|
||||||
{
|
{
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
|
|
@ -361,14 +417,18 @@ export function EditorPanelContent({
|
||||||
documentId,
|
documentId,
|
||||||
electronAPI,
|
electronAPI,
|
||||||
isLocalFileMode,
|
isLocalFileMode,
|
||||||
|
isMemoryMode,
|
||||||
localFilePath,
|
localFilePath,
|
||||||
|
memoryLimits,
|
||||||
|
memoryScope,
|
||||||
resolveLocalVirtualPath,
|
resolveLocalVirtualPath,
|
||||||
searchSpaceId,
|
searchSpaceId,
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
const isEditableType = editorDoc
|
const isEditableType = editorDoc
|
||||||
? (editorRenderMode === "source_code" ||
|
? (isMemoryMode ||
|
||||||
|
editorRenderMode === "source_code" ||
|
||||||
EDITABLE_DOCUMENT_TYPES.has(editorDoc.document_type ?? "")) &&
|
EDITABLE_DOCUMENT_TYPES.has(editorDoc.document_type ?? "")) &&
|
||||||
!isLargeDocument
|
!isLargeDocument
|
||||||
: false;
|
: false;
|
||||||
|
|
@ -381,6 +441,17 @@ export function EditorPanelContent({
|
||||||
const showDesktopHeader = !!onClose;
|
const showDesktopHeader = !!onClose;
|
||||||
const showEditingActions = isEditableType && isEditing;
|
const showEditingActions = isEditableType && isEditing;
|
||||||
const localFileLanguage = inferMonacoLanguageFromPath(localFilePath);
|
const localFileLanguage = inferMonacoLanguageFromPath(localFilePath);
|
||||||
|
const activeMarkdown = editedMarkdown ?? editorDoc?.source_markdown ?? "";
|
||||||
|
const memoryLimitState = isMemoryMode
|
||||||
|
? getMemoryLimitState(activeMarkdown.length, memoryLimits)
|
||||||
|
: null;
|
||||||
|
const memoryCounterClassName =
|
||||||
|
memoryLimitState?.level === "error"
|
||||||
|
? "text-red-500"
|
||||||
|
: memoryLimitState?.level === "warning"
|
||||||
|
? "text-orange-500"
|
||||||
|
: "text-muted-foreground";
|
||||||
|
const saveDisabled = saving || !hasUnsavedChanges || (memoryLimitState?.isOverLimit ?? false);
|
||||||
|
|
||||||
const handleCancelEditing = useCallback(() => {
|
const handleCancelEditing = useCallback(() => {
|
||||||
const savedContent = editorDoc?.source_markdown ?? "";
|
const savedContent = editorDoc?.source_markdown ?? "";
|
||||||
|
|
@ -396,7 +467,7 @@ export function EditorPanelContent({
|
||||||
setDownloading(true);
|
setDownloading(true);
|
||||||
try {
|
try {
|
||||||
const response = await authenticatedFetch(
|
const response = await authenticatedFetch(
|
||||||
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/search-spaces/${searchSpaceId}/documents/${documentId}/download-markdown`,
|
`${BACKEND_URL}/api/v1/search-spaces/${searchSpaceId}/documents/${documentId}/download-markdown`,
|
||||||
{ method: "GET" }
|
{ method: "GET" }
|
||||||
);
|
);
|
||||||
if (!response.ok) throw new Error("Download failed");
|
if (!response.ok) throw new Error("Download failed");
|
||||||
|
|
@ -466,6 +537,17 @@ export function EditorPanelContent({
|
||||||
<div className="grid h-10 grid-cols-[minmax(0,1fr)_auto] items-center gap-3 border-b px-4">
|
<div className="grid h-10 grid-cols-[minmax(0,1fr)_auto] items-center gap-3 border-b px-4">
|
||||||
<div className="min-w-0 flex flex-1 items-center gap-2">
|
<div className="min-w-0 flex flex-1 items-center gap-2">
|
||||||
<p className="truncate text-sm text-muted-foreground">{displayTitle}</p>
|
<p className="truncate text-sm text-muted-foreground">{displayTitle}</p>
|
||||||
|
{memoryLimitState && (
|
||||||
|
<>
|
||||||
|
<Separator
|
||||||
|
orientation="vertical"
|
||||||
|
className="mx-1 bg-border data-[orientation=vertical]:h-4 data-[orientation=vertical]:w-px dark:bg-white/10"
|
||||||
|
/>
|
||||||
|
<span className={`shrink-0 text-xs ${memoryCounterClassName}`}>
|
||||||
|
{memoryLimitState.label}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1 shrink-0">
|
<div className="flex items-center gap-1 shrink-0">
|
||||||
{showEditingActions ? (
|
{showEditingActions ? (
|
||||||
|
|
@ -487,7 +569,7 @@ export function EditorPanelContent({
|
||||||
const saveSucceeded = await handleSave({ silent: true });
|
const saveSucceeded = await handleSave({ silent: true });
|
||||||
if (saveSucceeded) setIsEditing(false);
|
if (saveSucceeded) setIsEditing(false);
|
||||||
}}
|
}}
|
||||||
disabled={saving || !hasUnsavedChanges}
|
disabled={saveDisabled}
|
||||||
>
|
>
|
||||||
<span className={saving ? "opacity-0" : ""}>Save</span>
|
<span className={saving ? "opacity-0" : ""}>Save</span>
|
||||||
{saving && <Spinner size="xs" className="absolute" />}
|
{saving && <Spinner size="xs" className="absolute" />}
|
||||||
|
|
@ -495,7 +577,7 @@ export function EditorPanelContent({
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{!isLocalFileMode && editorDoc?.document_type && documentId && (
|
{!isLocalFileMode && !isMemoryMode && editorDoc?.document_type && documentId && (
|
||||||
<VersionHistoryButton
|
<VersionHistoryButton
|
||||||
documentId={documentId}
|
documentId={documentId}
|
||||||
documentType={editorDoc.document_type}
|
documentType={editorDoc.document_type}
|
||||||
|
|
@ -539,6 +621,17 @@ export function EditorPanelContent({
|
||||||
<div className="flex h-14 items-center justify-between border-b px-4 shrink-0">
|
<div className="flex h-14 items-center justify-between border-b px-4 shrink-0">
|
||||||
<div className="flex flex-1 min-w-0 items-center gap-2">
|
<div className="flex flex-1 min-w-0 items-center gap-2">
|
||||||
<h2 className="text-sm font-semibold truncate">{displayTitle}</h2>
|
<h2 className="text-sm font-semibold truncate">{displayTitle}</h2>
|
||||||
|
{memoryLimitState && (
|
||||||
|
<>
|
||||||
|
<Separator
|
||||||
|
orientation="vertical"
|
||||||
|
className="mx-1 bg-border data-[orientation=vertical]:h-4 data-[orientation=vertical]:w-px dark:bg-white/10"
|
||||||
|
/>
|
||||||
|
<span className={`shrink-0 text-xs ${memoryCounterClassName}`}>
|
||||||
|
{memoryLimitState.label}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1 shrink-0">
|
<div className="flex items-center gap-1 shrink-0">
|
||||||
{showEditingActions ? (
|
{showEditingActions ? (
|
||||||
|
|
@ -560,7 +653,7 @@ export function EditorPanelContent({
|
||||||
const saveSucceeded = await handleSave({ silent: true });
|
const saveSucceeded = await handleSave({ silent: true });
|
||||||
if (saveSucceeded) setIsEditing(false);
|
if (saveSucceeded) setIsEditing(false);
|
||||||
}}
|
}}
|
||||||
disabled={saving || !hasUnsavedChanges}
|
disabled={saveDisabled}
|
||||||
>
|
>
|
||||||
<span className={saving ? "opacity-0" : ""}>Save</span>
|
<span className={saving ? "opacity-0" : ""}>Save</span>
|
||||||
{saving && <Spinner size="xs" className="absolute" />}
|
{saving && <Spinner size="xs" className="absolute" />}
|
||||||
|
|
@ -568,7 +661,7 @@ export function EditorPanelContent({
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{!isLocalFileMode && editorDoc?.document_type && documentId && (
|
{!isLocalFileMode && !isMemoryMode && editorDoc?.document_type && documentId && (
|
||||||
<VersionHistoryButton
|
<VersionHistoryButton
|
||||||
documentId={documentId}
|
documentId={documentId}
|
||||||
documentType={editorDoc.document_type}
|
documentType={editorDoc.document_type}
|
||||||
|
|
@ -664,7 +757,13 @@ export function EditorPanelContent({
|
||||||
<div className="flex h-full min-h-0 flex-col">
|
<div className="flex h-full min-h-0 flex-col">
|
||||||
<div className="flex-1 min-h-0 overflow-hidden">
|
<div className="flex-1 min-h-0 overflow-hidden">
|
||||||
<PlateEditor
|
<PlateEditor
|
||||||
key={`${isLocalFileMode ? (localFilePath ?? "local-file") : documentId}-${isEditing ? "editing" : "viewing"}`}
|
key={`${
|
||||||
|
isMemoryMode
|
||||||
|
? `memory-${memoryScope ?? "user"}`
|
||||||
|
: isLocalFileMode
|
||||||
|
? (localFilePath ?? "local-file")
|
||||||
|
: documentId
|
||||||
|
}-${isEditing ? "editing" : "viewing"}`}
|
||||||
preset="full"
|
preset="full"
|
||||||
markdown={editorDoc.source_markdown}
|
markdown={editorDoc.source_markdown}
|
||||||
onMarkdownChange={handleMarkdownChange}
|
onMarkdownChange={handleMarkdownChange}
|
||||||
|
|
@ -672,14 +771,14 @@ export function EditorPanelContent({
|
||||||
placeholder="Start writing..."
|
placeholder="Start writing..."
|
||||||
editorVariant="default"
|
editorVariant="default"
|
||||||
allowModeToggle={false}
|
allowModeToggle={false}
|
||||||
reserveToolbarSpace={isEditing}
|
reserveToolbarSpace
|
||||||
defaultEditing={isEditing}
|
defaultEditing={isEditing}
|
||||||
className="**:[[role=toolbar]]:bg-sidebar!"
|
className="**:[[role=toolbar]]:bg-sidebar!"
|
||||||
// Render `[citation:N]` badges in view mode only.
|
// Render `[citation:N]` badges in view mode only.
|
||||||
// Edit mode keeps raw text so the user can edit/delete
|
// Edit mode keeps raw text so the user can edit/delete
|
||||||
// tokens directly. `local_file` never reaches this branch
|
// tokens directly. `local_file` never reaches this branch
|
||||||
// (handled by the source_code editor above).
|
// (handled by the source_code editor above).
|
||||||
enableCitations={!isEditing && !isLocalFileMode}
|
enableCitations={!isEditing && !isLocalFileMode && !isMemoryMode}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -708,7 +807,9 @@ function DesktopEditorPanel() {
|
||||||
const hasTarget =
|
const hasTarget =
|
||||||
panelState.kind === "document"
|
panelState.kind === "document"
|
||||||
? !!panelState.documentId && !!panelState.searchSpaceId
|
? !!panelState.documentId && !!panelState.searchSpaceId
|
||||||
: !!panelState.localFilePath;
|
: panelState.kind === "local_file"
|
||||||
|
? !!panelState.localFilePath
|
||||||
|
: !!panelState.memoryScope;
|
||||||
if (!panelState.isOpen || !hasTarget) return null;
|
if (!panelState.isOpen || !hasTarget) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -717,6 +818,7 @@ function DesktopEditorPanel() {
|
||||||
kind={panelState.kind}
|
kind={panelState.kind}
|
||||||
documentId={panelState.documentId ?? undefined}
|
documentId={panelState.documentId ?? undefined}
|
||||||
localFilePath={panelState.localFilePath ?? undefined}
|
localFilePath={panelState.localFilePath ?? undefined}
|
||||||
|
memoryScope={panelState.memoryScope ?? undefined}
|
||||||
searchSpaceId={panelState.searchSpaceId ?? undefined}
|
searchSpaceId={panelState.searchSpaceId ?? undefined}
|
||||||
title={panelState.title}
|
title={panelState.title}
|
||||||
onClose={closePanel}
|
onClose={closePanel}
|
||||||
|
|
@ -734,7 +836,7 @@ function MobileEditorDrawer() {
|
||||||
const hasTarget =
|
const hasTarget =
|
||||||
panelState.kind === "document"
|
panelState.kind === "document"
|
||||||
? !!panelState.documentId && !!panelState.searchSpaceId
|
? !!panelState.documentId && !!panelState.searchSpaceId
|
||||||
: !!panelState.localFilePath;
|
: !!panelState.memoryScope;
|
||||||
if (!hasTarget) return null;
|
if (!hasTarget) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -756,6 +858,7 @@ function MobileEditorDrawer() {
|
||||||
kind={panelState.kind}
|
kind={panelState.kind}
|
||||||
documentId={panelState.documentId ?? undefined}
|
documentId={panelState.documentId ?? undefined}
|
||||||
localFilePath={panelState.localFilePath ?? undefined}
|
localFilePath={panelState.localFilePath ?? undefined}
|
||||||
|
memoryScope={panelState.memoryScope ?? undefined}
|
||||||
searchSpaceId={panelState.searchSpaceId ?? undefined}
|
searchSpaceId={panelState.searchSpaceId ?? undefined}
|
||||||
title={panelState.title}
|
title={panelState.title}
|
||||||
/>
|
/>
|
||||||
|
|
@ -771,7 +874,9 @@ export function EditorPanel() {
|
||||||
const hasTarget =
|
const hasTarget =
|
||||||
panelState.kind === "document"
|
panelState.kind === "document"
|
||||||
? !!panelState.documentId && !!panelState.searchSpaceId
|
? !!panelState.documentId && !!panelState.searchSpaceId
|
||||||
: !!panelState.localFilePath;
|
: panelState.kind === "local_file"
|
||||||
|
? !!panelState.localFilePath
|
||||||
|
: !!panelState.memoryScope;
|
||||||
|
|
||||||
if (!panelState.isOpen || !hasTarget) return null;
|
if (!panelState.isOpen || !hasTarget) return null;
|
||||||
if (!isDesktop && panelState.kind === "local_file") return null;
|
if (!isDesktop && panelState.kind === "local_file") return null;
|
||||||
|
|
@ -789,7 +894,9 @@ export function MobileEditorPanel() {
|
||||||
const hasTarget =
|
const hasTarget =
|
||||||
panelState.kind === "document"
|
panelState.kind === "document"
|
||||||
? !!panelState.documentId && !!panelState.searchSpaceId
|
? !!panelState.documentId && !!panelState.searchSpaceId
|
||||||
: !!panelState.localFilePath;
|
: panelState.kind === "local_file"
|
||||||
|
? !!panelState.localFilePath
|
||||||
|
: !!panelState.memoryScope;
|
||||||
|
|
||||||
if (isDesktop || !panelState.isOpen || !hasTarget || panelState.kind === "local_file")
|
if (isDesktop || !panelState.isOpen || !hasTarget || panelState.kind === "local_file")
|
||||||
return null;
|
return null;
|
||||||
|
|
|
||||||
116
surfsense_web/components/editor-panel/memory.ts
Normal file
116
surfsense_web/components/editor-panel/memory.ts
Normal file
|
|
@ -0,0 +1,116 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { authenticatedFetch } from "@/lib/auth-utils";
|
||||||
|
|
||||||
|
export type MemoryScope = "user" | "team";
|
||||||
|
|
||||||
|
export interface MemoryLimits {
|
||||||
|
soft: number;
|
||||||
|
hard: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MemoryLimitLevel = "ok" | "warning" | "error";
|
||||||
|
|
||||||
|
export interface MemoryEditorDocument {
|
||||||
|
document_id: number;
|
||||||
|
title: string;
|
||||||
|
document_type: "USER_MEMORY" | "TEAM_MEMORY";
|
||||||
|
source_markdown: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MemoryReadResponse {
|
||||||
|
memory_md?: string;
|
||||||
|
limits?: MemoryLimits;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMemoryPath(scope: MemoryScope, searchSpaceId?: number | null) {
|
||||||
|
if (scope === "user") return "/api/v1/users/me/memory";
|
||||||
|
if (!searchSpaceId) throw new Error("Missing search space context");
|
||||||
|
return `/api/v1/searchspaces/${searchSpaceId}/memory`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getBackendUrl(path: string) {
|
||||||
|
return `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}${path}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getMemoryLimitState(length: number, limits?: MemoryLimits | null) {
|
||||||
|
if (!limits) {
|
||||||
|
return {
|
||||||
|
level: "ok" as MemoryLimitLevel,
|
||||||
|
label: `${length.toLocaleString()} chars`,
|
||||||
|
isOverLimit: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const isOverLimit = length > limits.hard;
|
||||||
|
const isNearLimit = length > limits.soft;
|
||||||
|
const level: MemoryLimitLevel = isOverLimit ? "error" : isNearLimit ? "warning" : "ok";
|
||||||
|
const suffix = isOverLimit ? " - Exceeds limit" : isNearLimit ? " - Approaching limit" : "";
|
||||||
|
|
||||||
|
return {
|
||||||
|
level,
|
||||||
|
label: `${length.toLocaleString()}/${limits.hard.toLocaleString()} chars${suffix}`,
|
||||||
|
isOverLimit,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchMemoryEditorDocument({
|
||||||
|
scope,
|
||||||
|
searchSpaceId,
|
||||||
|
title,
|
||||||
|
signal,
|
||||||
|
}: {
|
||||||
|
scope: MemoryScope;
|
||||||
|
searchSpaceId?: number | null;
|
||||||
|
title?: string | null;
|
||||||
|
signal?: AbortSignal;
|
||||||
|
}) {
|
||||||
|
const response = await authenticatedFetch(getBackendUrl(getMemoryPath(scope, searchSpaceId)), {
|
||||||
|
method: "GET",
|
||||||
|
signal,
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json().catch(() => ({ detail: "Failed to fetch memory" }));
|
||||||
|
throw new Error(errorData.detail || "Failed to fetch memory");
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await response.json()) as MemoryReadResponse;
|
||||||
|
const isTeamMemory = scope === "team";
|
||||||
|
|
||||||
|
return {
|
||||||
|
limits: data.limits ?? null,
|
||||||
|
document: {
|
||||||
|
document_id: isTeamMemory ? -1002 : -1001,
|
||||||
|
title: title || (isTeamMemory ? "Team Memory" : "Personal Memory"),
|
||||||
|
document_type: isTeamMemory ? "TEAM_MEMORY" : "USER_MEMORY",
|
||||||
|
source_markdown: data.memory_md ?? "",
|
||||||
|
} satisfies MemoryEditorDocument,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveMemoryMarkdown({
|
||||||
|
scope,
|
||||||
|
searchSpaceId,
|
||||||
|
markdown,
|
||||||
|
}: {
|
||||||
|
scope: MemoryScope;
|
||||||
|
searchSpaceId?: number | null;
|
||||||
|
markdown: string;
|
||||||
|
}) {
|
||||||
|
const response = await authenticatedFetch(getBackendUrl(getMemoryPath(scope, searchSpaceId)), {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ memory_md: markdown }),
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json().catch(() => ({ detail: "Failed to save memory" }));
|
||||||
|
throw new Error(errorData.detail || "Failed to save memory");
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await response.json()) as MemoryReadResponse;
|
||||||
|
|
||||||
|
return {
|
||||||
|
markdown: data.memory_md ?? markdown,
|
||||||
|
limits: data.limits,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -10,7 +10,7 @@ import { trackAnonymousChatMessageSent } from "@/lib/posthog/events";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { QuotaBar } from "./quota-bar";
|
import { QuotaBar } from "./quota-bar";
|
||||||
import { QuotaWarningBanner } from "./quota-warning-banner";
|
import { QuotaWarningBanner } from "./quota-warning-banner";
|
||||||
|
import { BACKEND_URL } from "@/lib/env-config";
|
||||||
interface Message {
|
interface Message {
|
||||||
id: string;
|
id: string;
|
||||||
role: "user" | "assistant";
|
role: "user" | "assistant";
|
||||||
|
|
@ -81,7 +81,7 @@ export function AnonymousChat({ model }: AnonymousChatProps) {
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL || "http://localhost:8000"}/api/v1/public/anon-chat/stream`,
|
`${BACKEND_URL}/api/v1/public/anon-chat/stream`,
|
||||||
{
|
{
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
|
|
|
||||||
|
|
@ -103,7 +103,11 @@ export function RightPanelToggleButton({
|
||||||
const reportOpen = reportState.isOpen && !!reportState.reportId;
|
const reportOpen = reportState.isOpen && !!reportState.reportId;
|
||||||
const editorOpen =
|
const editorOpen =
|
||||||
editorState.isOpen &&
|
editorState.isOpen &&
|
||||||
(editorState.kind === "document" ? !!editorState.documentId : !!editorState.localFilePath);
|
(editorState.kind === "document"
|
||||||
|
? !!editorState.documentId
|
||||||
|
: editorState.kind === "memory"
|
||||||
|
? !!editorState.memoryScope
|
||||||
|
: !!editorState.localFilePath);
|
||||||
const hitlEditOpen = hitlEditState.isOpen && !!hitlEditState.onSave;
|
const hitlEditOpen = hitlEditState.isOpen && !!hitlEditState.onSave;
|
||||||
const citationOpen = citationState.isOpen && citationState.chunkId != null;
|
const citationOpen = citationState.isOpen && citationState.chunkId != null;
|
||||||
const hasContent = documentsOpen || reportOpen || editorOpen || hitlEditOpen || citationOpen;
|
const hasContent = documentsOpen || reportOpen || editorOpen || hitlEditOpen || citationOpen;
|
||||||
|
|
@ -151,7 +155,11 @@ export function RightPanelExpandButton() {
|
||||||
const reportOpen = reportState.isOpen && !!reportState.reportId;
|
const reportOpen = reportState.isOpen && !!reportState.reportId;
|
||||||
const editorOpen =
|
const editorOpen =
|
||||||
editorState.isOpen &&
|
editorState.isOpen &&
|
||||||
(editorState.kind === "document" ? !!editorState.documentId : !!editorState.localFilePath);
|
(editorState.kind === "document"
|
||||||
|
? !!editorState.documentId
|
||||||
|
: editorState.kind === "memory"
|
||||||
|
? !!editorState.memoryScope
|
||||||
|
: !!editorState.localFilePath);
|
||||||
const hitlEditOpen = hitlEditState.isOpen && !!hitlEditState.onSave;
|
const hitlEditOpen = hitlEditState.isOpen && !!hitlEditState.onSave;
|
||||||
const citationOpen = citationState.isOpen && citationState.chunkId != null;
|
const citationOpen = citationState.isOpen && citationState.chunkId != null;
|
||||||
const hasContent = documentsOpen || reportOpen || editorOpen || hitlEditOpen || citationOpen;
|
const hasContent = documentsOpen || reportOpen || editorOpen || hitlEditOpen || citationOpen;
|
||||||
|
|
@ -193,7 +201,11 @@ export function RightPanel({
|
||||||
const reportOpen = reportState.isOpen && !!reportState.reportId;
|
const reportOpen = reportState.isOpen && !!reportState.reportId;
|
||||||
const editorOpen =
|
const editorOpen =
|
||||||
editorState.isOpen &&
|
editorState.isOpen &&
|
||||||
(editorState.kind === "document" ? !!editorState.documentId : !!editorState.localFilePath);
|
(editorState.kind === "document"
|
||||||
|
? !!editorState.documentId
|
||||||
|
: editorState.kind === "memory"
|
||||||
|
? !!editorState.memoryScope
|
||||||
|
: !!editorState.localFilePath);
|
||||||
const hitlEditOpen = hitlEditState.isOpen && !!hitlEditState.onSave;
|
const hitlEditOpen = hitlEditState.isOpen && !!hitlEditState.onSave;
|
||||||
const citationOpen = citationState.isOpen && citationState.chunkId != null;
|
const citationOpen = citationState.isOpen && citationState.chunkId != null;
|
||||||
|
|
||||||
|
|
@ -292,6 +304,7 @@ export function RightPanel({
|
||||||
kind={editorState.kind}
|
kind={editorState.kind}
|
||||||
documentId={editorState.documentId ?? undefined}
|
documentId={editorState.documentId ?? undefined}
|
||||||
localFilePath={editorState.localFilePath ?? undefined}
|
localFilePath={editorState.localFilePath ?? undefined}
|
||||||
|
memoryScope={editorState.memoryScope ?? undefined}
|
||||||
searchSpaceId={editorState.searchSpaceId ?? undefined}
|
searchSpaceId={editorState.searchSpaceId ?? undefined}
|
||||||
title={editorState.title}
|
title={editorState.title}
|
||||||
onClose={closeEditor}
|
onClose={closeEditor}
|
||||||
|
|
|
||||||
|
|
@ -82,13 +82,50 @@ import { uploadFolderScan } from "@/lib/folder-sync-upload";
|
||||||
import { getSupportedExtensionsSet } from "@/lib/supported-extensions";
|
import { getSupportedExtensionsSet } from "@/lib/supported-extensions";
|
||||||
import { queries } from "@/zero/queries/index";
|
import { queries } from "@/zero/queries/index";
|
||||||
import { SidebarSlideOutPanel } from "./SidebarSlideOutPanel";
|
import { SidebarSlideOutPanel } from "./SidebarSlideOutPanel";
|
||||||
|
import { BACKEND_URL } from "@/lib/env-config";
|
||||||
|
|
||||||
const DesktopLocalTabContent = dynamic(
|
const DesktopLocalTabContent = dynamic(
|
||||||
() => import("./DesktopLocalTabContent").then((mod) => mod.DesktopLocalTabContent),
|
() => import("./DesktopLocalTabContent").then((mod) => mod.DesktopLocalTabContent),
|
||||||
{ ssr: false }
|
{ ssr: false }
|
||||||
);
|
);
|
||||||
|
|
||||||
const NON_DELETABLE_DOCUMENT_TYPES: readonly string[] = ["SURFSENSE_DOCS"];
|
const NON_DELETABLE_DOCUMENT_TYPES: readonly string[] = [
|
||||||
|
"SURFSENSE_DOCS",
|
||||||
|
"USER_MEMORY",
|
||||||
|
"TEAM_MEMORY",
|
||||||
|
];
|
||||||
|
const MEMORY_DOCUMENTS: DocumentNodeDoc[] = [
|
||||||
|
{
|
||||||
|
id: -1001,
|
||||||
|
title: "MEMORY.md",
|
||||||
|
document_type: "USER_MEMORY",
|
||||||
|
folderId: null,
|
||||||
|
status: { state: "ready" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: -1002,
|
||||||
|
title: "TEAM_MEMORY.md",
|
||||||
|
document_type: "TEAM_MEMORY",
|
||||||
|
folderId: null,
|
||||||
|
status: { state: "ready" },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function isMemoryDocument(doc: { document_type: string }) {
|
||||||
|
return doc.document_type === "USER_MEMORY" || doc.document_type === "TEAM_MEMORY";
|
||||||
|
}
|
||||||
|
|
||||||
|
function downloadTextFile(content: string, fileName: string, type = "text/markdown;charset=utf-8") {
|
||||||
|
const blob = new Blob([content], { type });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = url;
|
||||||
|
a.download = fileName;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
const LOCAL_FILESYSTEM_TRUST_KEY = "surfsense.local-filesystem-trust.v1";
|
const LOCAL_FILESYSTEM_TRUST_KEY = "surfsense.local-filesystem-trust.v1";
|
||||||
const MAX_LOCAL_FILESYSTEM_ROOTS = 10;
|
const MAX_LOCAL_FILESYSTEM_ROOTS = 10;
|
||||||
|
|
||||||
|
|
@ -716,7 +753,7 @@ function AuthenticatedDocumentsSidebarBase({
|
||||||
.trim()
|
.trim()
|
||||||
.slice(0, 80) || "folder";
|
.slice(0, 80) || "folder";
|
||||||
await doExport(
|
await doExport(
|
||||||
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/search-spaces/${searchSpaceId}/export?folder_id=${ctx.folder.id}`,
|
`${BACKEND_URL}/api/v1/search-spaces/${searchSpaceId}/export?folder_id=${ctx.folder.id}`,
|
||||||
`${safeName}.zip`
|
`${safeName}.zip`
|
||||||
);
|
);
|
||||||
toast.success(`Folder "${ctx.folder.name}" exported`);
|
toast.success(`Folder "${ctx.folder.name}" exported`);
|
||||||
|
|
@ -768,7 +805,7 @@ function AuthenticatedDocumentsSidebarBase({
|
||||||
.trim()
|
.trim()
|
||||||
.slice(0, 80) || "folder";
|
.slice(0, 80) || "folder";
|
||||||
await doExport(
|
await doExport(
|
||||||
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/search-spaces/${searchSpaceId}/export?folder_id=${folder.id}`,
|
`${BACKEND_URL}/api/v1/search-spaces/${searchSpaceId}/export?folder_id=${folder.id}`,
|
||||||
`${safeName}.zip`
|
`${safeName}.zip`
|
||||||
);
|
);
|
||||||
toast.success(`Folder "${folder.name}" exported`);
|
toast.success(`Folder "${folder.name}" exported`);
|
||||||
|
|
@ -784,6 +821,30 @@ function AuthenticatedDocumentsSidebarBase({
|
||||||
|
|
||||||
const handleExportDocument = useCallback(
|
const handleExportDocument = useCallback(
|
||||||
async (doc: DocumentNodeDoc, format: string) => {
|
async (doc: DocumentNodeDoc, format: string) => {
|
||||||
|
if (isMemoryDocument(doc)) {
|
||||||
|
try {
|
||||||
|
const endpoint =
|
||||||
|
doc.document_type === "USER_MEMORY"
|
||||||
|
? `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/users/me/memory`
|
||||||
|
: `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/searchspaces/${searchSpaceId}/memory`;
|
||||||
|
const response = await authenticatedFetch(endpoint, { method: "GET" });
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json().catch(() => ({ detail: "Export failed" }));
|
||||||
|
throw new Error(errorData.detail || "Export failed");
|
||||||
|
}
|
||||||
|
const data = (await response.json()) as { memory_md?: string };
|
||||||
|
downloadTextFile(
|
||||||
|
data.memory_md ?? "",
|
||||||
|
doc.title.endsWith(".md") ? doc.title : `${doc.title}.md`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Memory export failed:", err);
|
||||||
|
toast.error(err instanceof Error ? err.message : "Export failed");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const safeTitle =
|
const safeTitle =
|
||||||
doc.title
|
doc.title
|
||||||
.replace(/[^a-zA-Z0-9 _-]/g, "_")
|
.replace(/[^a-zA-Z0-9 _-]/g, "_")
|
||||||
|
|
@ -793,7 +854,7 @@ function AuthenticatedDocumentsSidebarBase({
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await authenticatedFetch(
|
const response = await authenticatedFetch(
|
||||||
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/search-spaces/${searchSpaceId}/documents/${doc.id}/export?format=${format}`,
|
`${BACKEND_URL}/api/v1/search-spaces/${searchSpaceId}/documents/${doc.id}/export?format=${format}`,
|
||||||
{ method: "GET" }
|
{ method: "GET" }
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -879,6 +940,7 @@ function AuthenticatedDocumentsSidebarBase({
|
||||||
|
|
||||||
const handleToggleChatMention = useCallback(
|
const handleToggleChatMention = useCallback(
|
||||||
(doc: { id: number; title: string; document_type: string }, isMentioned: boolean) => {
|
(doc: { id: number; title: string; document_type: string }, isMentioned: boolean) => {
|
||||||
|
if (isMemoryDocument(doc)) return;
|
||||||
const key = getMentionDocKey({ ...doc, kind: "doc" });
|
const key = getMentionDocKey({ ...doc, kind: "doc" });
|
||||||
if (isMentioned) {
|
if (isMentioned) {
|
||||||
setSidebarDocs((prev) => prev.filter((d) => getMentionDocKey(d) !== key));
|
setSidebarDocs((prev) => prev.filter((d) => getMentionDocKey(d) !== key));
|
||||||
|
|
@ -927,11 +989,66 @@ function AuthenticatedDocumentsSidebarBase({
|
||||||
[treeFolders, setSidebarDocs]
|
[treeFolders, setSidebarDocs]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const treeDocumentsWithMemory = useMemo(
|
||||||
|
() => [...MEMORY_DOCUMENTS, ...treeDocuments],
|
||||||
|
[treeDocuments]
|
||||||
|
);
|
||||||
|
|
||||||
const searchFilteredDocuments = useMemo(() => {
|
const searchFilteredDocuments = useMemo(() => {
|
||||||
const query = debouncedSearch.trim().toLowerCase();
|
const query = debouncedSearch.trim().toLowerCase();
|
||||||
if (!query) return treeDocuments;
|
if (!query) return treeDocumentsWithMemory;
|
||||||
return treeDocuments.filter((d) => d.title.toLowerCase().includes(query));
|
return treeDocumentsWithMemory.filter((d) => d.title.toLowerCase().includes(query));
|
||||||
}, [treeDocuments, debouncedSearch]);
|
}, [treeDocumentsWithMemory, debouncedSearch]);
|
||||||
|
|
||||||
|
const openMemoryDocument = useCallback(
|
||||||
|
(doc: DocumentNodeDoc) => {
|
||||||
|
if (doc.document_type === "USER_MEMORY") {
|
||||||
|
openEditorPanel({
|
||||||
|
kind: "memory",
|
||||||
|
memoryScope: "user",
|
||||||
|
searchSpaceId,
|
||||||
|
title: doc.title,
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (doc.document_type === "TEAM_MEMORY") {
|
||||||
|
openEditorPanel({
|
||||||
|
kind: "memory",
|
||||||
|
memoryScope: "team",
|
||||||
|
searchSpaceId,
|
||||||
|
title: doc.title,
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
[openEditorPanel, searchSpaceId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleResetMemoryDocument = useCallback(
|
||||||
|
async (doc: DocumentNodeDoc) => {
|
||||||
|
if (!isMemoryDocument(doc)) return;
|
||||||
|
if (!window.confirm(`Reset ${doc.title.toLowerCase()}? This clears the memory document.`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const endpoint =
|
||||||
|
doc.document_type === "USER_MEMORY"
|
||||||
|
? `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/users/me/memory/reset`
|
||||||
|
: `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/searchspaces/${searchSpaceId}/memory/reset`;
|
||||||
|
try {
|
||||||
|
const response = await authenticatedFetch(endpoint, { method: "POST" });
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json().catch(() => ({ detail: "Reset failed" }));
|
||||||
|
throw new Error(errorData.detail || "Reset failed");
|
||||||
|
}
|
||||||
|
toast.success(`${doc.title} reset`);
|
||||||
|
openMemoryDocument(doc);
|
||||||
|
} catch (error) {
|
||||||
|
toast.error((error as Error)?.message || `Failed to reset ${doc.title.toLowerCase()}`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[openMemoryDocument, searchSpaceId]
|
||||||
|
);
|
||||||
|
|
||||||
const typeCounts = useMemo(() => {
|
const typeCounts = useMemo(() => {
|
||||||
const counts: Partial<Record<string, number>> = {};
|
const counts: Partial<Record<string, number>> = {};
|
||||||
|
|
@ -1169,6 +1286,7 @@ function AuthenticatedDocumentsSidebarBase({
|
||||||
onCreateFolder={handleCreateFolder}
|
onCreateFolder={handleCreateFolder}
|
||||||
searchQuery={debouncedSearch.trim() || undefined}
|
searchQuery={debouncedSearch.trim() || undefined}
|
||||||
onPreviewDocument={(doc) => {
|
onPreviewDocument={(doc) => {
|
||||||
|
if (openMemoryDocument(doc)) return;
|
||||||
openEditorPanel({
|
openEditorPanel({
|
||||||
documentId: doc.id,
|
documentId: doc.id,
|
||||||
searchSpaceId,
|
searchSpaceId,
|
||||||
|
|
@ -1176,6 +1294,7 @@ function AuthenticatedDocumentsSidebarBase({
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
onEditDocument={(doc) => {
|
onEditDocument={(doc) => {
|
||||||
|
if (openMemoryDocument(doc)) return;
|
||||||
openEditorPanel({
|
openEditorPanel({
|
||||||
documentId: doc.id,
|
documentId: doc.id,
|
||||||
searchSpaceId,
|
searchSpaceId,
|
||||||
|
|
@ -1184,6 +1303,7 @@ function AuthenticatedDocumentsSidebarBase({
|
||||||
}}
|
}}
|
||||||
onDeleteDocument={(doc) => handleDeleteDocument(doc.id)}
|
onDeleteDocument={(doc) => handleDeleteDocument(doc.id)}
|
||||||
onMoveDocument={handleMoveDocument}
|
onMoveDocument={handleMoveDocument}
|
||||||
|
onResetDocument={handleResetMemoryDocument}
|
||||||
onExportDocument={handleExportDocument}
|
onExportDocument={handleExportDocument}
|
||||||
onVersionHistory={(doc) => setVersionDocId(doc.id)}
|
onVersionHistory={(doc) => setVersionDocId(doc.id)}
|
||||||
activeTypes={activeTypes}
|
activeTypes={activeTypes}
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Spinner } from "@/components/ui/spinner";
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
import { authenticatedFetch, getBearerToken, redirectToLogin } from "@/lib/auth-utils";
|
import { authenticatedFetch, getBearerToken, redirectToLogin } from "@/lib/auth-utils";
|
||||||
|
import { BACKEND_URL, BACKEND_URL } from "@/lib/env-config";
|
||||||
const LARGE_DOCUMENT_THRESHOLD = 2 * 1024 * 1024; // 2MB
|
const LARGE_DOCUMENT_THRESHOLD = 2 * 1024 * 1024; // 2MB
|
||||||
|
|
||||||
interface DocumentContent {
|
interface DocumentContent {
|
||||||
|
|
@ -85,7 +85,7 @@ export function DocumentTabContent({ documentId, searchSpaceId, title }: Documen
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const url = new URL(
|
const url = new URL(
|
||||||
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/search-spaces/${searchSpaceId}/documents/${documentId}/editor-content`
|
`${BACKEND_URL}/api/v1/search-spaces/${searchSpaceId}/documents/${documentId}/editor-content`
|
||||||
);
|
);
|
||||||
url.searchParams.set("max_length", String(LARGE_DOCUMENT_THRESHOLD));
|
url.searchParams.set("max_length", String(LARGE_DOCUMENT_THRESHOLD));
|
||||||
|
|
||||||
|
|
@ -143,7 +143,7 @@ export function DocumentTabContent({ documentId, searchSpaceId, title }: Documen
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
const response = await authenticatedFetch(
|
const response = await authenticatedFetch(
|
||||||
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/search-spaces/${searchSpaceId}/documents/${documentId}/save`,
|
`${BACKEND_URL}/api/v1/search-spaces/${searchSpaceId}/documents/${documentId}/save`,
|
||||||
{
|
{
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
|
|
@ -285,7 +285,7 @@ export function DocumentTabContent({ documentId, searchSpaceId, title }: Documen
|
||||||
setDownloading(true);
|
setDownloading(true);
|
||||||
try {
|
try {
|
||||||
const response = await authenticatedFetch(
|
const response = await authenticatedFetch(
|
||||||
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/search-spaces/${searchSpaceId}/documents/${documentId}/download-markdown`,
|
`${BACKEND_URL}/api/v1/search-spaces/${searchSpaceId}/documents/${documentId}/download-markdown`,
|
||||||
{ method: "GET" }
|
{ method: "GET" }
|
||||||
);
|
);
|
||||||
if (!response.ok) throw new Error("Download failed");
|
if (!response.ok) throw new Error("Download failed");
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ import { Spinner } from "@/components/ui/spinner";
|
||||||
import { useMediaQuery } from "@/hooks/use-media-query";
|
import { useMediaQuery } from "@/hooks/use-media-query";
|
||||||
import { baseApiService } from "@/lib/apis/base-api.service";
|
import { baseApiService } from "@/lib/apis/base-api.service";
|
||||||
import { authenticatedFetch } from "@/lib/auth-utils";
|
import { authenticatedFetch } from "@/lib/auth-utils";
|
||||||
|
import { BACKEND_URL } from "@/lib/env-config";
|
||||||
|
|
||||||
function ReportPanelSkeleton() {
|
function ReportPanelSkeleton() {
|
||||||
return (
|
return (
|
||||||
|
|
@ -244,7 +245,7 @@ export function ReportPanelContent({
|
||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
} else {
|
} else {
|
||||||
const response = await authenticatedFetch(
|
const response = await authenticatedFetch(
|
||||||
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/reports/${activeReportId}/export?format=${format}`,
|
`${BACKEND_URL}/api/v1/reports/${activeReportId}/export?format=${format}`,
|
||||||
{ method: "GET" }
|
{ method: "GET" }
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -277,7 +278,7 @@ export function ReportPanelContent({
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
const response = await authenticatedFetch(
|
const response = await authenticatedFetch(
|
||||||
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/reports/${activeReportId}/content`,
|
`${BACKEND_URL}/api/v1/reports/${activeReportId}/content`,
|
||||||
{
|
{
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
|
|
@ -505,7 +506,7 @@ export function ReportPanelContent({
|
||||||
</div>
|
</div>
|
||||||
) : reportContent.content_type === "typst" ? (
|
) : reportContent.content_type === "typst" ? (
|
||||||
<PdfViewer
|
<PdfViewer
|
||||||
pdfUrl={`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}${shareToken ? `/api/v1/public/${shareToken}/reports/${activeReportId}/preview` : `/api/v1/reports/${activeReportId}/preview`}`}
|
pdfUrl={`${BACKEND_URL}${shareToken ? `/api/v1/public/${shareToken}/reports/${activeReportId}/preview` : `/api/v1/reports/${activeReportId}/preview`}`}
|
||||||
isPublic={isPublic}
|
isPublic={isPublic}
|
||||||
toolbarActions={
|
toolbarActions={
|
||||||
<>
|
<>
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service";
|
||||||
import { authenticatedFetch } from "@/lib/auth-utils";
|
import { authenticatedFetch } from "@/lib/auth-utils";
|
||||||
import { cacheKeys } from "@/lib/query-client/cache-keys";
|
import { cacheKeys } from "@/lib/query-client/cache-keys";
|
||||||
import { Spinner } from "../ui/spinner";
|
import { Spinner } from "../ui/spinner";
|
||||||
|
import { BACKEND_URL } from "@/lib/env-config";
|
||||||
|
|
||||||
interface GeneralSettingsManagerProps {
|
interface GeneralSettingsManagerProps {
|
||||||
searchSpaceId: number;
|
searchSpaceId: number;
|
||||||
|
|
@ -48,7 +49,7 @@ export function GeneralSettingsManager({ searchSpaceId }: GeneralSettingsManager
|
||||||
setIsExporting(true);
|
setIsExporting(true);
|
||||||
try {
|
try {
|
||||||
const response = await authenticatedFetch(
|
const response = await authenticatedFetch(
|
||||||
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/search-spaces/${searchSpaceId}/export`,
|
`${BACKEND_URL}/api/v1/search-spaces/${searchSpaceId}/export`,
|
||||||
{ method: "GET" }
|
{ method: "GET" }
|
||||||
);
|
);
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service";
|
||||||
import { authenticatedFetch } from "@/lib/auth-utils";
|
import { authenticatedFetch } from "@/lib/auth-utils";
|
||||||
import { cacheKeys } from "@/lib/query-client/cache-keys";
|
import { cacheKeys } from "@/lib/query-client/cache-keys";
|
||||||
import { Spinner } from "../ui/spinner";
|
import { Spinner } from "../ui/spinner";
|
||||||
|
import { BACKEND_URL } from "@/lib/env-config";
|
||||||
|
|
||||||
interface PromptConfigManagerProps {
|
interface PromptConfigManagerProps {
|
||||||
searchSpaceId: number;
|
searchSpaceId: number;
|
||||||
|
|
@ -54,7 +55,7 @@ export function PromptConfigManager({ searchSpaceId }: PromptConfigManagerProps)
|
||||||
};
|
};
|
||||||
|
|
||||||
const response = await authenticatedFetch(
|
const response = await authenticatedFetch(
|
||||||
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/searchspaces/${searchSpaceId}`,
|
`${BACKEND_URL}/api/v1/searchspaces/${searchSpaceId}`,
|
||||||
{
|
{
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
|
|
|
||||||
|
|
@ -1,299 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|
||||||
import { useAtomValue } from "jotai";
|
|
||||||
import { ArrowUp, ChevronDown, ClipboardCopy, Download, Info, Pencil } from "lucide-react";
|
|
||||||
import { useEffect, useRef, useState } from "react";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import { z } from "zod";
|
|
||||||
import { updateSearchSpaceMutationAtom } from "@/atoms/search-spaces/search-space-mutation.atoms";
|
|
||||||
import { PlateEditor } from "@/components/editor/plate-editor";
|
|
||||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from "@/components/ui/dropdown-menu";
|
|
||||||
import { Spinner } from "@/components/ui/spinner";
|
|
||||||
import { baseApiService } from "@/lib/apis/base-api.service";
|
|
||||||
import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service";
|
|
||||||
import { cacheKeys } from "@/lib/query-client/cache-keys";
|
|
||||||
|
|
||||||
const MEMORY_HARD_LIMIT = 25_000;
|
|
||||||
|
|
||||||
const SearchSpaceSchema = z
|
|
||||||
.object({
|
|
||||||
shared_memory_md: z.string().optional().default(""),
|
|
||||||
})
|
|
||||||
.passthrough();
|
|
||||||
|
|
||||||
interface TeamMemoryManagerProps {
|
|
||||||
searchSpaceId: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function TeamMemoryManager({ searchSpaceId }: TeamMemoryManagerProps) {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
const { data: searchSpace, isLoading: loading } = useQuery({
|
|
||||||
queryKey: cacheKeys.searchSpaces.detail(searchSpaceId.toString()),
|
|
||||||
queryFn: () => searchSpacesApiService.getSearchSpace({ id: searchSpaceId }),
|
|
||||||
enabled: !!searchSpaceId,
|
|
||||||
});
|
|
||||||
|
|
||||||
const { mutateAsync: updateSearchSpace } = useAtomValue(updateSearchSpaceMutationAtom);
|
|
||||||
|
|
||||||
const [saving, setSaving] = useState(false);
|
|
||||||
const [editQuery, setEditQuery] = useState("");
|
|
||||||
const [editing, setEditing] = useState(false);
|
|
||||||
const [showInput, setShowInput] = useState(false);
|
|
||||||
const textareaRef = useRef<HTMLInputElement>(null);
|
|
||||||
const inputContainerRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
const memory = searchSpace?.shared_memory_md || "";
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!showInput) return;
|
|
||||||
|
|
||||||
const handlePointerDownOutside = (event: MouseEvent | TouchEvent) => {
|
|
||||||
const target = event.target;
|
|
||||||
if (!(target instanceof Node)) return;
|
|
||||||
if (inputContainerRef.current?.contains(target)) return;
|
|
||||||
|
|
||||||
setShowInput(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
document.addEventListener("mousedown", handlePointerDownOutside);
|
|
||||||
document.addEventListener("touchstart", handlePointerDownOutside, { passive: true });
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
document.removeEventListener("mousedown", handlePointerDownOutside);
|
|
||||||
document.removeEventListener("touchstart", handlePointerDownOutside);
|
|
||||||
};
|
|
||||||
}, [showInput]);
|
|
||||||
|
|
||||||
const handleClear = async () => {
|
|
||||||
try {
|
|
||||||
setSaving(true);
|
|
||||||
await updateSearchSpace({
|
|
||||||
id: searchSpaceId,
|
|
||||||
data: { shared_memory_md: "" },
|
|
||||||
});
|
|
||||||
toast.success("Team memory cleared");
|
|
||||||
} catch {
|
|
||||||
toast.error("Failed to clear team memory");
|
|
||||||
} finally {
|
|
||||||
setSaving(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleEdit = async () => {
|
|
||||||
const query = editQuery.trim();
|
|
||||||
if (!query) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
setEditing(true);
|
|
||||||
await baseApiService.post(
|
|
||||||
`/api/v1/searchspaces/${searchSpaceId}/memory/edit`,
|
|
||||||
SearchSpaceSchema,
|
|
||||||
{ body: { query } }
|
|
||||||
);
|
|
||||||
setEditQuery("");
|
|
||||||
setShowInput(false);
|
|
||||||
await queryClient.invalidateQueries({
|
|
||||||
queryKey: cacheKeys.searchSpaces.detail(searchSpaceId.toString()),
|
|
||||||
});
|
|
||||||
toast.success("Team memory updated");
|
|
||||||
} catch {
|
|
||||||
toast.error("Failed to edit team memory");
|
|
||||||
} finally {
|
|
||||||
setEditing(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const openInput = () => {
|
|
||||||
setShowInput(true);
|
|
||||||
requestAnimationFrame(() => textareaRef.current?.focus());
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDownload = () => {
|
|
||||||
if (!memory) return;
|
|
||||||
try {
|
|
||||||
const blob = new Blob([memory], { type: "text/markdown;charset=utf-8" });
|
|
||||||
const url = URL.createObjectURL(blob);
|
|
||||||
const a = document.createElement("a");
|
|
||||||
a.href = url;
|
|
||||||
a.download = "team-memory.md";
|
|
||||||
document.body.appendChild(a);
|
|
||||||
a.click();
|
|
||||||
document.body.removeChild(a);
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
} catch {
|
|
||||||
toast.error("Failed to download team memory");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCopyMarkdown = async () => {
|
|
||||||
if (!memory) return;
|
|
||||||
try {
|
|
||||||
await navigator.clipboard.writeText(memory);
|
|
||||||
toast.success("Copied to clipboard");
|
|
||||||
} catch {
|
|
||||||
toast.error("Failed to copy team memory");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
|
||||||
if (e.key === "Enter" && !e.shiftKey) {
|
|
||||||
e.preventDefault();
|
|
||||||
handleEdit();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const displayMemory = memory.replace(/\(\d{4}-\d{2}-\d{2}\)\s*\[(fact|pref|instr)\]\s*/g, "");
|
|
||||||
const charCount = memory.length;
|
|
||||||
|
|
||||||
const getCounterColor = () => {
|
|
||||||
if (charCount > MEMORY_HARD_LIMIT) return "text-red-500";
|
|
||||||
if (charCount > 15_000) return "text-orange-500";
|
|
||||||
if (charCount > 10_000) return "text-yellow-500";
|
|
||||||
return "text-muted-foreground";
|
|
||||||
};
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-center py-12">
|
|
||||||
<Spinner size="md" className="text-muted-foreground" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!memory) {
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col items-center justify-center py-16 text-center">
|
|
||||||
<h3 className="text-base font-medium text-foreground">
|
|
||||||
What does SurfSense remember about your team?
|
|
||||||
</h3>
|
|
||||||
<p className="mt-2 max-w-sm text-sm text-muted-foreground">
|
|
||||||
Nothing yet. SurfSense picks up on team decisions and conventions as your team chats.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<Alert>
|
|
||||||
<Info />
|
|
||||||
<AlertDescription>
|
|
||||||
<p>
|
|
||||||
SurfSense uses this shared memory to provide team-wide context across all conversations
|
|
||||||
in this search space.
|
|
||||||
</p>
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
|
|
||||||
<div className="relative h-[380px] rounded-lg border bg-background">
|
|
||||||
<div className="h-full overflow-y-auto scrollbar-thin">
|
|
||||||
<PlateEditor
|
|
||||||
markdown={displayMemory}
|
|
||||||
readOnly
|
|
||||||
preset="readonly"
|
|
||||||
variant="default"
|
|
||||||
editorVariant="none"
|
|
||||||
className="px-5 py-4 text-sm min-h-full"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{showInput ? (
|
|
||||||
<div className="absolute bottom-3 inset-x-3 z-10">
|
|
||||||
<div
|
|
||||||
ref={inputContainerRef}
|
|
||||||
className="relative flex h-[54px] items-center gap-2 rounded-[9999px] border bg-muted/60 backdrop-blur-sm pl-4 pr-1 shadow-sm"
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
ref={textareaRef}
|
|
||||||
type="text"
|
|
||||||
value={editQuery}
|
|
||||||
onChange={(e) => setEditQuery(e.target.value)}
|
|
||||||
onKeyDown={handleKeyDown}
|
|
||||||
placeholder="Tell SurfSense what to remember or forget about your team"
|
|
||||||
disabled={editing}
|
|
||||||
className="flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground/70"
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
size="icon"
|
|
||||||
variant="ghost"
|
|
||||||
onClick={handleEdit}
|
|
||||||
disabled={editing || !editQuery.trim()}
|
|
||||||
className={`h-11 w-11 shrink-0 rounded-full ${
|
|
||||||
editing
|
|
||||||
? ""
|
|
||||||
: "bg-muted-foreground/15 hover:bg-accent hover:text-accent-foreground"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{editing ? (
|
|
||||||
<Spinner size="sm" />
|
|
||||||
) : (
|
|
||||||
<ArrowUp className="!h-5 !w-5 text-foreground" strokeWidth={2.25} />
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
size="icon"
|
|
||||||
variant="secondary"
|
|
||||||
onClick={openInput}
|
|
||||||
className="absolute bottom-3 right-3 z-10 h-[54px] w-[54px] rounded-full border bg-muted/60 backdrop-blur-sm shadow-sm"
|
|
||||||
>
|
|
||||||
<Pencil className="!h-5 !w-5" />
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center justify-between gap-2">
|
|
||||||
<span className={`text-xs shrink-0 ${getCounterColor()}`}>
|
|
||||||
{charCount.toLocaleString()} / {MEMORY_HARD_LIMIT.toLocaleString()}
|
|
||||||
<span className="hidden sm:inline"> characters</span>
|
|
||||||
<span className="sm:hidden"> chars</span>
|
|
||||||
{charCount > 15_000 && charCount <= MEMORY_HARD_LIMIT && " - Approaching limit"}
|
|
||||||
{charCount > MEMORY_HARD_LIMIT && " - Exceeds limit"}
|
|
||||||
</span>
|
|
||||||
<div className="flex items-center gap-1.5 sm:gap-2">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="destructive"
|
|
||||||
size="sm"
|
|
||||||
className="text-xs sm:text-sm"
|
|
||||||
onClick={handleClear}
|
|
||||||
disabled={saving || editing || !memory}
|
|
||||||
>
|
|
||||||
<span className="hidden sm:inline">Reset Memory</span>
|
|
||||||
<span className="sm:hidden">Reset</span>
|
|
||||||
</Button>
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<Button type="button" variant="secondary" size="sm" disabled={!memory}>
|
|
||||||
Export
|
|
||||||
<ChevronDown className="h-3 w-3 opacity-60" />
|
|
||||||
</Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="end">
|
|
||||||
<DropdownMenuItem onClick={handleCopyMarkdown}>
|
|
||||||
<ClipboardCopy className="h-4 w-4 mr-2" />
|
|
||||||
Copy as Markdown
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem onClick={handleDownload}>
|
|
||||||
<Download className="h-4 w-4 mr-2" />
|
|
||||||
Download as Markdown
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -15,7 +15,7 @@ import {
|
||||||
import { baseApiService } from "@/lib/apis/base-api.service";
|
import { baseApiService } from "@/lib/apis/base-api.service";
|
||||||
import { authenticatedFetch } from "@/lib/auth-utils";
|
import { authenticatedFetch } from "@/lib/auth-utils";
|
||||||
import { clearActivePodcastTaskId, setActivePodcastTaskId } from "@/lib/chat/podcast-state";
|
import { clearActivePodcastTaskId, setActivePodcastTaskId } from "@/lib/chat/podcast-state";
|
||||||
|
import { BACKEND_URL } from "@/lib/env-config";
|
||||||
/**
|
/**
|
||||||
* Zod schemas for runtime validation
|
* Zod schemas for runtime validation
|
||||||
*/
|
*/
|
||||||
|
|
@ -194,7 +194,7 @@ function PodcastPlayer({
|
||||||
// Authenticated view - fetch audio and details in parallel
|
// Authenticated view - fetch audio and details in parallel
|
||||||
const [audioResponse, details] = await Promise.all([
|
const [audioResponse, details] = await Promise.all([
|
||||||
authenticatedFetch(
|
authenticatedFetch(
|
||||||
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/podcasts/${podcastId}/audio`,
|
`${BACKEND_URL}/api/v1/podcasts/${podcastId}/audio`,
|
||||||
{ method: "GET", signal: controller.signal }
|
{ method: "GET", signal: controller.signal }
|
||||||
),
|
),
|
||||||
baseApiService.get<unknown>(`/api/v1/podcasts/${podcastId}`),
|
baseApiService.get<unknown>(`/api/v1/podcasts/${podcastId}`),
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import { Button } from "@/components/ui/button";
|
||||||
import { useMediaQuery } from "@/hooks/use-media-query";
|
import { useMediaQuery } from "@/hooks/use-media-query";
|
||||||
import { baseApiService } from "@/lib/apis/base-api.service";
|
import { baseApiService } from "@/lib/apis/base-api.service";
|
||||||
import { getAuthHeaders } from "@/lib/auth-utils";
|
import { getAuthHeaders } from "@/lib/auth-utils";
|
||||||
|
import { BACKEND_URL } from "@/lib/env-config";
|
||||||
|
|
||||||
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
|
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
|
||||||
"pdfjs-dist/build/pdf.worker.min.mjs",
|
"pdfjs-dist/build/pdf.worker.min.mjs",
|
||||||
|
|
@ -222,7 +223,7 @@ function ResumeCard({
|
||||||
const previewPath = shareToken
|
const previewPath = shareToken
|
||||||
? `/api/v1/public/${shareToken}/reports/${reportId}/preview`
|
? `/api/v1/public/${shareToken}/reports/${reportId}/preview`
|
||||||
: `/api/v1/reports/${reportId}/preview`;
|
: `/api/v1/reports/${reportId}/preview`;
|
||||||
setPdfUrl(`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}${previewPath}`);
|
setPdfUrl(`${BACKEND_URL}${previewPath}`);
|
||||||
|
|
||||||
if (autoOpen && isDesktop && !autoOpenedRef.current) {
|
if (autoOpen && isDesktop && !autoOpenedRef.current) {
|
||||||
autoOpenedRef.current = true;
|
autoOpenedRef.current = true;
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ import {
|
||||||
type CompiledSlide,
|
type CompiledSlide,
|
||||||
} from "./combined-player";
|
} from "./combined-player";
|
||||||
import { getPptxExportErrorToast, getVideoDownloadErrorToast } from "./errors";
|
import { getPptxExportErrorToast, getVideoDownloadErrorToast } from "./errors";
|
||||||
|
import { BACKEND_URL } from "@/lib/env-config";
|
||||||
|
|
||||||
const GenerateVideoPresentationArgsSchema = z.object({
|
const GenerateVideoPresentationArgsSchema = z.object({
|
||||||
source_content: z.string(),
|
source_content: z.string(),
|
||||||
|
|
@ -136,7 +137,7 @@ function VideoPresentationPlayer({
|
||||||
const [isPptxExporting, setIsPptxExporting] = useState(false);
|
const [isPptxExporting, setIsPptxExporting] = useState(false);
|
||||||
const [pptxProgress, setPptxProgress] = useState<string | null>(null);
|
const [pptxProgress, setPptxProgress] = useState<string | null>(null);
|
||||||
|
|
||||||
const backendUrl = process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL ?? "";
|
const backendUrl = BACKEND_URL ?? "";
|
||||||
const audioBlobUrlsRef = useRef<string[]>([]);
|
const audioBlobUrlsRef = useRef<string[]>([]);
|
||||||
|
|
||||||
const loadPresentation = useCallback(async () => {
|
const loadPresentation = useCallback(async () => {
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ export function FixedToolbar({
|
||||||
return (
|
return (
|
||||||
<Toolbar
|
<Toolbar
|
||||||
className={cn(
|
className={cn(
|
||||||
"scrollbar-hide absolute top-0 left-0 z-40 w-full justify-between overflow-x-auto border-b bg-background p-1",
|
"scrollbar-hide sticky top-0 z-40 w-full shrink-0 justify-between overflow-x-auto border-b bg-background p-1",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { IconUsersGroup } from "@tabler/icons-react";
|
import { IconUsersGroup } from "@tabler/icons-react";
|
||||||
import {
|
import {
|
||||||
BookOpen,
|
BookOpen,
|
||||||
|
Brain,
|
||||||
File,
|
File,
|
||||||
FileText,
|
FileText,
|
||||||
Globe,
|
Globe,
|
||||||
|
|
@ -120,6 +121,9 @@ export const getConnectorIcon = (connectorType: EnumConnectorName | string, clas
|
||||||
return <Webhook {...iconProps} />;
|
return <Webhook {...iconProps} />;
|
||||||
case "SURFSENSE_DOCS":
|
case "SURFSENSE_DOCS":
|
||||||
return <BookOpen {...iconProps} />;
|
return <BookOpen {...iconProps} />;
|
||||||
|
case "USER_MEMORY":
|
||||||
|
case "TEAM_MEMORY":
|
||||||
|
return <Brain {...iconProps} />;
|
||||||
case "DEEP":
|
case "DEEP":
|
||||||
return <Sparkles {...iconProps} />;
|
return <Sparkles {...iconProps} />;
|
||||||
case "DEEPER":
|
case "DEEPER":
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,8 @@ export const documentTypeEnum = z.enum([
|
||||||
"LOCAL_FOLDER_FILE",
|
"LOCAL_FOLDER_FILE",
|
||||||
"SURFSENSE_DOCS",
|
"SURFSENSE_DOCS",
|
||||||
"NOTE",
|
"NOTE",
|
||||||
|
"USER_MEMORY",
|
||||||
|
"TEAM_MEMORY",
|
||||||
"COMPOSIO_GOOGLE_DRIVE_CONNECTOR",
|
"COMPOSIO_GOOGLE_DRIVE_CONNECTOR",
|
||||||
"COMPOSIO_GMAIL_CONNECTOR",
|
"COMPOSIO_GMAIL_CONNECTOR",
|
||||||
"COMPOSIO_GOOGLE_CALENDAR_CONNECTOR",
|
"COMPOSIO_GOOGLE_CALENDAR_CONNECTOR",
|
||||||
|
|
|
||||||
|
|
@ -56,7 +56,6 @@ export const updateSearchSpaceRequest = z.object({
|
||||||
description: true,
|
description: true,
|
||||||
citations_enabled: true,
|
citations_enabled: true,
|
||||||
qna_custom_instructions: true,
|
qna_custom_instructions: true,
|
||||||
shared_memory_md: true,
|
|
||||||
ai_file_sort_enabled: true,
|
ai_file_sort_enabled: true,
|
||||||
})
|
})
|
||||||
.partial(),
|
.partial(),
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { authenticatedFetch } from "@/lib/auth-utils";
|
import { authenticatedFetch } from "@/lib/auth-utils";
|
||||||
|
import { BACKEND_URL } from "@/lib/env-config";
|
||||||
export interface SearchSourceConnector {
|
export interface SearchSourceConnector {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
|
|
@ -108,7 +108,7 @@ export const useSearchSourceConnectors = (lazy: boolean = false, searchSpaceId?:
|
||||||
|
|
||||||
// Build URL with optional search_space_id query parameter
|
// Build URL with optional search_space_id query parameter
|
||||||
const url = new URL(
|
const url = new URL(
|
||||||
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/search-source-connectors`
|
`${BACKEND_URL}/api/v1/search-source-connectors`
|
||||||
);
|
);
|
||||||
if (spaceId !== undefined) {
|
if (spaceId !== undefined) {
|
||||||
url.searchParams.append("search_space_id", spaceId.toString());
|
url.searchParams.append("search_space_id", spaceId.toString());
|
||||||
|
|
@ -170,7 +170,7 @@ export const useSearchSourceConnectors = (lazy: boolean = false, searchSpaceId?:
|
||||||
try {
|
try {
|
||||||
// Add search_space_id as a query parameter
|
// Add search_space_id as a query parameter
|
||||||
const url = new URL(
|
const url = new URL(
|
||||||
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/search-source-connectors`
|
`${BACKEND_URL}/api/v1/search-source-connectors`
|
||||||
);
|
);
|
||||||
url.searchParams.append("search_space_id", spaceId.toString());
|
url.searchParams.append("search_space_id", spaceId.toString());
|
||||||
|
|
||||||
|
|
@ -208,7 +208,7 @@ export const useSearchSourceConnectors = (lazy: boolean = false, searchSpaceId?:
|
||||||
) => {
|
) => {
|
||||||
try {
|
try {
|
||||||
const response = await authenticatedFetch(
|
const response = await authenticatedFetch(
|
||||||
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/search-source-connectors/${connectorId}`,
|
`${BACKEND_URL}/api/v1/search-source-connectors/${connectorId}`,
|
||||||
{
|
{
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
|
|
@ -239,7 +239,7 @@ export const useSearchSourceConnectors = (lazy: boolean = false, searchSpaceId?:
|
||||||
const deleteConnector = async (connectorId: number) => {
|
const deleteConnector = async (connectorId: number) => {
|
||||||
try {
|
try {
|
||||||
const response = await authenticatedFetch(
|
const response = await authenticatedFetch(
|
||||||
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/search-source-connectors/${connectorId}`,
|
`${BACKEND_URL}/api/v1/search-source-connectors/${connectorId}`,
|
||||||
{
|
{
|
||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
|
|
@ -284,7 +284,7 @@ export const useSearchSourceConnectors = (lazy: boolean = false, searchSpaceId?:
|
||||||
|
|
||||||
const response = await authenticatedFetch(
|
const response = await authenticatedFetch(
|
||||||
`${
|
`${
|
||||||
process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL
|
BACKEND_URL
|
||||||
}/api/v1/search-source-connectors/${connectorId}/index?${params.toString()}`,
|
}/api/v1/search-source-connectors/${connectorId}/index?${params.toString()}`,
|
||||||
{
|
{
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ import {
|
||||||
NetworkError,
|
NetworkError,
|
||||||
NotFoundError,
|
NotFoundError,
|
||||||
} from "../error";
|
} from "../error";
|
||||||
|
import { BACKEND_URL } from "@/lib/env-config";
|
||||||
enum ResponseType {
|
enum ResponseType {
|
||||||
JSON = "json",
|
JSON = "json",
|
||||||
TEXT = "text",
|
TEXT = "text",
|
||||||
|
|
@ -390,4 +390,4 @@ class BaseApiService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const baseApiService = new BaseApiService(process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL || "");
|
export const baseApiService = new BaseApiService(BACKEND_URL);
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
/**
|
/**
|
||||||
* Authentication utilities for handling token expiration and redirects
|
* Authentication utilities for handling token expiration and redirects
|
||||||
*/
|
*/
|
||||||
|
import { BACKEND_URL } from "@/lib/env-config";
|
||||||
const REDIRECT_PATH_KEY = "surfsense_redirect_path";
|
const REDIRECT_PATH_KEY = "surfsense_redirect_path";
|
||||||
const BEARER_TOKEN_KEY = "surfsense_bearer_token";
|
const BEARER_TOKEN_KEY = "surfsense_bearer_token";
|
||||||
const REFRESH_TOKEN_KEY = "surfsense_refresh_token";
|
const REFRESH_TOKEN_KEY = "surfsense_refresh_token";
|
||||||
|
|
@ -194,8 +194,7 @@ export async function logout(): Promise<boolean> {
|
||||||
// Call backend to revoke the refresh token
|
// Call backend to revoke the refresh token
|
||||||
if (refreshToken) {
|
if (refreshToken) {
|
||||||
try {
|
try {
|
||||||
const backendUrl = process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL || "http://localhost:8000";
|
const response = await fetch(`${BACKEND_URL}/auth/jwt/revoke`, {
|
||||||
const response = await fetch(`${backendUrl}/auth/jwt/revoke`, {
|
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
|
|
@ -273,8 +272,7 @@ export async function refreshAccessToken(): Promise<string | null> {
|
||||||
isRefreshing = true;
|
isRefreshing = true;
|
||||||
refreshPromise = (async () => {
|
refreshPromise = (async () => {
|
||||||
try {
|
try {
|
||||||
const backendUrl = process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL || "http://localhost:8000";
|
const response = await fetch(`${BACKEND_URL}/auth/jwt/refresh`, {
|
||||||
const response = await fetch(`${backendUrl}/auth/jwt/refresh`, {
|
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { baseApiService } from "@/lib/apis/base-api.service";
|
import { baseApiService } from "@/lib/apis/base-api.service";
|
||||||
|
import { BACKEND_URL } from "@/lib/env-config";
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Types matching backend schemas
|
// Types matching backend schemas
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
@ -228,6 +228,5 @@ export interface RegenerateParams {
|
||||||
* Get the URL for the regenerate endpoint (for streaming fetch)
|
* Get the URL for the regenerate endpoint (for streaming fetch)
|
||||||
*/
|
*/
|
||||||
export function getRegenerateUrl(threadId: number): string {
|
export function getRegenerateUrl(threadId: number): string {
|
||||||
const backendUrl = process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL || "http://localhost:8000";
|
return `${BACKEND_URL}/api/v1/threads/${threadId}/regenerate`;
|
||||||
return `${backendUrl}/api/v1/threads/${threadId}/regenerate`;
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue