Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ Frontend has no linter. UI changes are verified by relevant flows in the browser
- Interface language ([docs/dev/i18n.md](docs/dev/i18n.md)): every user-visible string in the frontend goes through `t("English sentence")` from `src/shared/i18n/i18n.js` (`tn` for counts, `T` to mark a string in a static table); the English text is the key, `locales/zh.json` holds the Chinese. A new string needs its catalog line in the same change: `npm run i18n -- --sync` adds the empty key, `node --test tests/i18n.test.mjs` fails until it is filled; `npm run i18n:audit` lists text that never reached `t()`. `t()` works in module-level constants (the catalog loads before `App`; a language change reloads the page). Never compare against translated text. The browser suite pins `en-US` on every context.
- Theme: Settings → Appearance — System plus the seven pinned themes in `THEMES` (`app/prefs.js`; `gamma-theme` in localStorage, an inline script in `index.html` applies a pinned theme before first paint) plus display-only "Flip page colors" (`gamma-pdf-dark`).
- Sharing a page or a folder: `src/sharing/SharePopover.jsx` (the header link button, a popover under it like the account menu; for a folder the same popover with a folder `target` — only the words change — under the topbar's link button while a folder is open, or from the folder menu's Share…) — link + Copy + Stop sharing, access as three audience tiles (anyone / signed in / invited only) plus a View / Edit toggle, invited people with their own access, the citation section; built from the settings kit like the workspace Manage dialog. There is no "reset link" — stop and share again. Data functions stay in App.jsx (`loadShareSettings`, `updateShareSettings`, …).
- View modes are derived from the URL: `/` home, `/?page=<id>` page (with PDF if it has `source_url`), `/?share=<token>` the share view (`shareMode`: no library/chat/prefs; `readOnly` is state — false once the link resolves with edit rights, and `utils.withShare` puts the token on every API call; a folder share shows its listing, `sharing/SharedFolder.jsx`, and opens a page with `page=<id>` beside the token, each a history entry), `/?block=<id>` jump-to-block; every non-share URL also carries `ws=<workspace id>`.
- View modes are derived from the URL: `/` home, `/?page=<id>` page (with PDF if it has `source_url`), `/?share=<token>` the share view (`shareMode`: no library/chat/prefs; `readOnly` is state — false once the link resolves with edit rights, and `utils.withShare` puts the token on every API call; a folder share shows the home library confined to that folder — `library/libraryAccess.js` is the one object every home affordance asks, `root`/`browse`/`organize`/`pin`/`history`, also what makes a workspace viewer's library read-only — and opens a page with `page=<id>` beside the token, each a history entry), `/?block=<id>` jump-to-block; every non-share URL also carries `ws=<workspace id>`.
- Reference links: a highlight block with `properties.link_url` / `link_page_id` is a clickable link region on the PDF; `link_highlight_id` additionally targets an exact highlight in that paper. Document links resolve against the library by DOI/arXiv id before offering fetch-vs-browser.
- Home library (folder labels, merged listing, the shared `PageCard`, recents strip + snapshots, context menu): [docs/dev/home_library.md](docs/dev/home_library.md).
- Menus (`src/shared/ui/Menus.jsx`): `ContextMenu` + row primitives (`MenuItem`/`MenuLabel`/`SubMenuItem`). A flyout renders INSIDE the parent menu's DOM (portalling would break the outside-pointerdown test) and opens on hover guarded by `src/shared/ui/menuAim.js` (the "safe triangle"; UI-agnostic, reuse for any hierarchical surface).
Expand Down
25 changes: 14 additions & 11 deletions backend/gamma/ai_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -315,21 +315,21 @@ def server_entries_for(user: str) -> list:


def allowance_status(user: str, limit: int) -> dict:
"""What the pickers and the Usage pane show of the shared allowance:
{"limit", "used" (tokens through shared entries in the last 24 h),
"exhausted"}."""
"""What the account card, the pickers and the Usage pane show of the
shared allowance: {"limit" (0 = unlimited), "used" (tokens through
shared entries in the last 24 h), "exhausted"}."""
used = ai_usage.shared_used(user)
return {"limit": limit, "used": used, "exhausted": used >= limit}
return {"limit": limit, "used": used, "exhausted": bool(limit) and used >= limit}


def shared_allowance(user: str) -> dict | None:
"""``allowance_status`` when a metered shared entry applies to ``user``
(one it can use: a key, or a connected sign-in, under a non-zero
limit), else None — the object ai_runtime() reports, without building
"""``allowance_status`` when a shared entry applies to ``user`` (one it
can use: a key, or a connected sign-in; limit 0 when the admin set
none), else None — the object ai_runtime() reports, without building
the runtime."""
entries, limit = shared_access(user)
usable = any(ai_protocols.PROTOCOLS.get(e.get("protocol")) and _has_credential(e) for e in entries)
return allowance_status(user, limit) if usable and limit else None
return allowance_status(user, limit) if usable else None


def provider_label(entry: dict) -> str:
Expand Down Expand Up @@ -488,10 +488,13 @@ def ai_runtime(user: str) -> dict:
"shared": is_server_id(pid)})
allowance = None
shared_ids = [pid for pid in providers if is_server_id(pid)]
if limit and shared_ids:
if shared_ids:
# Reported whenever a shared entry applies (limit 0 = unlimited);
# the transport only meters under a limit.
allowance = allowance_status(user, limit)
for pid in shared_ids:
providers[pid]["allowance"] = {"user": user, "limit": limit}
if limit:
for pid in shared_ids:
providers[pid]["allowance"] = {"user": user, "limit": limit}
return {
"user": user, # whose config this is — the usage recorder's key
"providers": providers,
Expand Down
10 changes: 6 additions & 4 deletions backend/gamma/routers/blocks.py
Original file line number Diff line number Diff line change
Expand Up @@ -165,19 +165,21 @@ async def ub_get_or_create_by_doc(doc_id: str, payload: UBByDocCreate, request:
@router.get("/blocks/{block_id}/children")
async def ub_get_children(block_id: str, request: Request):
scope = share_scope(request)
if scope is not None and block_id == "root":
# A share link may not enumerate the owner's library root (a folder
# share lists its pages through GET /share/{token}).
if scope is not None and block_id == "root" and not scope.folder:
# A page share may not enumerate the owner's library; a folder share
# lists the pages it reaches — the share view's home library.
raise HTTPException(status_code=403, detail="not accessible via this share link")
with connect_pages_db(resolve_ws(request)) as conn:
if block_id != "root":
if not conn.execute("SELECT 1 FROM unified_blocks WHERE id = ?", (block_id,)).fetchone():
raise HTTPException(status_code=404, detail="block not found")
assert_block_in_scope(conn, block_id, scope)
assert_block_in_scope(conn, block_id, scope)
rows = conn.execute(
f"SELECT {BLOCK_COLUMNS} FROM unified_blocks WHERE parent_id = ? ORDER BY position ASC",
(block_id,),
).fetchall()
if scope is not None and block_id == "root":
rows = [r for r in rows if scope.allows_page(conn, r[0])]
previews = _page_previews(conn) if block_id == "root" else None
children = [block_to_dict(r) for r in rows]
if previews is not None:
Expand Down
9 changes: 7 additions & 2 deletions backend/gamma/routers/pdf.py
Original file line number Diff line number Diff line change
Expand Up @@ -296,9 +296,14 @@ def proxy_pdf(source_url: str, request: Request):
local_path = uploads / f"{pdf_doc_id}.pdf"
want_save = request.query_params.get("save") == "1"

# If a local copy exists, redirect to the uploads route (supports Range requests)
# If a local copy exists, redirect to the uploads route (supports Range
# requests). The browser follows a redirect with no help from the app, so
# the query that named the workspace — a share token, or ?ws= — rides
# along, or the copy would be looked for in the session's own library.
if local_path.exists():
return RedirectResponse(f"/api/uploads/{pdf_doc_id}.pdf", status_code=302)
carried = {k: v for k, v in request.query_params.items() if k in ("share", "ws")}
target = f"/api/uploads/{pdf_doc_id}.pdf" + (f"?{urllib.parse.urlencode(carried)}" if carried else "")
return RedirectResponse(target, status_code=302)

# Download from source. Streamed through to the client as upstream bytes
# arrive — buffering the whole file first meant the browser saw zero bytes
Expand Down
42 changes: 8 additions & 34 deletions backend/gamma/routers/shares.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,11 +37,11 @@
from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel

from ..auth import (SHARE_AUDIENCES, SHARE_ROLES, ShareScope, note_share_miss, require_ws,
serialize_share_users, share_access, share_lookup)
from ..auth import (SHARE_AUDIENCES, SHARE_ROLES, note_share_miss, require_ws, serialize_share_users,
share_access, share_lookup)
from ..blocks_store import page_attachment, root_pages
from ..db import connect_pages_db, connect_users_db, page_now
from ..foldertags import clean_path, parse_tags, path_within
from ..foldertags import clean_path, path_within

router = APIRouter(prefix="/api", tags=["shares"])

Expand Down Expand Up @@ -121,31 +121,6 @@ def _page_doc_id(ws: str, page_id: str) -> str:
return attachment["id"] if attachment else ""


def _folder_pages(ws: str, folder: str) -> list[dict]:
"""The share view's listing of a folder share: every page the scope
reaches, newest edit first — ``{id, title, doc_id, folders, labels,
created_at, updated_at}``."""
scope = ShareScope(folder=folder)
pages = []
with connect_pages_db(ws) as conn:
for page_id, content, props_raw, created_at, updated_at in conn.execute(
"SELECT id, content, properties, created_at, updated_at FROM unified_blocks "
"WHERE parent_id = 'root' ORDER BY updated_at DESC"):
try:
props = json.loads(props_raw or "{}")
except ValueError:
props = {}
if not any(path_within(tag, scope.folder) for tag in parse_tags(props.get("folder"))):
continue
attachment = page_attachment(props)
pages.append({"id": page_id, "title": content or "Untitled",
"doc_id": attachment["id"] if attachment else "",
"folders": parse_tags(props.get("folder")),
"labels": parse_tags(props.get("category")),
"created_at": created_at, "updated_at": updated_at})
return pages


def _validated(editor: str, current: dict, payload: ShareSettings) -> dict:
audience = payload.audience if payload.audience is not None else current["audience"]
role = payload.role if payload.role is not None else current["role"]
Expand Down Expand Up @@ -318,9 +293,10 @@ async def get_share(token: str, request: Request):
grant access, 403 when this signed-in account isn't allowed. Otherwise
what the link shares plus what this viewer may do (``can_edit``): a page
share carries ``page_id`` and ``doc_id`` (the page's PDF attachment id,
"" without one); a folder share carries ``folder`` and ``pages``, the
listing the share view shows (``_folder_pages``). ``username`` is who
shared it; ``workspace_id`` the workspace. ``viewer`` / ``viewer_is_guest``
"" without one); a folder share carries ``folder`` — the share view then
lists it through ``GET /blocks/root/children`` like the home library.
``username`` is who shared it; ``workspace_id`` the workspace.
``viewer`` / ``viewer_is_guest``
tell the share view whether to offer "Open in my library" (a member) or
"Add to my library" (an account that can import)."""
share = share_lookup(token)
Expand All @@ -336,8 +312,6 @@ async def get_share(token: str, request: Request):
"username": share["created_by"], "workspace_id": share["workspace_id"],
"audience": share["audience"], "role": share["role"], "can_edit": level == "edit",
"viewer": request.state.user or "", "viewer_is_guest": bool(request.state.is_guest)}
if share["folder"]:
out["pages"] = _folder_pages(share["workspace_id"], share["folder"])
else:
if share["page_id"]:
out["doc_id"] = _page_doc_id(share["workspace_id"], share["page_id"])
return out
2 changes: 1 addition & 1 deletion backend/gamma/seed.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ def _welcome_blocks():
(secrets.token_urlsafe(9), figures_id, generate_key_between("a0", None), f"![]({_SCREENSHOTS}/01-annotated-pdf.png)", '{}'),
(secrets.token_urlsafe(9), figures_id, generate_key_between("a0V", None), f"![]({_SCREENSHOTS}/02-home.png)", '{}'),
(guest_id, wid, generate_key_between("a1", None), "## Guest account", '{}'),
(secrets.token_urlsafe(9), guest_id, "a0", f"You are signed in as a **guest**. This workspace is yours alone, and it is deleted with everything in it {_guest_lifetime()} after you started. To keep your work, ask the admin for an account.", '{}'),
(secrets.token_urlsafe(9), guest_id, "a0", f"You are signed in as a **guest**. This workspace is yours alone. It stays for {_guest_lifetime()} after you started, or until you log out, and is then deleted with everything in it. To keep your work, ask the admin for an account.", '{}'),
(md_id, wid, generate_key_between("a1V", None), "## Markdown formatting", '{}'),
(secrets.token_urlsafe(9), md_id, "a0", "Blocks support **bold**, *italic*, `code`, [links](https://example.com), and inline $\\KaTeX$ math like $E = mc^2$.", '{}'),
]
Expand Down
12 changes: 8 additions & 4 deletions backend/tests/test_ai_allowance.py
Original file line number Diff line number Diff line change
Expand Up @@ -131,9 +131,12 @@ def test_admin_round_trip_and_validation(admin, member, shared):
def test_runtime_reports_the_allowance(admin, member, shared):
from gamma.ai_settings import ai_runtime
from gamma.ai_usage import shared_used
# No limit: nothing metered, nothing reported.
assert ai_runtime("allow_member")["allowance"] is None
# No limit: the usage is reported, nothing is metered.
spend("allow_member", shared, 40)
assert ai_runtime("allow_member")["allowance"] == {"limit": 0, "used": 40, "exhausted": False}
assert "allowance" not in ai_runtime("allow_member")["providers"][shared]
from gamma import ai_usage
ai_usage.clear("allow_member", keep_metered=False)

