Spaces:
Sleeping
Sleeping
Voice module integration in noteviewer
Browse files- .env.example +3 -0
- backend/.env.example +4 -1
- backend/src/api/main.py +2 -1
- backend/src/api/routes/__init__.py +2 -2
- backend/src/api/routes/tts.py +141 -0
- frontend/package-lock.json +23 -53
- frontend/src/components/NoteViewer.tsx +40 -1
- frontend/src/hooks/useAudioPlayer.ts +82 -0
- frontend/src/lib/markdownToText.ts +26 -0
- frontend/src/pages/MainApp.tsx +102 -1
- frontend/src/services/tts.ts +39 -0
.env.example
CHANGED
|
@@ -2,3 +2,6 @@ JWT_SECRET_KEY=change-me
|
|
| 2 |
HF_OAUTH_CLIENT_ID=your-hf-client-id
|
| 3 |
HF_OAUTH_CLIENT_SECRET=your-hf-client-secret
|
| 4 |
VAULT_BASE_PATH=./data/vaults
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
HF_OAUTH_CLIENT_ID=your-hf-client-id
|
| 3 |
HF_OAUTH_CLIENT_SECRET=your-hf-client-secret
|
| 4 |
VAULT_BASE_PATH=./data/vaults
|
| 5 |
+
ELEVENLABS_API_KEY=your-elevenlabs-api-key
|
| 6 |
+
ELEVENLABS_VOICE_ID=your-elevenlabs-voice-id
|
| 7 |
+
ELEVENLABS_MODEL=eleven_multilingual_v2
|
backend/.env.example
CHANGED
|
@@ -1,4 +1,7 @@
|
|
| 1 |
JWT_SECRET_KEY=your-secret-key-here
|
| 2 |
HF_OAUTH_CLIENT_ID=your-hf-client-id
|
| 3 |
HF_OAUTH_CLIENT_SECRET=your-hf-client-secret
|
| 4 |
-
VAULT_BASE_PATH=./data/vaults
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
JWT_SECRET_KEY=your-secret-key-here
|
| 2 |
HF_OAUTH_CLIENT_ID=your-hf-client-id
|
| 3 |
HF_OAUTH_CLIENT_SECRET=your-hf-client-secret
|
| 4 |
+
VAULT_BASE_PATH=./data/vaults
|
| 5 |
+
ELEVENLABS_API_KEY=your-elevenlabs-api-key
|
| 6 |
+
ELEVENLABS_VOICE_ID=your-elevenlabs-voice-id
|
| 7 |
+
ELEVENLABS_MODEL=eleven_multilingual_v2
|
backend/src/api/main.py
CHANGED
|
@@ -21,7 +21,7 @@ from starlette.responses import Response
|
|
| 21 |
from fastmcp.server.http import StreamableHTTPSessionManager, set_http_request
|
| 22 |
from fastapi.responses import FileResponse
|
| 23 |
|
| 24 |
-
from .routes import auth, index, notes, search, graph, demo, system, rag
|
| 25 |
from ..mcp.server import mcp
|
| 26 |
from ..services.seed import init_and_seed
|
| 27 |
from ..services.config import get_config
|
|
@@ -113,6 +113,7 @@ app.include_router(graph.router, tags=["graph"])
|
|
| 113 |
app.include_router(demo.router, tags=["demo"])
|
| 114 |
app.include_router(system.router, tags=["system"])
|
| 115 |
app.include_router(rag.router, tags=["rag"])
|
|
|
|
| 116 |
|
| 117 |
|
| 118 |
@app.api_route("/mcp", methods=["GET", "POST", "DELETE"])
|
|
|
|
| 21 |
from fastmcp.server.http import StreamableHTTPSessionManager, set_http_request
|
| 22 |
from fastapi.responses import FileResponse
|
| 23 |
|
| 24 |
+
from .routes import auth, index, notes, search, graph, demo, system, rag, tts
|
| 25 |
from ..mcp.server import mcp
|
| 26 |
from ..services.seed import init_and_seed
|
| 27 |
from ..services.config import get_config
|
|
|
|
| 113 |
app.include_router(demo.router, tags=["demo"])
|
| 114 |
app.include_router(system.router, tags=["system"])
|
| 115 |
app.include_router(rag.router, tags=["rag"])
|
| 116 |
+
app.include_router(tts.router, tags=["tts"])
|
| 117 |
|
| 118 |
|
| 119 |
@app.api_route("/mcp", methods=["GET", "POST", "DELETE"])
|
backend/src/api/routes/__init__.py
CHANGED
|
@@ -1,5 +1,5 @@
|
|
| 1 |
"""HTTP API route handlers."""
|
| 2 |
|
| 3 |
-
from . import auth, index, notes, search, graph, demo
|
| 4 |
|
| 5 |
-
__all__ = ["auth", "notes", "search", "index", "graph", "demo"]
|
|
|
|
| 1 |
"""HTTP API route handlers."""
|
| 2 |
|
| 3 |
+
from . import auth, index, notes, search, graph, demo, tts
|
| 4 |
|
| 5 |
+
__all__ = ["auth", "notes", "search", "index", "graph", "demo", "tts"]
|
backend/src/api/routes/tts.py
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""HTTP API routes for ElevenLabs text-to-speech."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import os
|
| 6 |
+
|
| 7 |
+
import httpx
|
| 8 |
+
from fastapi import APIRouter, Depends, HTTPException
|
| 9 |
+
from fastapi.responses import Response
|
| 10 |
+
from pydantic import BaseModel, Field
|
| 11 |
+
|
| 12 |
+
from ..middleware import AuthContext, get_auth_context
|
| 13 |
+
|
| 14 |
+
router = APIRouter()
|
| 15 |
+
|
| 16 |
+
ELEVENLABS_API_URL = "https://api.elevenlabs.io/v1/text-to-speech"
|
| 17 |
+
DEFAULT_MODEL = "eleven_multilingual_v2"
|
| 18 |
+
# ElevenLabs docs mention a hard limit; keep a conservative cap for safety.
|
| 19 |
+
MAX_TEXT_LENGTH = 4800
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
class TtsRequest(BaseModel):
|
| 23 |
+
"""Payload for synthesizing speech."""
|
| 24 |
+
|
| 25 |
+
text: str = Field(..., min_length=1, description="Plaintext to convert to speech")
|
| 26 |
+
voice_id: str | None = Field(
|
| 27 |
+
default=None,
|
| 28 |
+
description="Override voice id; falls back to ELEVENLABS_VOICE_ID",
|
| 29 |
+
)
|
| 30 |
+
model: str | None = Field(
|
| 31 |
+
default=None,
|
| 32 |
+
description="Model override; defaults to ELEVENLABS_MODEL or a safe default",
|
| 33 |
+
)
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
async def _call_elevenlabs(
|
| 37 |
+
api_key: str, voice_id: str, model: str, text: str
|
| 38 |
+
) -> httpx.Response:
|
| 39 |
+
"""Invoke ElevenLabs TTS API and return the raw response."""
|
| 40 |
+
headers = {
|
| 41 |
+
"xi-api-key": api_key,
|
| 42 |
+
"Accept": "audio/mpeg",
|
| 43 |
+
}
|
| 44 |
+
payload = {
|
| 45 |
+
"text": text,
|
| 46 |
+
"model_id": model,
|
| 47 |
+
}
|
| 48 |
+
async with httpx.AsyncClient(timeout=30.0) as client:
|
| 49 |
+
return await client.post(
|
| 50 |
+
f"{ELEVENLABS_API_URL}/{voice_id}",
|
| 51 |
+
headers=headers,
|
| 52 |
+
json=payload,
|
| 53 |
+
)
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
@router.post("/api/tts")
|
| 57 |
+
async def synthesize_tts(
|
| 58 |
+
payload: TtsRequest, auth: AuthContext = Depends(get_auth_context)
|
| 59 |
+
):
|
| 60 |
+
"""Synthesize speech for the provided text using ElevenLabs."""
|
| 61 |
+
api_key = os.getenv("ELEVENLABS_API_KEY")
|
| 62 |
+
default_voice = os.getenv("ELEVENLABS_VOICE_ID")
|
| 63 |
+
default_model = os.getenv("ELEVENLABS_MODEL") or DEFAULT_MODEL
|
| 64 |
+
|
| 65 |
+
if not api_key:
|
| 66 |
+
raise HTTPException(
|
| 67 |
+
status_code=500,
|
| 68 |
+
detail={
|
| 69 |
+
"error": "tts_not_configured",
|
| 70 |
+
"message": "ELEVENLABS_API_KEY is not set on the server.",
|
| 71 |
+
},
|
| 72 |
+
)
|
| 73 |
+
|
| 74 |
+
voice_id = payload.voice_id or default_voice
|
| 75 |
+
if not voice_id:
|
| 76 |
+
raise HTTPException(
|
| 77 |
+
status_code=400,
|
| 78 |
+
detail={
|
| 79 |
+
"error": "voice_required",
|
| 80 |
+
"message": "Voice ID is required. Set ELEVENLABS_VOICE_ID or pass voice_id in the request.",
|
| 81 |
+
},
|
| 82 |
+
)
|
| 83 |
+
|
| 84 |
+
text = (payload.text or "").strip()
|
| 85 |
+
if not text:
|
| 86 |
+
raise HTTPException(
|
| 87 |
+
status_code=400,
|
| 88 |
+
detail={
|
| 89 |
+
"error": "empty_text",
|
| 90 |
+
"message": "Text is empty.",
|
| 91 |
+
},
|
| 92 |
+
)
|
| 93 |
+
|
| 94 |
+
if len(text) > MAX_TEXT_LENGTH:
|
| 95 |
+
text = text[:MAX_TEXT_LENGTH]
|
| 96 |
+
|
| 97 |
+
try:
|
| 98 |
+
response = await _call_elevenlabs(
|
| 99 |
+
api_key, voice_id, payload.model or default_model, text
|
| 100 |
+
)
|
| 101 |
+
except httpx.TimeoutException as exc:
|
| 102 |
+
raise HTTPException(
|
| 103 |
+
status_code=504,
|
| 104 |
+
detail={
|
| 105 |
+
"error": "tts_timeout",
|
| 106 |
+
"message": "ElevenLabs request timed out.",
|
| 107 |
+
},
|
| 108 |
+
) from exc
|
| 109 |
+
except httpx.HTTPError as exc:
|
| 110 |
+
raise HTTPException(
|
| 111 |
+
status_code=502,
|
| 112 |
+
detail={
|
| 113 |
+
"error": "tts_http_error",
|
| 114 |
+
"message": f"ElevenLabs request failed: {str(exc)}",
|
| 115 |
+
},
|
| 116 |
+
) from exc
|
| 117 |
+
|
| 118 |
+
if response.status_code >= 400:
|
| 119 |
+
try:
|
| 120 |
+
error_payload = response.json()
|
| 121 |
+
message = (
|
| 122 |
+
error_payload.get("detail")
|
| 123 |
+
or error_payload.get("message")
|
| 124 |
+
or "Failed to synthesize speech."
|
| 125 |
+
)
|
| 126 |
+
except Exception:
|
| 127 |
+
message = response.text[:200] or "Failed to synthesize speech."
|
| 128 |
+
|
| 129 |
+
raise HTTPException(
|
| 130 |
+
status_code=response.status_code,
|
| 131 |
+
detail={
|
| 132 |
+
"error": "tts_failed",
|
| 133 |
+
"message": message,
|
| 134 |
+
},
|
| 135 |
+
)
|
| 136 |
+
|
| 137 |
+
return Response(
|
| 138 |
+
content=response.content,
|
| 139 |
+
media_type="audio/mpeg",
|
| 140 |
+
headers={"Cache-Control": "no-store"},
|
| 141 |
+
)
|
frontend/package-lock.json
CHANGED
|
@@ -1,12 +1,13 @@
|
|
| 1 |
{
|
| 2 |
-
"name": "frontend",
|
| 3 |
-
"version": "0.
|
| 4 |
"lockfileVersion": 3,
|
| 5 |
"requires": true,
|
| 6 |
"packages": {
|
| 7 |
"": {
|
| 8 |
-
"name": "frontend",
|
| 9 |
-
"version": "0.
|
|
|
|
| 10 |
"dependencies": {
|
| 11 |
"@radix-ui/react-avatar": "^1.1.11",
|
| 12 |
"@radix-ui/react-collapsible": "^1.1.12",
|
|
@@ -122,7 +123,6 @@
|
|
| 122 |
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
|
| 123 |
"dev": true,
|
| 124 |
"license": "MIT",
|
| 125 |
-
"peer": true,
|
| 126 |
"dependencies": {
|
| 127 |
"@babel/code-frame": "^7.27.1",
|
| 128 |
"@babel/generator": "^7.28.5",
|
|
@@ -714,7 +714,6 @@
|
|
| 714 |
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
| 715 |
"dev": true,
|
| 716 |
"license": "MIT",
|
| 717 |
-
"peer": true,
|
| 718 |
"engines": {
|
| 719 |
"node": ">=12"
|
| 720 |
},
|
|
@@ -1740,7 +1739,6 @@
|
|
| 1740 |
"integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==",
|
| 1741 |
"dev": true,
|
| 1742 |
"license": "MIT",
|
| 1743 |
-
"peer": true,
|
| 1744 |
"engines": {
|
| 1745 |
"node": "^14.21.3 || >=16"
|
| 1746 |
},
|
|
@@ -3341,7 +3339,6 @@
|
|
| 3341 |
"integrity": "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ==",
|
| 3342 |
"devOptional": true,
|
| 3343 |
"license": "MIT",
|
| 3344 |
-
"peer": true,
|
| 3345 |
"dependencies": {
|
| 3346 |
"undici-types": "~7.16.0"
|
| 3347 |
}
|
|
@@ -3351,7 +3348,6 @@
|
|
| 3351 |
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.5.tgz",
|
| 3352 |
"integrity": "sha512-keKxkZMqnDicuvFoJbzrhbtdLSPhj/rZThDlKWCDbgXmUg0rEUFtRssDXKYmtXluZlIqiC5VqkCgRwzuyLHKHw==",
|
| 3353 |
"license": "MIT",
|
| 3354 |
-
"peer": true,
|
| 3355 |
"dependencies": {
|
| 3356 |
"csstype": "^3.0.2"
|
| 3357 |
}
|
|
@@ -3362,7 +3358,6 @@
|
|
| 3362 |
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
|
| 3363 |
"devOptional": true,
|
| 3364 |
"license": "MIT",
|
| 3365 |
-
"peer": true,
|
| 3366 |
"peerDependencies": {
|
| 3367 |
"@types/react": "^19.2.0"
|
| 3368 |
}
|
|
@@ -3426,7 +3421,6 @@
|
|
| 3426 |
"integrity": "sha512-tK3GPFWbirvNgsNKto+UmB/cRtn6TZfyw0D6IKrW55n6Vbs7KJoZtI//kpTKzE/DUmmnAFD8/Ca46s7Obs92/w==",
|
| 3427 |
"dev": true,
|
| 3428 |
"license": "MIT",
|
| 3429 |
-
"peer": true,
|
| 3430 |
"dependencies": {
|
| 3431 |
"@typescript-eslint/scope-manager": "8.46.4",
|
| 3432 |
"@typescript-eslint/types": "8.46.4",
|
|
@@ -3708,7 +3702,6 @@
|
|
| 3708 |
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
| 3709 |
"dev": true,
|
| 3710 |
"license": "MIT",
|
| 3711 |
-
"peer": true,
|
| 3712 |
"bin": {
|
| 3713 |
"acorn": "bin/acorn"
|
| 3714 |
},
|
|
@@ -3976,24 +3969,28 @@
|
|
| 3976 |
}
|
| 3977 |
},
|
| 3978 |
"node_modules/body-parser": {
|
| 3979 |
-
"version": "2.2.
|
| 3980 |
-
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.
|
| 3981 |
-
"integrity": "sha512-
|
| 3982 |
"dev": true,
|
| 3983 |
"license": "MIT",
|
| 3984 |
"dependencies": {
|
| 3985 |
"bytes": "^3.1.2",
|
| 3986 |
"content-type": "^1.0.5",
|
| 3987 |
-
"debug": "^4.4.
|
| 3988 |
"http-errors": "^2.0.0",
|
| 3989 |
-
"iconv-lite": "^0.
|
| 3990 |
"on-finished": "^2.4.1",
|
| 3991 |
"qs": "^6.14.0",
|
| 3992 |
-
"raw-body": "^3.0.
|
| 3993 |
-
"type-is": "^2.0.
|
| 3994 |
},
|
| 3995 |
"engines": {
|
| 3996 |
"node": ">=18"
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3997 |
}
|
| 3998 |
},
|
| 3999 |
"node_modules/brace-expansion": {
|
|
@@ -4039,7 +4036,6 @@
|
|
| 4039 |
}
|
| 4040 |
],
|
| 4041 |
"license": "MIT",
|
| 4042 |
-
"peer": true,
|
| 4043 |
"dependencies": {
|
| 4044 |
"baseline-browser-mapping": "^2.8.25",
|
| 4045 |
"caniuse-lite": "^1.0.30001754",
|
|
@@ -4739,7 +4735,6 @@
|
|
| 4739 |
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
|
| 4740 |
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
|
| 4741 |
"license": "ISC",
|
| 4742 |
-
"peer": true,
|
| 4743 |
"engines": {
|
| 4744 |
"node": ">=12"
|
| 4745 |
}
|
|
@@ -5157,7 +5152,6 @@
|
|
| 5157 |
"integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==",
|
| 5158 |
"dev": true,
|
| 5159 |
"license": "MIT",
|
| 5160 |
-
"peer": true,
|
| 5161 |
"dependencies": {
|
| 5162 |
"@eslint-community/eslint-utils": "^4.8.0",
|
| 5163 |
"@eslint-community/regexpp": "^4.12.1",
|
|
@@ -5426,7 +5420,6 @@
|
|
| 5426 |
"integrity": "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==",
|
| 5427 |
"dev": true,
|
| 5428 |
"license": "MIT",
|
| 5429 |
-
"peer": true,
|
| 5430 |
"dependencies": {
|
| 5431 |
"accepts": "^2.0.0",
|
| 5432 |
"body-parser": "^2.2.0",
|
|
@@ -6215,9 +6208,9 @@
|
|
| 6215 |
}
|
| 6216 |
},
|
| 6217 |
"node_modules/iconv-lite": {
|
| 6218 |
-
"version": "0.
|
| 6219 |
-
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.
|
| 6220 |
-
"integrity": "sha512-
|
| 6221 |
"dev": true,
|
| 6222 |
"license": "MIT",
|
| 6223 |
"dependencies": {
|
|
@@ -6225,6 +6218,10 @@
|
|
| 6225 |
},
|
| 6226 |
"engines": {
|
| 6227 |
"node": ">=0.10.0"
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6228 |
}
|
| 6229 |
},
|
| 6230 |
"node_modules/ignore": {
|
|
@@ -6548,7 +6545,6 @@
|
|
| 6548 |
"resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz",
|
| 6549 |
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
|
| 6550 |
"license": "MIT",
|
| 6551 |
-
"peer": true,
|
| 6552 |
"bin": {
|
| 6553 |
"jiti": "bin/jiti.js"
|
| 6554 |
}
|
|
@@ -8418,7 +8414,6 @@
|
|
| 8418 |
}
|
| 8419 |
],
|
| 8420 |
"license": "MIT",
|
| 8421 |
-
"peer": true,
|
| 8422 |
"dependencies": {
|
| 8423 |
"nanoid": "^3.3.11",
|
| 8424 |
"picocolors": "^1.1.1",
|
|
@@ -8729,29 +8724,11 @@
|
|
| 8729 |
"node": ">= 0.10"
|
| 8730 |
}
|
| 8731 |
},
|
| 8732 |
-
"node_modules/raw-body/node_modules/iconv-lite": {
|
| 8733 |
-
"version": "0.7.0",
|
| 8734 |
-
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.0.tgz",
|
| 8735 |
-
"integrity": "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==",
|
| 8736 |
-
"dev": true,
|
| 8737 |
-
"license": "MIT",
|
| 8738 |
-
"dependencies": {
|
| 8739 |
-
"safer-buffer": ">= 2.1.2 < 3.0.0"
|
| 8740 |
-
},
|
| 8741 |
-
"engines": {
|
| 8742 |
-
"node": ">=0.10.0"
|
| 8743 |
-
},
|
| 8744 |
-
"funding": {
|
| 8745 |
-
"type": "opencollective",
|
| 8746 |
-
"url": "https://opencollective.com/express"
|
| 8747 |
-
}
|
| 8748 |
-
},
|
| 8749 |
"node_modules/react": {
|
| 8750 |
"version": "19.2.0",
|
| 8751 |
"resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz",
|
| 8752 |
"integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==",
|
| 8753 |
"license": "MIT",
|
| 8754 |
-
"peer": true,
|
| 8755 |
"engines": {
|
| 8756 |
"node": ">=0.10.0"
|
| 8757 |
}
|
|
@@ -8761,7 +8738,6 @@
|
|
| 8761 |
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz",
|
| 8762 |
"integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==",
|
| 8763 |
"license": "MIT",
|
| 8764 |
-
"peer": true,
|
| 8765 |
"dependencies": {
|
| 8766 |
"scheduler": "^0.27.0"
|
| 8767 |
},
|
|
@@ -9852,7 +9828,6 @@
|
|
| 9852 |
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.17.tgz",
|
| 9853 |
"integrity": "sha512-w33E2aCvSDP0tW9RZuNXadXlkHXqFzSkQew/aIa2i/Sj8fThxwovwlXHSPXTbAHwEIhBFXAedUhP2tueAKP8Og==",
|
| 9854 |
"license": "MIT",
|
| 9855 |
-
"peer": true,
|
| 9856 |
"dependencies": {
|
| 9857 |
"@alloc/quick-lru": "^5.2.0",
|
| 9858 |
"arg": "^5.0.2",
|
|
@@ -9990,7 +9965,6 @@
|
|
| 9990 |
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
| 9991 |
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
| 9992 |
"license": "MIT",
|
| 9993 |
-
"peer": true,
|
| 9994 |
"engines": {
|
| 9995 |
"node": ">=12"
|
| 9996 |
},
|
|
@@ -10170,7 +10144,6 @@
|
|
| 10170 |
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
| 10171 |
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
| 10172 |
"license": "Apache-2.0",
|
| 10173 |
-
"peer": true,
|
| 10174 |
"bin": {
|
| 10175 |
"tsc": "bin/tsc",
|
| 10176 |
"tsserver": "bin/tsserver"
|
|
@@ -10573,7 +10546,6 @@
|
|
| 10573 |
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
| 10574 |
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
| 10575 |
"license": "MIT",
|
| 10576 |
-
"peer": true,
|
| 10577 |
"engines": {
|
| 10578 |
"node": ">=12"
|
| 10579 |
},
|
|
@@ -10764,7 +10736,6 @@
|
|
| 10764 |
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.1.tgz",
|
| 10765 |
"integrity": "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==",
|
| 10766 |
"license": "ISC",
|
| 10767 |
-
"peer": true,
|
| 10768 |
"bin": {
|
| 10769 |
"yaml": "bin.mjs"
|
| 10770 |
},
|
|
@@ -10891,7 +10862,6 @@
|
|
| 10891 |
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
|
| 10892 |
"dev": true,
|
| 10893 |
"license": "MIT",
|
| 10894 |
-
"peer": true,
|
| 10895 |
"funding": {
|
| 10896 |
"url": "https://github.com/sponsors/colinhacks"
|
| 10897 |
}
|
|
|
|
| 1 |
{
|
| 2 |
+
"name": "document-mcp-frontend",
|
| 3 |
+
"version": "0.1.0",
|
| 4 |
"lockfileVersion": 3,
|
| 5 |
"requires": true,
|
| 6 |
"packages": {
|
| 7 |
"": {
|
| 8 |
+
"name": "document-mcp-frontend",
|
| 9 |
+
"version": "0.1.0",
|
| 10 |
+
"license": "MIT",
|
| 11 |
"dependencies": {
|
| 12 |
"@radix-ui/react-avatar": "^1.1.11",
|
| 13 |
"@radix-ui/react-collapsible": "^1.1.12",
|
|
|
|
| 123 |
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
|
| 124 |
"dev": true,
|
| 125 |
"license": "MIT",
|
|
|
|
| 126 |
"dependencies": {
|
| 127 |
"@babel/code-frame": "^7.27.1",
|
| 128 |
"@babel/generator": "^7.28.5",
|
|
|
|
| 714 |
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
| 715 |
"dev": true,
|
| 716 |
"license": "MIT",
|
|
|
|
| 717 |
"engines": {
|
| 718 |
"node": ">=12"
|
| 719 |
},
|
|
|
|
| 1739 |
"integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==",
|
| 1740 |
"dev": true,
|
| 1741 |
"license": "MIT",
|
|
|
|
| 1742 |
"engines": {
|
| 1743 |
"node": "^14.21.3 || >=16"
|
| 1744 |
},
|
|
|
|
| 3339 |
"integrity": "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ==",
|
| 3340 |
"devOptional": true,
|
| 3341 |
"license": "MIT",
|
|
|
|
| 3342 |
"dependencies": {
|
| 3343 |
"undici-types": "~7.16.0"
|
| 3344 |
}
|
|
|
|
| 3348 |
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.5.tgz",
|
| 3349 |
"integrity": "sha512-keKxkZMqnDicuvFoJbzrhbtdLSPhj/rZThDlKWCDbgXmUg0rEUFtRssDXKYmtXluZlIqiC5VqkCgRwzuyLHKHw==",
|
| 3350 |
"license": "MIT",
|
|
|
|
| 3351 |
"dependencies": {
|
| 3352 |
"csstype": "^3.0.2"
|
| 3353 |
}
|
|
|
|
| 3358 |
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
|
| 3359 |
"devOptional": true,
|
| 3360 |
"license": "MIT",
|
|
|
|
| 3361 |
"peerDependencies": {
|
| 3362 |
"@types/react": "^19.2.0"
|
| 3363 |
}
|
|
|
|
| 3421 |
"integrity": "sha512-tK3GPFWbirvNgsNKto+UmB/cRtn6TZfyw0D6IKrW55n6Vbs7KJoZtI//kpTKzE/DUmmnAFD8/Ca46s7Obs92/w==",
|
| 3422 |
"dev": true,
|
| 3423 |
"license": "MIT",
|
|
|
|
| 3424 |
"dependencies": {
|
| 3425 |
"@typescript-eslint/scope-manager": "8.46.4",
|
| 3426 |
"@typescript-eslint/types": "8.46.4",
|
|
|
|
| 3702 |
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
| 3703 |
"dev": true,
|
| 3704 |
"license": "MIT",
|
|
|
|
| 3705 |
"bin": {
|
| 3706 |
"acorn": "bin/acorn"
|
| 3707 |
},
|
|
|
|
| 3969 |
}
|
| 3970 |
},
|
| 3971 |
"node_modules/body-parser": {
|
| 3972 |
+
"version": "2.2.1",
|
| 3973 |
+
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.1.tgz",
|
| 3974 |
+
"integrity": "sha512-nfDwkulwiZYQIGwxdy0RUmowMhKcFVcYXUU7m4QlKYim1rUtg83xm2yjZ40QjDuc291AJjjeSc9b++AWHSgSHw==",
|
| 3975 |
"dev": true,
|
| 3976 |
"license": "MIT",
|
| 3977 |
"dependencies": {
|
| 3978 |
"bytes": "^3.1.2",
|
| 3979 |
"content-type": "^1.0.5",
|
| 3980 |
+
"debug": "^4.4.3",
|
| 3981 |
"http-errors": "^2.0.0",
|
| 3982 |
+
"iconv-lite": "^0.7.0",
|
| 3983 |
"on-finished": "^2.4.1",
|
| 3984 |
"qs": "^6.14.0",
|
| 3985 |
+
"raw-body": "^3.0.1",
|
| 3986 |
+
"type-is": "^2.0.1"
|
| 3987 |
},
|
| 3988 |
"engines": {
|
| 3989 |
"node": ">=18"
|
| 3990 |
+
},
|
| 3991 |
+
"funding": {
|
| 3992 |
+
"type": "opencollective",
|
| 3993 |
+
"url": "https://opencollective.com/express"
|
| 3994 |
}
|
| 3995 |
},
|
| 3996 |
"node_modules/brace-expansion": {
|
|
|
|
| 4036 |
}
|
| 4037 |
],
|
| 4038 |
"license": "MIT",
|
|
|
|
| 4039 |
"dependencies": {
|
| 4040 |
"baseline-browser-mapping": "^2.8.25",
|
| 4041 |
"caniuse-lite": "^1.0.30001754",
|
|
|
|
| 4735 |
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
|
| 4736 |
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
|
| 4737 |
"license": "ISC",
|
|
|
|
| 4738 |
"engines": {
|
| 4739 |
"node": ">=12"
|
| 4740 |
}
|
|
|
|
| 5152 |
"integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==",
|
| 5153 |
"dev": true,
|
| 5154 |
"license": "MIT",
|
|
|
|
| 5155 |
"dependencies": {
|
| 5156 |
"@eslint-community/eslint-utils": "^4.8.0",
|
| 5157 |
"@eslint-community/regexpp": "^4.12.1",
|
|
|
|
| 5420 |
"integrity": "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==",
|
| 5421 |
"dev": true,
|
| 5422 |
"license": "MIT",
|
|
|
|
| 5423 |
"dependencies": {
|
| 5424 |
"accepts": "^2.0.0",
|
| 5425 |
"body-parser": "^2.2.0",
|
|
|
|
| 6208 |
}
|
| 6209 |
},
|
| 6210 |
"node_modules/iconv-lite": {
|
| 6211 |
+
"version": "0.7.0",
|
| 6212 |
+
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.0.tgz",
|
| 6213 |
+
"integrity": "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==",
|
| 6214 |
"dev": true,
|
| 6215 |
"license": "MIT",
|
| 6216 |
"dependencies": {
|
|
|
|
| 6218 |
},
|
| 6219 |
"engines": {
|
| 6220 |
"node": ">=0.10.0"
|
| 6221 |
+
},
|
| 6222 |
+
"funding": {
|
| 6223 |
+
"type": "opencollective",
|
| 6224 |
+
"url": "https://opencollective.com/express"
|
| 6225 |
}
|
| 6226 |
},
|
| 6227 |
"node_modules/ignore": {
|
|
|
|
| 6545 |
"resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz",
|
| 6546 |
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
|
| 6547 |
"license": "MIT",
|
|
|
|
| 6548 |
"bin": {
|
| 6549 |
"jiti": "bin/jiti.js"
|
| 6550 |
}
|
|
|
|
| 8414 |
}
|
| 8415 |
],
|
| 8416 |
"license": "MIT",
|
|
|
|
| 8417 |
"dependencies": {
|
| 8418 |
"nanoid": "^3.3.11",
|
| 8419 |
"picocolors": "^1.1.1",
|
|
|
|
| 8724 |
"node": ">= 0.10"
|
| 8725 |
}
|
| 8726 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8727 |
"node_modules/react": {
|
| 8728 |
"version": "19.2.0",
|
| 8729 |
"resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz",
|
| 8730 |
"integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==",
|
| 8731 |
"license": "MIT",
|
|
|
|
| 8732 |
"engines": {
|
| 8733 |
"node": ">=0.10.0"
|
| 8734 |
}
|
|
|
|
| 8738 |
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz",
|
| 8739 |
"integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==",
|
| 8740 |
"license": "MIT",
|
|
|
|
| 8741 |
"dependencies": {
|
| 8742 |
"scheduler": "^0.27.0"
|
| 8743 |
},
|
|
|
|
| 9828 |
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.17.tgz",
|
| 9829 |
"integrity": "sha512-w33E2aCvSDP0tW9RZuNXadXlkHXqFzSkQew/aIa2i/Sj8fThxwovwlXHSPXTbAHwEIhBFXAedUhP2tueAKP8Og==",
|
| 9830 |
"license": "MIT",
|
|
|
|
| 9831 |
"dependencies": {
|
| 9832 |
"@alloc/quick-lru": "^5.2.0",
|
| 9833 |
"arg": "^5.0.2",
|
|
|
|
| 9965 |
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
| 9966 |
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
| 9967 |
"license": "MIT",
|
|
|
|
| 9968 |
"engines": {
|
| 9969 |
"node": ">=12"
|
| 9970 |
},
|
|
|
|
| 10144 |
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
| 10145 |
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
| 10146 |
"license": "Apache-2.0",
|
|
|
|
| 10147 |
"bin": {
|
| 10148 |
"tsc": "bin/tsc",
|
| 10149 |
"tsserver": "bin/tsserver"
|
|
|
|
| 10546 |
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
| 10547 |
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
| 10548 |
"license": "MIT",
|
|
|
|
| 10549 |
"engines": {
|
| 10550 |
"node": ">=12"
|
| 10551 |
},
|
|
|
|
| 10736 |
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.1.tgz",
|
| 10737 |
"integrity": "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==",
|
| 10738 |
"license": "ISC",
|
|
|
|
| 10739 |
"bin": {
|
| 10740 |
"yaml": "bin.mjs"
|
| 10741 |
},
|
|
|
|
| 10862 |
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
|
| 10863 |
"dev": true,
|
| 10864 |
"license": "MIT",
|
|
|
|
| 10865 |
"funding": {
|
| 10866 |
"url": "https://github.com/sponsors/colinhacks"
|
| 10867 |
}
|
frontend/src/components/NoteViewer.tsx
CHANGED
|
@@ -5,7 +5,7 @@
|
|
| 5 |
import { useMemo } from 'react';
|
| 6 |
import ReactMarkdown from 'react-markdown';
|
| 7 |
import remarkGfm from 'remark-gfm';
|
| 8 |
-
import { Edit, Trash2, Calendar, Tag as TagIcon, ArrowLeft } from 'lucide-react';
|
| 9 |
import { Badge } from '@/components/ui/badge';
|
| 10 |
import { Button } from '@/components/ui/button';
|
| 11 |
import { ScrollArea } from '@/components/ui/scroll-area';
|
|
@@ -20,6 +20,10 @@ interface NoteViewerProps {
|
|
| 20 |
onEdit?: () => void;
|
| 21 |
onDelete?: () => void;
|
| 22 |
onWikilinkClick: (linkText: string) => void;
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
}
|
| 24 |
|
| 25 |
export function NoteViewer({
|
|
@@ -28,6 +32,10 @@ export function NoteViewer({
|
|
| 28 |
onEdit,
|
| 29 |
onDelete,
|
| 30 |
onWikilinkClick,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 31 |
}: NoteViewerProps) {
|
| 32 |
// Create custom markdown components with wikilink handler
|
| 33 |
const markdownComponents = useMemo(
|
|
@@ -70,6 +78,37 @@ export function NoteViewer({
|
|
| 70 |
<p className="text-sm text-muted-foreground mt-1 animate-fade-in" style={{ animationDelay: '0.2s' }}>{note.note_path}</p>
|
| 71 |
</div>
|
| 72 |
<div className="flex gap-2 animate-fade-in" style={{ animationDelay: '0.15s' }}>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 73 |
{onEdit && (
|
| 74 |
<Button variant="outline" size="sm" onClick={onEdit}>
|
| 75 |
<Edit className="h-4 w-4 mr-2" />
|
|
|
|
| 5 |
import { useMemo } from 'react';
|
| 6 |
import ReactMarkdown from 'react-markdown';
|
| 7 |
import remarkGfm from 'remark-gfm';
|
| 8 |
+
import { Edit, Trash2, Calendar, Tag as TagIcon, ArrowLeft, Volume2, Pause, Play, Square } from 'lucide-react';
|
| 9 |
import { Badge } from '@/components/ui/badge';
|
| 10 |
import { Button } from '@/components/ui/button';
|
| 11 |
import { ScrollArea } from '@/components/ui/scroll-area';
|
|
|
|
| 20 |
onEdit?: () => void;
|
| 21 |
onDelete?: () => void;
|
| 22 |
onWikilinkClick: (linkText: string) => void;
|
| 23 |
+
ttsStatus?: 'idle' | 'loading' | 'playing' | 'paused' | 'error';
|
| 24 |
+
onTtsToggle?: () => void;
|
| 25 |
+
onTtsStop?: () => void;
|
| 26 |
+
ttsDisabledReason?: string;
|
| 27 |
}
|
| 28 |
|
| 29 |
export function NoteViewer({
|
|
|
|
| 32 |
onEdit,
|
| 33 |
onDelete,
|
| 34 |
onWikilinkClick,
|
| 35 |
+
ttsStatus = 'idle',
|
| 36 |
+
onTtsToggle,
|
| 37 |
+
onTtsStop,
|
| 38 |
+
ttsDisabledReason,
|
| 39 |
}: NoteViewerProps) {
|
| 40 |
// Create custom markdown components with wikilink handler
|
| 41 |
const markdownComponents = useMemo(
|
|
|
|
| 78 |
<p className="text-sm text-muted-foreground mt-1 animate-fade-in" style={{ animationDelay: '0.2s' }}>{note.note_path}</p>
|
| 79 |
</div>
|
| 80 |
<div className="flex gap-2 animate-fade-in" style={{ animationDelay: '0.15s' }}>
|
| 81 |
+
{onTtsToggle && (
|
| 82 |
+
<div className="flex gap-2">
|
| 83 |
+
<Button
|
| 84 |
+
variant="outline"
|
| 85 |
+
size="sm"
|
| 86 |
+
onClick={onTtsToggle}
|
| 87 |
+
disabled={Boolean(ttsDisabledReason) || ttsStatus === 'loading'}
|
| 88 |
+
title={ttsDisabledReason || undefined}
|
| 89 |
+
>
|
| 90 |
+
{ttsStatus === 'playing' ? (
|
| 91 |
+
<Pause className="h-4 w-4 mr-2" />
|
| 92 |
+
) : ttsStatus === 'paused' ? (
|
| 93 |
+
<Play className="h-4 w-4 mr-2" />
|
| 94 |
+
) : (
|
| 95 |
+
<Volume2 className="h-4 w-4 mr-2" />
|
| 96 |
+
)}
|
| 97 |
+
{ttsStatus === 'playing'
|
| 98 |
+
? 'Pause TTS'
|
| 99 |
+
: ttsStatus === 'paused'
|
| 100 |
+
? 'Resume TTS'
|
| 101 |
+
: ttsStatus === 'loading'
|
| 102 |
+
? 'Loading...'
|
| 103 |
+
: 'TTS Mode'}
|
| 104 |
+
</Button>
|
| 105 |
+
{(ttsStatus === 'playing' || ttsStatus === 'paused' || ttsStatus === 'error') && onTtsStop && (
|
| 106 |
+
<Button variant="ghost" size="sm" onClick={onTtsStop} title="Stop TTS">
|
| 107 |
+
<Square className="h-4 w-4" />
|
| 108 |
+
</Button>
|
| 109 |
+
)}
|
| 110 |
+
</div>
|
| 111 |
+
)}
|
| 112 |
{onEdit && (
|
| 113 |
<Button variant="outline" size="sm" onClick={onEdit}>
|
| 114 |
<Edit className="h-4 w-4 mr-2" />
|
frontend/src/hooks/useAudioPlayer.ts
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useEffect, useRef, useState } from 'react';
|
| 2 |
+
|
| 3 |
+
type PlayerStatus = 'idle' | 'loading' | 'playing' | 'paused' | 'error';
|
| 4 |
+
|
| 5 |
+
interface AudioPlayer {
|
| 6 |
+
status: PlayerStatus;
|
| 7 |
+
error: string | null;
|
| 8 |
+
play: (src: string) => void;
|
| 9 |
+
pause: () => void;
|
| 10 |
+
resume: () => void;
|
| 11 |
+
stop: () => void;
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
/**
|
| 15 |
+
* Lightweight audio controller for blob URLs returned by the TTS service.
|
| 16 |
+
*/
|
| 17 |
+
export function useAudioPlayer(): AudioPlayer {
|
| 18 |
+
const audioRef = useRef<HTMLAudioElement | null>(null);
|
| 19 |
+
const [status, setStatus] = useState<PlayerStatus>('idle');
|
| 20 |
+
const [error, setError] = useState<string | null>(null);
|
| 21 |
+
|
| 22 |
+
const cleanup = () => {
|
| 23 |
+
const audio = audioRef.current;
|
| 24 |
+
if (audio) {
|
| 25 |
+
audio.pause();
|
| 26 |
+
audio.src = '';
|
| 27 |
+
audioRef.current = null;
|
| 28 |
+
}
|
| 29 |
+
};
|
| 30 |
+
|
| 31 |
+
const stop = () => {
|
| 32 |
+
cleanup();
|
| 33 |
+
setStatus('idle');
|
| 34 |
+
};
|
| 35 |
+
|
| 36 |
+
const play = (src: string) => {
|
| 37 |
+
setError(null);
|
| 38 |
+
cleanup();
|
| 39 |
+
setStatus('loading');
|
| 40 |
+
const audio = new Audio(src);
|
| 41 |
+
audioRef.current = audio;
|
| 42 |
+
|
| 43 |
+
audio.oncanplay = () => {
|
| 44 |
+
audio.play().catch((err) => {
|
| 45 |
+
setError(err?.message || 'Failed to play audio.');
|
| 46 |
+
setStatus('error');
|
| 47 |
+
});
|
| 48 |
+
};
|
| 49 |
+
audio.onplay = () => setStatus('playing');
|
| 50 |
+
audio.onpause = () => setStatus((prev) => (prev === 'loading' ? 'loading' : 'paused'));
|
| 51 |
+
audio.onended = () => setStatus('idle');
|
| 52 |
+
audio.onerror = () => {
|
| 53 |
+
setError('Audio playback error.');
|
| 54 |
+
setStatus('error');
|
| 55 |
+
};
|
| 56 |
+
};
|
| 57 |
+
|
| 58 |
+
const pause = () => {
|
| 59 |
+
const audio = audioRef.current;
|
| 60 |
+
if (audio && !audio.paused) {
|
| 61 |
+
audio.pause();
|
| 62 |
+
}
|
| 63 |
+
};
|
| 64 |
+
|
| 65 |
+
const resume = () => {
|
| 66 |
+
const audio = audioRef.current;
|
| 67 |
+
if (audio && audio.paused) {
|
| 68 |
+
audio.play().catch((err) => {
|
| 69 |
+
setError(err?.message || 'Failed to resume audio.');
|
| 70 |
+
setStatus('error');
|
| 71 |
+
});
|
| 72 |
+
}
|
| 73 |
+
};
|
| 74 |
+
|
| 75 |
+
useEffect(() => {
|
| 76 |
+
return () => {
|
| 77 |
+
cleanup();
|
| 78 |
+
};
|
| 79 |
+
}, []);
|
| 80 |
+
|
| 81 |
+
return { status, error, play, pause, resume, stop };
|
| 82 |
+
}
|
frontend/src/lib/markdownToText.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Convert markdown content into a plaintext string suitable for TTS.
|
| 3 |
+
* Strips code blocks, images, and excess whitespace.
|
| 4 |
+
*/
|
| 5 |
+
export function markdownToPlainText(markdown: string): string {
|
| 6 |
+
if (!markdown) return '';
|
| 7 |
+
|
| 8 |
+
let text = markdown;
|
| 9 |
+
|
| 10 |
+
// Remove fenced code blocks
|
| 11 |
+
text = text.replace(/```[\\s\\S]*?```/g, '');
|
| 12 |
+
// Remove inline code
|
| 13 |
+
text = text.replace(/`([^`]*)`/g, '$1');
|
| 14 |
+
// Remove images: 
|
| 15 |
+
text = text.replace(/!\[[^\]]*\]\([^)]+\)/g, '');
|
| 16 |
+
// Replace markdown links with link text
|
| 17 |
+
text = text.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1');
|
| 18 |
+
// Replace headings with emphasized text
|
| 19 |
+
text = text.replace(/^(#{1,6})\s*(.*)$/gm, '$2');
|
| 20 |
+
// Replace list markers with dash
|
| 21 |
+
text = text.replace(/^\s*[-*+]\s+/gm, '- ');
|
| 22 |
+
// Normalize whitespace
|
| 23 |
+
text = text.replace(/\\s+\\n/g, '\\n').replace(/\\n{3,}/g, '\\n\\n');
|
| 24 |
+
|
| 25 |
+
return text.trim();
|
| 26 |
+
}
|
frontend/src/pages/MainApp.tsx
CHANGED
|
@@ -2,7 +2,7 @@
|
|
| 2 |
* T080, T083-T084: Main application layout with two-pane design
|
| 3 |
* Loads directory tree on mount and note + backlinks when path changes
|
| 4 |
*/
|
| 5 |
-
import { useState, useEffect } from 'react';
|
| 6 |
import { useNavigate } from 'react-router-dom';
|
| 7 |
import { Plus, Settings as SettingsIcon, FolderPlus, MessageCircle } from 'lucide-react';
|
| 8 |
import { ResizablePanelGroup, ResizablePanel, ResizableHandle } from '@/components/ui/resizable';
|
|
@@ -43,6 +43,9 @@ import type { Note, NoteSummary } from '@/types/note';
|
|
| 43 |
import { normalizeSlug } from '@/lib/wikilink';
|
| 44 |
import { Network } from 'lucide-react';
|
| 45 |
import { AUTH_TOKEN_CHANGED_EVENT, isDemoSession, login } from '@/services/auth';
|
|
|
|
|
|
|
|
|
|
| 46 |
|
| 47 |
export function MainApp() {
|
| 48 |
const navigate = useNavigate();
|
|
@@ -65,6 +68,29 @@ export function MainApp() {
|
|
| 65 |
const [isCreatingFolder, setIsCreatingFolder] = useState(false);
|
| 66 |
const [isDemoMode, setIsDemoMode] = useState<boolean>(isDemoSession());
|
| 67 |
const [isChatOpen, setIsChatOpen] = useState(false);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 68 |
|
| 69 |
useEffect(() => {
|
| 70 |
const handleAuthChange = () => {
|
|
@@ -80,6 +106,12 @@ export function MainApp() {
|
|
| 80 |
};
|
| 81 |
}, []);
|
| 82 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 83 |
// T083: Load directory tree on mount
|
| 84 |
// T119: Load index health
|
| 85 |
useEffect(() => {
|
|
@@ -115,6 +147,17 @@ export function MainApp() {
|
|
| 115 |
loadData();
|
| 116 |
}, []);
|
| 117 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 118 |
// T084: Load note and backlinks when path changes
|
| 119 |
useEffect(() => {
|
| 120 |
if (!selectedPath) {
|
|
@@ -179,6 +222,57 @@ export function MainApp() {
|
|
| 179 |
}
|
| 180 |
};
|
| 181 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 182 |
const handleSelectNote = (path: string) => {
|
| 183 |
setSelectedPath(path);
|
| 184 |
setError(null);
|
|
@@ -391,6 +485,9 @@ export function MainApp() {
|
|
| 391 |
}
|
| 392 |
};
|
| 393 |
|
|
|
|
|
|
|
|
|
|
| 394 |
return (
|
| 395 |
<div className="h-screen flex flex-col">
|
| 396 |
{/* Demo warning banner */}
|
|
@@ -608,6 +705,10 @@ export function MainApp() {
|
|
| 608 |
backlinks={backlinks}
|
| 609 |
onEdit={isDemoMode ? undefined : handleEdit}
|
| 610 |
onWikilinkClick={handleWikilinkClick}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 611 |
/>
|
| 612 |
)
|
| 613 |
) : (
|
|
|
|
| 2 |
* T080, T083-T084: Main application layout with two-pane design
|
| 3 |
* Loads directory tree on mount and note + backlinks when path changes
|
| 4 |
*/
|
| 5 |
+
import { useState, useEffect, useRef } from 'react';
|
| 6 |
import { useNavigate } from 'react-router-dom';
|
| 7 |
import { Plus, Settings as SettingsIcon, FolderPlus, MessageCircle } from 'lucide-react';
|
| 8 |
import { ResizablePanelGroup, ResizablePanel, ResizableHandle } from '@/components/ui/resizable';
|
|
|
|
| 43 |
import { normalizeSlug } from '@/lib/wikilink';
|
| 44 |
import { Network } from 'lucide-react';
|
| 45 |
import { AUTH_TOKEN_CHANGED_EVENT, isDemoSession, login } from '@/services/auth';
|
| 46 |
+
import { synthesizeTts } from '@/services/tts';
|
| 47 |
+
import { markdownToPlainText } from '@/lib/markdownToText';
|
| 48 |
+
import { useAudioPlayer } from '@/hooks/useAudioPlayer';
|
| 49 |
|
| 50 |
export function MainApp() {
|
| 51 |
const navigate = useNavigate();
|
|
|
|
| 68 |
const [isCreatingFolder, setIsCreatingFolder] = useState(false);
|
| 69 |
const [isDemoMode, setIsDemoMode] = useState<boolean>(isDemoSession());
|
| 70 |
const [isChatOpen, setIsChatOpen] = useState(false);
|
| 71 |
+
const [isSynthesizingTts, setIsSynthesizingTts] = useState(false);
|
| 72 |
+
const ttsUrlRef = useRef<string | null>(null);
|
| 73 |
+
const ttsAbortRef = useRef<AbortController | null>(null);
|
| 74 |
+
const {
|
| 75 |
+
status: ttsPlayerStatus,
|
| 76 |
+
error: ttsPlayerError,
|
| 77 |
+
play: playAudio,
|
| 78 |
+
pause: pauseAudio,
|
| 79 |
+
resume: resumeAudio,
|
| 80 |
+
stop: stopAudio,
|
| 81 |
+
} = useAudioPlayer();
|
| 82 |
+
const stopTts = () => {
|
| 83 |
+
if (ttsAbortRef.current) {
|
| 84 |
+
ttsAbortRef.current.abort();
|
| 85 |
+
ttsAbortRef.current = null;
|
| 86 |
+
}
|
| 87 |
+
stopAudio();
|
| 88 |
+
if (ttsUrlRef.current) {
|
| 89 |
+
URL.revokeObjectURL(ttsUrlRef.current);
|
| 90 |
+
ttsUrlRef.current = null;
|
| 91 |
+
}
|
| 92 |
+
setIsSynthesizingTts(false);
|
| 93 |
+
};
|
| 94 |
|
| 95 |
useEffect(() => {
|
| 96 |
const handleAuthChange = () => {
|
|
|
|
| 106 |
};
|
| 107 |
}, []);
|
| 108 |
|
| 109 |
+
useEffect(() => {
|
| 110 |
+
if (ttsPlayerError) {
|
| 111 |
+
toast.error(ttsPlayerError);
|
| 112 |
+
}
|
| 113 |
+
}, [ttsPlayerError, toast]);
|
| 114 |
+
|
| 115 |
// T083: Load directory tree on mount
|
| 116 |
// T119: Load index health
|
| 117 |
useEffect(() => {
|
|
|
|
| 147 |
loadData();
|
| 148 |
}, []);
|
| 149 |
|
| 150 |
+
useEffect(() => {
|
| 151 |
+
// Stop TTS when switching notes
|
| 152 |
+
stopTts();
|
| 153 |
+
}, [selectedPath]);
|
| 154 |
+
|
| 155 |
+
useEffect(() => {
|
| 156 |
+
return () => {
|
| 157 |
+
stopTts();
|
| 158 |
+
};
|
| 159 |
+
}, []);
|
| 160 |
+
|
| 161 |
// T084: Load note and backlinks when path changes
|
| 162 |
useEffect(() => {
|
| 163 |
if (!selectedPath) {
|
|
|
|
| 222 |
}
|
| 223 |
};
|
| 224 |
|
| 225 |
+
const handleTtsToggle = async () => {
|
| 226 |
+
if (!currentNote || isSynthesizingTts) {
|
| 227 |
+
return;
|
| 228 |
+
}
|
| 229 |
+
|
| 230 |
+
if (ttsPlayerStatus === 'playing') {
|
| 231 |
+
pauseAudio();
|
| 232 |
+
return;
|
| 233 |
+
}
|
| 234 |
+
|
| 235 |
+
if (ttsPlayerStatus === 'paused') {
|
| 236 |
+
resumeAudio();
|
| 237 |
+
return;
|
| 238 |
+
}
|
| 239 |
+
|
| 240 |
+
const plainText = markdownToPlainText(currentNote.body);
|
| 241 |
+
if (!plainText) {
|
| 242 |
+
toast.error('No readable content in this note.');
|
| 243 |
+
return;
|
| 244 |
+
}
|
| 245 |
+
|
| 246 |
+
if (ttsAbortRef.current) {
|
| 247 |
+
ttsAbortRef.current.abort();
|
| 248 |
+
}
|
| 249 |
+
const controller = new AbortController();
|
| 250 |
+
ttsAbortRef.current = controller;
|
| 251 |
+
setIsSynthesizingTts(true);
|
| 252 |
+
try {
|
| 253 |
+
const blob = await synthesizeTts(plainText, { signal: controller.signal });
|
| 254 |
+
if (ttsUrlRef.current) {
|
| 255 |
+
URL.revokeObjectURL(ttsUrlRef.current);
|
| 256 |
+
}
|
| 257 |
+
const url = URL.createObjectURL(blob);
|
| 258 |
+
ttsUrlRef.current = url;
|
| 259 |
+
playAudio(url);
|
| 260 |
+
} catch (err) {
|
| 261 |
+
if (err instanceof DOMException && err.name === 'AbortError') {
|
| 262 |
+
return;
|
| 263 |
+
}
|
| 264 |
+
const message = err instanceof Error ? err.message : 'Failed to generate speech.';
|
| 265 |
+
toast.error(message);
|
| 266 |
+
} finally {
|
| 267 |
+
ttsAbortRef.current = null;
|
| 268 |
+
setIsSynthesizingTts(false);
|
| 269 |
+
}
|
| 270 |
+
};
|
| 271 |
+
|
| 272 |
+
const handleTtsStop = () => {
|
| 273 |
+
stopTts();
|
| 274 |
+
};
|
| 275 |
+
|
| 276 |
const handleSelectNote = (path: string) => {
|
| 277 |
setSelectedPath(path);
|
| 278 |
setError(null);
|
|
|
|
| 485 |
}
|
| 486 |
};
|
| 487 |
|
| 488 |
+
const ttsStatus = isSynthesizingTts ? 'loading' : ttsPlayerStatus;
|
| 489 |
+
const ttsDisabledReason = undefined;
|
| 490 |
+
|
| 491 |
return (
|
| 492 |
<div className="h-screen flex flex-col">
|
| 493 |
{/* Demo warning banner */}
|
|
|
|
| 705 |
backlinks={backlinks}
|
| 706 |
onEdit={isDemoMode ? undefined : handleEdit}
|
| 707 |
onWikilinkClick={handleWikilinkClick}
|
| 708 |
+
ttsStatus={ttsStatus}
|
| 709 |
+
onTtsToggle={handleTtsToggle}
|
| 710 |
+
onTtsStop={handleTtsStop}
|
| 711 |
+
ttsDisabledReason={ttsDisabledReason}
|
| 712 |
/>
|
| 713 |
)
|
| 714 |
) : (
|
frontend/src/services/tts.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* ElevenLabs TTS client hitting the backend proxy.
|
| 3 |
+
*/
|
| 4 |
+
|
| 5 |
+
export interface TtsOptions {
|
| 6 |
+
voiceId?: string;
|
| 7 |
+
model?: string;
|
| 8 |
+
signal?: AbortSignal;
|
| 9 |
+
}
|
| 10 |
+
|
| 11 |
+
export async function synthesizeTts(text: string, options: TtsOptions = {}): Promise<Blob> {
|
| 12 |
+
const token = localStorage.getItem('auth_token');
|
| 13 |
+
const response = await fetch('/api/tts', {
|
| 14 |
+
method: 'POST',
|
| 15 |
+
headers: {
|
| 16 |
+
'Content-Type': 'application/json',
|
| 17 |
+
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
| 18 |
+
},
|
| 19 |
+
signal: options.signal,
|
| 20 |
+
body: JSON.stringify({
|
| 21 |
+
text,
|
| 22 |
+
voice_id: options.voiceId,
|
| 23 |
+
model: options.model,
|
| 24 |
+
}),
|
| 25 |
+
});
|
| 26 |
+
|
| 27 |
+
if (!response.ok) {
|
| 28 |
+
let message = `TTS failed (HTTP ${response.status})`;
|
| 29 |
+
try {
|
| 30 |
+
const data = await response.json();
|
| 31 |
+
message = data?.message || data?.detail?.message || message;
|
| 32 |
+
} catch {
|
| 33 |
+
// ignore parse errors
|
| 34 |
+
}
|
| 35 |
+
throw new Error(message);
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
return response.blob();
|
| 39 |
+
}
|