diff --git a/backend/app/api/endpoints/ai_builder.py b/backend/app/api/endpoints/ai_builder.py index 099cf539..6ea2c935 100644 --- a/backend/app/api/endpoints/ai_builder.py +++ b/backend/app/api/endpoints/ai_builder.py @@ -11,9 +11,11 @@ import logging from typing import Annotated import anthropic -from fastapi import APIRouter, Depends, HTTPException, status +from fastapi import APIRouter, Depends, HTTPException, Request, status from sqlalchemy.ext.asyncio import AsyncSession +from app.core.rate_limit import limiter + from app.api.deps import get_current_active_user, get_db, require_engineer_or_admin from app.core.config import settings from app.core.ai_conversation_store import ( @@ -86,7 +88,9 @@ async def get_quota( @router.post("/start", response_model=AIStartResponse, status_code=201) +@limiter.limit("10/minute") async def start_conversation( + request: Request, data: AIStartRequest, current_user: Annotated[User, Depends(get_current_active_user)], db: Annotated[AsyncSession, Depends(get_db)], @@ -140,7 +144,9 @@ async def start_conversation( @router.post("/scaffold", response_model=AIScaffoldResponse) +@limiter.limit("10/minute") async def scaffold( + request: Request, data: AIScaffoldRequest, current_user: Annotated[User, Depends(get_current_active_user)], db: Annotated[AsyncSession, Depends(get_db)], @@ -249,7 +255,9 @@ async def scaffold( @router.post("/branch-detail", response_model=AIBranchDetailResponse) +@limiter.limit("10/minute") async def branch_detail( + request: Request, data: AIBranchDetailRequest, current_user: Annotated[User, Depends(get_current_active_user)], db: Annotated[AsyncSession, Depends(get_db)], @@ -364,7 +372,9 @@ async def branch_detail( @router.post("/assemble", response_model=AIAssembleResponse) +@limiter.limit("10/minute") async def assemble( + request: Request, data: AIAssembleRequest, current_user: Annotated[User, Depends(get_current_active_user)], db: Annotated[AsyncSession, Depends(get_db)], diff --git a/backend/app/core/ai_quota_service.py b/backend/app/core/ai_quota_service.py index 1f89ec8b..c4d26b9e 100644 --- a/backend/app/core/ai_quota_service.py +++ b/backend/app/core/ai_quota_service.py @@ -4,6 +4,7 @@ Enforces monthly and daily limits on AI flow builder usage. Monthly quota consumed only on successful tree assembly (counts_toward_quota=True). Daily limit is an anti-abuse guard consumed on conversation start. """ +import calendar from datetime import datetime, timezone, timedelta from typing import Optional from uuid import UUID @@ -127,9 +128,13 @@ async def check_ai_quota( deny_reason = "daily" # Calculate reset timestamps + next_month = month_start.month % 12 + 1 + next_year = month_start.year + (1 if month_start.month == 12 else 0) + max_day = calendar.monthrange(next_year, next_month)[1] monthly_reset_at = month_start.replace( - month=month_start.month % 12 + 1, - year=month_start.year + (1 if month_start.month == 12 else 0), + month=next_month, + year=next_year, + day=min(month_start.day, max_day), ) daily_reset_at = day_start + timedelta(hours=24) diff --git a/backend/app/core/ai_tree_validator.py b/backend/app/core/ai_tree_validator.py index b58ef28d..fa6bc6dc 100644 --- a/backend/app/core/ai_tree_validator.py +++ b/backend/app/core/ai_tree_validator.py @@ -159,7 +159,7 @@ def _check_branch_termination(node: dict[str, Any], errors: list[str]) -> None: if node_type == "solution": return # Solution is a valid terminus - if not children and node_type != "solution": + if not children: errors.append( f"Node '{node_id}' (type={node_type}) is a dead end — " "it has no children and is not a solution node" diff --git a/backend/app/schemas/ai_builder.py b/backend/app/schemas/ai_builder.py index 67e4e87d..0c2f5baa 100644 --- a/backend/app/schemas/ai_builder.py +++ b/backend/app/schemas/ai_builder.py @@ -2,7 +2,7 @@ from typing import Any, Literal, Optional from uuid import UUID -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, field_validator # ── Requests ── @@ -17,7 +17,17 @@ class AIStartRequest(BaseModel): category_id: Optional[UUID] = None name: str = Field(..., min_length=1, max_length=255) description: str = Field("", max_length=2000) - environment_tags: list[str] = Field(default_factory=list) + environment_tags: list[str] = Field(default_factory=list, max_length=20) + + @field_validator("environment_tags") + @classmethod + def validate_tags(cls, v: list[str]) -> list[str]: + for tag in v: + if len(tag) > 100: + raise ValueError("Each environment tag must be 100 characters or fewer") + if not tag.strip(): + raise ValueError("Environment tags must not be empty") + return v class AIScaffoldRequest(BaseModel): diff --git a/frontend/src/components/ai-builder/AIFlowBuilderModal.tsx b/frontend/src/components/ai-builder/AIFlowBuilderModal.tsx index 5d1b0494..8bbbd362 100644 --- a/frontend/src/components/ai-builder/AIFlowBuilderModal.tsx +++ b/frontend/src/components/ai-builder/AIFlowBuilderModal.tsx @@ -1,4 +1,4 @@ -import { useEffect } from 'react' +import { useEffect, useRef } from 'react' import { useNavigate } from 'react-router-dom' import { Modal } from '@/components/common/Modal' import { useAIFlowBuilderStore } from '@/store/aiFlowBuilderStore' @@ -34,9 +34,11 @@ export function AIFlowBuilderModal({ isOpen, onClose }: AIFlowBuilderModalProps) } }, [isOpen, loadQuota]) - // Auto-trigger scaffold after conversation starts + // Auto-trigger scaffold after conversation starts (ref prevents double-fire) + const hasTriggeredScaffold = useRef(false) useEffect(() => { - if (phase === 'scaffolding' && !useAIFlowBuilderStore.getState().suggestedBranches.length) { + if (phase === 'scaffolding' && !hasTriggeredScaffold.current && !useAIFlowBuilderStore.getState().suggestedBranches.length) { + hasTriggeredScaffold.current = true scaffold() } }, [phase, scaffold]) diff --git a/frontend/src/components/ai-builder/BranchDetailView.tsx b/frontend/src/components/ai-builder/BranchDetailView.tsx index 8f4eb31d..25baba07 100644 --- a/frontend/src/components/ai-builder/BranchDetailView.tsx +++ b/frontend/src/components/ai-builder/BranchDetailView.tsx @@ -200,8 +200,8 @@ function NodePreview({ node, depth }: { node: Record; depth: nu {label} {type} - {children.map((child, i) => ( - + {children.map((child) => ( + ))} ) diff --git a/frontend/src/components/common/CreateFlowDropdown.tsx b/frontend/src/components/common/CreateFlowDropdown.tsx new file mode 100644 index 00000000..d39ed29c --- /dev/null +++ b/frontend/src/components/common/CreateFlowDropdown.tsx @@ -0,0 +1,93 @@ +import { useState } from 'react' +import { Link } from 'react-router-dom' +import { Plus, ChevronDown, Sparkles, FolderTree, ListOrdered, Wrench } from 'lucide-react' +import { cn } from '@/lib/utils' + +interface CreateFlowDropdownProps { + aiEnabled: boolean + onOpenAIBuilder: () => void + className?: string + /** Button label — defaults to "Create Flow" */ + label?: string +} + +export function CreateFlowDropdown({ + aiEnabled, + onOpenAIBuilder, + className, + label = 'Create Flow', +}: CreateFlowDropdownProps) { + const [showMenu, setShowMenu] = useState(false) + + return ( +
+ + {showMenu && ( + <> +
setShowMenu(false)} /> +
+ setShowMenu(false)} + className="flex items-center gap-3 rounded-md px-3 py-2.5 text-sm text-foreground hover:bg-accent" + > + +
+
Troubleshooting Tree
+
Branching decision flow
+
+ + setShowMenu(false)} + className="flex items-center gap-3 rounded-md px-3 py-2.5 text-sm text-foreground hover:bg-accent" + > + +
+
Procedural Flow
+
Step-by-step procedure
+
+ + setShowMenu(false)} + className="flex items-center gap-3 rounded-md px-3 py-2.5 text-sm text-foreground hover:bg-accent" + > + +
+
Maintenance Flow
+
Scheduled multi-target tasks
+
+ + {aiEnabled && ( + <> +
+ + + )} +
+ + )} +
+ ) +} diff --git a/frontend/src/hooks/useCachedQuota.ts b/frontend/src/hooks/useCachedQuota.ts index bc26b13c..32f3175f 100644 --- a/frontend/src/hooks/useCachedQuota.ts +++ b/frontend/src/hooks/useCachedQuota.ts @@ -5,6 +5,11 @@ const CACHE_TTL_MS = 5 * 60 * 1000 let cachedResult: { aiEnabled: boolean; timestamp: number } | null = null +/** Clear the cached quota (call on logout to prevent stale data across users). */ +export function clearCachedQuota() { + cachedResult = null +} + export function useCachedQuota() { const [aiEnabled, setAiEnabled] = useState(cachedResult?.aiEnabled ?? false) const [isLoading, setIsLoading] = useState(!cachedResult) diff --git a/frontend/src/pages/QuickStartPage.tsx b/frontend/src/pages/QuickStartPage.tsx index aeb005ba..179f9e0a 100644 --- a/frontend/src/pages/QuickStartPage.tsx +++ b/frontend/src/pages/QuickStartPage.tsx @@ -1,6 +1,6 @@ import { useState, useEffect, useRef, useMemo } from 'react' -import { useNavigate, Link } from 'react-router-dom' -import { Search, Plus, Loader2, Star, ChevronDown, ChevronLeft, ChevronRight, Sparkles, FolderTree, ListOrdered, Wrench } from 'lucide-react' +import { useNavigate } from 'react-router-dom' +import { Search, Loader2, Star, ChevronLeft, ChevronRight } from 'lucide-react' import { treesApi } from '@/api/trees' import { sessionsApi } from '@/api/sessions' import type { TreeListItem } from '@/types' @@ -19,6 +19,7 @@ import { TreeListView } from '@/components/library/TreeListView' import { TreeTableView } from '@/components/library/TreeTableView' import { ViewToggle } from '@/components/library/ViewToggle' import { AIFlowBuilderModal } from '@/components/ai-builder/AIFlowBuilderModal' +import { CreateFlowDropdown } from '@/components/common/CreateFlowDropdown' import { cn } from '@/lib/utils' function timeAgo(dateStr: string): string { @@ -61,8 +62,7 @@ export function QuickStartPage() { // Favorites state const [showAllFavorites, setShowAllFavorites] = useState(false) - // Create menu + AI Builder - const [showCreateMenu, setShowCreateMenu] = useState(false) + // AI Builder const [showAIBuilder, setShowAIBuilder] = useState(false) const { aiEnabled } = useCachedQuota() @@ -212,13 +212,7 @@ export function QuickStartPage() { // Handlers const handleStartSession = (treeId: string, treeType?: string) => { - if (treeType === 'maintenance') { - navigate(`/flows/${treeId}/maintenance`) - } else if (treeType === 'procedural') { - navigate(`/flows/${treeId}/navigate`) - } else { - navigate(`/trees/${treeId}/navigate`) - } + navigate(getTreeNavigatePath(treeId, treeType)) } const handleDeleteTree = () => {} // Not used on dashboard @@ -242,75 +236,10 @@ export function QuickStartPage() {
{canCreateTrees && ( -
- - {showCreateMenu && ( - <> -
setShowCreateMenu(false)} /> -
- setShowCreateMenu(false)} - className="flex items-center gap-3 rounded-md px-3 py-2.5 text-sm text-foreground hover:bg-accent" - > - -
-
Troubleshooting Tree
-
Branching decision flow
-
- - setShowCreateMenu(false)} - className="flex items-center gap-3 rounded-md px-3 py-2.5 text-sm text-foreground hover:bg-accent" - > - -
-
Procedural Flow
-
Step-by-step procedure
-
- - setShowCreateMenu(false)} - className="flex items-center gap-3 rounded-md px-3 py-2.5 text-sm text-foreground hover:bg-accent" - > - -
-
Maintenance Flow
-
Scheduled multi-target tasks
-
- - {aiEnabled && ( - <> -
- - - )} -
- - )} -
+ setShowAIBuilder(true)} + /> )}
@@ -444,13 +373,11 @@ export function QuickStartPage() {

You haven't created any flows yet.

{canCreateTrees && ( - + setShowAIBuilder(true)} + label="Create your first flow" + /> )}
) : ( diff --git a/frontend/src/pages/TreeLibraryPage.tsx b/frontend/src/pages/TreeLibraryPage.tsx index 57f38738..59c9d90a 100644 --- a/frontend/src/pages/TreeLibraryPage.tsx +++ b/frontend/src/pages/TreeLibraryPage.tsx @@ -1,6 +1,6 @@ import { useEffect, useState, useCallback, useMemo } from 'react' -import { useNavigate, Link, useSearchParams } from 'react-router-dom' -import { Plus, X, RotateCcw, Play, ChevronDown, Sparkles, FolderTree, ListOrdered, Wrench } from 'lucide-react' +import { useNavigate, useSearchParams } from 'react-router-dom' +import { X, RotateCcw, Play } from 'lucide-react' import { treesApi } from '@/api/trees' import { categoriesApi } from '@/api/categories' import { foldersApi } from '@/api/folders' @@ -14,12 +14,13 @@ import { TreeTableView } from '@/components/library/TreeTableView' import { ViewToggle } from '@/components/library/ViewToggle' import { SortDropdown } from '@/components/library/SortDropdown' import { cn, safeGetItem } from '@/lib/utils' -import { getSessionResumePath } from '@/lib/routing' +import { getSessionResumePath, getTreeNavigatePath } from '@/lib/routing' import { usePermissions } from '@/hooks/usePermissions' import { useUserPreferencesStore } from '@/store/userPreferencesStore' import { usePinnedFlowsStore } from '@/store/pinnedFlowsStore' import { useCachedQuota } from '@/hooks/useCachedQuota' import { AIFlowBuilderModal } from '@/components/ai-builder/AIFlowBuilderModal' +import { CreateFlowDropdown } from '@/components/common/CreateFlowDropdown' import { toast } from '@/lib/toast' export function TreeLibraryPage() { @@ -72,8 +73,7 @@ export function TreeLibraryPage() { // Fork state const [isForkingTree, setIsForkingTree] = useState(false) - // Create menu & AI builder state - const [showCreateMenu, setShowCreateMenu] = useState(false) + // AI builder state const [showAIBuilder, setShowAIBuilder] = useState(false) const { aiEnabled } = useCachedQuota() @@ -215,13 +215,7 @@ export function TreeLibraryPage() { } const handleStartSession = (treeId: string, treeType?: string) => { - if (treeType === 'maintenance') { - navigate(`/flows/${treeId}/maintenance`) - } else if (treeType === 'procedural') { - navigate(`/flows/${treeId}/navigate`) - } else { - navigate(`/trees/${treeId}/navigate`) - } + navigate(getTreeNavigatePath(treeId, treeType)) } const handleCreateFolder = (parentId?: string | null) => { @@ -285,78 +279,11 @@ export function TreeLibraryPage() {

{canCreateTrees && ( -
- - {showCreateMenu && ( - <> -
setShowCreateMenu(false)} /> -
- setShowCreateMenu(false)} - className="flex items-center gap-3 rounded-md px-3 py-2.5 text-sm text-foreground hover:bg-accent" - > - -
-
Troubleshooting Tree
-
Branching decision flow
-
- - setShowCreateMenu(false)} - className="flex items-center gap-3 rounded-md px-3 py-2.5 text-sm text-foreground hover:bg-accent" - > - -
-
Procedural Flow
-
Step-by-step procedure
-
- - setShowCreateMenu(false)} - className="flex items-center gap-3 rounded-md px-3 py-2.5 text-sm text-foreground hover:bg-accent" - > - -
-
Maintenance Flow
-
Scheduled multi-target tasks
-
- - {aiEnabled && ( - <> -
- - - )} -
- - )} -
+ setShowAIBuilder(true)} + label="Create New" + /> )}
diff --git a/frontend/src/store/authStore.ts b/frontend/src/store/authStore.ts index e06fbc32..a883f6d8 100644 --- a/frontend/src/store/authStore.ts +++ b/frontend/src/store/authStore.ts @@ -3,6 +3,7 @@ import { persist } from 'zustand/middleware' import type { User, Token, UserCreate, UserLogin, Account, SubscriptionDetails } from '@/types' import { authApi } from '@/api/auth' import { apiClient } from '@/api/client' +import { clearCachedQuota } from '@/hooks/useCachedQuota' interface AuthState { user: User | null @@ -79,6 +80,7 @@ export const useAuthStore = create()( } finally { localStorage.removeItem('access_token') localStorage.removeItem('refresh_token') + clearCachedQuota() set({ user: null, token: null, account: null, subscription: null, isAuthenticated: false, error: null }) } },