diff --git a/CHANGELOG.md b/CHANGELOG.md index b78e1bb..4940ced 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,17 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V ## [Unreleased] +## [0.53.0] - 2026-11-01 + +### Added +- Administration → Server & Isolation: Schalter „KI-Agenten (MCP) erlauben“. Ausgeschaltet ist der MCP-Endpunkt abgeschaltet, ebenso das Verbinden über claude.ai und ChatGPT; bestehende Connector-Verbindungen ruhen, API-Schlüssel funktionieren per curl weiter. In den API-Schlüssel-Einstellungen verschwindet dann der Reiter „KI-Agenten (MCP)“. Mit `MCP_ENABLED=false` bzw. `true` legt der Betreiber das fest, der Schalter ist dann gesperrt. +- KI-Agenten können über MCP Notizen anlegen und lesen. Dafür gibt es bei API-Schlüsseln und beim Verbinden über claude.ai/ChatGPT zwei neue Rechte unter „Notizen“: **Lesen** (Notizen auflisten, lesen und in der Suche finden) und **Anlegen** (neue Notizen mit Punkten und Unterpunkten, auf Wunsch in einem Ordner). Bestehende Notizen kann ein Agent nicht ändern. Beide Rechte sind standardmäßig aus und gelten nur über MCP, nicht per curl. + +## [0.52.0] - 2026-11-01 + +### Added +- Notizen: Ein ausgelagerter Punkt lässt sich zurückholen. Am Link, der nach `/extract` stehen bleibt, gibt es den Knopf „Notiz zurückholen“: Die Punkte der Notiz kommen wieder als Unterpunkte an diese Stelle, der Link wird wieder zum Text, und die Notiz wandert in den Papierkorb. Strg+Z oder „Rückgängig“ macht das wieder rückgängig. Geht mit eigenen Notizen, die nirgends sonst verlinkt sind. + ## [0.51.0] - 2026-11-01 ### Added @@ -786,7 +797,9 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V - Geteilte Listen (Einkaufs-, Pack- und Checklisten) mit `#Kategorie`-Gruppierung, Mengenangaben und Echtzeit-Abhaken. - Benutzer-, Gruppen- und Admin-Verwaltung (Ersteinrichtungs-Assistent, Einladungscodes, Profilbilder, persönliche API-Keys & Webhook-Ingest `/api/v1/webhooks/ingest`, JSON-Datenexport) sowie CSS-Theme-Engine mit 4 Themes (`Dark`, `Light`, `High Contrast`, `Soft`) und PWA-Unterstützung. -[Unreleased]: https://git.dsit.me/sandrodach/MindSift/compare/v0.51.0...dev +[Unreleased]: https://git.dsit.me/sandrodach/MindSift/compare/v0.53.0...dev +[0.53.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.52.0...v0.53.0 +[0.52.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.51.0...v0.52.0 [0.51.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.50.0...v0.51.0 [0.50.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.49.2...v0.50.0 [0.49.2]: https://git.dsit.me/sandrodach/MindSift/compare/v0.49.1...v0.49.2 diff --git a/Doku/api/rest.md b/Doku/api/rest.md index a50437c..addcef6 100644 --- a/Doku/api/rest.md +++ b/Doku/api/rest.md @@ -7,7 +7,7 @@ Basis: `/api/v1`. Antworten sind JSON; Fehler haben die Form `{"error": "Text"}` | Methode | Pfad | Zweck | |---|---|---| | GET | `/health` | Lebenszeichen | -| GET | `/auth/status` | `needsSetup`, `allowRegistration`, `instanceName`, `demoMode`, `sharingMode`, `legal` (`imprintUrl`, `hasImprintContent`, `privacyUrl`, `hasPrivacyContent`, `customLinkLabel`, `customLinkUrl`), `voice` (`mode` = `server`/`browser`/`off`, `maxSeconds`, `browserAllowed`) | +| GET | `/auth/status` | `needsSetup`, `allowRegistration`, `instanceName`, `demoMode`, `sharingMode`, `legal` (`imprintUrl`, `hasImprintContent`, `privacyUrl`, `hasPrivacyContent`, `customLinkLabel`, `customLinkUrl`), `voice` (`mode` = `server`/`browser`/`off`, `maxSeconds`, `browserAllowed`), `mcpEnabled`, `mcpConnectorUrl` | | GET | `/legal/{imprint\|privacy}` | Hinterlegter Markdown-Text (`content`), ohne Anmeldung; `404`, wenn keiner gesetzt ist | | POST | `/share-target` | PWA Web Share Target Fallback: Weiterleitung auf `/` (`303 See Other`), falls Service Worker noch inaktiv | | POST | `/auth/setup` | Erstes Administratorkonto (`email`, `password`, `displayName`, `instanceName`, `allowRegistration`, `sharingMode`, `seedSampleData`); nur solange es keine Konten gibt | @@ -49,7 +49,7 @@ Basis: `/api/v1`. Antworten sind JSON; Fehler haben die Form `{"error": "Text"}` | DELETE | `/user/account` | Eigenes Konto mit allen Daten löschen (`{"password"}` nötig); `403` für den letzten Server-Admin. `/auth/me` liefert dafür `isLastAdmin` | | GET | `/backups`, `/backups/{name}` | Eigene automatische Server-Backups auflisten bzw. herunterladen | | POST | `/backups/{name}/restore` | Server-Backup einspielen: `mode=merge` oder `restore` (gewählte Bereiche vorher leeren), `sections=notes,tasks,lists,inbox,themes`, `preview=1` | -| GET/POST | `/keys` | API-Keys auflisten bzw. anlegen (Klartext nur bei Erstellung); `POST` mit `name` und `scopes` (`webhook:triage`, `webhook:task`, `webhook:list`, `tasks:read`, `tasks:write`, `lists:read`, `lists:write`; Standard `["webhook:triage"]`) | +| GET/POST | `/keys` | API-Keys auflisten bzw. anlegen (Klartext nur bei Erstellung); `POST` mit `name` und `scopes` (`webhook:triage`, `webhook:task`, `webhook:list`, `tasks:read`, `tasks:write`, `lists:read`, `lists:write`, `notes:read`, `notes:create`; Standard `["webhook:triage"]`) | | POST | `/mcp` | MCP-Endpunkt für KI-Agenten, siehe unten | | PATCH | `/keys/{id}` | Name (`name`) und/oder Rechte (`scopes`) ändern; der Schlüssel selbst bleibt gleich | | DELETE | `/keys/{id}` | API-Key löschen | @@ -83,6 +83,8 @@ Führt eine Volltextsuche über SQLite FTS5 mit BM25-Relevanz-Ranking aus. MindSift ist ein [MCP](https://modelcontextprotocol.io)-Server (Streamable HTTP, zustandslos, nur JSON-RPC 2.0 per `POST`; kein Server-Stream, `GET` antwortet `405`, Batches werden abgelehnt). Anmeldung wie überall per `X-API-Key` oder `Authorization: Bearer`; eine Sitzung darf alles, ein API-Key nur die Werkzeuge seiner Scopes, und `tools/list` zeigt ihm nur diese. Protokollversionen `2025-06-18`, `2025-03-26`, `2024-11-05`. Unterstützte Methoden: `initialize`, `ping`, `tools/list`, `tools/call`; Benachrichtigungen antworten `202`. Fehler eines Werkzeugs kommen als `isError`-Ergebnis zurück, damit der Agent sie lesen kann; Ergebnisse stehen als `structuredContent` (Listen als `{items}`) und als JSON-Text. +Betreiber können MCP abschalten (`MCP_ENABLED=false` oder Schalter in der Administration, Feld `mcpEnabled` in `/admin/settings`). Dann antwortet der Endpunkt mit `404`, OAuth ist ebenfalls aus, und `GET /auth/status` liefert `mcpEnabled: false`. + | Werkzeug | Scope | Zweck | |---|---|---| | `list_task_lists` | `tasks:read` | Aufgabenlisten mit `permission`, offenen Aufgaben und Board-Spalten (`columns`: gültige Werte für `kanbanStatus`) | @@ -99,7 +101,10 @@ MindSift ist ein [MCP](https://modelcontextprotocol.io)-Server (Streamable HTTP, | `add_list_item` | `lists:write` | Eintrag hinzufügen (`listId`, `content`, `category`, `quantity`) | | `check_list_item` | `lists:write` | Eintrag abhaken (`completed` false = zurück) | | `update_list_item` | `lists:write` | Text, Kategorie, Menge ändern | -| `search` | ein Lese-Scope | Volltextsuche in Aufgaben und Listeneinträgen (nur die Teile der eigenen Scopes, keine Notizen) | +| `list_notes` | `notes:read` | Notizen ohne Inhalt, zuletzt geänderte zuerst (`folder` samt Unterordnern, `query` im Titel, `limit`) | +| `get_note` | `notes:read` | Eine Notiz; `content` sind die Punkte als eingerückte Markdown-Liste (Tab je Ebene, `- [ ]` für To-dos, Bilder und Aufnahmen als Platzhalter) | +| `create_note` | `notes:create` | Neue Notiz (`title`, `content` als Markdown wie beim Import, `folderPath`, `icon`); in geteilte Ordner nur mit Schreibrecht. Bestehende Notizen ändert kein Werkzeug | +| `search` | ein Lese-Scope | Volltextsuche in Aufgaben, Listeneinträgen und Notizen (nur die Teile der eigenen Scopes); Notizen als `notes` mit `noteId`, `title`, `snippet` | | `capture_to_inbox` | `webhook:triage` | Gedanken in die Inbox | Was ein Agent anlegt oder ändert, bekommt eine dauerhafte Herkunft (`origin`, siehe [API-Schlüssel](#api-schlüssel-rechte-und-herkunft)). `create_task` geht auch mit `webhook:task`, `add_list_item` auch mit `webhook:list`. @@ -108,7 +113,7 @@ Freigaben gelten wie für die Person selbst: Leser-Listen lassen sich nicht änd ### OAuth für claude.ai und ChatGPT -Clients, die keinen `X-API-Key` setzen können, verbinden sich per OAuth 2.1 nach der MCP-Spezifikation (Autorisierung, Stand 2025-06-18). In claude.ai wird dafür nur `/api/v1/mcp` als eigener Connector eingetragen; den Rest erledigt der Client. Voraussetzung ist `BASE_URL` (Issuer und Ressource); ohne `BASE_URL` und im Demo-Modus antworten alle Endpunkte mit `404`. `GET /auth/status` liefert die Adresse als `mcpConnectorUrl` (leer, wenn OAuth aus ist). +Clients, die keinen `X-API-Key` setzen können, verbinden sich per OAuth 2.1 nach der MCP-Spezifikation (Autorisierung, Stand 2025-06-18). In claude.ai wird dafür nur `/api/v1/mcp` als eigener Connector eingetragen; den Rest erledigt der Client. Voraussetzung ist `BASE_URL` (Issuer und Ressource); ohne `BASE_URL`, im Demo-Modus und bei abgeschaltetem MCP antworten alle Endpunkte mit `404`. `GET /auth/status` liefert die Adresse als `mcpConnectorUrl` (leer, wenn OAuth aus ist). | Methode | Pfad (ohne `/api/v1`) | Zweck | |---|---|---| @@ -121,7 +126,7 @@ Clients, die keinen `X-API-Key` setzen können, verbinden sich per OAuth 2.1 nac Die Zustimmungsseite nutzt `GET`/`POST /api/v1/oauth/authorize` (nur mit Anmeldung, nicht mit Schlüsseln). -- **Rechte:** dieselben wie bei Schlüsseln, angeboten werden `tasks:read`, `tasks:write`, `lists:read`, `lists:write` und `webhook:triage`. Fragt der Client nichts Bestimmtes an, sind Lesen und Ändern für Aufgaben und Listen vorbelegt. +- **Rechte:** dieselben wie bei Schlüsseln, angeboten werden `tasks:read`, `tasks:write`, `lists:read`, `lists:write`, `notes:read`, `notes:create` und `webhook:triage`. Fragt der Client nichts Bestimmtes an, sind Lesen und Ändern für Aufgaben und Listen vorbelegt. - **Verbindung = Eintrag unter `/keys`** mit `kind: "oauth"` und dem Namen „ (OAuth)“. Rechte ändern, umbenennen und löschen wie bei jedem Schlüssel; Herkunft (`origin`) mit diesem Namen. Erneutes Zustimmen desselben Clients verwendet den Eintrag weiter. - **Tokens:** Code 10 Minuten und einmal gültig, Zugangs-Token (`mindsift_oat_…`) 1 Stunde, Refresh-Token (`mindsift_ort_…`) 90 Tage und bei jeder Erneuerung neu. Wird ein bereits ersetzter Refresh-Token noch einmal benutzt, enden alle Tokens der Verbindung. Zugangs-Tokens gelten nur für `POST /mcp`, nicht für REST oder Webhooks. - **Verfall:** Eine Verbindung, die `OAUTH_IDLE_DAYS` (Standard 90, `0` = nie) nicht benutzt wurde, wird gelöscht. @@ -140,6 +145,10 @@ Ein Schlüssel darf nur diese Endpunkte und braucht dafür eines der genannten R | `lists:read` | Listen lesen | `GET /lists`, `GET /lists/{id}` | `list_lists`, `get_list`, `search` | | `webhook:list` | Listeneinträge hinzufügen | `POST /lists/{id}/items`, `/webhooks/ingest` mit Ziel `list_item` | `add_list_item` | | `lists:write` | Listen ändern (schließt Hinzufügen ein) | dazu `PATCH`/`PUT /lists/{id}/items/{itemId}` | dazu `check_list_item`, `update_list_item`, `create_list` | +| `notes:read` | Notizen lesen | – | `list_notes`, `get_note`, `search` | +| `notes:create` | Notizen anlegen | – | `create_note` | + +Notizen gibt es nur über MCP; per REST bleiben sie für Schlüssel gesperrt. Notizen tragen keine Herkunft (`origin`). Die Ids `webhook:…` stammen aus der Zeit, als Schlüssel nur einliefern konnten, und bleiben für bestehende Schlüssel gleich. @@ -211,6 +220,7 @@ Schreibzugriffe von Lesern auf Listen oder deren Aufgaben liefern `403` („Kein | GET | `/journal` | Tage des eigenen Journals, neueste zuerst → `{days: [{date, docId, empty}]}` (`empty`: Notiz noch ohne Text; Tage im Papierkorb fehlen) | | POST | `/journal/{date}` | Notiz des Tages (`JJJJ-MM-TT`) öffnen; gibt es noch keine, wird sie angelegt (Titel z. B. „Freitag, 30. Oktober 2026“, Icon 📅) → Dokument, `201` bei Neuanlage. Die Journal-Notizen sind normale Notizen (Teilen, Suche, Sync) | | POST | `/documents/{id}/extract/undo` | Legt die ausgelagerte Notiz `{id}` wieder in den Papierkorb (nur ihr Ersteller) | +| POST | `/documents/{id}/inline` | `{blockId}`: Gegenstück zu `extract` für einen Punkt, der nur aus `[[Notiz]]` (ggf. mit Hashtags) besteht → `{sourceId, content, blocks}`: Text für den Punkt (Titel plus Hashtags) und die Punkte der Notiz mit neuen IDs (`level` 0 = direkt unter dem Punkt). Die verlinkte Notiz kommt in den Papierkorb; rückgängig mit `POST /documents/{sourceId}/restore`. Die Ursprungsnotiz ändert der Editor selbst. `400` kein Link-Punkt, `404` Notiz nicht gefunden, `403` fremde Notiz, `409` noch aus anderen Notizen verlinkt | | POST | `/documents/{id}/merge` | `{sourceId}`: hängt die (eigene) Quellnotiz als Überschrift mit Unterpunkten an diese Notiz an, Quelle in den Papierkorb → `{targetId, sourceId, sourceTitle, headingId}` | | POST | `/documents/{id}/merge/undo` | `{sourceId, headingId}`: Abschnitt wieder entfernen, Quelle wiederherstellen | | POST | `/documents/{id}/media` | Bild oder Audio zu einer Notiz hochladen (`multipart/form-data`, Feld `file`; Schreibrecht nötig) → `{id, kind, mime, size, url}`. `415` falsches Format, `413` zu groß oder Kontingent voll | diff --git a/Doku/architecture.md b/Doku/architecture.md index c5b3b8f..070f2bf 100644 --- a/Doku/architecture.md +++ b/Doku/architecture.md @@ -78,6 +78,7 @@ Das Frontend liegt per `go:embed` im Binary ([cmd/server/main.go](../cmd/server/ | `MEDIA_MAX_IMAGE_MB`, `MEDIA_MAX_AUDIO_MB` | `15` / `50` | Größtes Bild bzw. größte Audiodatei in Notizen in MB | | `MEDIA_QUOTA_MB` | `0` | Speicherplatz für Medien pro Benutzer in MB (`0` = unbegrenzt) | | `MEDIA_DEMO_MAX_MB` | `3` | Nur im Demo-Modus: maximale Dateigröße für Medien-Uploads in MB | +| `MCP_ENABLED` | leer | `false` schaltet `/api/v1/mcp` ab (antwortet `404`, auch ohne Anmeldung) und damit OAuth; `true` lässt es fest an. Leer: Schalter „KI-Agenten (MCP) erlauben“ in Administration → Server & Isolation (Standard: an) | | `RETENTION_ALLOW_NEVER` | leer | `false` verbietet „nie“ bei den Aufbewahrungsfristen (Kontoeinstellungen → Aufbewahrung); wer „nie“ gewählt hatte, bekommt die Voreinstellung des Bereichs. `true` erlaubt es fest. Leer: Schalter in Administration → Server & Isolation (Standard: erlaubt) | | `STT_URL` | leer | Eigene Spracherkennung: Basis-URL eines OpenAI-kompatiblen Whisper-Servers (z. B. `http://whisper:8000`, speaches). Gesetzt = Server-Modus, sonst Spracherkennung des Browsers; in der Demo immer Browser | | `STT_MODEL`, `STT_LANGUAGE`, `STT_API_KEY` | `deepdml/faster-whisper-large-v3-turbo-ct2` / `de` / leer | Modell, Sprache (`auto` = erkennen), Schlüssel falls der Whisper-Server einen verlangt | @@ -100,8 +101,8 @@ Hinweis: `DATABASE_URL` mit `postgres…` führt dazu, dass der Standardpfad in - **Einladungscodes:** `INV-` plus 100 Zufallsbits in Base32. Die Registrierung beansprucht den Code, bevor das Konto entsteht (kein doppeltes Einlösen). - **JWT und Sitzungen:** HS256, Claims `userId`, `email`, `displayName`, `tokenVersion`, `jti`. Jede Anmeldung legt eine Zeile in `sessions` an (Gerät, IP, letzte Nutzung); `jti` ist deren ID. Gültig ist ein Token nur, wenn die Sitzung aktiv ist (nicht beendet, höchstens 30 Tage unbenutzt), das Konto aktiv ist und `tokenVersion` mit `users.token_version` übereinstimmt. Ist das Token älter als 24 Stunden, schickt der Server im Header `X-Session-Token` ein neues für dieselbe Sitzung; das Frontend übernimmt es (`request()` in `lib/api.ts`). Aktive Geräte bleiben so angemeldet, unbenutzte fallen nach 30 Tagen heraus (`internal/api/sessions.go`). Neue Passwörter brauchen mindestens 8 Zeichen (`auth.ValidatePassword`). `token_version` wird bei Passwortänderung, Rollenwechsel, Deaktivierung und „Alle Sitzungen beenden“ erhöht. - **Logout** beendet die Sitzung des Geräts; `GET /auth/sessions` listet die Geräte, `DELETE /auth/sessions/{id}` meldet eines ab. Passwort und Login-E-Mail ändern sich nur mit dem aktuellen Passwort (`currentPassword`, falsche Eingaben zählen für die Login-Sperre); ein neues Passwort meldet alle anderen Geräte ab, dieses Gerät erhält per `X-Session-Token` ein neues Token. Ist SMTP eingerichtet, geht eine Hinweismail an die bisherige Adresse. -- **API-Keys:** `mindsift_sec_`, gespeichert als SHA-256, nur das Präfix bleibt sichtbar. Der Key gilt als Bearer-Token oder in `X-API-Key`. Scopes `webhook:*` liefern Daten an (`POST /inbox`, `POST /webhooks/ingest`), die Agenten-Scopes `tasks:read`, `tasks:write`, `lists:read`, `lists:write` gelten nur am MCP-Endpunkt (`POST /mcp`, `internal/api/mcp.go`); jedes Werkzeug prüft seinen Scope, `tools/list` zeigt nur erlaubte. Alle anderen Endpunkte antworten für Keys mit `403`; Notizen sind mit Keys nie erreichbar. -- **OAuth für MCP** (`internal/api/oauth.go`, `internal/storage/oauth.go`): MindSift ist mit `BASE_URL` sein eigener OAuth-Server für `/api/v1/mcp` (Discovery nach RFC 9728/8414, dynamische Registrierung, Code-Flow mit PKCE S256, rotierende Refresh-Tokens), damit sich claude.ai und ChatGPT verbinden können. Die Zustimmungsseite ist Teil der App (`/oauth/authorize`, `OAuthConsentView`), weil die Anmeldung im `localStorage` liegt. Eine Zustimmung wird ein Eintrag in `api_keys` (`kind = 'oauth'`); Tokens (`mindsift_oat_`/`mindsift_ort_`) hängen daran und gelten nur am MCP-Endpunkt, Rechte und Herkunft laufen wie bei Schlüsseln. Abgelaufene Tokens und unbenutzte Verbindungen (`OAUTH_IDLE_DAYS`) räumt der stündliche Aufräum-Lauf weg. Im Demo-Modus aus. +- **API-Keys:** `mindsift_sec_`, gespeichert als SHA-256, nur das Präfix bleibt sichtbar. Der Key gilt als Bearer-Token oder in `X-API-Key`. Scopes `webhook:*` liefern Daten an (`POST /inbox`, `POST /webhooks/ingest`), die Agenten-Scopes `tasks:read`, `tasks:write`, `lists:read`, `lists:write` gelten nur am MCP-Endpunkt (`POST /mcp`, `internal/api/mcp.go`); jedes Werkzeug prüft seinen Scope, `tools/list` zeigt nur erlaubte. Alle anderen Endpunkte antworten für Keys mit `403`. Notizen erreichen Keys nur über MCP (`notes:read`: auflisten, lesen, suchen; `notes:create`: neue anlegen, Inhalt als Markdown über `MarkdownToBlocks`), nie per REST. +- **OAuth für MCP** (`internal/api/oauth.go`, `internal/storage/oauth.go`): MindSift ist mit `BASE_URL` sein eigener OAuth-Server für `/api/v1/mcp` (Discovery nach RFC 9728/8414, dynamische Registrierung, Code-Flow mit PKCE S256, rotierende Refresh-Tokens), damit sich claude.ai und ChatGPT verbinden können. Die Zustimmungsseite ist Teil der App (`/oauth/authorize`, `OAuthConsentView`), weil die Anmeldung im `localStorage` liegt. Eine Zustimmung wird ein Eintrag in `api_keys` (`kind = 'oauth'`); Tokens (`mindsift_oat_`/`mindsift_ort_`) hängen daran und gelten nur am MCP-Endpunkt, Rechte und Herkunft laufen wie bei Schlüsseln. Abgelaufene Tokens und unbenutzte Verbindungen (`OAUTH_IDLE_DAYS`) räumt der stündliche Aufräum-Lauf weg. Im Demo-Modus aus, ebenso wenn MCP abgeschaltet ist (`MCP_ENABLED` bzw. Admin-Schalter). - **Passwort vergessen:** nur wenn SMTP und `BASE_URL` gesetzt sind (nicht im Demo-Modus; `passwordReset` in `/auth/status`). `POST /auth/forgot-password` antwortet immer gleich und verschickt die Mail im Hintergrund, höchstens alle 2 Minuten pro Konto. Der Link `BASE_URL/?resetToken=…` gilt eine Stunde und einmal; gespeichert wird nur der SHA-256-Hash (`password_reset_tokens`). `POST /auth/reset-password` setzt das Passwort, entwertet alle offenen Links des Kontos und erhöht `token_version` (alle Sitzungen enden). Die App entfernt den Token sofort aus der Adresszeile. - **Registrierung:** nur mit Einladungscode oder wenn `allow_registration` aktiv ist. Das erste Konto entsteht über `/auth/setup` und wird Administrator. diff --git a/Doku/modules/workspace.md b/Doku/modules/workspace.md index b6a5838..d393057 100644 --- a/Doku/modules/workspace.md +++ b/Doku/modules/workspace.md @@ -18,6 +18,7 @@ Code: [WorkspaceView.tsx](../../frontend/src/features/workspace/WorkspaceView.ts - **Slash-Befehle:** `/todo`, `/link`, `/h1`, `/h2`, `/h3`, `/code` (mit Syntax-Highlighting im VS-Code-Stil), `/callout`, `/divider`, `/extract` (Ast als eigene Notiz auslagern), `/image` (Bilder einfügen/hochladen, Aliase `/bild`, `/foto`), `/audio` (Sprachnotiz/Audiodatei, Alias `/sprachnotiz`), `/diktat` (Spracheingabe mit Live-Mitschreiben, Alias `/dictate`) (Liste in `OutlinerBlock.tsx`). Das Menü öffnet sich nur am Textanfang oder nach einem Leerzeichen und schließt mit `Esc` oder `Backspace`. - **Link-Vorschau:** Besteht ein Bulletpoint ausschließlich aus einer einzelnen `https`-Adresse (oder wurde über `/link` in einem leeren Punkt eingefügt), zeigt MindSift darüber eine interaktive Vorschaukarte (`LinkPreviewCard`, serverseitig via `/link-preview`). ✕ blendet die Karte aus und wandelt die Adresse synchronisiert in `[Titel](url)` um. - **Ast auslagern (`/extract`):** Wandelt den aktuellen Punkt samt Unterpunkten in eine eigenständige Notiz im selben Ordner um. Der Server legt die Notiz an und liefert den Link zurück; der Editor ersetzt den Ast lokal über die Sync-Engine als eigene Operation (Strg+Z nimmt die Auslagerung zurück und legt die neue Notiz via `POST /documents/{id}/extract/undo` in den Papierkorb). +- **Notiz zurückholen:** Besteht ein Punkt nur aus einem Link auf eine Notiz (wie nach `/extract`), zeigt seine Knopfleiste „Notiz zurückholen“. `POST /documents/{id}/inline` liefert die Punkte der verlinkten Notiz und legt sie in den Papierkorb; der Editor setzt den Titel (mit den Hashtags des Links) wieder als Text ein und fügt die Punkte als Unterpunkte ein, vor eventuell schon vorhandenen. Strg+Z oder „Rückgängig“ stellt den Link wieder her und holt die Notiz aus dem Papierkorb. Nur mit eigenen Notizen, und nicht, solange andere Notizen sie noch verlinken. - **Notizen zusammenführen (Merge):** Per Drag & Drop einer Notiz auf eine andere in der Seitenleiste oder über das Drei-Punkte-Menü im Notizkopf („Mit anderer Notiz zusammenführen…“). Die eigene Quellnotiz wird als `h2`-Abschnitt angehängt und in den Papierkorb verschoben; der Hinweis bietet vollständiges Rückgängigmachen (`POST /documents/{id}/merge/undo`). - **Rückverweise:** Unter jeder Notiz listet „Verlinkt auf diese Notiz (n)“ alle Notizen mit `[[Titel]]` (auch `[[Titel|Text]]`, `[[Titel#Abschnitt]]`, Groß-/Kleinschreibung egal) samt Fundstelle; ein Klick springt direkt zum Bullet. Ohne Rückverweise bleibt der Bereich unsichtbar, eingeklappt merkt er sich im Browser ([BacklinksSection.tsx](../../frontend/src/features/workspace/BacklinksSection.tsx), `GET /documents/{id}/backlinks`). - **Links:** `[[Notiztitel]]`, Markdown-Links `[Titel](url)` und reine URLs werden als Token dargestellt; `LinkModal` hilft beim Verknüpfen von Notizen. diff --git a/README.md b/README.md index 185894a..fc665d8 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ [![Self-Hosted](https://img.shields.io/badge/100%25-Self--Hosted-2563eb?style=flat-square)](#) [![Docker](https://img.shields.io/badge/Deployment-Single%20Container-2496ed?style=flat-square&logo=docker&logoColor=white)](#-schnellstart) [![SQLite](https://img.shields.io/badge/Daten-SQLite-003b57?style=flat-square&logo=sqlite&logoColor=white)](#-deine-daten-gehören-dir) -[![Version](https://img.shields.io/badge/Version-v0.51.0-16a34a?style=flat-square)](CHANGELOG.md) +[![Version](https://img.shields.io/badge/Version-v0.53.0-16a34a?style=flat-square)](CHANGELOG.md) **MindSift** ist eine selbst gehostete Schaltstelle für spontane Gedanken, Aufgaben mit Kanban-Board, hierarchische Outliner-Notizen, die mehrere Personen gleichzeitig bearbeiten können, und gemeinsame Listen. Alles läuft als ein einzelner Container mit eingebetteter SQLite-Datenbank, auf deinem eigenen Server. @@ -195,6 +195,7 @@ Rufe `http://localhost:8080` auf. Beim ersten Start führt ein Assistent durch d | `JWT_SECRET` | zufällig erzeugt (`/data/jwt.secret`) | Signaturschlüssel der Anmelde-Tokens; Platzhalter werden ignoriert | | `BASE_URL` | `http://localhost:8080` | Öffentliche URL. Nur wenn gesetzt (und SMTP eingerichtet ist), gibt es „Passwort vergessen?“ – die Links in den Mails werden ausschließlich daraus gebaut | | `TRUSTED_PROXIES` | `private` | Wessen `X-Forwarded-For`/`X-Real-IP` geglaubt wird: `private` (Proxy im Docker-/Heimnetz), `all` (immer, früheres Verhalten), `none` oder eigene CIDR-Bereiche ([Doku/reverse-proxy.md](Doku/reverse-proxy.md#client-ip-und-trusted_proxies)) | +| `MCP_ENABLED` | leer (Admin-UI entscheidet) | `false` schaltet den MCP-Endpunkt für KI-Agenten ab, damit auch die Connector-Anmeldung (OAuth) für claude.ai und ChatGPT; `true` lässt ihn fest an. API-Schlüssel funktionieren per curl weiter | | `OAUTH_IDLE_DAYS` | `90` | Verbindungen von claude.ai, ChatGPT & Co. (OAuth), die so viele Tage nicht benutzt wurden, werden getrennt. `0` = nie. OAuth selbst gibt es nur mit `BASE_URL` | | `LOGIN_PROTECTION` | `true` | Sperre nach Fehlversuchen: 3 pro Konto und Adresse, 20 pro Konto, 30 pro Adresse, jeweils 15 Minuten. `false` schaltet sie ab | | `TZ` | `Europe/Berlin` | Zeitzone für Fälligkeiten und Alarme (ganztägige Aufgaben melden sich um 9:00) | diff --git a/frontend/package-lock.json b/frontend/package-lock.json index b404814..dcde0d0 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "mindsift-frontend", - "version": "0.51.0", + "version": "0.53.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "mindsift-frontend", - "version": "0.51.0", + "version": "0.53.0", "license": "AGPL-3.0-only", "dependencies": { "@fontsource-variable/inter": "5.3.0", diff --git a/frontend/package.json b/frontend/package.json index 5d01c1b..438abdc 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "mindsift-frontend", "private": true, - "version": "0.51.0", + "version": "0.53.0", "license": "AGPL-3.0-only", "type": "module", "scripts": { diff --git a/frontend/src/features/auth/OAuthConsentView.tsx b/frontend/src/features/auth/OAuthConsentView.tsx index e4aff57..e14481a 100644 --- a/frontend/src/features/auth/OAuthConsentView.tsx +++ b/frontend/src/features/auth/OAuthConsentView.tsx @@ -85,7 +85,7 @@ export const OAuthConsentView: React.FC = ({ user, instan

