diff --git a/api/alembic/versions/46378c10f132_add_user_experiment_access_table.py b/api/alembic/versions/46378c10f132_add_user_experiment_access_table.py new file mode 100644 index 0000000000..c3ab46b974 --- /dev/null +++ b/api/alembic/versions/46378c10f132_add_user_experiment_access_table.py @@ -0,0 +1,55 @@ +"""add_user_experiment_access_table + +Revision ID: 46378c10f132 +Revises: 6ccd4a4d9ca1 +Create Date: 2026-05-04 13:23:27.122716 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from transformerlab.db.migration_utils import table_exists + + +# revision identifiers, used by Alembic. +revision: str = "46378c10f132" +down_revision: Union[str, Sequence[str], None] = "6ccd4a4d9ca1" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + connection = op.get_bind() + if not table_exists(connection, "user_experiment_access"): + op.create_table( + "user_experiment_access", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("user_id", sa.String(), nullable=False), + sa.Column("team_id", sa.String(), nullable=False), + sa.Column("experiment_id", sa.String(), nullable=False), + sa.Column( + "last_opened_at", + sa.DateTime(), + server_default=sa.text("(CURRENT_TIMESTAMP)"), + nullable=False, + ), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint( + "user_id", + "team_id", + "experiment_id", + name="uq_user_experiment_access", + ), + ) + op.create_index( + "idx_user_experiment_access_user_team", + "user_experiment_access", + ["user_id", "team_id"], + ) + + +def downgrade() -> None: + op.drop_index("idx_user_experiment_access_user_team", table_name="user_experiment_access", if_exists=True) + op.drop_table("user_experiment_access", if_exists=True) diff --git a/api/test/api/test_experiment_service.py b/api/test/api/test_experiment_service.py index 734e768f0d..e9d346573b 100644 --- a/api/test/api/test_experiment_service.py +++ b/api/test/api/test_experiment_service.py @@ -46,6 +46,16 @@ async def test_missing_experiment_returns_none(tmp_experiments_dir): assert await experiment_service.experiment_get("no_such_experiment") is None +@pytest.mark.asyncio +async def test_duplicate_experiment_create_raises_file_exists(tmp_experiments_dir): + _ = tmp_experiments_dir + name = f"duplicate_exp_{uuid.uuid4().hex[:8]}" + await experiment_service.experiment_create(name, {"a": 1}) + + with pytest.raises(FileExistsError): + await experiment_service.experiment_create(name, {"a": 2}) + + # Added test to hit the new FileNotFoundError except-clauses in experiment_service @pytest.mark.asyncio async def test_missing_experiment_operations_handle_FileNotFound(tmp_experiments_dir): diff --git a/api/test/test_experiment_access_service.py b/api/test/test_experiment_access_service.py new file mode 100644 index 0000000000..da23fecfaa --- /dev/null +++ b/api/test/test_experiment_access_service.py @@ -0,0 +1,58 @@ +from unittest.mock import AsyncMock, MagicMock + +from sqlalchemy.exc import IntegrityError + +import transformerlab.services.experiment_access_service as svc + + +async def test_touch_experiment_upserts_record(): + mock_session = AsyncMock() + mock_session.add = MagicMock() + mock_result = MagicMock(rowcount=0) + mock_session.execute.return_value = mock_result + + await svc.touch_experiment(mock_session, "user1", "team1", "exp1") + + mock_session.add.assert_called_once() + mock_session.commit.assert_called_once() + + +async def test_touch_experiment_updates_existing_record(): + mock_session = AsyncMock() + mock_session.add = MagicMock() + mock_result = MagicMock(rowcount=1) + mock_session.execute.return_value = mock_result + + await svc.touch_experiment(mock_session, "user1", "team1", "exp1") + + mock_session.add.assert_not_called() + mock_session.commit.assert_called_once() + + +async def test_touch_experiment_handles_insert_race_integrity_error(): + mock_session = AsyncMock() + mock_session.add = MagicMock() + mock_result = MagicMock(rowcount=0) + mock_session.execute.return_value = mock_result + mock_session.commit.side_effect = [ + IntegrityError("stmt", "params", Exception("duplicate key")), + ] + + await svc.touch_experiment(mock_session, "user1", "team1", "exp1") + + mock_session.rollback.assert_called_once() + + +async def test_get_recent_experiment_ids_returns_ordered_list(): + mock_session = AsyncMock() + record1 = MagicMock() + record1.experiment_id = "exp_b" + record2 = MagicMock() + record2.experiment_id = "exp_a" + mock_result = MagicMock() + mock_result.scalars.return_value.all.return_value = [record1, record2] + mock_session.execute.return_value = mock_result + + result = await svc.get_recent_experiment_ids(mock_session, "user1", "team1", limit=3) + + assert result == ["exp_b", "exp_a"] diff --git a/api/transformerlab/routers/experiment/experiment.py b/api/transformerlab/routers/experiment/experiment.py index 11a1fca998..3d17c31938 100644 --- a/api/transformerlab/routers/experiment/experiment.py +++ b/api/transformerlab/routers/experiment/experiment.py @@ -2,10 +2,11 @@ from typing import Annotated -from fastapi import APIRouter, Body, Depends +from fastapi import APIRouter, Body, Depends, HTTPException from sqlalchemy.ext.asyncio import AsyncSession import transformerlab.services.experiment_service as experiment_service +import transformerlab.services.experiment_access_service as access_service from lab import Experiment, storage from transformerlab.shared import shared from transformerlab.routers.experiment import ( @@ -16,7 +17,8 @@ ) from transformerlab.routers.auth import get_user_and_team from transformerlab.services.permission_service import check_permission, get_user_team, require_permission -from transformerlab.shared.models.models import TeamRole +from sqlalchemy import select +from transformerlab.shared.models.models import TeamRole, UserExperimentAccess from transformerlab.shared.models.user_model import get_async_session from werkzeug.utils import secure_filename @@ -54,7 +56,7 @@ async def experiments_get_all( session: AsyncSession = Depends(get_async_session), user_and_team: dict = Depends(get_user_and_team), ): - """Get a list of all experiments""" + """Get a list of all experiments, filtered by role, with per-user last_opened_at.""" experiments = await experiment_service.experiment_get_all() user = user_and_team["user"] team_id = user_and_team["team_id"] @@ -63,34 +65,99 @@ async def experiments_get_all( user_team = await get_user_team(session, user_id, team_id) if user_team is None: return [] + + # Role-based filtering (existing logic) if user_team.role == TeamRole.OWNER.value: - return experiments - - filtered_experiments = [] - for experiment in experiments: - experiment_id = str(experiment.get("id")) - if not experiment_id: - continue - allowed = await check_permission( - session=session, - user_id=user_id, - team_id=team_id, - resource_type="experiment", - resource_id=experiment_id, - action="read", - user_team=user_team, + filtered = experiments + else: + filtered = [] + for experiment in experiments: + experiment_id = str(experiment.get("id")) + if not experiment_id: + continue + allowed = await check_permission( + session=session, + user_id=user_id, + team_id=team_id, + resource_type="experiment", + resource_id=experiment_id, + action="read", + user_team=user_team, + ) + if allowed: + filtered.append(experiment) + + # Attach per-user last_opened_at + access_records = await session.execute( + select(UserExperimentAccess).where( + UserExperimentAccess.user_id == user_id, + UserExperimentAccess.team_id == team_id, ) - if allowed: - filtered_experiments.append(experiment) - return filtered_experiments + ) + access_map = {row.experiment_id: row.last_opened_at.isoformat() for row in access_records.scalars().all()} + + for exp in filtered: + exp_id = str(exp.get("id", "")) + exp["last_opened_at"] = access_map.get(exp_id) + + return filtered + + +@router.post("/{id}/touch", summary="Record experiment opened", tags=["experiment"]) +async def experiment_touch( + id: str, + session: AsyncSession = Depends(get_async_session), + user_and_team: dict = Depends(get_user_and_team), + _: None = Depends(require_permission("experiment", "read")), +): + user_id = str(user_and_team["user"].id) + team_id = str(user_and_team["team_id"]) + await access_service.touch_experiment(session, user_id, team_id, id) + return {"status": "ok"} + + +@router.get("/recent", summary="Get recently opened experiments", tags=["experiment"]) +async def experiments_get_recent( + session: AsyncSession = Depends(get_async_session), + user_and_team: dict = Depends(get_user_and_team), +): + """Return last 3 experiments opened by the current user that the user still has access to. + Falls back to 3 permitted experiments if no access records exist.""" + user = user_and_team["user"] + team_id = str(user_and_team["team_id"]) + user_id = str(user.id) + + user_team = await get_user_team(session, user_id, team_id) + if user_team is None: + return [] + + recent_ids = await access_service.get_recent_experiment_ids(session, user_id, team_id, limit=3) + permitted_experiments = await experiments_get_all(session=session, user_and_team=user_and_team) + if not recent_ids: + return permitted_experiments[:3] + + permitted_by_id = { + str(exp.get("id")): exp for exp in permitted_experiments if isinstance(exp, dict) and exp.get("id") + } + ordered_recent = [permitted_by_id[exp_id] for exp_id in recent_ids if exp_id in permitted_by_id] + return ordered_recent[:3] @router.get("/create", summary="Create Experiment", tags=["experiment"]) -async def experiments_create(name: str): +async def experiments_create( + name: str, + user_and_team: dict = Depends(get_user_and_team), +): # Apply secure filename validation to the experiment name secure_name = secure_filename(name) + if not secure_name: + raise HTTPException(status_code=422, detail="Invalid experiment name") + user_id = str(user_and_team["user"].id) - newid = await experiment_service.experiment_create(secure_name, {}) + try: + newid = await experiment_service.experiment_create(secure_name, {}, created_by=user_id) + except FileExistsError as e: + raise HTTPException(status_code=409, detail=f"Experiment '{secure_name}' already exists") from e return newid diff --git a/api/transformerlab/services/experiment_access_service.py b/api/transformerlab/services/experiment_access_service.py new file mode 100644 index 0000000000..2ad371739e --- /dev/null +++ b/api/transformerlab/services/experiment_access_service.py @@ -0,0 +1,54 @@ +import logging +from datetime import datetime, timezone + +from sqlalchemy import select, update +from sqlalchemy.exc import IntegrityError +from sqlalchemy.ext.asyncio import AsyncSession + +from transformerlab.shared.models.models import UserExperimentAccess + +logger = logging.getLogger(__name__) + + +async def touch_experiment(session: AsyncSession, user_id: str, team_id: str, experiment_id: str) -> None: + """Upsert last_opened_at for a user-experiment pair.""" + now = datetime.now(timezone.utc) + result = await session.execute( + update(UserExperimentAccess) + .where( + UserExperimentAccess.user_id == user_id, + UserExperimentAccess.team_id == team_id, + UserExperimentAccess.experiment_id == experiment_id, + ) + .values(last_opened_at=now) + ) + if result.rowcount == 0: + try: + session.add( + UserExperimentAccess( + user_id=user_id, + team_id=team_id, + experiment_id=experiment_id, + last_opened_at=now, + ) + ) + await session.commit() + except IntegrityError: + # Another concurrent request inserted first; treat as success. + await session.rollback() + else: + await session.commit() + + +async def get_recent_experiment_ids(session: AsyncSession, user_id: str, team_id: str, limit: int = 3) -> list[str]: + """Return experiment IDs ordered by last_opened_at DESC for a user.""" + result = await session.execute( + select(UserExperimentAccess) + .where( + UserExperimentAccess.user_id == user_id, + UserExperimentAccess.team_id == team_id, + ) + .order_by(UserExperimentAccess.last_opened_at.desc()) + .limit(limit) + ) + return [row.experiment_id for row in result.scalars().all()] diff --git a/api/transformerlab/services/experiment_service.py b/api/transformerlab/services/experiment_service.py index 4740ac68b6..6b6803febe 100644 --- a/api/transformerlab/services/experiment_service.py +++ b/api/transformerlab/services/experiment_service.py @@ -3,11 +3,14 @@ import json import os +from sqlalchemy import delete from lab import Experiment from lab import dirs as lab_dirs from lab import storage +from transformerlab.db.session import async_session from transformerlab.services.cache_service import cache, cached +from transformerlab.shared.models.models import UserExperimentAccess logger = logging.getLogger(__name__) EXPERIMENT_LIST_CONCURRENCY = max(1, int(os.getenv("TLAB_EXPERIMENT_LIST_CONCURRENCY", "24"))) @@ -100,7 +103,9 @@ async def _read_with_limit(exp_path: str) -> dict | None: return experiments -async def experiment_create(name: str, config: dict) -> str: +async def experiment_create(name: str, config: dict, created_by: str | None = None) -> str: + if created_by: + config = {**config, "created_by": created_by} await Experiment.create_with_config(name, config) # Ensure the experiment dropdown refreshes immediately after creation. await cache.invalidate("experiments") @@ -131,6 +136,9 @@ async def experiment_delete(id): try: exp = await Experiment.get(id) await exp.delete() + async with async_session() as session: + await session.execute(delete(UserExperimentAccess).where(UserExperimentAccess.experiment_id == str(id))) + await session.commit() await cache.invalidate("experiments") except FileNotFoundError: print(f"Experiment with id '{id}' not found") diff --git a/api/transformerlab/shared/models/models.py b/api/transformerlab/shared/models/models.py index 3e6e0cd547..e3c6ff57b0 100644 --- a/api/transformerlab/shared/models/models.py +++ b/api/transformerlab/shared/models/models.py @@ -346,3 +346,22 @@ class JobQueue(Base): Index("idx_job_queue_status_type", "status", "queue_type"), Index("idx_job_queue_job_id", "job_id"), ) + + +class UserExperimentAccess(Base): + """Tracks when each user last opened each experiment.""" + + __tablename__ = "user_experiment_access" + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + user_id: Mapped[str] = mapped_column(String, nullable=False) + team_id: Mapped[str] = mapped_column(String, nullable=False) + experiment_id: Mapped[str] = mapped_column(String, nullable=False) + last_opened_at: Mapped[DateTime] = mapped_column( + DateTime, server_default=func.now(), onupdate=func.now(), nullable=False + ) + + __table_args__ = ( + UniqueConstraint("user_id", "team_id", "experiment_id", name="uq_user_experiment_access"), + Index("idx_user_experiment_access_user_team", "user_id", "team_id"), + ) diff --git a/src/renderer/components/Experiment/ExperimentsManagerModal.tsx b/src/renderer/components/Experiment/ExperimentsManagerModal.tsx new file mode 100644 index 0000000000..10c33670d9 --- /dev/null +++ b/src/renderer/components/Experiment/ExperimentsManagerModal.tsx @@ -0,0 +1,316 @@ +import { + Box, + Button, + Chip, + CircularProgress, + Divider, + IconButton, + Input, + Modal, + ModalClose, + ModalDialog, + Stack, + Table, + Tooltip, + Typography, +} from '@mui/joy'; +import { Share2Icon, Trash2Icon } from 'lucide-react'; +import { useMemo, useState } from 'react'; +import { + useSWRWithAuth as useSWR, + useAuth, + useAPI, +} from 'renderer/lib/authContext'; +import * as chatAPI from 'renderer/lib/transformerlab-api-sdk'; +import { fetcher } from 'renderer/lib/transformerlab-api-sdk'; +import ShareExperimentModal from './ShareExperimentModal'; + +interface Experiment { + id: string; + name: string; + last_opened_at: string | null; + config?: { created_by?: string }; +} + +interface TeamMember { + user_id: string; + email?: string; + role: string; +} + +interface ExperimentsManagerModalProps { + open: boolean; + onClose: () => void; + onExperimentSelect: (experimentId: string) => void; + onNewExperiment: () => void; + mutateRecent: () => void | Promise; +} + +function formatRelativeTime(isoString: string | null): string { + if (!isoString) return 'Never'; + const date = new Date(isoString); + const diffMs = Date.now() - date.getTime(); + const diffDays = Math.floor(diffMs / 86400000); + // Server clock slightly ahead of client yields negative diffDays ("-1 days ago"). + if (diffDays <= 0) return 'Today'; + if (diffDays === 1) return 'Yesterday'; + if (diffDays < 7) return `${diffDays} days ago`; + return date.toLocaleDateString(); +} + +export default function ExperimentsManagerModal({ + open, + onClose, + onExperimentSelect, + onNewExperiment, + mutateRecent, +}: ExperimentsManagerModalProps) { + const { user, team } = useAuth(); + const [search, setSearch] = useState(''); + const [shareTarget, setShareTarget] = useState(null); + + const { + data: experiments, + isLoading, + mutate, + } = useSWR( + open && chatAPI.API_URL() !== null + ? chatAPI.Endpoints.Experiment.GetAll() + : null, + fetcher, + ); + + // Fetch team members for ShareExperimentModal (matches Team.tsx pattern) + const { data: membersData } = useAPI('teams', ['getMembers'], { + teamId: team?.id ?? null, + }); + const members: TeamMember[] = Array.isArray(membersData?.members) + ? membersData.members + : []; + + // Determine if current user is team owner (admin) + const isAdmin = useMemo( + () => members.some((m) => m.user_id === user?.id && m.role === 'owner'), + [members, user?.id], + ); + + const currentUserId = user?.id as string | undefined; + + const filtered = useMemo(() => { + if (!Array.isArray(experiments)) return []; + const q = search.toLowerCase(); + return experiments.filter((e: Experiment) => + e.name?.toLowerCase().includes(q), + ); + }, [experiments, search]); + + const canManage = (exp: Experiment) => + isAdmin || exp.config?.created_by === currentUserId; + + const handleDelete = async (exp: Experiment) => { + if ( + !confirm( + `Are you sure you want to delete "${exp.name}"? This cannot be undone.`, + ) + ) + return; + await chatAPI.authenticatedFetch( + chatAPI.Endpoints.Experiment.Delete(exp.id), + {}, + ); + mutate(); + mutateRecent(); + }; + + const handleOpen = (exp: Experiment) => { + onExperimentSelect(exp.id); + mutate(); + onClose(); + }; + + return ( + <> + + + + + + + Experiments + + + + + + + setSearch(e.target.value)} + sx={{ mb: 2 }} + /> + + + {isLoading ? ( + + + + ) : filtered.length === 0 ? ( + + {search + ? 'No experiments match your search.' + : 'No experiments yet.'} + + ) : ( + + + + + + + + + + + + + {filtered.map((exp: Experiment) => { + const ownerLabel = + exp.config?.created_by === currentUserId + ? 'you' + : (members.find( + (m) => m.user_id === exp.config?.created_by, + )?.email ?? 'unknown'); + + return ( + + + + + + + + ); + })} + +
NameOwnerLast OpenedSharingActions
+ handleOpen(exp)} + > + {exp.name} + + + + {ownerLabel} + + + + {formatRelativeTime(exp.last_opened_at)} + + + {isAdmin ? ( + + {canManage(exp) ? 'owned' : 'team'} + + ) : exp.config?.created_by === currentUserId ? ( + + yours + + ) : ( + + shared + + )} + + + + {canManage(exp) && ( + <> + + setShareTarget(exp)} + > + + + + + handleDelete(exp)} + > + + + + + )} + +
+
+ )} +
+
+ + {shareTarget && ( + { + mutate(); + mutateRecent(); + }} + onClose={() => setShareTarget(null)} + /> + )} + + ); +} diff --git a/src/renderer/components/Experiment/SelectExperimentMenu.tsx b/src/renderer/components/Experiment/SelectExperimentMenu.tsx index 2dcfd44571..a02674f761 100644 --- a/src/renderer/components/Experiment/SelectExperimentMenu.tsx +++ b/src/renderer/components/Experiment/SelectExperimentMenu.tsx @@ -4,6 +4,7 @@ import MenuItem from '@mui/joy/MenuItem'; import { CheckIcon, ChevronDownIcon, + LayoutGridIcon, PlusCircleIcon, SettingsIcon, StopCircleIcon, @@ -35,6 +36,12 @@ import { useNavigate, useLocation } from 'react-router-dom'; import * as chatAPI from 'renderer/lib/transformerlab-api-sdk'; import { getAPIFullPath, fetcher } from 'renderer/lib/transformerlab-api-sdk'; import { useExperimentInfo } from 'renderer/lib/ExperimentInfoContext'; +import ExperimentsManagerModal from './ExperimentsManagerModal'; + +interface ExperimentMenuItem { + id: string; + name: string; +} function ExperimentSettingsMenu({ experimentInfo, @@ -98,6 +105,7 @@ function ExperimentSettingsMenu({ export default function SelectExperimentMenu({ models }) { const [modalOpen, setModalOpen] = useState(false); + const [isManagerOpen, setIsManagerOpen] = useState(false); const navigate = useNavigate(); const location = useLocation(); const { experimentInfo, setExperimentId } = useExperimentInfo(); @@ -105,7 +113,7 @@ export default function SelectExperimentMenu({ models }) { // This gets all the available experiments const { data, isLoading, mutate } = useSWR( - chatAPI.API_URL() === null ? null : chatAPI.Endpoints.Experiment.GetAll(), + chatAPI.API_URL() === null ? null : chatAPI.Endpoints.Experiment.Recent(), fetcher, ); @@ -122,13 +130,29 @@ export default function SelectExperimentMenu({ models }) { const hasProviders = providers.length > 0; const DEV_MODE = experimentInfo?.name === 'dev'; + const experimentItems: ExperimentMenuItem[] = Array.isArray(data) + ? data.filter( + (experiment): experiment is ExperimentMenuItem => + typeof experiment?.id === 'string' && + typeof experiment?.name === 'string', + ) + : []; useEffect(() => { mutate(); }, [experimentInfo]); - const createHandleClose = (experimentId: string | number) => () => { + const createHandleClose = (experimentId: string | number) => async () => { setExperimentId(String(experimentId)); + try { + await chatAPI.authenticatedFetch( + chatAPI.Endpoints.Experiment.Touch(String(experimentId)), + { method: 'POST' }, + ); + mutate(); + } catch { + // non-critical, don't block the switch + } // If currently on an experiment page, update the URL to reflect the new experiment const match = location.pathname.match(/^\/experiment\/[^/]+\/(.+)$/); if (match) { @@ -144,7 +168,7 @@ export default function SelectExperimentMenu({ models }) { // Allow creation if data is an empty array (no experiments yet) if (isLoading || data === null || data === undefined) { alert('Please wait for experiments to load before creating a new one.'); - return; + return false; } let newId = 0; @@ -153,6 +177,11 @@ export default function SelectExperimentMenu({ models }) { const response = await chatAPI.authenticatedFetch( chatAPI.Endpoints.Experiment.Create(name), ); + if (!response.ok) { + const errorText = await response.text(); + alert(errorText || 'Failed to create experiment.'); + return false; + } newId = await response.json(); } else { const response = await chatAPI.authenticatedFetch( @@ -170,31 +199,32 @@ export default function SelectExperimentMenu({ models }) { alert( `Error creating experiment from recipe: ${responseJson?.message || 'Unknown error'}`, ); - return; + return false; } newId = responseJson?.data?.experiment_id; } - // After creation, refresh the list and ensure the new experiment is in it before updating state - await mutate(); - const updatedData = await mutate(); // Wait for mutate to complete and get fresh data - const newExperimentExists = updatedData?.some( - (exp: any) => exp.id === newId, + // Recent dropdown only shows a few experiments; membership there is not a valid + // "exists" check. Confirm the experiment is readable, then refresh the menu. + const existsRes = await chatAPI.authenticatedFetch( + chatAPI.Endpoints.Experiment.Get(String(newId)), ); - if (!newExperimentExists) { + if (!existsRes.ok) { alert( - 'Experiment created, but failed to load in the list. Please refresh and try again.', + 'Experiment created, but it could not be loaded. Please refresh and try again.', ); - return; + return false; } + await mutate(); setExperimentId(String(newId)); - createHandleClose(newId)(); + void createHandleClose(String(newId))(); // Navigate to Notes page if experiment was created from a recipe AND recipe is not blank if (fromRecipeId !== null && fromRecipeId !== -1) { navigate(`/experiment/${encodeURIComponent(name)}/notes`); } + return true; }, [setExperimentId, mutate, navigate, isLoading, data], ); @@ -355,49 +385,48 @@ export default function SelectExperimentMenu({ models }) { }} > {isLoading && Loading...} - {data && - data - .filter( - (experiment: any) => experiment?.id && experiment?.name, - ) // skip bad rows - .map((experiment: any) => { - return ( - - - {experiment.name} - - {experimentInfo?.id === experiment.id && ( - - )} - - ); - })} + {experimentItems.map((experiment) => { + return ( + + + {experiment.name} + + {experimentInfo?.id === experiment.id && ( + + )} + + ); + })} + setIsManagerOpen(true)}> + + + + See all experiments + setModalOpen(true)} disabled={isLoading}> @@ -436,12 +465,14 @@ export default function SelectExperimentMenu({ models }) { return; } // Check if experiment name already exists (fallback, as API also checks) - if (data?.some((exp: any) => exp.name === name)) { + if (experimentItems.some((exp) => exp.name === name)) { alert('Experiment name already exists.'); return; } - await createNewExperiment(name); - setModalOpen(false); + const created = await createNewExperiment(name); + if (created) { + setModalOpen(false); + } }} > + setIsManagerOpen(false)} + onExperimentSelect={(experimentId: string) => { + createHandleClose(experimentId)(); + setIsManagerOpen(false); + }} + onNewExperiment={() => { + setIsManagerOpen(false); + setModalOpen(true); + }} + mutateRecent={mutate} + /> ); } diff --git a/src/renderer/components/Experiment/ShareExperimentModal.tsx b/src/renderer/components/Experiment/ShareExperimentModal.tsx new file mode 100644 index 0000000000..5791569eb7 --- /dev/null +++ b/src/renderer/components/Experiment/ShareExperimentModal.tsx @@ -0,0 +1,245 @@ +import { + Button, + CircularProgress, + Divider, + IconButton, + Modal, + ModalClose, + ModalDialog, + Option, + Select, + Stack, + Table, + Typography, +} from '@mui/joy'; +import { Trash2Icon } from 'lucide-react'; +import { useEffect, useState } from 'react'; +import { useAuth } from 'renderer/lib/authContext'; + +type PermissionLevel = 'read' | 'read_write' | 'admin'; + +const LEVEL_ACTIONS: Record = { + read: ['read'], + read_write: ['read', 'write', 'execute'], + admin: ['read', 'write', 'execute', 'delete', 'admin'], +}; + +const LEVEL_LABELS: Record = { + read: 'Read', + read_write: 'Read + Write', + admin: 'Admin', +}; + +interface ShareRule { + id: string; + user_id: string; + resource_type: string; + resource_id: string; + actions: string[]; +} + +interface TeamMember { + user_id: string; + email?: string; + role: string; +} + +interface ShareExperimentModalProps { + open: boolean; + experimentId: string; + experimentName: string; + members: TeamMember[]; + onClose: () => void; + onShared?: () => void; +} + +function getErrorMessage(error: unknown): string { + if (error instanceof Error && error.message) { + return error.message; + } + return 'Request failed'; +} + +export default function ShareExperimentModal({ + open, + experimentId, + experimentName, + members, + onClose, + onShared, +}: ShareExperimentModalProps) { + const { team, fetchWithAuth } = useAuth(); + const [rules, setRules] = useState([]); + const [loading, setLoading] = useState(false); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + const [selectedUserId, setSelectedUserId] = useState(''); + const [selectedLevel, setSelectedLevel] = useState('read'); + + const nonOwnerMembers = members.filter((m) => m.role !== 'owner'); + + useEffect(() => { + if (!open || !team?.id) return; + setLoading(true); + // GET /teams/{teamId}/permissions returns all team rules — filter by this experiment + fetchWithAuth(`teams/${team.id}/permissions`) + .then((res) => res.json()) + .then((data: { permissions?: ShareRule[] } | ShareRule[]) => { + const raw = Array.isArray(data) ? data : data?.permissions; + const list = Array.isArray(raw) ? raw : []; + const forThisExp = list.filter( + (r) => + r.resource_type === 'experiment' && + String(r.resource_id) === String(experimentId), + ); + setRules(forThisExp); + }) + .catch(() => setRules([])) + .finally(() => setLoading(false)); + }, [open, team?.id, experimentId, fetchWithAuth]); + + const onAdd = async () => { + if (!selectedUserId || !team?.id) return; + setSaving(true); + setError(null); + try { + const res = await fetchWithAuth(`teams/${team.id}/permissions`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + user_id: selectedUserId, + resource_type: 'experiment', + resource_id: experimentId, + actions: LEVEL_ACTIONS[selectedLevel], + }), + }); + if (!res.ok) throw new Error(await res.text()); + const saved = await res.json(); + setRules((prev) => { + const without = prev.filter((r) => r.user_id !== saved.user_id); + return [...without, saved]; + }); + setSelectedUserId(''); + onShared?.(); + } catch (e: unknown) { + setError(getErrorMessage(e) || 'Failed to add share'); + } finally { + setSaving(false); + } + }; + + const onRemove = async (ruleId: string) => { + if (!team?.id) return; + setSaving(true); + setError(null); + try { + const res = await fetchWithAuth( + `teams/${team.id}/permissions/${ruleId}`, + { + method: 'DELETE', + }, + ); + if (!res.ok) throw new Error(await res.text()); + setRules((prev) => prev.filter((r) => r.id !== ruleId)); + onShared?.(); + } catch (e: unknown) { + setError(getErrorMessage(e) || 'Failed to remove share'); + } finally { + setSaving(false); + } + }; + + const getMemberEmail = (userId: string) => + members.find((m) => m.user_id === userId)?.email ?? userId; + + return ( + + + + + Share "{experimentName}" + + + + {error && ( + + {error} + + )} + + + + + + + + {loading ? ( + + ) : rules.length === 0 ? ( + + Not shared with anyone yet. + + ) : ( + + + + + + + + + {rules.map((rule) => ( + + + + + + ))} + +
UserAccess +
{getMemberEmail(rule.user_id)} + {rule.actions.includes('admin') + ? 'Admin' + : rule.actions.includes('write') + ? 'Read + Write' + : 'Read'} + + onRemove(rule.id)} + > + + +
+ )} +
+
+ ); +} diff --git a/src/renderer/components/Welcome/Welcome.tsx b/src/renderer/components/Welcome/Welcome.tsx index 644eb98165..ba9bc6c54a 100644 --- a/src/renderer/components/Welcome/Welcome.tsx +++ b/src/renderer/components/Welcome/Welcome.tsx @@ -52,6 +52,11 @@ export default function Welcome() { const response = await chatAPI.authenticatedFetch( chatAPI.Endpoints.Experiment.Create(name), ); + if (!response.ok) { + const errorText = await response.text(); + alert(errorText || 'Failed to create experiment.'); + return; + } newId = await response.json(); } else { const response = await chatAPI.authenticatedFetch( diff --git a/src/renderer/lib/api-client/endpoints.ts b/src/renderer/lib/api-client/endpoints.ts index 526ac35e5d..257a6509e4 100644 --- a/src/renderer/lib/api-client/endpoints.ts +++ b/src/renderer/lib/api-client/endpoints.ts @@ -247,6 +247,8 @@ Endpoints.Experiment = { Create: (name: string) => `${API_URL()}experiment/create?name=${name}`, Get: (id: string) => `${API_URL()}experiment/${id}`, Delete: (id: string) => `${API_URL()}experiment/${id}/delete`, + Touch: (id: string) => `${API_URL()}experiment/${id}/touch`, + Recent: () => `${API_URL()}experiment/recent`, SavePrompt: (id: string) => `${API_URL()}experiment/${id}/prompt`, GetFile: (id: string, filename: string) => `${API_URL()}experiment/${id}/file_contents?filename=${filename}`,