Skip to content

Commit 2f14581

Browse files
committed
Merge branch 'main' of github.com:jupyter-naas/abi
2 parents e18ba74 + 2bc2cf6 commit 2f14581

File tree

12 files changed

+771
-76
lines changed

12 files changed

+771
-76
lines changed

libs/naas-abi-core/CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,14 @@
22

33
<!-- version list -->
44

5+
## v1.21.0 (2026-03-16)
6+
7+
### Features
8+
9+
- Make it possible to skip ontology loading
10+
([`72bceae`](https://github.com/jupyter-naas/abi/commit/72bceaec4aeed2675b43f4042dde74e14d0a6ad4))
11+
12+
513
## v1.20.1 (2026-03-12)
614

715
### Bug Fixes

libs/naas-abi-core/pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "naas-abi-core"
3-
version = "1.20.1"
3+
version = "1.21.0"
44
description = "Abi framework allowing you to build your AI system."
55
authors = [{ name = "Maxime Jublou", email = "maxime@naas.ai" },{ name = "Florent Ravenel", email = "florent@naas.ai" }, { name = "Jeremy Ravenel", email = "jeremy@naas.ai" }]
66
requires-python = ">=3.10,<4"

libs/naas-abi/CHANGELOG.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,19 @@
22

33
<!-- version list -->
44

5+
## v1.12.0 (2026-03-17)
6+
7+
### Bug Fixes
8+
9+
- Apps and files section nexus ui
10+
([`1d1072f`](https://github.com/jupyter-naas/abi/commit/1d1072f05f56e27914b2784bfc78381b17038695))
11+
12+
### Features
13+
14+
- Add viewers to files
15+
([`79e9068`](https://github.com/jupyter-naas/abi/commit/79e906835240e64170aca034a8a0bbeeb82a4bdc))
16+
17+
518
## v1.11.0 (2026-03-05)
619

720
### Bug Fixes

libs/naas-abi/naas_abi/__init__.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,18 @@ class TenantConfig(BaseModel):
3939
show_terms_footer: bool = False
4040
show_powered_by: bool = True
4141
login_footer_text: str | None = None
42+
apps: list["ExternalAppConfig"] = Field(default_factory=list)
43+
44+
45+
class ExternalAppConfig(BaseModel):
46+
"""External app shortcut displayed in the Apps page."""
47+
48+
model_config = ConfigDict(extra="forbid")
49+
50+
name: str
51+
url: str
52+
description: str | None = None
53+
icon_emoji: str | None = None
4254

4355

4456
class UserSeedConfig(BaseModel):

libs/naas-abi/naas_abi/apps/nexus/apps/api/app/api/endpoints/files.py

Lines changed: 118 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,23 @@
11
"""File management API endpoints backed by ABI ObjectStorageService."""
22

33
from datetime import datetime
4-
from pathlib import PurePosixPath
4+
from pathlib import Path, PurePosixPath
5+
import shutil
6+
import subprocess
7+
import tempfile
58

6-
from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, Request, UploadFile
9+
from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, Request, Response, UploadFile
710
from naas_abi.apps.nexus.apps.api.app.api.endpoints.auth import get_current_user_required
811
from naas_abi_core.services.object_storage.ObjectStoragePort import Exceptions
912
from naas_abi_core.services.object_storage.ObjectStorageService import ObjectStorageService
1013
from pydantic import BaseModel, Field
1114

1215
router = APIRouter(dependencies=[Depends(get_current_user_required)])
1316

14-
OBJECT_STORAGE_PREFIX = "nexus/files"
17+
# Empty prefix means: browse the object storage base_prefix as-is.
18+
# With current config this is `abi/datastore`, so users can navigate
19+
# `external/...`, `nexus/...`, etc. directly from the Files UI.
20+
OBJECT_STORAGE_PREFIX = ""
1521
FOLDER_MARKER = ".nexus_folder"
1622
MAX_UPLOAD_SIZE = 50 * 1024 * 1024 # 50MB
1723

@@ -142,7 +148,7 @@ def _list_directory(storage: ObjectStorageService, path: str) -> list[str]:
142148

143149
children: set[str] = set()
144150
for raw_path in raw_paths:
145-
relative = _relative_from_storage_path(raw_path)
151+
relative = _relative_from_storage_path(raw_path).strip("/")
146152
if not relative:
147153
continue
148154
if relative == FOLDER_MARKER or relative.endswith(f"/{FOLDER_MARKER}"):
@@ -240,6 +246,13 @@ async def list_files(request: Request, path: str = Query("", description="Direct
240246
".txt": "text/plain",
241247
".yaml": "text/yaml",
242248
".yml": "text/yaml",
249+
".pdf": "application/pdf",
250+
".png": "image/png",
251+
".jpg": "image/jpeg",
252+
".jpeg": "image/jpeg",
253+
".gif": "image/gif",
254+
".webp": "image/webp",
255+
".svg": "image/svg+xml",
243256
}
244257
content_type = content_types.get(ext, "application/octet-stream")
245258
except Exceptions.ObjectNotFound:
@@ -396,6 +409,107 @@ async def upload_file(
396409
# =============================================================================
397410

398411

412+
@router.get("/preview/pdf/{path:path}")
413+
async def preview_file_as_pdf(request: Request, path: str):
414+
"""Preview a presentation file as PDF (requires LibreOffice)."""
415+
storage = get_object_storage(request)
416+
normalized_path = normalize_relative_path(path)
417+
ext = PurePosixPath(normalized_path).suffix.lower()
418+
if ext not in {".ppt", ".pptx"}:
419+
raise HTTPException(status_code=400, detail="Only PPT/PPTX preview is supported")
420+
if _is_directory(storage, normalized_path):
421+
raise HTTPException(status_code=400, detail="Cannot preview a directory")
422+
423+
try:
424+
content_bytes = _read_bytes(storage, normalized_path)
425+
except Exceptions.ObjectNotFound as exc:
426+
raise HTTPException(status_code=404, detail="File not found") from exc
427+
428+
soffice = shutil.which("soffice") or shutil.which("libreoffice")
429+
if not soffice:
430+
raise HTTPException(
431+
status_code=501,
432+
detail="PPTX preview is unavailable: LibreOffice is not installed on the API service.",
433+
)
434+
435+
with tempfile.TemporaryDirectory(prefix="nexus-ppt-preview-") as tmp_dir:
436+
input_path = Path(tmp_dir) / PurePosixPath(normalized_path).name
437+
output_name = f"{PurePosixPath(normalized_path).stem}.pdf"
438+
output_path = Path(tmp_dir) / output_name
439+
input_path.write_bytes(content_bytes)
440+
441+
result = subprocess.run(
442+
[
443+
soffice,
444+
"--headless",
445+
"--convert-to",
446+
"pdf",
447+
"--outdir",
448+
str(Path(tmp_dir)),
449+
str(input_path),
450+
],
451+
capture_output=True,
452+
text=True,
453+
timeout=60,
454+
check=False,
455+
)
456+
if result.returncode != 0 or not output_path.exists():
457+
raise HTTPException(
458+
status_code=500,
459+
detail="Failed to convert presentation to PDF preview.",
460+
)
461+
462+
pdf_bytes = output_path.read_bytes()
463+
464+
return Response(
465+
content=pdf_bytes,
466+
media_type="application/pdf",
467+
headers={"Content-Disposition": f'inline; filename="{output_name}"'},
468+
)
469+
470+
471+
@router.get("/raw/{path:path}")
472+
async def read_file_raw(request: Request, path: str):
473+
"""Read raw file bytes (for binary previews/downloads)."""
474+
storage = get_object_storage(request)
475+
normalized_path = normalize_relative_path(path)
476+
477+
if _is_directory(storage, normalized_path):
478+
raise HTTPException(status_code=400, detail="Cannot read a directory")
479+
480+
try:
481+
content_bytes = _read_bytes(storage, normalized_path)
482+
except Exceptions.ObjectNotFound as exc:
483+
raise HTTPException(status_code=404, detail="File not found") from exc
484+
485+
ext = PurePosixPath(normalized_path).suffix.lower()
486+
content_types = {
487+
".py": "text/x-python",
488+
".js": "text/javascript",
489+
".ts": "text/typescript",
490+
".json": "application/json",
491+
".md": "text/markdown",
492+
".txt": "text/plain",
493+
".yaml": "text/yaml",
494+
".yml": "text/yaml",
495+
".pdf": "application/pdf",
496+
".png": "image/png",
497+
".jpg": "image/jpeg",
498+
".jpeg": "image/jpeg",
499+
".gif": "image/gif",
500+
".webp": "image/webp",
501+
".svg": "image/svg+xml",
502+
}
503+
content_type = content_types.get(ext, "application/octet-stream")
504+
filename = PurePosixPath(normalized_path).name
505+
506+
return Response(
507+
content=content_bytes,
508+
media_type=content_type,
509+
headers={"Content-Disposition": f'inline; filename="{filename}"'},
510+
)
511+
512+
399513
@router.get("/{path:path}", response_model=FileContent)
400514
async def read_file(request: Request, path: str):
401515
"""Read file content."""

libs/naas-abi/naas_abi/apps/nexus/apps/api/app/api/endpoints/tenant.py

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,18 @@
77

88
from fastapi import APIRouter
99
from naas_abi.apps.nexus.apps.api.app.core.config import settings
10-
from pydantic import BaseModel
10+
from pydantic import BaseModel, Field
1111

1212
router = APIRouter()
1313

1414

1515
class TenantResponse(BaseModel):
16+
class ExternalAppResponse(BaseModel):
17+
name: str
18+
url: str
19+
description: str | None = None
20+
icon_emoji: str | None = None
21+
1622
tab_title: str
1723
favicon_url: str | None = None
1824
logo_url: str | None = None
@@ -33,6 +39,7 @@ class TenantResponse(BaseModel):
3339
show_terms_footer: bool
3440
show_powered_by: bool
3541
login_footer_text: str | None = None
42+
apps: list[ExternalAppResponse] = Field(default_factory=list)
3643

3744

3845
@router.get("", response_model=TenantResponse)
@@ -59,4 +66,13 @@ async def get_tenant_config() -> TenantResponse:
5966
show_terms_footer=settings.tenant.show_terms_footer,
6067
show_powered_by=settings.tenant.show_powered_by,
6168
login_footer_text=settings.tenant.login_footer_text,
69+
apps=[
70+
TenantResponse.ExternalAppResponse(
71+
name=app.name,
72+
url=app.url,
73+
description=app.description,
74+
icon_emoji=app.icon_emoji,
75+
)
76+
for app in settings.tenant.apps
77+
],
6278
)

libs/naas-abi/naas_abi/apps/nexus/apps/api/app/core/config.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,18 @@ class TenantConfig(BaseModel):
4747
show_terms_footer: bool = False
4848
show_powered_by: bool = True
4949
login_footer_text: str | None = None
50+
apps: list["ExternalAppConfig"] = Field(default_factory=list)
51+
52+
53+
class ExternalAppConfig(BaseModel):
54+
"""External app shortcut displayed in the Apps page."""
55+
56+
model_config = ConfigDict(extra="forbid")
57+
58+
name: str
59+
url: str
60+
description: str | None = None
61+
icon_emoji: str | None = None
5062

5163

5264
class UserSeedConfig(BaseModel):

0 commit comments

Comments
 (0)