feat: AI-assisted flow builder with 4-stage wizard #87
@@ -267,6 +267,12 @@ navigate(`/trees/${newTree.id}/edit`)
|
||||
|
||||
**21. Test fixtures in `conftest.py`:** Available fixtures are `client` (async HTTP client), `test_db` (async session), `test_user` (registers user, returns email/password/user_data), `auth_headers` (Bearer token dict), `test_tree` (creates a tree), `test_admin` (super_admin user), `admin_auth_headers` (admin Bearer token). There is NO `async_client` or `engineer_token` fixture.
|
||||
|
||||
**23. Action nodes navigate via `next_node_id`, not `children`:** `TreeNavigationPage.tsx` handles action nodes by following `next_node_id` only — the `children` array on action nodes is ignored at runtime. Action nodes without `next_node_id` render no "Continue" button (dead end). Any AI generation or manual tree editing must set `next_node_id` on action nodes.
|
||||
|
||||
**24. Anthropic model IDs require full dated version string:** `claude-haiku-4-5` is invalid; must be `claude-haiku-4-5-20251001`. See `backend/app/core/config.py` → `AI_MODEL`.
|
||||
|
||||
**25. Claude API may wrap JSON responses in markdown fences:** When parsing AI-generated JSON, always strip ` ```json ... ``` ` fences before parsing. See `_strip_markdown_fences()` in `ai_tree_generator_service.py`.
|
||||
|
||||
---
|
||||
|
||||
## RBAC & Permissions
|
||||
|
||||
216
backend/alembic/versions/a1b2c3d4e5f6_add_ai_flow_builder.py
Normal file
216
backend/alembic/versions/a1b2c3d4e5f6_add_ai_flow_builder.py
Normal file
@@ -0,0 +1,216 @@
|
||||
"""add ai flow builder tables and columns
|
||||
|
||||
Revision ID: a1b2c3d4e5f6
|
||||
Revises: e65b9f8fd458
|
||||
Create Date: 2026-02-20 12:00:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
|
||||
revision: str = "a1b2c3d4e5f6"
|
||||
down_revision: Union[str, None] = "e65b9f8fd458"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ── ai_conversations table ──
|
||||
op.create_table(
|
||||
"ai_conversations",
|
||||
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||||
sa.Column(
|
||||
"user_id",
|
||||
postgresql.UUID(as_uuid=True),
|
||||
sa.ForeignKey("users.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"account_id",
|
||||
postgresql.UUID(as_uuid=True),
|
||||
sa.ForeignKey("accounts.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("status", sa.String(20), nullable=False, server_default="foundation"),
|
||||
sa.Column("messages", postgresql.JSONB(), nullable=False, server_default="[]"),
|
||||
sa.Column(
|
||||
"wizard_state", postgresql.JSONB(), nullable=False, server_default="{}"
|
||||
),
|
||||
sa.Column("generated_tree", postgresql.JSONB(), nullable=True),
|
||||
sa.Column("question_rounds", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column(
|
||||
"expires_at", sa.DateTime(timezone=True), nullable=False
|
||||
),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=sa.func.now(),
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at",
|
||||
sa.DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=sa.func.now(),
|
||||
),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_ai_conversations_user_id", "ai_conversations", ["user_id"]
|
||||
)
|
||||
op.create_index(
|
||||
"ix_ai_conversations_account_id", "ai_conversations", ["account_id"]
|
||||
)
|
||||
op.create_index(
|
||||
"ix_ai_conversations_user_created",
|
||||
"ai_conversations",
|
||||
["user_id", sa.text("created_at DESC")],
|
||||
)
|
||||
op.create_index(
|
||||
"ix_ai_conversations_expires_at", "ai_conversations", ["expires_at"]
|
||||
)
|
||||
|
||||
# ── ai_usage table ──
|
||||
op.create_table(
|
||||
"ai_usage",
|
||||
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||||
sa.Column(
|
||||
"user_id",
|
||||
postgresql.UUID(as_uuid=True),
|
||||
sa.ForeignKey("users.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"account_id",
|
||||
postgresql.UUID(as_uuid=True),
|
||||
sa.ForeignKey("accounts.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"conversation_id",
|
||||
postgresql.UUID(as_uuid=True),
|
||||
sa.ForeignKey("ai_conversations.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
),
|
||||
sa.Column("generation_type", sa.String(20), nullable=False),
|
||||
sa.Column("tier_at_time", sa.String(20), nullable=False),
|
||||
sa.Column("input_tokens", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column("output_tokens", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column(
|
||||
"estimated_cost_usd",
|
||||
sa.Numeric(10, 6),
|
||||
nullable=False,
|
||||
server_default="0",
|
||||
),
|
||||
sa.Column("succeeded", sa.Boolean(), nullable=False, server_default="true"),
|
||||
sa.Column(
|
||||
"counts_toward_quota",
|
||||
sa.Boolean(),
|
||||
nullable=False,
|
||||
server_default="false",
|
||||
),
|
||||
sa.Column("error_code", sa.String(100), nullable=True),
|
||||
sa.Column("metadata", postgresql.JSONB(), nullable=False, server_default="{}"),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=sa.func.now(),
|
||||
),
|
||||
)
|
||||
op.create_index("ix_ai_usage_user_id", "ai_usage", ["user_id"])
|
||||
op.create_index("ix_ai_usage_account_id", "ai_usage", ["account_id"])
|
||||
op.create_index("ix_ai_usage_created_at", "ai_usage", ["created_at"])
|
||||
op.create_index(
|
||||
"ix_ai_usage_user_created",
|
||||
"ai_usage",
|
||||
["user_id", sa.text("created_at DESC")],
|
||||
)
|
||||
op.create_index(
|
||||
"ix_ai_usage_user_type_created",
|
||||
"ai_usage",
|
||||
["user_id", "generation_type", sa.text("created_at DESC")],
|
||||
)
|
||||
# Prevents double quota decrement from race conditions
|
||||
op.execute(
|
||||
"""
|
||||
CREATE UNIQUE INDEX ix_ai_usage_unique_quota
|
||||
ON ai_usage (conversation_id)
|
||||
WHERE counts_toward_quota = true;
|
||||
"""
|
||||
)
|
||||
|
||||
# ── Schema modifications to existing tables ──
|
||||
|
||||
# users: add ai_billing_cycle_anchor_at
|
||||
op.add_column(
|
||||
"users",
|
||||
sa.Column("ai_billing_cycle_anchor_at", sa.DateTime(timezone=True), nullable=True),
|
||||
)
|
||||
# Backfill: use created_at as the billing anchor
|
||||
op.execute(
|
||||
"UPDATE users SET ai_billing_cycle_anchor_at = created_at WHERE ai_billing_cycle_anchor_at IS NULL"
|
||||
)
|
||||
|
||||
# plan_limits: add AI limit columns
|
||||
op.add_column(
|
||||
"plan_limits",
|
||||
sa.Column("max_ai_builds_per_month", sa.Integer(), nullable=True),
|
||||
)
|
||||
op.add_column(
|
||||
"plan_limits",
|
||||
sa.Column("max_ai_builds_per_24h", sa.Integer(), nullable=True),
|
||||
)
|
||||
|
||||
# account_limit_overrides: add AI override columns
|
||||
op.add_column(
|
||||
"account_limit_overrides",
|
||||
sa.Column("override_max_ai_builds_per_month", sa.Integer(), nullable=True),
|
||||
)
|
||||
op.add_column(
|
||||
"account_limit_overrides",
|
||||
sa.Column("override_max_ai_builds_per_24h", sa.Integer(), nullable=True),
|
||||
)
|
||||
|
||||
# Seed plan_limits with AI quota values
|
||||
op.execute(
|
||||
"""
|
||||
UPDATE plan_limits SET max_ai_builds_per_month = 2, max_ai_builds_per_24h = 1
|
||||
WHERE plan = 'free';
|
||||
"""
|
||||
)
|
||||
op.execute(
|
||||
"""
|
||||
UPDATE plan_limits SET max_ai_builds_per_month = 50, max_ai_builds_per_24h = 10
|
||||
WHERE plan = 'pro';
|
||||
"""
|
||||
)
|
||||
op.execute(
|
||||
"""
|
||||
UPDATE plan_limits SET max_ai_builds_per_month = 200, max_ai_builds_per_24h = 20
|
||||
WHERE plan = 'team';
|
||||
"""
|
||||
)
|
||||
# Enterprise: NULL means unlimited (no update needed as default is NULL)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Drop AI override columns from account_limit_overrides
|
||||
op.drop_column("account_limit_overrides", "override_max_ai_builds_per_24h")
|
||||
op.drop_column("account_limit_overrides", "override_max_ai_builds_per_month")
|
||||
|
||||
# Drop AI limit columns from plan_limits
|
||||
op.drop_column("plan_limits", "max_ai_builds_per_24h")
|
||||
op.drop_column("plan_limits", "max_ai_builds_per_month")
|
||||
|
||||
# Drop ai_billing_cycle_anchor_at from users
|
||||
op.drop_column("users", "ai_billing_cycle_anchor_at")
|
||||
|
||||
# Drop ai_usage table (indexes drop automatically)
|
||||
op.drop_table("ai_usage")
|
||||
|
||||
# Drop ai_conversations table (indexes drop automatically)
|
||||
op.drop_table("ai_conversations")
|
||||
437
backend/app/api/endpoints/ai_builder.py
Normal file
437
backend/app/api/endpoints/ai_builder.py
Normal file
@@ -0,0 +1,437 @@
|
||||
"""AI Flow Builder wizard endpoints.
|
||||
|
||||
4-stage wizard:
|
||||
POST /ai/start — Stage 1: create conversation with metadata
|
||||
POST /ai/scaffold — Stage 2: AI suggests branches
|
||||
POST /ai/branch-detail — Stage 3: AI generates detail for one branch
|
||||
POST /ai/assemble — Stage 4: assemble branches into tree (no AI)
|
||||
GET /ai/quota — quota status
|
||||
"""
|
||||
import logging
|
||||
from typing import Annotated
|
||||
|
||||
import anthropic
|
||||
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 (
|
||||
create_conversation,
|
||||
get_conversation,
|
||||
update_conversation,
|
||||
)
|
||||
from app.core.ai_quota_service import check_ai_quota, record_ai_usage, get_user_plan
|
||||
from app.core.ai_tree_generator_service import (
|
||||
scaffold_branches,
|
||||
generate_branch_detail,
|
||||
assemble_tree,
|
||||
)
|
||||
from app.models.user import User
|
||||
from app.schemas.ai_builder import (
|
||||
AIStartRequest,
|
||||
AIStartResponse,
|
||||
AIScaffoldRequest,
|
||||
AIScaffoldResponse,
|
||||
AIBranchDetailRequest,
|
||||
AIBranchDetailResponse,
|
||||
AIAssembleRequest,
|
||||
AIAssembleResponse,
|
||||
AIQuotaStatusResponse,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/ai", tags=["ai-builder"])
|
||||
|
||||
|
||||
def _require_ai_enabled() -> None:
|
||||
"""Raise 503 if AI is not configured."""
|
||||
if not settings.ai_enabled:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="AI flow builder is not configured. Set ANTHROPIC_API_KEY.",
|
||||
)
|
||||
|
||||
|
||||
@router.get("/quota", response_model=AIQuotaStatusResponse)
|
||||
async def get_quota(
|
||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
):
|
||||
"""Get current user's AI quota status."""
|
||||
if not settings.ai_enabled:
|
||||
return AIQuotaStatusResponse(
|
||||
plan="free",
|
||||
monthly_used=0,
|
||||
monthly_limit=None,
|
||||
monthly_reset_at="",
|
||||
daily_used=0,
|
||||
daily_limit=None,
|
||||
daily_reset_at="",
|
||||
allowed=False,
|
||||
ai_enabled=False,
|
||||
)
|
||||
|
||||
_, quota_status = await check_ai_quota(
|
||||
user_id=current_user.id,
|
||||
account_id=current_user.account_id,
|
||||
db=db,
|
||||
billing_anchor=current_user.ai_billing_cycle_anchor_at,
|
||||
)
|
||||
return AIQuotaStatusResponse(
|
||||
**quota_status,
|
||||
ai_enabled=True,
|
||||
)
|
||||
|
||||
|
||||
@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)],
|
||||
_: None = Depends(require_engineer_or_admin),
|
||||
):
|
||||
"""Stage 1: Create a new AI wizard conversation with foundation metadata."""
|
||||
_require_ai_enabled()
|
||||
|
||||
# Check daily quota (anti-abuse)
|
||||
allowed, quota_status = await check_ai_quota(
|
||||
user_id=current_user.id,
|
||||
account_id=current_user.account_id,
|
||||
db=db,
|
||||
billing_anchor=current_user.ai_billing_cycle_anchor_at,
|
||||
)
|
||||
if not allowed:
|
||||
reset_key = (
|
||||
"daily_reset_at"
|
||||
if quota_status.get("deny_reason") == "daily"
|
||||
else "monthly_reset_at"
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
detail={
|
||||
"message": f"AI build limit exceeded ({quota_status['deny_reason']})",
|
||||
"reset_at": quota_status.get(reset_key),
|
||||
"quota": quota_status,
|
||||
},
|
||||
)
|
||||
|
||||
wizard_state = {
|
||||
"flow_type": data.flow_type,
|
||||
"name": data.name,
|
||||
"description": data.description,
|
||||
"environment_tags": data.environment_tags,
|
||||
"category_id": str(data.category_id) if data.category_id else None,
|
||||
}
|
||||
|
||||
conversation = await create_conversation(
|
||||
user_id=current_user.id,
|
||||
account_id=current_user.account_id,
|
||||
wizard_state=wizard_state,
|
||||
db=db,
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
return AIStartResponse(
|
||||
conversation_id=conversation.id,
|
||||
status=conversation.status,
|
||||
)
|
||||
|
||||
|
||||
@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)],
|
||||
_: None = Depends(require_engineer_or_admin),
|
||||
):
|
||||
"""Stage 2: AI suggests top-level branches."""
|
||||
_require_ai_enabled()
|
||||
|
||||
conversation = await get_conversation(
|
||||
data.conversation_id, current_user.id, db
|
||||
)
|
||||
|
||||
# Check per-flow call limit
|
||||
if conversation.question_rounds >= settings.AI_MAX_CALLS_PER_FLOW:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
detail="Maximum AI calls per flow exceeded",
|
||||
)
|
||||
|
||||
plan = await get_user_plan(current_user.account_id, db)
|
||||
|
||||
try:
|
||||
branches, input_tokens, output_tokens, cost = await scaffold_branches(
|
||||
conversation.wizard_state,
|
||||
)
|
||||
except anthropic.APIError as e:
|
||||
await record_ai_usage(
|
||||
user_id=current_user.id,
|
||||
account_id=current_user.account_id,
|
||||
conversation_id=conversation.id,
|
||||
generation_type="scaffold",
|
||||
tier=plan,
|
||||
input_tokens=0,
|
||||
output_tokens=0,
|
||||
estimated_cost=0,
|
||||
succeeded=False,
|
||||
counts_toward_quota=False,
|
||||
error_code=type(e).__name__,
|
||||
extra_data={"error": str(e)},
|
||||
db=db,
|
||||
)
|
||||
await db.commit()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail="AI provider error. Please try again.",
|
||||
)
|
||||
except ValueError as e:
|
||||
await record_ai_usage(
|
||||
user_id=current_user.id,
|
||||
account_id=current_user.account_id,
|
||||
conversation_id=conversation.id,
|
||||
generation_type="scaffold",
|
||||
tier=plan,
|
||||
input_tokens=0,
|
||||
output_tokens=0,
|
||||
estimated_cost=0,
|
||||
succeeded=False,
|
||||
counts_toward_quota=False,
|
||||
error_code="invalid_output",
|
||||
extra_data={"error": str(e)},
|
||||
db=db,
|
||||
)
|
||||
await db.commit()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail=f"AI returned invalid output: {e}",
|
||||
)
|
||||
|
||||
# Record successful usage
|
||||
await record_ai_usage(
|
||||
user_id=current_user.id,
|
||||
account_id=current_user.account_id,
|
||||
conversation_id=conversation.id,
|
||||
generation_type="scaffold",
|
||||
tier=plan,
|
||||
input_tokens=input_tokens,
|
||||
output_tokens=output_tokens,
|
||||
estimated_cost=cost,
|
||||
succeeded=True,
|
||||
counts_toward_quota=False,
|
||||
error_code=None,
|
||||
extra_data=None,
|
||||
db=db,
|
||||
)
|
||||
|
||||
# Update conversation state
|
||||
wizard_state = dict(conversation.wizard_state)
|
||||
wizard_state["branches"] = branches
|
||||
await update_conversation(
|
||||
conversation.id,
|
||||
current_user.id,
|
||||
{
|
||||
"status": "scaffolding",
|
||||
"wizard_state": wizard_state,
|
||||
"question_rounds": conversation.question_rounds + 1,
|
||||
},
|
||||
db,
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
return AIScaffoldResponse(
|
||||
conversation_id=conversation.id,
|
||||
branches=branches,
|
||||
status="scaffolding",
|
||||
)
|
||||
|
||||
|
||||
@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)],
|
||||
_: None = Depends(require_engineer_or_admin),
|
||||
):
|
||||
"""Stage 3: AI generates detailed nodes for one branch."""
|
||||
_require_ai_enabled()
|
||||
|
||||
conversation = await get_conversation(
|
||||
data.conversation_id, current_user.id, db
|
||||
)
|
||||
|
||||
if conversation.question_rounds >= settings.AI_MAX_CALLS_PER_FLOW:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
detail="Maximum AI calls per flow exceeded",
|
||||
)
|
||||
|
||||
wizard_state = conversation.wizard_state
|
||||
existing_branches = [
|
||||
b.get("name", "") for b in wizard_state.get("branches", [])
|
||||
]
|
||||
|
||||
plan = await get_user_plan(current_user.account_id, db)
|
||||
|
||||
try:
|
||||
branch_tree, input_tokens, output_tokens, cost = (
|
||||
await generate_branch_detail(
|
||||
wizard_state,
|
||||
data.branch_name,
|
||||
existing_branches,
|
||||
)
|
||||
)
|
||||
except anthropic.APIError as e:
|
||||
await record_ai_usage(
|
||||
user_id=current_user.id,
|
||||
account_id=current_user.account_id,
|
||||
conversation_id=conversation.id,
|
||||
generation_type="branch_detail",
|
||||
tier=plan,
|
||||
input_tokens=0,
|
||||
output_tokens=0,
|
||||
estimated_cost=0,
|
||||
succeeded=False,
|
||||
counts_toward_quota=False,
|
||||
error_code=type(e).__name__,
|
||||
extra_data={"error": str(e), "branch_name": data.branch_name},
|
||||
db=db,
|
||||
)
|
||||
await db.commit()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail="AI provider error. Please try again.",
|
||||
)
|
||||
except ValueError as e:
|
||||
await record_ai_usage(
|
||||
user_id=current_user.id,
|
||||
account_id=current_user.account_id,
|
||||
conversation_id=conversation.id,
|
||||
generation_type="branch_detail",
|
||||
tier=plan,
|
||||
input_tokens=0,
|
||||
output_tokens=0,
|
||||
estimated_cost=0,
|
||||
succeeded=False,
|
||||
counts_toward_quota=False,
|
||||
error_code="invalid_output",
|
||||
extra_data={"error": str(e), "branch_name": data.branch_name},
|
||||
db=db,
|
||||
)
|
||||
await db.commit()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail=f"AI returned invalid output: {e}",
|
||||
)
|
||||
|
||||
# Record successful usage
|
||||
await record_ai_usage(
|
||||
user_id=current_user.id,
|
||||
account_id=current_user.account_id,
|
||||
conversation_id=conversation.id,
|
||||
generation_type="branch_detail",
|
||||
tier=plan,
|
||||
input_tokens=input_tokens,
|
||||
output_tokens=output_tokens,
|
||||
estimated_cost=cost,
|
||||
succeeded=True,
|
||||
counts_toward_quota=False,
|
||||
error_code=None,
|
||||
extra_data={"branch_name": data.branch_name},
|
||||
db=db,
|
||||
)
|
||||
|
||||
# Update conversation
|
||||
await update_conversation(
|
||||
conversation.id,
|
||||
current_user.id,
|
||||
{
|
||||
"status": "detailing",
|
||||
"question_rounds": conversation.question_rounds + 1,
|
||||
},
|
||||
db,
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
return AIBranchDetailResponse(
|
||||
conversation_id=conversation.id,
|
||||
branch_name=data.branch_name,
|
||||
steps=branch_tree,
|
||||
status="detailing",
|
||||
)
|
||||
|
||||
|
||||
@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)],
|
||||
_: None = Depends(require_engineer_or_admin),
|
||||
):
|
||||
"""Stage 4: Assemble selected branches into a complete tree (no AI calls)."""
|
||||
conversation = await get_conversation(
|
||||
data.conversation_id, current_user.id, db
|
||||
)
|
||||
|
||||
wizard_state = conversation.wizard_state
|
||||
branches_for_assembly = [b.model_dump() for b in data.selected_branches]
|
||||
|
||||
try:
|
||||
tree_structure, name, description, stats = assemble_tree(
|
||||
wizard_state, branches_for_assembly
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail=str(e),
|
||||
)
|
||||
|
||||
# Record quota-consuming usage on successful assembly
|
||||
plan = await get_user_plan(current_user.account_id, db)
|
||||
await record_ai_usage(
|
||||
user_id=current_user.id,
|
||||
account_id=current_user.account_id,
|
||||
conversation_id=conversation.id,
|
||||
generation_type="tree",
|
||||
tier=plan,
|
||||
input_tokens=0,
|
||||
output_tokens=0,
|
||||
estimated_cost=0,
|
||||
succeeded=True,
|
||||
counts_toward_quota=True,
|
||||
error_code=None,
|
||||
extra_data={"stats": stats},
|
||||
db=db,
|
||||
)
|
||||
|
||||
# Update conversation with assembled tree
|
||||
await update_conversation(
|
||||
conversation.id,
|
||||
current_user.id,
|
||||
{
|
||||
"status": "completed",
|
||||
"generated_tree": tree_structure,
|
||||
},
|
||||
db,
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
return AIAssembleResponse(
|
||||
tree_structure=tree_structure,
|
||||
suggested_name=name,
|
||||
suggested_description=description,
|
||||
summary=stats,
|
||||
status="completed",
|
||||
)
|
||||
@@ -229,6 +229,94 @@ async def list_categories(
|
||||
return sorted(categories)
|
||||
|
||||
|
||||
# --- Pinned Flows Endpoints (must be before /{tree_id} to avoid route shadowing) ---
|
||||
|
||||
MAX_PINNED_FLOWS = 15
|
||||
|
||||
|
||||
@router.get("/pinned", response_model=PinnedFlowsListResponse)
|
||||
async def list_pinned_flows(
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
current_user: Annotated[User, Depends(get_current_active_user)]
|
||||
):
|
||||
"""List user's pinned flows, ordered by display_order."""
|
||||
result = await db.execute(
|
||||
select(UserPinnedTree, Tree)
|
||||
.join(Tree, UserPinnedTree.tree_id == Tree.id)
|
||||
.options(selectinload(Tree.category_rel))
|
||||
.where(
|
||||
UserPinnedTree.user_id == current_user.id,
|
||||
Tree.is_active == True,
|
||||
Tree.deleted_at.is_(None)
|
||||
)
|
||||
.order_by(UserPinnedTree.display_order, UserPinnedTree.pinned_at)
|
||||
)
|
||||
rows = result.all()
|
||||
|
||||
items = []
|
||||
for pin, tree in rows:
|
||||
items.append(PinnedFlowResponse(
|
||||
id=pin.id,
|
||||
tree_id=tree.id,
|
||||
tree_name=tree.name,
|
||||
tree_type=tree.tree_type,
|
||||
category_emoji=None,
|
||||
category_name=tree.category_rel.name if tree.category_rel else None,
|
||||
pinned_at=pin.pinned_at,
|
||||
display_order=pin.display_order,
|
||||
))
|
||||
|
||||
return PinnedFlowsListResponse(items=items, count=len(items))
|
||||
|
||||
|
||||
@router.patch("/pinned/reorder", response_model=PinnedFlowsListResponse)
|
||||
async def reorder_pinned_flows(
|
||||
reorder_data: PinnedFlowReorderRequest,
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
current_user: Annotated[User, Depends(get_current_active_user)]
|
||||
):
|
||||
"""Update display_order for all pinned flows."""
|
||||
for item in reorder_data.order:
|
||||
await db.execute(
|
||||
update(UserPinnedTree)
|
||||
.where(
|
||||
UserPinnedTree.user_id == current_user.id,
|
||||
UserPinnedTree.tree_id == item.tree_id
|
||||
)
|
||||
.values(display_order=item.display_order)
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
# Return updated list
|
||||
result = await db.execute(
|
||||
select(UserPinnedTree, Tree)
|
||||
.join(Tree, UserPinnedTree.tree_id == Tree.id)
|
||||
.options(selectinload(Tree.category_rel))
|
||||
.where(
|
||||
UserPinnedTree.user_id == current_user.id,
|
||||
Tree.is_active == True,
|
||||
Tree.deleted_at.is_(None)
|
||||
)
|
||||
.order_by(UserPinnedTree.display_order, UserPinnedTree.pinned_at)
|
||||
)
|
||||
rows = result.all()
|
||||
|
||||
items = []
|
||||
for pin, tree in rows:
|
||||
items.append(PinnedFlowResponse(
|
||||
id=pin.id,
|
||||
tree_id=tree.id,
|
||||
tree_name=tree.name,
|
||||
tree_type=tree.tree_type,
|
||||
category_emoji=None,
|
||||
category_name=tree.category_rel.name if tree.category_rel else None,
|
||||
pinned_at=pin.pinned_at,
|
||||
display_order=pin.display_order,
|
||||
))
|
||||
|
||||
return PinnedFlowsListResponse(items=items, count=len(items))
|
||||
|
||||
|
||||
@router.get("/search", response_model=list[TreeListResponse])
|
||||
async def search_trees(
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
@@ -1034,46 +1122,6 @@ async def check_tree_can_publish(
|
||||
)
|
||||
|
||||
|
||||
# --- Pinned Flows Endpoints ---
|
||||
|
||||
MAX_PINNED_FLOWS = 15
|
||||
|
||||
|
||||
@router.get("/pinned", response_model=PinnedFlowsListResponse)
|
||||
async def list_pinned_flows(
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
current_user: Annotated[User, Depends(get_current_active_user)]
|
||||
):
|
||||
"""List user's pinned flows, ordered by display_order."""
|
||||
result = await db.execute(
|
||||
select(UserPinnedTree, Tree)
|
||||
.join(Tree, UserPinnedTree.tree_id == Tree.id)
|
||||
.options(selectinload(Tree.category_rel))
|
||||
.where(
|
||||
UserPinnedTree.user_id == current_user.id,
|
||||
Tree.is_active == True,
|
||||
Tree.deleted_at.is_(None)
|
||||
)
|
||||
.order_by(UserPinnedTree.display_order, UserPinnedTree.pinned_at)
|
||||
)
|
||||
rows = result.all()
|
||||
|
||||
items = []
|
||||
for pin, tree in rows:
|
||||
items.append(PinnedFlowResponse(
|
||||
id=pin.id,
|
||||
tree_id=tree.id,
|
||||
tree_name=tree.name,
|
||||
tree_type=tree.tree_type,
|
||||
category_emoji=None,
|
||||
category_name=tree.category_rel.name if tree.category_rel else None,
|
||||
pinned_at=pin.pinned_at,
|
||||
display_order=pin.display_order,
|
||||
))
|
||||
|
||||
return PinnedFlowsListResponse(items=items, count=len(items))
|
||||
|
||||
|
||||
@router.post("/{tree_id}/pin", response_model=PinnedFlowResponse)
|
||||
async def pin_flow(
|
||||
tree_id: UUID,
|
||||
@@ -1166,51 +1214,3 @@ async def unpin_flow(
|
||||
await db.delete(pin)
|
||||
await db.commit()
|
||||
return {"success": True}
|
||||
|
||||
|
||||
@router.patch("/pinned/reorder", response_model=PinnedFlowsListResponse)
|
||||
async def reorder_pinned_flows(
|
||||
reorder_data: PinnedFlowReorderRequest,
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
current_user: Annotated[User, Depends(get_current_active_user)]
|
||||
):
|
||||
"""Update display_order for all pinned flows."""
|
||||
for item in reorder_data.order:
|
||||
await db.execute(
|
||||
update(UserPinnedTree)
|
||||
.where(
|
||||
UserPinnedTree.user_id == current_user.id,
|
||||
UserPinnedTree.tree_id == item.tree_id
|
||||
)
|
||||
.values(display_order=item.display_order)
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
# Return updated list
|
||||
result = await db.execute(
|
||||
select(UserPinnedTree, Tree)
|
||||
.join(Tree, UserPinnedTree.tree_id == Tree.id)
|
||||
.options(selectinload(Tree.category_rel))
|
||||
.where(
|
||||
UserPinnedTree.user_id == current_user.id,
|
||||
Tree.is_active == True,
|
||||
Tree.deleted_at.is_(None)
|
||||
)
|
||||
.order_by(UserPinnedTree.display_order, UserPinnedTree.pinned_at)
|
||||
)
|
||||
rows = result.all()
|
||||
|
||||
items = []
|
||||
for pin, tree in rows:
|
||||
items.append(PinnedFlowResponse(
|
||||
id=pin.id,
|
||||
tree_id=tree.id,
|
||||
tree_name=tree.name,
|
||||
tree_type=tree.tree_type,
|
||||
category_emoji=None,
|
||||
category_name=tree.category_rel.name if tree.category_rel else None,
|
||||
pinned_at=pin.pinned_at,
|
||||
display_order=pin.display_order,
|
||||
))
|
||||
|
||||
return PinnedFlowsListResponse(items=items, count=len(items))
|
||||
|
||||
@@ -5,6 +5,7 @@ from app.api.endpoints import ratings, analytics
|
||||
from app.api.endpoints import target_lists
|
||||
from app.api.endpoints import maintenance_schedules
|
||||
from app.api.endpoints import feedback
|
||||
from app.api.endpoints import ai_builder
|
||||
|
||||
api_router = APIRouter()
|
||||
|
||||
@@ -34,3 +35,4 @@ api_router.include_router(analytics.router)
|
||||
api_router.include_router(target_lists.router)
|
||||
api_router.include_router(maintenance_schedules.router)
|
||||
api_router.include_router(feedback.router)
|
||||
api_router.include_router(ai_builder.router)
|
||||
|
||||
87
backend/app/core/ai_conversation_store.py
Normal file
87
backend/app/core/ai_conversation_store.py
Normal file
@@ -0,0 +1,87 @@
|
||||
"""DB-backed CRUD for AI wizard conversation state.
|
||||
|
||||
Conversations have a 24-hour TTL. Every access validates ownership and expiry.
|
||||
"""
|
||||
import uuid
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from typing import Any, Optional
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.config import settings
|
||||
from app.models.ai_conversation import AIConversation
|
||||
|
||||
|
||||
async def create_conversation(
|
||||
user_id: UUID,
|
||||
account_id: UUID,
|
||||
wizard_state: dict[str, Any],
|
||||
db: AsyncSession,
|
||||
) -> AIConversation:
|
||||
"""Create a new AI wizard conversation."""
|
||||
conversation = AIConversation(
|
||||
user_id=user_id,
|
||||
account_id=account_id,
|
||||
status="foundation",
|
||||
wizard_state=wizard_state,
|
||||
messages=[],
|
||||
expires_at=datetime.now(timezone.utc)
|
||||
+ timedelta(hours=settings.AI_CONVERSATION_TTL_HOURS),
|
||||
)
|
||||
db.add(conversation)
|
||||
await db.flush()
|
||||
return conversation
|
||||
|
||||
|
||||
async def get_conversation(
|
||||
conversation_id: UUID,
|
||||
user_id: UUID,
|
||||
db: AsyncSession,
|
||||
) -> AIConversation:
|
||||
"""Get a conversation, validating ownership and expiry.
|
||||
|
||||
Raises HTTPException 410 if expired, 404 if not found or wrong owner.
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(AIConversation).where(AIConversation.id == conversation_id)
|
||||
)
|
||||
conversation = result.scalar_one_or_none()
|
||||
|
||||
if not conversation or conversation.user_id != user_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Conversation not found",
|
||||
)
|
||||
|
||||
if conversation.expires_at < datetime.now(timezone.utc):
|
||||
conversation.status = "expired"
|
||||
await db.flush()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_410_GONE,
|
||||
detail="Conversation expired. Please start a new AI build.",
|
||||
)
|
||||
|
||||
return conversation
|
||||
|
||||
|
||||
async def update_conversation(
|
||||
conversation_id: UUID,
|
||||
user_id: UUID,
|
||||
updates: dict[str, Any],
|
||||
db: AsyncSession,
|
||||
) -> AIConversation:
|
||||
"""Update a conversation's fields.
|
||||
|
||||
Validates ownership and expiry before updating.
|
||||
"""
|
||||
conversation = await get_conversation(conversation_id, user_id, db)
|
||||
|
||||
for key, value in updates.items():
|
||||
if hasattr(conversation, key):
|
||||
setattr(conversation, key, value)
|
||||
|
||||
await db.flush()
|
||||
return conversation
|
||||
186
backend/app/core/ai_quota_service.py
Normal file
186
backend/app/core/ai_quota_service.py
Normal file
@@ -0,0 +1,186 @@
|
||||
"""AI generation quota management.
|
||||
|
||||
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
|
||||
|
||||
from sqlalchemy import select, func
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.ai_usage import AIUsage
|
||||
from app.models.plan_limits import PlanLimits
|
||||
from app.models.account_limit_override import AccountLimitOverride
|
||||
from app.core.subscriptions import get_account_subscription, get_plan_limits
|
||||
|
||||
|
||||
async def get_user_plan(account_id: Optional[UUID], db: AsyncSession) -> str:
|
||||
"""Get the plan tier for an account."""
|
||||
if not account_id:
|
||||
return "free"
|
||||
sub = await get_account_subscription(account_id, db)
|
||||
if sub is None:
|
||||
return "free"
|
||||
return sub.plan if sub.plan else "free"
|
||||
|
||||
|
||||
async def _get_effective_limits(
|
||||
account_id: UUID, plan: str, db: AsyncSession
|
||||
) -> tuple[Optional[int], Optional[int]]:
|
||||
"""Get effective AI limits (monthly, daily), applying account overrides.
|
||||
|
||||
Returns (monthly_limit, daily_limit). None means unlimited.
|
||||
"""
|
||||
limits = await get_plan_limits(plan, db)
|
||||
monthly = limits.max_ai_builds_per_month if limits else None
|
||||
daily = limits.max_ai_builds_per_24h if limits else None
|
||||
|
||||
# Check for account-level overrides
|
||||
result = await db.execute(
|
||||
select(AccountLimitOverride).where(
|
||||
AccountLimitOverride.account_id == account_id
|
||||
)
|
||||
)
|
||||
override = result.scalar_one_or_none()
|
||||
if override:
|
||||
if override.override_max_ai_builds_per_month is not None:
|
||||
monthly = override.override_max_ai_builds_per_month
|
||||
if override.override_max_ai_builds_per_24h is not None:
|
||||
daily = override.override_max_ai_builds_per_24h
|
||||
|
||||
return monthly, daily
|
||||
|
||||
|
||||
def _get_billing_anchor_month_start(anchor: Optional[datetime]) -> datetime:
|
||||
"""Calculate the start of the current billing month from the anchor date.
|
||||
|
||||
If the anchor is day 15, the billing month runs from the 15th of each month.
|
||||
Falls back to calendar month if anchor is None.
|
||||
"""
|
||||
now = datetime.now(timezone.utc)
|
||||
if not anchor:
|
||||
return now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
||||
|
||||
anchor_day = min(anchor.day, 28) # Clamp to avoid month overflow
|
||||
this_month_anchor = now.replace(
|
||||
day=anchor_day, hour=0, minute=0, second=0, microsecond=0
|
||||
)
|
||||
|
||||
if now >= this_month_anchor:
|
||||
return this_month_anchor
|
||||
else:
|
||||
# We're before the anchor day, so billing month started last month
|
||||
if now.month == 1:
|
||||
return this_month_anchor.replace(year=now.year - 1, month=12)
|
||||
else:
|
||||
return this_month_anchor.replace(month=now.month - 1)
|
||||
|
||||
|
||||
async def check_ai_quota(
|
||||
user_id: UUID,
|
||||
account_id: UUID,
|
||||
db: AsyncSession,
|
||||
billing_anchor: Optional[datetime] = None,
|
||||
) -> tuple[bool, dict]:
|
||||
"""Check if user can make an AI generation.
|
||||
|
||||
Returns (allowed, quota_status_dict).
|
||||
Monthly counts only rows with counts_toward_quota=True.
|
||||
Daily counts only rows with generation_type in ('scaffold', 'branch_detail').
|
||||
"""
|
||||
plan = await get_user_plan(account_id, db)
|
||||
monthly_limit, daily_limit = await _get_effective_limits(account_id, plan, db)
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
month_start = _get_billing_anchor_month_start(billing_anchor)
|
||||
day_start = now - timedelta(hours=24)
|
||||
|
||||
# Monthly: count successful quota-consuming records
|
||||
monthly_count = await db.scalar(
|
||||
select(func.count(AIUsage.id)).where(
|
||||
AIUsage.user_id == user_id,
|
||||
AIUsage.counts_toward_quota == True, # noqa: E712
|
||||
AIUsage.created_at >= month_start,
|
||||
)
|
||||
) or 0
|
||||
|
||||
# Daily: count all AI API calls (scaffold + branch_detail) in last 24h
|
||||
daily_count = await db.scalar(
|
||||
select(func.count(AIUsage.id)).where(
|
||||
AIUsage.user_id == user_id,
|
||||
AIUsage.succeeded == True, # noqa: E712
|
||||
AIUsage.generation_type.in_(["scaffold", "branch_detail"]),
|
||||
AIUsage.created_at >= day_start,
|
||||
)
|
||||
) or 0
|
||||
|
||||
allowed = True
|
||||
deny_reason = None
|
||||
if monthly_limit is not None and monthly_count >= monthly_limit:
|
||||
allowed = False
|
||||
deny_reason = "monthly"
|
||||
if daily_limit is not None and daily_count >= daily_limit:
|
||||
allowed = False
|
||||
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=next_month,
|
||||
year=next_year,
|
||||
day=min(month_start.day, max_day),
|
||||
)
|
||||
daily_reset_at = day_start + timedelta(hours=24)
|
||||
|
||||
return allowed, {
|
||||
"plan": plan,
|
||||
"monthly_used": monthly_count,
|
||||
"monthly_limit": monthly_limit,
|
||||
"monthly_reset_at": monthly_reset_at.isoformat(),
|
||||
"daily_used": daily_count,
|
||||
"daily_limit": daily_limit,
|
||||
"daily_reset_at": daily_reset_at.isoformat(),
|
||||
"allowed": allowed,
|
||||
"deny_reason": deny_reason,
|
||||
}
|
||||
|
||||
|
||||
async def record_ai_usage(
|
||||
user_id: UUID,
|
||||
account_id: UUID,
|
||||
conversation_id: Optional[UUID],
|
||||
generation_type: str,
|
||||
tier: str,
|
||||
input_tokens: int,
|
||||
output_tokens: int,
|
||||
estimated_cost: float,
|
||||
succeeded: bool,
|
||||
counts_toward_quota: bool,
|
||||
error_code: Optional[str],
|
||||
extra_data: Optional[dict],
|
||||
db: AsyncSession,
|
||||
) -> AIUsage:
|
||||
"""Record an AI usage entry."""
|
||||
usage = AIUsage(
|
||||
user_id=user_id,
|
||||
account_id=account_id,
|
||||
conversation_id=conversation_id,
|
||||
generation_type=generation_type,
|
||||
tier_at_time=tier,
|
||||
input_tokens=input_tokens,
|
||||
output_tokens=output_tokens,
|
||||
estimated_cost_usd=estimated_cost,
|
||||
succeeded=succeeded,
|
||||
counts_toward_quota=counts_toward_quota,
|
||||
error_code=error_code,
|
||||
extra_data=extra_data or {},
|
||||
)
|
||||
db.add(usage)
|
||||
await db.flush()
|
||||
return usage
|
||||
322
backend/app/core/ai_tree_generator_service.py
Normal file
322
backend/app/core/ai_tree_generator_service.py
Normal file
@@ -0,0 +1,322 @@
|
||||
"""AI-powered tree generation service using Anthropic Claude API.
|
||||
|
||||
Implements the 4-stage wizard flow:
|
||||
Stage 2 (scaffold): AI suggests 4-7 top-level branches
|
||||
Stage 3 (branch_detail): AI generates detailed nodes per branch
|
||||
Stage 4 (assemble): Pure assembly logic — zero AI calls
|
||||
|
||||
System prompts are static constants to enable Anthropic prompt caching.
|
||||
"""
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
import anthropic
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.ai_tree_validator import validate_generated_tree, count_tree_stats
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ── Cost estimation (Haiku 4.5 pricing) ──
|
||||
COST_PER_INPUT_TOKEN = 1.0 / 1_000_000 # $1.00 per 1M input tokens
|
||||
COST_PER_OUTPUT_TOKEN = 5.0 / 1_000_000 # $5.00 per 1M output tokens
|
||||
|
||||
|
||||
# ── System Prompts ──
|
||||
|
||||
SCAFFOLD_SYSTEM_PROMPT = """You are ResolutionFlow AI, assisting MSP engineers to build troubleshooting and procedural flows for IT service management.
|
||||
|
||||
Context: Your audience is technical MSP staff experienced with Windows Server, Active Directory, networking, and common MSP tooling (ConnectWise, Datto, SonicWall, etc.).
|
||||
|
||||
Task: Given a flow type, category, name, description, and environment tags, suggest 4-7 top-level branches for the flow.
|
||||
|
||||
For TROUBLESHOOTING flows:
|
||||
- Branches should describe the symptom the user observes — written as what the user sees or reports
|
||||
- The branch name becomes a selectable option on the first screen, so it must be self-identifying from a user's perspective
|
||||
- Good: "Drive letter missing after login", "Mapped drive shows as disconnected (red X)", "Access denied when opening files"
|
||||
- Bad: "Authentication Failures", "GPO Issues", "Connectivity Problems" — too vague for users to self-identify
|
||||
- Order from most common to least common
|
||||
|
||||
For PROCEDURE flows:
|
||||
- Branches should be phase-based stages (e.g., "Prerequisites", "Configuration", "Verification", "Documentation")
|
||||
- Each branch represents a major step in the process
|
||||
- Order in logical execution sequence
|
||||
|
||||
Rules:
|
||||
- Suggest 4-7 branches
|
||||
- Be specific to the technology/service described — avoid generic internal category names
|
||||
- Branch names should be concise (3-7 words) and describe the observable symptom or phase
|
||||
- Each branch needs a brief description (1 sentence) explaining what scenarios it covers
|
||||
- Return ONLY valid JSON, no markdown, no explanation
|
||||
|
||||
Output format:
|
||||
{"branches": [{"name": "Branch Name", "description": "Brief description of what this covers"}]}"""
|
||||
|
||||
|
||||
BRANCH_DETAIL_SYSTEM_PROMPT = """You are ResolutionFlow AI generating step-by-step detail for one branch of a troubleshooting or procedural flow for MSP engineers.
|
||||
|
||||
Context: Your audience is technical MSP staff experienced with Windows Server, Active Directory, networking, and common MSP tooling.
|
||||
|
||||
You must return ONLY valid JSON — no markdown, no code fences, no explanation.
|
||||
|
||||
Required node schema:
|
||||
|
||||
Decision nodes (branching diagnostic questions — choose the right path):
|
||||
{"id": "unique-slug", "type": "decision", "question": "The diagnostic question", "help_text": "Optional context or command hint", "options": [{"id": "opt-id", "label": "Specific observable answer", "next_node_id": "child-node-id"}], "children": [<all child nodes listed here>]}
|
||||
|
||||
Action nodes (a single investigation or remediation step — MUST have next_node_id pointing to the next node):
|
||||
{"id": "unique-slug", "type": "action", "title": "Short title", "description": "Detailed instructions", "commands": ["PowerShell or CMD commands"], "expected_outcome": "What success looks like", "next_node_id": "id-of-next-sibling-node"}
|
||||
|
||||
Solution nodes (leaf nodes — the final resolution, no children):
|
||||
{"id": "unique-slug", "type": "solution", "title": "Resolution title", "description": "Full resolution description", "resolution_steps": ["Step 1", "Step 2"]}
|
||||
|
||||
CRITICAL NAVIGATION RULES:
|
||||
- Decision node: each option's next_node_id MUST exactly match the "id" of a direct child in that decision node's "children" array
|
||||
- Action node: next_node_id MUST exactly match the "id" of a sibling node (another child of the same parent decision node)
|
||||
- Every action node MUST have a next_node_id — action nodes with no next step are broken dead ends
|
||||
- Solution nodes have no children and no next_node_id — they are the terminus
|
||||
- Every path through the tree MUST end at a solution node
|
||||
|
||||
Additional rules:
|
||||
1. Generate 4-10 nodes total for this branch
|
||||
2. Start with a decision node if troubleshooting, action node if procedure
|
||||
3. Decision nodes must have at least 2 options with specific, observable answer choices
|
||||
4. Include realistic MSP commands (PowerShell preferred for Windows)
|
||||
5. Use unique node IDs prefixed with the branch context (e.g., "gpo-check-link")
|
||||
6. Build the tree bottom-up in your head: create solution/leaf nodes first, then build parent nodes referencing their IDs
|
||||
|
||||
Few-shot example showing correct action node next_node_id usage:
|
||||
{"id": "dns-root", "type": "decision", "question": "Can the client resolve any DNS names?", "help_text": "Run: nslookup google.com", "options": [{"id": "dns-opt-none", "label": "No — nslookup times out or returns 'server failed'", "next_node_id": "dns-check-service"}, {"id": "dns-opt-partial", "label": "Some names resolve but others fail", "next_node_id": "dns-check-specific"}], "children": [{"id": "dns-check-service", "type": "action", "title": "Check DNS Client Service", "description": "Verify the DNS Client service is running on the affected machine", "commands": ["Get-Service -Name Dnscache | Select-Object Status,StartType"], "expected_outcome": "Status should be Running", "next_node_id": "dns-service-solution"}, {"id": "dns-service-solution", "type": "solution", "title": "DNS Service Was Stopped", "description": "The DNS Client service was stopped, preventing all name resolution", "resolution_steps": ["Run: Start-Service Dnscache", "Set startup type: Set-Service Dnscache -StartupType Automatic", "Flush cache: ipconfig /flushdns", "Test: nslookup google.com"]}, {"id": "dns-check-specific", "type": "solution", "title": "Selective DNS Failure — Stale or Missing Records", "description": "Some records resolve correctly, indicating DNS is functional but specific records are stale or missing", "resolution_steps": ["Check DNS server for missing A/CNAME records", "Clear DNS cache on the DNS server: Clear-DnsServerCache", "Flush client cache: ipconfig /flushdns", "Verify with: nslookup <failing-hostname>"]}]}"""
|
||||
|
||||
|
||||
CORRECTIVE_PROMPT_TEMPLATE = """Your previous JSON was invalid for ResolutionFlow's tree schema.
|
||||
|
||||
Validation errors:
|
||||
{error_list}
|
||||
|
||||
IMPORTANT: If any error mentions a next_node_id referencing a non-existent child, you must ensure every option's next_node_id exactly matches the "id" field of one of the node's direct children. The child node must exist in the "children" array of the same parent node.
|
||||
|
||||
Return a corrected full JSON object only. No markdown, no prose, no code fences.
|
||||
Fix ALL listed errors while maintaining the same troubleshooting/procedural logic."""
|
||||
|
||||
|
||||
def _strip_markdown_fences(text: str) -> str:
|
||||
"""Strip markdown code fences if the model wrapped its JSON response."""
|
||||
text = text.strip()
|
||||
match = re.match(r"^```(?:json)?\s*([\s\S]*?)```$", text)
|
||||
if match:
|
||||
return match.group(1).strip()
|
||||
return text
|
||||
|
||||
|
||||
def _get_client() -> anthropic.AsyncAnthropic:
|
||||
"""Get configured async Anthropic client."""
|
||||
if not settings.ANTHROPIC_API_KEY:
|
||||
raise RuntimeError("ANTHROPIC_API_KEY not configured")
|
||||
return anthropic.AsyncAnthropic(
|
||||
api_key=settings.ANTHROPIC_API_KEY,
|
||||
timeout=settings.AI_REQUEST_TIMEOUT_SECONDS,
|
||||
)
|
||||
|
||||
|
||||
def _estimate_cost(input_tokens: int, output_tokens: int) -> float:
|
||||
"""Estimate USD cost from token counts."""
|
||||
return (input_tokens * COST_PER_INPUT_TOKEN) + (
|
||||
output_tokens * COST_PER_OUTPUT_TOKEN
|
||||
)
|
||||
|
||||
|
||||
async def scaffold_branches(
|
||||
wizard_state: dict[str, Any],
|
||||
) -> tuple[list[dict[str, str]], int, int, float]:
|
||||
"""Stage 2: AI suggests top-level branches.
|
||||
|
||||
Returns (branches, input_tokens, output_tokens, estimated_cost).
|
||||
Raises ValueError on invalid response.
|
||||
"""
|
||||
client = _get_client()
|
||||
|
||||
flow_type = wizard_state.get("flow_type", "troubleshooting")
|
||||
name = wizard_state.get("name", "")
|
||||
description = wizard_state.get("description", "")
|
||||
tags = wizard_state.get("environment_tags", [])
|
||||
|
||||
user_message = (
|
||||
f"Flow type: {flow_type}\n"
|
||||
f"Name: {name}\n"
|
||||
f"Description: {description}\n"
|
||||
)
|
||||
if tags:
|
||||
user_message += f"Environment: {', '.join(tags)}\n"
|
||||
|
||||
response = await client.messages.create(
|
||||
model=settings.AI_MODEL,
|
||||
max_tokens=1024,
|
||||
system=SCAFFOLD_SYSTEM_PROMPT,
|
||||
messages=[{"role": "user", "content": user_message}],
|
||||
)
|
||||
|
||||
raw_text = _strip_markdown_fences(response.content[0].text)
|
||||
input_tokens = response.usage.input_tokens
|
||||
output_tokens = response.usage.output_tokens
|
||||
cost = _estimate_cost(input_tokens, output_tokens)
|
||||
|
||||
try:
|
||||
data = json.loads(raw_text)
|
||||
except json.JSONDecodeError as e:
|
||||
raise ValueError(f"AI returned invalid JSON: {e}")
|
||||
|
||||
branches = data.get("branches", [])
|
||||
if not isinstance(branches, list) or len(branches) < 2:
|
||||
raise ValueError("AI returned fewer than 2 branches")
|
||||
|
||||
return branches, input_tokens, output_tokens, cost
|
||||
|
||||
|
||||
async def generate_branch_detail(
|
||||
wizard_state: dict[str, Any],
|
||||
branch_name: str,
|
||||
existing_branches: list[str],
|
||||
) -> tuple[dict[str, Any], int, int, float]:
|
||||
"""Stage 3: AI generates detailed nodes for one branch.
|
||||
|
||||
Returns (branch_tree, input_tokens, output_tokens, estimated_cost).
|
||||
On validation failure, retries once with corrective prompt.
|
||||
Raises ValueError if both attempts fail.
|
||||
"""
|
||||
client = _get_client()
|
||||
|
||||
flow_type = wizard_state.get("flow_type", "troubleshooting")
|
||||
name = wizard_state.get("name", "")
|
||||
description = wizard_state.get("description", "")
|
||||
|
||||
user_message = (
|
||||
f"Flow: {name} ({flow_type})\n"
|
||||
f"Description: {description}\n"
|
||||
f"Branch to detail: {branch_name}\n"
|
||||
)
|
||||
if existing_branches:
|
||||
other = [b for b in existing_branches if b != branch_name]
|
||||
if other:
|
||||
user_message += f"Other branches (avoid overlap): {', '.join(other)}\n"
|
||||
|
||||
messages = [{"role": "user", "content": user_message}]
|
||||
total_input = 0
|
||||
total_output = 0
|
||||
|
||||
for attempt in range(3):
|
||||
response = await client.messages.create(
|
||||
model=settings.AI_MODEL,
|
||||
max_tokens=8192,
|
||||
system=BRANCH_DETAIL_SYSTEM_PROMPT,
|
||||
messages=messages,
|
||||
)
|
||||
|
||||
total_input += response.usage.input_tokens
|
||||
total_output += response.usage.output_tokens
|
||||
logger.debug(
|
||||
"branch_detail attempt=%d stop_reason=%s content_blocks=%d output_tokens=%d",
|
||||
attempt,
|
||||
response.stop_reason,
|
||||
len(response.content),
|
||||
response.usage.output_tokens,
|
||||
)
|
||||
raw_text = _strip_markdown_fences(response.content[0].text) if response.content else ""
|
||||
if not raw_text:
|
||||
logger.warning("branch_detail attempt=%d returned empty text, stop_reason=%s", attempt, response.stop_reason)
|
||||
|
||||
try:
|
||||
branch_tree = json.loads(raw_text)
|
||||
except json.JSONDecodeError as e:
|
||||
if attempt < 2:
|
||||
messages.append({"role": "assistant", "content": raw_text})
|
||||
messages.append({
|
||||
"role": "user",
|
||||
"content": CORRECTIVE_PROMPT_TEMPLATE.format(
|
||||
error_list=f"JSON parse error: {e}"
|
||||
),
|
||||
})
|
||||
continue
|
||||
raise ValueError(f"AI returned invalid JSON after retry: {e}")
|
||||
|
||||
errors = validate_generated_tree(branch_tree)
|
||||
if not errors:
|
||||
cost = _estimate_cost(total_input, total_output)
|
||||
return branch_tree, total_input, total_output, cost
|
||||
|
||||
if attempt < 2:
|
||||
messages.append({"role": "assistant", "content": raw_text})
|
||||
messages.append({
|
||||
"role": "user",
|
||||
"content": CORRECTIVE_PROMPT_TEMPLATE.format(
|
||||
error_list="\n".join(f"- {e}" for e in errors)
|
||||
),
|
||||
})
|
||||
continue
|
||||
|
||||
raise ValueError(
|
||||
f"AI tree validation failed after retry: {'; '.join(errors)}"
|
||||
)
|
||||
|
||||
# Should not reach here
|
||||
raise ValueError("Branch detail generation failed")
|
||||
|
||||
|
||||
def assemble_tree(
|
||||
wizard_state: dict[str, Any],
|
||||
branches: list[dict[str, Any]],
|
||||
) -> tuple[dict[str, Any], str, str, dict[str, int]]:
|
||||
"""Stage 4: Assemble branches into a complete tree.
|
||||
|
||||
Zero AI calls — pure assembly logic.
|
||||
Returns (tree_structure, suggested_name, suggested_description, summary_stats).
|
||||
"""
|
||||
flow_type = wizard_state.get("flow_type", "troubleshooting")
|
||||
name = wizard_state.get("name", "Untitled Flow")
|
||||
description = wizard_state.get("description", "")
|
||||
|
||||
# Build root decision node pointing to each branch
|
||||
options = []
|
||||
children = []
|
||||
for i, branch in enumerate(branches):
|
||||
branch_name = branch.get("name", f"Branch {i + 1}")
|
||||
branch_tree = branch.get("steps")
|
||||
|
||||
if not branch_tree or not isinstance(branch_tree, dict):
|
||||
# Skip branches without detail
|
||||
continue
|
||||
|
||||
branch_id = branch_tree.get("id", f"branch_{i}")
|
||||
options.append({
|
||||
"id": f"opt_{i + 1}",
|
||||
"label": branch_name,
|
||||
"next_node_id": branch_id,
|
||||
})
|
||||
children.append(branch_tree)
|
||||
|
||||
if len(options) < 2:
|
||||
raise ValueError("Need at least 2 branches with detail to assemble a tree")
|
||||
|
||||
# Determine root question based on flow type
|
||||
if flow_type == "troubleshooting":
|
||||
root_question = f"What is the user experiencing? Select the symptom that best matches their report."
|
||||
root_help = "Choose the option that most closely describes the user's reported problem."
|
||||
else:
|
||||
root_question = f"Which phase of {name} are you working on?"
|
||||
root_help = None
|
||||
|
||||
tree_structure = {
|
||||
"id": "root",
|
||||
"type": "decision",
|
||||
"question": root_question,
|
||||
**({"help_text": root_help} if root_help else {}),
|
||||
"options": options,
|
||||
"children": children,
|
||||
}
|
||||
|
||||
stats = count_tree_stats(tree_structure)
|
||||
|
||||
return tree_structure, name, description, stats
|
||||
217
backend/app/core/ai_tree_validator.py
Normal file
217
backend/app/core/ai_tree_validator.py
Normal file
@@ -0,0 +1,217 @@
|
||||
"""Validation for AI-generated tree structures.
|
||||
|
||||
Ensures generated trees conform to ResolutionFlow's node schema
|
||||
before they are saved to the database.
|
||||
"""
|
||||
from typing import Any
|
||||
|
||||
|
||||
VALID_NODE_TYPES = {"decision", "action", "solution"}
|
||||
|
||||
# Required fields per node type
|
||||
REQUIRED_FIELDS = {
|
||||
"decision": {"id", "type", "question", "options", "children"},
|
||||
"action": {"id", "type", "title", "description"},
|
||||
"solution": {"id", "type", "title", "description"},
|
||||
}
|
||||
|
||||
|
||||
class TreeValidationError(Exception):
|
||||
"""Raised when a generated tree fails validation."""
|
||||
|
||||
def __init__(self, errors: list[str]):
|
||||
self.errors = errors
|
||||
super().__init__(f"Tree validation failed: {'; '.join(errors)}")
|
||||
|
||||
|
||||
def validate_generated_tree(tree: dict[str, Any]) -> list[str]:
|
||||
"""Validate an AI-generated tree structure.
|
||||
|
||||
Returns a list of error strings. Empty list means valid.
|
||||
"""
|
||||
errors: list[str] = []
|
||||
|
||||
if not isinstance(tree, dict):
|
||||
return ["Tree must be a JSON object"]
|
||||
|
||||
# Root must be a decision node
|
||||
if tree.get("type") != "decision":
|
||||
errors.append("Root node must be type 'decision'")
|
||||
|
||||
# Collect all node IDs and validate structure
|
||||
all_ids: set[str] = set()
|
||||
all_referenced_ids: set[str] = set()
|
||||
node_count = 0
|
||||
solution_count = 0
|
||||
|
||||
def _validate_node(node: dict[str, Any], path: str) -> None:
|
||||
nonlocal node_count, solution_count
|
||||
|
||||
if not isinstance(node, dict):
|
||||
errors.append(f"Node at {path} is not an object")
|
||||
return
|
||||
|
||||
node_count += 1
|
||||
node_type = node.get("type")
|
||||
node_id = node.get("id")
|
||||
|
||||
# Check node ID
|
||||
if not node_id:
|
||||
errors.append(f"Node at {path} missing 'id'")
|
||||
elif node_id in all_ids:
|
||||
errors.append(f"Duplicate node ID: '{node_id}'")
|
||||
else:
|
||||
all_ids.add(node_id)
|
||||
|
||||
# Check node type
|
||||
if node_type not in VALID_NODE_TYPES:
|
||||
errors.append(
|
||||
f"Node '{node_id or path}' has invalid type '{node_type}'. "
|
||||
f"Must be one of: {', '.join(sorted(VALID_NODE_TYPES))}"
|
||||
)
|
||||
return
|
||||
|
||||
# Check required fields
|
||||
required = REQUIRED_FIELDS[node_type]
|
||||
missing = required - set(node.keys())
|
||||
if missing:
|
||||
errors.append(
|
||||
f"Node '{node_id}' (type={node_type}) missing fields: {', '.join(sorted(missing))}"
|
||||
)
|
||||
|
||||
# Type-specific validation
|
||||
if node_type == "decision":
|
||||
options = node.get("options", [])
|
||||
if not isinstance(options, list) or len(options) < 2:
|
||||
errors.append(
|
||||
f"Decision node '{node_id}' must have at least 2 options"
|
||||
)
|
||||
else:
|
||||
children = node.get("children", [])
|
||||
child_ids = {c.get("id") for c in children if isinstance(c, dict)}
|
||||
option_ids: set[str] = set()
|
||||
|
||||
for opt in options:
|
||||
if not isinstance(opt, dict):
|
||||
errors.append(f"Option in node '{node_id}' is not an object")
|
||||
continue
|
||||
opt_id = opt.get("id")
|
||||
if opt_id and opt_id in option_ids:
|
||||
errors.append(
|
||||
f"Duplicate option ID '{opt_id}' in node '{node_id}'"
|
||||
)
|
||||
if opt_id:
|
||||
option_ids.add(opt_id)
|
||||
|
||||
next_id = opt.get("next_node_id")
|
||||
if next_id:
|
||||
all_referenced_ids.add(next_id)
|
||||
if child_ids and next_id not in child_ids:
|
||||
errors.append(
|
||||
f"Option '{opt.get('label', '?')}' in node '{node_id}' "
|
||||
f"references non-existent child '{next_id}'"
|
||||
)
|
||||
|
||||
elif node_type == "action":
|
||||
next_id = node.get("next_node_id")
|
||||
if not next_id:
|
||||
errors.append(
|
||||
f"Action node '{node_id}' is missing 'next_node_id'. "
|
||||
"Every action node must point to the next node (a sibling in the parent's children)."
|
||||
)
|
||||
else:
|
||||
all_referenced_ids.add(next_id)
|
||||
|
||||
elif node_type == "solution":
|
||||
solution_count += 1
|
||||
|
||||
# Recurse into children
|
||||
for i, child in enumerate(node.get("children", [])):
|
||||
_validate_node(child, f"{path}.children[{i}]")
|
||||
|
||||
_validate_node(tree, "root")
|
||||
|
||||
# Global checks
|
||||
if node_count < 5:
|
||||
errors.append(
|
||||
f"Tree has only {node_count} nodes. Minimum 5 required for a useful tree."
|
||||
)
|
||||
if node_count > 50:
|
||||
errors.append(
|
||||
f"Tree has {node_count} nodes. Maximum 50 allowed."
|
||||
)
|
||||
if solution_count < 2:
|
||||
errors.append(
|
||||
f"Tree has only {solution_count} solution nodes. "
|
||||
"Need at least 2 to cover different resolution paths."
|
||||
)
|
||||
|
||||
# Check that all leaf (non-solution) nodes have children or are solutions
|
||||
_check_branch_termination(tree, errors)
|
||||
|
||||
return errors
|
||||
|
||||
|
||||
def _check_branch_termination(node: dict[str, Any], errors: list[str]) -> None:
|
||||
"""Verify every branch eventually reaches a solution node.
|
||||
|
||||
Action nodes continue via next_node_id (validated separately).
|
||||
Only decision nodes with no children are dead ends.
|
||||
"""
|
||||
if not isinstance(node, dict):
|
||||
return
|
||||
|
||||
node_type = node.get("type")
|
||||
node_id = node.get("id", "?")
|
||||
children = node.get("children", [])
|
||||
|
||||
if node_type == "solution":
|
||||
return # Solution is a valid terminus
|
||||
|
||||
if node_type == "action":
|
||||
# Action nodes continue via next_node_id (a sibling), not children.
|
||||
# next_node_id presence is validated in _validate_node.
|
||||
# Recurse into children if present (non-standard but tolerate it).
|
||||
for child in children:
|
||||
_check_branch_termination(child, errors)
|
||||
return
|
||||
|
||||
# Decision node: must have children
|
||||
if not children:
|
||||
errors.append(
|
||||
f"Decision node '{node_id}' is a dead end — "
|
||||
"it has no children"
|
||||
)
|
||||
return
|
||||
|
||||
for child in children:
|
||||
_check_branch_termination(child, errors)
|
||||
|
||||
|
||||
def count_tree_stats(tree: dict[str, Any]) -> dict[str, int]:
|
||||
"""Count node types and calculate depth of a tree."""
|
||||
stats = {
|
||||
"node_count": 0,
|
||||
"decision_count": 0,
|
||||
"action_count": 0,
|
||||
"solution_count": 0,
|
||||
"depth": 0,
|
||||
}
|
||||
|
||||
def _count(node: dict[str, Any], depth: int) -> None:
|
||||
if not isinstance(node, dict):
|
||||
return
|
||||
stats["node_count"] += 1
|
||||
node_type = node.get("type", "")
|
||||
if node_type == "decision":
|
||||
stats["decision_count"] += 1
|
||||
elif node_type == "action":
|
||||
stats["action_count"] += 1
|
||||
elif node_type == "solution":
|
||||
stats["solution_count"] += 1
|
||||
stats["depth"] = max(stats["depth"], depth)
|
||||
for child in node.get("children", []):
|
||||
_count(child, depth + 1)
|
||||
|
||||
_count(tree, 1)
|
||||
return stats
|
||||
@@ -72,6 +72,18 @@ class Settings(BaseSettings):
|
||||
"""Check if Stripe is configured."""
|
||||
return self.STRIPE_SECRET_KEY is not None and self.STRIPE_WEBHOOK_SECRET is not None
|
||||
|
||||
# AI Flow Builder
|
||||
ANTHROPIC_API_KEY: Optional[str] = None
|
||||
AI_MODEL: str = "claude-haiku-4-5-20251001"
|
||||
AI_CONVERSATION_TTL_HOURS: int = 24
|
||||
AI_MAX_CALLS_PER_FLOW: int = 10
|
||||
AI_REQUEST_TIMEOUT_SECONDS: int = 45
|
||||
|
||||
@property
|
||||
def ai_enabled(self) -> bool:
|
||||
"""Check if AI Flow Builder is configured."""
|
||||
return self.ANTHROPIC_API_KEY is not None
|
||||
|
||||
# Deployment – auto-seed test data on PR environments
|
||||
SEED_ON_DEPLOY: bool = False
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""APScheduler integration for maintenance flow auto-session creation."""
|
||||
"""APScheduler integration for maintenance flow auto-session creation and AI cleanup."""
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
@@ -7,8 +7,9 @@ from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||
from apscheduler.schedulers.base import SchedulerNotRunningError
|
||||
from apscheduler.jobstores.base import JobLookupError
|
||||
from apscheduler.triggers.cron import CronTrigger
|
||||
from apscheduler.triggers.interval import IntervalTrigger
|
||||
import pytz
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import select, delete
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -114,6 +115,27 @@ async def _fire_maintenance_schedule(schedule_id: str) -> None:
|
||||
await db.rollback()
|
||||
|
||||
|
||||
async def _cleanup_expired_ai_conversations() -> None:
|
||||
"""Delete expired AI wizard conversations."""
|
||||
import app.models # noqa: F401
|
||||
from app.core.database import async_session_maker
|
||||
from app.models.ai_conversation import AIConversation
|
||||
|
||||
async with async_session_maker() as db:
|
||||
try:
|
||||
result = await db.execute(
|
||||
delete(AIConversation).where(
|
||||
AIConversation.expires_at < datetime.now(timezone.utc)
|
||||
)
|
||||
)
|
||||
if result.rowcount > 0:
|
||||
logger.info(f"Cleaned up {result.rowcount} expired AI conversation(s)")
|
||||
await db.commit()
|
||||
except Exception:
|
||||
logger.exception("Error cleaning up expired AI conversations")
|
||||
await db.rollback()
|
||||
|
||||
|
||||
async def load_all_schedules(db: AsyncSession) -> None:
|
||||
"""Load all active schedules into APScheduler on startup."""
|
||||
# Import all models to ensure SQLAlchemy mapper relationships resolve
|
||||
|
||||
@@ -28,7 +28,7 @@ def convert_session_to_tree(
|
||||
return {
|
||||
"id": str(uuid.uuid4()),
|
||||
"type": "solution",
|
||||
"solution": "Session had no recorded path",
|
||||
"title": "Session had no recorded path",
|
||||
"children": []
|
||||
}
|
||||
|
||||
@@ -63,7 +63,7 @@ def convert_session_to_tree(
|
||||
new_node = {
|
||||
"id": node_id,
|
||||
"type": "action",
|
||||
"action": f"Step from original tree (node {node_id})",
|
||||
"title": f"Step from original tree (node {node_id})",
|
||||
"children": []
|
||||
}
|
||||
|
||||
@@ -130,15 +130,15 @@ def _create_node_from_original(
|
||||
if decision and decision.get("answer"):
|
||||
new_node["question"] += f"\n\nAnswer: {decision['answer']}"
|
||||
elif node_type == "action":
|
||||
new_node["action"] = original_node.get("action", "")
|
||||
new_node["title"] = original_node.get("title", original_node.get("action", ""))
|
||||
if decision and decision.get("action_performed"):
|
||||
new_node["action"] = decision["action_performed"]
|
||||
new_node["title"] = decision["action_performed"]
|
||||
if decision and decision.get("command_output"):
|
||||
output = decision["command_output"].strip()
|
||||
if output:
|
||||
new_node["action"] += f"\n\nCommand Output:\n{output}"
|
||||
new_node["title"] += f"\n\nCommand Output:\n{output}"
|
||||
elif node_type == "solution":
|
||||
new_node["solution"] = original_node.get("solution", "")
|
||||
new_node["title"] = original_node.get("title", original_node.get("solution", ""))
|
||||
|
||||
return new_node
|
||||
|
||||
@@ -169,18 +169,18 @@ def _create_node_from_custom_step(
|
||||
if step_type == "decision":
|
||||
new_node["question"] = content
|
||||
elif step_type == "action":
|
||||
new_node["action"] = content
|
||||
new_node["title"] = content
|
||||
elif step_type == "solution":
|
||||
new_node["solution"] = content
|
||||
new_node["title"] = content
|
||||
|
||||
# Add notes if present
|
||||
if custom_step.get("notes"):
|
||||
if step_type == "decision":
|
||||
new_node["question"] += f"\n\nNotes: {custom_step['notes']}"
|
||||
elif step_type == "action":
|
||||
new_node["action"] += f"\n\nNotes: {custom_step['notes']}"
|
||||
new_node["title"] += f"\n\nNotes: {custom_step['notes']}"
|
||||
elif step_type == "solution":
|
||||
new_node["solution"] += f"\n\nNotes: {custom_step['notes']}"
|
||||
new_node["title"] += f"\n\nNotes: {custom_step['notes']}"
|
||||
|
||||
return new_node
|
||||
|
||||
|
||||
@@ -83,17 +83,17 @@ def _validate_node(node: dict[str, Any], path: str, errors: list[dict[str, str]]
|
||||
})
|
||||
|
||||
elif node_type == "action":
|
||||
if "action" not in node or not node["action"]:
|
||||
if "title" not in node or not node["title"]:
|
||||
errors.append({
|
||||
"field": f"{path}.action",
|
||||
"message": "Action nodes must have a non-empty action"
|
||||
"field": f"{path}.title",
|
||||
"message": "Action nodes must have a non-empty title"
|
||||
})
|
||||
|
||||
elif node_type == "solution":
|
||||
if "solution" not in node or not node["solution"]:
|
||||
if "title" not in node or not node["title"]:
|
||||
errors.append({
|
||||
"field": f"{path}.solution",
|
||||
"message": "Solution nodes must have a non-empty solution"
|
||||
"field": f"{path}.title",
|
||||
"message": "Solution nodes must have a non-empty title"
|
||||
})
|
||||
|
||||
elif node_type == "answer":
|
||||
|
||||
@@ -13,7 +13,7 @@ from app.core.logging_config import setup_logging
|
||||
from app.core.middleware import RequestLoggingMiddleware, ErrorLoggingMiddleware
|
||||
from app.core.rate_limit import limiter
|
||||
from app.api.router import api_router
|
||||
from app.core.scheduler import scheduler, load_all_schedules
|
||||
from app.core.scheduler import scheduler, load_all_schedules, _cleanup_expired_ai_conversations
|
||||
|
||||
# Initialize logging configuration
|
||||
setup_logging()
|
||||
@@ -103,10 +103,17 @@ async def lifespan(app: FastAPI):
|
||||
# Note: In production, use Alembic migrations instead of init_db
|
||||
# await init_db()
|
||||
|
||||
# Start maintenance schedule runner
|
||||
# Start maintenance schedule runner + AI conversation cleanup
|
||||
scheduler.start()
|
||||
async with async_session_maker() as db:
|
||||
await load_all_schedules(db)
|
||||
scheduler.add_job(
|
||||
_cleanup_expired_ai_conversations,
|
||||
trigger="interval",
|
||||
hours=1,
|
||||
id="cleanup_ai_conversations",
|
||||
replace_existing=True,
|
||||
)
|
||||
|
||||
# Auto-seed trees in background on PR environments
|
||||
seed_task = None
|
||||
|
||||
@@ -26,6 +26,8 @@ from .user_pinned_tree import UserPinnedTree
|
||||
from .target_list import TargetList
|
||||
from .maintenance_schedule import MaintenanceSchedule
|
||||
from .feedback import Feedback
|
||||
from .ai_conversation import AIConversation
|
||||
from .ai_usage import AIUsage
|
||||
|
||||
__all__ = [
|
||||
"User",
|
||||
@@ -63,4 +65,6 @@ __all__ = [
|
||||
"TargetList",
|
||||
"MaintenanceSchedule",
|
||||
"Feedback",
|
||||
"AIConversation",
|
||||
"AIUsage",
|
||||
]
|
||||
|
||||
@@ -24,6 +24,8 @@ class AccountLimitOverride(Base):
|
||||
override_max_trees: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
|
||||
override_max_sessions_per_month: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
|
||||
override_max_users: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
|
||||
override_max_ai_builds_per_month: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
|
||||
override_max_ai_builds_per_24h: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
|
||||
note: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||
created_by_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
|
||||
67
backend/app/models/ai_conversation.py
Normal file
67
backend/app/models/ai_conversation.py
Normal file
@@ -0,0 +1,67 @@
|
||||
"""AI Flow Builder conversation tracking.
|
||||
|
||||
Stores wizard session state across the 4-stage flow builder process.
|
||||
Conversations expire after 24 hours and are cleaned up by the scheduler.
|
||||
"""
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional, Any
|
||||
|
||||
from sqlalchemy import String, DateTime, ForeignKey, Integer, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from sqlalchemy.dialects.postgresql import UUID, JSONB
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class AIConversation(Base):
|
||||
__tablename__ = "ai_conversations"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||
)
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("users.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
account_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("accounts.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
status: Mapped[str] = mapped_column(
|
||||
String(20),
|
||||
nullable=False,
|
||||
default="foundation",
|
||||
comment="foundation | scaffolding | detailing | reviewing | completed | expired",
|
||||
)
|
||||
# Conversation history across all wizard stages
|
||||
messages: Mapped[list[dict[str, Any]]] = mapped_column(
|
||||
JSONB, nullable=False, default=list
|
||||
)
|
||||
# Wizard state: Stage 1 metadata, Stage 2 branches, Stage 3 detail
|
||||
wizard_state: Mapped[dict[str, Any]] = mapped_column(
|
||||
JSONB, nullable=False, default=dict
|
||||
)
|
||||
# Assembled tree from Stage 4 (null until assembly)
|
||||
generated_tree: Mapped[Optional[dict[str, Any]]] = mapped_column(
|
||||
JSONB, nullable=True
|
||||
)
|
||||
# Tracks AI call count for per-flow limits
|
||||
question_rounds: Mapped[int] = mapped_column(
|
||||
Integer, nullable=False, default=0
|
||||
)
|
||||
expires_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
default=lambda: datetime.now(timezone.utc),
|
||||
onupdate=lambda: datetime.now(timezone.utc),
|
||||
)
|
||||
69
backend/app/models/ai_usage.py
Normal file
69
backend/app/models/ai_usage.py
Normal file
@@ -0,0 +1,69 @@
|
||||
"""AI usage tracking for quota enforcement and cost visibility.
|
||||
|
||||
Every AI API call is recorded here. Only rows with counts_toward_quota=True
|
||||
and succeeded=True are counted against the user's monthly quota.
|
||||
"""
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional, Any
|
||||
|
||||
from sqlalchemy import String, DateTime, ForeignKey, Integer, Boolean, Numeric
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from sqlalchemy.dialects.postgresql import UUID, JSONB
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class AIUsage(Base):
|
||||
__tablename__ = "ai_usage"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||
)
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("users.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
account_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("accounts.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
conversation_id: Mapped[Optional[uuid.UUID]] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("ai_conversations.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
)
|
||||
generation_type: Mapped[str] = mapped_column(
|
||||
String(20),
|
||||
nullable=False,
|
||||
comment="scaffold | branch_detail | branch_suggest",
|
||||
)
|
||||
tier_at_time: Mapped[str] = mapped_column(
|
||||
String(20),
|
||||
nullable=False,
|
||||
comment="free | pro | team | enterprise",
|
||||
)
|
||||
input_tokens: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
output_tokens: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
estimated_cost_usd: Mapped[float] = mapped_column(
|
||||
Numeric(10, 6), nullable=False, default=0
|
||||
)
|
||||
succeeded: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||
counts_toward_quota: Mapped[bool] = mapped_column(
|
||||
Boolean, nullable=False, default=False
|
||||
)
|
||||
error_code: Mapped[Optional[str]] = mapped_column(
|
||||
String(100), nullable=True
|
||||
)
|
||||
extra_data: Mapped[dict[str, Any]] = mapped_column(
|
||||
"metadata", JSONB, nullable=False, default=dict
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
default=lambda: datetime.now(timezone.utc),
|
||||
index=True,
|
||||
)
|
||||
@@ -14,3 +14,7 @@ class PlanLimits(Base):
|
||||
custom_branding: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
priority_support: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
export_formats: Mapped[list] = mapped_column(JSONB, nullable=False, default=lambda: ["markdown", "text"])
|
||||
|
||||
# AI Flow Builder limits
|
||||
max_ai_builds_per_month: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
max_ai_builds_per_24h: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
|
||||
@@ -67,6 +67,11 @@ class User(Base):
|
||||
)
|
||||
last_login: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
# AI billing cycle anchor (for quota reset calculation)
|
||||
ai_billing_cycle_anchor_at: Mapped[Optional[datetime]] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
|
||||
# Soft delete
|
||||
deleted_at: Mapped[Optional[datetime]] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
|
||||
@@ -5,6 +5,11 @@ from .session import SessionCreate, SessionUpdate, SessionResponse, SessionExpor
|
||||
from .category import CategoryCreate, CategoryUpdate, CategoryResponse, CategoryListResponse
|
||||
from .tag import TagCreate, TagResponse, TagListResponse, TagAssignment
|
||||
from .folder import FolderCreate, FolderUpdate, FolderResponse, FolderListResponse, FolderReorderRequest, FolderTreeRequest
|
||||
from .ai_builder import (
|
||||
AIStartRequest, AIScaffoldRequest, AIBranchDetailRequest, AIAssembleRequest,
|
||||
AIStartResponse, AIScaffoldResponse, AIBranchDetailResponse, AIAssembleResponse,
|
||||
AIQuotaStatusResponse,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
# User
|
||||
@@ -21,4 +26,8 @@ __all__ = [
|
||||
"TagCreate", "TagResponse", "TagListResponse", "TagAssignment",
|
||||
# Folder
|
||||
"FolderCreate", "FolderUpdate", "FolderResponse", "FolderListResponse", "FolderReorderRequest", "FolderTreeRequest",
|
||||
# AI Builder
|
||||
"AIStartRequest", "AIScaffoldRequest", "AIBranchDetailRequest", "AIAssembleRequest",
|
||||
"AIStartResponse", "AIScaffoldResponse", "AIBranchDetailResponse", "AIAssembleResponse",
|
||||
"AIQuotaStatusResponse",
|
||||
]
|
||||
|
||||
126
backend/app/schemas/ai_builder.py
Normal file
126
backend/app/schemas/ai_builder.py
Normal file
@@ -0,0 +1,126 @@
|
||||
"""Pydantic schemas for the AI Flow Builder wizard."""
|
||||
from typing import Any, Literal, Optional
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
|
||||
# ── Requests ──
|
||||
|
||||
|
||||
class AIStartRequest(BaseModel):
|
||||
"""Stage 1: Foundation — engineer provides flow metadata."""
|
||||
|
||||
flow_type: Literal["troubleshooting", "procedural"] = Field(
|
||||
..., description="Type of flow to generate"
|
||||
)
|
||||
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, 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):
|
||||
"""Stage 2: Request AI-generated branch suggestions."""
|
||||
|
||||
conversation_id: UUID
|
||||
|
||||
|
||||
class AIBranchDetailRequest(BaseModel):
|
||||
"""Stage 3: Request AI-generated detail for one branch."""
|
||||
|
||||
conversation_id: UUID
|
||||
branch_name: str = Field(..., min_length=1, max_length=255)
|
||||
|
||||
|
||||
class AIBranchUpdate(BaseModel):
|
||||
"""A branch with optional user edits for assembly."""
|
||||
|
||||
name: str
|
||||
description: str = ""
|
||||
steps: Optional[dict[str, Any]] = None
|
||||
|
||||
|
||||
class AIAssembleRequest(BaseModel):
|
||||
"""Stage 4: Assemble selected branches into a complete tree."""
|
||||
|
||||
conversation_id: UUID
|
||||
selected_branches: list[AIBranchUpdate] = Field(..., min_length=2)
|
||||
|
||||
|
||||
# ── Responses ──
|
||||
|
||||
|
||||
class AIStartResponse(BaseModel):
|
||||
"""Response after creating a conversation."""
|
||||
|
||||
conversation_id: UUID
|
||||
status: str
|
||||
|
||||
|
||||
class AIBranchSuggestion(BaseModel):
|
||||
"""A single branch suggestion from the AI."""
|
||||
|
||||
name: str
|
||||
description: str
|
||||
|
||||
|
||||
class AIScaffoldResponse(BaseModel):
|
||||
"""Response with AI-suggested branches."""
|
||||
|
||||
conversation_id: UUID
|
||||
branches: list[AIBranchSuggestion]
|
||||
status: str
|
||||
|
||||
|
||||
class AIBranchDetailResponse(BaseModel):
|
||||
"""Response with AI-generated detail for one branch."""
|
||||
|
||||
conversation_id: UUID
|
||||
branch_name: str
|
||||
steps: dict[str, Any]
|
||||
status: str
|
||||
|
||||
|
||||
class AITreeSummary(BaseModel):
|
||||
"""Summary statistics for an assembled tree."""
|
||||
|
||||
node_count: int
|
||||
decision_count: int
|
||||
action_count: int
|
||||
solution_count: int
|
||||
depth: int
|
||||
|
||||
|
||||
class AIAssembleResponse(BaseModel):
|
||||
"""Response with the fully assembled tree."""
|
||||
|
||||
tree_structure: dict[str, Any]
|
||||
suggested_name: str
|
||||
suggested_description: str
|
||||
summary: AITreeSummary
|
||||
status: str
|
||||
|
||||
|
||||
class AIQuotaStatusResponse(BaseModel):
|
||||
"""Current user's AI quota status."""
|
||||
|
||||
plan: str
|
||||
monthly_used: int
|
||||
monthly_limit: Optional[int]
|
||||
monthly_reset_at: str
|
||||
daily_used: int
|
||||
daily_limit: Optional[int]
|
||||
daily_reset_at: str
|
||||
allowed: bool
|
||||
ai_enabled: bool
|
||||
@@ -4,7 +4,7 @@
|
||||
# Testing
|
||||
pytest==7.4.3
|
||||
pytest-asyncio==0.23.0
|
||||
httpx==0.26.0
|
||||
httpx>=0.27.0
|
||||
pytest-cov==4.1.0
|
||||
|
||||
# Code quality
|
||||
|
||||
@@ -31,6 +31,9 @@ resend==2.21.0
|
||||
# HTTP client (seed scripts, internal API calls)
|
||||
httpx>=0.27.0
|
||||
|
||||
# AI Flow Builder
|
||||
anthropic>=0.40.0
|
||||
|
||||
# Utilities
|
||||
python-dotenv==1.0.1
|
||||
croniter>=2.0.0
|
||||
|
||||
358
backend/tests/test_ai_endpoints.py
Normal file
358
backend/tests/test_ai_endpoints.py
Normal file
@@ -0,0 +1,358 @@
|
||||
"""Integration tests for AI Flow Builder endpoints.
|
||||
|
||||
All Anthropic API calls are mocked — zero real API spend.
|
||||
"""
|
||||
import json
|
||||
from unittest.mock import AsyncMock, patch, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
|
||||
# ── Sample AI responses ──
|
||||
|
||||
SCAFFOLD_RESPONSE_JSON = json.dumps({
|
||||
"branches": [
|
||||
{"name": "Service Not Running", "description": "The target service is stopped or crashed."},
|
||||
{"name": "Authentication Failures", "description": "Users cannot authenticate against the service."},
|
||||
{"name": "Network Connectivity", "description": "Network-level issues preventing access."},
|
||||
{"name": "Configuration Errors", "description": "Misconfiguration of the service or its dependencies."},
|
||||
]
|
||||
})
|
||||
|
||||
BRANCH_DETAIL_JSON = json.dumps({
|
||||
"id": "svc-root",
|
||||
"type": "decision",
|
||||
"question": "Is the service running?",
|
||||
"options": [
|
||||
{"id": "opt-yes", "label": "Yes", "next_node_id": "svc-check-logs"},
|
||||
{"id": "opt-no", "label": "No", "next_node_id": "svc-restart"},
|
||||
],
|
||||
"children": [
|
||||
{
|
||||
"id": "svc-check-logs",
|
||||
"type": "action",
|
||||
"title": "Check Event Logs",
|
||||
"description": "Check Windows Event Viewer for errors.",
|
||||
"commands": ["Get-EventLog -LogName Application -Newest 20"],
|
||||
"next_node_id": "svc-logs-resolved",
|
||||
},
|
||||
{
|
||||
"id": "svc-logs-resolved",
|
||||
"type": "solution",
|
||||
"title": "Issue Found in Logs",
|
||||
"description": "Error identified and resolved.",
|
||||
"resolution_steps": ["Fix the error", "Restart service"],
|
||||
},
|
||||
{
|
||||
"id": "svc-restart",
|
||||
"type": "action",
|
||||
"title": "Restart Service",
|
||||
"description": "Attempt to restart the service.",
|
||||
"commands": ["Restart-Service -Name 'TestService'"],
|
||||
"next_node_id": "svc-restart-ok",
|
||||
},
|
||||
{
|
||||
"id": "svc-restart-ok",
|
||||
"type": "solution",
|
||||
"title": "Service Restored",
|
||||
"description": "Service is running after restart.",
|
||||
"resolution_steps": ["Verify connectivity", "Document in ticket"],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
|
||||
def _mock_anthropic_response(text: str, input_tokens: int = 100, output_tokens: int = 200):
|
||||
"""Create a mock Anthropic API response."""
|
||||
response = MagicMock()
|
||||
response.content = [MagicMock(text=text)]
|
||||
response.usage = MagicMock(input_tokens=input_tokens, output_tokens=output_tokens)
|
||||
return response
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def enable_ai():
|
||||
"""Temporarily enable AI by setting a fake API key."""
|
||||
original = settings.ANTHROPIC_API_KEY
|
||||
settings.ANTHROPIC_API_KEY = "test-key-fake"
|
||||
yield
|
||||
settings.ANTHROPIC_API_KEY = original
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def disable_ai():
|
||||
"""Ensure AI is disabled."""
|
||||
original = settings.ANTHROPIC_API_KEY
|
||||
settings.ANTHROPIC_API_KEY = None
|
||||
yield
|
||||
settings.ANTHROPIC_API_KEY = original
|
||||
|
||||
|
||||
# ── Quota endpoint ──
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_quota_returns_disabled_when_no_key(client, auth_headers, disable_ai):
|
||||
"""GET /ai/quota returns ai_enabled=false when no API key."""
|
||||
response = await client.get("/api/v1/ai/quota", headers=auth_headers)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["ai_enabled"] is False
|
||||
assert data["allowed"] is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_quota_returns_enabled_with_key(client, auth_headers, enable_ai):
|
||||
"""GET /ai/quota returns ai_enabled=true with API key configured."""
|
||||
response = await client.get("/api/v1/ai/quota", headers=auth_headers)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["ai_enabled"] is True
|
||||
assert data["allowed"] is True
|
||||
|
||||
|
||||
# ── Start endpoint ──
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_requires_auth(client, enable_ai):
|
||||
"""POST /ai/start requires authentication."""
|
||||
response = await client.post("/api/v1/ai/start", json={
|
||||
"flow_type": "troubleshooting",
|
||||
"name": "Test Flow",
|
||||
"description": "Test",
|
||||
})
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_returns_503_when_disabled(client, auth_headers, disable_ai):
|
||||
"""POST /ai/start returns 503 when AI is not configured."""
|
||||
response = await client.post(
|
||||
"/api/v1/ai/start",
|
||||
json={
|
||||
"flow_type": "troubleshooting",
|
||||
"name": "Test Flow",
|
||||
"description": "Test description",
|
||||
},
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert response.status_code == 503
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_creates_conversation(client, auth_headers, enable_ai):
|
||||
"""POST /ai/start creates a conversation and returns conversation_id."""
|
||||
response = await client.post(
|
||||
"/api/v1/ai/start",
|
||||
json={
|
||||
"flow_type": "troubleshooting",
|
||||
"name": "DNS Issues",
|
||||
"description": "Troubleshooting DNS resolution failures",
|
||||
"environment_tags": ["Windows Server", "Active Directory"],
|
||||
},
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert response.status_code == 201
|
||||
data = response.json()
|
||||
assert "conversation_id" in data
|
||||
assert data["status"] == "foundation"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_validates_input(client, auth_headers, enable_ai):
|
||||
"""POST /ai/start rejects invalid input."""
|
||||
response = await client.post(
|
||||
"/api/v1/ai/start",
|
||||
json={
|
||||
"flow_type": "troubleshooting",
|
||||
"name": "", # Empty name
|
||||
"description": "Test",
|
||||
},
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
# ── Scaffold endpoint ──
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_scaffold_success(client, auth_headers, enable_ai):
|
||||
"""POST /ai/scaffold returns AI-generated branches."""
|
||||
# Create conversation first
|
||||
start_resp = await client.post(
|
||||
"/api/v1/ai/start",
|
||||
json={
|
||||
"flow_type": "troubleshooting",
|
||||
"name": "DNS Issues",
|
||||
"description": "DNS resolution failures",
|
||||
},
|
||||
headers=auth_headers,
|
||||
)
|
||||
conversation_id = start_resp.json()["conversation_id"]
|
||||
|
||||
# Mock Anthropic
|
||||
mock_response = _mock_anthropic_response(SCAFFOLD_RESPONSE_JSON)
|
||||
with patch("app.core.ai_tree_generator_service._get_client") as mock_client:
|
||||
mock_client.return_value.messages.create = AsyncMock(return_value=mock_response)
|
||||
|
||||
response = await client.post(
|
||||
"/api/v1/ai/scaffold",
|
||||
json={"conversation_id": conversation_id},
|
||||
headers=auth_headers,
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["status"] == "scaffolding"
|
||||
assert len(data["branches"]) == 4
|
||||
assert data["branches"][0]["name"] == "Service Not Running"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_scaffold_invalid_conversation(client, auth_headers, enable_ai):
|
||||
"""POST /ai/scaffold returns 404 for nonexistent conversation."""
|
||||
response = await client.post(
|
||||
"/api/v1/ai/scaffold",
|
||||
json={"conversation_id": "00000000-0000-0000-0000-000000000000"},
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
# ── Branch detail endpoint ──
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_branch_detail_success(client, auth_headers, enable_ai):
|
||||
"""POST /ai/branch-detail returns AI-generated branch nodes."""
|
||||
# Create and scaffold first
|
||||
start_resp = await client.post(
|
||||
"/api/v1/ai/start",
|
||||
json={
|
||||
"flow_type": "troubleshooting",
|
||||
"name": "Service Issues",
|
||||
"description": "Service troubleshooting",
|
||||
},
|
||||
headers=auth_headers,
|
||||
)
|
||||
conversation_id = start_resp.json()["conversation_id"]
|
||||
|
||||
scaffold_mock = _mock_anthropic_response(SCAFFOLD_RESPONSE_JSON)
|
||||
with patch("app.core.ai_tree_generator_service._get_client") as mock_client:
|
||||
mock_client.return_value.messages.create = AsyncMock(return_value=scaffold_mock)
|
||||
await client.post(
|
||||
"/api/v1/ai/scaffold",
|
||||
json={"conversation_id": conversation_id},
|
||||
headers=auth_headers,
|
||||
)
|
||||
|
||||
# Now generate branch detail
|
||||
detail_mock = _mock_anthropic_response(BRANCH_DETAIL_JSON)
|
||||
with patch("app.core.ai_tree_generator_service._get_client") as mock_client:
|
||||
mock_client.return_value.messages.create = AsyncMock(return_value=detail_mock)
|
||||
|
||||
response = await client.post(
|
||||
"/api/v1/ai/branch-detail",
|
||||
json={
|
||||
"conversation_id": conversation_id,
|
||||
"branch_name": "Service Not Running",
|
||||
},
|
||||
headers=auth_headers,
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["branch_name"] == "Service Not Running"
|
||||
assert data["steps"]["id"] == "svc-root"
|
||||
assert data["steps"]["type"] == "decision"
|
||||
|
||||
|
||||
# ── Assemble endpoint ──
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_assemble_success(client, auth_headers, enable_ai):
|
||||
"""POST /ai/assemble returns assembled tree from branches with detail."""
|
||||
# Create conversation
|
||||
start_resp = await client.post(
|
||||
"/api/v1/ai/start",
|
||||
json={
|
||||
"flow_type": "troubleshooting",
|
||||
"name": "Service Issues",
|
||||
"description": "Service troubleshooting",
|
||||
},
|
||||
headers=auth_headers,
|
||||
)
|
||||
conversation_id = start_resp.json()["conversation_id"]
|
||||
|
||||
# Scaffold
|
||||
scaffold_mock = _mock_anthropic_response(SCAFFOLD_RESPONSE_JSON)
|
||||
with patch("app.core.ai_tree_generator_service._get_client") as mock_client:
|
||||
mock_client.return_value.messages.create = AsyncMock(return_value=scaffold_mock)
|
||||
await client.post(
|
||||
"/api/v1/ai/scaffold",
|
||||
json={"conversation_id": conversation_id},
|
||||
headers=auth_headers,
|
||||
)
|
||||
|
||||
# Assemble with branch detail included
|
||||
branch_tree = json.loads(BRANCH_DETAIL_JSON)
|
||||
response = await client.post(
|
||||
"/api/v1/ai/assemble",
|
||||
json={
|
||||
"conversation_id": conversation_id,
|
||||
"selected_branches": [
|
||||
{
|
||||
"name": "Service Not Running",
|
||||
"description": "The target service is stopped.",
|
||||
"steps": branch_tree,
|
||||
},
|
||||
{
|
||||
"name": "Authentication Failures",
|
||||
"description": "Users cannot authenticate.",
|
||||
"steps": branch_tree,
|
||||
},
|
||||
],
|
||||
},
|
||||
headers=auth_headers,
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["status"] == "completed"
|
||||
assert data["suggested_name"] == "Service Issues"
|
||||
assert "tree_structure" in data
|
||||
assert data["tree_structure"]["type"] == "decision"
|
||||
assert data["summary"]["node_count"] > 0
|
||||
assert data["summary"]["solution_count"] >= 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_assemble_requires_min_2_branches(client, auth_headers, enable_ai):
|
||||
"""POST /ai/assemble rejects fewer than 2 branches."""
|
||||
start_resp = await client.post(
|
||||
"/api/v1/ai/start",
|
||||
json={
|
||||
"flow_type": "troubleshooting",
|
||||
"name": "Test",
|
||||
"description": "Test",
|
||||
},
|
||||
headers=auth_headers,
|
||||
)
|
||||
conversation_id = start_resp.json()["conversation_id"]
|
||||
|
||||
response = await client.post(
|
||||
"/api/v1/ai/assemble",
|
||||
json={
|
||||
"conversation_id": conversation_id,
|
||||
"selected_branches": [
|
||||
{"name": "Only Branch", "description": "Just one"},
|
||||
],
|
||||
},
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert response.status_code == 422
|
||||
192
backend/tests/test_ai_tree_validator.py
Normal file
192
backend/tests/test_ai_tree_validator.py
Normal file
@@ -0,0 +1,192 @@
|
||||
"""Tests for AI-generated tree structure validation."""
|
||||
import pytest
|
||||
|
||||
from app.core.ai_tree_validator import validate_generated_tree, count_tree_stats
|
||||
|
||||
|
||||
def _make_valid_tree():
|
||||
"""Helper: minimal valid tree for testing.
|
||||
|
||||
Action nodes use next_node_id to point to a sibling (not children).
|
||||
The solution following an action is a sibling under the parent decision.
|
||||
"""
|
||||
return {
|
||||
"id": "root",
|
||||
"type": "decision",
|
||||
"question": "Is the service running?",
|
||||
"options": [
|
||||
{"id": "opt-yes", "label": "Yes", "next_node_id": "check-logs"},
|
||||
{"id": "opt-no", "label": "No", "next_node_id": "restart-service"},
|
||||
],
|
||||
"children": [
|
||||
{
|
||||
"id": "check-logs",
|
||||
"type": "decision",
|
||||
"question": "Are there errors in the logs?",
|
||||
"options": [
|
||||
{"id": "opt-errors", "label": "Yes", "next_node_id": "fix-errors"},
|
||||
{"id": "opt-clean", "label": "No", "next_node_id": "escalate"},
|
||||
],
|
||||
"children": [
|
||||
{
|
||||
"id": "fix-errors",
|
||||
"type": "solution",
|
||||
"title": "Fix Errors",
|
||||
"description": "Apply the fix for the errors found.",
|
||||
},
|
||||
{
|
||||
"id": "escalate",
|
||||
"type": "solution",
|
||||
"title": "Escalate",
|
||||
"description": "No errors found; escalate to Tier 2.",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": "restart-service",
|
||||
"type": "action",
|
||||
"title": "Restart the Service",
|
||||
"description": "Restart the service and verify.",
|
||||
"commands": ["Restart-Service -Name 'TestService'"],
|
||||
"next_node_id": "service-resolved",
|
||||
},
|
||||
{
|
||||
"id": "service-resolved",
|
||||
"type": "solution",
|
||||
"title": "Service Restored",
|
||||
"description": "Service is running after restart.",
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
class TestValidTree:
|
||||
def test_valid_tree_passes(self):
|
||||
errors = validate_generated_tree(_make_valid_tree())
|
||||
assert errors == []
|
||||
|
||||
def test_not_a_dict(self):
|
||||
errors = validate_generated_tree("not a dict")
|
||||
assert any("must be a JSON object" in e for e in errors)
|
||||
|
||||
def test_root_not_decision(self):
|
||||
tree = _make_valid_tree()
|
||||
tree["type"] = "action"
|
||||
tree["title"] = "Fake"
|
||||
errors = validate_generated_tree(tree)
|
||||
assert any("Root node must be type 'decision'" in e for e in errors)
|
||||
|
||||
|
||||
class TestNodeValidation:
|
||||
def test_missing_id(self):
|
||||
tree = _make_valid_tree()
|
||||
del tree["children"][0]["id"]
|
||||
errors = validate_generated_tree(tree)
|
||||
assert any("missing 'id'" in e for e in errors)
|
||||
|
||||
def test_duplicate_ids(self):
|
||||
tree = _make_valid_tree()
|
||||
tree["children"][1]["id"] = "check-logs" # same as sibling
|
||||
errors = validate_generated_tree(tree)
|
||||
assert any("Duplicate node ID" in e for e in errors)
|
||||
|
||||
def test_invalid_node_type(self):
|
||||
tree = _make_valid_tree()
|
||||
tree["children"][0]["type"] = "unknown"
|
||||
errors = validate_generated_tree(tree)
|
||||
assert any("invalid type" in e for e in errors)
|
||||
|
||||
def test_decision_missing_options(self):
|
||||
tree = _make_valid_tree()
|
||||
del tree["children"][0]["options"]
|
||||
errors = validate_generated_tree(tree)
|
||||
assert any("missing fields" in e for e in errors)
|
||||
|
||||
def test_decision_less_than_2_options(self):
|
||||
tree = _make_valid_tree()
|
||||
tree["children"][0]["options"] = [
|
||||
{"id": "opt-1", "label": "Only", "next_node_id": "fix-errors"}
|
||||
]
|
||||
errors = validate_generated_tree(tree)
|
||||
assert any("at least 2 options" in e for e in errors)
|
||||
|
||||
def test_action_missing_next_node_id(self):
|
||||
tree = _make_valid_tree()
|
||||
del tree["children"][1]["next_node_id"]
|
||||
errors = validate_generated_tree(tree)
|
||||
assert any("missing 'next_node_id'" in e for e in errors)
|
||||
|
||||
|
||||
class TestReferenceIntegrity:
|
||||
def test_option_references_nonexistent_child(self):
|
||||
tree = _make_valid_tree()
|
||||
tree["options"][0]["next_node_id"] = "nonexistent"
|
||||
errors = validate_generated_tree(tree)
|
||||
assert any("non-existent child" in e for e in errors)
|
||||
|
||||
def test_duplicate_option_ids(self):
|
||||
tree = _make_valid_tree()
|
||||
tree["options"][0]["id"] = "same"
|
||||
tree["options"][1]["id"] = "same"
|
||||
errors = validate_generated_tree(tree)
|
||||
assert any("Duplicate option ID" in e for e in errors)
|
||||
|
||||
|
||||
class TestGlobalChecks:
|
||||
def test_too_few_nodes(self):
|
||||
tree = {
|
||||
"id": "root",
|
||||
"type": "decision",
|
||||
"question": "Test?",
|
||||
"options": [
|
||||
{"id": "o1", "label": "A", "next_node_id": "s1"},
|
||||
{"id": "o2", "label": "B", "next_node_id": "s2"},
|
||||
],
|
||||
"children": [
|
||||
{"id": "s1", "type": "solution", "title": "S1", "description": "D1"},
|
||||
{"id": "s2", "type": "solution", "title": "S2", "description": "D2"},
|
||||
],
|
||||
}
|
||||
errors = validate_generated_tree(tree)
|
||||
assert any("Minimum 5 required" in e for e in errors)
|
||||
|
||||
def test_too_few_solutions(self):
|
||||
tree = _make_valid_tree()
|
||||
# Remove all solutions except one — replace children of check-logs
|
||||
tree["children"][0]["children"] = [
|
||||
{
|
||||
"id": "only-solution",
|
||||
"type": "solution",
|
||||
"title": "Only",
|
||||
"description": "Only solution",
|
||||
}
|
||||
]
|
||||
tree["children"][0]["options"] = [
|
||||
{"id": "o1", "label": "A", "next_node_id": "only-solution"},
|
||||
{"id": "o2", "label": "B", "next_node_id": "only-solution"},
|
||||
]
|
||||
# Remove the solution that restart-service points to
|
||||
tree["children"].pop(2) # remove service-resolved
|
||||
errors = validate_generated_tree(tree)
|
||||
assert any("solution" in e.lower() for e in errors)
|
||||
|
||||
|
||||
class TestDeadEndDetection:
|
||||
def test_dead_end_decision_node(self):
|
||||
"""A decision node with no children is a dead end."""
|
||||
tree = _make_valid_tree()
|
||||
# Remove children from check-logs decision node — becomes dead end
|
||||
tree["children"][0]["children"] = []
|
||||
errors = validate_generated_tree(tree)
|
||||
assert any("dead end" in e for e in errors)
|
||||
|
||||
|
||||
class TestCountTreeStats:
|
||||
def test_stats_correct(self):
|
||||
tree = _make_valid_tree()
|
||||
stats = count_tree_stats(tree)
|
||||
assert stats["node_count"] == 6
|
||||
assert stats["decision_count"] == 2
|
||||
assert stats["action_count"] == 1
|
||||
assert stats["solution_count"] == 3
|
||||
assert stats["depth"] >= 3
|
||||
@@ -20,13 +20,13 @@ class TestTreeValidation:
|
||||
{
|
||||
"id": "yes",
|
||||
"type": "solution",
|
||||
"solution": "Server is healthy",
|
||||
"title": "Server is healthy",
|
||||
"children": []
|
||||
},
|
||||
{
|
||||
"id": "no",
|
||||
"type": "action",
|
||||
"action": "Restart the server",
|
||||
"title": "Restart the server",
|
||||
"children": []
|
||||
}
|
||||
]
|
||||
@@ -70,15 +70,15 @@ class TestTreeValidation:
|
||||
"type": "decision",
|
||||
"question": "Test?",
|
||||
"children": [
|
||||
{"id": "child1", "type": "solution", "solution": "Fix"}
|
||||
{"id": "child1", "type": "solution", "title": "Fix"}
|
||||
]
|
||||
}
|
||||
is_valid, errors = validate_tree_structure(tree_structure)
|
||||
assert not is_valid
|
||||
assert any("at least 2" in error["message"] for error in errors)
|
||||
|
||||
def test_action_node_missing_action(self):
|
||||
"""Test validation when action node has no action."""
|
||||
def test_action_node_missing_title(self):
|
||||
"""Test validation when action node has no title."""
|
||||
tree_structure = {
|
||||
"id": "root",
|
||||
"type": "action",
|
||||
@@ -86,10 +86,10 @@ class TestTreeValidation:
|
||||
}
|
||||
is_valid, errors = validate_tree_structure(tree_structure)
|
||||
assert not is_valid
|
||||
assert any("action" in error["field"] for error in errors)
|
||||
assert any("title" in error["field"] for error in errors)
|
||||
|
||||
def test_solution_node_missing_solution(self):
|
||||
"""Test validation when solution node has no solution."""
|
||||
def test_solution_node_missing_title(self):
|
||||
"""Test validation when solution node has no title."""
|
||||
tree_structure = {
|
||||
"id": "root",
|
||||
"type": "solution",
|
||||
@@ -97,7 +97,7 @@ class TestTreeValidation:
|
||||
}
|
||||
is_valid, errors = validate_tree_structure(tree_structure)
|
||||
assert not is_valid
|
||||
assert any("solution" in error["field"] for error in errors)
|
||||
assert any("title" in error["field"] for error in errors)
|
||||
|
||||
def test_unknown_node_type(self):
|
||||
"""Test validation with unknown node type."""
|
||||
@@ -112,14 +112,14 @@ class TestTreeValidation:
|
||||
|
||||
def test_can_publish_with_empty_name(self):
|
||||
"""Test can_publish with empty name."""
|
||||
tree_structure = {"id": "root", "type": "solution", "solution": "Fix"}
|
||||
tree_structure = {"id": "root", "type": "solution", "title": "Fix"}
|
||||
can_publish, errors = can_publish_tree(tree_structure, "", None)
|
||||
assert not can_publish
|
||||
assert any("name" in error["field"] for error in errors)
|
||||
|
||||
def test_can_publish_valid_tree(self):
|
||||
"""Test can_publish with valid tree and name."""
|
||||
tree_structure = {"id": "root", "type": "solution", "solution": "Fix"}
|
||||
tree_structure = {"id": "root", "type": "solution", "title": "Fix"}
|
||||
can_publish, errors = can_publish_tree(tree_structure, "Valid Tree", "Description")
|
||||
assert can_publish
|
||||
assert len(errors) == 0
|
||||
@@ -157,8 +157,8 @@ class TestDraftTreesAPI:
|
||||
"type": "decision",
|
||||
"question": "Is it working?",
|
||||
"children": [
|
||||
{"id": "yes", "type": "solution", "solution": "Great!"},
|
||||
{"id": "no", "type": "action", "action": "Fix it"}
|
||||
{"id": "yes", "type": "solution", "title": "Great!"},
|
||||
{"id": "no", "type": "action", "title": "Fix it"}
|
||||
]
|
||||
},
|
||||
"status": "published"
|
||||
@@ -193,8 +193,8 @@ class TestDraftTreesAPI:
|
||||
name="Draft to Published",
|
||||
description="Test tree",
|
||||
tree_structure={"id": "root", "type": "decision", "question": "Test?", "children": [
|
||||
{"id": "yes", "type": "solution", "solution": "Yes"},
|
||||
{"id": "no", "type": "solution", "solution": "No"}
|
||||
{"id": "yes", "type": "solution", "title": "Yes"},
|
||||
{"id": "no", "type": "solution", "title": "No"}
|
||||
]},
|
||||
author_id=UUID(test_user["user_data"]["id"]),
|
||||
account_id=UUID(test_user["user_data"]["account_id"]),
|
||||
@@ -252,8 +252,8 @@ class TestDraftTreesAPI:
|
||||
"type": "decision",
|
||||
"question": "Is it working?",
|
||||
"children": [
|
||||
{"id": "yes", "type": "solution", "solution": "Great!"},
|
||||
{"id": "no", "type": "action", "action": "Fix it"}
|
||||
{"id": "yes", "type": "solution", "title": "Great!"},
|
||||
{"id": "no", "type": "action", "title": "Fix it"}
|
||||
]
|
||||
},
|
||||
author_id=UUID(test_user["user_data"]["id"]),
|
||||
@@ -315,7 +315,7 @@ class TestDraftTreesAPI:
|
||||
tree = Tree(
|
||||
name="Test Tree",
|
||||
description="Test",
|
||||
tree_structure={"id": "root", "type": "solution", "solution": "Fix"},
|
||||
tree_structure={"id": "root", "type": "solution", "title": "Fix"},
|
||||
author_id=UUID(test_user["user_data"]["id"]),
|
||||
account_id=UUID(test_user["user_data"]["account_id"]),
|
||||
status='published'
|
||||
@@ -337,7 +337,7 @@ class TestDraftTreesAPI:
|
||||
tree = Tree(
|
||||
name="Legacy Tree",
|
||||
description="Created before status field",
|
||||
tree_structure={"id": "root", "type": "solution", "solution": "Fix"},
|
||||
tree_structure={"id": "root", "type": "solution", "title": "Fix"},
|
||||
author_id=None,
|
||||
account_id=None
|
||||
)
|
||||
|
||||
@@ -228,8 +228,8 @@ class TestCanPublishTreeDispatch:
|
||||
"type": "decision",
|
||||
"question": "Test?",
|
||||
"children": [
|
||||
{"id": "y", "type": "solution", "solution": "Yes"},
|
||||
{"id": "n", "type": "solution", "solution": "No"},
|
||||
{"id": "y", "type": "solution", "title": "Yes"},
|
||||
{"id": "n", "type": "solution", "title": "No"},
|
||||
]
|
||||
}
|
||||
can, errors = can_publish_tree(structure, "My Tree", tree_type="troubleshooting")
|
||||
|
||||
@@ -20,7 +20,7 @@ class TestSessionToTreeConversion:
|
||||
"""Test converting a session with no path."""
|
||||
tree_structure = convert_session_to_tree([], {}, [], [])
|
||||
assert tree_structure["type"] == "solution"
|
||||
assert "no recorded path" in tree_structure["solution"].lower()
|
||||
assert "no recorded path" in tree_structure["title"].lower()
|
||||
|
||||
def test_convert_simple_linear_path(self):
|
||||
"""Test converting a simple linear path."""
|
||||
@@ -29,8 +29,8 @@ class TestSessionToTreeConversion:
|
||||
"type": "decision",
|
||||
"question": "Is it working?",
|
||||
"children": [
|
||||
{"id": "yes", "type": "solution", "solution": "Great!"},
|
||||
{"id": "no", "type": "action", "action": "Fix it"}
|
||||
{"id": "yes", "type": "solution", "title": "Great!"},
|
||||
{"id": "no", "type": "action", "title": "Fix it"}
|
||||
]
|
||||
}
|
||||
path_taken = ["root", "no"]
|
||||
@@ -51,7 +51,7 @@ class TestSessionToTreeConversion:
|
||||
tree_snapshot = {
|
||||
"id": "root",
|
||||
"type": "solution",
|
||||
"solution": "Done"
|
||||
"title": "Done"
|
||||
}
|
||||
custom_step_id = "custom-123"
|
||||
path_taken = ["root", custom_step_id]
|
||||
@@ -70,7 +70,7 @@ class TestSessionToTreeConversion:
|
||||
assert len(result["children"]) == 1
|
||||
custom_node = result["children"][0]
|
||||
assert custom_node["type"] == "action"
|
||||
assert "Custom troubleshooting step" in custom_node["action"]
|
||||
assert "Custom troubleshooting step" in custom_node["title"]
|
||||
|
||||
def test_find_node_in_tree(self):
|
||||
"""Test finding a node in nested tree structure."""
|
||||
@@ -142,7 +142,7 @@ class TestSaveSessionAsTreeAPI:
|
||||
tree = Tree(
|
||||
name="Test Tree",
|
||||
description="Test",
|
||||
tree_structure={"id": "root", "type": "solution", "solution": "Fix"},
|
||||
tree_structure={"id": "root", "type": "solution", "title": "Fix"},
|
||||
author_id=UUID(test_user["user_data"]["id"]),
|
||||
account_id=UUID(test_user["user_data"]["account_id"]),
|
||||
status='published'
|
||||
@@ -187,7 +187,7 @@ class TestSaveSessionAsTreeAPI:
|
||||
|
||||
tree = Tree(
|
||||
name="Original Tree",
|
||||
tree_structure={"id": "root", "type": "solution", "solution": "Fix"},
|
||||
tree_structure={"id": "root", "type": "solution", "title": "Fix"},
|
||||
author_id=UUID(test_user["user_data"]["id"]),
|
||||
account_id=UUID(test_user["user_data"]["account_id"]),
|
||||
status='published'
|
||||
@@ -227,7 +227,7 @@ class TestSaveSessionAsTreeAPI:
|
||||
# Create a simple tree with just a solution (will convert to valid linear tree)
|
||||
tree = Tree(
|
||||
name="Test Tree",
|
||||
tree_structure={"id": "root", "type": "solution", "solution": "Fixed"},
|
||||
tree_structure={"id": "root", "type": "solution", "title": "Fixed"},
|
||||
author_id=UUID(test_user["user_data"]["id"]),
|
||||
account_id=UUID(test_user["user_data"]["account_id"]),
|
||||
status='published'
|
||||
@@ -267,7 +267,7 @@ class TestSaveSessionAsTreeAPI:
|
||||
|
||||
tree = Tree(
|
||||
name="Original Tree",
|
||||
tree_structure={"id": "root", "type": "solution", "solution": "Fix"},
|
||||
tree_structure={"id": "root", "type": "solution", "title": "Fix"},
|
||||
author_id=UUID(test_user["user_data"]["id"]),
|
||||
account_id=UUID(test_user["user_data"]["account_id"]),
|
||||
status='published'
|
||||
@@ -326,7 +326,7 @@ class TestSaveSessionAsTreeAPI:
|
||||
# Create a tree
|
||||
tree = Tree(
|
||||
name="Test Tree",
|
||||
tree_structure={"id": "root", "type": "solution", "solution": "Fix"},
|
||||
tree_structure={"id": "root", "type": "solution", "title": "Fix"},
|
||||
author_id=UUID(test_user["user_data"]["id"]),
|
||||
account_id=UUID(test_user["user_data"]["account_id"]),
|
||||
status='published'
|
||||
|
||||
@@ -15,7 +15,7 @@ class TestValidateTreeStructure:
|
||||
|
||||
def test_valid_solution_tree(self):
|
||||
valid, errors = validate_tree_structure({
|
||||
"id": "root", "type": "solution", "solution": "Done"
|
||||
"id": "root", "type": "solution", "title": "Done"
|
||||
})
|
||||
assert valid
|
||||
assert errors == []
|
||||
@@ -26,8 +26,8 @@ class TestValidateTreeStructure:
|
||||
"type": "decision",
|
||||
"question": "Is it on?",
|
||||
"children": [
|
||||
{"id": "yes", "type": "solution", "solution": "Great"},
|
||||
{"id": "no", "type": "action", "action": "Turn it on"},
|
||||
{"id": "yes", "type": "solution", "title": "Great"},
|
||||
{"id": "no", "type": "action", "title": "Turn it on"},
|
||||
],
|
||||
})
|
||||
assert valid
|
||||
@@ -39,7 +39,7 @@ class TestValidateTreeStructure:
|
||||
assert any("empty" in e["message"].lower() for e in errors)
|
||||
|
||||
def test_missing_id_on_root(self):
|
||||
valid, errors = validate_tree_structure({"type": "solution", "solution": "X"})
|
||||
valid, errors = validate_tree_structure({"type": "solution", "title": "X"})
|
||||
assert not valid
|
||||
assert any("id" in e["field"] for e in errors)
|
||||
|
||||
@@ -67,7 +67,7 @@ class TestValidateTreeStructure:
|
||||
"type": "decision",
|
||||
"question": "Q?",
|
||||
"children": [
|
||||
{"id": "only", "type": "solution", "solution": "S"},
|
||||
{"id": "only", "type": "solution", "title": "S"},
|
||||
],
|
||||
})
|
||||
assert not valid
|
||||
@@ -80,29 +80,29 @@ class TestValidateTreeStructure:
|
||||
})
|
||||
assert valid
|
||||
|
||||
def test_action_missing_action_field(self):
|
||||
def test_action_missing_title_field(self):
|
||||
valid, errors = validate_tree_structure({
|
||||
"id": "root", "type": "action"
|
||||
})
|
||||
assert not valid
|
||||
assert any("action" in e["message"].lower() for e in errors)
|
||||
assert any("title" in e["message"].lower() for e in errors)
|
||||
|
||||
def test_action_with_empty_action(self):
|
||||
def test_action_with_empty_title(self):
|
||||
valid, errors = validate_tree_structure({
|
||||
"id": "root", "type": "action", "action": ""
|
||||
"id": "root", "type": "action", "title": ""
|
||||
})
|
||||
assert not valid
|
||||
|
||||
def test_solution_missing_solution_field(self):
|
||||
def test_solution_missing_title_field(self):
|
||||
valid, errors = validate_tree_structure({
|
||||
"id": "root", "type": "solution"
|
||||
})
|
||||
assert not valid
|
||||
assert any("solution" in e["message"].lower() for e in errors)
|
||||
assert any("title" in e["message"].lower() for e in errors)
|
||||
|
||||
def test_solution_with_empty_solution(self):
|
||||
def test_solution_with_empty_title(self):
|
||||
valid, errors = validate_tree_structure({
|
||||
"id": "root", "type": "solution", "solution": ""
|
||||
"id": "root", "type": "solution", "title": ""
|
||||
})
|
||||
assert not valid
|
||||
|
||||
@@ -119,8 +119,8 @@ class TestValidateTreeStructure:
|
||||
"type": "decision",
|
||||
"question": "Q?",
|
||||
"children": [
|
||||
{"type": "solution", "solution": "S1"},
|
||||
{"id": "c2", "type": "solution", "solution": "S2"},
|
||||
{"type": "solution", "title": "S1"},
|
||||
{"id": "c2", "type": "solution", "title": "S2"},
|
||||
],
|
||||
})
|
||||
assert not valid
|
||||
@@ -133,7 +133,7 @@ class TestValidateTreeStructure:
|
||||
"question": "Q?",
|
||||
"children": [
|
||||
{"id": "c1"},
|
||||
{"id": "c2", "type": "solution", "solution": "S2"},
|
||||
{"id": "c2", "type": "solution", "title": "S2"},
|
||||
],
|
||||
})
|
||||
assert not valid
|
||||
@@ -151,11 +151,11 @@ class TestValidateTreeStructure:
|
||||
"type": "decision",
|
||||
"question": "Level 2?",
|
||||
"children": [
|
||||
{"id": "l3a", "type": "solution", "solution": "Deep"},
|
||||
{"id": "l3b", "type": "solution"}, # Missing solution
|
||||
{"id": "l3a", "type": "solution", "title": "Deep"},
|
||||
{"id": "l3b", "type": "solution"}, # Missing title
|
||||
],
|
||||
},
|
||||
{"id": "l2b", "type": "solution", "solution": "Shallow"},
|
||||
{"id": "l2b", "type": "solution", "title": "Shallow"},
|
||||
],
|
||||
})
|
||||
assert not valid
|
||||
@@ -167,8 +167,8 @@ class TestValidateTreeStructure:
|
||||
"type": "decision",
|
||||
"question": "Q?",
|
||||
"children": [
|
||||
{"id": "c1", "type": "solution"}, # missing solution
|
||||
{"id": "c2", "type": "action"}, # missing action
|
||||
{"id": "c1", "type": "solution"}, # missing title
|
||||
{"id": "c2", "type": "action"}, # missing title
|
||||
],
|
||||
})
|
||||
assert not valid
|
||||
@@ -179,7 +179,7 @@ class TestCanPublishTree:
|
||||
|
||||
def test_valid_tree_can_publish(self):
|
||||
can, errors = can_publish_tree(
|
||||
{"id": "root", "type": "solution", "solution": "Done"},
|
||||
{"id": "root", "type": "solution", "title": "Done"},
|
||||
"My Tree"
|
||||
)
|
||||
assert can
|
||||
@@ -187,7 +187,7 @@ class TestCanPublishTree:
|
||||
|
||||
def test_empty_name_cannot_publish(self):
|
||||
can, errors = can_publish_tree(
|
||||
{"id": "root", "type": "solution", "solution": "Done"},
|
||||
{"id": "root", "type": "solution", "title": "Done"},
|
||||
""
|
||||
)
|
||||
assert not can
|
||||
@@ -195,14 +195,14 @@ class TestCanPublishTree:
|
||||
|
||||
def test_whitespace_name_cannot_publish(self):
|
||||
can, errors = can_publish_tree(
|
||||
{"id": "root", "type": "solution", "solution": "Done"},
|
||||
{"id": "root", "type": "solution", "title": "Done"},
|
||||
" "
|
||||
)
|
||||
assert not can
|
||||
|
||||
def test_none_name_cannot_publish(self):
|
||||
can, errors = can_publish_tree(
|
||||
{"id": "root", "type": "solution", "solution": "Done"},
|
||||
{"id": "root", "type": "solution", "title": "Done"},
|
||||
None
|
||||
)
|
||||
assert not can
|
||||
|
||||
745
docs/plans/2026-02-20-dashboard-implementation-plan.md
Normal file
745
docs/plans/2026-02-20-dashboard-implementation-plan.md
Normal file
@@ -0,0 +1,745 @@
|
||||
# Dashboard: My Flows, Favorites/Pin UI, and AI Builder — Implementation Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Transform the dashboard into a structured personal workspace with a Favorites section (pinned flows), paginated "My Flows", shared pin store, and AI Builder access from the Library page.
|
||||
|
||||
**Architecture:** Zustand store (`pinnedFlowsStore`) becomes single source of truth for pin state across Sidebar, Dashboard, and Library. URL-synced pagination hook manages My Flows paging. Cached quota hook avoids redundant AI quota fetches. All three library view components gain optional pin button props.
|
||||
|
||||
**Tech Stack:** React 19, Zustand, React Router v7, Tailwind CSS, Lucide icons, Axios
|
||||
|
||||
**Design doc:** `docs/plans/2026-02-20-final-dashboard-plan.md`
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Add `pin()` to `pinnedFlowsApi`
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/api/pinnedFlows.ts`
|
||||
|
||||
**Step 1: Add `pin` method**
|
||||
|
||||
In `frontend/src/api/pinnedFlows.ts`, add after the `unpin` method:
|
||||
|
||||
```typescript
|
||||
pin: async (treeId: string): Promise<void> => {
|
||||
await apiClient.post(`/trees/${treeId}/pin`)
|
||||
},
|
||||
```
|
||||
|
||||
**Step 2: Verify build**
|
||||
|
||||
Run: `cd /c/Dev/Projects/patherly/frontend && npx tsc --noEmit`
|
||||
Expected: No errors
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/api/pinnedFlows.ts
|
||||
git commit -m "feat: add pin() method to pinnedFlowsApi"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Create `pinnedFlowsStore`
|
||||
|
||||
**Files:**
|
||||
- Create: `frontend/src/store/pinnedFlowsStore.ts`
|
||||
|
||||
**Step 1: Create the store**
|
||||
|
||||
Create `frontend/src/store/pinnedFlowsStore.ts`:
|
||||
|
||||
```typescript
|
||||
import { create } from 'zustand'
|
||||
import { pinnedFlowsApi } from '@/api/pinnedFlows'
|
||||
import type { PinnedFlow } from '@/api/pinnedFlows'
|
||||
import { toast } from '@/lib/toast'
|
||||
|
||||
interface PinnedFlowsState {
|
||||
items: PinnedFlow[]
|
||||
isLoaded: boolean
|
||||
isLoading: boolean
|
||||
isMutatingByTreeId: Record<string, boolean>
|
||||
error: string | null
|
||||
|
||||
// Actions
|
||||
load: (force?: boolean) => Promise<void>
|
||||
pin: (treeId: string) => Promise<void>
|
||||
unpin: (treeId: string) => Promise<void>
|
||||
toggle: (treeId: string) => void
|
||||
|
||||
// Derived helpers
|
||||
isPinned: (treeId: string) => boolean
|
||||
}
|
||||
|
||||
export const usePinnedFlowsStore = create<PinnedFlowsState>()((set, get) => ({
|
||||
items: [],
|
||||
isLoaded: false,
|
||||
isLoading: false,
|
||||
isMutatingByTreeId: {},
|
||||
error: null,
|
||||
|
||||
load: async (force = false) => {
|
||||
const state = get()
|
||||
if (state.isLoaded && !force) return
|
||||
if (state.isLoading) return
|
||||
|
||||
set({ isLoading: true, error: null })
|
||||
try {
|
||||
const data = await pinnedFlowsApi.list()
|
||||
set({ items: data.items, isLoaded: true, isLoading: false })
|
||||
} catch {
|
||||
set({ error: 'Failed to load pinned flows', isLoading: false })
|
||||
}
|
||||
},
|
||||
|
||||
pin: async (treeId: string) => {
|
||||
const state = get()
|
||||
if (state.isMutatingByTreeId[treeId]) return
|
||||
|
||||
set({ isMutatingByTreeId: { ...state.isMutatingByTreeId, [treeId]: true } })
|
||||
|
||||
try {
|
||||
await pinnedFlowsApi.pin(treeId)
|
||||
// Reload to get full PinnedFlow object with tree_name, tree_type, etc.
|
||||
const data = await pinnedFlowsApi.list()
|
||||
set((s) => ({
|
||||
items: data.items,
|
||||
isMutatingByTreeId: { ...s.isMutatingByTreeId, [treeId]: false },
|
||||
}))
|
||||
} catch (err: unknown) {
|
||||
const status = (err as { response?: { status?: number } })?.response?.status
|
||||
if (status === 409) {
|
||||
toast.error('Maximum of 15 favorites reached. Unpin a flow to add a new one.')
|
||||
} else {
|
||||
toast.error('Failed to pin flow')
|
||||
}
|
||||
set((s) => ({
|
||||
isMutatingByTreeId: { ...s.isMutatingByTreeId, [treeId]: false },
|
||||
}))
|
||||
}
|
||||
},
|
||||
|
||||
unpin: async (treeId: string) => {
|
||||
const state = get()
|
||||
if (state.isMutatingByTreeId[treeId]) return
|
||||
|
||||
// Optimistic remove
|
||||
const prevItems = state.items
|
||||
set({
|
||||
items: state.items.filter((f) => f.tree_id !== treeId),
|
||||
isMutatingByTreeId: { ...state.isMutatingByTreeId, [treeId]: true },
|
||||
})
|
||||
|
||||
try {
|
||||
await pinnedFlowsApi.unpin(treeId)
|
||||
set((s) => ({
|
||||
isMutatingByTreeId: { ...s.isMutatingByTreeId, [treeId]: false },
|
||||
}))
|
||||
} catch {
|
||||
// Rollback
|
||||
toast.error('Failed to unpin flow')
|
||||
set((s) => ({
|
||||
items: prevItems,
|
||||
isMutatingByTreeId: { ...s.isMutatingByTreeId, [treeId]: false },
|
||||
}))
|
||||
}
|
||||
},
|
||||
|
||||
toggle: (treeId: string) => {
|
||||
const state = get()
|
||||
if (state.isPinned(treeId)) {
|
||||
state.unpin(treeId)
|
||||
} else {
|
||||
state.pin(treeId)
|
||||
}
|
||||
},
|
||||
|
||||
isPinned: (treeId: string) => {
|
||||
return get().items.some((f) => f.tree_id === treeId)
|
||||
},
|
||||
}))
|
||||
|
||||
// Derived selectors (use outside component or in selectors)
|
||||
export const selectPinnedTreeIds = (state: PinnedFlowsState): Set<string> =>
|
||||
new Set(state.items.map((f) => f.tree_id))
|
||||
|
||||
export const selectPinLoadingTreeIds = (state: PinnedFlowsState): Set<string> =>
|
||||
new Set(
|
||||
Object.entries(state.isMutatingByTreeId)
|
||||
.filter(([, v]) => v)
|
||||
.map(([k]) => k)
|
||||
)
|
||||
```
|
||||
|
||||
**Step 2: Verify build**
|
||||
|
||||
Run: `cd /c/Dev/Projects/patherly/frontend && npx tsc --noEmit`
|
||||
Expected: No errors
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/store/pinnedFlowsStore.ts
|
||||
git commit -m "feat: create pinnedFlowsStore — single source of truth for pin state"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Add `dashboardMyFlowsView` to `userPreferencesStore`
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/store/userPreferencesStore.ts`
|
||||
|
||||
**Step 1: Add preference**
|
||||
|
||||
In the `UserPreferencesState` interface, add:
|
||||
```typescript
|
||||
dashboardMyFlowsView: 'grid' | 'list' | 'table'
|
||||
setDashboardMyFlowsView: (view: 'grid' | 'list' | 'table') => void
|
||||
```
|
||||
|
||||
In the store implementation, add alongside existing fields:
|
||||
```typescript
|
||||
dashboardMyFlowsView: 'grid',
|
||||
setDashboardMyFlowsView: (view) => set({ dashboardMyFlowsView: view }),
|
||||
```
|
||||
|
||||
**Step 2: Verify build**
|
||||
|
||||
Run: `cd /c/Dev/Projects/patherly/frontend && npx tsc --noEmit`
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/store/userPreferencesStore.ts
|
||||
git commit -m "feat: add dashboardMyFlowsView preference (independent from Library)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Replace local pin state in Sidebar with store
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/components/layout/Sidebar.tsx`
|
||||
|
||||
**Step 1: Swap to store**
|
||||
|
||||
Remove from imports:
|
||||
- `pinnedFlowsApi` import
|
||||
- `PinnedFlow` type import
|
||||
|
||||
Add import:
|
||||
```typescript
|
||||
import { usePinnedFlowsStore } from '@/store/pinnedFlowsStore'
|
||||
```
|
||||
|
||||
Remove from component:
|
||||
- `const [pinnedFlows, setPinnedFlows] = useState<PinnedFlow[]>([])`
|
||||
- The `pinnedFlowsApi.list()` call from the `useEffect`
|
||||
- The entire `handleUnpin` function
|
||||
|
||||
Replace with:
|
||||
```typescript
|
||||
const pinnedItems = usePinnedFlowsStore((s) => s.items)
|
||||
const loadPinned = usePinnedFlowsStore((s) => s.load)
|
||||
const unpinFlow = usePinnedFlowsStore((s) => s.unpin)
|
||||
```
|
||||
|
||||
In the `useEffect` that fetches data, remove the `pinnedFlowsApi.list()` from `Promise.all`. Add a separate call:
|
||||
```typescript
|
||||
useEffect(() => { loadPinned() }, [loadPinned])
|
||||
```
|
||||
|
||||
Update `PinnedFlowsSection` props:
|
||||
```tsx
|
||||
<PinnedFlowsSection flows={pinnedItems} onUnpin={unpinFlow} />
|
||||
```
|
||||
|
||||
**Step 2: Verify build**
|
||||
|
||||
Run: `cd /c/Dev/Projects/patherly/frontend && npx tsc --noEmit`
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/components/layout/Sidebar.tsx
|
||||
git commit -m "refactor: sidebar uses pinnedFlowsStore instead of local state"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Create `usePaginationParams` hook
|
||||
|
||||
**Files:**
|
||||
- Create: `frontend/src/hooks/usePaginationParams.ts`
|
||||
|
||||
**Step 1: Create the hook**
|
||||
|
||||
```typescript
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
import { useCallback, useMemo } from 'react'
|
||||
|
||||
type PageSize = number | 'all'
|
||||
|
||||
interface UsePaginationParamsOptions {
|
||||
defaultPageSize?: number
|
||||
allowedPageSizes?: PageSize[]
|
||||
}
|
||||
|
||||
export function usePaginationParams(options: UsePaginationParamsOptions = {}) {
|
||||
const { defaultPageSize = 10, allowedPageSizes = [10, 25, 50, 'all'] } = options
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
|
||||
const page = useMemo(() => {
|
||||
const raw = searchParams.get('page')
|
||||
const n = raw ? parseInt(raw, 10) : 1
|
||||
return Number.isFinite(n) && n >= 1 ? n : 1
|
||||
}, [searchParams])
|
||||
|
||||
const pageSize = useMemo((): PageSize => {
|
||||
const raw = searchParams.get('size')
|
||||
if (raw === 'all' && allowedPageSizes.includes('all')) return 'all'
|
||||
const n = raw ? parseInt(raw, 10) : defaultPageSize
|
||||
if (Number.isFinite(n) && allowedPageSizes.includes(n)) return n
|
||||
return defaultPageSize
|
||||
}, [searchParams, defaultPageSize, allowedPageSizes])
|
||||
|
||||
const setPage = useCallback(
|
||||
(newPage: number) => {
|
||||
setSearchParams((prev) => {
|
||||
const next = new URLSearchParams(prev)
|
||||
if (newPage <= 1) {
|
||||
next.delete('page')
|
||||
} else {
|
||||
next.set('page', String(newPage))
|
||||
}
|
||||
return next
|
||||
})
|
||||
},
|
||||
[setSearchParams]
|
||||
)
|
||||
|
||||
const setPageSize = useCallback(
|
||||
(newSize: PageSize) => {
|
||||
setSearchParams((prev) => {
|
||||
const next = new URLSearchParams(prev)
|
||||
next.set('size', String(newSize))
|
||||
next.delete('page') // reset to page 1
|
||||
return next
|
||||
})
|
||||
},
|
||||
[setSearchParams]
|
||||
)
|
||||
|
||||
return { page, pageSize, setPage, setPageSize }
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Verify build**
|
||||
|
||||
Run: `cd /c/Dev/Projects/patherly/frontend && npx tsc --noEmit`
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/hooks/usePaginationParams.ts
|
||||
git commit -m "feat: create usePaginationParams hook — URL-synced pagination"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Create `useCachedQuota` hook
|
||||
|
||||
**Files:**
|
||||
- Create: `frontend/src/hooks/useCachedQuota.ts`
|
||||
|
||||
**Step 1: Create the hook**
|
||||
|
||||
```typescript
|
||||
import { useState, useEffect } from 'react'
|
||||
import { aiBuilderApi } from '@/api'
|
||||
|
||||
const CACHE_TTL_MS = 5 * 60 * 1000 // 5 minutes
|
||||
|
||||
let cachedResult: { aiEnabled: boolean; timestamp: number } | null = null
|
||||
|
||||
export function useCachedQuota() {
|
||||
const [aiEnabled, setAiEnabled] = useState(cachedResult?.aiEnabled ?? false)
|
||||
const [isLoading, setIsLoading] = useState(!cachedResult)
|
||||
|
||||
useEffect(() => {
|
||||
if (cachedResult && Date.now() - cachedResult.timestamp < CACHE_TTL_MS) {
|
||||
setAiEnabled(cachedResult.aiEnabled)
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
setIsLoading(true)
|
||||
aiBuilderApi
|
||||
.getQuota()
|
||||
.then((q) => {
|
||||
cachedResult = { aiEnabled: q.ai_enabled, timestamp: Date.now() }
|
||||
setAiEnabled(q.ai_enabled)
|
||||
})
|
||||
.catch(() => {
|
||||
// Leave as false
|
||||
})
|
||||
.finally(() => setIsLoading(false))
|
||||
}, [])
|
||||
|
||||
return { aiEnabled, isLoading }
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Verify build**
|
||||
|
||||
Run: `cd /c/Dev/Projects/patherly/frontend && npx tsc --noEmit`
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/hooks/useCachedQuota.ts
|
||||
git commit -m "feat: create useCachedQuota hook — 5-min TTL AI quota cache"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 7: Add pin button props to `TreeGridView`
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/components/library/TreeGridView.tsx`
|
||||
|
||||
**Step 1: Read current file to understand structure**
|
||||
|
||||
Read `frontend/src/components/library/TreeGridView.tsx` fully before editing.
|
||||
|
||||
**Step 2: Add optional pin props to interface**
|
||||
|
||||
Add to `TreeGridViewProps`:
|
||||
```typescript
|
||||
pinnedTreeIds?: Set<string>
|
||||
onTogglePin?: (treeId: string) => void
|
||||
pinLoadingTreeIds?: Set<string>
|
||||
```
|
||||
|
||||
**Step 3: Add star button to each card**
|
||||
|
||||
Import `Star` from `lucide-react`. In each card's top-right area, render (only when `onTogglePin` is provided):
|
||||
|
||||
```tsx
|
||||
{onTogglePin && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
e.preventDefault()
|
||||
onTogglePin(tree.id)
|
||||
}}
|
||||
disabled={pinLoadingTreeIds?.has(tree.id)}
|
||||
aria-label={pinnedTreeIds?.has(tree.id) ? 'Remove from favorites' : 'Add to favorites'}
|
||||
className={cn(
|
||||
'absolute top-3 right-3 rounded-md p-1 transition-colors',
|
||||
pinnedTreeIds?.has(tree.id)
|
||||
? 'text-amber-400 hover:text-amber-300'
|
||||
: 'text-muted-foreground/40 hover:text-amber-400',
|
||||
pinLoadingTreeIds?.has(tree.id) && 'opacity-50 pointer-events-none'
|
||||
)}
|
||||
>
|
||||
<Star size={16} fill={pinnedTreeIds?.has(tree.id) ? 'currentColor' : 'none'} />
|
||||
</button>
|
||||
)}
|
||||
```
|
||||
|
||||
**Step 4: Verify build**
|
||||
|
||||
Run: `cd /c/Dev/Projects/patherly/frontend && npx tsc --noEmit`
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/components/library/TreeGridView.tsx
|
||||
git commit -m "feat: add optional pin/favorite button to TreeGridView"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 8: Add pin button props to `TreeListView`
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/components/library/TreeListView.tsx`
|
||||
|
||||
**Step 1: Read the file, then add same optional props and star button pattern as Task 7.**
|
||||
|
||||
Place the star button at the end of each row (before the actions area). Use the exact same props interface addition and button pattern from Task 7.
|
||||
|
||||
**Step 2: Verify build**
|
||||
|
||||
Run: `cd /c/Dev/Projects/patherly/frontend && npx tsc --noEmit`
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/components/library/TreeListView.tsx
|
||||
git commit -m "feat: add optional pin/favorite button to TreeListView"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 9: Add pin button props to `TreeTableView`
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/components/library/TreeTableView.tsx`
|
||||
|
||||
**Step 1: Read the file, then add same optional props.**
|
||||
|
||||
Add a narrow "Favorite" column as the leftmost column. Header: star icon. Cell: same button pattern from Task 7. Only render the column when `onTogglePin` is provided.
|
||||
|
||||
**Step 2: Verify build**
|
||||
|
||||
Run: `cd /c/Dev/Projects/patherly/frontend && npx tsc --noEmit`
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/components/library/TreeTableView.tsx
|
||||
git commit -m "feat: add optional pin/favorite column to TreeTableView"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 10: TreeLibraryPage — Create dropdown + AI Builder + pin wiring
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/pages/TreeLibraryPage.tsx`
|
||||
|
||||
**Step 1: Add imports**
|
||||
|
||||
```typescript
|
||||
import { ChevronDown, Sparkles, FolderTree, ListOrdered, Wrench } from 'lucide-react'
|
||||
import { usePinnedFlowsStore, selectPinnedTreeIds, selectPinLoadingTreeIds } from '@/store/pinnedFlowsStore'
|
||||
import { useCachedQuota } from '@/hooks/useCachedQuota'
|
||||
import { AIFlowBuilderModal } from '@/components/ai-builder/AIFlowBuilderModal'
|
||||
```
|
||||
|
||||
**Step 2: Add state and store selectors**
|
||||
|
||||
Inside the component, add:
|
||||
```typescript
|
||||
const [showCreateMenu, setShowCreateMenu] = useState(false)
|
||||
const [showAIBuilder, setShowAIBuilder] = useState(false)
|
||||
const { aiEnabled } = useCachedQuota()
|
||||
|
||||
const pinnedTreeIds = usePinnedFlowsStore(selectPinnedTreeIds)
|
||||
const pinLoadingTreeIds = usePinnedFlowsStore(selectPinLoadingTreeIds)
|
||||
const togglePin = usePinnedFlowsStore((s) => s.toggle)
|
||||
const loadPinned = usePinnedFlowsStore((s) => s.load)
|
||||
|
||||
useEffect(() => { loadPinned() }, [loadPinned])
|
||||
```
|
||||
|
||||
**Step 3: Replace the `<Link>` create button**
|
||||
|
||||
Replace the single `<Link to={...}>` create button with the dropdown menu pattern from `MyTreesPage.tsx` (lines 134-197). Copy that exact pattern — it already has Troubleshooting Tree, Procedural Flow, Maintenance Flow, divider, and Build with AI (conditional on `aiEnabled`).
|
||||
|
||||
**Step 4: Pass pin props to view components**
|
||||
|
||||
Add to each `TreeGridView`, `TreeListView`, `TreeTableView` render:
|
||||
```tsx
|
||||
pinnedTreeIds={pinnedTreeIds}
|
||||
onTogglePin={togglePin}
|
||||
pinLoadingTreeIds={pinLoadingTreeIds}
|
||||
```
|
||||
|
||||
**Step 5: Add AI Builder modal**
|
||||
|
||||
Before the closing `</div>` of the component, add:
|
||||
```tsx
|
||||
{showAIBuilder && (
|
||||
<AIFlowBuilderModal
|
||||
isOpen={showAIBuilder}
|
||||
onClose={() => setShowAIBuilder(false)}
|
||||
/>
|
||||
)}
|
||||
```
|
||||
|
||||
**Step 6: Verify build**
|
||||
|
||||
Run: `cd /c/Dev/Projects/patherly/frontend && npx tsc --noEmit`
|
||||
|
||||
**Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/pages/TreeLibraryPage.tsx
|
||||
git commit -m "feat: Library page — create dropdown with AI Builder + pin controls on all views"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 11: QuickStartPage — Favorites section + paginated My Flows
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/pages/QuickStartPage.tsx`
|
||||
|
||||
This is the largest task. The page gets a major refactor.
|
||||
|
||||
**Step 1: Read the current file fully (already done in exploration)**
|
||||
|
||||
**Step 2: Rewrite QuickStartPage**
|
||||
|
||||
The page structure becomes:
|
||||
|
||||
1. **Page header** with "Create" dropdown (same pattern as Task 10)
|
||||
2. **QuickStats** (keep existing)
|
||||
3. **Search** (keep existing inline search)
|
||||
4. **Recent Sessions** (keep existing `SessionsPanel`)
|
||||
5. **Favorites section** (NEW — from `pinnedFlowsStore.items`)
|
||||
6. **My Flows section** (NEW — paginated, `author_id` filter, view toggle)
|
||||
|
||||
Key implementation details:
|
||||
|
||||
**Favorites section:**
|
||||
- Source: `usePinnedFlowsStore` items
|
||||
- Layout: wrapping grid, max 2 rows (~8 cards). "View all favorites" expands if > 8.
|
||||
- Each card: flow name + type emoji + unpin star button
|
||||
- Skeleton: 4 pulse placeholder cards while `isLoading`
|
||||
- Empty: "Star a flow to pin it here for quick access."
|
||||
|
||||
**My Flows section:**
|
||||
- Source: `treesApi.list({ author_id: user.id, sort_by: 'updated_at', limit: pageSize + 1, skip: (page - 1) * pageSize })`
|
||||
- Use `usePaginationParams({ defaultPageSize: 10, allowedPageSizes: [10, 25, 50, 'all'] })`
|
||||
- `hasNextPage = response.length > pageSize; displayItems = response.slice(0, pageSize)`
|
||||
- For "All": fetch in chunks of 100 up to 500 max
|
||||
- View toggle: `ViewToggle` bound to `dashboardMyFlowsView`
|
||||
- Render: `TreeGridView` / `TreeListView` / `TreeTableView` with pin props
|
||||
- Pagination controls: `Prev` / `Next` / page label / size dropdown
|
||||
- Skeleton: 6 placeholder cards/rows
|
||||
- Empty: "You haven't created any flows yet." + "Create your first flow" CTA
|
||||
|
||||
**Remove:**
|
||||
- The `FiltersBar` (All, Recently Used, My Flows, Team Flows) — replaced by the Favorites + My Flows structure
|
||||
- The `SectionGroup` "All Flows" wrapper
|
||||
- The hard-cap of 20 items
|
||||
|
||||
**Step 3: Verify build**
|
||||
|
||||
Run: `cd /c/Dev/Projects/patherly/frontend && npm run build`
|
||||
Expected: Build succeeds with no errors
|
||||
|
||||
**Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/pages/QuickStartPage.tsx
|
||||
git commit -m "feat: dashboard — Favorites grid + paginated My Flows + skeletons + empty states"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 12: PinnedFlowsSection — Dual collapse + smooth transitions
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/components/sidebar/PinnedFlowsSection.tsx`
|
||||
|
||||
**Step 1: Read the file (already done)**
|
||||
|
||||
**Step 2: Implement dual collapse**
|
||||
|
||||
Add state:
|
||||
```typescript
|
||||
const [showAll, setShowAll] = useState(false)
|
||||
const TRUNCATE_COUNT = 5
|
||||
```
|
||||
|
||||
Modify header collapse to reset truncation:
|
||||
```typescript
|
||||
const handleToggleCollapse = () => {
|
||||
if (collapsed) {
|
||||
setShowAll(false) // Reset to truncated on re-expand
|
||||
}
|
||||
setCollapsed(!collapsed)
|
||||
}
|
||||
```
|
||||
|
||||
Replace the flow list rendering:
|
||||
```typescript
|
||||
const visibleFlows = showAll ? flows : flows.slice(0, TRUNCATE_COUNT)
|
||||
const hasMore = flows.length > TRUNCATE_COUNT
|
||||
```
|
||||
|
||||
Add click handler on flow buttons that auto-collapses:
|
||||
```typescript
|
||||
onClick={() => {
|
||||
setShowAll(false) // Collapse back to 5
|
||||
navigate(getTreeNavigatePath(flow.tree_id, flow.tree_type))
|
||||
}}
|
||||
```
|
||||
|
||||
Add "Show more" / "Show less" link after the flow list:
|
||||
```tsx
|
||||
{hasMore && !collapsed && (
|
||||
<button
|
||||
onClick={() => setShowAll(!showAll)}
|
||||
className="px-3 py-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
{showAll ? 'Show less' : `Show more (${flows.length})`}
|
||||
</button>
|
||||
)}
|
||||
```
|
||||
|
||||
Add CSS transition on the list container:
|
||||
```tsx
|
||||
<div
|
||||
className="space-y-0.5 overflow-hidden transition-[max-height] duration-250 ease-out"
|
||||
style={{ maxHeight: collapsed ? 0 : showAll ? `${flows.length * 40 + 40}px` : `${TRUNCATE_COUNT * 40 + 40}px` }}
|
||||
>
|
||||
```
|
||||
|
||||
**Step 3: Verify build**
|
||||
|
||||
Run: `cd /c/Dev/Projects/patherly/frontend && npx tsc --noEmit`
|
||||
|
||||
**Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/components/sidebar/PinnedFlowsSection.tsx
|
||||
git commit -m "feat: sidebar pinned section — dual collapse + show more/less + smooth transitions"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 13: Final build validation
|
||||
|
||||
**Step 1: Full build check**
|
||||
|
||||
Run: `cd /c/Dev/Projects/patherly/frontend && npm run build`
|
||||
Expected: Build succeeds with zero errors
|
||||
|
||||
**Step 2: Fix any type errors or import issues**
|
||||
|
||||
If build fails, fix issues and amend the relevant commit.
|
||||
|
||||
**Step 3: Commit any final fixes**
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "fix: resolve build errors from dashboard implementation"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Summary of files changed
|
||||
|
||||
| # | File | Action |
|
||||
|---|------|--------|
|
||||
| 1 | `frontend/src/api/pinnedFlows.ts` | Add `pin()` method |
|
||||
| 2 | `frontend/src/store/pinnedFlowsStore.ts` | **New** — Zustand pin store |
|
||||
| 3 | `frontend/src/store/userPreferencesStore.ts` | Add `dashboardMyFlowsView` |
|
||||
| 4 | `frontend/src/components/layout/Sidebar.tsx` | Use store instead of local state |
|
||||
| 5 | `frontend/src/hooks/usePaginationParams.ts` | **New** — URL-synced pagination |
|
||||
| 6 | `frontend/src/hooks/useCachedQuota.ts` | **New** — AI quota cache |
|
||||
| 7 | `frontend/src/components/library/TreeGridView.tsx` | Optional pin button |
|
||||
| 8 | `frontend/src/components/library/TreeListView.tsx` | Optional pin button |
|
||||
| 9 | `frontend/src/components/library/TreeTableView.tsx` | Optional pin column |
|
||||
| 10 | `frontend/src/pages/TreeLibraryPage.tsx` | Create dropdown + AI Builder + pins |
|
||||
| 11 | `frontend/src/pages/QuickStartPage.tsx` | Major refactor: Favorites + My Flows |
|
||||
| 12 | `frontend/src/components/sidebar/PinnedFlowsSection.tsx` | Dual collapse |
|
||||
362
docs/plans/2026-02-20-final-dashboard-plan.md
Normal file
362
docs/plans/2026-02-20-final-dashboard-plan.md
Normal file
@@ -0,0 +1,362 @@
|
||||
# Dashboard: My Flows, Favorites/Pin UI, and AI Builder in Create Menu
|
||||
|
||||
## Final Implementation Plan (Reviewed & Merged)
|
||||
|
||||
### Decisions Locked
|
||||
|
||||
1. **"My Flows"** = trees where `author_id = currentUser.id` (includes forks, since forks set the forking user as author).
|
||||
2. **Pagination** = `Prev / Next` with page-size selector (10 / 25 / 50 / All). No numbered total pages — the `/trees` API returns no total count. Page and page size are synced to URL query params (`?page=3&size=25`).
|
||||
3. **Pin state** = shared Zustand store (`pinnedFlowsStore`), used by Sidebar, Dashboard, and Library. No local state for pins anywhere. **This store owns pin CRUD only — no other state belongs here.**
|
||||
4. **"Show: All"** = fetches in chunks of 100, capped at 500 items maximum. If ceiling is reached, show message: "Showing first 500 flows. Use search or filters to find specific flows."
|
||||
5. **Dashboard view preference** = separate key (`dashboardMyFlowsView`) from Library view preference. The two are independent.
|
||||
6. **Sidebar pinned section** = two independent collapse states: header collapse (hide/show entire section) and list truncation (5 vs. all). When header is collapsed and re-expanded, list resets to 5 items (truncated default).
|
||||
7. **Max pins** = 15, enforced by backend. Frontend handles 409 conflict with user-facing toast.
|
||||
8. **AI Builder quota** = cached with 5-minute TTL. Not re-fetched on every page load.
|
||||
9. **Favorites layout** = compact wrapping grid, max 2 rows (~8 cards visible). "View all" expand link if more than 8 pinned flows.
|
||||
10. **Loading states** = skeleton loaders for both Favorites and My Flows sections during initial fetch. Meaningful empty states with CTAs for new users.
|
||||
11. **Accessibility** = all pin/favorite buttons include `aria-label` (dynamic: "Add to favorites" / "Remove from favorites"), `e.stopPropagation()`, and `e.preventDefault()`.
|
||||
|
||||
### Known API Constraints (No Backend Changes)
|
||||
|
||||
- `GET /trees` returns `TreeListItem[]` with no total count metadata. Pagination uses `skip` + `limit` params (max `limit` is 100).
|
||||
- `POST /trees/{id}/pin`, `DELETE /trees/{id}/pin`, `GET /trees/pinned`, `PATCH /trees/pinned/reorder` all exist and work.
|
||||
- `pinnedFlowsApi` already has `list()` and `unpin()`. Needs `pin()` added.
|
||||
- Backend enforces `MAX_PINNED_FLOWS=15` and returns 409 on conflict.
|
||||
|
||||
### Files Modified
|
||||
|
||||
| File | Phase | Change |
|
||||
|------|-------|--------|
|
||||
| `frontend/src/api/pinnedFlows.ts` | 1 | Add `pin()` method |
|
||||
| `frontend/src/store/pinnedFlowsStore.ts` | 1 | **New file.** Zustand store — single source of truth for all pin state |
|
||||
| `frontend/src/store/userPreferencesStore.ts` | 1 | Add `dashboardMyFlowsView` preference + setter |
|
||||
| `frontend/src/hooks/usePaginationParams.ts` | 1 | **New file.** Custom hook — reads/writes `page` and `size` URL query params |
|
||||
| `frontend/src/hooks/useCachedQuota.ts` | 1 | **New file.** Custom hook — fetches AI quota with 5-min TTL cache |
|
||||
| `frontend/src/components/layout/Sidebar.tsx` | 1 | Replace local pinned state with `pinnedFlowsStore` selectors |
|
||||
| `frontend/src/components/library/TreeGridView.tsx` | 2 | Add optional pin props + star button with aria-label |
|
||||
| `frontend/src/components/library/TreeListView.tsx` | 2 | Add optional pin props + star button with aria-label |
|
||||
| `frontend/src/components/library/TreeTableView.tsx` | 2 | Add optional pin props + star/favorite column with aria-label |
|
||||
| `frontend/src/pages/TreeLibraryPage.tsx` | 3 | Replace Create link with dropdown menu + AI Builder (cached quota) + wire pin store |
|
||||
| `frontend/src/pages/QuickStartPage.tsx` | 4 | Major refactor: Favorites grid + paginated My Flows + skeletons + empty states |
|
||||
| `frontend/src/components/sidebar/PinnedFlowsSection.tsx` | 5 | Dual collapse states + reset-on-reexpand + auto-collapse on navigation |
|
||||
|
||||
---
|
||||
|
||||
### Phase 1: Infrastructure (Stores, Hooks, API)
|
||||
|
||||
**Goal:** Build the shared state and utility layers that every subsequent phase depends on.
|
||||
|
||||
#### 1a. Add `pin()` to API client
|
||||
|
||||
**File:** `frontend/src/api/pinnedFlows.ts`
|
||||
|
||||
```typescript
|
||||
pin: async (treeId: string) => apiClient.post(`/trees/${treeId}/pin`)
|
||||
```
|
||||
|
||||
#### 1b. Create `pinnedFlowsStore`
|
||||
|
||||
**File:** `frontend/src/store/pinnedFlowsStore.ts` (new)
|
||||
|
||||
State:
|
||||
- `items: PinnedFlow[]` — the pinned flows list
|
||||
- `isLoaded: boolean` — whether initial fetch has completed
|
||||
- `isLoading: boolean` — whether initial fetch is in progress
|
||||
- `isMutatingByTreeId: Record<string, boolean>` — per-tree mutation tracking
|
||||
- `error: string | null`
|
||||
|
||||
Actions:
|
||||
- `load(force?: boolean)` — fetch from API. Skip if `isLoaded` unless `force=true`.
|
||||
- `pin(treeId: string)` — optimistic add to `items`, call API, rollback + toast on failure. On 409: toast "Maximum of 15 favorites reached. Unpin a flow to add a new one." and do not add to items.
|
||||
- `unpin(treeId: string)` — optimistic remove from `items`, call API, rollback + toast on failure.
|
||||
- `toggle(treeId: string)` — calls `pin` or `unpin` based on current state.
|
||||
|
||||
Derived:
|
||||
- `isPinned(treeId: string): boolean`
|
||||
- `pinnedTreeIds: Set<string>` — for passing as prop to view components
|
||||
- `pinLoadingTreeIds: Set<string>` — derived from `isMutatingByTreeId` for disabling buttons
|
||||
|
||||
**Scope guardrail:** This store owns pin CRUD and derived pin state only. Dashboard layout preferences belong in `userPreferencesStore`. Recently viewed flows or other concerns belong in separate stores/hooks.
|
||||
|
||||
#### 1c. Replace local pin state in Sidebar
|
||||
|
||||
**File:** `frontend/src/components/layout/Sidebar.tsx`
|
||||
|
||||
- Remove local `useState` / `useEffect` for pinned flows (currently mount-only fetch)
|
||||
- Import and use `usePinnedFlowsStore` selectors and actions
|
||||
- Call `pinnedFlowsStore.load()` on mount (store handles deduplication)
|
||||
|
||||
#### 1d. Add dashboard view preference
|
||||
|
||||
**File:** `frontend/src/store/userPreferencesStore.ts`
|
||||
|
||||
- New field: `dashboardMyFlowsView: 'grid' | 'list' | 'table'` (default: `'grid'`)
|
||||
- New setter: `setDashboardMyFlowsView`
|
||||
- Persisted to localStorage alongside existing preferences
|
||||
- This is independent from the existing `treeLibraryView` preference
|
||||
|
||||
#### 1e. Create pagination params hook
|
||||
|
||||
**File:** `frontend/src/hooks/usePaginationParams.ts` (new)
|
||||
|
||||
A reusable hook that syncs pagination state to URL query params:
|
||||
|
||||
```typescript
|
||||
// Usage:
|
||||
const { page, pageSize, setPage, setPageSize } = usePaginationParams({
|
||||
defaultPageSize: 10,
|
||||
allowedPageSizes: [10, 25, 50, 'all'],
|
||||
})
|
||||
// URL: /dashboard?page=2&size=25
|
||||
// Handles: invalid values (falls back to defaults), page reset when size changes
|
||||
```
|
||||
|
||||
Behavior:
|
||||
- Reads `page` and `size` from URL search params on mount
|
||||
- Falls back to defaults if missing or invalid
|
||||
- `setPageSize` resets `page` to 1 (changing page size while on page 3 is confusing)
|
||||
- Validates `page` is a positive integer, `size` is one of the allowed values
|
||||
- Uses `useSearchParams` from React Router
|
||||
|
||||
#### 1f. Create cached quota hook
|
||||
|
||||
**File:** `frontend/src/hooks/useCachedQuota.ts` (new)
|
||||
|
||||
```typescript
|
||||
// Usage:
|
||||
const { aiEnabled, isLoading } = useCachedQuota()
|
||||
```
|
||||
|
||||
Behavior:
|
||||
- On first call, fetches `aiBuilderApi.getQuota()`
|
||||
- Caches result in module-level variable with timestamp
|
||||
- Subsequent calls within 5 minutes return cached value (no API call)
|
||||
- After 5 minutes, re-fetches on next call
|
||||
- Returns `{ aiEnabled: boolean, isLoading: boolean }`
|
||||
|
||||
---
|
||||
|
||||
### Phase 2: Pin/Unpin Controls in Library Views
|
||||
|
||||
**Goal:** Add favorite buttons to all three view components without breaking existing behavior.
|
||||
|
||||
**Files:** `TreeGridView.tsx`, `TreeListView.tsx`, `TreeTableView.tsx`
|
||||
|
||||
#### New optional props on all three:
|
||||
|
||||
```typescript
|
||||
pinnedTreeIds?: Set<string>
|
||||
onTogglePin?: (treeId: string) => void
|
||||
pinLoadingTreeIds?: Set<string>
|
||||
```
|
||||
|
||||
Props are optional so these components remain backward-compatible with any page that doesn't use pins.
|
||||
|
||||
#### Pin button pattern (all three views):
|
||||
|
||||
```tsx
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
onTogglePin?.(treeId);
|
||||
}}
|
||||
disabled={pinLoadingTreeIds?.has(treeId)}
|
||||
aria-label={pinnedTreeIds?.has(treeId) ? "Remove from favorites" : "Add to favorites"}
|
||||
className={/* reduced opacity when disabled */}
|
||||
>
|
||||
{pinnedTreeIds?.has(treeId) ? <StarFilledIcon /> : <StarOutlineIcon />}
|
||||
</button>
|
||||
```
|
||||
|
||||
#### View-specific placement:
|
||||
- **Grid:** Star icon in top-right corner of card
|
||||
- **List:** Star icon at the end of each row
|
||||
- **Table:** Dedicated narrow "Favorite" column (leftmost)
|
||||
|
||||
**Critical:** `e.stopPropagation()` + `e.preventDefault()` prevents the click from triggering card/row navigation. Button is disabled (reduced opacity, no pointer events) while that tree's mutation is in-flight.
|
||||
|
||||
---
|
||||
|
||||
### Phase 3: TreeLibraryPage — Create Menu + Pin Wiring
|
||||
|
||||
**Goal:** Add AI Builder access and connect Library page to shared pin store.
|
||||
|
||||
**File:** `frontend/src/pages/TreeLibraryPage.tsx`
|
||||
|
||||
#### 3a. Replace Create link with dropdown
|
||||
|
||||
Replace the single `<Link>` create button with a dropdown menu (same visual pattern as `MyTreesPage`'s `showCreateMenu`).
|
||||
|
||||
Menu items (fixed order):
|
||||
1. Troubleshooting Tree
|
||||
2. Procedural Flow
|
||||
3. Maintenance Flow
|
||||
4. `<divider>`
|
||||
5. Build with AI *(only shown when `aiEnabled` is true)*
|
||||
|
||||
#### 3b. AI Builder integration
|
||||
|
||||
- Use `useCachedQuota()` hook (from Phase 1f) — no fetch-on-mount, uses cached value
|
||||
- Import and render `AIFlowBuilderModal` (already built)
|
||||
- Modal state: `showAIBuilder` boolean, toggled by menu item click
|
||||
|
||||
#### 3c. Wire view components to pin store
|
||||
|
||||
- Import `usePinnedFlowsStore`
|
||||
- Call `store.load()` on mount
|
||||
- Pass `pinnedTreeIds`, `onTogglePin: store.toggle`, and `pinLoadingTreeIds` to active view component
|
||||
|
||||
---
|
||||
|
||||
### Phase 4: QuickStartPage Refactor — Favorites + My Flows
|
||||
|
||||
**Goal:** Transform the dashboard from a flat "All Flows" dump into a structured personal workspace.
|
||||
|
||||
**File:** `frontend/src/pages/QuickStartPage.tsx`
|
||||
|
||||
#### 4a. Favorites Section (above My Flows)
|
||||
|
||||
**Layout:** Compact wrapping grid. Max 2 visible rows (~8 cards). If more than 8 pinned flows, show "View all favorites" link that expands to show all.
|
||||
|
||||
**Data source:** `pinnedFlowsStore.items`, ordered by `display_order`.
|
||||
|
||||
**Each card:** Click to navigate + unpin button (star icon, same pattern as Phase 2).
|
||||
|
||||
**Section header:** "Favorites" with count badge (e.g., "Favorites (7)").
|
||||
|
||||
**Loading state:** Skeleton loader — 4 placeholder cards with pulse animation, matching the grid layout dimensions so content doesn't shift when data arrives.
|
||||
|
||||
**Empty state:** Subtle message: "Star a flow to pin it here for quick access." No CTA button — the action is contextual (you star flows from the Library or My Flows).
|
||||
|
||||
#### 4b. My Flows Section (replaces "All Flows")
|
||||
|
||||
**Section header:** "My Flows"
|
||||
|
||||
**Data source:** `treesApi.list({ author_id: currentUser.id, sort_by: 'updated_at', skip, limit })`
|
||||
|
||||
**Pagination (via `usePaginationParams` hook):**
|
||||
- Page size selector: dropdown with 10 (default), 25, 50, All
|
||||
- For numeric sizes:
|
||||
```typescript
|
||||
// Request one extra item to detect if there's a next page.
|
||||
// If response.length > pageSize, a next page exists.
|
||||
// We only display the first `pageSize` items.
|
||||
const response = await treesApi.list({
|
||||
author_id: currentUser.id,
|
||||
limit: pageSize + 1,
|
||||
skip: (page - 1) * pageSize,
|
||||
});
|
||||
const hasNextPage = response.length > pageSize;
|
||||
const displayItems = response.slice(0, pageSize);
|
||||
```
|
||||
- For "All": fetch in chunks of 100 (`skip=0, limit=100`, then `skip=100`, etc.) until response returns fewer than 100 items OR 500 items total reached. If ceiling hit, show: "Showing first 500 flows. Use search or filters to find specific flows."
|
||||
- Controls: `Prev` (disabled on page 1) / `Next` (disabled when `!hasNextPage`) / current page label / size dropdown
|
||||
- URL synced: `?page=2&size=25` — changing page size resets to page 1
|
||||
|
||||
**View toggle:** Reuse `ViewToggle` component, bound to `dashboardMyFlowsView` preference (independent from Library).
|
||||
|
||||
**Render:** Pass `TreeGridView` / `TreeListView` / `TreeTableView` with pin props from store.
|
||||
|
||||
**Loading state:** Skeleton loader — 6 placeholder cards/rows matching the active view type (grid skeleton for grid view, row skeletons for list/table view).
|
||||
|
||||
**Empty state:** "You haven't created any flows yet." with a CTA button: "Create your first flow" that triggers the Create dropdown menu (same options as TreeLibraryPage: Troubleshooting Tree, Procedural Flow, Maintenance Flow, divider, Build with AI).
|
||||
|
||||
#### 4c. Cleanup
|
||||
|
||||
- Remove the current hard-cap of 20 items
|
||||
- Remove the "All Flows" `SectionGroup` wrapper
|
||||
- Keep existing stats cards, recent sessions, and search panels unless explicitly removed later
|
||||
|
||||
---
|
||||
|
||||
### Phase 5: Sidebar PinnedFlowsSection — Dual Collapse
|
||||
|
||||
**Goal:** Make the sidebar pinned section polished without taking over the sidebar.
|
||||
|
||||
**File:** `frontend/src/components/sidebar/PinnedFlowsSection.tsx`
|
||||
|
||||
#### Two independent collapse states:
|
||||
|
||||
1. **Header collapse:** Click section header → hides/shows entire pinned flows area (existing behavior, keep it). When re-expanding after a collapse, **always reset list truncation to 5 items.**
|
||||
|
||||
2. **List truncation:** When section is expanded:
|
||||
- Show first 5 pinned flows by default
|
||||
- "Show more (X)" link at bottom expands to show all (X = total count)
|
||||
- "Show less" link collapses back to 5
|
||||
- Clicking a pinned flow link: navigate AND auto-collapse back to 5
|
||||
|
||||
#### Smooth transitions:
|
||||
|
||||
- CSS `max-height` transition on the list container: `transition: max-height 250ms ease-out`
|
||||
- Keep it subtle — no dramatic animations
|
||||
|
||||
---
|
||||
|
||||
### Test Cases
|
||||
|
||||
#### `pinnedFlowsStore` unit tests:
|
||||
- `load()` populates `items` from API
|
||||
- `load()` skips fetch when `isLoaded=true` (unless `force=true`)
|
||||
- `toggle()` pins an unpinned tree, unpins a pinned tree
|
||||
- Optimistic update: `items` updates immediately before API resolves
|
||||
- Rollback: `items` reverts if API call fails
|
||||
- 409 conflict: shows error toast, does not add to `items`
|
||||
- Sidebar + Dashboard selectors reflect same state after mutation
|
||||
- `pinnedTreeIds` derived set updates correctly
|
||||
|
||||
#### `usePaginationParams` hook tests:
|
||||
- Reads `page` and `size` from URL on mount
|
||||
- Falls back to defaults when URL params missing or invalid
|
||||
- `setPageSize` resets page to 1
|
||||
- Invalid page (negative, zero, non-number) falls back to 1
|
||||
|
||||
#### `useCachedQuota` hook tests:
|
||||
- First call fetches from API
|
||||
- Second call within 5 minutes returns cached value (no API call)
|
||||
- Call after 5 minutes re-fetches
|
||||
|
||||
#### `PinnedFlowsSection` component tests:
|
||||
- Shows max 5 items by default
|
||||
- "Show more" reveals all items
|
||||
- Clicking a flow collapses list back to 5
|
||||
- Header collapse hides entire section
|
||||
- Re-expanding after header collapse resets to 5 items
|
||||
|
||||
#### `QuickStartPage` integration tests:
|
||||
- My Flows uses `author_id` filter
|
||||
- Numeric page size: requests `limit=size+1`, displays `size` results
|
||||
- "All" fetches iteratively, stops at 500 ceiling
|
||||
- Favorites section updates immediately after pin/unpin
|
||||
- Empty state shows CTA when user has zero flows
|
||||
- Skeleton loaders appear during fetch
|
||||
- URL params update when page/size changes
|
||||
|
||||
#### `TreeLibraryPage` tests:
|
||||
- Create dropdown renders all flow type options
|
||||
- "Build with AI" only shown when `aiEnabled=true`
|
||||
- "Build with AI" opens `AIFlowBuilderModal`
|
||||
- Pin buttons work and sync with store
|
||||
|
||||
#### Regression:
|
||||
- `cd frontend && npm run test`
|
||||
- `cd frontend && npm run build`
|
||||
|
||||
### Manual Verification Checklist
|
||||
|
||||
- [ ] Dashboard: Favorites section shows pinned flows in compact grid, My Flows shows paginated authored flows
|
||||
- [ ] Pin a flow on Library page → appears in Dashboard Favorites AND Sidebar immediately (no navigation)
|
||||
- [ ] Unpin from Dashboard → removed from Sidebar immediately
|
||||
- [ ] Page size dropdown: 10/25/50/All all work, Prev/Next show/hide correctly
|
||||
- [ ] "Show All" stops at 500 items with message if ceiling hit
|
||||
- [ ] Change page → URL updates to `?page=X&size=Y`. Refresh → same page/size restored.
|
||||
- [ ] View toggle on Dashboard is independent from Library view toggle
|
||||
- [ ] Sidebar: max 5 shown, "Show more" expands, clicking a flow collapses and navigates
|
||||
- [ ] Collapse sidebar section → re-expand → list is back to 5 (not "show all")
|
||||
- [ ] Try to pin a 16th flow → toast about max limit, flow not pinned
|
||||
- [ ] AI Builder: Library page "Create New" → "Build with AI" opens modal (uses cached quota)
|
||||
- [ ] New user with zero flows: sees empty state with "Create your first flow" CTA
|
||||
- [ ] New user with zero favorites: sees "Star a flow to pin it here" message
|
||||
- [ ] During initial load: skeleton placeholders visible, no layout shift when data arrives
|
||||
- [ ] Pin button: screen reader announces "Add to favorites" / "Remove from favorites"
|
||||
- [ ] Build passes: `cd frontend && npm run build` with no errors
|
||||
61
frontend/src/api/aiBuilder.ts
Normal file
61
frontend/src/api/aiBuilder.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { apiClient } from './client'
|
||||
import type {
|
||||
AIQuotaStatus,
|
||||
AIStartResponse,
|
||||
AIScaffoldResponse,
|
||||
AIBranchDetailResponse,
|
||||
AIAssembleResponse,
|
||||
} from '@/types'
|
||||
|
||||
export const aiBuilderApi = {
|
||||
getQuota: async (): Promise<AIQuotaStatus> => {
|
||||
const { data } = await apiClient.get('/ai/quota')
|
||||
return data
|
||||
},
|
||||
|
||||
start: async (params: {
|
||||
flow_type: 'troubleshooting' | 'procedural'
|
||||
name: string
|
||||
description: string
|
||||
environment_tags?: string[]
|
||||
category_id?: string
|
||||
}): Promise<AIStartResponse> => {
|
||||
const { data } = await apiClient.post('/ai/start', params)
|
||||
return data
|
||||
},
|
||||
|
||||
scaffold: async (conversationId: string): Promise<AIScaffoldResponse> => {
|
||||
const { data } = await apiClient.post('/ai/scaffold', {
|
||||
conversation_id: conversationId,
|
||||
})
|
||||
return data
|
||||
},
|
||||
|
||||
branchDetail: async (
|
||||
conversationId: string,
|
||||
branchName: string
|
||||
): Promise<AIBranchDetailResponse> => {
|
||||
const { data } = await apiClient.post('/ai/branch-detail', {
|
||||
conversation_id: conversationId,
|
||||
branch_name: branchName,
|
||||
})
|
||||
return data
|
||||
},
|
||||
|
||||
assemble: async (
|
||||
conversationId: string,
|
||||
selectedBranches: Array<{
|
||||
name: string
|
||||
description: string
|
||||
steps?: Record<string, unknown>
|
||||
}>
|
||||
): Promise<AIAssembleResponse> => {
|
||||
const { data } = await apiClient.post('/ai/assemble', {
|
||||
conversation_id: conversationId,
|
||||
selected_branches: selectedBranches,
|
||||
})
|
||||
return data
|
||||
},
|
||||
}
|
||||
|
||||
export default aiBuilderApi
|
||||
@@ -16,3 +16,4 @@ export { default as analyticsApi } from './analytics'
|
||||
export { targetListsApi } from './targetLists'
|
||||
export { maintenanceSchedulesApi, batchLaunchApi } from './maintenanceSchedules'
|
||||
export { default as feedbackApi } from './feedback'
|
||||
export { default as aiBuilderApi } from './aiBuilder'
|
||||
|
||||
@@ -25,6 +25,10 @@ export const pinnedFlowsApi = {
|
||||
unpin: async (treeId: string): Promise<void> => {
|
||||
await apiClient.delete(`/trees/${treeId}/pin`)
|
||||
},
|
||||
|
||||
pin: async (treeId: string): Promise<void> => {
|
||||
await apiClient.post(`/trees/${treeId}/pin`)
|
||||
},
|
||||
}
|
||||
|
||||
export default pinnedFlowsApi
|
||||
|
||||
@@ -1,2 +1 @@
|
||||
export { EmptyState } from '@/components/common/EmptyState'
|
||||
export { default } from '@/components/common/EmptyState'
|
||||
|
||||
138
frontend/src/components/ai-builder/AIFlowBuilderModal.tsx
Normal file
138
frontend/src/components/ai-builder/AIFlowBuilderModal.tsx
Normal file
@@ -0,0 +1,138 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Modal } from '@/components/common/Modal'
|
||||
import { useAIFlowBuilderStore } from '@/store/aiFlowBuilderStore'
|
||||
import { treesApi } from '@/api/trees'
|
||||
import { toast } from '@/lib/toast'
|
||||
import { WizardStepIndicator } from './WizardStepIndicator'
|
||||
import { FoundationForm } from './FoundationForm'
|
||||
import { BranchSelector } from './BranchSelector'
|
||||
import { BranchDetailView } from './BranchDetailView'
|
||||
import { TreePreviewCard } from './TreePreviewCard'
|
||||
import { GeneratingAnimation } from './GeneratingAnimation'
|
||||
|
||||
interface AIFlowBuilderModalProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function AIFlowBuilderModal({ isOpen, onClose }: AIFlowBuilderModalProps) {
|
||||
const navigate = useNavigate()
|
||||
const {
|
||||
phase,
|
||||
metadata,
|
||||
assembledTree,
|
||||
loadQuota,
|
||||
scaffold,
|
||||
reset,
|
||||
} = useAIFlowBuilderStore()
|
||||
|
||||
// Load quota when modal opens
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
loadQuota()
|
||||
}
|
||||
}, [isOpen, loadQuota])
|
||||
|
||||
// Auto-trigger scaffold after conversation starts (ref prevents double-fire)
|
||||
const hasTriggeredScaffold = useRef(false)
|
||||
useEffect(() => {
|
||||
if (phase === 'scaffolding' && !hasTriggeredScaffold.current && !useAIFlowBuilderStore.getState().suggestedBranches.length) {
|
||||
hasTriggeredScaffold.current = true
|
||||
scaffold()
|
||||
}
|
||||
}, [phase, scaffold])
|
||||
|
||||
const handleClose = () => {
|
||||
reset()
|
||||
onClose()
|
||||
}
|
||||
|
||||
const handleOpenInEditor = async () => {
|
||||
if (!assembledTree) return
|
||||
try {
|
||||
const tree = await treesApi.create({
|
||||
name: assembledTree.suggested_name,
|
||||
description: assembledTree.suggested_description,
|
||||
tree_structure: assembledTree.tree_structure,
|
||||
tree_type: metadata.flow_type,
|
||||
status: 'draft',
|
||||
})
|
||||
handleClose()
|
||||
const editorPath =
|
||||
metadata.flow_type === 'procedural'
|
||||
? `/flows/${tree.id}/edit`
|
||||
: `/trees/${tree.id}/edit`
|
||||
navigate(editorPath)
|
||||
} catch {
|
||||
toast.error('Failed to create flow. Please try again.')
|
||||
}
|
||||
}
|
||||
|
||||
const getTitle = () => {
|
||||
switch (phase) {
|
||||
case 'foundation':
|
||||
return 'Build with AI'
|
||||
case 'scaffolding':
|
||||
case 'generating':
|
||||
return 'AI Scaffold'
|
||||
case 'detailing':
|
||||
return 'Branch Detail'
|
||||
case 'reviewing':
|
||||
return 'Review & Assemble'
|
||||
case 'error':
|
||||
return 'AI Flow Builder'
|
||||
default:
|
||||
return 'Build with AI'
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={handleClose}
|
||||
title={getTitle()}
|
||||
size="lg"
|
||||
footer={
|
||||
<WizardStepIndicator phase={phase} />
|
||||
}
|
||||
>
|
||||
{phase === 'foundation' && <FoundationForm />}
|
||||
{phase === 'scaffolding' && <BranchSelector />}
|
||||
{phase === 'generating' && <GeneratingAnimation />}
|
||||
{phase === 'detailing' && <BranchDetailView />}
|
||||
{phase === 'reviewing' && (
|
||||
<TreePreviewCard onOpenInEditor={handleOpenInEditor} />
|
||||
)}
|
||||
{phase === 'error' && <ErrorView />}
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
function ErrorView() {
|
||||
const { error, reset, setPhase } = useAIFlowBuilderStore()
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-4 py-8">
|
||||
<div className="rounded-lg border border-red-400/20 bg-red-400/5 px-4 py-3 text-sm text-red-400">
|
||||
{error || 'An unexpected error occurred.'}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPhase('foundation')}
|
||||
className="rounded-lg border border-border px-4 py-2 text-sm text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
Go Back
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={reset}
|
||||
className="rounded-lg bg-gradient-brand px-4 py-2 text-sm font-medium text-white shadow-lg shadow-primary/20 hover:opacity-90"
|
||||
>
|
||||
Start Over
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
212
frontend/src/components/ai-builder/BranchDetailView.tsx
Normal file
212
frontend/src/components/ai-builder/BranchDetailView.tsx
Normal file
@@ -0,0 +1,212 @@
|
||||
import { Check, RefreshCw, SkipForward, ChevronRight, ChevronLeft } from 'lucide-react'
|
||||
import { useAIFlowBuilderStore } from '@/store/aiFlowBuilderStore'
|
||||
import { GeneratingAnimation } from './GeneratingAnimation'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export function BranchDetailView() {
|
||||
const {
|
||||
selectedBranches,
|
||||
currentBranchIndex,
|
||||
generateBranchDetail,
|
||||
assemble,
|
||||
isLoading,
|
||||
error,
|
||||
phase,
|
||||
setError,
|
||||
} = useAIFlowBuilderStore()
|
||||
|
||||
const viewingIndex = currentBranchIndex
|
||||
const setViewingIndex = (i: number) => useAIFlowBuilderStore.setState({ currentBranchIndex: i })
|
||||
const currentBranch = selectedBranches[viewingIndex]
|
||||
|
||||
const allBranchesHaveDetail = selectedBranches.every((b) => b.steps)
|
||||
const branchesWithDetail = selectedBranches.filter((b) => b.steps).length
|
||||
|
||||
const handleGenerate = async (branchName: string) => {
|
||||
setError(null)
|
||||
await generateBranchDetail(branchName)
|
||||
}
|
||||
|
||||
const handleAssemble = async () => {
|
||||
await assemble()
|
||||
}
|
||||
|
||||
if (phase === 'generating' && isLoading) {
|
||||
return <GeneratingAnimation />
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* Content area */}
|
||||
<div className="space-y-4">
|
||||
{/* Branch tabs */}
|
||||
<div className="flex items-center gap-2 overflow-x-auto pb-1">
|
||||
{selectedBranches.map((branch, i) => (
|
||||
<button
|
||||
key={branch.name}
|
||||
type="button"
|
||||
onClick={() => setViewingIndex(i)}
|
||||
className={cn(
|
||||
'flex shrink-0 items-center gap-1.5 rounded-full border px-3 py-1.5 text-xs font-medium transition-colors',
|
||||
viewingIndex === i
|
||||
? 'border-primary/30 bg-primary/10 text-foreground'
|
||||
: 'border-border text-muted-foreground hover:bg-accent',
|
||||
branch.steps && 'pr-2'
|
||||
)}
|
||||
>
|
||||
{branch.name}
|
||||
{branch.steps && (
|
||||
<Check className="h-3 w-3 text-green-400" />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Current branch detail */}
|
||||
{currentBranch && (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-foreground">{currentBranch.name}</h3>
|
||||
<p className="text-xs text-muted-foreground">{currentBranch.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{currentBranch.steps ? (
|
||||
<div className="space-y-3">
|
||||
{/* Mini tree preview */}
|
||||
<div className="max-h-48 overflow-y-auto rounded-lg border border-border bg-accent/30 p-3">
|
||||
<NodePreview node={currentBranch.steps} depth={0} />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleGenerate(currentBranch.name)}
|
||||
disabled={isLoading}
|
||||
className="flex items-center gap-1.5 rounded-lg border border-border px-3 py-1.5 text-xs text-muted-foreground hover:bg-accent hover:text-foreground disabled:opacity-50"
|
||||
>
|
||||
<RefreshCw className="h-3 w-3" />
|
||||
Regenerate
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center gap-3 rounded-lg border border-dashed border-border bg-accent/20 py-8">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Generate AI detail for this branch
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleGenerate(currentBranch.name)}
|
||||
disabled={isLoading}
|
||||
className={cn(
|
||||
'rounded-lg bg-gradient-brand px-4 py-2 text-sm font-medium text-white shadow-lg shadow-primary/20',
|
||||
isLoading ? 'cursor-not-allowed opacity-50' : 'hover:opacity-90'
|
||||
)}
|
||||
>
|
||||
Generate Detail
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (viewingIndex < selectedBranches.length - 1) {
|
||||
setViewingIndex(viewingIndex + 1)
|
||||
}
|
||||
}}
|
||||
className="flex items-center gap-1 rounded-lg border border-border px-3 py-2 text-sm text-muted-foreground hover:bg-accent"
|
||||
>
|
||||
<SkipForward className="h-3.5 w-3.5" />
|
||||
Skip
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className="rounded-lg border border-red-400/20 bg-red-400/5 px-3 py-2 text-sm text-red-400">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Navigation — sticky so it's always visible */}
|
||||
<div className="sticky bottom-0 flex items-center justify-between border-t border-border bg-card pt-3 pb-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setViewingIndex(Math.max(0, viewingIndex - 1))}
|
||||
disabled={viewingIndex === 0}
|
||||
className="flex items-center gap-1 rounded-lg border border-border px-3 py-1.5 text-xs text-muted-foreground hover:bg-accent disabled:opacity-30"
|
||||
>
|
||||
<ChevronLeft className="h-3.5 w-3.5" />
|
||||
Previous
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setViewingIndex(Math.min(selectedBranches.length - 1, viewingIndex + 1))
|
||||
}
|
||||
disabled={viewingIndex === selectedBranches.length - 1}
|
||||
className="flex items-center gap-1 rounded-lg border border-border px-3 py-1.5 text-xs text-muted-foreground hover:bg-accent disabled:opacity-30"
|
||||
>
|
||||
Next
|
||||
<ChevronRight className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{branchesWithDetail}/{selectedBranches.length} detailed
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAssemble}
|
||||
disabled={!allBranchesHaveDetail || isLoading}
|
||||
className={cn(
|
||||
'rounded-lg bg-gradient-brand px-4 py-2 text-sm font-medium text-white shadow-lg shadow-primary/20',
|
||||
allBranchesHaveDetail && !isLoading
|
||||
? 'hover:opacity-90'
|
||||
: 'cursor-not-allowed opacity-50'
|
||||
)}
|
||||
>
|
||||
Assemble Tree
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Recursive mini-preview of a node tree */
|
||||
function NodePreview({ node, depth }: { node: Record<string, unknown>; depth: number }) {
|
||||
const type = node.type as string
|
||||
const label =
|
||||
type === 'decision'
|
||||
? (node.question as string)
|
||||
: (node.title as string) || 'Untitled'
|
||||
const children = (node.children as Record<string, unknown>[]) || []
|
||||
|
||||
const typeColors: Record<string, string> = {
|
||||
decision: 'bg-blue-400',
|
||||
action: 'bg-amber-400',
|
||||
solution: 'bg-green-400',
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ marginLeft: depth * 16 }}>
|
||||
<div className="flex items-center gap-2 py-0.5">
|
||||
<div className={cn('h-2 w-2 rounded-full', typeColors[type] || 'bg-muted-foreground')} />
|
||||
<span className="text-xs text-foreground truncate">{label}</span>
|
||||
<span className="text-[10px] font-label text-muted-foreground">{type}</span>
|
||||
</div>
|
||||
{children.map((child) => (
|
||||
<NodePreview key={child.id as string ?? crypto.randomUUID()} node={child} depth={depth + 1} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
280
frontend/src/components/ai-builder/BranchSelector.tsx
Normal file
280
frontend/src/components/ai-builder/BranchSelector.tsx
Normal file
@@ -0,0 +1,280 @@
|
||||
import { useState } from 'react'
|
||||
import { GripVertical, Plus, X, Pencil, Check } from 'lucide-react'
|
||||
import { useAIFlowBuilderStore } from '@/store/aiFlowBuilderStore'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { AIBranch } from '@/types'
|
||||
|
||||
export function BranchSelector() {
|
||||
const {
|
||||
suggestedBranches,
|
||||
selectedBranches,
|
||||
selectBranches,
|
||||
setPhase,
|
||||
error,
|
||||
} = useAIFlowBuilderStore()
|
||||
|
||||
const [editingIndex, setEditingIndex] = useState<number | null>(null)
|
||||
const [editName, setEditName] = useState('')
|
||||
const [editDesc, setEditDesc] = useState('')
|
||||
const [showAddForm, setShowAddForm] = useState(false)
|
||||
const [newName, setNewName] = useState('')
|
||||
const [newDesc, setNewDesc] = useState('')
|
||||
|
||||
const toggleBranch = (branch: AIBranch) => {
|
||||
const isSelected = selectedBranches.some((b) => b.name === branch.name)
|
||||
if (isSelected) {
|
||||
selectBranches(selectedBranches.filter((b) => b.name !== branch.name))
|
||||
} else {
|
||||
selectBranches([...selectedBranches, branch])
|
||||
}
|
||||
}
|
||||
|
||||
const startEditing = (index: number) => {
|
||||
const branch = selectedBranches[index]
|
||||
setEditingIndex(index)
|
||||
setEditName(branch.name)
|
||||
setEditDesc(branch.description)
|
||||
}
|
||||
|
||||
const saveEdit = () => {
|
||||
if (editingIndex === null || !editName.trim()) return
|
||||
const updated = [...selectedBranches]
|
||||
updated[editingIndex] = {
|
||||
...updated[editingIndex],
|
||||
name: editName.trim(),
|
||||
description: editDesc.trim(),
|
||||
}
|
||||
selectBranches(updated)
|
||||
setEditingIndex(null)
|
||||
}
|
||||
|
||||
const addCustomBranch = () => {
|
||||
if (!newName.trim()) return
|
||||
const branch: AIBranch = {
|
||||
name: newName.trim(),
|
||||
description: newDesc.trim(),
|
||||
isCustom: true,
|
||||
}
|
||||
selectBranches([...selectedBranches, branch])
|
||||
setNewName('')
|
||||
setNewDesc('')
|
||||
setShowAddForm(false)
|
||||
}
|
||||
|
||||
const moveBranch = (fromIndex: number, direction: 'up' | 'down') => {
|
||||
const toIndex = direction === 'up' ? fromIndex - 1 : fromIndex + 1
|
||||
if (toIndex < 0 || toIndex >= selectedBranches.length) return
|
||||
const updated = [...selectedBranches]
|
||||
;[updated[fromIndex], updated[toIndex]] = [updated[toIndex], updated[fromIndex]]
|
||||
selectBranches(updated)
|
||||
}
|
||||
|
||||
const canProceed = selectedBranches.length >= 2
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
AI suggested {suggestedBranches.length} branches. Select, reorder, rename, or add your own.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Branch list */}
|
||||
<div className="space-y-2">
|
||||
{suggestedBranches.map((branch) => {
|
||||
const isSelected = selectedBranches.some((b) => b.name === branch.name)
|
||||
const selectedIndex = selectedBranches.findIndex((b) => b.name === branch.name)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={branch.name}
|
||||
className={cn(
|
||||
'flex items-start gap-3 rounded-lg border p-3 transition-colors cursor-pointer',
|
||||
isSelected
|
||||
? 'border-primary/30 bg-primary/5'
|
||||
: 'border-border bg-card hover:bg-accent/50'
|
||||
)}
|
||||
onClick={() => toggleBranch(branch)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded border',
|
||||
isSelected
|
||||
? 'border-primary bg-primary text-white'
|
||||
: 'border-border'
|
||||
)}
|
||||
>
|
||||
{isSelected && <Check className="h-3 w-3" />}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
{editingIndex !== null && selectedIndex === editingIndex ? (
|
||||
<div className="space-y-2" onClick={(e) => e.stopPropagation()}>
|
||||
<input
|
||||
type="text"
|
||||
value={editName}
|
||||
onChange={(e) => setEditName(e.target.value)}
|
||||
className="w-full rounded border border-border bg-card px-2 py-1 text-sm text-foreground focus:border-primary focus:outline-none"
|
||||
autoFocus
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={editDesc}
|
||||
onChange={(e) => setEditDesc(e.target.value)}
|
||||
className="w-full rounded border border-border bg-card px-2 py-1 text-xs text-muted-foreground focus:border-primary focus:outline-none"
|
||||
/>
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={saveEdit}
|
||||
className="rounded bg-primary px-2 py-0.5 text-xs text-white"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditingIndex(null)}
|
||||
className="rounded border border-border px-2 py-0.5 text-xs text-muted-foreground"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="text-sm font-medium text-foreground">{branch.name}</div>
|
||||
<div className="text-xs text-muted-foreground">{branch.description}</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{isSelected && editingIndex !== selectedIndex && (
|
||||
<div className="flex items-center gap-0.5" onClick={(e) => e.stopPropagation()}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => moveBranch(selectedIndex, 'up')}
|
||||
disabled={selectedIndex === 0}
|
||||
className="rounded p-1 text-muted-foreground hover:text-foreground disabled:opacity-30"
|
||||
title="Move up"
|
||||
>
|
||||
<GripVertical className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => startEditing(selectedIndex)}
|
||||
className="rounded p-1 text-muted-foreground hover:text-foreground"
|
||||
title="Edit"
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Custom branches (not in suggested) */}
|
||||
{selectedBranches
|
||||
.filter((b) => b.isCustom)
|
||||
.map((branch, i) => {
|
||||
return (
|
||||
<div
|
||||
key={`custom-${i}`}
|
||||
className="flex items-start gap-3 rounded-lg border border-primary/30 bg-primary/5 p-3"
|
||||
>
|
||||
<div className="mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded border border-primary bg-primary text-white">
|
||||
<Check className="h-3 w-3" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium text-foreground">{branch.name}</div>
|
||||
<div className="text-xs text-muted-foreground">{branch.description}</div>
|
||||
<span className="mt-1 inline-block rounded bg-primary/10 px-1.5 py-0.5 text-[10px] font-label text-primary">
|
||||
Custom
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
selectBranches(selectedBranches.filter((b) => b.name !== branch.name))
|
||||
}
|
||||
className="rounded p-1 text-muted-foreground hover:text-red-400"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Add custom branch */}
|
||||
{showAddForm ? (
|
||||
<div className="space-y-2 rounded-lg border border-dashed border-border p-3">
|
||||
<input
|
||||
type="text"
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
placeholder="Branch name"
|
||||
className="w-full rounded border border-border bg-card px-2 py-1.5 text-sm text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-none"
|
||||
autoFocus
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={newDesc}
|
||||
onChange={(e) => setNewDesc(e.target.value)}
|
||||
placeholder="Brief description"
|
||||
className="w-full rounded border border-border bg-card px-2 py-1.5 text-xs text-muted-foreground placeholder:text-muted-foreground/60 focus:border-primary focus:outline-none"
|
||||
/>
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={addCustomBranch}
|
||||
disabled={!newName.trim()}
|
||||
className="rounded bg-primary px-2.5 py-1 text-xs text-white disabled:opacity-50"
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAddForm(false)}
|
||||
className="rounded border border-border px-2.5 py-1 text-xs text-muted-foreground"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAddForm(true)}
|
||||
className="flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
Add custom branch
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className="rounded-lg border border-red-400/20 bg-red-400/5 px-3 py-2 text-sm text-red-400">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-between pt-2">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{selectedBranches.length} branch{selectedBranches.length !== 1 ? 'es' : ''} selected (min 2)
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPhase('detailing')}
|
||||
disabled={!canProceed}
|
||||
className={cn(
|
||||
'rounded-lg bg-gradient-brand px-4 py-2 text-sm font-medium text-white shadow-lg shadow-primary/20',
|
||||
canProceed ? 'hover:opacity-90' : 'cursor-not-allowed opacity-50'
|
||||
)}
|
||||
>
|
||||
Continue to Detail
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
163
frontend/src/components/ai-builder/FoundationForm.tsx
Normal file
163
frontend/src/components/ai-builder/FoundationForm.tsx
Normal file
@@ -0,0 +1,163 @@
|
||||
import { useState } from 'react'
|
||||
import { useAIFlowBuilderStore } from '@/store/aiFlowBuilderStore'
|
||||
import { QuotaDisplay } from './QuotaDisplay'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export function FoundationForm() {
|
||||
const { metadata, setMetadata, quota, start, isLoading, error } = useAIFlowBuilderStore()
|
||||
const [tagInput, setTagInput] = useState('')
|
||||
|
||||
const canSubmit =
|
||||
metadata.name.trim().length > 0 &&
|
||||
metadata.description.trim().length > 0 &&
|
||||
!isLoading &&
|
||||
(quota?.allowed !== false)
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!canSubmit) return
|
||||
await start()
|
||||
}
|
||||
|
||||
const addTag = () => {
|
||||
const tag = tagInput.trim()
|
||||
if (tag && !metadata.environment_tags.includes(tag)) {
|
||||
setMetadata({ environment_tags: [...metadata.environment_tags, tag] })
|
||||
}
|
||||
setTagInput('')
|
||||
}
|
||||
|
||||
const removeTag = (tag: string) => {
|
||||
setMetadata({ environment_tags: metadata.environment_tags.filter((t) => t !== tag) })
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
{quota && <QuotaDisplay quota={quota} />}
|
||||
|
||||
{/* Flow Type */}
|
||||
<div>
|
||||
<label className="mb-2 block font-label text-[0.6875rem] uppercase tracking-wide text-muted-foreground">
|
||||
Flow Type
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
{(['troubleshooting', 'procedural'] as const).map((type) => (
|
||||
<button
|
||||
key={type}
|
||||
type="button"
|
||||
onClick={() => setMetadata({ flow_type: type })}
|
||||
className={cn(
|
||||
'flex-1 rounded-lg border px-3 py-2.5 text-sm font-medium transition-colors',
|
||||
metadata.flow_type === type
|
||||
? 'border-primary/30 bg-primary/10 text-foreground'
|
||||
: 'border-border bg-card text-muted-foreground hover:bg-accent'
|
||||
)}
|
||||
>
|
||||
{type === 'troubleshooting' ? 'Troubleshooting' : 'Procedural'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Name */}
|
||||
<div>
|
||||
<label className="mb-2 block font-label text-[0.6875rem] uppercase tracking-wide text-muted-foreground">
|
||||
Flow Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={metadata.name}
|
||||
onChange={(e) => setMetadata({ name: e.target.value })}
|
||||
placeholder="e.g. DNS Resolution Failures"
|
||||
className="w-full rounded-lg border border-border bg-card px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/20"
|
||||
maxLength={255}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div>
|
||||
<label className="mb-2 block font-label text-[0.6875rem] uppercase tracking-wide text-muted-foreground">
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
value={metadata.description}
|
||||
onChange={(e) => setMetadata({ description: e.target.value })}
|
||||
placeholder="Describe what this flow covers. The more detail you provide, the better the AI suggestions will be."
|
||||
rows={4}
|
||||
className="w-full rounded-lg border border-border bg-card px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/20 resize-none"
|
||||
maxLength={2000}
|
||||
/>
|
||||
<p className="mt-1 text-right text-[10px] text-muted-foreground">
|
||||
{metadata.description.length}/2000
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Environment Tags */}
|
||||
<div>
|
||||
<label className="mb-2 block font-label text-[0.6875rem] uppercase tracking-wide text-muted-foreground">
|
||||
Environment Tags <span className="normal-case tracking-normal text-muted-foreground/60">(optional)</span>
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={tagInput}
|
||||
onChange={(e) => setTagInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
addTag()
|
||||
}
|
||||
}}
|
||||
placeholder="e.g. Windows Server, Active Directory"
|
||||
className="flex-1 rounded-lg border border-border bg-card px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/20"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={addTag}
|
||||
className="rounded-lg border border-border px-3 py-2 text-sm text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
{metadata.environment_tags.length > 0 && (
|
||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||
{metadata.environment_tags.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="inline-flex items-center gap-1 rounded-full bg-card border border-border px-2.5 py-0.5 font-label text-xs text-muted-foreground"
|
||||
>
|
||||
{tag}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeTag(tag)}
|
||||
className="ml-0.5 text-muted-foreground/60 hover:text-foreground"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className="rounded-lg border border-red-400/20 bg-red-400/5 px-3 py-2 text-sm text-red-400">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Submit */}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!canSubmit}
|
||||
className={cn(
|
||||
'w-full rounded-lg bg-gradient-brand py-2.5 text-sm font-medium text-white shadow-lg shadow-primary/20',
|
||||
canSubmit ? 'hover:opacity-90' : 'cursor-not-allowed opacity-50'
|
||||
)}
|
||||
>
|
||||
{isLoading ? 'Creating...' : 'Continue to AI Scaffold'}
|
||||
</button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
33
frontend/src/components/ai-builder/GeneratingAnimation.tsx
Normal file
33
frontend/src/components/ai-builder/GeneratingAnimation.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Sparkles } from 'lucide-react'
|
||||
|
||||
const MESSAGES = [
|
||||
'Analyzing your flow requirements...',
|
||||
'Building decision paths...',
|
||||
'Generating troubleshooting logic...',
|
||||
'Crafting resolution steps...',
|
||||
'Structuring the flow...',
|
||||
]
|
||||
|
||||
export function GeneratingAnimation() {
|
||||
const [messageIndex, setMessageIndex] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
setMessageIndex((prev) => (prev + 1) % MESSAGES.length)
|
||||
}, 3000)
|
||||
return () => clearInterval(interval)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center gap-4 py-12">
|
||||
<div className="relative">
|
||||
<div className="h-12 w-12 animate-spin rounded-full border-4 border-border border-t-primary" />
|
||||
<Sparkles className="absolute left-1/2 top-1/2 h-5 w-5 -translate-x-1/2 -translate-y-1/2 text-primary" />
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground animate-pulse">
|
||||
{MESSAGES[messageIndex]}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
48
frontend/src/components/ai-builder/QuotaDisplay.tsx
Normal file
48
frontend/src/components/ai-builder/QuotaDisplay.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { AIQuotaStatus } from '@/types'
|
||||
|
||||
interface QuotaDisplayProps {
|
||||
quota: AIQuotaStatus
|
||||
compact?: boolean
|
||||
}
|
||||
|
||||
export function QuotaDisplay({ quota, compact = false }: QuotaDisplayProps) {
|
||||
if (!quota.ai_enabled) return null
|
||||
|
||||
const monthlyRemaining =
|
||||
quota.monthly_limit !== null
|
||||
? Math.max(0, quota.monthly_limit - quota.monthly_used)
|
||||
: null
|
||||
|
||||
const getColor = () => {
|
||||
if (!quota.allowed) return 'text-red-400'
|
||||
if (monthlyRemaining !== null && monthlyRemaining <= 1) return 'text-amber-400'
|
||||
return 'text-green-400'
|
||||
}
|
||||
|
||||
if (compact) {
|
||||
return (
|
||||
<span className={cn('text-xs font-label', getColor())}>
|
||||
{monthlyRemaining !== null
|
||||
? `${monthlyRemaining}/${quota.monthly_limit} builds`
|
||||
: 'Unlimited'}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 rounded-lg border border-border bg-accent/50 px-3 py-1.5">
|
||||
<div className={cn('h-2 w-2 rounded-full', getColor().replace('text-', 'bg-'))} />
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{monthlyRemaining !== null ? (
|
||||
<>
|
||||
<span className={cn('font-medium', getColor())}>{monthlyRemaining}</span>
|
||||
{' '}of {quota.monthly_limit} AI builds remaining
|
||||
</>
|
||||
) : (
|
||||
'Unlimited AI builds'
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
85
frontend/src/components/ai-builder/TreePreviewCard.tsx
Normal file
85
frontend/src/components/ai-builder/TreePreviewCard.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
import { GitBranch, Layers, CheckCircle, ArrowRight, RotateCcw } from 'lucide-react'
|
||||
import { useAIFlowBuilderStore } from '@/store/aiFlowBuilderStore'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface TreePreviewCardProps {
|
||||
onOpenInEditor: () => void
|
||||
}
|
||||
|
||||
export function TreePreviewCard({ onOpenInEditor }: TreePreviewCardProps) {
|
||||
const { assembledTree, reset, isLoading } = useAIFlowBuilderStore()
|
||||
|
||||
if (!assembledTree) return null
|
||||
|
||||
const { summary } = assembledTree
|
||||
|
||||
const stats = [
|
||||
{ label: 'Nodes', value: summary.node_count, icon: Layers },
|
||||
{ label: 'Decisions', value: summary.decision_count, icon: GitBranch },
|
||||
{ label: 'Solutions', value: summary.solution_count, icon: CheckCircle },
|
||||
{ label: 'Depth', value: summary.depth, icon: Layers },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="text-center">
|
||||
<div className="mx-auto mb-3 flex h-12 w-12 items-center justify-center rounded-full bg-green-400/10">
|
||||
<CheckCircle className="h-6 w-6 text-green-400" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-foreground">
|
||||
Tree Assembled
|
||||
</h3>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
"{assembledTree.suggested_name}" is ready to review in the editor.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Stats grid */}
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{stats.map(({ label, value, icon: Icon }) => (
|
||||
<div
|
||||
key={label}
|
||||
className="flex flex-col items-center rounded-lg border border-border bg-accent/30 p-2.5"
|
||||
>
|
||||
<Icon className="mb-1 h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-lg font-semibold text-gradient-brand">{value}</span>
|
||||
<span className="text-[10px] font-label uppercase tracking-wide text-muted-foreground">
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
{assembledTree.suggested_description && (
|
||||
<div className="rounded-lg border border-border bg-accent/20 p-3">
|
||||
<p className="text-xs text-muted-foreground">{assembledTree.suggested_description}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenInEditor}
|
||||
disabled={isLoading}
|
||||
className={cn(
|
||||
'flex flex-1 items-center justify-center gap-2 rounded-lg bg-gradient-brand py-2.5 text-sm font-medium text-white shadow-lg shadow-primary/20',
|
||||
'hover:opacity-90'
|
||||
)}
|
||||
>
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
Open in Editor
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={reset}
|
||||
className="flex items-center gap-2 rounded-lg border border-border px-4 py-2.5 text-sm text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
Start Over
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
70
frontend/src/components/ai-builder/WizardStepIndicator.tsx
Normal file
70
frontend/src/components/ai-builder/WizardStepIndicator.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import { Check } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { AIWizardPhase } from '@/types'
|
||||
|
||||
const STEPS = [
|
||||
{ key: 'foundation', label: 'Foundation' },
|
||||
{ key: 'scaffolding', label: 'Scaffold' },
|
||||
{ key: 'detailing', label: 'Detail' },
|
||||
{ key: 'reviewing', label: 'Review' },
|
||||
] as const
|
||||
|
||||
const PHASE_ORDER: Record<string, number> = {
|
||||
foundation: 0,
|
||||
scaffolding: 1,
|
||||
generating: 1,
|
||||
detailing: 2,
|
||||
reviewing: 3,
|
||||
completed: 4,
|
||||
error: -1,
|
||||
}
|
||||
|
||||
interface WizardStepIndicatorProps {
|
||||
phase: AIWizardPhase
|
||||
}
|
||||
|
||||
export function WizardStepIndicator({ phase }: WizardStepIndicatorProps) {
|
||||
const currentIndex = PHASE_ORDER[phase] ?? 0
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1 px-2">
|
||||
{STEPS.map((step, i) => {
|
||||
const isCompleted = currentIndex > i
|
||||
const isCurrent = currentIndex === i
|
||||
|
||||
return (
|
||||
<div key={step.key} className="flex items-center gap-1">
|
||||
{i > 0 && (
|
||||
<div
|
||||
className={cn(
|
||||
'h-px w-4 sm:w-6',
|
||||
isCompleted ? 'bg-primary' : 'bg-border'
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div
|
||||
className={cn(
|
||||
'flex h-5 w-5 items-center justify-center rounded-full text-[10px] font-medium',
|
||||
isCompleted && 'bg-primary text-white',
|
||||
isCurrent && 'bg-primary/20 text-primary ring-1 ring-primary/40',
|
||||
!isCompleted && !isCurrent && 'bg-accent text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
{isCompleted ? <Check className="h-3 w-3" /> : i + 1}
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
'hidden text-xs sm:inline',
|
||||
isCurrent ? 'font-medium text-foreground' : 'text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
{step.label}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -37,7 +37,9 @@ export function FlowAnalyticsPanel({ treeId }: FlowAnalyticsPanelProps) {
|
||||
const [error, setError] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setLoading(true)
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setError(false)
|
||||
analyticsApi
|
||||
.getFlowAnalytics(treeId, period)
|
||||
|
||||
93
frontend/src/components/common/CreateFlowDropdown.tsx
Normal file
93
frontend/src/components/common/CreateFlowDropdown.tsx
Normal file
@@ -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 (
|
||||
<div className={cn('relative', className)}>
|
||||
<button
|
||||
onClick={() => setShowMenu(!showMenu)}
|
||||
className="flex items-center gap-2 rounded-lg bg-gradient-brand px-4 py-2 text-sm font-semibold text-white shadow-lg shadow-primary/20 hover:opacity-90 transition-opacity"
|
||||
>
|
||||
<Plus size={16} />
|
||||
{label}
|
||||
<ChevronDown size={14} />
|
||||
</button>
|
||||
{showMenu && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-10" onClick={() => setShowMenu(false)} />
|
||||
<div className="absolute right-0 z-20 mt-1 w-56 rounded-lg border border-border bg-card p-1 shadow-xl backdrop-blur-sm">
|
||||
<Link
|
||||
to="/trees/new"
|
||||
onClick={() => setShowMenu(false)}
|
||||
className="flex items-center gap-3 rounded-md px-3 py-2.5 text-sm text-foreground hover:bg-accent"
|
||||
>
|
||||
<FolderTree className="h-4 w-4 text-muted-foreground" />
|
||||
<div>
|
||||
<div className="font-medium">Troubleshooting Tree</div>
|
||||
<div className="text-xs text-muted-foreground">Branching decision flow</div>
|
||||
</div>
|
||||
</Link>
|
||||
<Link
|
||||
to="/flows/new"
|
||||
onClick={() => setShowMenu(false)}
|
||||
className="flex items-center gap-3 rounded-md px-3 py-2.5 text-sm text-foreground hover:bg-accent"
|
||||
>
|
||||
<ListOrdered className="h-4 w-4 text-muted-foreground" />
|
||||
<div>
|
||||
<div className="font-medium">Procedural Flow</div>
|
||||
<div className="text-xs text-muted-foreground">Step-by-step procedure</div>
|
||||
</div>
|
||||
</Link>
|
||||
<Link
|
||||
to="/flows/new?type=maintenance"
|
||||
onClick={() => setShowMenu(false)}
|
||||
className="flex items-center gap-3 rounded-md px-3 py-2.5 text-sm text-foreground hover:bg-accent"
|
||||
>
|
||||
<Wrench className="h-4 w-4 text-amber-400" />
|
||||
<div>
|
||||
<div className="font-medium">Maintenance Flow</div>
|
||||
<div className="text-xs text-muted-foreground">Scheduled multi-target tasks</div>
|
||||
</div>
|
||||
</Link>
|
||||
{aiEnabled && (
|
||||
<>
|
||||
<div className="my-1 border-t border-border" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setShowMenu(false)
|
||||
onOpenAIBuilder()
|
||||
}}
|
||||
className="flex w-full items-center gap-3 rounded-md px-3 py-2.5 text-sm text-foreground hover:bg-accent"
|
||||
>
|
||||
<Sparkles className="h-4 w-4 text-primary" />
|
||||
<div className="text-left">
|
||||
<div className="font-medium">Build with AI</div>
|
||||
<div className="text-xs text-muted-foreground">AI-assisted flow creation</div>
|
||||
</div>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -29,7 +29,9 @@ export function Modal({ isOpen, onClose, title, children, footer, size = 'md', a
|
||||
setIsFullScreen(next)
|
||||
try {
|
||||
localStorage.setItem('rf-editor-fullscreen', String(next))
|
||||
} catch {}
|
||||
} catch {
|
||||
// localStorage unavailable — ignore
|
||||
}
|
||||
}
|
||||
|
||||
// Close on Escape key
|
||||
|
||||
@@ -40,6 +40,7 @@ export function QuickLaunch({ open, onClose }: QuickLaunchProps) {
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setSelectedIndex(0)
|
||||
treesApi.list({ sort_by: 'updated_at' })
|
||||
.then(trees => setRecentTrees(trees.slice(0, 4)))
|
||||
|
||||
@@ -2,32 +2,36 @@ import { useEffect, useState } from 'react'
|
||||
import { LayoutGrid, Box, PenLine, Clock, FileText, Bookmark, BarChart3, Settings, PanelLeftClose, PanelLeftOpen, MessageSquareText } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useUserPreferencesStore } from '@/store/userPreferencesStore'
|
||||
import { usePinnedFlowsStore } from '@/store/pinnedFlowsStore'
|
||||
import { PinnedFlowsSection } from '@/components/sidebar/PinnedFlowsSection'
|
||||
import { NavItem } from './NavItem'
|
||||
import { sessionsApi, treesApi } from '@/api'
|
||||
import { pinnedFlowsApi } from '@/api/pinnedFlows'
|
||||
import type { PinnedFlow } from '@/api/pinnedFlows'
|
||||
import { toast } from '@/lib/toast'
|
||||
|
||||
export function Sidebar() {
|
||||
const sidebarCollapsed = useUserPreferencesStore(s => s.sidebarCollapsed)
|
||||
const toggleSidebar = useUserPreferencesStore(s => s.toggleSidebar)
|
||||
|
||||
const pinnedItems = usePinnedFlowsStore((s) => s.items)
|
||||
const loadPinned = usePinnedFlowsStore((s) => s.load)
|
||||
const unpinFlow = usePinnedFlowsStore((s) => s.unpin)
|
||||
|
||||
const [activeSessionCount, setActiveSessionCount] = useState(0)
|
||||
const [pinnedFlows, setPinnedFlows] = useState<PinnedFlow[]>([])
|
||||
const [treeCounts, setTreeCounts] = useState({ total: 0, troubleshooting: 0, procedural: 0, maintenance: 0 })
|
||||
|
||||
// Load pinned flows on mount
|
||||
useEffect(() => {
|
||||
loadPinned()
|
||||
}, [loadPinned])
|
||||
|
||||
// Fetch sidebar data on mount
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const [activeSessions, allTrees, pinnedData] = await Promise.all([
|
||||
const [activeSessions, allTrees] = await Promise.all([
|
||||
sessionsApi.list({ completed: false, size: 50 }).catch(() => []),
|
||||
treesApi.list({ sort_by: 'name' }).catch(() => []),
|
||||
pinnedFlowsApi.list().catch(() => ({ items: [], count: 0 })),
|
||||
])
|
||||
setActiveSessionCount(activeSessions.length)
|
||||
setPinnedFlows(pinnedData.items)
|
||||
|
||||
const total = allTrees.length
|
||||
const troubleshooting = allTrees.filter(t => t.tree_type === 'troubleshooting').length
|
||||
@@ -41,16 +45,6 @@ export function Sidebar() {
|
||||
fetchData()
|
||||
}, [])
|
||||
|
||||
const handleUnpin = async (treeId: string) => {
|
||||
try {
|
||||
await pinnedFlowsApi.unpin(treeId)
|
||||
setPinnedFlows(prev => prev.filter(f => f.tree_id !== treeId))
|
||||
toast.success('Unpinned from sidebar')
|
||||
} catch {
|
||||
toast.error('Failed to unpin flow')
|
||||
}
|
||||
}
|
||||
|
||||
const handleSidebarWheel = (e: React.WheelEvent<HTMLElement>) => {
|
||||
const sidebar = e.currentTarget
|
||||
const canSidebarScroll = sidebar.scrollHeight > sidebar.clientHeight
|
||||
@@ -89,7 +83,7 @@ export function Sidebar() {
|
||||
) : (
|
||||
<>
|
||||
{/* Pinned Flows */}
|
||||
<PinnedFlowsSection flows={pinnedFlows} onUnpin={handleUnpin} />
|
||||
<PinnedFlowsSection flows={pinnedItems} onUnpin={unpinFlow} />
|
||||
|
||||
<div className="border-b border-[hsl(var(--border-subtle))]" />
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import { Pencil, Globe, Lock, Trash2, GitBranch, FileText, Wrench } from 'lucide-react'
|
||||
import { Pencil, Globe, Lock, Trash2, GitBranch, FileText, Wrench, Star } from 'lucide-react'
|
||||
import type { TreeListItem } from '@/types'
|
||||
import { TagBadges } from '@/components/common/TagBadges'
|
||||
import { cn } from '@/lib/utils'
|
||||
@@ -13,6 +13,9 @@ interface TreeGridViewProps {
|
||||
onFolderCreated: (parentId?: string | null) => void
|
||||
onDeleteTree: (tree: TreeListItem) => void
|
||||
onForkTree?: (treeId: string) => void
|
||||
pinnedTreeIds?: Set<string>
|
||||
onTogglePin?: (treeId: string) => void
|
||||
pinLoadingTreeIds?: Set<string>
|
||||
}
|
||||
|
||||
export function TreeGridView({
|
||||
@@ -21,6 +24,9 @@ export function TreeGridView({
|
||||
onTagClick,
|
||||
onDeleteTree,
|
||||
onForkTree,
|
||||
pinnedTreeIds,
|
||||
onTogglePin,
|
||||
pinLoadingTreeIds,
|
||||
}: TreeGridViewProps) {
|
||||
const { canEditTree, canDeleteTree } = usePermissions()
|
||||
|
||||
@@ -29,7 +35,7 @@ export function TreeGridView({
|
||||
{trees.map((tree) => (
|
||||
<div
|
||||
key={tree.id}
|
||||
className="bg-card border border-border rounded-2xl p-4 transition-all hover:-translate-y-0.5 hover:border-primary/30 hover:shadow-md sm:p-6"
|
||||
className="relative bg-card border border-border rounded-2xl p-4 transition-all hover:-translate-y-0.5 hover:border-primary/30 hover:shadow-md sm:p-6"
|
||||
>
|
||||
<div className="mb-2 flex items-start justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -48,6 +54,26 @@ export function TreeGridView({
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{onTogglePin && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
e.preventDefault()
|
||||
onTogglePin(tree.id)
|
||||
}}
|
||||
disabled={pinLoadingTreeIds?.has(tree.id)}
|
||||
aria-label={pinnedTreeIds?.has(tree.id) ? 'Remove from favorites' : 'Add to favorites'}
|
||||
className={cn(
|
||||
'rounded-md p-1 transition-colors',
|
||||
pinnedTreeIds?.has(tree.id)
|
||||
? 'text-amber-400 hover:text-amber-300'
|
||||
: 'text-muted-foreground/40 hover:text-amber-400',
|
||||
pinLoadingTreeIds?.has(tree.id) && 'opacity-50 pointer-events-none'
|
||||
)}
|
||||
>
|
||||
<Star size={14} fill={pinnedTreeIds?.has(tree.id) ? 'currentColor' : 'none'} />
|
||||
</button>
|
||||
)}
|
||||
{tree.is_public ? (
|
||||
<span title="Public tree">
|
||||
<Globe className="h-4 w-4 text-muted-foreground" />
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import { Pencil, Globe, Lock, GitBranch, FileText, Trash2, Wrench } from 'lucide-react'
|
||||
import { Pencil, Globe, Lock, GitBranch, FileText, Trash2, Wrench, Star } from 'lucide-react'
|
||||
import type { TreeListItem } from '@/types'
|
||||
import { TagBadges } from '@/components/common/TagBadges'
|
||||
import { cn } from '@/lib/utils'
|
||||
@@ -13,6 +13,9 @@ interface TreeListViewProps {
|
||||
onFolderCreated: (parentId?: string | null) => void
|
||||
onDeleteTree: (tree: TreeListItem) => void
|
||||
onForkTree?: (treeId: string) => void
|
||||
pinnedTreeIds?: Set<string>
|
||||
onTogglePin?: (treeId: string) => void
|
||||
pinLoadingTreeIds?: Set<string>
|
||||
}
|
||||
|
||||
export function TreeListView({
|
||||
@@ -21,6 +24,9 @@ export function TreeListView({
|
||||
onTagClick,
|
||||
onDeleteTree,
|
||||
onForkTree,
|
||||
pinnedTreeIds,
|
||||
onTogglePin,
|
||||
pinLoadingTreeIds,
|
||||
}: TreeListViewProps) {
|
||||
const { canEditTree } = usePermissions()
|
||||
|
||||
@@ -84,6 +90,26 @@ export function TreeListView({
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{onTogglePin && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
e.preventDefault()
|
||||
onTogglePin(tree.id)
|
||||
}}
|
||||
disabled={pinLoadingTreeIds?.has(tree.id)}
|
||||
aria-label={pinnedTreeIds?.has(tree.id) ? 'Remove from favorites' : 'Add to favorites'}
|
||||
className={cn(
|
||||
'shrink-0 rounded-md p-1 transition-colors',
|
||||
pinnedTreeIds?.has(tree.id)
|
||||
? 'text-amber-400 hover:text-amber-300'
|
||||
: 'text-muted-foreground/40 hover:text-amber-400',
|
||||
pinLoadingTreeIds?.has(tree.id) && 'opacity-50 pointer-events-none'
|
||||
)}
|
||||
>
|
||||
<Star size={16} fill={pinnedTreeIds?.has(tree.id) ? 'currentColor' : 'none'} />
|
||||
</button>
|
||||
)}
|
||||
{onForkTree && (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { Pencil, Globe, Lock, ChevronUp, ChevronDown, GitBranch, FileText, Trash2, Wrench } from 'lucide-react'
|
||||
import { Pencil, Globe, Lock, ChevronUp, ChevronDown, GitBranch, FileText, Trash2, Wrench, Star } from 'lucide-react'
|
||||
import type { TreeListItem } from '@/types'
|
||||
import { TagBadges } from '@/components/common/TagBadges'
|
||||
import { cn } from '@/lib/utils'
|
||||
@@ -15,6 +15,9 @@ interface TreeTableViewProps {
|
||||
onDeleteTree: (tree: TreeListItem) => void
|
||||
onSortChange?: (sortBy: string) => void
|
||||
onForkTree?: (treeId: string) => void
|
||||
pinnedTreeIds?: Set<string>
|
||||
onTogglePin?: (treeId: string) => void
|
||||
pinLoadingTreeIds?: Set<string>
|
||||
}
|
||||
|
||||
type SortColumn = 'name' | 'category' | 'version' | 'usage' | 'updated'
|
||||
@@ -26,6 +29,9 @@ export function TreeTableView({
|
||||
onDeleteTree,
|
||||
onSortChange,
|
||||
onForkTree,
|
||||
pinnedTreeIds,
|
||||
onTogglePin,
|
||||
pinLoadingTreeIds,
|
||||
}: TreeTableViewProps) {
|
||||
const { canEditTree } = usePermissions()
|
||||
const [sortColumn, setSortColumn] = useState<SortColumn | null>(null)
|
||||
@@ -73,6 +79,11 @@ export function TreeTableView({
|
||||
<table className="w-full">
|
||||
<thead className="bg-accent/50 sticky top-0 z-10">
|
||||
<tr className="border-b border-border">
|
||||
{onTogglePin && (
|
||||
<th className="w-10 px-2 py-3 text-center">
|
||||
<Star size={14} className="inline text-muted-foreground" />
|
||||
</th>
|
||||
)}
|
||||
<th
|
||||
className="px-4 py-3 text-left text-sm font-medium text-muted-foreground cursor-pointer hover:text-foreground"
|
||||
onClick={() => handleSort('name')}
|
||||
@@ -132,6 +143,28 @@ export function TreeTableView({
|
||||
<tbody className="bg-transparent">
|
||||
{trees.map((tree) => (
|
||||
<tr key={tree.id} className="border-b border-border last:border-0 hover:bg-accent/50">
|
||||
{onTogglePin && (
|
||||
<td className="w-10 px-2 py-3 text-center">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
e.preventDefault()
|
||||
onTogglePin(tree.id)
|
||||
}}
|
||||
disabled={pinLoadingTreeIds?.has(tree.id)}
|
||||
aria-label={pinnedTreeIds?.has(tree.id) ? 'Remove from favorites' : 'Add to favorites'}
|
||||
className={cn(
|
||||
'rounded-md p-1 transition-colors',
|
||||
pinnedTreeIds?.has(tree.id)
|
||||
? 'text-amber-400 hover:text-amber-300'
|
||||
: 'text-muted-foreground/40 hover:text-amber-400',
|
||||
pinLoadingTreeIds?.has(tree.id) && 'opacity-50 pointer-events-none'
|
||||
)}
|
||||
>
|
||||
<Star size={14} fill={pinnedTreeIds?.has(tree.id) ? 'currentColor' : 'none'} />
|
||||
</button>
|
||||
</td>
|
||||
)}
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-foreground truncate max-w-[200px]">
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Star } from 'lucide-react'
|
||||
import { Modal } from '@/components/common/Modal'
|
||||
import { analyticsApi } from '@/api'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { markSessionRated } from './csatUtils'
|
||||
|
||||
interface CSATModalProps {
|
||||
isOpen: boolean
|
||||
@@ -10,26 +11,6 @@ interface CSATModalProps {
|
||||
sessionId: string
|
||||
}
|
||||
|
||||
const RATED_SESSIONS_KEY = 'rf-rated-sessions'
|
||||
|
||||
function getRatedSessions(): string[] {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(RATED_SESSIONS_KEY) || '[]')
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
function markSessionRated(sessionId: string) {
|
||||
const rated = getRatedSessions()
|
||||
rated.push(sessionId)
|
||||
localStorage.setItem(RATED_SESSIONS_KEY, JSON.stringify(rated.slice(-100)))
|
||||
}
|
||||
|
||||
export function hasBeenRated(sessionId: string): boolean {
|
||||
return getRatedSessions().includes(sessionId)
|
||||
}
|
||||
|
||||
export function CSATModal({ isOpen, onClose, sessionId }: CSATModalProps) {
|
||||
const [rating, setRating] = useState(0)
|
||||
const [hoveredRating, setHoveredRating] = useState(0)
|
||||
|
||||
19
frontend/src/components/session/csatUtils.ts
Normal file
19
frontend/src/components/session/csatUtils.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
const RATED_SESSIONS_KEY = 'rf-rated-sessions'
|
||||
|
||||
export function getRatedSessions(): string[] {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(RATED_SESSIONS_KEY) || '[]')
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export function markSessionRated(sessionId: string) {
|
||||
const rated = getRatedSessions()
|
||||
rated.push(sessionId)
|
||||
localStorage.setItem(RATED_SESSIONS_KEY, JSON.stringify(rated.slice(-100)))
|
||||
}
|
||||
|
||||
export function hasBeenRated(sessionId: string): boolean {
|
||||
return getRatedSessions().includes(sessionId)
|
||||
}
|
||||
@@ -10,51 +10,89 @@ interface PinnedFlowsSectionProps {
|
||||
onUnpin: (treeId: string) => void
|
||||
}
|
||||
|
||||
const TRUNCATE_COUNT = 5
|
||||
|
||||
export function PinnedFlowsSection({ flows, onUnpin }: PinnedFlowsSectionProps) {
|
||||
const navigate = useNavigate()
|
||||
const [collapsed, setCollapsed] = useState(false)
|
||||
const [showAll, setShowAll] = useState(false)
|
||||
|
||||
const handleToggleCollapse = () => {
|
||||
if (collapsed) {
|
||||
setShowAll(false) // Reset to truncated on re-expand
|
||||
}
|
||||
setCollapsed(!collapsed)
|
||||
}
|
||||
|
||||
const visibleFlows = showAll ? flows : flows.slice(0, TRUNCATE_COUNT)
|
||||
const hasMore = flows.length > TRUNCATE_COUNT
|
||||
|
||||
const handleFlowClick = (flow: PinnedFlow) => {
|
||||
setShowAll(false) // Collapse back to 5 on navigation
|
||||
navigate(getTreeNavigatePath(flow.tree_id, flow.tree_type))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="px-3 py-2">
|
||||
<button
|
||||
onClick={() => setCollapsed(!collapsed)}
|
||||
onClick={handleToggleCollapse}
|
||||
className="flex w-full items-center gap-1 px-3 mb-1 font-heading text-[0.6875rem] font-bold uppercase tracking-[0.04em] text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
{collapsed ? <ChevronRight size={12} /> : <ChevronDown size={12} />}
|
||||
Pinned
|
||||
{flows.length > 0 && (
|
||||
<span className="ml-auto text-[0.625rem] font-normal">{flows.length}</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{!collapsed && (
|
||||
<div className="space-y-0.5 max-h-[280px] overflow-y-auto">
|
||||
<div
|
||||
className="overflow-hidden transition-[max-height] duration-[250ms] ease-out"
|
||||
style={{
|
||||
maxHeight: collapsed ? 0 : showAll
|
||||
? `${flows.length * 36 + 40}px`
|
||||
: `${Math.min(flows.length, TRUNCATE_COUNT) * 36 + 40}px`,
|
||||
}}
|
||||
>
|
||||
<div className="space-y-0.5">
|
||||
{flows.length === 0 ? (
|
||||
<p className="px-3 py-2 text-xs text-muted-foreground">
|
||||
Pin your most-used flows here
|
||||
</p>
|
||||
) : (
|
||||
flows.map(flow => (
|
||||
<button
|
||||
key={flow.tree_id}
|
||||
onClick={() => navigate(getTreeNavigatePath(flow.tree_id, flow.tree_type))}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault()
|
||||
onUnpin(flow.tree_id)
|
||||
}}
|
||||
className={cn(
|
||||
'group flex w-full items-center gap-2.5 rounded-lg px-3 py-1.5 text-[0.8125rem] font-medium transition-colors',
|
||||
'text-muted-foreground hover:bg-[hsl(var(--sidebar-hover))] hover:text-foreground'
|
||||
)}
|
||||
title={`${flow.tree_name} (right-click to unpin)`}
|
||||
>
|
||||
<span className="text-sm shrink-0">
|
||||
{flow.tree_type === 'procedural' ? '📋' : flow.tree_type === 'maintenance' ? '🛠️' : '🔧'}
|
||||
</span>
|
||||
<span className="truncate flex-1 text-left">{flow.tree_name}</span>
|
||||
<Pin size={12} className="shrink-0 opacity-0 group-hover:opacity-40 transition-opacity" />
|
||||
</button>
|
||||
))
|
||||
<>
|
||||
{visibleFlows.map(flow => (
|
||||
<button
|
||||
key={flow.tree_id}
|
||||
onClick={() => handleFlowClick(flow)}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault()
|
||||
onUnpin(flow.tree_id)
|
||||
}}
|
||||
className={cn(
|
||||
'group flex w-full items-center gap-2.5 rounded-lg px-3 py-1.5 text-[0.8125rem] font-medium transition-colors',
|
||||
'text-muted-foreground hover:bg-[hsl(var(--sidebar-hover))] hover:text-foreground'
|
||||
)}
|
||||
title={`${flow.tree_name} (right-click to unpin)`}
|
||||
>
|
||||
<span className="text-sm shrink-0">
|
||||
{flow.tree_type === 'procedural' ? '📋' : flow.tree_type === 'maintenance' ? '🛠️' : '🔧'}
|
||||
</span>
|
||||
<span className="truncate flex-1 text-left">{flow.tree_name}</span>
|
||||
<Pin size={12} className="shrink-0 opacity-0 group-hover:opacity-40 transition-opacity" />
|
||||
</button>
|
||||
))}
|
||||
{hasMore && (
|
||||
<button
|
||||
onClick={() => setShowAll(!showAll)}
|
||||
className="w-full px-3 py-1 text-xs text-muted-foreground hover:text-foreground transition-colors text-left"
|
||||
>
|
||||
{showAll ? 'Show less' : `Show more (${flows.length})`}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ const TYPE_CONFIG: Record<Exclude<NodeType, 'answer'>, { icon: typeof HelpCircle
|
||||
}
|
||||
|
||||
function cloneWithoutChildren(node: TreeStructure): TreeStructure {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { children: _children, ...rest } = node
|
||||
return structuredClone(rest) as TreeStructure
|
||||
}
|
||||
@@ -44,8 +45,11 @@ export function NodeEditorPanel({ nodeId, onClose, onSelectType }: NodeEditorPan
|
||||
// (e.g., answer stub → decision/action/solution via type picker)
|
||||
useEffect(() => {
|
||||
if (node) {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setDraft(cloneWithoutChildren(node))
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setIsDirty(false)
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setShowDeleteConfirm(false)
|
||||
}
|
||||
}, [nodeId, node?.type]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
@@ -57,6 +61,7 @@ export function NodeEditorPanel({ nodeId, onClose, onSelectType }: NodeEditorPan
|
||||
|
||||
const handleSave = useCallback(() => {
|
||||
if (!draft || !node) return
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { children: _children, ...draftWithoutChildren } = draft
|
||||
updateNode(nodeId, draftWithoutChildren)
|
||||
|
||||
|
||||
@@ -60,6 +60,7 @@ export function useTreeLayout(): UseTreeLayoutResult {
|
||||
|
||||
if (!treeStructure) return { rawNodes: nodes, rawEdges: edges }
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
function walk(node: TreeStructure, _parentId?: string | null) {
|
||||
const isCollapsed = collapsedNodeIds.has(node.id)
|
||||
const hasChildren = (node.children?.length ?? 0) > 0
|
||||
|
||||
38
frontend/src/hooks/useCachedQuota.ts
Normal file
38
frontend/src/hooks/useCachedQuota.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { aiBuilderApi } from '@/api'
|
||||
|
||||
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)
|
||||
|
||||
useEffect(() => {
|
||||
if (cachedResult && Date.now() - cachedResult.timestamp < CACHE_TTL_MS) {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setAiEnabled(cachedResult.aiEnabled)
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
setIsLoading(true)
|
||||
aiBuilderApi
|
||||
.getQuota()
|
||||
.then((q) => {
|
||||
cachedResult = { aiEnabled: q.ai_enabled, timestamp: Date.now() }
|
||||
setAiEnabled(q.ai_enabled)
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => setIsLoading(false))
|
||||
}, [])
|
||||
|
||||
return { aiEnabled, isLoading }
|
||||
}
|
||||
60
frontend/src/hooks/usePaginationParams.ts
Normal file
60
frontend/src/hooks/usePaginationParams.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
import { useCallback, useMemo } from 'react'
|
||||
|
||||
type PageSize = number | 'all'
|
||||
|
||||
interface UsePaginationParamsOptions {
|
||||
defaultPageSize?: number
|
||||
allowedPageSizes?: PageSize[]
|
||||
}
|
||||
|
||||
const DEFAULT_ALLOWED: PageSize[] = [10, 25, 50, 'all']
|
||||
|
||||
export function usePaginationParams(options: UsePaginationParamsOptions = {}) {
|
||||
const { defaultPageSize = 10, allowedPageSizes = DEFAULT_ALLOWED } = options
|
||||
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
|
||||
const page = useMemo(() => {
|
||||
const raw = searchParams.get('page')
|
||||
const n = raw ? parseInt(raw, 10) : 1
|
||||
return Number.isFinite(n) && n >= 1 ? n : 1
|
||||
}, [searchParams])
|
||||
|
||||
const pageSize = useMemo((): PageSize => {
|
||||
const raw = searchParams.get('size')
|
||||
if (raw === 'all' && allowedPageSizes.includes('all')) return 'all'
|
||||
const n = raw ? parseInt(raw, 10) : defaultPageSize
|
||||
if (Number.isFinite(n) && allowedPageSizes.includes(n)) return n
|
||||
return defaultPageSize
|
||||
}, [searchParams, defaultPageSize, allowedPageSizes])
|
||||
|
||||
const setPage = useCallback(
|
||||
(newPage: number) => {
|
||||
setSearchParams((prev) => {
|
||||
const next = new URLSearchParams(prev)
|
||||
if (newPage <= 1) {
|
||||
next.delete('page')
|
||||
} else {
|
||||
next.set('page', String(newPage))
|
||||
}
|
||||
return next
|
||||
})
|
||||
},
|
||||
[setSearchParams]
|
||||
)
|
||||
|
||||
const setPageSize = useCallback(
|
||||
(newSize: PageSize) => {
|
||||
setSearchParams((prev) => {
|
||||
const next = new URLSearchParams(prev)
|
||||
next.set('size', String(newSize))
|
||||
next.delete('page')
|
||||
return next
|
||||
})
|
||||
},
|
||||
[setSearchParams]
|
||||
)
|
||||
|
||||
return { page, pageSize, setPage, setPageSize }
|
||||
}
|
||||
@@ -35,6 +35,7 @@ export default function MyAnalyticsPage() {
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setLoading(true)
|
||||
analyticsApi
|
||||
.getPersonalAnalytics(period)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useNavigate, Link } from 'react-router-dom'
|
||||
import { Play, Pencil, Share2, Trash2, GitBranch, Clock, TrendingUp, FolderTree, Plus, ListOrdered, ChevronDown, Wrench } from 'lucide-react'
|
||||
import { Play, Pencil, Share2, Trash2, GitBranch, Clock, TrendingUp, FolderTree, Plus, ListOrdered, ChevronDown, Wrench, Sparkles } from 'lucide-react'
|
||||
import { treesApi } from '@/api/trees'
|
||||
import { sessionsApi } from '@/api/sessions'
|
||||
import type { TreeListItem } from '@/types'
|
||||
@@ -12,6 +12,8 @@ import { cn } from '@/lib/utils'
|
||||
import { useAuthStore } from '@/store/authStore'
|
||||
import { usePermissions } from '@/hooks/usePermissions'
|
||||
import { toast } from '@/lib/toast'
|
||||
import { AIFlowBuilderModal } from '@/components/ai-builder/AIFlowBuilderModal'
|
||||
import { aiBuilderApi } from '@/api/aiBuilder'
|
||||
|
||||
interface TreeWithStats extends TreeListItem {
|
||||
lastUsed?: string
|
||||
@@ -32,11 +34,17 @@ export function MyTreesPage() {
|
||||
const [treeToShare, setTreeToShare] = useState<TreeWithStats | null>(null)
|
||||
const [showShareModal, setShowShareModal] = useState(false)
|
||||
const [showCreateMenu, setShowCreateMenu] = useState(false)
|
||||
const [showAIBuilder, setShowAIBuilder] = useState(false)
|
||||
const [aiEnabled, setAiEnabled] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
loadMyTrees()
|
||||
}, [user?.id])
|
||||
|
||||
useEffect(() => {
|
||||
aiBuilderApi.getQuota().then((q) => setAiEnabled(q.ai_enabled)).catch(() => {})
|
||||
}, [])
|
||||
|
||||
const loadMyTrees = async () => {
|
||||
if (!user?.id) return
|
||||
setIsLoading(true)
|
||||
@@ -168,6 +176,25 @@ export function MyTreesPage() {
|
||||
<div className="text-xs text-muted-foreground">Scheduled multi-target tasks</div>
|
||||
</div>
|
||||
</Link>
|
||||
{aiEnabled && (
|
||||
<>
|
||||
<div className="my-1 border-t border-border" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setShowCreateMenu(false)
|
||||
setShowAIBuilder(true)
|
||||
}}
|
||||
className="flex w-full items-center gap-3 rounded-md px-3 py-2.5 text-sm text-foreground hover:bg-accent"
|
||||
>
|
||||
<Sparkles className="h-4 w-4 text-primary" />
|
||||
<div className="text-left">
|
||||
<div className="font-medium">Build with AI</div>
|
||||
<div className="text-xs text-muted-foreground">AI-assisted flow creation</div>
|
||||
</div>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
@@ -373,6 +400,12 @@ export function MyTreesPage() {
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* AI Flow Builder Modal */}
|
||||
<AIFlowBuilderModal
|
||||
isOpen={showAIBuilder}
|
||||
onClose={() => setShowAIBuilder(false)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -14,7 +14,8 @@ import { Spinner } from '@/components/common/Spinner'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { toast } from '@/lib/toast'
|
||||
import { StepFeedback } from '@/components/session/StepFeedback'
|
||||
import { CSATModal, hasBeenRated } from '@/components/session/CSATModal'
|
||||
import { CSATModal } from '@/components/session/CSATModal'
|
||||
import { hasBeenRated } from '@/components/session/csatUtils'
|
||||
|
||||
interface StepState {
|
||||
notes: string
|
||||
|
||||
@@ -1,17 +1,26 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { useNavigate, Link } from 'react-router-dom'
|
||||
import { Search, Plus, Loader2 } from 'lucide-react'
|
||||
import { useState, useEffect, useRef, useMemo } from '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'
|
||||
import type { Session } from '@/types/session'
|
||||
import { getTreeNavigatePath } from '@/lib/routing'
|
||||
import { usePermissions } from '@/hooks/usePermissions'
|
||||
import { useAuthStore } from '@/store/authStore'
|
||||
import { usePinnedFlowsStore } from '@/store/pinnedFlowsStore'
|
||||
import { useUserPreferencesStore } from '@/store/userPreferencesStore'
|
||||
import { usePaginationParams } from '@/hooks/usePaginationParams'
|
||||
import { useCachedQuota } from '@/hooks/useCachedQuota'
|
||||
import { QuickStats } from '@/components/dashboard/QuickStats'
|
||||
import { FiltersBar } from '@/components/dashboard/FiltersBar'
|
||||
import { SectionGroup } from '@/components/dashboard/SectionGroup'
|
||||
import { SessionsPanel } from '@/components/dashboard/SessionsPanel'
|
||||
import { TreeListItem as TreeListItemComponent } from '@/components/dashboard/TreeListItem'
|
||||
import { TreeGridView } from '@/components/library/TreeGridView'
|
||||
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 {
|
||||
const now = Date.now()
|
||||
@@ -30,7 +39,9 @@ function timeAgo(dateStr: string): string {
|
||||
export function QuickStartPage() {
|
||||
const navigate = useNavigate()
|
||||
const { canCreateTrees } = usePermissions()
|
||||
const user = useAuthStore((s) => s.user)
|
||||
|
||||
// Search state
|
||||
const [query, setQuery] = useState('')
|
||||
const [searchResults, setSearchResults] = useState<TreeListItem[]>([])
|
||||
const [isSearching, setIsSearching] = useState(false)
|
||||
@@ -38,34 +49,110 @@ export function QuickStartPage() {
|
||||
const searchRef = useRef<HTMLDivElement>(null)
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
const [trees, setTrees] = useState<TreeListItem[]>([])
|
||||
// Sessions state
|
||||
const [activeSessions, setActiveSessions] = useState<Session[]>([])
|
||||
const [allSessions, setAllSessions] = useState<Session[]>([])
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
|
||||
const [activeFilter, setActiveFilter] = useState('all')
|
||||
// My Flows state
|
||||
const [myFlows, setMyFlows] = useState<TreeListItem[]>([])
|
||||
const [isLoadingFlows, setIsLoadingFlows] = useState(true)
|
||||
const [hasNextPage, setHasNextPage] = useState(false)
|
||||
const [allFlowsCeiling, setAllFlowsCeiling] = useState(false)
|
||||
|
||||
// Load data on mount
|
||||
// Favorites state
|
||||
const [showAllFavorites, setShowAllFavorites] = useState(false)
|
||||
|
||||
// AI Builder
|
||||
const [showAIBuilder, setShowAIBuilder] = useState(false)
|
||||
const { aiEnabled } = useCachedQuota()
|
||||
|
||||
// Pin store
|
||||
const pinnedItems = usePinnedFlowsStore((s) => s.items)
|
||||
const pinnedIsLoading = usePinnedFlowsStore((s) => s.isLoading)
|
||||
const loadPinned = usePinnedFlowsStore((s) => s.load)
|
||||
const isMutatingByTreeId = usePinnedFlowsStore((s) => s.isMutatingByTreeId)
|
||||
const togglePin = usePinnedFlowsStore((s) => s.toggle)
|
||||
|
||||
const pinnedTreeIds = useMemo(() => new Set(pinnedItems.map((f) => f.tree_id)), [pinnedItems])
|
||||
const pinLoadingTreeIds = useMemo(
|
||||
() => new Set(Object.entries(isMutatingByTreeId).filter(([, v]) => v).map(([k]) => k)),
|
||||
[isMutatingByTreeId]
|
||||
)
|
||||
|
||||
// Preferences
|
||||
const { dashboardMyFlowsView, setDashboardMyFlowsView } = useUserPreferencesStore()
|
||||
|
||||
// Pagination
|
||||
const { page, pageSize, setPage, setPageSize } = usePaginationParams({
|
||||
defaultPageSize: 10,
|
||||
allowedPageSizes: [10, 25, 50, 'all'],
|
||||
})
|
||||
|
||||
// Load pinned flows
|
||||
useEffect(() => { loadPinned() }, [loadPinned])
|
||||
|
||||
// Load sessions on mount
|
||||
useEffect(() => {
|
||||
async function loadData() {
|
||||
try {
|
||||
const [treeList, active, recent] = await Promise.all([
|
||||
treesApi.list({ sort_by: 'updated_at' }),
|
||||
sessionsApi.list({ completed: false, size: 5 }),
|
||||
sessionsApi.list({ size: 10 }),
|
||||
])
|
||||
setTrees(treeList)
|
||||
setActiveSessions(active)
|
||||
setAllSessions(recent)
|
||||
} catch (err) {
|
||||
console.error('Failed to load dashboard data:', err)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
loadData()
|
||||
Promise.all([
|
||||
sessionsApi.list({ completed: false, size: 5 }).catch(() => []),
|
||||
sessionsApi.list({ size: 10 }).catch(() => []),
|
||||
]).then(([active, recent]) => {
|
||||
setActiveSessions(active)
|
||||
setAllSessions(recent)
|
||||
})
|
||||
}, [])
|
||||
|
||||
// Load my flows when page/size or user changes
|
||||
useEffect(() => {
|
||||
if (!user?.id) return
|
||||
|
||||
const loadFlows = async () => {
|
||||
setIsLoadingFlows(true)
|
||||
setAllFlowsCeiling(false)
|
||||
|
||||
if (pageSize === 'all') {
|
||||
// Fetch in chunks of 100, max 500
|
||||
let allItems: TreeListItem[] = []
|
||||
let skip = 0
|
||||
const CHUNK = 100
|
||||
const MAX = 500
|
||||
|
||||
while (true) {
|
||||
const chunk = await treesApi.list({
|
||||
author_id: user.id,
|
||||
sort_by: 'updated_at',
|
||||
limit: CHUNK,
|
||||
skip,
|
||||
})
|
||||
allItems = [...allItems, ...chunk]
|
||||
if (chunk.length < CHUNK || allItems.length >= MAX) {
|
||||
if (allItems.length >= MAX) {
|
||||
allItems = allItems.slice(0, MAX)
|
||||
setAllFlowsCeiling(true)
|
||||
}
|
||||
break
|
||||
}
|
||||
skip += CHUNK
|
||||
}
|
||||
setMyFlows(allItems)
|
||||
setHasNextPage(false)
|
||||
} else {
|
||||
const numSize = pageSize as number
|
||||
const response = await treesApi.list({
|
||||
author_id: user.id,
|
||||
sort_by: 'updated_at',
|
||||
limit: numSize + 1,
|
||||
skip: (page - 1) * numSize,
|
||||
})
|
||||
setHasNextPage(response.length > numSize)
|
||||
setMyFlows(response.slice(0, numSize))
|
||||
}
|
||||
setIsLoadingFlows(false)
|
||||
}
|
||||
|
||||
loadFlows().catch(() => setIsLoadingFlows(false))
|
||||
}, [user?.id, page, pageSize])
|
||||
|
||||
// Debounced search
|
||||
useEffect(() => {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current)
|
||||
@@ -90,7 +177,7 @@ export function QuickStartPage() {
|
||||
return () => { if (debounceRef.current) clearTimeout(debounceRef.current) }
|
||||
}, [query])
|
||||
|
||||
// Close dropdown on outside click
|
||||
// Close search dropdown on outside click
|
||||
useEffect(() => {
|
||||
function handleClick(e: MouseEvent) {
|
||||
if (searchRef.current && !searchRef.current.contains(e.target as Node)) {
|
||||
@@ -101,8 +188,7 @@ export function QuickStartPage() {
|
||||
return () => document.removeEventListener('mousedown', handleClick)
|
||||
}, [])
|
||||
|
||||
// Compute stats
|
||||
const totalTrees = trees.length
|
||||
// Stats
|
||||
const openSessions = activeSessions.length
|
||||
const todaySessions = allSessions.filter(s => {
|
||||
const d = new Date(s.started_at)
|
||||
@@ -111,14 +197,6 @@ export function QuickStartPage() {
|
||||
}).length
|
||||
const completedSessions = allSessions.filter(s => s.completed_at).length
|
||||
|
||||
// Filter trees
|
||||
const filteredTrees = activeFilter === 'all'
|
||||
? trees
|
||||
: activeFilter === 'recent'
|
||||
? trees.slice(0, 10)
|
||||
: trees
|
||||
|
||||
// Map sessions for SessionsPanel
|
||||
const recentSessionItems = allSessions.slice(0, 5).map(s => ({
|
||||
id: s.id,
|
||||
treeName: s.tree_snapshot?.name || 'Unknown',
|
||||
@@ -127,12 +205,22 @@ export function QuickStartPage() {
|
||||
timeAgo: timeAgo(s.started_at),
|
||||
}))
|
||||
|
||||
const filters = [
|
||||
{ id: 'all', label: 'All' },
|
||||
{ id: 'recent', label: 'Recently Used' },
|
||||
{ id: 'my', label: 'My Flows' },
|
||||
{ id: 'team', label: 'Team Flows' },
|
||||
]
|
||||
// Favorites display
|
||||
const MAX_VISIBLE_FAVORITES = 8
|
||||
const visibleFavorites = showAllFavorites ? pinnedItems : pinnedItems.slice(0, MAX_VISIBLE_FAVORITES)
|
||||
const hasMoreFavorites = pinnedItems.length > MAX_VISIBLE_FAVORITES
|
||||
|
||||
// Handlers
|
||||
const handleStartSession = (treeId: string, treeType?: string) => {
|
||||
navigate(getTreeNavigatePath(treeId, treeType))
|
||||
}
|
||||
|
||||
const handleDeleteTree = () => {} // Not used on dashboard
|
||||
const handleTagClick = () => {} // Not used on dashboard
|
||||
const handleFolderCreated = () => {} // Not used on dashboard
|
||||
|
||||
// Page size options
|
||||
const pageSizeOptions: (number | 'all')[] = [10, 25, 50, 'all']
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
@@ -148,13 +236,10 @@ export function QuickStartPage() {
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{canCreateTrees && (
|
||||
<Link
|
||||
to="/trees/new"
|
||||
className="flex items-center gap-2 rounded-lg bg-gradient-brand px-4 py-2 text-sm font-semibold text-white shadow-lg shadow-primary/20 hover:opacity-90 transition-opacity"
|
||||
>
|
||||
<Plus size={16} />
|
||||
Create Flow
|
||||
</Link>
|
||||
<CreateFlowDropdown
|
||||
aiEnabled={aiEnabled}
|
||||
onOpenAIBuilder={() => setShowAIBuilder(true)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -162,17 +247,14 @@ export function QuickStartPage() {
|
||||
{/* Quick Stats */}
|
||||
<QuickStats
|
||||
stats={[
|
||||
{ label: 'Active Flows', value: totalTrees, gradient: true },
|
||||
{ label: 'My Flows', value: myFlows.length, gradient: true },
|
||||
{ label: 'Sessions Today', value: todaySessions, color: '#f59e0b' },
|
||||
{ label: 'Open Sessions', value: openSessions, meta: `${completedSessions} completed` },
|
||||
{ label: 'Docs Generated', value: completedSessions },
|
||||
{ label: 'Favorites', value: pinnedItems.length },
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* Filters */}
|
||||
<FiltersBar filters={filters} activeFilter={activeFilter} onFilterChange={setActiveFilter} />
|
||||
|
||||
{/* Search (inline, not hero) */}
|
||||
{/* Search */}
|
||||
<div ref={searchRef} className="relative">
|
||||
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground" />
|
||||
<input
|
||||
@@ -215,39 +297,212 @@ export function QuickStartPage() {
|
||||
{/* Recent Sessions */}
|
||||
<SessionsPanel sessions={recentSessionItems} delay={150} />
|
||||
|
||||
{/* Tree/Flow List */}
|
||||
{isLoading ? (
|
||||
<div className="flex justify-center py-12">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : (
|
||||
<SectionGroup
|
||||
title="All Flows"
|
||||
count={filteredTrees.length}
|
||||
delay={200}
|
||||
>
|
||||
{filteredTrees.slice(0, 20).map(tree => (
|
||||
<TreeListItemComponent
|
||||
key={tree.id}
|
||||
id={tree.id}
|
||||
name={tree.name}
|
||||
description={tree.description}
|
||||
treeType={tree.tree_type || 'troubleshooting'}
|
||||
category={tree.category_info ? { name: tree.category_info.name, color: undefined } : null}
|
||||
tags={tree.tags}
|
||||
usageCount={tree.usage_count}
|
||||
updatedAt={tree.updated_at}
|
||||
/>
|
||||
))}
|
||||
{filteredTrees.length > 20 && (
|
||||
<Link
|
||||
to="/trees"
|
||||
className="block rounded-lg border border-border bg-card px-4 py-3 text-center text-sm text-muted-foreground hover:text-foreground hover:border-border/80 transition-colors"
|
||||
{/* Favorites Section */}
|
||||
<div>
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h2 className="font-heading text-lg font-semibold text-foreground">
|
||||
Favorites
|
||||
{pinnedItems.length > 0 && (
|
||||
<span className="ml-2 text-sm font-normal text-muted-foreground">({pinnedItems.length})</span>
|
||||
)}
|
||||
</h2>
|
||||
{hasMoreFavorites && (
|
||||
<button
|
||||
onClick={() => setShowAllFavorites(!showAllFavorites)}
|
||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
View all {filteredTrees.length} flows →
|
||||
</Link>
|
||||
{showAllFavorites ? 'Show less' : 'View all favorites'}
|
||||
</button>
|
||||
)}
|
||||
</SectionGroup>
|
||||
</div>
|
||||
{pinnedIsLoading ? (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-3">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<div key={i} className="h-20 rounded-xl bg-card border border-border animate-pulse" />
|
||||
))}
|
||||
</div>
|
||||
) : pinnedItems.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground py-4">
|
||||
Star a flow to pin it here for quick access.
|
||||
</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-3">
|
||||
{visibleFavorites.map((flow) => (
|
||||
<button
|
||||
key={flow.tree_id}
|
||||
onClick={() => navigate(getTreeNavigatePath(flow.tree_id, flow.tree_type))}
|
||||
className="group relative flex items-center gap-3 rounded-xl bg-card border border-border p-4 text-left transition-colors hover:border-border/80 hover:bg-accent/50"
|
||||
>
|
||||
<span className="text-lg shrink-0">
|
||||
{flow.tree_type === 'procedural' ? '📋' : flow.tree_type === 'maintenance' ? '🛠️' : '🔧'}
|
||||
</span>
|
||||
<span className="truncate text-sm font-medium text-foreground">{flow.tree_name}</span>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
e.preventDefault()
|
||||
togglePin(flow.tree_id)
|
||||
}}
|
||||
aria-label="Remove from favorites"
|
||||
className="absolute top-2 right-2 rounded-md p-1 text-amber-400 opacity-0 group-hover:opacity-100 hover:text-amber-300 transition-all"
|
||||
>
|
||||
<Star size={14} fill="currentColor" />
|
||||
</button>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* My Flows Section */}
|
||||
<div>
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h2 className="font-heading text-lg font-semibold text-foreground">My Flows</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
<ViewToggle view={dashboardMyFlowsView} onChange={setDashboardMyFlowsView} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoadingFlows ? (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<div key={i} className="h-32 rounded-xl bg-card border border-border animate-pulse" />
|
||||
))}
|
||||
</div>
|
||||
) : myFlows.length === 0 ? (
|
||||
<div className="py-12 text-center">
|
||||
<p className="text-muted-foreground mb-4">You haven't created any flows yet.</p>
|
||||
{canCreateTrees && (
|
||||
<CreateFlowDropdown
|
||||
aiEnabled={aiEnabled}
|
||||
onOpenAIBuilder={() => setShowAIBuilder(true)}
|
||||
label="Create your first flow"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{allFlowsCeiling && (
|
||||
<p className="mb-3 text-sm text-muted-foreground">
|
||||
Showing first 500 flows. Use search or filters to find specific flows.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{dashboardMyFlowsView === 'grid' && (
|
||||
<TreeGridView
|
||||
trees={myFlows}
|
||||
onStartSession={handleStartSession}
|
||||
onTagClick={handleTagClick}
|
||||
onFolderCreated={handleFolderCreated}
|
||||
onDeleteTree={handleDeleteTree}
|
||||
pinnedTreeIds={pinnedTreeIds}
|
||||
onTogglePin={togglePin}
|
||||
pinLoadingTreeIds={pinLoadingTreeIds}
|
||||
/>
|
||||
)}
|
||||
{dashboardMyFlowsView === 'list' && (
|
||||
<TreeListView
|
||||
trees={myFlows}
|
||||
onStartSession={handleStartSession}
|
||||
onTagClick={handleTagClick}
|
||||
onFolderCreated={handleFolderCreated}
|
||||
onDeleteTree={handleDeleteTree}
|
||||
pinnedTreeIds={pinnedTreeIds}
|
||||
onTogglePin={togglePin}
|
||||
pinLoadingTreeIds={pinLoadingTreeIds}
|
||||
/>
|
||||
)}
|
||||
{dashboardMyFlowsView === 'table' && (
|
||||
<TreeTableView
|
||||
trees={myFlows}
|
||||
onStartSession={handleStartSession}
|
||||
onTagClick={handleTagClick}
|
||||
onFolderCreated={handleFolderCreated}
|
||||
onDeleteTree={handleDeleteTree}
|
||||
pinnedTreeIds={pinnedTreeIds}
|
||||
onTogglePin={togglePin}
|
||||
pinLoadingTreeIds={pinLoadingTreeIds}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Pagination controls */}
|
||||
{pageSize !== 'all' && (
|
||||
<div className="mt-4 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setPage(page - 1)}
|
||||
disabled={page <= 1}
|
||||
className={cn(
|
||||
'flex items-center gap-1 rounded-md border border-border px-3 py-1.5 text-sm transition-colors',
|
||||
page <= 1 ? 'opacity-50 cursor-not-allowed' : 'hover:bg-accent'
|
||||
)}
|
||||
>
|
||||
<ChevronLeft size={14} />
|
||||
Prev
|
||||
</button>
|
||||
<span className="text-sm text-muted-foreground">Page {page}</span>
|
||||
<button
|
||||
onClick={() => setPage(page + 1)}
|
||||
disabled={!hasNextPage}
|
||||
className={cn(
|
||||
'flex items-center gap-1 rounded-md border border-border px-3 py-1.5 text-sm transition-colors',
|
||||
!hasNextPage ? 'opacity-50 cursor-not-allowed' : 'hover:bg-accent'
|
||||
)}
|
||||
>
|
||||
Next
|
||||
<ChevronRight size={14} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-muted-foreground">Show:</span>
|
||||
<select
|
||||
value={String(pageSize)}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value
|
||||
setPageSize(val === 'all' ? 'all' : parseInt(val, 10))
|
||||
}}
|
||||
className="rounded-md border border-border bg-card px-2 py-1 text-sm text-foreground focus:border-primary focus:outline-none"
|
||||
>
|
||||
{pageSizeOptions.map((opt) => (
|
||||
<option key={String(opt)} value={String(opt)}>
|
||||
{opt === 'all' ? 'All' : opt}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{pageSize === 'all' && (
|
||||
<div className="mt-4 flex items-center justify-end">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-muted-foreground">Show:</span>
|
||||
<select
|
||||
value="all"
|
||||
onChange={(e) => {
|
||||
const val = e.target.value
|
||||
setPageSize(val === 'all' ? 'all' : parseInt(val, 10))
|
||||
}}
|
||||
className="rounded-md border border-border bg-card px-2 py-1 text-sm text-foreground focus:border-primary focus:outline-none"
|
||||
>
|
||||
{pageSizeOptions.map((opt) => (
|
||||
<option key={String(opt)} value={String(opt)}>
|
||||
{opt === 'all' ? 'All' : opt}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* AI Builder Modal */}
|
||||
{showAIBuilder && (
|
||||
<AIFlowBuilderModal
|
||||
isOpen={showAIBuilder}
|
||||
onClose={() => setShowAIBuilder(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -37,6 +37,7 @@ export default function TeamAnalyticsPage() {
|
||||
useEffect(() => {
|
||||
if (!isAccountOwner && !isSuperAdmin) return
|
||||
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setLoading(true)
|
||||
analyticsApi
|
||||
.getTeamAnalytics(period)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import { useNavigate, Link, useSearchParams } from 'react-router-dom'
|
||||
import { Plus, X, RotateCcw, Play } from 'lucide-react'
|
||||
import { useEffect, useState, useCallback, useMemo } from '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,9 +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() {
|
||||
@@ -69,6 +73,21 @@ export function TreeLibraryPage() {
|
||||
// Fork state
|
||||
const [isForkingTree, setIsForkingTree] = useState(false)
|
||||
|
||||
// AI builder state
|
||||
const [showAIBuilder, setShowAIBuilder] = useState(false)
|
||||
const { aiEnabled } = useCachedQuota()
|
||||
|
||||
// Pin store
|
||||
const pinnedItems = usePinnedFlowsStore((s) => s.items)
|
||||
const isMutatingByTreeId = usePinnedFlowsStore((s) => s.isMutatingByTreeId)
|
||||
const pinnedTreeIds = useMemo(() => new Set(pinnedItems.map((f) => f.tree_id)), [pinnedItems])
|
||||
const pinLoadingTreeIds = useMemo(
|
||||
() => new Set(Object.entries(isMutatingByTreeId).filter(([, v]) => v).map(([k]) => k)),
|
||||
[isMutatingByTreeId]
|
||||
)
|
||||
const togglePin = usePinnedFlowsStore((s) => s.toggle)
|
||||
const loadPinned = usePinnedFlowsStore((s) => s.load)
|
||||
|
||||
// Repeat Last Session
|
||||
const lastSessionData = (() => {
|
||||
const raw = safeGetItem('last-session')
|
||||
@@ -102,6 +121,9 @@ export function TreeLibraryPage() {
|
||||
.catch((err) => console.error('Failed to load incomplete sessions:', err))
|
||||
}, [])
|
||||
|
||||
// Load pinned flows
|
||||
useEffect(() => { loadPinned() }, [loadPinned])
|
||||
|
||||
const dismissSession = (sessionId: string) => {
|
||||
const next = new Set(dismissedSessionIds)
|
||||
next.add(sessionId)
|
||||
@@ -193,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) => {
|
||||
@@ -263,16 +279,11 @@ export function TreeLibraryPage() {
|
||||
</p>
|
||||
</div>
|
||||
{canCreateTrees && (
|
||||
<Link
|
||||
to={typeFilter === 'procedural' ? '/flows/new' : typeFilter === 'maintenance' ? '/flows/new?type=maintenance' : '/trees/new'}
|
||||
className={cn(
|
||||
'flex items-center gap-2 rounded-md bg-gradient-brand px-4 py-2 text-sm font-medium text-white shadow-lg shadow-primary/20',
|
||||
'hover:opacity-90'
|
||||
)}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
{typeFilter === 'procedural' ? 'New Project' : typeFilter === 'maintenance' ? 'New Maintenance Flow' : 'Create Flow'}
|
||||
</Link>
|
||||
<CreateFlowDropdown
|
||||
aiEnabled={aiEnabled}
|
||||
onOpenAIBuilder={() => setShowAIBuilder(true)}
|
||||
label="Create New"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -474,6 +485,9 @@ export function TreeLibraryPage() {
|
||||
setShowDeleteConfirm(true)
|
||||
}}
|
||||
onForkTree={handleForkTree}
|
||||
pinnedTreeIds={pinnedTreeIds}
|
||||
onTogglePin={togglePin}
|
||||
pinLoadingTreeIds={pinLoadingTreeIds}
|
||||
/>
|
||||
)}
|
||||
{treeLibraryView === 'list' && (
|
||||
@@ -487,6 +501,9 @@ export function TreeLibraryPage() {
|
||||
setShowDeleteConfirm(true)
|
||||
}}
|
||||
onForkTree={handleForkTree}
|
||||
pinnedTreeIds={pinnedTreeIds}
|
||||
onTogglePin={togglePin}
|
||||
pinLoadingTreeIds={pinLoadingTreeIds}
|
||||
/>
|
||||
)}
|
||||
{treeLibraryView === 'table' && (
|
||||
@@ -505,6 +522,9 @@ export function TreeLibraryPage() {
|
||||
)
|
||||
}}
|
||||
onForkTree={handleForkTree}
|
||||
pinnedTreeIds={pinnedTreeIds}
|
||||
onTogglePin={togglePin}
|
||||
pinLoadingTreeIds={pinLoadingTreeIds}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
@@ -538,6 +558,14 @@ export function TreeLibraryPage() {
|
||||
confirmVariant="destructive"
|
||||
isLoading={isDeleting}
|
||||
/>
|
||||
|
||||
{/* AI Builder Modal */}
|
||||
{showAIBuilder && (
|
||||
<AIFlowBuilderModal
|
||||
isOpen={showAIBuilder}
|
||||
onClose={() => setShowAIBuilder(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -15,7 +15,8 @@ import { Spinner } from '@/components/common/Spinner'
|
||||
import { toast } from '@/lib/toast'
|
||||
import { Modal } from '@/components/common/Modal'
|
||||
import { ShareSessionModal } from '@/components/session/ShareSessionModal'
|
||||
import { CSATModal, hasBeenRated } from '@/components/session/CSATModal'
|
||||
import { CSATModal } from '@/components/session/CSATModal'
|
||||
import { hasBeenRated } from '@/components/session/csatUtils'
|
||||
import { StepFeedback } from '@/components/session/StepFeedback'
|
||||
import { buildSessionShareUrl, getLatestActiveShareForSession } from '@/lib/sessionShare'
|
||||
|
||||
|
||||
205
frontend/src/store/aiFlowBuilderStore.ts
Normal file
205
frontend/src/store/aiFlowBuilderStore.ts
Normal file
@@ -0,0 +1,205 @@
|
||||
import { create } from 'zustand'
|
||||
import { aiBuilderApi } from '@/api/aiBuilder'
|
||||
import type { AIQuotaStatus, AIBranch, AIAssembleResponse, AIWizardPhase } from '@/types'
|
||||
|
||||
interface AIFlowBuilderState {
|
||||
// Wizard state
|
||||
phase: AIWizardPhase
|
||||
conversationId: string | null
|
||||
metadata: {
|
||||
flow_type: 'troubleshooting' | 'procedural'
|
||||
name: string
|
||||
description: string
|
||||
environment_tags: string[]
|
||||
category_id: string | null
|
||||
}
|
||||
|
||||
// Stage 2
|
||||
suggestedBranches: AIBranch[]
|
||||
selectedBranches: AIBranch[]
|
||||
|
||||
// Stage 3
|
||||
currentBranchIndex: number
|
||||
|
||||
// Stage 4
|
||||
assembledTree: AIAssembleResponse | null
|
||||
|
||||
// Quota
|
||||
quota: AIQuotaStatus | null
|
||||
|
||||
// UI state
|
||||
isLoading: boolean
|
||||
error: string | null
|
||||
|
||||
// Actions
|
||||
loadQuota: () => Promise<void>
|
||||
setMetadata: (metadata: Partial<AIFlowBuilderState['metadata']>) => void
|
||||
start: () => Promise<void>
|
||||
scaffold: () => Promise<void>
|
||||
selectBranches: (branches: AIBranch[]) => void
|
||||
generateBranchDetail: (branchName: string) => Promise<void>
|
||||
assemble: () => Promise<void>
|
||||
reset: () => void
|
||||
setPhase: (phase: AIWizardPhase) => void
|
||||
setError: (error: string | null) => void
|
||||
}
|
||||
|
||||
const initialMetadata = {
|
||||
flow_type: 'troubleshooting' as const,
|
||||
name: '',
|
||||
description: '',
|
||||
environment_tags: [] as string[],
|
||||
category_id: null as string | null,
|
||||
}
|
||||
|
||||
export const useAIFlowBuilderStore = create<AIFlowBuilderState>()((set, get) => ({
|
||||
phase: 'foundation',
|
||||
conversationId: null,
|
||||
metadata: { ...initialMetadata },
|
||||
suggestedBranches: [],
|
||||
selectedBranches: [],
|
||||
currentBranchIndex: 0,
|
||||
assembledTree: null,
|
||||
quota: null,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
|
||||
loadQuota: async () => {
|
||||
try {
|
||||
const quota = await aiBuilderApi.getQuota()
|
||||
set({ quota })
|
||||
} catch {
|
||||
// Silently fail — quota display is optional
|
||||
}
|
||||
},
|
||||
|
||||
setMetadata: (metadata) => {
|
||||
set((state) => ({
|
||||
metadata: { ...state.metadata, ...metadata },
|
||||
}))
|
||||
},
|
||||
|
||||
start: async () => {
|
||||
const { metadata } = get()
|
||||
set({ isLoading: true, error: null })
|
||||
try {
|
||||
const response = await aiBuilderApi.start({
|
||||
flow_type: metadata.flow_type,
|
||||
name: metadata.name,
|
||||
description: metadata.description,
|
||||
environment_tags: metadata.environment_tags,
|
||||
category_id: metadata.category_id ?? undefined,
|
||||
})
|
||||
set({
|
||||
conversationId: response.conversation_id,
|
||||
phase: 'scaffolding',
|
||||
isLoading: false,
|
||||
})
|
||||
} catch (err) {
|
||||
const message = _extractError(err)
|
||||
set({ error: message, isLoading: false })
|
||||
}
|
||||
},
|
||||
|
||||
scaffold: async () => {
|
||||
const { conversationId } = get()
|
||||
if (!conversationId) return
|
||||
set({ isLoading: true, error: null, phase: 'generating' })
|
||||
try {
|
||||
const response = await aiBuilderApi.scaffold(conversationId)
|
||||
const branches: AIBranch[] = response.branches.map((b) => ({
|
||||
name: b.name,
|
||||
description: b.description,
|
||||
}))
|
||||
set({
|
||||
suggestedBranches: branches,
|
||||
selectedBranches: branches,
|
||||
phase: 'scaffolding',
|
||||
isLoading: false,
|
||||
})
|
||||
} catch (err) {
|
||||
const message = _extractError(err)
|
||||
set({ error: message, phase: 'error', isLoading: false })
|
||||
}
|
||||
},
|
||||
|
||||
selectBranches: (branches) => {
|
||||
set({ selectedBranches: branches })
|
||||
},
|
||||
|
||||
generateBranchDetail: async (branchName) => {
|
||||
const { conversationId, selectedBranches } = get()
|
||||
if (!conversationId) return
|
||||
set({ isLoading: true, error: null, phase: 'generating' })
|
||||
try {
|
||||
const response = await aiBuilderApi.branchDetail(conversationId, branchName)
|
||||
const updatedBranches = selectedBranches.map((b) =>
|
||||
b.name === branchName ? { ...b, steps: response.steps } : b
|
||||
)
|
||||
// Advance to the next branch that still needs detail
|
||||
const nextIndex = updatedBranches.findIndex((b) => !b.steps)
|
||||
const currentBranchIndex = nextIndex !== -1 ? nextIndex : updatedBranches.findIndex((b) => b.name === branchName)
|
||||
set({
|
||||
selectedBranches: updatedBranches,
|
||||
currentBranchIndex,
|
||||
phase: 'detailing',
|
||||
isLoading: false,
|
||||
})
|
||||
} catch (err) {
|
||||
const message = _extractError(err)
|
||||
set({ error: message, phase: 'error', isLoading: false })
|
||||
}
|
||||
},
|
||||
|
||||
assemble: async () => {
|
||||
const { conversationId, selectedBranches } = get()
|
||||
if (!conversationId) return
|
||||
set({ isLoading: true, error: null })
|
||||
try {
|
||||
const response = await aiBuilderApi.assemble(
|
||||
conversationId,
|
||||
selectedBranches.map((b) => ({
|
||||
name: b.name,
|
||||
description: b.description,
|
||||
steps: b.steps,
|
||||
}))
|
||||
)
|
||||
set({
|
||||
assembledTree: response,
|
||||
phase: 'reviewing',
|
||||
isLoading: false,
|
||||
})
|
||||
} catch (err) {
|
||||
const message = _extractError(err)
|
||||
set({ error: message, phase: 'error', isLoading: false })
|
||||
}
|
||||
},
|
||||
|
||||
reset: () => {
|
||||
set({
|
||||
phase: 'foundation',
|
||||
conversationId: null,
|
||||
metadata: { ...initialMetadata },
|
||||
suggestedBranches: [],
|
||||
selectedBranches: [],
|
||||
currentBranchIndex: 0,
|
||||
assembledTree: null,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
})
|
||||
},
|
||||
|
||||
setPhase: (phase) => set({ phase }),
|
||||
setError: (error) => set({ error }),
|
||||
}))
|
||||
|
||||
function _extractError(err: unknown): string {
|
||||
if (err && typeof err === 'object' && 'response' in err) {
|
||||
const axiosErr = err as { response?: { data?: { detail?: string | { message?: string } } } }
|
||||
const detail = axiosErr.response?.data?.detail
|
||||
if (typeof detail === 'string') return detail
|
||||
if (detail && typeof detail === 'object' && 'message' in detail) return detail.message ?? 'Unknown error'
|
||||
}
|
||||
if (err instanceof Error) return err.message
|
||||
return 'An unexpected error occurred'
|
||||
}
|
||||
@@ -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<AuthState>()(
|
||||
} finally {
|
||||
localStorage.removeItem('access_token')
|
||||
localStorage.removeItem('refresh_token')
|
||||
clearCachedQuota()
|
||||
set({ user: null, token: null, account: null, subscription: null, isAuthenticated: false, error: null })
|
||||
}
|
||||
},
|
||||
|
||||
104
frontend/src/store/pinnedFlowsStore.ts
Normal file
104
frontend/src/store/pinnedFlowsStore.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import { create } from 'zustand'
|
||||
import { pinnedFlowsApi } from '@/api/pinnedFlows'
|
||||
import type { PinnedFlow } from '@/api/pinnedFlows'
|
||||
import { toast } from '@/lib/toast'
|
||||
|
||||
interface PinnedFlowsState {
|
||||
items: PinnedFlow[]
|
||||
isLoaded: boolean
|
||||
isLoading: boolean
|
||||
isMutatingByTreeId: Record<string, boolean>
|
||||
error: string | null
|
||||
|
||||
load: (force?: boolean) => Promise<void>
|
||||
pin: (treeId: string) => Promise<void>
|
||||
unpin: (treeId: string) => Promise<void>
|
||||
toggle: (treeId: string) => void
|
||||
isPinned: (treeId: string) => boolean
|
||||
}
|
||||
|
||||
export const usePinnedFlowsStore = create<PinnedFlowsState>()((set, get) => ({
|
||||
items: [],
|
||||
isLoaded: false,
|
||||
isLoading: false,
|
||||
isMutatingByTreeId: {},
|
||||
error: null,
|
||||
|
||||
load: async (force = false) => {
|
||||
const state = get()
|
||||
if (state.isLoaded && !force) return
|
||||
if (state.isLoading) return
|
||||
|
||||
set({ isLoading: true, error: null })
|
||||
try {
|
||||
const data = await pinnedFlowsApi.list()
|
||||
set({ items: data.items, isLoaded: true, isLoading: false })
|
||||
} catch {
|
||||
set({ error: 'Failed to load pinned flows', isLoading: false })
|
||||
}
|
||||
},
|
||||
|
||||
pin: async (treeId: string) => {
|
||||
const state = get()
|
||||
if (state.isMutatingByTreeId[treeId]) return
|
||||
|
||||
set({ isMutatingByTreeId: { ...state.isMutatingByTreeId, [treeId]: true } })
|
||||
|
||||
try {
|
||||
await pinnedFlowsApi.pin(treeId)
|
||||
const data = await pinnedFlowsApi.list()
|
||||
set((s) => ({
|
||||
items: data.items,
|
||||
isMutatingByTreeId: { ...s.isMutatingByTreeId, [treeId]: false },
|
||||
}))
|
||||
} catch (err: unknown) {
|
||||
const status = (err as { response?: { status?: number } })?.response?.status
|
||||
if (status === 409) {
|
||||
toast.error('Maximum of 15 favorites reached. Unpin a flow to add a new one.')
|
||||
} else {
|
||||
toast.error('Failed to pin flow')
|
||||
}
|
||||
set((s) => ({
|
||||
isMutatingByTreeId: { ...s.isMutatingByTreeId, [treeId]: false },
|
||||
}))
|
||||
}
|
||||
},
|
||||
|
||||
unpin: async (treeId: string) => {
|
||||
const state = get()
|
||||
if (state.isMutatingByTreeId[treeId]) return
|
||||
|
||||
const prevItems = state.items
|
||||
set({
|
||||
items: state.items.filter((f) => f.tree_id !== treeId),
|
||||
isMutatingByTreeId: { ...state.isMutatingByTreeId, [treeId]: true },
|
||||
})
|
||||
|
||||
try {
|
||||
await pinnedFlowsApi.unpin(treeId)
|
||||
set((s) => ({
|
||||
isMutatingByTreeId: { ...s.isMutatingByTreeId, [treeId]: false },
|
||||
}))
|
||||
} catch {
|
||||
toast.error('Failed to unpin flow')
|
||||
set((s) => ({
|
||||
items: prevItems,
|
||||
isMutatingByTreeId: { ...s.isMutatingByTreeId, [treeId]: false },
|
||||
}))
|
||||
}
|
||||
},
|
||||
|
||||
toggle: (treeId: string) => {
|
||||
const state = get()
|
||||
if (state.isPinned(treeId)) {
|
||||
state.unpin(treeId)
|
||||
} else {
|
||||
state.pin(treeId)
|
||||
}
|
||||
},
|
||||
|
||||
isPinned: (treeId: string) => {
|
||||
return get().items.some((f) => f.tree_id === treeId)
|
||||
},
|
||||
}))
|
||||
|
||||
@@ -17,6 +17,8 @@ interface UserPreferencesState {
|
||||
setPreferredEditorMode: (mode: EditorMode) => void
|
||||
sidebarCollapsed: boolean
|
||||
toggleSidebar: () => void
|
||||
dashboardMyFlowsView: TreeLibraryView
|
||||
setDashboardMyFlowsView: (view: TreeLibraryView) => void
|
||||
}
|
||||
|
||||
export const useUserPreferencesStore = create<UserPreferencesState>()(
|
||||
@@ -32,6 +34,8 @@ export const useUserPreferencesStore = create<UserPreferencesState>()(
|
||||
setPreferredEditorMode: (mode) => set({ preferredEditorMode: mode }),
|
||||
sidebarCollapsed: false,
|
||||
toggleSidebar: () => set({ sidebarCollapsed: !get().sidebarCollapsed }),
|
||||
dashboardMyFlowsView: 'grid',
|
||||
setDashboardMyFlowsView: (view) => set({ dashboardMyFlowsView: view }),
|
||||
}),
|
||||
{
|
||||
name: 'user-preferences-storage',
|
||||
|
||||
60
frontend/src/types/ai.ts
Normal file
60
frontend/src/types/ai.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
export interface AIQuotaStatus {
|
||||
plan: string
|
||||
monthly_used: number
|
||||
monthly_limit: number | null
|
||||
monthly_reset_at: string
|
||||
daily_used: number
|
||||
daily_limit: number | null
|
||||
daily_reset_at: string
|
||||
allowed: boolean
|
||||
ai_enabled: boolean
|
||||
}
|
||||
|
||||
export interface AIBranch {
|
||||
name: string
|
||||
description: string
|
||||
steps?: Record<string, unknown>
|
||||
isCustom?: boolean
|
||||
}
|
||||
|
||||
export interface AITreeSummary {
|
||||
node_count: number
|
||||
decision_count: number
|
||||
action_count: number
|
||||
solution_count: number
|
||||
depth: number
|
||||
}
|
||||
|
||||
export interface AIStartResponse {
|
||||
conversation_id: string
|
||||
status: string
|
||||
}
|
||||
|
||||
export interface AIScaffoldResponse {
|
||||
conversation_id: string
|
||||
branches: Array<{ name: string; description: string }>
|
||||
status: string
|
||||
}
|
||||
|
||||
export interface AIBranchDetailResponse {
|
||||
conversation_id: string
|
||||
branch_name: string
|
||||
steps: Record<string, unknown>
|
||||
status: string
|
||||
}
|
||||
|
||||
export interface AIAssembleResponse {
|
||||
tree_structure: Record<string, unknown>
|
||||
suggested_name: string
|
||||
suggested_description: string
|
||||
summary: AITreeSummary
|
||||
status: string
|
||||
}
|
||||
|
||||
export type AIWizardPhase =
|
||||
| 'foundation'
|
||||
| 'scaffolding'
|
||||
| 'detailing'
|
||||
| 'reviewing'
|
||||
| 'generating'
|
||||
| 'error'
|
||||
@@ -34,3 +34,14 @@ export type {
|
||||
BatchLaunchRequest,
|
||||
BatchLaunchResponse,
|
||||
} from './maintenance'
|
||||
|
||||
export type {
|
||||
AIQuotaStatus,
|
||||
AIBranch,
|
||||
AITreeSummary,
|
||||
AIStartResponse,
|
||||
AIScaffoldResponse,
|
||||
AIBranchDetailResponse,
|
||||
AIAssembleResponse,
|
||||
AIWizardPhase,
|
||||
} from './ai'
|
||||
|
||||
Reference in New Issue
Block a user