From 9f3c78dde0070ea7162f489444c5eb24c0808314 Mon Sep 17 00:00:00 2001 From: Sandro Date: Thu, 29 Oct 2026 22:08:35 +0100 Subject: [PATCH 01/19] feat(workspace): sidebar as a plain file explorer --- .../src/features/workspace/WorkspaceView.tsx | 201 +++++++++--------- .../__tests__/WorkspaceView.sidebar.test.tsx | 165 ++++++++++++++ 2 files changed, 261 insertions(+), 105 deletions(-) create mode 100644 frontend/src/features/workspace/__tests__/WorkspaceView.sidebar.test.tsx diff --git a/frontend/src/features/workspace/WorkspaceView.tsx b/frontend/src/features/workspace/WorkspaceView.tsx index 19d3f9b..7df2e50 100644 --- a/frontend/src/features/workspace/WorkspaceView.tsx +++ b/frontend/src/features/workspace/WorkspaceView.tsx @@ -911,7 +911,7 @@ export const WorkspaceView: React.FC = ({ ); const rootDocs = useMemo( - () => docs.filter((d) => !d.folderPath), + () => docs.filter((d) => !d.folderPath && !d.isPinned), [docs] ); @@ -1330,114 +1330,105 @@ export const WorkspaceView: React.FC = ({ {/* Scrollable Navigation Tree */}
- {/* Pinned Notes */} - {pinnedDocs.length > 0 && ( + {pinnedDocs.length === 0 && folderTree.length === 0 && rootDocs.length === 0 ? ( +
Noch keine Notizen
+ ) : (
-
- - Angeheftet -
-
- {pinnedDocs.map((d) => ( -
handleDocDragStart(e, d.id)} - onClick={() => { - setSelectedDocId(d.id); - setSubView('editor'); - setMobileShowDetail(true); - }} - className={`w-full flex items-center gap-2 px-2 py-1.5 rounded-btn text-left text-xs cursor-pointer border transition-colors ${ - selectedDocId === d.id && subView === 'editor' - ? 'bg-panel-elevated text-main font-semibold border-border/80' - : 'border-transparent text-main hover:bg-panel-elevated' - }`} - > - {d.icon || '📝'} - {d.title || 'Unbenannte Notiz'} -
- ))} -
+ {/* Pinned Notes */} + {pinnedDocs.length > 0 && ( +
0 || rootDocs.length > 0 + ? 'border-b border-border pb-1.5 mb-1.5' + : '' + }`} + > + {pinnedDocs.map((d) => ( +
handleDocDragStart(e, d.id)} + onClick={() => { + setSelectedDocId(d.id); + setSubView('editor'); + setMobileShowDetail(true); + }} + className={`w-full flex items-center gap-2 px-2 py-1.5 rounded-btn text-left text-xs cursor-pointer border transition-colors ${ + selectedDocId === d.id && subView === 'editor' + ? 'bg-panel-elevated text-main font-semibold border-border/80' + : 'border-transparent text-main hover:bg-panel-elevated' + }`} + > + {d.icon || '📝'} + {d.title || 'Unbenannte Notiz'} + {d.sharedWith && d.sharedWith.length > 0 && ( + + )} + +
+ ))} +
+ )} + + {/* Folders and Root Documents */} + {(folderTree.length > 0 || rootDocs.length > 0) && ( +
+ {folderTree.map((node) => ( + { + setSelectedDocId(id); + setTargetBlockId(null); + setNewlyCreatedDocId(null); + setSubView('editor'); + setMobileShowDetail(true); + }} + /> + ))} + + {rootDocs.map((d) => ( +
handleDocDragStart(e, d.id)} + onClick={() => { + setSelectedDocId(d.id); + setSubView('editor'); + setMobileShowDetail(true); + }} + className={`w-full flex items-center gap-2 px-2 py-1.5 rounded-btn text-left text-xs cursor-pointer border transition-colors ${ + selectedDocId === d.id && subView === 'editor' + ? 'bg-panel-elevated text-main font-semibold border-border/80' + : 'border-transparent text-main hover:bg-panel-elevated' + }`} + > + {d.icon || '📝'} + {d.title || 'Unbenannte Notiz'} + {d.sharedWith && d.sharedWith.length > 0 && ( + + )} +
+ ))} +
+ )}
)} - {/* Folders Tree with Recursive Hierarchical Nesting */} - {folderTree.length > 0 && ( -
-
- Ordner -
-
- {folderTree.map((node) => ( - { - setSelectedDocId(id); - setTargetBlockId(null); - setNewlyCreatedDocId(null); - setSubView('editor'); - setMobileShowDetail(true); - }} - /> - ))} -
-
- )} - - {/* Root Documents */} -
-
- Notizen (Hauptebene) -
- {rootDocs.length === 0 ? ( -
- Keine Notizen auf der Hauptebene. -
- ) : ( -
- {rootDocs.map((d) => ( -
handleDocDragStart(e, d.id)} - onClick={() => { - setSelectedDocId(d.id); - setSubView('editor'); - setMobileShowDetail(true); - }} - className={`w-full flex items-center gap-2 px-2 py-1.5 rounded-btn text-left text-xs cursor-pointer border transition-colors ${ - selectedDocId === d.id && subView === 'editor' - ? 'bg-panel-elevated text-main font-semibold border-border/80' - : 'border-transparent text-main hover:bg-panel-elevated' - }`} - > - {d.icon || '📝'} - {d.title || 'Unbenannte Notiz'} - {d.sharedWith && d.sharedWith.length > 0 && ( - - )} -
- ))} -
- )} -
- {/* Bottom drop target for moving to root */} {(draggingDocId || draggingFolderPath) && (
({ + DocumentEditor: () =>
DocumentEditor
, +})); + +vi.mock('../../../lib/api', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + api: { + ...actual.api, + getDocuments: vi.fn(), + getFolders: vi.fn(), + getSharedFolders: vi.fn(), + getWorkspaceTags: vi.fn(), + getTrash: vi.fn(), + }, + onWebSocketMessage: vi.fn(() => () => {}), + triggerHaptic: vi.fn(), + }; +}); + +const makeDoc = (overrides: Partial & { id: string; title: string }): Document => ({ + id: overrides.id, + userId: 'u1', + title: overrides.title, + icon: '📝', + folderPath: '', + content: '', + blocks: [], + tags: [], + isPinned: false, + isArchived: false, + createdAt: '2026-10-29T10:00:00Z', + updatedAt: '2026-10-29T10:00:00Z', + ...overrides, +}); + +describe('WorkspaceView sidebar explorer', () => { + beforeEach(() => { + vi.restoreAllMocks(); + vi.mocked(api.getFolders).mockResolvedValue([]); + vi.mocked(api.getSharedFolders).mockResolvedValue([]); + vi.mocked(api.getWorkspaceTags).mockResolvedValue([]); + vi.mocked(api.getTrash).mockResolvedValue([]); + }); + + it('renders empty state "Noch keine Notizen" when there are no notes and no folders', async () => { + vi.mocked(api.getDocuments).mockResolvedValue([]); + vi.mocked(api.getFolders).mockResolvedValue([]); + + render(); + + await waitFor(() => { + expect(screen.getByText('Noch keine Notizen')).toBeTruthy(); + }); + + expect(screen.queryByText('Angeheftet')).toBeNull(); + expect(screen.queryByText('Ordner')).toBeNull(); + expect(screen.queryByText('Notizen (Hauptebene)')).toBeNull(); + expect(screen.queryByText('Keine Notizen auf der Hauptebene.')).toBeNull(); + }); + + it('renders pinned docs, folders, and root docs without headings', async () => { + const docs: Document[] = [ + makeDoc({ + id: 'doc_pin_root', + title: 'Wichtige Pinned Notiz', + icon: '⭐', + isPinned: true, + }), + makeDoc({ + id: 'doc_root', + title: 'Normale Root Notiz', + icon: '📝', + }), + makeDoc({ + id: 'doc_in_folder', + title: 'Projekt Plan', + icon: '📄', + folderPath: 'Projekte', + }), + ]; + + vi.mocked(api.getDocuments).mockResolvedValue(docs); + vi.mocked(api.getFolders).mockResolvedValue(['Projekte']); + + render(); + + await waitFor(() => { + expect(screen.getByText('Wichtige Pinned Notiz')).toBeTruthy(); + expect(screen.getByText('Normale Root Notiz')).toBeTruthy(); + expect(screen.getByText('Projekte')).toBeTruthy(); + }); + + // Ensure no old headings are rendered + expect(screen.queryByText('Angeheftet')).toBeNull(); + expect(screen.queryByText('Ordner')).toBeNull(); + expect(screen.queryByText('Notizen (Hauptebene)')).toBeNull(); + expect(screen.queryByText('Keine Notizen auf der Hauptebene.')).toBeNull(); + + // The pinned root doc appears only once + const pinnedMatches = screen.getAllByText('Wichtige Pinned Notiz'); + expect(pinnedMatches.length).toBe(1); + + // Pinned container has separator when items follow + const pinnedItem = screen.getByText('Wichtige Pinned Notiz').closest('div[draggable="true"]'); + const pinnedContainer = pinnedItem?.parentElement; + expect(pinnedContainer?.className).toContain('border-b border-border pb-1.5 mb-1.5'); + }); + + it('does not render separator under pinned notes when nothing follows', async () => { + const docs: Document[] = [ + makeDoc({ + id: 'doc_pin_only', + title: 'Einzige Notiz Pinned', + icon: '⭐', + isPinned: true, + }), + ]; + + vi.mocked(api.getDocuments).mockResolvedValue(docs); + vi.mocked(api.getFolders).mockResolvedValue([]); + + render(); + + await waitFor(() => { + expect(screen.getByText('Einzige Notiz Pinned')).toBeTruthy(); + }); + + const pinnedItem = screen.getByText('Einzige Notiz Pinned').closest('div[draggable="true"]'); + const pinnedContainer = pinnedItem?.parentElement; + expect(pinnedContainer?.className).not.toContain('border-b'); + }); + + it('keeps pinned note in folder visible in its folder', async () => { + const docs: Document[] = [ + makeDoc({ + id: 'doc_pin_in_folder', + title: 'Pinned im Ordner', + icon: '📌', + folderPath: 'Team', + isPinned: true, + }), + ]; + + vi.mocked(api.getDocuments).mockResolvedValue(docs); + vi.mocked(api.getFolders).mockResolvedValue(['Team']); + + render(); + + await waitFor(() => { + // In pinned list + expect(screen.getByText('Pinned im Ordner')).toBeTruthy(); + // Folder exists + expect(screen.getByText('Team')).toBeTruthy(); + }); + }); +}); From 7801cd13b92f760a7f87240d80817590b8cd7faa Mon Sep 17 00:00:00 2001 From: Sandro Date: Thu, 29 Oct 2026 22:12:57 +0100 Subject: [PATCH 02/19] feat(voice): self-hosted speech recognition via Whisper (STT_*) - STT_URL switches voice input to an OpenAI-compatible Whisper server (e.g. speaches); without it the browser's recognition stays. Configured by environment only, with limits for upload size, parallel jobs, hourly budget per user and timeout. The demo always uses the browser. - POST /voice/transcribe forwards recordings and returns the text; nothing is stored. On startup MindSift waits for the server and asks it to download the model if missing. - Admin card shows the active mode and server state, with a test recording. - docker-compose: optional "whisper" service (speaches 0.8.3 CPU, profile "whisper", models in ./whisper). Co-Authored-By: Claude Opus 5.5 --- .env.example | 16 + .gitignore | 3 + CHANGELOG.md | 4 + Doku/api/rest.md | 2 + Doku/architecture.md | 11 + README.md | 2 +- docker-compose.yml | 34 ++ .../features/settings/AdminSettingsView.tsx | 3 + .../src/features/settings/VoiceStatusCard.tsx | 189 +++++++++++ frontend/src/lib/api.ts | 21 ++ frontend/src/lib/types.ts | 20 ++ internal/api/bodylimit.go | 8 +- internal/api/bodylimit_test.go | 2 +- internal/api/handlers_auth.go | 2 + internal/api/server.go | 9 +- internal/api/voice.go | 306 ++++++++++++++++++ internal/api/voice_test.go | 133 ++++++++ internal/config/config.go | 57 ++++ 18 files changed, 816 insertions(+), 6 deletions(-) create mode 100644 frontend/src/features/settings/VoiceStatusCard.tsx create mode 100644 internal/api/voice.go create mode 100644 internal/api/voice_test.go diff --git a/.env.example b/.env.example index 7ad4c2a..87a7662 100644 --- a/.env.example +++ b/.env.example @@ -25,3 +25,19 @@ LEGAL_IMPRINT_URL= LEGAL_PRIVACY_URL= LEGAL_IMPRINT_CONTENT_FILE= LEGAL_PRIVACY_CONTENT_FILE= + +# Optional: own speech recognition (Whisper). Without STT_URL the browser's recognition is used. +# Enable: STT_URL=http://whisper:8000 and COMPOSE_PROFILES=whisper (starts the "whisper" service), +# then: mkdir -p whisper && sudo chown 1000:1000 whisper && docker compose up -d +# COMPOSE_PROFILES=whisper +STT_URL= +STT_MODEL=deepdml/faster-whisper-large-v3-turbo-ct2 +STT_LANGUAGE=de +STT_API_KEY= +STT_DISABLED=false +# Limits (server mode) +STT_MAX_SECONDS=600 +STT_MAX_UPLOAD_MB=25 +STT_MAX_PARALLEL=1 +STT_PER_USER_PER_HOUR=60 +STT_TIMEOUT_SECONDS=120 diff --git a/.gitignore b/.gitignore index de8430c..1b357ae 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,6 @@ dev-data/ .idea/ .vscode/ e2e/node_modules/ + +# Whisper models (docker compose service "whisper") +whisper/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 17ef0b8..7ac0846 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V ## [Unreleased] +### Added +- Eigene Spracherkennung (optional): Mit `STT_URL` schickt MindSift Aufnahmen an einen selbst betriebenen Whisper-Server (OpenAI-kompatibel, z. B. speaches) statt an Google oder Microsoft. Einstellung nur per Umgebungsvariablen (`STT_*`) inklusive Grenzwerten; `docker-compose.yml` bringt den Dienst `whisper` als Profil mit, das Modell lädt MindSift beim ersten Start selbst. Administration → Server & Isolation zeigt den aktiven Modus und bietet eine Testaufnahme. In der Demo bleibt es bei der Browser-Erkennung. +- API: `POST /voice/transcribe`, `GET /admin/voice`; `/auth/status` meldet `voice.mode`. + ## [0.6.1] - 2026-10-29 ### Fixed diff --git a/Doku/api/rest.md b/Doku/api/rest.md index 6b2560a..b5363ce 100644 --- a/Doku/api/rest.md +++ b/Doku/api/rest.md @@ -17,6 +17,7 @@ Basis: `/api/v1`. Antworten sind JSON; Fehler haben die Form `{"error": "Text"}` | POST | `/auth/quickstart` | Immer `403` (Gastzugang deaktiviert) | | POST | `/webhooks/ingest` | Einliefern per Webhook, Key als `X-API-Key` (empfohlen), Bearer oder `?token=` (in der URL nur API-Keys, keine Sitzungs-Tokens). Body: `title`/`content`/`text`/`message`, `priority`, `source`, `tags`, `dueDate`; `target` (oder `type`): `triage` (Standard, Inbox), `task` (optional `taskListId`), `list_item` (`listId`, `category`, `quantity`). Klartext-Body wird als Inhalt genommen. `403` ohne passenden Scope oder ohne Schreibrecht auf die Liste | | POST | `/auth/stream-ticket` | Einmal-Ticket (60 s) für WebSocket/SSE | +| POST | `/voice/transcribe` | Nur im Server-Modus der Spracherkennung: `multipart/form-data` mit `file` (Aufnahme, z. B. WebM/Opus oder MP4/AAC) → `{text}`. `404` ohne `STT_URL`, `413` zu groß, `429` Stundenlimit, `503` ausgelastet, `502` Whisper-Fehler (Details im Log) | | GET | `/ws`, `/ws/spaces/{id}`, `/ws/lists/{id}` | WebSocket, siehe [websocket.md](websocket.md); `?ticket=` oder `Authorization`-Header | | GET | `/events` | SSE-Fallback; `?ticket=` oder `Authorization`-Header | @@ -193,6 +194,7 @@ Wiederholte Operationen (gleiche `opId`) werden nicht erneut angewendet; die Ant | GET/PUT | `/admin/settings` | `instanceName`, `allowRegistration`, `sharingMode` (`private`/`organizations`/`open`), SMTP-Felder; ein Moduswechsel bereinigt Freigaben. `smtpPass` kommt nie zurück (`smtpPassSet` zeigt, ob eines gespeichert ist); leer = unverändert | | GET | `/admin/settings/sharing-impact?mode=` | Probelauf: wie viele Freigaben und Gruppen-Mitgliedschaften ein Wechsel entfernen würde (`affected`) | | POST | `/admin/smtp/test` | Testmail (`toEmail`) | +| GET | `/admin/voice` | Aktive Spracherkennung (`mode`: `server`/`browser`/`off`), Einstellungen aus `STT_*` ohne Schlüssel, Grenzen und im Server-Modus ein frischer Zustand (`status.reachable`, `status.modelReady`) | | GET/POST | `/admin/backups` | Datenbank-Abzüge auflisten bzw. sofort sichern (Abzug + alle Benutzer-Backups) | | GET | `/admin/backups/{name}` | Datenbank-Abzug herunterladen | diff --git a/Doku/architecture.md b/Doku/architecture.md index f890dbf..96dc342 100644 --- a/Doku/architecture.md +++ b/Doku/architecture.md @@ -69,6 +69,11 @@ Das Frontend liegt per `go:embed` im Binary ([cmd/server/main.go](../cmd/server/ | `DEMO_RESET_MINUTES` | `20` | Demo-Modus: periodischer Reset auf die Beispieldaten (`0` = aus). Feste IDs der Demo-Konten (`usr_demo_admin`, `usr_demo_user`) halten Sitzungen gültig; laufende Anfragen sperren den Reset (`internal/api/demo.go`, Streams ausgenommen); danach `DEMO_RESET` an alle Verbindungen, `/auth/status` liefert `demoNextReset`/`demoResetInSeconds`. | | `SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASS`, `SMTP_FROM`, `SMTP_TLS` | leer / `587` / `true` | Mailversand, auch im Admin-UI einstellbar | | `SMTP_SKIP_VERIFY` | `false` | Zertifikat des Mailservers nicht prüfen (nur interne Server mit selbstsigniertem Zertifikat) | +| `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 | +| `STT_DISABLED` | `false` | `true` blendet die Spracheingabe überall aus | +| `STT_MAX_SECONDS`, `STT_MAX_UPLOAD_MB` | `600` / `25` | Längste Aufnahme (stoppt im Browser), größte Aufnahme | +| `STT_MAX_PARALLEL`, `STT_PER_USER_PER_HOUR`, `STT_TIMEOUT_SECONDS` | `1` / `60` / `120` | Gleichzeitige Erkennungen (weitere warten), Erkennungen pro Person und Stunde, Höchstdauer inkl. Warten | | `LEGAL_IMPRINT_URL`, `LEGAL_PRIVACY_URL` | leer | Impressum/Datenschutz als externer Link (nur `http(s)`) | | `LEGAL_IMPRINT_CONTENT`, `LEGAL_PRIVACY_CONTENT` (oder `…_CONTENT_FILE` mit Dateipfad) | leer | Impressum/Datenschutz als Markdown, angezeigt in einem Lesefenster. Ohne URL und Text bleibt der Link unsichtbar. | @@ -85,6 +90,12 @@ Hinweis: `DATABASE_URL` mit `postgres…` führt dazu, dass der Standardpfad in - **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. +## Spracherkennung + +Nur über Umgebungsvariablen (`STT_*`, siehe oben), nicht in der Oberfläche; Administration → Server & Isolation zeigt den aktiven Modus, den Zustand des Whisper-Servers und bietet eine Testaufnahme. Im Server-Modus nimmt der Browser per `MediaRecorder` auf und schickt die Aufnahme an `POST /voice/transcribe`; MindSift reicht sie an `STT_URL/v1/audio/transcriptions` weiter (`model`, `language`) und gibt nur den Text zurück. Nichts wird gespeichert oder protokolliert außer Fehlern (`internal/api/voice.go`). Beim Start wartet MindSift auf den Whisper-Server und stößt den Download des Modells an, falls es fehlt (`POST /v1/models/` bei speaches). + +Docker Compose bringt den optionalen Dienst `whisper` (speaches 0.8.3, CPU, Profil `whisper`) mit; das Modell liegt in `./whisper` (Ordner gehört UID 1000). Gemessen auf einem Desktop mit 16 Kernen (int8) für einen Satz von 4 s: `Systran/faster-whisper-small` 1,6 s, `deepdml/faster-whisper-large-v3-turbo-ct2` 8 s bei besserer Erkennung (RAM ca. 1 bzw. 2 GB). Auf geteilten vCPUs ist mit dem Mehrfachen zu rechnen. + ## Hintergrundjob: Alarme `startAlarmTicker` prüft jede Minute alle offenen, noch nicht gemeldeten Aufgaben mit Fälligkeit. Ist der Alarmzeitpunkt erreicht (Fälligkeit mit Uhrzeit bzw. 9:00 bei ganztägigen Aufgaben, minus Vorlaufzeit des Benutzers, Zeitzone `TZ`), sendet er über die Kanäle des Benutzers (`email`, `discord`, `webhook`) und setzt `alarm_sent`, sobald mindestens ein Kanal erfolgreich war. Nach 3 Durchläufen ohne Erfolg gibt er auf (`alarm_failures`). Details und Einschränkungen: [modules/tasks.md](modules/tasks.md). diff --git a/README.md b/README.md index b9007b8..f4b445d 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ Die meisten Notiz- und Aufgaben-Apps verlangen Struktur im falschen Moment: Wenn ### ⚡ Schnellerfassung & Triage -* Eingabeleiste im Kopf der App, jederzeit mit `C` oder `/` fokussierbar; auch per Spracheingabe (Browser-Funktion). +* Eingabeleiste im Kopf der App, jederzeit mit `C` oder `/` fokussierbar; auch per Spracheingabe: über den Browser oder datenschutzfreundlich über einen eigenen Whisper-Container (optional in `docker-compose.yml`, siehe [Architektur](Doku/architecture.md#spracherkennung)). * Ziel beim Erfassen wählbar: Posteingang, Aufgabe, Listeneintrag oder Notiz. * **Triage-Deck:** `H`/`←` verwerfen, `L`/`→` zur Aufgabe, `T` Aufgabe mit Termin, `D` in Notiz verwandeln, Leertaste = später. Auf dem Smartphone per Wischgeste. * Papierkorb mit Wiederherstellen. diff --git a/docker-compose.yml b/docker-compose.yml index ff56f61..e37bd3d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -32,3 +32,37 @@ services: - LEGAL_PRIVACY_URL=${LEGAL_PRIVACY_URL:-} - LEGAL_IMPRINT_CONTENT_FILE=${LEGAL_IMPRINT_CONTENT_FILE:-} - LEGAL_PRIVACY_CONTENT_FILE=${LEGAL_PRIVACY_CONTENT_FILE:-} + # Optional: eigene Spracherkennung (Whisper, siehe Dienst "whisper" unten). Ohne STT_URL nutzt + # MindSift die Spracherkennung des Browsers. Alles nur hier einstellbar, nicht in der Oberfläche; + # Probleme stehen im Log (docker compose logs mindsift whisper). + - STT_URL=${STT_URL:-} # z. B. http://whisper:8000 – gesetzt = Server-Modus + - STT_MODEL=${STT_MODEL:-deepdml/faster-whisper-large-v3-turbo-ct2} # kleiner/schneller: Systran/faster-whisper-small + - STT_LANGUAGE=${STT_LANGUAGE:-de} # Sprachcode, "auto" = automatisch erkennen + - STT_API_KEY=${STT_API_KEY:-} # nur falls der Whisper-Server einen verlangt (API_KEY dort) + - STT_DISABLED=${STT_DISABLED:-false} # true = Mikrofon überall ausblenden + # Schutzwerte für den Server-Modus + - STT_MAX_SECONDS=${STT_MAX_SECONDS:-600} # längste Aufnahme in Sekunden (danach stoppt der Browser) + - STT_MAX_UPLOAD_MB=${STT_MAX_UPLOAD_MB:-25} # größte Aufnahme in MB + - STT_MAX_PARALLEL=${STT_MAX_PARALLEL:-1} # gleichzeitige Erkennungen, weitere warten in der Schlange + - STT_PER_USER_PER_HOUR=${STT_PER_USER_PER_HOUR:-60} # Erkennungen pro Benutzer und Stunde + - STT_TIMEOUT_SECONDS=${STT_TIMEOUT_SECONDS:-120} # Höchstdauer pro Erkennung inkl. Warten + + # Optionale Spracherkennung auf dem eigenen Server (speaches mit faster-whisper, nur CPU). + # Start: in .env STT_URL=http://whisper:8000 und COMPOSE_PROFILES=whisper setzen, dann + # mkdir -p whisper && sudo chown 1000:1000 whisper (der Container läuft als UID 1000) + # docker compose up -d + # Das Modell (large-v3-turbo ca. 1,6 GB) lädt MindSift beim ersten Start automatisch in ./whisper. + # RAM: large-v3-turbo ca. 2 GB, small ca. 1 GB. + whisper: + image: ghcr.io/speaches-ai/speaches:0.8.3-cpu + container_name: mindsift-whisper + restart: unless-stopped + profiles: ["whisper"] + volumes: + - ./whisper:/home/ubuntu/.cache/huggingface/hub + environment: + - WHISPER__COMPUTE_TYPE=int8 # schneller und sparsamer auf der CPU, kaum Qualitätsverlust + - STT_MODEL_TTL=-1 # Modell im Speicher halten (sonst nach 5 Min. entladen, nächste Erkennung lädt neu) + - ENABLE_UI=false # keine Weboberfläche von speaches + - LOG_LEVEL=info + # Kein Port nach außen: nur MindSift spricht im Docker-Netz mit dem Dienst. diff --git a/frontend/src/features/settings/AdminSettingsView.tsx b/frontend/src/features/settings/AdminSettingsView.tsx index ddc9bc1..fcb8730 100644 --- a/frontend/src/features/settings/AdminSettingsView.tsx +++ b/frontend/src/features/settings/AdminSettingsView.tsx @@ -29,6 +29,7 @@ import { passwordProblem } from '../../lib/password'; import { UserAvatar } from '../../components/UserAvatar'; import { ToggleSwitch } from '../../components/ToggleSwitch'; import { BackupAdminCard } from './BackupAdminCard'; +import { VoiceStatusCard } from './VoiceStatusCard'; import { CountBadge } from '../../components/CountBadge'; interface AdminSettingsViewProps { @@ -1115,6 +1116,8 @@ export const AdminSettingsView: React.FC = ({ handleSaveSystemSettings(patch)} /> + + {/* Alarm webhooks into the local network (SSRF protection) */}
diff --git a/frontend/src/features/settings/VoiceStatusCard.tsx b/frontend/src/features/settings/VoiceStatusCard.tsx new file mode 100644 index 0000000..c4d08c9 --- /dev/null +++ b/frontend/src/features/settings/VoiceStatusCard.tsx @@ -0,0 +1,189 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { AlertCircle, Check, Loader2, Mic, RefreshCw, Square } from 'lucide-react'; +import { api } from '../../lib/api'; +import { VoiceAdminStatus } from '../../lib/types'; + +const TEST_SECONDS = 6; + +// Admin: which speech recognition is active. Configured only by environment (STT_*), so this card +// just shows the state and offers a short test recording through the own Whisper server. +export const VoiceStatusCard: React.FC = () => { + const [info, setInfo] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [recording, setRecording] = useState(false); + const [testing, setTesting] = useState(false); + const [result, setResult] = useState<{ text: string; ms: number } | null>(null); + const recorderRef = useRef(null); + const stopTimerRef = useRef(null); + + const load = async () => { + setLoading(true); + try { + setInfo(await api.adminVoiceStatus()); + setError(null); + } catch (err: any) { + setError(err.message || 'Status konnte nicht geladen werden'); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + void load(); + return () => { + if (stopTimerRef.current !== null) window.clearTimeout(stopTimerRef.current); + recorderRef.current?.stream.getTracks().forEach((t) => t.stop()); + }; + }, []); + + const stopRecording = () => { + if (stopTimerRef.current !== null) window.clearTimeout(stopTimerRef.current); + stopTimerRef.current = null; + if (recorderRef.current?.state === 'recording') recorderRef.current.stop(); + }; + + const startTest = async () => { + setResult(null); + setError(null); + let stream: MediaStream; + try { + stream = await navigator.mediaDevices.getUserMedia({ audio: true }); + } catch { + setError('Kein Zugriff auf das Mikrofon (Berechtigung, oder die Seite läuft nicht über HTTPS)'); + return; + } + const recorder = new MediaRecorder(stream); + const chunks: Blob[] = []; + recorder.ondataavailable = (e) => { + if (e.data.size > 0) chunks.push(e.data); + }; + recorder.onstop = async () => { + stream.getTracks().forEach((t) => t.stop()); + setRecording(false); + setTesting(true); + const started = performance.now(); + try { + const text = await api.transcribe(new Blob(chunks, { type: recorder.mimeType })); + setResult({ text, ms: Math.round(performance.now() - started) }); + } catch (err: any) { + setError(err.message || 'Spracherkennung fehlgeschlagen'); + } finally { + setTesting(false); + } + }; + recorderRef.current = recorder; + recorder.start(); + setRecording(true); + stopTimerRef.current = window.setTimeout(stopRecording, TEST_SECONDS * 1000); + }; + + const status = info?.status; + const serverOk = info?.mode === 'server' && status?.reachable && status?.modelReady; + + return ( +
+
+
+

+ + Spracherkennung +

+

+ Wird nur über Umgebungsvariablen eingestellt (STT_URL,{' '} + STT_MODEL …); Fehler stehen im Container-Log. +

+
+ +
+ + {info && ( +
+
Aktiv
+
+ {info.mode === 'server' + ? 'Eigener Server (Whisper)' + : info.mode === 'off' + ? 'Ausgeschaltet' + : info.demo && info.url + ? 'Browser (in der Demo immer)' + : 'Browser (Spracherkennung des Browsers)'} +
+ {info.url && ( + <> +
Server
+
{info.url}
+
Modell
+
{info.model}
+
Sprache
+
{info.language || 'automatisch'}
+ + )} + {info.mode === 'server' && ( + <> +
Zustand
+
+ {serverOk ? ( + + Erreichbar, Modell geladen + + ) : ( + + + {status?.reachable ? 'Modell fehlt noch (wird heruntergeladen)' : 'Nicht erreichbar'} + {status?.error && !status.reachable ? `: ${status.error}` : ''} + + )} +
+
Grenzen
+
+ {Math.round(info.maxSeconds / 60)} Min. bzw. {info.maxUploadMb} MB pro Aufnahme, {info.maxParallel}{' '} + gleichzeitig, {info.perUserPerHour} pro Person und Stunde, {info.timeoutSeconds} s Zeitlimit +
+ + )} +
+ )} + + {info?.mode === 'server' && ( +
+ {recording ? ( + + ) : ( + + )} + {recording && Sprich jetzt …} + {result && ( + + „{result.text || '(nichts erkannt)'}“ · {(result.ms / 1000).toFixed(1)} s + + )} +
+ )} + + {error &&

{error}

} +
+ ); +}; diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index f3fdf5a..deb7dbe 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -35,6 +35,7 @@ import { TagSummary, User, UserInvite, + VoiceAdminStatus, WSMessage, } from './types'; import { extractHashtags } from './hashtags'; @@ -1047,6 +1048,26 @@ export const api = { await request(`/api/v1/admin/invites/${id}`, { method: 'DELETE' }); }, + async adminVoiceStatus(): Promise { + return request('/api/v1/admin/voice'); + }, + + // Sends a recording to the own Whisper server (voice mode "server") and returns the text + async transcribe(audio: Blob): Promise { + const token = getToken(); + const ext = audio.type.includes('mp4') ? 'm4a' : audio.type.includes('ogg') ? 'ogg' : 'webm'; + const form = new FormData(); + form.append('file', audio, `aufnahme.${ext}`); + const res = await fetch('/api/v1/voice/transcribe', { + method: 'POST', + headers: token ? { Authorization: `Bearer ${token}` } : {}, + body: form, + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(data.error || `Spracherkennung fehlgeschlagen (HTTP ${res.status})`); + return data.text || ''; + }, + async adminGetSettings(): Promise { return request('/api/v1/admin/settings'); }, diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index 42d3ff0..c5b13c6 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -21,6 +21,26 @@ export interface AuthStatus { privacyUrl?: string; hasPrivacyContent?: boolean; }; + // Voice input: own Whisper server, the browser's recognition, or switched off (STT_* environment) + voice?: { mode: VoiceMode; maxSeconds: number }; +} + +export type VoiceMode = 'server' | 'browser' | 'off'; + +// Admin view of the speech recognition (read-only, configured by environment) +export interface VoiceAdminStatus { + mode: VoiceMode; + demo: boolean; + url?: string; + model?: string; + language?: string; + apiKeySet?: boolean; + maxSeconds: number; + maxUploadMb: number; + maxParallel: number; + perUserPerHour: number; + timeoutSeconds: number; + status?: { reachable: boolean; modelReady: boolean; error?: string; checkedAt?: string }; } // Who can see whom: nobody, members of the same organization, or everybody (Doku/sharing-and-permissions.md) diff --git a/internal/api/bodylimit.go b/internal/api/bodylimit.go index cac200f..1c3eb46 100644 --- a/internal/api/bodylimit.go +++ b/internal/api/bodylimit.go @@ -12,8 +12,10 @@ import ( // with huge uploads. Note sync and imports need more; their handlers apply the same limits again. const defaultMaxBody = 2 << 20 -func bodyLimitFor(path string) int64 { +func (s *Server) bodyLimitFor(path string) int64 { switch { + case path == "/api/v1/voice/transcribe": + return int64(s.cfg.Speech.MaxUploadMB)<<20 + 1<<20 case strings.HasPrefix(path, "/api/v1/import/"): return storage.MaxImportUploadBytes + 1<<20 case strings.HasPrefix(path, "/api/v1/docs/") && strings.HasSuffix(path, "/sync"): @@ -22,10 +24,10 @@ func bodyLimitFor(path string) int64 { return defaultMaxBody } -func bodyLimit(next http.Handler) http.Handler { +func (s *Server) bodyLimit(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Body != nil && r.Body != http.NoBody { - r.Body = http.MaxBytesReader(w, r.Body, bodyLimitFor(r.URL.Path)) + r.Body = http.MaxBytesReader(w, r.Body, s.bodyLimitFor(r.URL.Path)) } next.ServeHTTP(w, r) }) diff --git a/internal/api/bodylimit_test.go b/internal/api/bodylimit_test.go index da5328e..e93e176 100644 --- a/internal/api/bodylimit_test.go +++ b/internal/api/bodylimit_test.go @@ -16,7 +16,7 @@ func TestRequestBodyLimited(t *testing.T) { if rec := sessionCall(router, "POST", "/api/v1/inbox", token, `{"content":"klein"}`, ""); rec.Code >= 300 { t.Fatalf("normal body: got %d %s", rec.Code, rec.Body.String()) } - if bodyLimitFor("/api/v1/docs/doc_1/sync") != syncMaxBody || bodyLimitFor("/api/v1/import/obsidian") <= defaultMaxBody { + if srv.bodyLimitFor("/api/v1/docs/doc_1/sync") != syncMaxBody || srv.bodyLimitFor("/api/v1/import/obsidian") <= defaultMaxBody { t.Fatal("sync and import keep their larger limits") } } diff --git a/internal/api/handlers_auth.go b/internal/api/handlers_auth.go index 6c230cc..8def15f 100644 --- a/internal/api/handlers_auth.go +++ b/internal/api/handlers_auth.go @@ -44,6 +44,8 @@ func (s *Server) handleAuthStatus(w http.ResponseWriter, r *http.Request) { "demoMode": s.cfg.DemoMode, "sharingMode": s.store.SharingMode(), "passwordReset": s.passwordResetAvailable(), + // How voice input works: "server" (own Whisper), "browser" or "off"; set by STT_* only + "voice": map[string]interface{}{"mode": s.voiceMode(), "maxSeconds": s.cfg.Speech.MaxSeconds}, // Public so the login screen can link Impressum and Datenschutz "legal": map[string]interface{}{ "imprintUrl": s.cfg.Legal.ImprintURL, diff --git a/internal/api/server.go b/internal/api/server.go index 7c5208f..8c33eec 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -33,6 +33,7 @@ type Server struct { demo demoState // periodic reset in demo mode (demo.go) streamTickets streamTickets // one-time tickets for WebSocket/SSE (streamticket.go) loginGuard *loginGuard // brute-force protection (loginguard.go) + voice *voiceService // self-hosted speech recognition (voice.go) } func NewServer(cfg *config.Config, store *storage.Store, wsHub *ws.Hub, staticFS fs.FS) *Server { @@ -45,6 +46,10 @@ func NewServer(cfg *config.Config, store *storage.Store, wsHub *ws.Hub, staticFS notifier: collab.NewNotifier(), } srv.loginGuard = newLoginGuard(!cfg.DisableLoginProtection) + srv.voice = newVoiceService(cfg.Speech.MaxParallel) + if srv.voiceMode() == "server" { + go srv.startVoiceCheck() + } wsHub.SetRoomAuthorizer(srv.canJoinRoom) wsHub.SetOriginCheck(srv.streamOriginAllowed) // Next occurrences of repeating tasks appear live on every connected client @@ -84,7 +89,7 @@ func (s *Server) Router() http.Handler { r.Use(s.clientIPMiddleware) r.Use(s.securityHeaders) - r.Use(bodyLimit) + r.Use(s.bodyLimit) r.Use(s.demoResetGate) r.Use(middleware.Recoverer) r.Use(s.apiLoggingMiddleware) @@ -126,6 +131,7 @@ func (s *Server) Router() http.Handler { r.Get("/api/v1/auth/me", s.handleMe) r.Post("/api/v1/events/rooms", s.handleSSERooms) r.Post("/api/v1/auth/stream-ticket", s.handleStreamTicket) + r.Post("/api/v1/voice/transcribe", s.handleVoiceTranscribe) r.Get("/api/v1/auth/sessions", s.handleListSessions) r.Delete("/api/v1/auth/sessions/{id}", s.notInDemo(s.handleEndSession)) r.Post("/api/v1/auth/logout", s.handleLogout) @@ -271,6 +277,7 @@ func (s *Server) Router() http.Handler { r.Get("/api/v1/admin/backups/{name}", s.handleAdminDownloadBackup) r.Put("/api/v1/admin/settings", s.notInDemo(s.handleAdminUpdateSettings)) r.Get("/api/v1/admin/settings/sharing-impact", s.handleAdminSharingImpact) + r.Get("/api/v1/admin/voice", s.handleAdminVoiceStatus) // Organizations decide who can see whom; only server admins manage them r.Get("/api/v1/admin/organizations", s.handleAdminGetOrganizations) diff --git a/internal/api/voice.go b/internal/api/voice.go new file mode 100644 index 0000000..1dec38f --- /dev/null +++ b/internal/api/voice.go @@ -0,0 +1,306 @@ +package api + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "mime/multipart" + "net/http" + "net/textproto" + "path" + "strings" + "sync" + "time" + + "github.com/sandrodach/mindsift/internal/auth" +) + +// Self-hosted speech recognition: the browser records audio and sends it here, MindSift passes it on +// to an OpenAI-compatible Whisper server (STT_URL, e.g. speaches) and returns the text. Nothing is +// stored. A limited number of transcriptions run at the same time (STT_MAX_PARALLEL), the others +// wait in line; each user has an hourly budget (STT_PER_USER_PER_HOUR). Problems are logged, the +// browser only gets a short message. + +type voiceService struct { + slots chan struct{} + + mu sync.Mutex + usage map[string][]time.Time // userID -> transcriptions within the last hour + status voiceStatus + checked time.Time +} + +// voiceStatus is what the admin page shows about the Whisper server. +type voiceStatus struct { + Reachable bool `json:"reachable"` + ModelReady bool `json:"modelReady"` + Error string `json:"error,omitempty"` + CheckedAt string `json:"checkedAt,omitempty"` +} + +func newVoiceService(parallel int) *voiceService { + if parallel < 1 { + parallel = 1 + } + return &voiceService{slots: make(chan struct{}, parallel), usage: map[string][]time.Time{}} +} + +func (s *Server) voiceMode() string { + return s.cfg.Speech.Mode(s.cfg.DemoMode) +} + +// allowVoice counts one transcription for the user; false when the hourly budget is used up. +func (v *voiceService) allowVoice(userID string, perHour int, now time.Time) bool { + v.mu.Lock() + defer v.mu.Unlock() + recent := v.usage[userID][:0] + for _, t := range v.usage[userID] { + if now.Sub(t) < time.Hour { + recent = append(recent, t) + } + } + if len(recent) >= perHour { + v.usage[userID] = recent + return false + } + v.usage[userID] = append(recent, now) + return true +} + +// sttEndpoint joins the base URL with an API path; a base URL ending in /v1 works as well. +func (s *Server) sttEndpoint(p string) string { + base := strings.TrimSuffix(s.cfg.Speech.URL, "/v1") + return base + p +} + +func (s *Server) sttRequest(ctx context.Context, method, p string, body io.Reader, contentType string) (*http.Response, error) { + req, err := http.NewRequestWithContext(ctx, method, s.sttEndpoint(p), body) + if err != nil { + return nil, err + } + if contentType != "" { + req.Header.Set("Content-Type", contentType) + } + if s.cfg.Speech.APIKey != "" { + req.Header.Set("Authorization", "Bearer "+s.cfg.Speech.APIKey) + } + return http.DefaultClient.Do(req) +} + +func (s *Server) handleVoiceTranscribe(w http.ResponseWriter, r *http.Request) { + if s.voiceMode() != "server" { + writeError(w, http.StatusNotFound, "Serverseitige Spracherkennung ist nicht eingerichtet") + return + } + cfg := s.cfg.Speech + userID := auth.GetUserID(r.Context()) + if !s.voice.allowVoice(userID, cfg.PerUserPerHour, time.Now()) { + writeError(w, http.StatusTooManyRequests, "Stundenlimit für die Spracherkennung erreicht. Bitte später erneut versuchen.") + return + } + allowLongTransfer(w) + file, header, err := r.FormFile("file") + if err != nil { + var tooBig *http.MaxBytesError + if errors.As(err, &tooBig) { + writeError(w, http.StatusRequestEntityTooLarge, fmt.Sprintf("Aufnahme zu groß (höchstens %d MB)", cfg.MaxUploadMB)) + return + } + writeError(w, http.StatusBadRequest, "Keine Aufnahme im Feld \"file\"") + return + } + defer file.Close() + audio, err := io.ReadAll(file) + if err != nil || len(audio) == 0 { + writeError(w, http.StatusBadRequest, "Aufnahme konnte nicht gelesen werden") + return + } + + // Wait for a free slot; the request may be cancelled meanwhile (user stopped waiting) + ctx, cancel := context.WithTimeout(r.Context(), time.Duration(cfg.TimeoutSeconds)*time.Second) + defer cancel() + select { + case s.voice.slots <- struct{}{}: + defer func() { <-s.voice.slots }() + case <-ctx.Done(): + slog.Warn("Spracherkennung: keine freie Kapazität innerhalb der Zeitgrenze", "user_id", userID, "parallel", cfg.MaxParallel) + writeError(w, http.StatusServiceUnavailable, "Die Spracherkennung ist gerade ausgelastet. Bitte gleich noch einmal versuchen.") + return + } + + started := time.Now() + text, err := s.transcribe(ctx, audio, header.Filename, header.Header.Get("Content-Type")) + if err != nil { + slog.Warn("Spracherkennung fehlgeschlagen", "url", s.sttEndpoint("/v1/audio/transcriptions"), "model", cfg.Model, + "bytes", len(audio), "duration_ms", time.Since(started).Milliseconds(), "error", err) + msg := "Spracherkennung fehlgeschlagen" + if errors.Is(err, context.DeadlineExceeded) { + msg = "Spracherkennung hat zu lange gedauert" + } + writeError(w, http.StatusBadGateway, msg) + return + } + slog.Debug("Spracherkennung", "user_id", userID, "bytes", len(audio), "duration_ms", time.Since(started).Milliseconds()) + writeJSON(w, http.StatusOK, map[string]string{"text": text}) +} + +// transcribe sends the audio to the Whisper server (OpenAI API: POST /v1/audio/transcriptions). +func (s *Server) transcribe(ctx context.Context, audio []byte, filename, contentType string) (string, error) { + var body bytes.Buffer + mw := multipart.NewWriter(&body) + if filename == "" { + filename = "aufnahme.webm" + } + part := textproto.MIMEHeader{} + part.Set("Content-Disposition", fmt.Sprintf(`form-data; name="file"; filename="%s"`, path.Base(strings.ReplaceAll(filename, `"`, "")))) + if contentType == "" { + contentType = "application/octet-stream" + } + part.Set("Content-Type", contentType) + fw, err := mw.CreatePart(part) + if err != nil { + return "", err + } + if _, err := fw.Write(audio); err != nil { + return "", err + } + _ = mw.WriteField("model", s.cfg.Speech.Model) + if lang := s.cfg.Speech.Language; lang != "" && lang != "auto" { + _ = mw.WriteField("language", lang) + } + _ = mw.WriteField("response_format", "json") + if err := mw.Close(); err != nil { + return "", err + } + + resp, err := s.sttRequest(ctx, http.MethodPost, "/v1/audio/transcriptions", &body, mw.FormDataContentType()) + if err != nil { + return "", err + } + defer resp.Body.Close() + raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(raw))) + } + var out struct { + Text string `json:"text"` + } + if err := json.Unmarshal(raw, &out); err != nil { + return "", fmt.Errorf("unerwartete Antwort: %s", strings.TrimSpace(string(raw))) + } + return strings.TrimSpace(out.Text), nil +} + +// checkVoice asks the Whisper server whether it is up and has the model. +func (s *Server) checkVoice(ctx context.Context) voiceStatus { + st := voiceStatus{CheckedAt: time.Now().UTC().Format(time.RFC3339)} + resp, err := s.sttRequest(ctx, http.MethodGet, "/v1/models", nil, "") + if err != nil { + st.Error = err.Error() + return st + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + st.Error = fmt.Sprintf("GET /v1/models: HTTP %d", resp.StatusCode) + return st + } + st.Reachable = true + var list struct { + Data []struct { + ID string `json:"id"` + } `json:"data"` + } + _ = json.NewDecoder(io.LimitReader(resp.Body, 4<<20)).Decode(&list) + for _, m := range list.Data { + if m.ID == s.cfg.Speech.Model { + st.ModelReady = true + } + } + if !st.ModelReady { + st.Error = "Modell ist noch nicht heruntergeladen" + } + return st +} + +func (s *Server) setVoiceStatus(st voiceStatus) { + s.voice.mu.Lock() + s.voice.status = st + s.voice.checked = time.Now() + s.voice.mu.Unlock() +} + +// startVoiceCheck waits for the Whisper server after startup (it may start later than MindSift) and +// asks it to download the model if it does not have it yet, so the first recording works. +func (s *Server) startVoiceCheck() { + cfg := s.cfg.Speech + slog.Info("Spracherkennung: Server-Modus", "url", cfg.URL, "model", cfg.Model, "language", cfg.Language, + "max_parallel", cfg.MaxParallel, "per_user_per_hour", cfg.PerUserPerHour, "max_upload_mb", cfg.MaxUploadMB) + downloadRequested := false + for attempt := 0; ; attempt++ { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + st := s.checkVoice(ctx) + cancel() + s.setVoiceStatus(st) + switch { + case st.ModelReady: + slog.Info("Spracherkennung bereit", "url", cfg.URL, "model", cfg.Model) + return + case st.Reachable && !downloadRequested: + downloadRequested = true + slog.Info("Spracherkennung: Modell wird heruntergeladen (kann einige Minuten dauern)", "model", cfg.Model) + go s.requestModelDownload() + case !st.Reachable && attempt%6 == 0: + slog.Warn("Spracherkennung: Whisper-Server nicht erreichbar, neuer Versuch folgt", "url", cfg.URL, "error", st.Error) + } + time.Sleep(10 * time.Second) + } +} + +func (s *Server) requestModelDownload() { + // Model IDs contain a slash (owner/name); speaches takes them as a path + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Hour) + defer cancel() + resp, err := s.sttRequest(ctx, http.MethodPost, "/v1/models/"+s.cfg.Speech.Model, nil, "") + if err != nil { + slog.Warn("Spracherkennung: Modell-Download fehlgeschlagen", "model", s.cfg.Speech.Model, "error", err) + return + } + defer resp.Body.Close() + raw, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + if resp.StatusCode >= 300 { + slog.Warn("Spracherkennung: Modell-Download abgelehnt", "model", s.cfg.Speech.Model, "status", resp.StatusCode, "body", strings.TrimSpace(string(raw))) + } +} + +// handleAdminVoiceStatus shows the admin which speech recognition is active (configured by +// environment only) and checks the Whisper server right now. +func (s *Server) handleAdminVoiceStatus(w http.ResponseWriter, r *http.Request) { + cfg := s.cfg.Speech + out := map[string]interface{}{ + "mode": s.voiceMode(), + "demo": s.cfg.DemoMode, + "maxSeconds": cfg.MaxSeconds, + "maxUploadMb": cfg.MaxUploadMB, + "maxParallel": cfg.MaxParallel, + "perUserPerHour": cfg.PerUserPerHour, + "timeoutSeconds": cfg.TimeoutSeconds, + } + if cfg.URL != "" { + out["url"] = cfg.URL + out["model"] = cfg.Model + out["language"] = cfg.Language + out["apiKeySet"] = cfg.APIKey != "" + } + if s.voiceMode() == "server" { + ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) + st := s.checkVoice(ctx) + cancel() + s.setVoiceStatus(st) + out["status"] = st + } + writeJSON(w, http.StatusOK, out) +} diff --git a/internal/api/voice_test.go b/internal/api/voice_test.go new file mode 100644 index 0000000..d88f8d2 --- /dev/null +++ b/internal/api/voice_test.go @@ -0,0 +1,133 @@ +package api + +import ( + "bytes" + "context" + "encoding/json" + "io" + "mime/multipart" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + + "github.com/sandrodach/mindsift/internal/config" +) + +// fakeWhisper answers like speaches: model list and OpenAI-style transcriptions. +func fakeWhisper(t *testing.T, models []string, got *map[string]string, downloads *int32) *httptest.Server { + t.Helper() + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/v1/models": + var data []map[string]string + for _, m := range models { + data = append(data, map[string]string{"id": m}) + } + _ = json.NewEncoder(w).Encode(map[string]interface{}{"data": data}) + case r.Method == http.MethodPost && strings.HasPrefix(r.URL.Path, "/v1/models/"): + atomic.AddInt32(downloads, 1) + w.WriteHeader(http.StatusCreated) + case r.Method == http.MethodPost && r.URL.Path == "/v1/audio/transcriptions": + if r.Header.Get("Authorization") != "Bearer geheim" { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + _ = r.ParseMultipartForm(10 << 20) + f, _, _ := r.FormFile("file") + audio, _ := io.ReadAll(f) + *got = map[string]string{"model": r.FormValue("model"), "language": r.FormValue("language"), "audio": string(audio)} + _ = json.NewEncoder(w).Encode(map[string]string{"text": " Hallo Welt, wie geht's? "}) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(ts.Close) + return ts +} + +func voiceUpload(t *testing.T, router http.Handler, token string, audio []byte) *httptest.ResponseRecorder { + t.Helper() + var body bytes.Buffer + mw := multipart.NewWriter(&body) + fw, _ := mw.CreateFormFile("file", "aufnahme.webm") + _, _ = fw.Write(audio) + _ = mw.Close() + req := httptest.NewRequest("POST", "/api/v1/voice/transcribe", &body) + req.Header.Set("Content-Type", mw.FormDataContentType()) + req.Header.Set("Authorization", "Bearer "+token) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + return rec +} + +func voiceServer(t *testing.T, speech config.SpeechConfig) (*Server, string) { + t.Helper() + srv, _, token := setupTestServer(t) + srv.cfg.Speech = speech + srv.voice = newVoiceService(speech.MaxParallel) + return srv, token +} + +func TestVoiceTranscribeForwardsToWhisper(t *testing.T) { + var got map[string]string + var downloads int32 + whisper := fakeWhisper(t, []string{"Systran/faster-whisper-small"}, &got, &downloads) + srv, token := voiceServer(t, config.SpeechConfig{URL: whisper.URL + "/v1", Model: "Systran/faster-whisper-small", Language: "de", + APIKey: "geheim", MaxSeconds: 600, MaxUploadMB: 1, MaxParallel: 1, PerUserPerHour: 2, TimeoutSeconds: 10}) + router := srv.Router() + + rec := voiceUpload(t, router, token, []byte("RIFF-audio")) + if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"text":"Hallo Welt, wie geht's?"`) { + t.Fatalf("transcribe: %d %s", rec.Code, rec.Body.String()) + } + if got["model"] != "Systran/faster-whisper-small" || got["language"] != "de" || got["audio"] != "RIFF-audio" { + t.Fatalf("forwarded wrong data: %+v", got) + } + // Hourly budget per user + _ = voiceUpload(t, router, token, []byte("x")) + if rec := voiceUpload(t, router, token, []byte("x")); rec.Code != http.StatusTooManyRequests { + t.Fatalf("over the hourly budget: want 429, got %d", rec.Code) + } + // Model already there: the startup check reports ready and requests no download + st := srv.checkVoice(context.Background()) + if !st.Reachable || !st.ModelReady || downloads != 0 { + t.Fatalf("status %+v, downloads %d", st, downloads) + } + // Status for the login screen and the admin card, without the API key + if body := sessionCall(router, "GET", "/api/v1/auth/status", "", "", "").Body.String(); !strings.Contains(body, `"mode":"server"`) { + t.Fatalf("auth status: %s", body) + } + body := sessionCall(router, "GET", "/api/v1/admin/voice", token, "", "").Body.String() + if !strings.Contains(body, `"modelReady":true`) || strings.Contains(body, "geheim") { + t.Fatalf("admin voice status: %s", body) + } +} + +func TestVoiceUploadLimitAndModes(t *testing.T) { + var got map[string]string + var downloads int32 + whisper := fakeWhisper(t, nil, &got, &downloads) + srv, token := voiceServer(t, config.SpeechConfig{URL: whisper.URL, Model: "m", MaxUploadMB: 1, MaxParallel: 1, PerUserPerHour: 10, TimeoutSeconds: 10}) + if rec := voiceUpload(t, srv.Router(), token, bytes.Repeat([]byte("x"), 3<<20)); rec.Code != http.StatusRequestEntityTooLarge && rec.Code != http.StatusBadRequest { + t.Fatalf("too large: want 413/400, got %d", rec.Code) + } + if st := srv.checkVoice(context.Background()); st.ModelReady || !st.Reachable { + t.Fatalf("missing model must be reported: %+v", st) + } + + if m := (config.SpeechConfig{URL: "http://whisper:8000"}).Mode(true); m != "browser" { + t.Fatalf("demo must use the browser, got %s", m) + } + if m := (config.SpeechConfig{}).Mode(false); m != "browser" { + t.Fatalf("without URL: browser, got %s", m) + } + if m := (config.SpeechConfig{URL: "x", Disabled: true}).Mode(false); m != "off" { + t.Fatalf("disabled: off, got %s", m) + } + browserSrv, browserToken := voiceServer(t, config.SpeechConfig{MaxParallel: 1, PerUserPerHour: 10}) + if rec := voiceUpload(t, browserSrv.Router(), browserToken, []byte("x")); rec.Code != http.StatusNotFound { + t.Fatalf("browser mode: want 404, got %d", rec.Code) + } +} diff --git a/internal/config/config.go b/internal/config/config.go index 35ea501..8e7cc63 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -43,6 +43,37 @@ type Config struct { // Legal notices (Impressum, Datenschutz): an external URL and/or Markdown text, shown even before login Legal LegalTexts + + // Speech recognition, configured only by environment (STT_*) + Speech SpeechConfig +} + +// SpeechConfig: with STT_URL set, voice input is transcribed by a self-hosted, OpenAI-compatible +// Whisper server (e.g. speaches); otherwise the browser's own speech recognition is used. +type SpeechConfig struct { + URL string // base URL, e.g. http://whisper:8000 + Model string + Language string + APIKey string + Disabled bool // STT_DISABLED=true hides voice input everywhere + MaxSeconds int // recordings stop after this long (enforced in the browser) + MaxUploadMB int + MaxParallel int // transcriptions running at the same time; more wait in line + PerUserPerHour int + TimeoutSeconds int +} + +// Mode is "server", "browser" or "off". The demo never uses the server, so visitors cannot keep +// its CPU busy. +func (s SpeechConfig) Mode(demo bool) string { + switch { + case s.Disabled: + return "off" + case s.URL != "" && !demo: + return "server" + default: + return "browser" + } } type LegalTexts struct { @@ -179,6 +210,18 @@ func Load() *Config { SMTPFrom: smtpFrom, SMTPTLS: smtpTLS, SMTPSkipVerify: smtpSkipVerify, + Speech: SpeechConfig{ + URL: strings.TrimRight(getEnv("STT_URL", ""), "/"), + Model: getEnv("STT_MODEL", "deepdml/faster-whisper-large-v3-turbo-ct2"), + Language: getEnv("STT_LANGUAGE", "de"), + APIKey: getEnv("STT_API_KEY", ""), + Disabled: strings.ToLower(getEnv("STT_DISABLED", "false")) == "true", + MaxSeconds: envInt("STT_MAX_SECONDS", 600, 5, 3600), + MaxUploadMB: envInt("STT_MAX_UPLOAD_MB", 25, 1, 500), + MaxParallel: envInt("STT_MAX_PARALLEL", 1, 1, 32), + PerUserPerHour: envInt("STT_PER_USER_PER_HOUR", 60, 1, 100000), + TimeoutSeconds: envInt("STT_TIMEOUT_SECONDS", 120, 5, 3600), + }, Legal: LegalTexts{ ImprintURL: legalURL("LEGAL_IMPRINT_URL"), ImprintContent: legalContent("LEGAL_IMPRINT_CONTENT"), @@ -209,6 +252,20 @@ func getEnv(key, fallback string) string { return fallback } +// envInt reads a whole number within [min, max]; anything else falls back to def (with a warning). +func envInt(key string, def, min, max int) int { + raw := getEnv(key, "") + if raw == "" { + return def + } + n, err := strconv.Atoi(raw) + if err != nil || n < min || n > max { + slog.Warn("Ungültiger Wert, Standard wird verwendet", "variable", key, "value", raw, "default", def) + return def + } + return n +} + func demoResetMinutes() int { n, err := strconv.Atoi(strings.TrimSpace(getEnv("DEMO_RESET_MINUTES", "20"))) if err != nil || n < 0 { From ff53f06991fd134db7eb85ef0b2c0ef4c7fbffcf Mon Sep 17 00:00:00 2001 From: Sandro Date: Thu, 29 Oct 2026 22:18:03 +0100 Subject: [PATCH 03/19] feat(workspace): code block with language picker and live highlighting --- .../features/workspace/CodeBlockEditor.tsx | 625 ++++++++++++++++++ .../src/features/workspace/CodeBlockItem.tsx | 282 -------- .../src/features/workspace/OutlinerBlock.tsx | 149 +++-- .../__tests__/CodeBlockEditor.test.tsx | 212 ++++++ frontend/src/index.css | 110 ++- frontend/src/themes/theme-dark.css | 11 + frontend/src/themes/theme-high-contrast.css | 11 + frontend/src/themes/theme-light.css | 11 + frontend/src/themes/theme-polarized.css | 11 + frontend/src/themes/theme-soft.css | 11 + frontend/src/themes/theme-suri.css | 11 + frontend/src/themes/theme-template.css | 11 + frontend/src/vite-env.d.ts | 1 + 13 files changed, 1089 insertions(+), 367 deletions(-) create mode 100644 frontend/src/features/workspace/CodeBlockEditor.tsx delete mode 100644 frontend/src/features/workspace/CodeBlockItem.tsx create mode 100644 frontend/src/features/workspace/__tests__/CodeBlockEditor.test.tsx diff --git a/frontend/src/features/workspace/CodeBlockEditor.tsx b/frontend/src/features/workspace/CodeBlockEditor.tsx new file mode 100644 index 0000000..8f314b7 --- /dev/null +++ b/frontend/src/features/workspace/CodeBlockEditor.tsx @@ -0,0 +1,625 @@ +import React, { useState, useEffect, useLayoutEffect, useMemo, useRef, useCallback } from 'react'; +import Prism from 'prismjs'; +import { Code, Copy, Check } from 'lucide-react'; +import { DocumentBlock } from '../../lib/types'; + +Prism.manual = true; +if (typeof globalThis !== 'undefined') { + (globalThis as Record).Prism = Prism; +} + +export interface CodeLanguageOption { + id: string; + label: string; +} + +export interface CodeLanguageGroup { + label: string; + options: CodeLanguageOption[]; +} + +export const CODE_LANGUAGE_GROUPS: CodeLanguageGroup[] = [ + { + label: 'Konfiguration & Daten', + options: [ + { id: 'yaml', label: 'YAML' }, + { id: 'json', label: 'JSON' }, + { id: 'toml', label: 'TOML' }, + { id: 'ini', label: 'INI' }, + { id: 'docker', label: 'Dockerfile' }, + { id: 'nginx', label: 'Nginx' }, + { id: 'hcl', label: 'HCL / Terraform' }, + { id: 'xml', label: 'XML' }, + { id: 'csv', label: 'CSV' }, + { id: 'graphql', label: 'GraphQL' }, + { id: 'protobuf', label: 'Protocol Buffers' }, + ], + }, + { + label: 'Shell & Betrieb', + options: [ + { id: 'bash', label: 'Bash / Shell' }, + { id: 'powershell', label: 'PowerShell' }, + { id: 'makefile', label: 'Makefile' }, + { id: 'systemd', label: 'Systemd' }, + { id: 'vim', label: 'Vim' }, + { id: 'diff', label: 'Diff' }, + { id: 'git', label: 'Git' }, + { id: 'log', label: 'Log' }, + ], + }, + { + label: 'Web & Frontend', + options: [ + { id: 'javascript', label: 'JavaScript' }, + { id: 'typescript', label: 'TypeScript' }, + { id: 'jsx', label: 'JSX' }, + { id: 'tsx', label: 'TSX' }, + { id: 'markup', label: 'HTML / Markup' }, + { id: 'html', label: 'HTML' }, + { id: 'css', label: 'CSS' }, + { id: 'scss', label: 'SCSS' }, + ], + }, + { + label: 'Skriptsprachen', + options: [ + { id: 'python', label: 'Python' }, + { id: 'php', label: 'PHP' }, + { id: 'ruby', label: 'Ruby' }, + { id: 'lua', label: 'Lua' }, + { id: 'perl', label: 'Perl' }, + ], + }, + { + label: 'System & Backend', + options: [ + { id: 'go', label: 'Go' }, + { id: 'rust', label: 'Rust' }, + { id: 'sql', label: 'SQL' }, + { id: 'c', label: 'C' }, + { id: 'cpp', label: 'C++' }, + { id: 'csharp', label: 'C#' }, + { id: 'java', label: 'Java' }, + { id: 'kotlin', label: 'Kotlin' }, + { id: 'swift', label: 'Swift' }, + { id: 'dart', label: 'Dart' }, + { id: 'zig', label: 'Zig' }, + { id: 'elixir', label: 'Elixir' }, + { id: 'scala', label: 'Scala' }, + { id: 'haskell', label: 'Haskell' }, + ], + }, + { + label: 'Dokumentation & Text', + options: [ + { id: 'markdown', label: 'Markdown' }, + { id: 'latex', label: 'LaTeX' }, + { id: 'regex', label: 'Regex' }, + { id: 'text', label: 'Plain Text' }, + ], + }, +]; + +export const CODE_LANGUAGES: CodeLanguageOption[] = CODE_LANGUAGE_GROUPS.flatMap((g) => g.options); + +const KNOWN_OPTION_IDS = new Set(CODE_LANGUAGES.map((l) => l.id)); + +const LANGUAGE_ALIASES: Record = { + yml: 'yaml', + sh: 'bash', + shell: 'bash', + zsh: 'bash', + js: 'javascript', + mjs: 'javascript', + cjs: 'javascript', + ts: 'typescript', + mts: 'typescript', + cts: 'typescript', + py: 'python', + rb: 'ruby', + rs: 'rust', + cs: 'csharp', + 'c#': 'csharp', + dotnet: 'csharp', + 'c++': 'cpp', + cxx: 'cpp', + cc: 'cpp', + hpp: 'cpp', + h: 'c', + kt: 'kotlin', + kts: 'kotlin', + hs: 'haskell', + md: 'markdown', + tex: 'latex', + dockerfile: 'docker', + containerfile: 'docker', + htm: 'html', + svg: 'xml', + proto: 'protobuf', + ps1: 'powershell', + pwsh: 'powershell', + make: 'makefile', + gql: 'graphql', + tf: 'hcl', + terraform: 'hcl', + ex: 'elixir', + exs: 'elixir', + pl: 'perl', + conf: 'ini', + golang: 'go', + plaintext: 'text', + plain: 'text', + txt: 'text', +}; + +const SHORT_PREFIX_CANDIDATES = new Set([ + 'c', + 'go', + 'js', + 'ts', + 'sh', + 'py', + 'rb', + 'rs', + 'cs', + 'kt', + 'hs', + 'md', + 'ex', + 'pl', + 'tf', + 'tex', + 'htm', + 'yml', + 'doc', + 'docker', + 'make', + 'proto', + 'plain', +]); + +export function resolveLanguageId(raw?: string): string | undefined { + if (!raw) return undefined; + const key = raw.trim().toLowerCase(); + if (!key) return undefined; + if (KNOWN_OPTION_IDS.has(key)) return key; + return LANGUAGE_ALIASES[key]; +} + +// True when a language token in `/code ` or ````` cannot be a prefix of another language user is still typing +export function isUnambiguousLanguageShortcut(raw?: string): boolean { + if (!raw) return false; + const key = raw.trim().toLowerCase(); + if (SHORT_PREFIX_CANDIDATES.has(key)) return false; + return Boolean(resolveLanguageId(key)); +} + +// On-demand loaders for Prism grammars (Vite code-splits each into its own chunk) +const PRISM_LOADERS: Record Promise> = { + yaml: () => import('prismjs/components/prism-yaml'), + json: () => import('prismjs/components/prism-json'), + toml: () => import('prismjs/components/prism-toml'), + ini: () => import('prismjs/components/prism-ini'), + docker: () => import('prismjs/components/prism-docker'), + nginx: () => import('prismjs/components/prism-nginx'), + hcl: () => import('prismjs/components/prism-hcl'), + csv: () => import('prismjs/components/prism-csv'), + graphql: () => import('prismjs/components/prism-graphql'), + protobuf: () => import('prismjs/components/prism-protobuf'), + bash: () => import('prismjs/components/prism-bash'), + powershell: () => import('prismjs/components/prism-powershell'), + makefile: () => import('prismjs/components/prism-makefile'), + systemd: () => import('prismjs/components/prism-systemd'), + vim: () => import('prismjs/components/prism-vim'), + diff: () => import('prismjs/components/prism-diff'), + git: () => import('prismjs/components/prism-git'), + log: () => import('prismjs/components/prism-log'), + typescript: () => import('prismjs/components/prism-typescript'), + jsx: () => import('prismjs/components/prism-jsx'), + tsx: () => import('prismjs/components/prism-tsx'), + scss: () => import('prismjs/components/prism-scss'), + python: () => import('prismjs/components/prism-python'), + 'markup-templating': () => import('prismjs/components/prism-markup-templating'), + php: () => import('prismjs/components/prism-php'), + ruby: () => import('prismjs/components/prism-ruby'), + lua: () => import('prismjs/components/prism-lua'), + perl: () => import('prismjs/components/prism-perl'), + go: () => import('prismjs/components/prism-go'), + rust: () => import('prismjs/components/prism-rust'), + sql: () => import('prismjs/components/prism-sql'), + c: () => import('prismjs/components/prism-c'), + cpp: () => import('prismjs/components/prism-cpp'), + csharp: () => import('prismjs/components/prism-csharp'), + java: () => import('prismjs/components/prism-java'), + kotlin: () => import('prismjs/components/prism-kotlin'), + swift: () => import('prismjs/components/prism-swift'), + dart: () => import('prismjs/components/prism-dart'), + zig: () => import('prismjs/components/prism-zig'), + elixir: () => import('prismjs/components/prism-elixir'), + scala: () => import('prismjs/components/prism-scala'), + haskell: () => import('prismjs/components/prism-haskell'), + markdown: () => import('prismjs/components/prism-markdown'), + latex: () => import('prismjs/components/prism-latex'), + regex: () => import('prismjs/components/prism-regex'), +}; + +// Prerequisite grammars that must be loaded in order before the target grammar +const PRISM_DEPENDENCIES: Record = { + tsx: ['jsx', 'typescript'], + cpp: ['c'], + php: ['markup-templating'], + scala: ['java'], +}; + +const inFlightGrammarLoads = new Map>(); + +export function toPrismGrammarKey(lang?: string): string { + if (!lang) return ''; + const resolved = resolveLanguageId(lang) || lang.trim().toLowerCase(); + if (!resolved || resolved === 'text' || resolved === 'plaintext') return ''; + if (resolved === 'html') return 'markup'; + return resolved; +} + +export async function loadPrismLanguage(lang?: string): Promise { + const key = toPrismGrammarKey(lang); + if (!key) return false; + if (Prism.languages[key]) return true; + + const existing = inFlightGrammarLoads.get(key); + if (existing) return existing; + + const task = (async () => { + try { + const deps = PRISM_DEPENDENCIES[key]; + if (deps) { + for (const dep of deps) { + if (!Prism.languages[dep]) { + await loadPrismLanguage(dep); + } + } + } + const loader = PRISM_LOADERS[key]; + if (loader) { + await loader(); + } + return Boolean(Prism.languages[key]); + } catch { + return false; + } finally { + inFlightGrammarLoads.delete(key); + } + })(); + + inFlightGrammarLoads.set(key, task); + return task; +} + +export function escapeHtml(raw: string): string { + return raw + .replace(/&/g, '&') + .replace(//g, '>'); +} + +export interface CodeBlockEditorProps { + block: DocumentBlock; + content?: string; + canEdit?: boolean; + textareaRef?: React.MutableRefObject; + registerTextareaRef?: (blockId: string, el: HTMLTextAreaElement | null) => void; + onChangeContent?: (content: string) => void; + onChangeLang?: (lang: string) => void; + onConvertToText?: () => void; + handleFocus?: (e: React.FocusEvent) => void; + handleBlur?: (e: React.FocusEvent) => void; + handleContentChange?: (e: React.ChangeEvent) => void; + handleKeyDown?: (e: React.KeyboardEvent) => void; +} + +export const CodeBlockEditor: React.FC = ({ + block, + content, + canEdit = true, + textareaRef, + registerTextareaRef, + onChangeContent, + onChangeLang, + onConvertToText, + handleFocus, + handleBlur, + handleContentChange, + handleKeyDown, +}) => { + const isControlled = content !== undefined; + const [uncontrolledContent, setUncontrolledContent] = useState(block.content || ''); + const currentContent = isControlled ? content : uncontrolledContent; + + useEffect(() => { + if (!isControlled) { + setUncontrolledContent(block.content || ''); + } + }, [block.content, isControlled]); + + const [copied, setCopied] = useState(false); + const [loadedGrammar, setLoadedGrammar] = useState(''); + const copyTimerRef = useRef(null); + const localTextareaRef = useRef(null); + const preRef = useRef(null); + + useEffect(() => { + return () => { + if (copyTimerRef.current !== null) { + window.clearTimeout(copyTimerRef.current); + } + }; + }, []); + + const prismLang = toPrismGrammarKey(block.lang); + + useEffect(() => { + if (!prismLang) return; + if (Prism.languages[prismLang]) { + setLoadedGrammar(prismLang); + return; + } + let cancelled = false; + loadPrismLanguage(prismLang).then((loaded) => { + if (!cancelled && loaded) { + setLoadedGrammar(prismLang); + } + }); + return () => { + cancelled = true; + }; + }, [prismLang]); + + const highlightedHtml = useMemo(() => { + if (!currentContent) return ''; + const activeLang = prismLang && (Prism.languages[prismLang] || loadedGrammar === prismLang) ? prismLang : ''; + if (!activeLang) return escapeHtml(currentContent); + const grammar = Prism.languages[activeLang]; + if (!grammar) return escapeHtml(currentContent); + try { + return Prism.highlight(currentContent, grammar, activeLang); + } catch { + return escapeHtml(currentContent); + } + }, [currentContent, prismLang, loadedGrammar]); + + const adjustHeight = useCallback(() => { + const el = localTextareaRef.current; + if (!el) return; + el.style.height = 'auto'; + el.style.height = `${Math.max(22, el.scrollHeight)}px`; + }, []); + + useLayoutEffect(() => { + adjustHeight(); + }, [currentContent, adjustHeight]); + + const handleCopy = (e: React.MouseEvent) => { + e.stopPropagation(); + try { + const p = navigator.clipboard?.writeText(currentContent); + if (p && typeof p.catch === 'function') { + p.catch(() => {}); + } + } catch { + // ignore clipboard errors + } + setCopied(true); + if (copyTimerRef.current !== null) { + window.clearTimeout(copyTimerRef.current); + } + copyTimerRef.current = window.setTimeout(() => { + copyTimerRef.current = null; + setCopied(false); + }, 1500); + }; + + const applyContentUpdate = (newVal: string, target: HTMLTextAreaElement) => { + if (!isControlled) { + setUncontrolledContent(newVal); + } + if (onChangeContent) { + onChangeContent(newVal); + } else if (handleContentChange) { + target.value = newVal; + handleContentChange({ + target, + currentTarget: target, + } as React.ChangeEvent); + } + }; + + const onTextareaChange = (e: React.ChangeEvent) => { + if (!canEdit) return; + if (!isControlled) { + setUncontrolledContent(e.target.value); + } + if (handleContentChange) { + handleContentChange(e); + } else if (onChangeContent) { + onChangeContent(e.target.value); + } + }; + + const onTextareaKeyDown = (e: React.KeyboardEvent) => { + if (canEdit && e.key === 'Tab') { + e.preventDefault(); + e.stopPropagation(); + const ta = e.currentTarget; + const val = currentContent; + const start = ta.selectionStart ?? 0; + const end = ta.selectionEnd ?? start; + + const lineStart = start === 0 ? 0 : val.lastIndexOf('\n', start - 1) + 1; + const effectiveEnd = end > start && val[end - 1] === '\n' ? end - 1 : end; + const lineEndIdx = val.indexOf('\n', effectiveEnd); + const blockEnd = lineEndIdx === -1 ? val.length : lineEndIdx; + const lines = val.slice(lineStart, blockEnd).split('\n'); + + if (!e.shiftKey) { + const updatedLines = lines.map((line) => ' ' + line); + const newVal = val.slice(0, lineStart) + updatedLines.join('\n') + val.slice(blockEnd); + const newStart = start + 2; + const newEnd = end + updatedLines.length * 2; + applyContentUpdate(newVal, ta); + ta.value = newVal; + ta.setSelectionRange(newStart, newEnd); + setTimeout(() => { + if (localTextareaRef.current) { + localTextareaRef.current.setSelectionRange(newStart, newEnd); + } + }, 0); + } else { + let removedFirst = 0; + let removedTotal = 0; + const updatedLines = lines.map((line, idx) => { + let removeCount = 0; + if (line.startsWith(' ')) removeCount = 2; + else if (line.startsWith(' ') || line.startsWith('\t')) removeCount = 1; + if (idx === 0) removedFirst = removeCount; + removedTotal += removeCount; + return line.slice(removeCount); + }); + if (removedTotal > 0) { + const newVal = val.slice(0, lineStart) + updatedLines.join('\n') + val.slice(blockEnd); + const newStart = Math.max(lineStart, start - removedFirst); + const newEnd = Math.max(newStart, end - removedTotal); + applyContentUpdate(newVal, ta); + ta.value = newVal; + ta.setSelectionRange(newStart, newEnd); + setTimeout(() => { + if (localTextareaRef.current) { + localTextareaRef.current.setSelectionRange(newStart, newEnd); + } + }, 0); + } + } + return; + } + + handleKeyDown?.(e); + }; + + const handleScroll = (e: React.UIEvent) => { + if (preRef.current) { + preRef.current.scrollTop = e.currentTarget.scrollTop; + preRef.current.scrollLeft = e.currentTarget.scrollLeft; + } + }; + + const rawLang = block.lang || ''; + const selectValue = rawLang ? resolveLanguageId(rawLang) || rawLang : ''; + const hasCustomUnknownLang = Boolean(selectValue && !KNOWN_OPTION_IDS.has(selectValue)); + + return ( +
+ {/* Code Block Header */} +
+
+ + {canEdit ? ( + + ) : ( + {block.lang || 'Text'} + )} +
+ +
+ + + {canEdit && onConvertToText && ( + + )} +
+
+ + {/* Live Highlighted Overlay + Textarea */} +
+ +