set_allowance(admin, accounts=LIMIT)
spend("allow_member", shared, 300)
Expand All @@ -157,7 +160,8 @@ def test_runtime_reports_the_allowance(admin, member, shared):

def test_usage_carries_the_allowance(admin, member, shared):
body = member.get("/api/ai/usage").json()
assert body["allowance"] is None and "windows" in body and "models" in body
assert body["allowance"] == {"limit": 0, "used": 0, "exhausted": False}
assert "windows" in body and "models" in body
set_allowance(admin, accounts=LIMIT)
spend("allow_member", shared, 250)
body = member.get("/api/ai/usage").json()
Expand Down Expand Up @@ -258,7 +262,7 @@ def test_guests_have_their_own_limit(admin, member, shared, upstream):
# Accounts unlimited, guests metered; the switch decides access at all.
r = admin.put("/api/admin/ai-providers", json={"guests": True, "allowance": {"guests": 300}})
assert r.status_code == 200
assert member.get("/api/ai/models").json()["allowance"] is None
assert member.get("/api/ai/models").json()["allowance"]["limit"] == 0 # unlimited, still reported
assert guest.get("/api/ai/models").json()["allowance"] == {"limit": 300, "used": 0, "exhausted": False}
spend(name, shared, 100)
r = guest.post("/api/ai/chat", json={"prompt": "hi", "stream": True})
Expand Down
Loading
Loading