{info.clientName} möchte auf dein MindSift zugreifen

Angemeldet als {user.displayName || user.email}. Freigaben gelten wie - für dich selbst; Notizen und Einstellungen bleiben gesperrt. + für dich selbst; Einstellungen bleiben gesperrt.

diff --git a/frontend/src/features/help/HelpView.tsx b/frontend/src/features/help/HelpView.tsx index e4a2fbf..5f95790 100644 --- a/frontend/src/features/help/HelpView.tsx +++ b/frontend/src/features/help/HelpView.tsx @@ -207,7 +207,7 @@ export const HelpView: React.FC = ({ onBack }) => {
📤 - /extract oder /notiz (Punkt mit allen Unterpunkten in eine eigene Notiz auslagern, an seiner Stelle bleibt ein [[Link]]) + /extract oder /notiz (Punkt mit allen Unterpunkten in eine eigene Notiz auslagern, an seiner Stelle bleibt ein [[Link]]; der Knopf „Notiz zurückholen“ am Link macht das wieder rückgängig)
🎙️ diff --git a/frontend/src/features/settings/AdminSettingsView.tsx b/frontend/src/features/settings/AdminSettingsView.tsx index d7cec05..881d1ae 100644 --- a/frontend/src/features/settings/AdminSettingsView.tsx +++ b/frontend/src/features/settings/AdminSettingsView.tsx @@ -160,6 +160,8 @@ export const AdminSettingsView: React.FC = ({ backupRetentionDays: sys.backupRetentionDays || 3, retentionAllowNever: sys.retentionAllowNever ?? true, retentionNeverViaEnv: Boolean(sys.retentionNeverViaEnv), + mcpEnabled: sys.mcpEnabled ?? true, + mcpConfiguredViaEnv: Boolean(sys.mcpConfiguredViaEnv), }); } } catch (err: any) { @@ -1334,6 +1336,24 @@ export const AdminSettingsView: React.FC = ({ />
+ {/* AI agents over MCP (and claude.ai / ChatGPT over OAuth) */} +
+
+

KI-Agenten (MCP) erlauben

+

+ Eingeschaltet können Claude Code, Codex & Co. per API-Schlüssel und claude.ai oder ChatGPT als Connector + auf MindSift zugreifen. Ausgeschaltet ist der MCP-Endpunkt samt Connector-Anmeldung abgeschaltet; + bestehende Connector-Verbindungen ruhen, API-Schlüssel funktionieren per curl weiter. + {settings.mcpConfiguredViaEnv && ' Festgelegt per MCP_ENABLED.'} +

+
+ handleSaveSystemSettings({ mcpEnabled })} + /> +
+ {/* Who may register without an invite */} {(() => { const mode = settings.registrationMode || 'closed'; diff --git a/frontend/src/features/settings/ApiKeysPanel.tsx b/frontend/src/features/settings/ApiKeysPanel.tsx index a9d7640..7495147 100644 --- a/frontend/src/features/settings/ApiKeysPanel.tsx +++ b/frontend/src/features/settings/ApiKeysPanel.tsx @@ -37,6 +37,8 @@ export const ApiKeysPanel: React.FC = ({ onIngested }) => { const [connectorUrl, setConnectorUrl] = useState(''); const [copiedConnector, setCopiedConnector] = useState(false); const [access, setAccess] = useState('rest'); + // The operator can switch MCP off; then only the curl / smart home access is shown + const [mcpEnabled, setMcpEnabled] = useState(true); // Live Webhook Simulator State const [simTitle, setSimTitle] = useState('Waschmaschine im Keller ist fertig!'); @@ -59,7 +61,10 @@ export const ApiKeysPanel: React.FC = ({ onIngested }) => { api.getTaskLists().then(setTaskLists).catch(() => {}); api .getAuthStatus() - .then((st) => setConnectorUrl(st.mcpConnectorUrl || '')) + .then((st) => { + setConnectorUrl(st.mcpConnectorUrl || ''); + setMcpEnabled(st.mcpEnabled !== false); + }) .catch(() => {}); }, []); @@ -95,10 +100,10 @@ export const ApiKeysPanel: React.FC = ({ onIngested }) => { } }; - // Switches grouped by tasks, lists and inbox (same layout for a new and an existing key) + // Switches grouped by tasks, lists, notes and inbox (same layout for a new and an existing key) const renderScopeSwitches = (selected: ApiKeyScope[], toggle: (id: ApiKeyScope) => void, label: string) => ( -
- {SCOPE_GROUPS.map(([group, heading]) => ( +
+ {SCOPE_GROUPS.filter(([group]) => mcpEnabled || group !== 'notes').map(([group, heading]) => (

{heading}

{SCOPES.filter((sc) => sc.group === group).map((sc) => ( @@ -232,9 +237,9 @@ http_headers = { "X-API-Key" = "${exampleToken}" }`

API-Schlüssel

Ein Schlüssel verbindet Home Assistant, n8n, eigene Skripte oder KI-Agenten (Claude Code, Codex …) mit - MindSift. Was er darf, legst du mit den Schaltern fest; das gilt per curl und per MCP gleich. Notizen und - Einstellungen bleiben für Schlüssel gesperrt. Was ein Schlüssel anlegt oder ändert, trägt seinen Namen als - Badge. + MindSift. Was er darf, legst du mit den Schaltern fest; das gilt per curl und per MCP gleich, nur Notizen + gibt es ausschließlich über MCP. Einstellungen bleiben für Schlüssel gesperrt. Was ein Schlüssel bei Aufgaben + und Listen anlegt oder ändert, trägt seinen Namen als Badge.

@@ -358,30 +363,32 @@ http_headers = { "X-API-Key" = "${exampleToken}" }` {/* How to connect: one API, two accesses */}
-
- {( - [ - ['rest', 'curl / Smart Home', Terminal], - ['mcp', 'KI-Agenten (MCP)', Bot], - ] as const - ).map(([value, label, Icon]) => ( - - ))} -
+ {mcpEnabled && ( +
+ {( + [ + ['rest', 'curl / Smart Home', Terminal], + ['mcp', 'KI-Agenten (MCP)', Bot], + ] as const + ).map(([value, label, Icon]) => ( + + ))} +
+ )} - {access === 'rest' && ( + {(access === 'rest' || !mcpEnabled) && (
{/* Reading: e.g. a smart home sensor for what is due today */}
@@ -547,7 +554,7 @@ http_headers = { "X-API-Key" = "${exampleToken}" }`
)} - {access === 'mcp' && ( + {access === 'mcp' && mcpEnabled && (
@@ -629,7 +636,9 @@ http_headers = { "X-API-Key" = "${exampleToken}" }` list_lists, create_list,{' '} get_list, add_list_item,{' '} check_list_item, update_list_item,{' '} - search, capture_to_inbox. Der Agent sieht + list_notes, get_note,{' '} + create_note, search,{' '} + capture_to_inbox. Der Agent sieht nur die Werkzeuge, für die der Schlüssel bzw. die Verbindung berechtigt ist; Freigaben gelten wie für dich selbst.

diff --git a/frontend/src/features/settings/UserSettingsView.tsx b/frontend/src/features/settings/UserSettingsView.tsx index 0538663..0910a68 100644 --- a/frontend/src/features/settings/UserSettingsView.tsx +++ b/frontend/src/features/settings/UserSettingsView.tsx @@ -1956,7 +1956,8 @@ export const UserSettingsView: React.FC = ({

API

Mit persönlichen API-Schlüsseln lesen und ändern Skripte, dein Smart Home oder KI-Agenten wie Claude Code - und Codex deine Aufgaben, Listen und die Inbox: per curl oder über MCP, mit denselben Rechten. + und Codex deine Aufgaben, Listen und die Inbox: per curl oder über MCP, mit denselben Rechten. Notizen + lesen und anlegen geht nur über MCP.

diff --git a/frontend/src/features/settings/__tests__/ApiKeysPanel.test.tsx b/frontend/src/features/settings/__tests__/ApiKeysPanel.test.tsx index 9ebc041..ea67506 100644 --- a/frontend/src/features/settings/__tests__/ApiKeysPanel.test.tsx +++ b/frontend/src/features/settings/__tests__/ApiKeysPanel.test.tsx @@ -3,13 +3,16 @@ import { render, screen, fireEvent, act, within } from '@testing-library/react'; const createAPIKey = vi.fn(async () => ({ id: 'k1', userId: 'u', name: 'HA', keyPrefix: 'mindsift_sec_ab', scopes: 'webhook:triage,webhook:list', createdAt: '' })); const updateAPIKey = vi.fn(async () => ({})); +const getAuthStatus = vi.fn(async (): Promise<{ mcpConnectorUrl?: string; mcpEnabled?: boolean }> => ({ + mcpConnectorUrl: 'https://ms.example/api/v1/mcp', +})); vi.mock('../../../lib/api', () => ({ api: { getAPIKeys: vi.fn(async () => [ { id: 'k0', userId: 'u', name: 'Rauchmelder', keyPrefix: 'mindsift_sec_xy', scopes: 'webhook:triage,webhook:task', createdAt: '' }, { id: 'k9', userId: 'u', name: 'Claude (OAuth)', keyPrefix: 'OAuth', scopes: 'tasks:read', createdAt: '', kind: 'oauth' }, ]), - getAuthStatus: vi.fn(async () => ({ mcpConnectorUrl: 'https://ms.example/api/v1/mcp' })), + getAuthStatus: (...args: unknown[]) => getAuthStatus(...(args as [])), getAllLists: vi.fn(async () => []), getTaskLists: vi.fn(async () => []), createAPIKey: (...args: unknown[]) => createAPIKey(...(args as [])), @@ -59,6 +62,15 @@ describe('ApiKeysPanel', () => { expect(screen.getByText(/\[mcp_servers\.mindsift\]/)).toBeTruthy(); }); + it('hides the MCP tab when the operator switched MCP off', async () => { + getAuthStatus.mockResolvedValueOnce({ mcpConnectorUrl: '', mcpEnabled: false }); + render(); + await act(async () => {}); + expect(screen.queryByRole('tab', { name: /KI-Agenten/ })).toBeNull(); + expect(screen.queryByRole('switch', { name: 'Notizen lesen' })).toBeNull(); + expect(screen.getAllByText(/tasks\?due=today&status=open/).length).toBeGreaterThan(0); + }); + it('marks OAuth connections instead of showing a key prefix', async () => { render(); expect(await screen.findByText('Claude (OAuth)')).toBeTruthy(); diff --git a/frontend/src/features/workspace/DocumentEditor.tsx b/frontend/src/features/workspace/DocumentEditor.tsx index 03d6441..00fc9b4 100644 --- a/frontend/src/features/workspace/DocumentEditor.tsx +++ b/frontend/src/features/workspace/DocumentEditor.tsx @@ -545,7 +545,21 @@ export const DocumentEditor: React.FC = ({ // The last extraction, so undoing it (toast or Strg+Z) also puts the new note into the trash const lastExtractRef = useRef<{ newDocId: string; blockId: string; link: string } | null>(null); + // The last note brought back, so undoing it also takes the note out of the trash again + const lastInlineRef = useRef<{ sourceId: string; blockId: string; content: string } | null>(null); const finishUndoExtract = useCallback(() => { + const inlined = lastInlineRef.current; + if (inlined) { + const block = currentBlocksRef.current.find((b) => b.id === inlined.blockId); + if (!block || block.content !== inlined.content) { + lastInlineRef.current = null; + api + .restoreDocument(inlined.sourceId) + .then(() => api.getDocument(inlined.sourceId)) + .then((restored) => onDocCreated?.(restored)) + .catch((err: any) => showNotice(err?.message || 'Notiz konnte nicht wiederhergestellt werden')); + } + } const info = lastExtractRef.current; if (!info) return; const block = currentBlocksRef.current.find((b) => b.id === info.blockId); @@ -555,7 +569,7 @@ export const DocumentEditor: React.FC = ({ .undoExtract(info.newDocId) .then(() => onDocRemoved?.(info.newDocId)) .catch((err: any) => showNotice(err?.message || 'Ausgelagerte Notiz konnte nicht entfernt werden')); - }, [onDocRemoved, showNotice]); + }, [onDocCreated, onDocRemoved, showNotice]); useEffect(() => () => { if (noticeTimerRef.current !== null) window.clearTimeout(noticeTimerRef.current); }, []); @@ -614,6 +628,7 @@ export const DocumentEditor: React.FC = ({ while (end < prev.length && (prev[end].level || 0) > level) end++; applyLocalBlocks([...prev.slice(0, i), { ...prev[i], content: link }, ...prev.slice(end)]); } + lastInlineRef.current = null; lastExtractRef.current = { newDocId: created.id, blockId, link }; onDocCreated?.(created); showNotice(`In eigene Notiz „${created.title}“ umgewandelt`, [ @@ -627,6 +642,35 @@ export const DocumentEditor: React.FC = ({ [docId, onOpenDocBlock, onDocCreated, showNotice, handleUndo, applyLocalBlocks] ); + // The way back: a bullet that only links to a note gets that note's bullets below it again, the note + // goes to the trash. Inserted here as an own edit like extracting, so Strg+Z brings the link back. + const handleInlineBlock = useCallback( + async (blockId: string) => { + const engine = engineRef.current; + if (!engine) return; + try { + await engine.syncNow(); + const res = await api.inlineNote(docId, blockId); + (document.activeElement as HTMLElement | null)?.blur?.(); + const prev = currentBlocksRef.current; + const i = prev.findIndex((b) => b.id === blockId); + if (i >= 0) { + const level = prev[i].level || 0; + const inserted = res.blocks.map((b) => ({ ...b, level: level + 1 + (b.level || 0) })); + // Above any bullets added below the link meanwhile + applyLocalBlocks([...prev.slice(0, i), { ...prev[i], content: res.content }, ...inserted, ...prev.slice(i + 1)]); + } + lastExtractRef.current = null; + lastInlineRef.current = { sourceId: res.sourceId, blockId, content: res.content }; + onDocRemoved?.(res.sourceId); + showNotice('Notiz zurückgeholt, sie liegt jetzt im Papierkorb', [{ label: 'Rückgängig', run: () => handleUndo() }]); + } catch (err: any) { + showNotice(err?.message || 'Zurückholen fehlgeschlagen'); + } + }, + [docId, onDocRemoved, showNotice, handleUndo, applyLocalBlocks] + ); + // Keep parent sidebar metadata in sync when blocks/tags change useEffect(() => { if (!docRef.current) return; @@ -1363,6 +1407,7 @@ export const DocumentEditor: React.FC = ({ levelOffset={levelOffset} onZoomBlock={setZoomedBlockId} onExtractBlock={canEdit ? handleExtractBlock : undefined} + onInlineBlock={canEdit ? handleInlineBlock : undefined} onUpdateBlock={handleUpdateBlock} onUpdateBlocks={handleUpdateBlocks} onCreateBlock={handleCreateBlock} @@ -1513,6 +1558,7 @@ export const DocumentEditor: React.FC = ({ levelOffset={0} onZoomBlock={setZoomedBlockId} onExtractBlock={canEdit ? handleExtractBlock : undefined} + onInlineBlock={canEdit ? handleInlineBlock : undefined} onUpdateBlock={handleUpdateBlock} onUpdateBlocks={handleUpdateBlocks} onCreateBlock={handleCreateBlock} diff --git a/frontend/src/features/workspace/OutlinerBlock.tsx b/frontend/src/features/workspace/OutlinerBlock.tsx index 6ddc0a6..3208ba2 100644 --- a/frontend/src/features/workspace/OutlinerBlock.tsx +++ b/frontend/src/features/workspace/OutlinerBlock.tsx @@ -24,6 +24,7 @@ import { Mic, AudioLines, FileOutput, + FileInput, Link2, Type, } from 'lucide-react'; @@ -96,6 +97,7 @@ export interface OutlinerBlockProps { onZoomBlock?: (blockId: string) => void; /** Turns this bullet and its children into a note of its own */ onExtractBlock?: (blockId: string) => void; + onInlineBlock?: (blockId: string) => void; onUpdateBlock: (updated: DocumentBlock, immediate?: boolean) => void; onCreateBlockBelow: (afterBlockId: string, newContent?: string) => void; onDeleteBlock: (blockId: string, deleteSubtree?: boolean) => void; @@ -151,6 +153,7 @@ export const OutlinerBlock: React.FC = ({ levelOffset = 0, onZoomBlock, onExtractBlock, + onInlineBlock, onUpdateBlock, onCreateBlockBelow, onDeleteBlock, @@ -1724,6 +1727,19 @@ export const OutlinerBlock: React.FC = ({ )} + {/* The way back: a bullet that only links to a note gets that note's bullets again */} + {canEdit && onInlineBlock && isNoteLinkOnly(localContent) && !mediaPicker && ( + + )} + {/* Zoom / Focus button */} {onZoomBlock && (