[PAI-979] Chore pi default oauth check (#4744)
This commit is contained in:
@@ -6,6 +6,7 @@ from typing import AsyncGenerator
|
||||
from typing import Coroutine
|
||||
from typing import Optional
|
||||
from typing import cast
|
||||
from urllib.parse import urlparse
|
||||
from uuid import UUID
|
||||
from uuid import uuid4
|
||||
|
||||
@@ -20,6 +21,7 @@ from sqlalchemy import select
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
|
||||
from pi import logger
|
||||
from pi import settings
|
||||
from pi.app.api.v1.dependencies import cookie_schema
|
||||
from pi.app.api.v1.dependencies import is_valid_session
|
||||
from pi.app.api.v1.dependencies import validate_plane_token
|
||||
@@ -39,6 +41,7 @@ from pi.app.schemas.chat import ActionBatchExecutionRequest
|
||||
from pi.app.schemas.chat import ArtifactData
|
||||
from pi.app.schemas.chat import ChatFeedback
|
||||
from pi.app.schemas.chat import ChatInitializationRequest
|
||||
from pi.app.schemas.chat import ChatInitResponse
|
||||
from pi.app.schemas.chat import ChatRequest
|
||||
from pi.app.schemas.chat import ChatSearchResponse
|
||||
from pi.app.schemas.chat import ChatSuggestionTemplate
|
||||
@@ -57,6 +60,7 @@ from pi.app.utils.background_tasks import schedule_chat_search_upsert
|
||||
from pi.app.utils.exceptions import SQLGenerationError
|
||||
from pi.core.db.plane_pi.lifecycle import get_async_session
|
||||
from pi.core.db.plane_pi.lifecycle import get_streaming_db_session
|
||||
from pi.services.actions.oauth_service import PlaneOAuthService
|
||||
from pi.services.chat.chat import PlaneChatBot
|
||||
from pi.services.chat.helpers.batch_execution_helpers import get_original_user_query
|
||||
from pi.services.chat.helpers.batch_execution_helpers import get_planned_actions_for_execution
|
||||
@@ -97,6 +101,71 @@ BATCH_EXECUTION_ERRORS = {
|
||||
}
|
||||
|
||||
|
||||
@router.get("/start/", response_model=ChatInitResponse)
|
||||
async def chat_start(
|
||||
workspace_id: UUID = Query(..., description="Workspace ID to check authorization for"),
|
||||
db: AsyncSession = Depends(get_async_session),
|
||||
session: str = Depends(cookie_schema),
|
||||
):
|
||||
"""
|
||||
Start/Bootstrap Pi chat - first API call when starting a chat session.
|
||||
Checks OAuth authorization status and returns chat templates.
|
||||
|
||||
Usage: GET /api/v1/chat/start/?workspace_id=<uuid>
|
||||
|
||||
If authorized: returns is_authorized=true with full templates list
|
||||
If not authorized: returns is_authorized=false with empty templates list
|
||||
"""
|
||||
try:
|
||||
# Validate session
|
||||
auth = await is_valid_session(session)
|
||||
if not auth.user:
|
||||
return JSONResponse(status_code=401, content={"detail": "Invalid User"})
|
||||
user_id = auth.user.id
|
||||
except Exception as e:
|
||||
log.error(f"Error validating session: {e!s}")
|
||||
return JSONResponse(status_code=401, content={"detail": "Invalid Session"})
|
||||
|
||||
try:
|
||||
oauth_service = PlaneOAuthService()
|
||||
|
||||
# Check if user has valid OAuth token for workspace
|
||||
access_token = await oauth_service.get_valid_token(db=db, user_id=user_id, workspace_id=workspace_id)
|
||||
|
||||
if access_token:
|
||||
# User is authorized - get templates
|
||||
templates = tiles_factory()
|
||||
return ChatInitResponse(is_authorized=True, templates=templates, oauth_url=None)
|
||||
else:
|
||||
# User is not authorized - return empty templates with OAuth URL
|
||||
from pi.services.actions.oauth_url_encoder import OAuthUrlEncoder
|
||||
|
||||
redirect = urlparse(settings.plane_api.OAUTH_REDIRECT_URI)
|
||||
base_url = f"{redirect.scheme}://{redirect.netloc}"
|
||||
|
||||
# Include BASE_PATH if configured
|
||||
base_path = settings.plane_api.BASE_PATH or ""
|
||||
if base_path and not base_path.startswith("/"):
|
||||
base_path = f"/{base_path}"
|
||||
base_path = base_path.rstrip("/")
|
||||
|
||||
# Build OAuth parameters (only user_id and workspace_id)
|
||||
oauth_params = {
|
||||
"user_id": str(user_id),
|
||||
"workspace_id": str(workspace_id),
|
||||
}
|
||||
|
||||
# Generate clean, encrypted OAuth URL
|
||||
oauth_encoder = OAuthUrlEncoder()
|
||||
oauth_url = oauth_encoder.generate_clean_oauth_url(f"{base_url}{base_path}", oauth_params)
|
||||
|
||||
return ChatInitResponse(is_authorized=False, templates=[], oauth_url=oauth_url)
|
||||
|
||||
except Exception as e:
|
||||
log.error(f"Error in chat start: {e!s}")
|
||||
return JSONResponse(status_code=500, content={"detail": "Failed to initialize chat"})
|
||||
|
||||
|
||||
@router.post("/slack/answer/")
|
||||
async def get_answer_for_slack(data: ChatRequest, request: Request, db: AsyncSession = Depends(get_async_session)):
|
||||
try:
|
||||
|
||||
@@ -4,6 +4,7 @@ Handles authorization flow, callback processing, and token management
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter
|
||||
@@ -35,10 +36,11 @@ async def browser_initiate_oauth(
|
||||
db: AsyncSession = Depends(get_async_session),
|
||||
session: str = Depends(cookie_schema),
|
||||
):
|
||||
"""Browser-based OAuth initiation with session cookie authentication."""
|
||||
auth = await is_valid_session(session)
|
||||
if not auth.user:
|
||||
return JSONResponse(status_code=401, content={"detail": "Invalid User"})
|
||||
user_id = auth.user.id
|
||||
|
||||
oauth_service = PlaneOAuthService()
|
||||
|
||||
# Clean up expired states periodically
|
||||
@@ -48,19 +50,16 @@ async def browser_initiate_oauth(
|
||||
log.warning(f"Failed to cleanup expired states: {e}")
|
||||
|
||||
# Get workspace slug if workspace_id is provided
|
||||
workspace_slug = None
|
||||
if workspace_id:
|
||||
workspace_slug = await get_workspace_slug(str(workspace_id))
|
||||
workspace_slug = await get_workspace_slug(str(workspace_id)) if workspace_id else None
|
||||
|
||||
# same logic as POST /initiate/
|
||||
# Generate authorization URL and save state
|
||||
auth_url, state = oauth_service.generate_authorization_url(
|
||||
user_id=user_id,
|
||||
user_id=auth.user.id,
|
||||
workspace_id=workspace_id,
|
||||
workspace_slug=workspace_slug,
|
||||
)
|
||||
await oauth_service.save_oauth_state(db, state, user_id, workspace_id, workspace_slug)
|
||||
await oauth_service.save_oauth_state(db, state, auth.user.id, workspace_id, workspace_slug)
|
||||
|
||||
# just redirect the browser
|
||||
return RedirectResponse(url=auth_url, status_code=302)
|
||||
|
||||
|
||||
@@ -69,10 +68,11 @@ async def public_initiate_oauth(
|
||||
user_id: UUID = Query(..., description="User ID requesting authorization"),
|
||||
workspace_id: UUID = Query(..., description="Workspace ID to authorize"),
|
||||
force_reset: bool = Query(False, description="Force reset of existing OAuth states"),
|
||||
sidebar_open_url: Optional[str] = Query(None, description="The URL where the sidebar was opened from"),
|
||||
db: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
"""
|
||||
Public OAuth initiation endpoint that doesn't require session authentication.
|
||||
Public OAuth initiation endpoint without session authentication.
|
||||
Used when session cookies can't be shared across domains.
|
||||
"""
|
||||
oauth_service = PlaneOAuthService()
|
||||
@@ -83,7 +83,7 @@ async def public_initiate_oauth(
|
||||
except Exception as e:
|
||||
log.warning(f"Failed to cleanup expired states: {e}")
|
||||
|
||||
# If force_reset is requested, clear existing states for this user/workspace
|
||||
# Force reset if requested
|
||||
if force_reset:
|
||||
try:
|
||||
reset_count = await oauth_service.reset_user_oauth_states(db, user_id, workspace_id)
|
||||
@@ -91,10 +91,8 @@ async def public_initiate_oauth(
|
||||
except Exception as e:
|
||||
log.warning(f"Failed to reset OAuth states: {e}")
|
||||
|
||||
# Get workspace slug for the workspace
|
||||
workspace_slug = None
|
||||
if workspace_id:
|
||||
workspace_slug = await get_workspace_slug(str(workspace_id))
|
||||
# Get workspace slug
|
||||
workspace_slug = await get_workspace_slug(str(workspace_id)) if workspace_id else None
|
||||
|
||||
# Generate authorization URL with state
|
||||
auth_url, state = oauth_service.generate_authorization_url(
|
||||
@@ -103,19 +101,25 @@ async def public_initiate_oauth(
|
||||
workspace_slug=workspace_slug,
|
||||
)
|
||||
|
||||
# Save state for verification
|
||||
await oauth_service.save_oauth_state(db, state, user_id, workspace_id, workspace_slug)
|
||||
# Save OAuth state with sidebar context
|
||||
await oauth_service.save_oauth_state(
|
||||
db,
|
||||
state,
|
||||
user_id,
|
||||
workspace_id,
|
||||
workspace_slug,
|
||||
pi_sidebar_open=sidebar_open_url is not None,
|
||||
sidebar_open_url=sidebar_open_url,
|
||||
)
|
||||
|
||||
log.debug(f"Generated new OAuth state for user {user_id}")
|
||||
|
||||
# Redirect to Plane for authorization
|
||||
log.debug(f"Generated OAuth state for user {user_id}")
|
||||
return RedirectResponse(url=auth_url, status_code=302)
|
||||
|
||||
|
||||
@router.get("/callback/")
|
||||
async def oauth_callback(
|
||||
code: str = Query(..., description="Authorization code from Plane"),
|
||||
state: str | None = Query(None, description="State parameter for verification - optional for marketplace install"),
|
||||
state: str | None = Query(None, description="State parameter for verification"),
|
||||
app_installation_id: str = Query(None, description="App installation ID from Plane"),
|
||||
db: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
@@ -125,41 +129,33 @@ async def oauth_callback(
|
||||
"""
|
||||
try:
|
||||
oauth_service = PlaneOAuthService()
|
||||
log.debug(f"OAuth callback - code: {code[:10]}..., state: {state}, app_installation_id: {app_installation_id}")
|
||||
|
||||
log.debug(f"OAuth callback received - code: {code[:10]}..., state: {state}, app_installation_id: {app_installation_id}")
|
||||
|
||||
# Verify state parameter
|
||||
oauth_state = None
|
||||
if state:
|
||||
# Flow initiated via /oauth/initiate/ or /oauth/init/
|
||||
oauth_state = await oauth_service.verify_state(db, state)
|
||||
if not oauth_state:
|
||||
log.error(f"Invalid or expired OAuth state: {state}")
|
||||
# Clean up expired states to help with future requests
|
||||
cleanup_count = await oauth_service.cleanup_expired_states(db)
|
||||
log.info(f"Cleaned up {cleanup_count} expired states")
|
||||
return JSONResponse(status_code=400, content={"detail": "Invalid or expired authorization state. Please try initiating OAuth again."})
|
||||
else:
|
||||
log.debug(f"OAuth state verified successfully: {state}")
|
||||
log.debug(f"OAuth state verified: {state}")
|
||||
else:
|
||||
log.info("Processing OAuth callback without state parameter")
|
||||
|
||||
# Exchange code for tokens
|
||||
token_data = await oauth_service.exchange_code_for_tokens(code=code)
|
||||
|
||||
# Get app installation details to fetch workspace info
|
||||
# Extract workspace and user info from state
|
||||
workspace_id = oauth_state.workspace_id if oauth_state else None
|
||||
workspace_slug = oauth_state.workspace_slug if oauth_state and oauth_state.workspace_slug else "unknown"
|
||||
user_id_for_token = oauth_state.user_id if oauth_state else None
|
||||
|
||||
# Ensure we have a valid workspace_id
|
||||
# Validate required fields
|
||||
if not workspace_id:
|
||||
log.error("No workspace_id available from state or installation details")
|
||||
log.error("No workspace_id available from state")
|
||||
raise Exception("Unable to determine workspace for token storage")
|
||||
|
||||
# Determine user ID to associate with token
|
||||
user_id_for_token = None
|
||||
if oauth_state:
|
||||
user_id_for_token = oauth_state.user_id
|
||||
|
||||
if not user_id_for_token:
|
||||
log.error("Unable to determine user_id for token storage")
|
||||
raise Exception("user_id required for token storage")
|
||||
@@ -175,85 +171,77 @@ async def oauth_callback(
|
||||
app_bot_user_id=None,
|
||||
)
|
||||
|
||||
# Mark state as used only after successful completion
|
||||
# Mark state as used
|
||||
if oauth_state:
|
||||
await oauth_service.mark_state_as_used(db, oauth_state)
|
||||
|
||||
log.info(
|
||||
"OAuth completed successfully for user %s, workspace %s",
|
||||
str(user_id_for_token),
|
||||
workspace_slug,
|
||||
)
|
||||
log.info(f"OAuth completed for user {user_id_for_token}, workspace {workspace_slug}")
|
||||
|
||||
# Determine redirect URL based on stored context
|
||||
if oauth_state and oauth_state.chat_id:
|
||||
# Include message_token in redirect URL for frontend to use with stream-answer
|
||||
message_token_param = ""
|
||||
if oauth_state.message_token:
|
||||
message_token_param = f"&message_token={oauth_state.message_token}"
|
||||
|
||||
# Update the MessageFlowStep to mark OAuth as complete
|
||||
try:
|
||||
from sqlalchemy import text
|
||||
|
||||
# Update the oauth_completed column to mark OAuth as complete
|
||||
update_stmt = text("""
|
||||
UPDATE message_flow_steps
|
||||
SET oauth_completed = true, oauth_completed_at = :timestamp
|
||||
WHERE message_id = :message_id AND tool_name = 'QUEUE'
|
||||
""")
|
||||
await db.execute(update_stmt, {"message_id": str(oauth_state.message_token), "timestamp": datetime.utcnow()})
|
||||
await db.commit()
|
||||
log.info(f"Updated MessageFlowStep {oauth_state.message_token} to mark OAuth as complete")
|
||||
except Exception as e:
|
||||
log.warning(f"Failed to update MessageFlowStep OAuth status: {e}")
|
||||
# Don't fail the OAuth flow if this update fails
|
||||
|
||||
# Sidebar open redirect
|
||||
if getattr(oauth_state, "pi_sidebar_open", False) and oauth_state.sidebar_open_url:
|
||||
# Build sidebar redirect URL
|
||||
sidebar_url = oauth_state.sidebar_open_url
|
||||
chat_id = oauth_state.chat_id
|
||||
redirect_url = (
|
||||
f"{settings.plane_api.FRONTEND_URL}/{sidebar_url}/?chat_id={chat_id}{message_token_param}&pi_sidebar_open=true&oauth_success=true" # noqa: E501
|
||||
)
|
||||
# Project chat redirect
|
||||
elif getattr(oauth_state, "is_project_chat", False):
|
||||
redirect_url = f"{settings.plane_api.FRONTEND_URL}/{workspace_slug}/projects/pi-chat/{oauth_state.chat_id}/?oauth_success=true{message_token_param}" # noqa: E501
|
||||
# Default chat redirect
|
||||
else:
|
||||
redirect_url = (
|
||||
f"{settings.plane_api.FRONTEND_URL}/{workspace_slug}/pi-chat/{oauth_state.chat_id}/?oauth_success=true{message_token_param}"
|
||||
)
|
||||
else:
|
||||
# Fallback to default success page
|
||||
redirect_url = f"{settings.plane_api.FRONTEND_URL}?oauth_success=true&workspace={workspace_slug}"
|
||||
redirect_url = await _build_redirect_url(oauth_state, workspace_slug, db)
|
||||
|
||||
return RedirectResponse(url=redirect_url, status_code=302)
|
||||
|
||||
except Exception as e:
|
||||
log.error(f"Error processing OAuth callback: {e!s}")
|
||||
# Redirect to frontend with error
|
||||
frontend_url = settings.plane_api.FRONTEND_URL
|
||||
redirect_url = f"{frontend_url}?oauth_error=true&message=authorization_failed"
|
||||
redirect_url = f"{settings.plane_api.FRONTEND_URL}?oauth_error=true&message=authorization_failed"
|
||||
return RedirectResponse(url=redirect_url, status_code=302)
|
||||
|
||||
|
||||
async def _build_redirect_url(oauth_state, workspace_slug: str, db: AsyncSession) -> str:
|
||||
"""Build the appropriate redirect URL based on OAuth state context."""
|
||||
if not oauth_state:
|
||||
return f"{settings.plane_api.FRONTEND_URL}/{workspace_slug}/pi-chat/?oauth_success=true"
|
||||
|
||||
# Handle message token parameter
|
||||
message_token_param = ""
|
||||
if oauth_state.message_token:
|
||||
message_token_param = f"&message_token={oauth_state.message_token}"
|
||||
|
||||
# Update MessageFlowStep to mark OAuth as complete
|
||||
try:
|
||||
from sqlalchemy import text
|
||||
|
||||
update_stmt = text("""
|
||||
UPDATE message_flow_steps
|
||||
SET oauth_completed = true, oauth_completed_at = :timestamp
|
||||
WHERE message_id = :message_id AND tool_name = 'QUEUE'
|
||||
""")
|
||||
await db.execute(update_stmt, {"message_id": str(oauth_state.message_token), "timestamp": datetime.utcnow()})
|
||||
await db.commit()
|
||||
log.info(f"Updated MessageFlowStep {oauth_state.message_token} to mark OAuth as complete")
|
||||
except Exception as e:
|
||||
log.warning(f"Failed to update MessageFlowStep OAuth status: {e}")
|
||||
|
||||
# Sidebar open redirect (highest priority)
|
||||
if getattr(oauth_state, "pi_sidebar_open", False) and oauth_state.sidebar_open_url:
|
||||
return oauth_state.sidebar_open_url
|
||||
|
||||
# Chat-specific redirects
|
||||
if oauth_state.chat_id:
|
||||
# Project chat redirect
|
||||
if getattr(oauth_state, "is_project_chat", False):
|
||||
return (
|
||||
f"{settings.plane_api.FRONTEND_URL}/{workspace_slug}/projects/pi-chat/{oauth_state.chat_id}/?oauth_success=true{message_token_param}"
|
||||
)
|
||||
# Default chat redirect
|
||||
return f"{settings.plane_api.FRONTEND_URL}/{workspace_slug}/pi-chat/{oauth_state.chat_id}/?oauth_success=true{message_token_param}"
|
||||
|
||||
# Fallback to default success page
|
||||
return f"{settings.plane_api.FRONTEND_URL}/{workspace_slug}/pi-chat/?oauth_success=true"
|
||||
|
||||
|
||||
@router.post("/status/", response_model=OAuthStatusResponse)
|
||||
async def check_oauth_status(
|
||||
data: OAuthStatusRequest,
|
||||
db: AsyncSession = Depends(get_async_session),
|
||||
session: str = Depends(cookie_schema),
|
||||
):
|
||||
"""
|
||||
Check OAuth authorization status for a specific workspace.
|
||||
Returns whether user has valid authorization and token details.
|
||||
"""
|
||||
"""Check OAuth authorization status for a specific workspace."""
|
||||
try:
|
||||
auth = await is_valid_session(session)
|
||||
if not auth.user:
|
||||
return JSONResponse(status_code=401, content={"detail": "Invalid User"})
|
||||
user_id = auth.user.id
|
||||
except Exception as e:
|
||||
log.error(f"Error validating session: {e!s}")
|
||||
return JSONResponse(status_code=401, content={"detail": "Invalid Session"})
|
||||
@@ -262,7 +250,7 @@ async def check_oauth_status(
|
||||
oauth_service = PlaneOAuthService()
|
||||
|
||||
# Check if user has valid token for workspace
|
||||
access_token = await oauth_service.get_valid_token(db=db, user_id=user_id, workspace_id=data.workspace_id)
|
||||
access_token = await oauth_service.get_valid_token(db=db, user_id=auth.user.id, workspace_id=data.workspace_id)
|
||||
|
||||
if access_token:
|
||||
# Get token details for expiry info
|
||||
@@ -272,7 +260,9 @@ async def check_oauth_status(
|
||||
|
||||
result = await db.execute(
|
||||
select(PlaneOAuthToken).where(
|
||||
PlaneOAuthToken.user_id == user_id, PlaneOAuthToken.workspace_id == data.workspace_id, PlaneOAuthToken.is_active is True
|
||||
PlaneOAuthToken.user_id == auth.user.id,
|
||||
PlaneOAuthToken.workspace_id == data.workspace_id,
|
||||
PlaneOAuthToken.is_active is True,
|
||||
)
|
||||
)
|
||||
oauth_token = result.scalar_one_or_none()
|
||||
@@ -282,8 +272,8 @@ async def check_oauth_status(
|
||||
workspace_slug=oauth_token.workspace_slug if oauth_token else None,
|
||||
expires_at=oauth_token.expires_at.isoformat() if oauth_token else None,
|
||||
)
|
||||
else:
|
||||
return OAuthStatusResponse(is_authorized=False, workspace_slug=None, expires_at=None)
|
||||
|
||||
return OAuthStatusResponse(is_authorized=False, workspace_slug=None, expires_at=None)
|
||||
|
||||
except Exception as e:
|
||||
log.error(f"Error checking OAuth status: {e!s}")
|
||||
@@ -296,30 +286,24 @@ async def revoke_oauth_authorization(
|
||||
db: AsyncSession = Depends(get_async_session),
|
||||
session: str = Depends(cookie_schema),
|
||||
):
|
||||
"""
|
||||
Revoke OAuth authorization for a specific workspace.
|
||||
Deactivates stored tokens and prevents further API access.
|
||||
"""
|
||||
"""Revoke OAuth authorization for a specific workspace."""
|
||||
try:
|
||||
auth = await is_valid_session(session)
|
||||
if not auth.user:
|
||||
return JSONResponse(status_code=401, content={"detail": "Invalid User"})
|
||||
user_id = auth.user.id
|
||||
except Exception as e:
|
||||
log.error(f"Error validating session: {e!s}")
|
||||
return JSONResponse(status_code=401, content={"detail": "Invalid Session"})
|
||||
|
||||
try:
|
||||
oauth_service = PlaneOAuthService()
|
||||
|
||||
# Revoke token
|
||||
success = await oauth_service.revoke_token(db=db, user_id=user_id, workspace_id=data.workspace_id)
|
||||
success = await oauth_service.revoke_token(db=db, user_id=auth.user.id, workspace_id=data.workspace_id)
|
||||
|
||||
if success:
|
||||
log.info(f"OAuth authorization revoked for user {user_id}, workspace {data.workspace_id}")
|
||||
log.info(f"OAuth authorization revoked for user {auth.user.id}, workspace {data.workspace_id}")
|
||||
return OAuthRevokeResponse(success=True, message="Authorization revoked successfully")
|
||||
else:
|
||||
return OAuthRevokeResponse(success=False, message="No active authorization found for this workspace")
|
||||
|
||||
return OAuthRevokeResponse(success=False, message="No active authorization found for this workspace")
|
||||
|
||||
except Exception as e:
|
||||
log.error(f"Error revoking OAuth authorization: {e!s}")
|
||||
@@ -331,15 +315,11 @@ async def list_authorized_workspaces(
|
||||
db: AsyncSession = Depends(get_async_session),
|
||||
session: str = Depends(cookie_schema),
|
||||
):
|
||||
"""
|
||||
List all workspaces the user has authorized access to.
|
||||
Returns workspace details and token status.
|
||||
"""
|
||||
"""List all workspaces the user has authorized access to."""
|
||||
try:
|
||||
auth = await is_valid_session(session)
|
||||
if not auth.user:
|
||||
return JSONResponse(status_code=401, content={"detail": "Invalid User"})
|
||||
user_id = auth.user.id
|
||||
except Exception as e:
|
||||
log.error(f"Error validating session: {e!s}")
|
||||
return JSONResponse(status_code=401, content={"detail": "Invalid Session"})
|
||||
@@ -350,18 +330,19 @@ async def list_authorized_workspaces(
|
||||
from pi.app.models.oauth import PlaneOAuthToken
|
||||
|
||||
# Get all active tokens for user
|
||||
result = await db.execute(select(PlaneOAuthToken).where(PlaneOAuthToken.user_id == user_id, PlaneOAuthToken.is_active is True))
|
||||
result = await db.execute(select(PlaneOAuthToken).where(PlaneOAuthToken.user_id == auth.user.id, PlaneOAuthToken.is_active is True))
|
||||
tokens = result.scalars().all()
|
||||
|
||||
workspaces = []
|
||||
for token in tokens:
|
||||
workspaces.append({
|
||||
workspaces = [
|
||||
{
|
||||
"workspace_id": str(token.workspace_id),
|
||||
"workspace_slug": token.workspace_slug,
|
||||
"expires_at": token.expires_at.isoformat(),
|
||||
"needs_refresh": token.needs_refresh(),
|
||||
"is_expired": token.is_expired(),
|
||||
})
|
||||
}
|
||||
for token in tokens
|
||||
]
|
||||
|
||||
return JSONResponse(content={"workspaces": workspaces})
|
||||
|
||||
@@ -373,20 +354,15 @@ async def list_authorized_workspaces(
|
||||
@router.post("/reset-states/")
|
||||
async def reset_oauth_states(
|
||||
user_id: UUID = Query(..., description="User ID to reset states for"),
|
||||
workspace_id: UUID = Query(None, description="Workspace ID to reset (optional - if not provided, resets all user states)"),
|
||||
workspace_id: UUID = Query(None, description="Workspace ID to reset (optional)"),
|
||||
db: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
"""
|
||||
Reset OAuth states for a user to allow fresh authorization attempts.
|
||||
This endpoint can be used when users are stuck with invalid/expired state errors.
|
||||
"""
|
||||
"""Reset OAuth states for a user to allow fresh authorization attempts."""
|
||||
try:
|
||||
oauth_service = PlaneOAuthService()
|
||||
|
||||
# Reset states for the user
|
||||
reset_count = await oauth_service.reset_user_oauth_states(db, user_id, workspace_id)
|
||||
|
||||
# Also cleanup expired states
|
||||
cleanup_count = await oauth_service.cleanup_expired_states(db)
|
||||
|
||||
message = f"Reset {reset_count} OAuth states for user {user_id}"
|
||||
@@ -395,7 +371,6 @@ async def reset_oauth_states(
|
||||
message += f". Cleaned up {cleanup_count} expired states."
|
||||
|
||||
log.info(message)
|
||||
|
||||
return JSONResponse(content={"success": True, "message": message, "reset_count": reset_count, "cleanup_count": cleanup_count})
|
||||
|
||||
except Exception as e:
|
||||
@@ -406,16 +381,17 @@ async def reset_oauth_states(
|
||||
@router.get("/authorize/{encoded_params}", tags=["oauth"])
|
||||
async def clean_oauth_init(
|
||||
encoded_params: str,
|
||||
sidebar_open_url: Optional[str] = Query(None, description="Sidebar open URL from frontend"),
|
||||
pi_sidebar_open: Optional[str] = Query(None, description="Pi sidebar open flag from frontend"),
|
||||
db: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
"""
|
||||
Clean OAuth initiation endpoint with encrypted parameters.
|
||||
This replaces the ugly URL with a clean, encrypted one.
|
||||
Clean OAuth initialization endpoint with encrypted parameters.
|
||||
"""
|
||||
try:
|
||||
from pi.services.actions.oauth_url_encoder import OAuthUrlEncoder
|
||||
|
||||
# Decode the parameters
|
||||
# Decode the parameters from encrypted token
|
||||
oauth_encoder = OAuthUrlEncoder()
|
||||
params = oauth_encoder.decode_oauth_params(encoded_params)
|
||||
|
||||
@@ -426,8 +402,20 @@ async def clean_oauth_init(
|
||||
message_token = params.get("message_token")
|
||||
is_project_chat = params.get("is_project_chat", "false").lower() == "true"
|
||||
project_id = params.get("project_id")
|
||||
pi_sidebar_open = params.get("pi_sidebar_open", "false").lower() == "true"
|
||||
sidebar_open_url = params.get("sidebar_open_url")
|
||||
|
||||
# Use query parameters if provided, otherwise fallback to encrypted params
|
||||
final_sidebar_open_url = sidebar_open_url or params.get("sidebar_open_url")
|
||||
final_pi_sidebar_open = (
|
||||
pi_sidebar_open.strip().lower() == "true" if pi_sidebar_open else params.get("pi_sidebar_open", "false").lower() == "true"
|
||||
)
|
||||
|
||||
# Set pi_sidebar_open=True when sidebar_open_url is provided
|
||||
if final_sidebar_open_url and not final_pi_sidebar_open:
|
||||
final_pi_sidebar_open = True
|
||||
|
||||
# Prepend FRONTEND_URL if sidebar_open_url is a relative path
|
||||
if final_sidebar_open_url and not final_sidebar_open_url.startswith(("http://", "https://")):
|
||||
final_sidebar_open_url = f"{settings.plane_api.FRONTEND_URL}{final_sidebar_open_url}"
|
||||
|
||||
oauth_service = PlaneOAuthService()
|
||||
|
||||
@@ -438,9 +426,11 @@ async def clean_oauth_init(
|
||||
log.warning(f"Failed to cleanup expired states: {e}")
|
||||
|
||||
# Get workspace slug for the workspace
|
||||
workspace_slug = None
|
||||
if workspace_id:
|
||||
workspace_slug = await get_workspace_slug(str(workspace_id))
|
||||
workspace_slug = await get_workspace_slug(str(workspace_id))
|
||||
if not workspace_slug:
|
||||
log.error(f"Workspace not found for id {workspace_id}")
|
||||
redirect_url = f"{settings.plane_api.FRONTEND_URL}?oauth_error=true&message=workspace_not_found"
|
||||
return RedirectResponse(url=redirect_url, status_code=302)
|
||||
|
||||
# Generate authorization URL with state
|
||||
auth_url, state = oauth_service.generate_authorization_url(
|
||||
@@ -460,18 +450,16 @@ async def clean_oauth_init(
|
||||
message_token=message_token,
|
||||
is_project_chat=is_project_chat,
|
||||
project_id=project_id,
|
||||
pi_sidebar_open=pi_sidebar_open,
|
||||
sidebar_open_url=sidebar_open_url,
|
||||
pi_sidebar_open=final_pi_sidebar_open,
|
||||
sidebar_open_url=final_sidebar_open_url,
|
||||
)
|
||||
|
||||
log.debug(f"Generated new OAuth state for user {user_id}")
|
||||
log.debug(f"Generated OAuth state for user {user_id}, workspace {workspace_slug}")
|
||||
|
||||
# Redirect to Plane for authorization
|
||||
return RedirectResponse(url=auth_url, status_code=302)
|
||||
|
||||
except Exception as e:
|
||||
log.error(f"Error processing clean OAuth init: {e}")
|
||||
# Redirect to frontend with error
|
||||
frontend_url = settings.plane_api.FRONTEND_URL
|
||||
redirect_url = f"{frontend_url}?oauth_error=true&message=invalid_parameters"
|
||||
log.error(f"Error processing OAuth init: {e}")
|
||||
redirect_url = f"{settings.plane_api.FRONTEND_URL}?oauth_error=true&message=invalid_parameters"
|
||||
return RedirectResponse(url=redirect_url, status_code=302)
|
||||
|
||||
@@ -228,3 +228,17 @@ class GetThreadsPaginatedResponse(PaginationResponse):
|
||||
"""Paginated response for user chat threads."""
|
||||
|
||||
results: list[dict[str, Any]]
|
||||
|
||||
|
||||
class ChatInitRequest(BaseModel):
|
||||
"""Request to initialize chat - checks OAuth and gets templates"""
|
||||
|
||||
workspace_id: UUID4 = Field(description="Workspace ID to check")
|
||||
|
||||
|
||||
class ChatInitResponse(BaseModel):
|
||||
"""Response with OAuth status and chat templates for initialization"""
|
||||
|
||||
is_authorized: bool = Field(description="Whether user has valid authorization for workspace")
|
||||
templates: List[Any] = Field(default_factory=list, description="Chat suggestion templates")
|
||||
oauth_url: Optional[str] = Field(None, description="OAuth authorization URL if not authorized")
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
@@ -11,10 +11,11 @@ import { EWorkspaceFeatures } from "@/plane-web/types/workspace-feature";
|
||||
import { BetaBadge } from "../common/beta";
|
||||
import { WithFeatureFlagHOC } from "../feature-flags";
|
||||
import { InputBox } from "../pi-chat/input";
|
||||
import { UnauthorizedView } from "../pi-chat/unauthorized";
|
||||
|
||||
export const HomePageHeader = observer(() => {
|
||||
const { workspaceSlug } = useParams();
|
||||
const { activeChatId, initPiChat } = usePiChat();
|
||||
const { activeChatId, isWorkspaceAuthorized, initPiChat } = usePiChat();
|
||||
const { isWorkspaceFeatureEnabled } = useWorkspaceFeatures();
|
||||
if (!isWorkspaceFeatureEnabled(EWorkspaceFeatures.IS_PI_ENABLED)) return <></>;
|
||||
|
||||
@@ -36,13 +37,20 @@ export const HomePageHeader = observer(() => {
|
||||
</Link>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<InputBox
|
||||
isFullScreen
|
||||
isProjectLevel
|
||||
showProgress // Required since its taken to a whole different page
|
||||
className="relative bg-transparent mt-2 max-w-[950px] mx-auto w-full"
|
||||
activeChatId={activeChatId}
|
||||
/>
|
||||
{isWorkspaceAuthorized ? (
|
||||
<InputBox
|
||||
isFullScreen
|
||||
isProjectLevel
|
||||
showProgress // Required since its taken to a whole different page
|
||||
className="relative bg-transparent mt-2 max-w-[950px] mx-auto w-full"
|
||||
activeChatId={activeChatId}
|
||||
/>
|
||||
) : (
|
||||
<UnauthorizedView
|
||||
className="border border-custom-border-100 rounded-lg p-4 mt-3 max-h-[164px] justify-start"
|
||||
imgClassName="h-[117px]"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</WithFeatureFlagHOC>
|
||||
);
|
||||
|
||||
@@ -43,14 +43,11 @@ export const AiMessage = observer((props: TProps) => {
|
||||
remarkPlugins={[remarkGfm]}
|
||||
className="pi-chat-root [&>*:first-child]:mt-0 animate-fade-in"
|
||||
components={{
|
||||
a: ({ children, href }) =>
|
||||
href?.startsWith(`${PI_URL}/api/v1/oauth/authorize/`) && !isLatest ? ( // NOTE: Prev auth links shouldn't be accessible
|
||||
<span className="!underline !text-custom-text-350">{children}</span>
|
||||
) : (
|
||||
<a href={href || ""} rel="noopener noreferrer">
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
a: ({ children, href }) => (
|
||||
<a href={href || ""} target="_blank" rel="noopener noreferrer">
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
table: ({ children }) => (
|
||||
<div className="overflow-x-auto w-full my-4 border-custom-border-200">
|
||||
<table className="min-w-full border-collapse">{children}</table>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { Copy, FilePlus2, ThumbsDown, ThumbsUp, Repeat2 } from "lucide-react";
|
||||
import { setToast, TOAST_TYPE } from "@plane/propel/toast";
|
||||
import { cn, Tooltip } from "@plane/ui";
|
||||
@@ -18,13 +19,13 @@ export type TProps = {
|
||||
handleConvertToPage?: () => void;
|
||||
};
|
||||
|
||||
export const Feedback = (props: TProps) => {
|
||||
export const Feedback = observer((props: TProps) => {
|
||||
// props
|
||||
const { answer, activeChatId, id, workspaceId, feedback, queryId, isLatest, handleConvertToPage } = props;
|
||||
// states
|
||||
const [isFeedbackModalOpen, setIsFeedbackModalOpen] = useState(false);
|
||||
// store
|
||||
const { sendFeedback, regenerateAnswer } = usePiChat();
|
||||
const { isWorkspaceAuthorized, sendFeedback, regenerateAnswer } = usePiChat();
|
||||
// handlers
|
||||
const handleCopyLink = () => {
|
||||
copyTextToClipboard(answer).then(() => {
|
||||
@@ -119,12 +120,21 @@ export const Feedback = (props: TProps) => {
|
||||
|
||||
{/* Convert to page */}
|
||||
<div className="flex text-sm font-medium gap-1 cursor-pointer">
|
||||
<Tooltip tooltipContent="Convert to page" position="bottom" className="mb-4">
|
||||
<button type="button" onClick={handleConvertToPage}>
|
||||
<FilePlus2 size={16} className="my-auto text-custom-text-300 transition-colors " />
|
||||
<Tooltip
|
||||
tooltipContent={isWorkspaceAuthorized ? "Convert to page" : "Authorize workspace to convert to page"}
|
||||
position="bottom"
|
||||
className="mb-4"
|
||||
>
|
||||
<button onClick={() => isWorkspaceAuthorized && handleConvertToPage?.()}>
|
||||
<FilePlus2
|
||||
size={16}
|
||||
className={cn("my-auto text-custom-text-300 transition-colors", {
|
||||
"cursor-not-allowed text-custom-text-400": !isWorkspaceAuthorized,
|
||||
})}
|
||||
/>
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
@@ -7,7 +7,9 @@ import { Loader } from "@plane/ui";
|
||||
import { cn } from "@plane/utils";
|
||||
import { useWorkspace } from "@/hooks/store/use-workspace";
|
||||
import { usePiChat } from "@/plane-web/hooks/store/use-pi-chat";
|
||||
import type { TTemplate } from "@/plane-web/types";
|
||||
import SystemPrompts from "../system-prompts";
|
||||
import { UnauthorizedView } from "../unauthorized";
|
||||
|
||||
type TProps = {
|
||||
currentUser: IUser | undefined;
|
||||
@@ -19,19 +21,24 @@ export const NewConversation = observer((props: TProps) => {
|
||||
const { currentUser, isFullScreen, shouldRedirect = true, isProjectLevel = false } = props;
|
||||
// store hooks
|
||||
const { workspaceSlug } = useParams();
|
||||
const { getTemplates } = usePiChat();
|
||||
const { getInstance } = usePiChat();
|
||||
const { getWorkspaceBySlug } = useWorkspace();
|
||||
const workspaceId = getWorkspaceBySlug(workspaceSlug as string)?.id;
|
||||
const { data: templates, isLoading } = useSWR("PI_TEMPLATES", () => getTemplates(workspaceId), {
|
||||
revalidateOnFocus: false,
|
||||
revalidateIfStale: false,
|
||||
errorRetryCount: 0,
|
||||
});
|
||||
// SWR
|
||||
const { data: instance, isLoading } = useSWR(
|
||||
workspaceId ? `PI_STARTER_${workspaceId}` : null,
|
||||
workspaceId ? () => getInstance(workspaceId) : null,
|
||||
{
|
||||
revalidateOnFocus: false,
|
||||
revalidateIfStale: false,
|
||||
errorRetryCount: 0,
|
||||
}
|
||||
);
|
||||
// state
|
||||
const [isInitializing, setIsInitializing] = useState<string | null>(null);
|
||||
|
||||
if (!currentUser) return null;
|
||||
|
||||
if (!isLoading && !instance?.is_authorized) return <UnauthorizedView />;
|
||||
return (
|
||||
<div
|
||||
className={cn("m-auto pb-[180px]", {
|
||||
@@ -54,17 +61,18 @@ export const NewConversation = observer((props: TProps) => {
|
||||
</Loader>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex gap-4 flex-wrap justify-center ">
|
||||
{templates?.map((prompt, index) => (
|
||||
<SystemPrompts
|
||||
key={index}
|
||||
prompt={prompt}
|
||||
shouldRedirect={shouldRedirect}
|
||||
isProjectLevel={isProjectLevel}
|
||||
isInitializing={isInitializing === prompt.text}
|
||||
setIsInitializing={(value) => setIsInitializing(value)}
|
||||
/>
|
||||
))}
|
||||
<div className="flex gap-4 flex-wrap m-auto justify-center mt-6">
|
||||
{instance?.is_authorized &&
|
||||
instance?.templates?.map((prompt: TTemplate, index: number) => (
|
||||
<SystemPrompts
|
||||
key={index}
|
||||
prompt={prompt}
|
||||
shouldRedirect={shouldRedirect}
|
||||
isProjectLevel={isProjectLevel}
|
||||
isInitializing={isInitializing === prompt.text}
|
||||
setIsInitializing={(value) => setIsInitializing(value)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -11,6 +11,7 @@ import { Loading } from "./conversation/loading";
|
||||
import { Messages } from "./conversation/messages";
|
||||
import { scrollIntoViewHelper } from "./helper";
|
||||
import { InputBox } from "./input";
|
||||
import { UnauthorizedView } from "./unauthorized";
|
||||
|
||||
type TProps = {
|
||||
isFullScreen?: boolean;
|
||||
@@ -23,13 +24,13 @@ export const PiChatDetail = observer((props: TProps) => {
|
||||
// router
|
||||
const pathName = usePathname();
|
||||
// store hooks
|
||||
const { isAuthorized, isLoading, activeChatId } = usePiChat();
|
||||
const { isAuthorized: isChatAuthorized, isWorkspaceAuthorized, isLoading, activeChatId } = usePiChat();
|
||||
const { data: currentUser } = useUser();
|
||||
// derived values
|
||||
const isFullScreen = pathName.includes("pi-chat") || isFullScreenProp;
|
||||
return (
|
||||
<>
|
||||
{isAuthorized ? (
|
||||
{isChatAuthorized && isWorkspaceAuthorized ? (
|
||||
<div
|
||||
className={cn(
|
||||
"px-page-x relative flex flex-col h-[90%] flex-1 align-middle justify-center max-w-[400px] md:m-auto w-full",
|
||||
@@ -75,8 +76,10 @@ export const PiChatDetail = observer((props: TProps) => {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
) : isWorkspaceAuthorized ? (
|
||||
<NotAuthorizedView className="bg-transparent" />
|
||||
) : (
|
||||
<UnauthorizedView />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import Link from "next/link";
|
||||
import { useParams, usePathname } from "next/navigation";
|
||||
import { useTheme } from "next-themes";
|
||||
import useSWR from "swr";
|
||||
import { getButtonStyling } from "@plane/propel/button";
|
||||
import { cn, resolveGeneralTheme } from "@plane/utils";
|
||||
import darkState from "@/app/assets/auth/pi-chat-dark.webp?url";
|
||||
import lightState from "@/app/assets/auth/pi-chat-light.webp?url";
|
||||
import { useWorkspace } from "@/hooks/store/use-workspace";
|
||||
import { usePiChat } from "@/plane-web/hooks/store/use-pi-chat";
|
||||
|
||||
export const UnauthorizedView = (props: { className?: string; imgClassName?: string }) => {
|
||||
const { className, imgClassName } = props;
|
||||
// router
|
||||
const pathname = usePathname();
|
||||
const { workspaceSlug } = useParams();
|
||||
// store hooks
|
||||
const { isPiChatDrawerOpen, getInstance } = usePiChat();
|
||||
const { resolvedTheme } = useTheme();
|
||||
const { getWorkspaceBySlug } = useWorkspace();
|
||||
// derived values
|
||||
const workspaceId = getWorkspaceBySlug(workspaceSlug as string)?.id;
|
||||
// SWR
|
||||
const { data: instance } = useSWR(
|
||||
workspaceId ? `PI_STARTER_${workspaceId}` : null,
|
||||
workspaceId ? () => getInstance(workspaceId) : null,
|
||||
{
|
||||
revalidateOnFocus: false,
|
||||
revalidateIfStale: false,
|
||||
errorRetryCount: 0,
|
||||
}
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={"@container w-full h-full"}>
|
||||
<div
|
||||
className={cn("flex @[400px]:flex-row flex-col size-full items-center justify-center gap-8 px-8", className)}
|
||||
>
|
||||
<div className="flex max-h-full bg-custom-background-90 p-12 pr-0 rounded-lg items-center max-w-[350px] overflow-hidden shadow-r-md justify-end">
|
||||
<img
|
||||
className={cn("w-auto", imgClassName)}
|
||||
src={resolveGeneralTheme(resolvedTheme) === "dark" ? darkState : lightState}
|
||||
alt="Unauthorized"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4 max-w-[400px]">
|
||||
<div className="flex flex-col">
|
||||
<div className="text-lg font-semibold leading-7 text-custom-text-100">
|
||||
Plane AI can now take actions for you.
|
||||
</div>
|
||||
<div className="text-sm leading-5 text-custom-text-300">
|
||||
Use Build mode to create work items, cycles and more. Activate now to start Plane AI actions.
|
||||
</div>
|
||||
</div>
|
||||
{instance && !instance?.is_authorized && (
|
||||
<Link
|
||||
href={`${instance.oauth_url}?sidebar_open_url=${pathname}${isPiChatDrawerOpen ? "?pi_sidebar_open=true" : ""}`}
|
||||
className={cn(getButtonStyling("primary", "md"), "w-fit")}
|
||||
>
|
||||
Activate Build mode
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
import { useProjectAdvanced } from "@/plane-web/hooks/store/projects/use-projects";
|
||||
import { EWorkspaceFeatures } from "@/plane-web/types/workspace-feature";
|
||||
import { useInitiatives } from "../hooks/store/use-initiatives";
|
||||
import { usePiChat } from "../hooks/store/use-pi-chat";
|
||||
export const WorkspaceAuthWrapper: FC<IWorkspaceAuthWrapper> = observer((props) => {
|
||||
// props
|
||||
const { children } = props;
|
||||
@@ -55,6 +56,8 @@ export const WorkspaceAuthWrapper: FC<IWorkspaceAuthWrapper> = observer((props)
|
||||
const { fetchAllCustomerPropertiesAndOptions } = useCustomerProperties();
|
||||
const { isCustomersFeatureEnabled, fetchCustomers } = useCustomers();
|
||||
const { initiative } = useInitiatives();
|
||||
const { getWorkspaceBySlug } = useWorkspace();
|
||||
const { getInstance } = usePiChat();
|
||||
// derived values
|
||||
const isFreeMemberCountExceeded = subscriptionDetail?.is_free_member_count_exceeded;
|
||||
const isWorkspaceSettingsRoute = pathname.includes(`/${workspaceSlug}/settings`);
|
||||
@@ -68,7 +71,7 @@ export const WorkspaceAuthWrapper: FC<IWorkspaceAuthWrapper> = observer((props)
|
||||
const isPageTemplatesEnabled = useFlag(workspaceSlug?.toString(), "PAGE_TEMPLATES");
|
||||
const isInitiativesFeatureEnabled = initiative.isInitiativesFeatureEnabled;
|
||||
const isTemplatePublishEnabled = getIsTemplatePublishEnabled(workspaceSlug.toString());
|
||||
|
||||
const workspaceId = getWorkspaceBySlug(workspaceSlug as string)?.id;
|
||||
// fetching feature flags
|
||||
const { isLoading: flagsLoader, error: flagsError } = useSWR(
|
||||
workspaceSlug ? `WORKSPACE_FLAGS_${workspaceSlug}` : null,
|
||||
@@ -216,6 +219,12 @@ export const WorkspaceAuthWrapper: FC<IWorkspaceAuthWrapper> = observer((props)
|
||||
{ revalidateIfStale: false, revalidateOnFocus: false }
|
||||
);
|
||||
|
||||
useSWR(workspaceId ? `PI_STARTER_${workspaceId}` : null, workspaceId ? () => getInstance(workspaceId) : null, {
|
||||
revalidateOnFocus: false,
|
||||
revalidateIfStale: false,
|
||||
errorRetryCount: 0,
|
||||
});
|
||||
|
||||
// loading state
|
||||
const isLoading = flagsLoader && !flagsError;
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ import type {
|
||||
TFeedback,
|
||||
TQuery,
|
||||
TSearchQuery,
|
||||
TTemplate,
|
||||
TInstanceResponse,
|
||||
TUserThreads,
|
||||
TAiModels,
|
||||
TInitPayload,
|
||||
@@ -23,9 +23,6 @@ import type {
|
||||
TPiAttachment,
|
||||
} from "../types";
|
||||
|
||||
type TTemplateResponse = {
|
||||
templates: TTemplate[];
|
||||
};
|
||||
type TChatHistoryResponse = {
|
||||
results: {
|
||||
chat_id: string;
|
||||
@@ -123,11 +120,11 @@ export class PiChatService extends APIService {
|
||||
return r;
|
||||
}
|
||||
|
||||
// fetch templates
|
||||
async listTemplates(workspaceId: string | undefined): Promise<TTemplateResponse> {
|
||||
return this.get(`/api/v1/chat/get-templates/`, {
|
||||
// fetch instance
|
||||
async getInstance(workspaceId: string): Promise<TInstanceResponse> {
|
||||
return this.get(`/api/v1/chat/start/`, {
|
||||
params: {
|
||||
...(workspaceId && { workspace_id: workspaceId }),
|
||||
workspace_id: workspaceId,
|
||||
},
|
||||
})
|
||||
.then((response) => response?.data)
|
||||
|
||||
@@ -16,10 +16,10 @@ import type {
|
||||
TFocus,
|
||||
TInitPayload,
|
||||
TQuery,
|
||||
TTemplate,
|
||||
TUserThreads,
|
||||
TArtifact,
|
||||
TUpdatedArtifact,
|
||||
TInstanceResponse,
|
||||
} from "@/plane-web/types";
|
||||
import { ESource, EExecutionStatus } from "@/plane-web/types";
|
||||
import { ArtifactsStore } from "./artifacts";
|
||||
@@ -36,6 +36,7 @@ export interface IPiChatStore {
|
||||
activeModel: TAiModels | undefined;
|
||||
models: TAiModels[];
|
||||
isAuthorized: boolean;
|
||||
isWorkspaceAuthorized: boolean;
|
||||
favoriteChats: string[];
|
||||
isLoading: boolean;
|
||||
isPiTyping: boolean;
|
||||
@@ -62,7 +63,7 @@ export interface IPiChatStore {
|
||||
callbackUrl: string,
|
||||
attachmentIds: string[]
|
||||
) => Promise<void>;
|
||||
getTemplates: (workspaceId: string | undefined) => Promise<TTemplate[]>;
|
||||
getInstance: (workspaceId: string) => Promise<TInstanceResponse>;
|
||||
fetchUserThreads: (workspaceId: string | undefined, isProjectChat: boolean) => void;
|
||||
searchCallback: (workspace: string, query: string, focus: TFocus) => Promise<IFormattedValue>;
|
||||
sendFeedback: (
|
||||
@@ -120,6 +121,7 @@ export class PiChatStore implements IPiChatStore {
|
||||
models: TAiModels[] = [];
|
||||
activeModel: TAiModels | undefined = undefined;
|
||||
isAuthorized = true;
|
||||
isWorkspaceAuthorized = true;
|
||||
chatMap: Record<string, TChatHistory> = {};
|
||||
piThreads: string[] = [];
|
||||
projectThreads: string[] = [];
|
||||
@@ -151,6 +153,7 @@ export class PiChatStore implements IPiChatStore {
|
||||
isPiTypingMap: observable,
|
||||
isLoadingMap: observable,
|
||||
isAuthorized: observable,
|
||||
isWorkspaceAuthorized: observable,
|
||||
isLoadingThreads: observable,
|
||||
favoriteChats: observable,
|
||||
isPiChatDrawerOpen: observable.ref,
|
||||
@@ -163,7 +166,7 @@ export class PiChatStore implements IPiChatStore {
|
||||
// actions
|
||||
initPiChat: action,
|
||||
getAnswer: action,
|
||||
getTemplates: action,
|
||||
getInstance: action,
|
||||
fetchUserThreads: action,
|
||||
searchCallback: action,
|
||||
sendFeedback: action,
|
||||
@@ -355,9 +358,10 @@ export class PiChatStore implements IPiChatStore {
|
||||
return payload;
|
||||
};
|
||||
|
||||
getTemplates = async (workspaceId: string | undefined) => {
|
||||
const response = await this.piChatService.listTemplates(workspaceId);
|
||||
return response?.templates;
|
||||
getInstance = async (workspaceId: string): Promise<TInstanceResponse> => {
|
||||
const response = await this.piChatService.getInstance(workspaceId);
|
||||
this.isWorkspaceAuthorized = response.is_authorized;
|
||||
return response;
|
||||
};
|
||||
|
||||
getStreamingAnswer = async (
|
||||
|
||||
Vendored
+10
@@ -252,5 +252,15 @@ export type TPiAttachmentIdMap = {
|
||||
[chatId: string]: string[];
|
||||
};
|
||||
|
||||
export type TInstanceResponse =
|
||||
| {
|
||||
is_authorized: true;
|
||||
templates: TTemplate[];
|
||||
}
|
||||
| {
|
||||
is_authorized: false;
|
||||
oauth_url: string;
|
||||
};
|
||||
|
||||
// constants
|
||||
export const EDITABLE_ARTIFACT_TYPES = ["workitem", "epic", "page"];
|
||||
|
||||
Reference in New Issue
Block a user