feat: flow export/import + procedural Flow Assist #96
20
CLAUDE.md
20
CLAUDE.md
@@ -279,7 +279,7 @@ navigate(`/trees/${newTree.id}/edit`)
|
|||||||
|
|
||||||
**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.
|
**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`.
|
**24. Anthropic model IDs:** Both alias (`claude-sonnet-4-6`) and dated (`claude-haiku-4-5-20251001`) forms work. Current default: `claude-sonnet-4-6`. See `backend/app/core/config.py` → `AI_MODEL_ANTHROPIC`.
|
||||||
|
|
||||||
**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`.
|
**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`.
|
||||||
|
|
||||||
@@ -313,12 +313,28 @@ navigate(`/trees/${newTree.id}/edit`)
|
|||||||
|
|
||||||
**38. Alembic migrations MUST use sequential numbered prefixes:** Check `backend/alembic/versions/` for the highest numbered migration and use the next number. Format: `XXX_descriptive_name.py` (e.g., `040_add_whatever.py`). NEVER use auto-generated revision IDs like `0f1ca2af3647`. Always pass `--rev-id` flag: `alembic revision --autogenerate -m "desc" --rev-id=040`.
|
**38. Alembic migrations MUST use sequential numbered prefixes:** Check `backend/alembic/versions/` for the highest numbered migration and use the next number. Format: `XXX_descriptive_name.py` (e.g., `040_add_whatever.py`). NEVER use auto-generated revision IDs like `0f1ca2af3647`. Always pass `--rev-id` flag: `alembic revision --autogenerate -m "desc" --rev-id=040`.
|
||||||
|
|
||||||
**41. Assistant chat uses local React state, not a Zustand store:** `AssistantChatPage.tsx` manages `chats`, `activeChatId`, `messages`, `input`, `loading` as `useState`. The `aiChatStore.ts` is for the AI Chat Builder (tree generation), NOT the standalone assistant. Don't look for a store when modifying assistant chat.
|
**41. Assistant chat uses local React state, not a Zustand store:** `AssistantChatPage.tsx` manages `chats`, `activeChatId`, `messages`, `input`, `loading` as `useState`. Don't look for a store when modifying assistant chat.
|
||||||
|
|
||||||
**42. Public pages use raw `fetch()`, not `apiClient`:** Survey, shared sessions, and other no-auth pages call the API directly via `fetch()` with `${import.meta.env.VITE_API_URL || 'http://localhost:8000'}/api/v1/...`. Don't use `apiClient` — it requires auth tokens and uses relative paths.
|
**42. Public pages use raw `fetch()`, not `apiClient`:** Survey, shared sessions, and other no-auth pages call the API directly via `fetch()` with `${import.meta.env.VITE_API_URL || 'http://localhost:8000'}/api/v1/...`. Don't use `apiClient` — it requires auth tokens and uses relative paths.
|
||||||
|
|
||||||
**43. Adding new email types:** Add static async method to `EmailService` in `core/email.py`. Pattern: check `settings.email_enabled`, import `resend`, build HTML string, call `resend.Emails.send()`, return `bool`. Always fire-and-forget from endpoints (log errors, don't fail the request).
|
**43. Adding new email types:** Add static async method to `EmailService` in `core/email.py`. Pattern: check `settings.email_enabled`, import `resend`, build HTML string, call `resend.Emails.send()`, return `bool`. Always fire-and-forget from endpoints (log errors, don't fail the request).
|
||||||
|
|
||||||
|
**44. AI Chat Builder (Flow Assist) is flow-type-aware:** `ai_chat_service.py` dispatches system prompts, response markers, and validation by `flow_type`. Troubleshooting uses `[TREE_UPDATE]` markers + `validate_generated_tree()`. Procedural/maintenance uses `[STEPS_UPDATE]` markers + `validate_generated_procedural_steps()`. Both support `[METADATA]`; procedural also supports `[INTAKE_FORM]`.
|
||||||
|
|
||||||
|
**45. Intake form field schema uses `variable_name` and `field_type`:** NOT `name` and `type`. Pattern: `{"variable_name": "server_name", "label": "Server Name", "field_type": "text", "required": true, "display_order": 1}`. Used in `tree_validation.py` and AI prompt examples.
|
||||||
|
|
||||||
|
**46. `CreateFlowDropdown` uses `AIPromptDialog` for AI-assisted creation:** Opens a simple prompt dialog modal (not a separate page). The dialog starts an AI session, generates the flow, imports it, and navigates to the editor with `{ state: { aiPanelOpen: true, sessionId } }`. The old standalone `/ai/chat` page and `AIFlowBuilderModal` have been removed.
|
||||||
|
|
||||||
|
**47. Editor-Embedded Flow Assist architecture:** AI assistance is embedded in each editor via `EditorAIPanel` (320px side panel) + `useEditorAI` hook + `ContextMenu`. Tree editor: panel replaces node editor panel (single-panel rule). Procedural editor: panel sits alongside step list (flex layout). Ghost nodes/steps use `_suggestion: true` flag with dashed borders + accept/dismiss controls. Action types (`generate_branch`, `modify_node`, `add_steps`, etc.) route to model tiers via `settings.get_model_for_action()`. Delta responses use `[DELTA]...[/DELTA]` markers. Suggestion audit trail in `ai_suggestions` table.
|
||||||
|
|
||||||
|
**48. Tree orphan validation uses dynamic root ID:** `treeEditorStore.ts` orphan check compares against `state.treeStructure?.id` (NOT hardcoded `'root'`). AI-generated trees use descriptive root IDs like `"verify-account-exists"`.
|
||||||
|
|
||||||
|
**49. Full-stack features — verify both ends:** When adding a field to a backend API response (e.g., `working_tree` in `AIChatMessageResponse`), always verify the frontend consumer actually reads and uses it. Similarly, when a frontend hook/component expects data from an API, confirm the backend populates it. Check the full data flow: schema → endpoint → API client → hook → store → UI.
|
||||||
|
|
||||||
|
**50. Anthropic SDK retry behavior:** Default `max_retries=2` with exponential backoff can cause requests to take 3× the timeout (e.g., 45s × 3 = 135s). Set `max_retries=1` in `AnthropicProvider` to fail fast. Current timeout is `AI_REQUEST_TIMEOUT_SECONDS=120`.
|
||||||
|
|
||||||
|
**51. AI model tier routing:** `config.py` has `AI_MODEL_TIERS` (fast/standard) and `ACTION_MODEL_MAP` mapping action types to tiers. Use `settings.get_model_for_action(action_type)` to resolve concrete model names. Model IDs must be valid — use alias form (`claude-sonnet-4-6`) not invented dated forms.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## RBAC & Permissions
|
## RBAC & Permissions
|
||||||
|
|||||||
@@ -86,6 +86,7 @@
|
|||||||
|
|
||||||
| Task | Status | Notes |
|
| Task | Status | Notes |
|
||||||
|------|--------|-------|
|
|------|--------|-------|
|
||||||
|
| Editor-Embedded Flow Assist | In Progress | AI panel in tree + procedural editors, ghost node suggestions, action-type routing |
|
||||||
| Step Library Frontend | In Progress | Backend complete, frontend UI pending |
|
| Step Library Frontend | In Progress | Backend complete, frontend UI pending |
|
||||||
| Procedural Flows Lifecycle | In Progress | Resume support done, full run chooser/reuse pending |
|
| Procedural Flows Lifecycle | In Progress | Resume support done, full run chooser/reuse pending |
|
||||||
| Tree Forking UI | Planning | Backend schema complete (migration 022) |
|
| Tree Forking UI | Planning | Backend schema complete (migration 022) |
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ from app.models.copilot_conversation import CopilotConversation
|
|||||||
from app.models.assistant_chat import AssistantChat
|
from app.models.assistant_chat import AssistantChat
|
||||||
from app.models.survey_response import SurveyResponse
|
from app.models.survey_response import SurveyResponse
|
||||||
from app.models.survey_invite import SurveyInvite
|
from app.models.survey_invite import SurveyInvite
|
||||||
|
from app.models.ai_suggestion import AISuggestion # noqa: F401
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
|
|
||||||
# this is the Alembic Config object
|
# this is the Alembic Config object
|
||||||
|
|||||||
23
backend/alembic/versions/050_add_import_metadata_to_trees.py
Normal file
23
backend/alembic/versions/050_add_import_metadata_to_trees.py
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
"""Add import_metadata JSONB column to trees table.
|
||||||
|
|
||||||
|
Revision ID: 050
|
||||||
|
Revises: 049
|
||||||
|
Create Date: 2026-03-05
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects.postgresql import JSONB
|
||||||
|
|
||||||
|
# revision identifiers
|
||||||
|
revision = '050'
|
||||||
|
down_revision = '049'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column('trees', sa.Column('import_metadata', JSONB, nullable=True))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column('trees', 'import_metadata')
|
||||||
39
backend/alembic/versions/051_extend_ai_chat_session.py
Normal file
39
backend/alembic/versions/051_extend_ai_chat_session.py
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
"""extend ai chat session with tree_id and archived_at
|
||||||
|
|
||||||
|
Revision ID: 051
|
||||||
|
Revises: 050
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision = "051"
|
||||||
|
down_revision = "050"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column(
|
||||||
|
"ai_chat_sessions",
|
||||||
|
sa.Column("tree_id", sa.UUID(), nullable=True),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"ai_chat_sessions",
|
||||||
|
sa.Column("archived_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
)
|
||||||
|
op.create_index("ix_ai_chat_sessions_tree_id", "ai_chat_sessions", ["tree_id"])
|
||||||
|
op.create_foreign_key(
|
||||||
|
"fk_ai_chat_sessions_tree_id",
|
||||||
|
"ai_chat_sessions",
|
||||||
|
"trees",
|
||||||
|
["tree_id"],
|
||||||
|
["id"],
|
||||||
|
ondelete="CASCADE",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_constraint("fk_ai_chat_sessions_tree_id", "ai_chat_sessions", type_="foreignkey")
|
||||||
|
op.drop_index("ix_ai_chat_sessions_tree_id", table_name="ai_chat_sessions")
|
||||||
|
op.drop_column("ai_chat_sessions", "archived_at")
|
||||||
|
op.drop_column("ai_chat_sessions", "tree_id")
|
||||||
37
backend/alembic/versions/052_add_ai_suggestion_table.py
Normal file
37
backend/alembic/versions/052_add_ai_suggestion_table.py
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
"""add ai suggestion table
|
||||||
|
|
||||||
|
Revision ID: 052
|
||||||
|
Revises: 051
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID, JSONB
|
||||||
|
|
||||||
|
revision = "052"
|
||||||
|
down_revision = "051"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"ai_suggestions",
|
||||||
|
sa.Column("id", UUID(as_uuid=True), primary_key=True),
|
||||||
|
sa.Column("tree_id", UUID(as_uuid=True), sa.ForeignKey("trees.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("user_id", UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("session_id", UUID(as_uuid=True), sa.ForeignKey("ai_chat_sessions.id", ondelete="SET NULL"), nullable=True),
|
||||||
|
sa.Column("action_type", sa.String(50), nullable=False),
|
||||||
|
sa.Column("target_node_id", sa.String(255), nullable=True),
|
||||||
|
sa.Column("changes_json", JSONB, nullable=False, server_default="{}"),
|
||||||
|
sa.Column("status", sa.String(20), nullable=False, server_default="pending"),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||||
|
sa.Column("resolved_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
)
|
||||||
|
op.create_index("ix_ai_suggestions_tree_id", "ai_suggestions", ["tree_id"])
|
||||||
|
op.create_index("ix_ai_suggestions_user_id", "ai_suggestions", ["user_id"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("ix_ai_suggestions_user_id", table_name="ai_suggestions")
|
||||||
|
op.drop_index("ix_ai_suggestions_tree_id", table_name="ai_suggestions")
|
||||||
|
op.drop_table("ai_suggestions")
|
||||||
@@ -95,6 +95,7 @@ async def create_session(
|
|||||||
user_id=current_user.id,
|
user_id=current_user.id,
|
||||||
account_id=current_user.account_id,
|
account_id=current_user.account_id,
|
||||||
db=db,
|
db=db,
|
||||||
|
tree_id=data.tree_id,
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception("AI chat session start failed: %s", e)
|
logger.exception("AI chat session start failed: %s", e)
|
||||||
@@ -168,7 +169,10 @@ async def post_message(
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
ai_content, tree_update, new_phase, metadata = await send_message(
|
ai_content, tree_update, new_phase, metadata = await send_message(
|
||||||
session, data.content, db
|
session, data.content, db,
|
||||||
|
action_type=data.action_type or "open_chat",
|
||||||
|
focal_node_id=data.focal_node_id,
|
||||||
|
flow_context=data.flow_context,
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception("AI chat message failed: %s", e)
|
logger.exception("AI chat message failed: %s", e)
|
||||||
@@ -390,11 +394,18 @@ async def import_tree(
|
|||||||
# Always create a new Tree record (no duplicate check — user may
|
# Always create a new Tree record (no duplicate check — user may
|
||||||
# want multiple copies or re-import after edits)
|
# want multiple copies or re-import after edits)
|
||||||
metadata = session.tree_metadata or {}
|
metadata = session.tree_metadata or {}
|
||||||
|
|
||||||
|
# Extract intake form from metadata if present (procedural flows)
|
||||||
|
intake_form = None
|
||||||
|
if isinstance(metadata.get("intake_form"), list):
|
||||||
|
intake_form = metadata.pop("intake_form")
|
||||||
|
|
||||||
tree = Tree(
|
tree = Tree(
|
||||||
name=data.name or metadata.get("name", "AI-Generated Flow"),
|
name=data.name or metadata.get("name", "AI-Generated Flow"),
|
||||||
description=data.description or metadata.get("description", ""),
|
description=data.description or metadata.get("description", ""),
|
||||||
tree_type=session.flow_type,
|
tree_type=session.flow_type,
|
||||||
tree_structure=session.working_tree,
|
tree_structure=session.working_tree,
|
||||||
|
intake_form=intake_form,
|
||||||
author_id=current_user.id,
|
author_id=current_user.id,
|
||||||
account_id=current_user.account_id,
|
account_id=current_user.account_id,
|
||||||
category_id=data.category_id,
|
category_id=data.category_id,
|
||||||
|
|||||||
79
backend/app/api/endpoints/ai_suggestions.py
Normal file
79
backend/app/api/endpoints/ai_suggestions.py
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
"""AI Suggestion audit trail endpoints."""
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from app.api.deps import get_current_active_user, get_db
|
||||||
|
from app.models.user import User
|
||||||
|
from app.models.ai_suggestion import AISuggestion
|
||||||
|
from app.schemas.ai_suggestion import (
|
||||||
|
AISuggestionCreate,
|
||||||
|
AISuggestionResponse,
|
||||||
|
AISuggestionResolve,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/ai/suggestions", tags=["ai-suggestions"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/tree/{tree_id}", response_model=list[AISuggestionResponse])
|
||||||
|
async def list_suggestions(
|
||||||
|
tree_id: UUID,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_active_user),
|
||||||
|
):
|
||||||
|
"""List all suggestions for a flow, filtered to current user."""
|
||||||
|
result = await db.execute(
|
||||||
|
select(AISuggestion)
|
||||||
|
.where(AISuggestion.tree_id == tree_id, AISuggestion.user_id == current_user.id)
|
||||||
|
.order_by(AISuggestion.created_at.desc())
|
||||||
|
)
|
||||||
|
return result.scalars().all()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=AISuggestionResponse, status_code=201)
|
||||||
|
async def create_suggestion(
|
||||||
|
data: AISuggestionCreate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_active_user),
|
||||||
|
):
|
||||||
|
"""Record a new AI suggestion."""
|
||||||
|
suggestion = AISuggestion(
|
||||||
|
tree_id=data.tree_id,
|
||||||
|
user_id=current_user.id,
|
||||||
|
session_id=data.session_id,
|
||||||
|
action_type=data.action_type,
|
||||||
|
target_node_id=data.target_node_id,
|
||||||
|
changes_json=data.changes_json,
|
||||||
|
)
|
||||||
|
db.add(suggestion)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(suggestion)
|
||||||
|
return suggestion
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{suggestion_id}", response_model=AISuggestionResponse)
|
||||||
|
async def resolve_suggestion(
|
||||||
|
suggestion_id: UUID,
|
||||||
|
data: AISuggestionResolve,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_active_user),
|
||||||
|
):
|
||||||
|
"""Accept or dismiss a suggestion."""
|
||||||
|
result = await db.execute(
|
||||||
|
select(AISuggestion).where(
|
||||||
|
AISuggestion.id == suggestion_id,
|
||||||
|
AISuggestion.user_id == current_user.id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
suggestion = result.scalar_one_or_none()
|
||||||
|
if not suggestion:
|
||||||
|
raise HTTPException(status_code=404, detail="Suggestion not found")
|
||||||
|
|
||||||
|
suggestion.status = data.status
|
||||||
|
suggestion.resolved_at = datetime.now(timezone.utc)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(suggestion)
|
||||||
|
return suggestion
|
||||||
281
backend/app/api/endpoints/tree_transfer.py
Normal file
281
backend/app/api/endpoints/tree_transfer.py
Normal file
@@ -0,0 +1,281 @@
|
|||||||
|
"""Flow export/import endpoints (.rfflow files)."""
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Annotated, Optional
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||||
|
from fastapi.responses import Response
|
||||||
|
from sqlalchemy import select, or_
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
|
from app.api.deps import get_current_active_user, require_engineer_or_admin
|
||||||
|
from app.core.audit import log_audit
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.core.permissions import can_access_tree
|
||||||
|
from app.core.subscriptions import check_tree_limit
|
||||||
|
from app.core.tree_validation import can_publish_tree
|
||||||
|
from app.models.category import TreeCategory
|
||||||
|
from app.models.tag import TreeTag, tree_tag_assignments
|
||||||
|
from app.models.tree import Tree
|
||||||
|
from app.models.user import User
|
||||||
|
from app.schemas.tree_export import (
|
||||||
|
FlowExportCategory,
|
||||||
|
FlowExportData,
|
||||||
|
FlowExportEnvelope,
|
||||||
|
FlowImportRequest,
|
||||||
|
FlowImportResponse,
|
||||||
|
)
|
||||||
|
from app.services.rag_service import index_tree as rag_index_tree
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/trees", tags=["tree-transfer"])
|
||||||
|
|
||||||
|
|
||||||
|
def _slugify(name: str) -> str:
|
||||||
|
"""Create a filename-safe slug from a name."""
|
||||||
|
slug = re.sub(r'[^\w\s-]', '', name.lower().strip())
|
||||||
|
return re.sub(r'[-\s]+', '-', slug)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Export ---
|
||||||
|
|
||||||
|
@router.get("/{tree_id}/export")
|
||||||
|
async def export_tree(
|
||||||
|
tree_id: UUID,
|
||||||
|
db: Annotated[AsyncSession, Depends(get_db)],
|
||||||
|
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||||
|
):
|
||||||
|
"""Export a tree as a downloadable .rfflow JSON file."""
|
||||||
|
# Load tree with relationships + author name
|
||||||
|
result = await db.execute(
|
||||||
|
select(Tree)
|
||||||
|
.options(
|
||||||
|
selectinload(Tree.category_rel),
|
||||||
|
selectinload(Tree.tags),
|
||||||
|
selectinload(Tree.author),
|
||||||
|
)
|
||||||
|
.where(Tree.id == tree_id)
|
||||||
|
)
|
||||||
|
tree = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not tree:
|
||||||
|
raise HTTPException(status_code=404, detail="Tree not found")
|
||||||
|
|
||||||
|
if not tree.is_active or not can_access_tree(current_user, tree):
|
||||||
|
raise HTTPException(status_code=403, detail="You don't have access to this tree")
|
||||||
|
|
||||||
|
# Build export category
|
||||||
|
export_category = None
|
||||||
|
if tree.category_rel:
|
||||||
|
export_category = FlowExportCategory(
|
||||||
|
name=tree.category_rel.name,
|
||||||
|
slug=tree.category_rel.slug,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Build export data
|
||||||
|
author_name = None
|
||||||
|
if tree.author:
|
||||||
|
author_name = tree.author.name or tree.author.email
|
||||||
|
|
||||||
|
flow_data = FlowExportData(
|
||||||
|
name=tree.name,
|
||||||
|
description=tree.description,
|
||||||
|
tree_type=tree.tree_type,
|
||||||
|
version=tree.version,
|
||||||
|
author_name=author_name,
|
||||||
|
category=export_category,
|
||||||
|
tags=tree.tag_names,
|
||||||
|
tree_structure=tree.tree_structure,
|
||||||
|
intake_form=tree.intake_form,
|
||||||
|
)
|
||||||
|
|
||||||
|
envelope = FlowExportEnvelope(
|
||||||
|
rfflow_version="1.0",
|
||||||
|
exported_at=datetime.now(timezone.utc),
|
||||||
|
source_app="ResolutionFlow",
|
||||||
|
flow=flow_data,
|
||||||
|
)
|
||||||
|
|
||||||
|
slug = _slugify(tree.name)
|
||||||
|
|
||||||
|
# Audit log
|
||||||
|
await log_audit(db, current_user.id, "tree.export", "tree", tree.id)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
content = envelope.model_dump_json(indent=2)
|
||||||
|
return Response(
|
||||||
|
content=content,
|
||||||
|
media_type="application/json",
|
||||||
|
headers={"Content-Disposition": f'attachment; filename="{slug}.rfflow"'},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Import ---
|
||||||
|
|
||||||
|
@router.post("/import", response_model=FlowImportResponse, status_code=status.HTTP_201_CREATED)
|
||||||
|
async def import_tree(
|
||||||
|
data: FlowImportRequest,
|
||||||
|
db: Annotated[AsyncSession, Depends(get_db)],
|
||||||
|
current_user: Annotated[User, Depends(require_engineer_or_admin)],
|
||||||
|
name_override: Optional[str] = Query(None, max_length=255),
|
||||||
|
):
|
||||||
|
"""Import a flow from a parsed .rfflow file. Creates as draft."""
|
||||||
|
# Validate version
|
||||||
|
if data.rfflow_version != "1.0":
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
|
detail=f"Unsupported rfflow version: {data.rfflow_version}. Only '1.0' is supported.",
|
||||||
|
)
|
||||||
|
|
||||||
|
flow = data.flow
|
||||||
|
|
||||||
|
# Check subscription tree limit
|
||||||
|
if current_user.account_id:
|
||||||
|
can_create, limit, count = await check_tree_limit(current_user.account_id, db)
|
||||||
|
if not can_create:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_402_PAYMENT_REQUIRED,
|
||||||
|
detail=f"Tree limit reached ({count}/{limit}). Upgrade your plan to create more trees.",
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- Category resolution ---
|
||||||
|
category_id = None
|
||||||
|
category_created = False
|
||||||
|
if flow.category:
|
||||||
|
# Try to match by slug within user's account
|
||||||
|
cat_result = await db.execute(
|
||||||
|
select(TreeCategory).where(
|
||||||
|
TreeCategory.slug == flow.category.slug,
|
||||||
|
or_(
|
||||||
|
TreeCategory.account_id.is_(None),
|
||||||
|
TreeCategory.account_id == current_user.account_id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
category = cat_result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if category:
|
||||||
|
category_id = category.id
|
||||||
|
else:
|
||||||
|
# Create new category
|
||||||
|
new_cat = TreeCategory(
|
||||||
|
name=flow.category.name,
|
||||||
|
slug=flow.category.slug,
|
||||||
|
account_id=current_user.account_id,
|
||||||
|
)
|
||||||
|
db.add(new_cat)
|
||||||
|
await db.flush()
|
||||||
|
category_id = new_cat.id
|
||||||
|
category_created = True
|
||||||
|
|
||||||
|
# --- Tag resolution ---
|
||||||
|
tags_created: list[str] = []
|
||||||
|
tags_to_add: list[TreeTag] = []
|
||||||
|
tree_account_id = current_user.account_id
|
||||||
|
|
||||||
|
for tag_name in flow.tags:
|
||||||
|
slug = TreeTag.slugify(tag_name)
|
||||||
|
|
||||||
|
tag_result = await db.execute(
|
||||||
|
select(TreeTag).where(
|
||||||
|
TreeTag.slug == slug,
|
||||||
|
or_(
|
||||||
|
TreeTag.account_id.is_(None),
|
||||||
|
TreeTag.account_id == tree_account_id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
tag = tag_result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not tag:
|
||||||
|
tag = TreeTag(
|
||||||
|
name=tag_name,
|
||||||
|
slug=slug,
|
||||||
|
account_id=tree_account_id,
|
||||||
|
created_by=current_user.id,
|
||||||
|
)
|
||||||
|
db.add(tag)
|
||||||
|
await db.flush()
|
||||||
|
tags_created.append(tag_name)
|
||||||
|
|
||||||
|
tags_to_add.append(tag)
|
||||||
|
tag.usage_count += 1
|
||||||
|
|
||||||
|
# --- Validation warnings (non-blocking since status=draft) ---
|
||||||
|
warnings: list[str] = []
|
||||||
|
intake_form_dicts = flow.intake_form
|
||||||
|
can_pub, validation_errors = can_publish_tree(
|
||||||
|
flow.tree_structure,
|
||||||
|
flow.name,
|
||||||
|
flow.description,
|
||||||
|
tree_type=flow.tree_type,
|
||||||
|
intake_form=intake_form_dicts,
|
||||||
|
)
|
||||||
|
if not can_pub:
|
||||||
|
for err in validation_errors:
|
||||||
|
msg = err.get("message", str(err)) if isinstance(err, dict) else str(err)
|
||||||
|
warnings.append(msg)
|
||||||
|
|
||||||
|
# --- Create tree ---
|
||||||
|
tree_name = name_override or flow.name
|
||||||
|
import_metadata = {
|
||||||
|
"original_author_name": flow.author_name,
|
||||||
|
"exported_at": data.exported_at.isoformat(),
|
||||||
|
"imported_at": datetime.now(timezone.utc).isoformat(),
|
||||||
|
"source_app": data.source_app,
|
||||||
|
}
|
||||||
|
|
||||||
|
new_tree = Tree(
|
||||||
|
name=tree_name,
|
||||||
|
description=flow.description,
|
||||||
|
tree_type=flow.tree_type,
|
||||||
|
tree_structure=flow.tree_structure,
|
||||||
|
intake_form=intake_form_dicts,
|
||||||
|
category_id=category_id,
|
||||||
|
author_id=current_user.id,
|
||||||
|
account_id=current_user.account_id,
|
||||||
|
status="draft",
|
||||||
|
version=1,
|
||||||
|
import_metadata=import_metadata,
|
||||||
|
)
|
||||||
|
db.add(new_tree)
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
# Tag junction table inserts
|
||||||
|
for tag in tags_to_add:
|
||||||
|
await db.execute(
|
||||||
|
tree_tag_assignments.insert().values(
|
||||||
|
tree_id=new_tree.id,
|
||||||
|
tag_id=tag.id,
|
||||||
|
assigned_by=current_user.id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Audit log
|
||||||
|
await log_audit(db, current_user.id, "tree.import", "tree", new_tree.id, {
|
||||||
|
"source_app": data.source_app,
|
||||||
|
"original_author": flow.author_name,
|
||||||
|
})
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
# RAG index (best-effort)
|
||||||
|
try:
|
||||||
|
await rag_index_tree(new_tree.id, db)
|
||||||
|
await db.commit()
|
||||||
|
except Exception:
|
||||||
|
logger.warning("RAG indexing failed for imported tree %s", new_tree.id)
|
||||||
|
|
||||||
|
return FlowImportResponse(
|
||||||
|
tree_id=str(new_tree.id),
|
||||||
|
name=tree_name,
|
||||||
|
tree_type=flow.tree_type,
|
||||||
|
status="draft",
|
||||||
|
category_created=category_created,
|
||||||
|
tags_created=tags_created,
|
||||||
|
validation_warnings=warnings,
|
||||||
|
)
|
||||||
@@ -116,7 +116,8 @@ def build_full_tree_response(tree: Tree, parent_tree: Tree = None) -> TreeRespon
|
|||||||
version=tree.version,
|
version=tree.version,
|
||||||
usage_count=tree.usage_count,
|
usage_count=tree.usage_count,
|
||||||
created_at=tree.created_at,
|
created_at=tree.created_at,
|
||||||
updated_at=tree.updated_at
|
updated_at=tree.updated_at,
|
||||||
|
import_metadata=tree.import_metadata
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ from app.api.endpoints import copilot
|
|||||||
from app.api.endpoints import assistant_chat
|
from app.api.endpoints import assistant_chat
|
||||||
from app.api.endpoints import survey
|
from app.api.endpoints import survey
|
||||||
from app.api.endpoints import admin_survey
|
from app.api.endpoints import admin_survey
|
||||||
|
from app.api.endpoints import tree_transfer
|
||||||
|
from app.api.endpoints import ai_suggestions
|
||||||
|
|
||||||
api_router = APIRouter()
|
api_router = APIRouter()
|
||||||
|
|
||||||
@@ -48,3 +50,5 @@ api_router.include_router(copilot.router)
|
|||||||
api_router.include_router(assistant_chat.router)
|
api_router.include_router(assistant_chat.router)
|
||||||
api_router.include_router(survey.router)
|
api_router.include_router(survey.router)
|
||||||
api_router.include_router(admin_survey.router)
|
api_router.include_router(admin_survey.router)
|
||||||
|
api_router.include_router(tree_transfer.router)
|
||||||
|
api_router.include_router(ai_suggestions.router)
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ from sqlalchemy import select
|
|||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.core.ai_provider import get_ai_provider
|
from app.core.ai_provider import get_ai_provider
|
||||||
from app.core.ai_tree_validator import validate_generated_tree
|
from app.core.ai_tree_validator import validate_generated_tree, validate_generated_procedural_steps
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.models.ai_chat_session import AIChatSession
|
from app.models.ai_chat_session import AIChatSession
|
||||||
|
|
||||||
@@ -44,7 +44,7 @@ CRITICAL BEHAVIORS:
|
|||||||
- Include expected outcomes for every action: what does success look like?
|
- Include expected outcomes for every action: what does success look like?
|
||||||
- Surface edge cases proactively: "What about multi-forest environments?" or "Does this change if they have conditional access policies?"
|
- Surface edge cases proactively: "What about multi-forest environments?" or "Does this change if they have conditional access policies?"
|
||||||
- Explain WHY the diagnostic order matters: "We check connectivity before auth because a network issue masquerades as an auth failure."
|
- Explain WHY the diagnostic order matters: "We check connectivity before auth because a network issue masquerades as an auth failure."
|
||||||
- Ask ONE focused question at a time. Do not overwhelm with multiple questions.
|
- Ask ONE focused question at a time. NEVER ask multiple questions in a single response — no numbered lists of questions, no "also, what about X?", no follow-up questions tacked on. One question, then wait for the answer.
|
||||||
- Use plain, collegial language. Sound like a colleague, not a form."""
|
- Use plain, collegial language. Sound like a colleague, not a form."""
|
||||||
|
|
||||||
SCHEMA_CONTEXT = """
|
SCHEMA_CONTEXT = """
|
||||||
@@ -140,17 +140,138 @@ IMPORTANT:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
PROCEDURAL_SCHEMA_CONTEXT = """
|
||||||
|
PROCEDURAL STEP SCHEMA — This is what you are building:
|
||||||
|
|
||||||
|
The flow is an ordered array of steps in a JSON object: {"steps": [...]}
|
||||||
|
|
||||||
|
Each step has a "type" field:
|
||||||
|
|
||||||
|
1. procedure_step — A concrete step the engineer performs
|
||||||
|
Required: id (string), type ("procedure_step"), title (string), description (string)
|
||||||
|
Optional:
|
||||||
|
- content_type ("action"|"informational"|"verification"|"warning") — default "action"
|
||||||
|
- estimated_minutes (number)
|
||||||
|
- commands (array of objects: {code: string, label?: string, language?: string}) — exact CLI/PowerShell syntax
|
||||||
|
- expected_outcome (string) — what success looks like
|
||||||
|
- verification_prompt (string) — question to confirm completion
|
||||||
|
- verification_type ("checkbox"|"text_input") — how the engineer confirms
|
||||||
|
- warning_text (string) — caution or prerequisite info
|
||||||
|
- notes_enabled (boolean) — allow engineer to capture notes on this step
|
||||||
|
- reference_url (string) — link to documentation
|
||||||
|
|
||||||
|
2. section_header — Groups steps into logical phases
|
||||||
|
Required: id (string), type ("section_header"), title (string)
|
||||||
|
Section headers apply to all subsequent steps until the next section_header.
|
||||||
|
|
||||||
|
3. procedure_end — Terminal marker (always the last step)
|
||||||
|
Required: id (string), type ("procedure_end"), title (string)
|
||||||
|
|
||||||
|
STRUCTURAL RULES:
|
||||||
|
- Steps are executed in array order (flat list, no branching)
|
||||||
|
- All IDs must be unique descriptive slugs (e.g., "check-dns-resolution", not UUIDs)
|
||||||
|
- The last step MUST be type "procedure_end"
|
||||||
|
- Use section_headers to organize steps into logical phases
|
||||||
|
- Commands are arrays of objects: [{"code": "Get-Service ADSync", "label": "Check sync service", "language": "powershell"}]
|
||||||
|
- Descriptions support [VAR:variable_name] interpolation for intake form variables (e.g., "Connect to [VAR:server_name] via RDP")
|
||||||
|
|
||||||
|
VARIABLE INTERPOLATION:
|
||||||
|
When the procedure needs per-execution input (server name, IP address, client name, etc.), use [VAR:variable_name] syntax in descriptions and commands. These map to intake form fields that the engineer fills in before starting.
|
||||||
|
"""
|
||||||
|
|
||||||
|
PROCEDURAL_INTERVIEW_PROTOCOL = """
|
||||||
|
INTERVIEW PHASES — Follow this progression:
|
||||||
|
|
||||||
|
PHASE 1 - SCOPING (current_phase: scoping):
|
||||||
|
Understand the process being documented:
|
||||||
|
- What process or procedure is this flow for?
|
||||||
|
- Who will execute it? (Tier 1 help desk, Tier 2, senior engineers?)
|
||||||
|
- What environment context? (Specific vendor, on-prem vs cloud, tools available?)
|
||||||
|
- Will this need per-execution input? (server name, client info, IP addresses → intake form fields)
|
||||||
|
Demonstrate domain expertise: if the user says "Exchange Online mailbox migration," show understanding: "Are we covering full tenant-to-tenant migration, on-prem to Exchange Online cutover, or individual mailbox moves with hybrid?"
|
||||||
|
DO NOT emit [STEPS_UPDATE] during scoping. You are still understanding the process.
|
||||||
|
|
||||||
|
PHASE 2 - DISCOVERY (current_phase: discovery):
|
||||||
|
Build the procedure step by step IN ORDER:
|
||||||
|
- Start with prerequisites and initial verification
|
||||||
|
- Walk through each step sequentially — ask what happens first, then next, then next
|
||||||
|
- Suggest section headers to organize logical phases (e.g., "Pre-Flight Checks", "Migration", "Verification")
|
||||||
|
- Capture specific commands, tools, and expected outcomes for each step
|
||||||
|
- Identify where [VAR:variable_name] placeholders are needed
|
||||||
|
EMIT [STEPS_UPDATE] when you and the user have agreed on concrete steps. Build progressively — emit partial step lists as you go.
|
||||||
|
|
||||||
|
PHASE 3 - ENRICHMENT (current_phase: enrichment):
|
||||||
|
Circle back to enrich existing steps:
|
||||||
|
- Add exact PowerShell/CLI commands with full syntax
|
||||||
|
- Add verification prompts for critical steps
|
||||||
|
- Add warning_text for steps with risk (data loss, downtime, etc.)
|
||||||
|
- Add estimated_minutes for time-critical procedures
|
||||||
|
- Add expected_outcome for action steps
|
||||||
|
- Suggest reference_url links to documentation
|
||||||
|
- Identify missing edge cases or safety checks
|
||||||
|
EMIT [STEPS_UPDATE] when enriching steps with additional detail.
|
||||||
|
|
||||||
|
PHASE 4 - REVIEW (current_phase: review):
|
||||||
|
Present a summary:
|
||||||
|
- Total step count by content_type
|
||||||
|
- Outline of sections and steps
|
||||||
|
- List of intake form variables ([VAR:...]) used
|
||||||
|
- Flag any steps missing commands or verification
|
||||||
|
- Offer chance to reorder, add, or remove steps
|
||||||
|
EMIT [STEPS_UPDATE] only if the user requests changes.
|
||||||
|
|
||||||
|
TRANSITION between phases by emitting [PHASE:phase_name] when the conversation naturally moves to the next stage. You decide when enough information has been gathered for each phase.
|
||||||
|
"""
|
||||||
|
|
||||||
|
PROCEDURAL_RESPONSE_FORMAT = """
|
||||||
|
RESPONSE FORMAT:
|
||||||
|
|
||||||
|
Your response is natural conversational text. When the step structure changes, include structured markers that will be parsed by the system (the user will NOT see these markers):
|
||||||
|
|
||||||
|
1. Steps update (only when structure changes — see phase rules above):
|
||||||
|
[STEPS_UPDATE]
|
||||||
|
{"steps": [...valid steps array...]}
|
||||||
|
[/STEPS_UPDATE]
|
||||||
|
|
||||||
|
2. Phase transition (when moving to next phase):
|
||||||
|
[PHASE:discovery]
|
||||||
|
|
||||||
|
3. Metadata capture (when you learn the flow's name, description, or tags):
|
||||||
|
[METADATA]
|
||||||
|
{"name": "...", "description": "...", "tags": ["..."]}
|
||||||
|
[/METADATA]
|
||||||
|
|
||||||
|
4. Intake form suggestion (when intake form fields are identified):
|
||||||
|
[INTAKE_FORM]
|
||||||
|
[{"variable_name": "server_name", "label": "Server Name", "field_type": "text", "required": true, "placeholder": "e.g., DC01", "group_name": "Server Details", "display_order": 1}]
|
||||||
|
[/INTAKE_FORM]
|
||||||
|
|
||||||
|
IMPORTANT:
|
||||||
|
- Include [STEPS_UPDATE] sparingly. Only when concrete steps are established or modified.
|
||||||
|
- The steps update should be the COMPLETE working step list, not a diff.
|
||||||
|
- Always include conversational text OUTSIDE the markers — never respond with only markers.
|
||||||
|
- The procedure_end step is always included as the last step.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
def _build_system_prompt(flow_type: str) -> str:
|
def _build_system_prompt(flow_type: str) -> str:
|
||||||
"""Assemble the full system prompt for the chat builder."""
|
"""Assemble the full system prompt for the chat builder."""
|
||||||
|
if flow_type in ("procedural", "maintenance"):
|
||||||
|
flow_context = (
|
||||||
|
"The user wants to build a PROCEDURAL flow — a step-by-step process guide "
|
||||||
|
"with ordered phases, verification checkpoints, and optional intake form variables. "
|
||||||
|
"This is NOT a branching decision tree — it is a flat, sequential procedure."
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
f"{ROLE_PERSONA}\n\n{flow_context}\n\n"
|
||||||
|
f"{PROCEDURAL_SCHEMA_CONTEXT}\n\n{PROCEDURAL_INTERVIEW_PROTOCOL}\n\n{PROCEDURAL_RESPONSE_FORMAT}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
flow_context = (
|
flow_context = (
|
||||||
"The user wants to build a TROUBLESHOOTING flow — a diagnostic decision tree "
|
"The user wants to build a TROUBLESHOOTING flow — a diagnostic decision tree "
|
||||||
"that guides engineers through symptom identification, diagnostic checks, and "
|
"that guides engineers through symptom identification, diagnostic checks, and "
|
||||||
"resolution steps."
|
"resolution steps."
|
||||||
if flow_type == "troubleshooting"
|
|
||||||
else "The user wants to build a PROCEDURAL flow — a step-by-step process guide "
|
|
||||||
"with phases, checklists, and verification steps."
|
|
||||||
)
|
)
|
||||||
|
|
||||||
return f"{ROLE_PERSONA}\n\n{flow_context}\n\n{SCHEMA_CONTEXT}\n\n{INTERVIEW_PROTOCOL}\n\n{RESPONSE_FORMAT}"
|
return f"{ROLE_PERSONA}\n\n{flow_context}\n\n{SCHEMA_CONTEXT}\n\n{INTERVIEW_PROTOCOL}\n\n{RESPONSE_FORMAT}"
|
||||||
|
|
||||||
|
|
||||||
@@ -163,6 +284,92 @@ def _strip_markdown_fences(text: str) -> str:
|
|||||||
return text
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_delta(response: str) -> dict | None:
|
||||||
|
"""Extract [DELTA]...[/DELTA] JSON from AI response."""
|
||||||
|
match = re.search(r'\[DELTA\](.*?)\[/DELTA\]', response, re.DOTALL)
|
||||||
|
if not match:
|
||||||
|
return None
|
||||||
|
raw = _strip_markdown_fences(match.group(1).strip())
|
||||||
|
try:
|
||||||
|
return json.loads(raw)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _find_node_by_id(tree: dict, node_id: str) -> dict | None:
|
||||||
|
"""Find a node by ID in a tree structure (recursive)."""
|
||||||
|
if tree.get("id") == node_id:
|
||||||
|
return tree
|
||||||
|
for child in tree.get("children", []):
|
||||||
|
found = _find_node_by_id(child, node_id)
|
||||||
|
if found:
|
||||||
|
return found
|
||||||
|
for step in tree.get("steps", []):
|
||||||
|
if step.get("id") == node_id:
|
||||||
|
return step
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _build_action_prompt(
|
||||||
|
action_type: str,
|
||||||
|
focal_node_id: str | None,
|
||||||
|
tree_structure: dict,
|
||||||
|
flow_type: str,
|
||||||
|
) -> str:
|
||||||
|
"""Build action-specific system prompt supplement."""
|
||||||
|
tree_json = json.dumps(tree_structure, indent=2)
|
||||||
|
|
||||||
|
focal_context = ""
|
||||||
|
if focal_node_id:
|
||||||
|
focal_node = _find_node_by_id(tree_structure, focal_node_id)
|
||||||
|
if focal_node:
|
||||||
|
focal_context = f"\n\nFOCAL NODE (the node being acted on):\n{json.dumps(focal_node, indent=2)}"
|
||||||
|
|
||||||
|
prompts = {
|
||||||
|
"generate_branch": (
|
||||||
|
f"Generate a complete branch of child nodes for the focal node. "
|
||||||
|
f"Return the new nodes wrapped in [DELTA]...[/DELTA] markers as JSON with "
|
||||||
|
f"action='add', target_node_id='{focal_node_id}', and nodes array."
|
||||||
|
f"{focal_context}"
|
||||||
|
),
|
||||||
|
"modify_node": (
|
||||||
|
f"Modify the focal node based on the user's instruction. "
|
||||||
|
f"Return the updated node in [DELTA]...[/DELTA] markers with action='modify'."
|
||||||
|
f"{focal_context}"
|
||||||
|
),
|
||||||
|
"add_steps": (
|
||||||
|
f"Generate new procedural steps to insert after the focal step. "
|
||||||
|
f"Return them in [DELTA]...[/DELTA] markers with action='add'."
|
||||||
|
f"{focal_context}"
|
||||||
|
),
|
||||||
|
"quick_action": (
|
||||||
|
f"Respond to the user's quick action request about the focal node. "
|
||||||
|
f"If the action modifies the node, return changes in [DELTA]...[/DELTA] markers. "
|
||||||
|
f"If it's informational (e.g. explain), just respond in text."
|
||||||
|
f"{focal_context}"
|
||||||
|
),
|
||||||
|
"open_chat": (
|
||||||
|
"Have a helpful conversation about the flow. If the user asks for changes, "
|
||||||
|
"return them in [DELTA]...[/DELTA] markers. Otherwise respond in text."
|
||||||
|
),
|
||||||
|
"generate_full": (
|
||||||
|
"Generate a complete flow structure based on the user's description."
|
||||||
|
),
|
||||||
|
"variable_inference": (
|
||||||
|
"Analyze the procedural steps for implicit variables. Look for references to "
|
||||||
|
"specific servers, clients, credentials, or other values that should be captured "
|
||||||
|
"in an intake form. Return suggestions as JSON."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
action_prompt = prompts.get(action_type, prompts["open_chat"])
|
||||||
|
|
||||||
|
return (
|
||||||
|
f"CURRENT FLOW STRUCTURE ({flow_type}):\n{tree_json}\n\n"
|
||||||
|
f"ACTION: {action_type}\n{action_prompt}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _parse_ai_response(raw_response: str) -> dict[str, Any]:
|
def _parse_ai_response(raw_response: str) -> dict[str, Any]:
|
||||||
"""Parse structured markers from AI response.
|
"""Parse structured markers from AI response.
|
||||||
|
|
||||||
@@ -177,6 +384,7 @@ def _parse_ai_response(raw_response: str) -> dict[str, Any]:
|
|||||||
"tree_update": None,
|
"tree_update": None,
|
||||||
"phase": None,
|
"phase": None,
|
||||||
"metadata": None,
|
"metadata": None,
|
||||||
|
"intake_form": None,
|
||||||
}
|
}
|
||||||
|
|
||||||
# Extract [TREE_UPDATE]...[/TREE_UPDATE]
|
# Extract [TREE_UPDATE]...[/TREE_UPDATE]
|
||||||
@@ -198,6 +406,40 @@ def _parse_ai_response(raw_response: str) -> dict[str, Any]:
|
|||||||
logger.warning("Truncated [TREE_UPDATE] block detected (no closing tag) — stripping from display")
|
logger.warning("Truncated [TREE_UPDATE] block detected (no closing tag) — stripping from display")
|
||||||
result["content"] = raw_response[: truncated_match.start()]
|
result["content"] = raw_response[: truncated_match.start()]
|
||||||
|
|
||||||
|
# Extract [STEPS_UPDATE]...[/STEPS_UPDATE] (procedural flows)
|
||||||
|
steps_match = re.search(
|
||||||
|
r"\[STEPS_UPDATE\]\s*([\s\S]*?)\s*\[/STEPS_UPDATE\]", result["content"]
|
||||||
|
)
|
||||||
|
if steps_match:
|
||||||
|
try:
|
||||||
|
raw_json = _strip_markdown_fences(steps_match.group(1))
|
||||||
|
result["tree_update"] = json.loads(raw_json)
|
||||||
|
except (json.JSONDecodeError, ValueError) as e:
|
||||||
|
logger.warning("Failed to parse steps update JSON: %s", e)
|
||||||
|
result["content"] = result["content"][: steps_match.start()] + result["content"][steps_match.end() :]
|
||||||
|
else:
|
||||||
|
truncated_steps = re.search(r"\[STEPS_UPDATE\][\s\S]*$", result["content"])
|
||||||
|
if truncated_steps:
|
||||||
|
logger.warning("Truncated [STEPS_UPDATE] block detected (no closing tag) — stripping from display")
|
||||||
|
result["content"] = result["content"][: truncated_steps.start()]
|
||||||
|
|
||||||
|
# Extract [INTAKE_FORM]...[/INTAKE_FORM] (procedural flows)
|
||||||
|
intake_match = re.search(
|
||||||
|
r"\[INTAKE_FORM\]\s*([\s\S]*?)\s*\[/INTAKE_FORM\]", result["content"]
|
||||||
|
)
|
||||||
|
if intake_match:
|
||||||
|
try:
|
||||||
|
raw_json = _strip_markdown_fences(intake_match.group(1))
|
||||||
|
result["intake_form"] = json.loads(raw_json)
|
||||||
|
except (json.JSONDecodeError, ValueError) as e:
|
||||||
|
logger.warning("Failed to parse intake form JSON: %s", e)
|
||||||
|
result["content"] = result["content"][: intake_match.start()] + result["content"][intake_match.end() :]
|
||||||
|
else:
|
||||||
|
truncated_intake = re.search(r"\[INTAKE_FORM\][\s\S]*$", result["content"])
|
||||||
|
if truncated_intake:
|
||||||
|
logger.warning("Truncated [INTAKE_FORM] block detected — stripping from display")
|
||||||
|
result["content"] = result["content"][: truncated_intake.start()]
|
||||||
|
|
||||||
# Extract [PHASE:name]
|
# Extract [PHASE:name]
|
||||||
phase_match = re.search(r"\[PHASE:(\w+)\]", result["content"])
|
phase_match = re.search(r"\[PHASE:(\w+)\]", result["content"])
|
||||||
if phase_match:
|
if phase_match:
|
||||||
@@ -235,6 +477,7 @@ async def start_chat_session(
|
|||||||
user_id: uuid.UUID,
|
user_id: uuid.UUID,
|
||||||
account_id: uuid.UUID,
|
account_id: uuid.UUID,
|
||||||
db: AsyncSession,
|
db: AsyncSession,
|
||||||
|
tree_id: str | None = None,
|
||||||
) -> tuple[AIChatSession, str]:
|
) -> tuple[AIChatSession, str]:
|
||||||
"""Create a chat session and return the AI's opening greeting.
|
"""Create a chat session and return the AI's opening greeting.
|
||||||
|
|
||||||
@@ -244,6 +487,7 @@ async def start_chat_session(
|
|||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
account_id=account_id,
|
account_id=account_id,
|
||||||
flow_type=flow_type,
|
flow_type=flow_type,
|
||||||
|
tree_id=uuid.UUID(tree_id) if tree_id else None,
|
||||||
expires_at=datetime.now(timezone.utc) + timedelta(hours=settings.AI_CONVERSATION_TTL_HOURS),
|
expires_at=datetime.now(timezone.utc) + timedelta(hours=settings.AI_CONVERSATION_TTL_HOURS),
|
||||||
)
|
)
|
||||||
db.add(session)
|
db.add(session)
|
||||||
@@ -287,13 +531,35 @@ async def send_message(
|
|||||||
session: AIChatSession,
|
session: AIChatSession,
|
||||||
user_message: str,
|
user_message: str,
|
||||||
db: AsyncSession,
|
db: AsyncSession,
|
||||||
|
action_type: str = "open_chat",
|
||||||
|
focal_node_id: str | None = None,
|
||||||
|
flow_context: dict | None = None,
|
||||||
) -> tuple[str, Optional[dict], Optional[str], Optional[dict]]:
|
) -> tuple[str, Optional[dict], Optional[str], Optional[dict]]:
|
||||||
"""Send a user message and get AI response.
|
"""Send a user message and get AI response.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
flow_context: Live flow structure from the editor. Contains the current
|
||||||
|
tree_structure (troubleshooting) or steps + intake_form (procedural).
|
||||||
|
This gives the AI full awareness of the flow being edited.
|
||||||
|
|
||||||
Returns (ai_content, working_tree_update, new_phase, metadata_update).
|
Returns (ai_content, working_tree_update, new_phase, metadata_update).
|
||||||
"""
|
"""
|
||||||
system_prompt = _build_system_prompt(session.flow_type)
|
system_prompt = _build_system_prompt(session.flow_type)
|
||||||
|
|
||||||
|
# Inject live flow context so the AI can see current editor state
|
||||||
|
if flow_context:
|
||||||
|
context_json = json.dumps(flow_context, indent=2)
|
||||||
|
system_prompt += (
|
||||||
|
f"\n\nCURRENT FLOW STATE (live from editor):\n{context_json}"
|
||||||
|
)
|
||||||
|
if focal_node_id:
|
||||||
|
focal_node = _find_node_by_id(flow_context, focal_node_id)
|
||||||
|
if focal_node:
|
||||||
|
system_prompt += (
|
||||||
|
f"\n\nFOCAL NODE/STEP (the item being acted on):\n"
|
||||||
|
f"{json.dumps(focal_node, indent=2)}"
|
||||||
|
)
|
||||||
|
|
||||||
# Build messages array from conversation history
|
# Build messages array from conversation history
|
||||||
now_iso = datetime.now(timezone.utc).isoformat()
|
now_iso = datetime.now(timezone.utc).isoformat()
|
||||||
history = list(session.conversation_history)
|
history = list(session.conversation_history)
|
||||||
@@ -305,7 +571,9 @@ async def send_message(
|
|||||||
for msg in history
|
for msg in history
|
||||||
]
|
]
|
||||||
|
|
||||||
provider = get_ai_provider()
|
# Resolve model for this action type
|
||||||
|
action_model = settings.get_model_for_action(action_type)
|
||||||
|
provider = get_ai_provider(model=action_model)
|
||||||
response_text, input_tokens, output_tokens = await provider.generate_text(
|
response_text, input_tokens, output_tokens = await provider.generate_text(
|
||||||
system_prompt=system_prompt,
|
system_prompt=system_prompt,
|
||||||
messages=provider_messages,
|
messages=provider_messages,
|
||||||
@@ -318,6 +586,13 @@ async def send_message(
|
|||||||
# only require valid root structure, not min node counts)
|
# only require valid root structure, not min node counts)
|
||||||
tree_update = parsed["tree_update"]
|
tree_update = parsed["tree_update"]
|
||||||
if tree_update:
|
if tree_update:
|
||||||
|
if session.flow_type in ("procedural", "maintenance"):
|
||||||
|
# Procedural: must be a dict with a "steps" list
|
||||||
|
if not isinstance(tree_update, dict) or not isinstance(tree_update.get("steps"), list):
|
||||||
|
logger.warning("AI steps update rejected: must be a dict with a 'steps' list")
|
||||||
|
tree_update = None
|
||||||
|
else:
|
||||||
|
# Troubleshooting: root must be a decision node
|
||||||
if not isinstance(tree_update, dict) or tree_update.get("type") != "decision":
|
if not isinstance(tree_update, dict) or tree_update.get("type") != "decision":
|
||||||
logger.warning("AI tree update rejected: root must be a decision node")
|
logger.warning("AI tree update rejected: root must be a decision node")
|
||||||
tree_update = None
|
tree_update = None
|
||||||
@@ -345,6 +620,11 @@ async def send_message(
|
|||||||
merged.update(parsed["metadata"])
|
merged.update(parsed["metadata"])
|
||||||
session.tree_metadata = merged
|
session.tree_metadata = merged
|
||||||
|
|
||||||
|
if parsed.get("intake_form"):
|
||||||
|
merged = dict(session.tree_metadata)
|
||||||
|
merged["intake_form"] = parsed["intake_form"]
|
||||||
|
session.tree_metadata = merged
|
||||||
|
|
||||||
session.updated_at = datetime.now(timezone.utc)
|
session.updated_at = datetime.now(timezone.utc)
|
||||||
|
|
||||||
return parsed["content"], tree_update, parsed["phase"], parsed["metadata"]
|
return parsed["content"], tree_update, parsed["phase"], parsed["metadata"]
|
||||||
@@ -367,6 +647,32 @@ async def generate_final_tree(
|
|||||||
for msg in session.conversation_history
|
for msg in session.conversation_history
|
||||||
]
|
]
|
||||||
|
|
||||||
|
if session.flow_type in ("procedural", "maintenance"):
|
||||||
|
generation_instruction = """Based on our entire conversation, generate the COMPLETE and FINAL procedural steps JSON for this flow.
|
||||||
|
|
||||||
|
Requirements:
|
||||||
|
- Output format: {"steps": [...]} — a JSON object with a "steps" array
|
||||||
|
- Include ALL steps, section headers, and details we discussed
|
||||||
|
- Use descriptive step IDs (slugs, not UUIDs)
|
||||||
|
- Steps are in execution order (flat list, no branching)
|
||||||
|
- Use section_header steps to organize into logical phases
|
||||||
|
- Every procedure_step should have commands with exact syntax where discussed
|
||||||
|
- Every procedure_step should have expected_outcome and verification_prompt where discussed
|
||||||
|
- Include content_type, estimated_minutes, warning_text, and reference_url where discussed
|
||||||
|
- Use [VAR:variable_name] syntax in descriptions/commands for intake form variables
|
||||||
|
- The LAST step MUST be type "procedure_end"
|
||||||
|
- Respond with ONLY the JSON — no conversational text, no markdown fences
|
||||||
|
|
||||||
|
Also provide metadata as a separate JSON object after the steps:
|
||||||
|
[METADATA]
|
||||||
|
{"name": "...", "description": "...", "tags": ["..."]}
|
||||||
|
[/METADATA]
|
||||||
|
|
||||||
|
If we discussed intake form fields, also include:
|
||||||
|
[INTAKE_FORM]
|
||||||
|
[{"variable_name": "server_name", "label": "Server Name", "field_type": "text", "required": true, "placeholder": "e.g., DC01", "group_name": "Server Details", "display_order": 1}]
|
||||||
|
[/INTAKE_FORM]"""
|
||||||
|
else:
|
||||||
generation_instruction = """Based on our entire conversation, generate the COMPLETE and FINAL TreeStructure JSON for this flow.
|
generation_instruction = """Based on our entire conversation, generate the COMPLETE and FINAL TreeStructure JSON for this flow.
|
||||||
|
|
||||||
Requirements:
|
Requirements:
|
||||||
@@ -386,7 +692,7 @@ Also provide metadata as a separate JSON object after the tree:
|
|||||||
|
|
||||||
provider_messages.append({"role": "user", "content": generation_instruction})
|
provider_messages.append({"role": "user", "content": generation_instruction})
|
||||||
|
|
||||||
provider = get_ai_provider()
|
provider = get_ai_provider(model=settings.get_model_for_action("generate_full"))
|
||||||
|
|
||||||
for attempt in range(2): # One try + one retry
|
for attempt in range(2): # One try + one retry
|
||||||
response_text, input_tokens, output_tokens = await provider.generate_text(
|
response_text, input_tokens, output_tokens = await provider.generate_text(
|
||||||
@@ -421,21 +727,30 @@ Also provide metadata as a separate JSON object after the tree:
|
|||||||
continue
|
continue
|
||||||
raise ValueError("AI failed to produce valid JSON after retry")
|
raise ValueError("AI failed to produce valid JSON after retry")
|
||||||
|
|
||||||
errors = validate_generated_tree(tree)
|
if session.flow_type in ("procedural", "maintenance"):
|
||||||
if errors:
|
val_errors = validate_generated_procedural_steps(tree)
|
||||||
|
else:
|
||||||
|
val_errors = validate_generated_tree(tree)
|
||||||
|
|
||||||
|
if val_errors:
|
||||||
if attempt == 0:
|
if attempt == 0:
|
||||||
provider_messages.append({"role": "assistant", "content": response_text})
|
provider_messages.append({"role": "assistant", "content": response_text})
|
||||||
correction = (
|
correction = (
|
||||||
f"The tree has validation errors: {'; '.join(errors)}. "
|
f"The generated structure has validation errors: {'; '.join(val_errors)}. "
|
||||||
"Please fix these issues and respond with the corrected JSON only."
|
"Please fix these issues and respond with the corrected JSON only."
|
||||||
)
|
)
|
||||||
provider_messages.append({"role": "user", "content": correction})
|
provider_messages.append({"role": "user", "content": correction})
|
||||||
continue
|
continue
|
||||||
raise ValueError(f"Generated tree failed validation: {'; '.join(errors)}")
|
raise ValueError(f"Generated structure failed validation: {'; '.join(val_errors)}")
|
||||||
|
|
||||||
# Success
|
# Success
|
||||||
session.working_tree = tree
|
session.working_tree = tree
|
||||||
session.tree_metadata = metadata
|
session.tree_metadata = metadata
|
||||||
|
if parsed.get("intake_form"):
|
||||||
|
merged = dict(session.tree_metadata)
|
||||||
|
merged["intake_form"] = parsed["intake_form"]
|
||||||
|
session.tree_metadata = merged
|
||||||
|
metadata = session.tree_metadata
|
||||||
session.current_phase = "generation"
|
session.current_phase = "generation"
|
||||||
session.updated_at = datetime.now(timezone.utc)
|
session.updated_at = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
|||||||
@@ -184,6 +184,7 @@ class AnthropicProvider(AIProvider):
|
|||||||
client = anthropic.AsyncAnthropic(
|
client = anthropic.AsyncAnthropic(
|
||||||
api_key=self._api_key,
|
api_key=self._api_key,
|
||||||
timeout=self._timeout,
|
timeout=self._timeout,
|
||||||
|
max_retries=1,
|
||||||
)
|
)
|
||||||
|
|
||||||
response = await client.messages.create(
|
response = await client.messages.create(
|
||||||
@@ -209,9 +210,13 @@ class AnthropicProvider(AIProvider):
|
|||||||
return await self.generate_json(system_prompt, messages, max_tokens)
|
return await self.generate_json(system_prompt, messages, max_tokens)
|
||||||
|
|
||||||
|
|
||||||
def get_ai_provider() -> AIProvider:
|
def get_ai_provider(model: str | None = None) -> AIProvider:
|
||||||
"""Factory that returns the configured AI provider.
|
"""Factory that returns the configured AI provider.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
model: Optional model override (Anthropic model ID). Only applied to
|
||||||
|
AnthropicProvider; Gemini always uses settings.AI_MODEL_GEMINI.
|
||||||
|
|
||||||
Selection logic:
|
Selection logic:
|
||||||
1. If AI_PROVIDER == "gemini" and GOOGLE_AI_API_KEY is set -> GeminiProvider
|
1. If AI_PROVIDER == "gemini" and GOOGLE_AI_API_KEY is set -> GeminiProvider
|
||||||
2. If AI_PROVIDER == "anthropic" and ANTHROPIC_API_KEY is set -> AnthropicProvider
|
2. If AI_PROVIDER == "anthropic" and ANTHROPIC_API_KEY is set -> AnthropicProvider
|
||||||
@@ -230,7 +235,7 @@ def get_ai_provider() -> AIProvider:
|
|||||||
if settings.ANTHROPIC_API_KEY:
|
if settings.ANTHROPIC_API_KEY:
|
||||||
return AnthropicProvider(
|
return AnthropicProvider(
|
||||||
api_key=settings.ANTHROPIC_API_KEY,
|
api_key=settings.ANTHROPIC_API_KEY,
|
||||||
model=settings.AI_MODEL_ANTHROPIC,
|
model=model or settings.AI_MODEL_ANTHROPIC,
|
||||||
timeout=settings.AI_REQUEST_TIMEOUT_SECONDS,
|
timeout=settings.AI_REQUEST_TIMEOUT_SECONDS,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -238,7 +243,7 @@ def get_ai_provider() -> AIProvider:
|
|||||||
if settings.ANTHROPIC_API_KEY:
|
if settings.ANTHROPIC_API_KEY:
|
||||||
return AnthropicProvider(
|
return AnthropicProvider(
|
||||||
api_key=settings.ANTHROPIC_API_KEY,
|
api_key=settings.ANTHROPIC_API_KEY,
|
||||||
model=settings.AI_MODEL_ANTHROPIC,
|
model=model or settings.AI_MODEL_ANTHROPIC,
|
||||||
timeout=settings.AI_REQUEST_TIMEOUT_SECONDS,
|
timeout=settings.AI_REQUEST_TIMEOUT_SECONDS,
|
||||||
)
|
)
|
||||||
# Fallback to Gemini
|
# Fallback to Gemini
|
||||||
|
|||||||
@@ -230,3 +230,96 @@ def count_tree_stats(tree: dict[str, Any]) -> dict[str, int]:
|
|||||||
|
|
||||||
_count(tree, 1)
|
_count(tree, 1)
|
||||||
return stats
|
return stats
|
||||||
|
|
||||||
|
|
||||||
|
# --- Procedural flow validation ---
|
||||||
|
|
||||||
|
VALID_PROCEDURAL_STEP_TYPES = {"procedure_step", "procedure_end", "section_header"}
|
||||||
|
VALID_CONTENT_TYPES = {"action", "informational", "verification", "warning"}
|
||||||
|
|
||||||
|
|
||||||
|
def validate_generated_procedural_steps(tree: dict[str, Any]) -> list[str]:
|
||||||
|
"""Validate an AI-generated procedural step array.
|
||||||
|
|
||||||
|
Expects a dict with a 'steps' key containing a list of step objects.
|
||||||
|
Returns a list of error strings. Empty list means valid.
|
||||||
|
"""
|
||||||
|
errors: list[str] = []
|
||||||
|
|
||||||
|
if not isinstance(tree, dict):
|
||||||
|
return ["Procedural flow must be a JSON object"]
|
||||||
|
|
||||||
|
steps = tree.get("steps")
|
||||||
|
if not isinstance(steps, list) or len(steps) == 0:
|
||||||
|
return ["Procedural flow must have a non-empty 'steps' array"]
|
||||||
|
|
||||||
|
if len(steps) > 100:
|
||||||
|
errors.append(
|
||||||
|
f"Procedural flow has {len(steps)} steps. Maximum 100 allowed."
|
||||||
|
)
|
||||||
|
|
||||||
|
all_ids: set[str] = set()
|
||||||
|
procedure_step_count = 0
|
||||||
|
procedure_end_count = 0
|
||||||
|
|
||||||
|
for i, step in enumerate(steps):
|
||||||
|
if not isinstance(step, dict):
|
||||||
|
errors.append(f"Step at index {i} is not an object")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Check required fields
|
||||||
|
step_id = step.get("id")
|
||||||
|
step_type = step.get("type")
|
||||||
|
step_title = step.get("title")
|
||||||
|
|
||||||
|
if not step_id or not isinstance(step_id, str):
|
||||||
|
errors.append(f"Step at index {i} missing or invalid 'id' (must be a string)")
|
||||||
|
elif step_id in all_ids:
|
||||||
|
errors.append(f"Duplicate step ID: '{step_id}'")
|
||||||
|
else:
|
||||||
|
all_ids.add(step_id)
|
||||||
|
|
||||||
|
if not step_type or step_type not in VALID_PROCEDURAL_STEP_TYPES:
|
||||||
|
errors.append(
|
||||||
|
f"Step '{step_id or f'index {i}'}' has invalid type '{step_type}'. "
|
||||||
|
f"Must be one of: {', '.join(sorted(VALID_PROCEDURAL_STEP_TYPES))}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
if step_type == "procedure_step":
|
||||||
|
procedure_step_count += 1
|
||||||
|
elif step_type == "procedure_end":
|
||||||
|
procedure_end_count += 1
|
||||||
|
|
||||||
|
if not step_title or not isinstance(step_title, str):
|
||||||
|
errors.append(f"Step '{step_id or f'index {i}'}' missing or invalid 'title' (must be a string)")
|
||||||
|
|
||||||
|
# Validate content_type if present
|
||||||
|
content_type = step.get("content_type")
|
||||||
|
if content_type is not None and content_type not in VALID_CONTENT_TYPES:
|
||||||
|
errors.append(
|
||||||
|
f"Step '{step_id or f'index {i}'}' has invalid content_type '{content_type}'. "
|
||||||
|
f"Must be one of: {', '.join(sorted(VALID_CONTENT_TYPES))}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Must have exactly one procedure_end as the last step
|
||||||
|
if procedure_end_count == 0:
|
||||||
|
errors.append("Procedural flow must have exactly one 'procedure_end' step")
|
||||||
|
elif procedure_end_count > 1:
|
||||||
|
errors.append(
|
||||||
|
f"Procedural flow has {procedure_end_count} 'procedure_end' steps. "
|
||||||
|
"Must have exactly one."
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# Exactly one — check it's the last step
|
||||||
|
last_step = steps[-1]
|
||||||
|
if isinstance(last_step, dict) and last_step.get("type") != "procedure_end":
|
||||||
|
errors.append("The 'procedure_end' step must be the last step in the array")
|
||||||
|
|
||||||
|
# Need at least 2 procedure_step items
|
||||||
|
if procedure_step_count < 2:
|
||||||
|
errors.append(
|
||||||
|
f"Procedural flow has only {procedure_step_count} 'procedure_step' items. "
|
||||||
|
"Need at least 2 for a useful procedure."
|
||||||
|
)
|
||||||
|
|
||||||
|
return errors
|
||||||
|
|||||||
@@ -74,15 +74,36 @@ class Settings(BaseSettings):
|
|||||||
|
|
||||||
# AI Flow Builder
|
# AI Flow Builder
|
||||||
ANTHROPIC_API_KEY: Optional[str] = None
|
ANTHROPIC_API_KEY: Optional[str] = None
|
||||||
AI_MODEL: str = "claude-haiku-4-5-20251001"
|
AI_MODEL: str = "claude-sonnet-4-6"
|
||||||
AI_CONVERSATION_TTL_HOURS: int = 24
|
AI_CONVERSATION_TTL_HOURS: int = 24
|
||||||
AI_MAX_CALLS_PER_FLOW: int = 10
|
AI_MAX_CALLS_PER_FLOW: int = 10
|
||||||
AI_REQUEST_TIMEOUT_SECONDS: int = 45
|
AI_REQUEST_TIMEOUT_SECONDS: int = 120
|
||||||
# AI Provider selection
|
# AI Provider selection
|
||||||
AI_PROVIDER: str = "gemini" # "gemini" or "anthropic"
|
AI_PROVIDER: str = "anthropic" # "gemini" or "anthropic"
|
||||||
GOOGLE_AI_API_KEY: Optional[str] = None
|
GOOGLE_AI_API_KEY: Optional[str] = None
|
||||||
AI_MODEL_GEMINI: str = "gemini-2.5-flash"
|
AI_MODEL_GEMINI: str = "gemini-2.5-flash"
|
||||||
AI_MODEL_ANTHROPIC: str = "claude-haiku-4-5-20251001"
|
AI_MODEL_ANTHROPIC: str = "claude-sonnet-4-6"
|
||||||
|
|
||||||
|
# Model tier routing — maps action types to model tiers
|
||||||
|
AI_MODEL_TIERS: dict[str, str] = {
|
||||||
|
"fast": "claude-haiku-4-5-20251001",
|
||||||
|
"standard": "claude-sonnet-4-6",
|
||||||
|
}
|
||||||
|
|
||||||
|
ACTION_MODEL_MAP: dict[str, str] = {
|
||||||
|
"generate_full": "standard",
|
||||||
|
"generate_branch": "standard",
|
||||||
|
"modify_node": "fast",
|
||||||
|
"add_steps": "standard",
|
||||||
|
"quick_action": "fast",
|
||||||
|
"open_chat": "standard",
|
||||||
|
"variable_inference": "fast",
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_model_for_action(self, action_type: str) -> str:
|
||||||
|
"""Resolve an action type to a concrete model name via tier routing."""
|
||||||
|
tier = self.ACTION_MODEL_MAP.get(action_type, "standard")
|
||||||
|
return self.AI_MODEL_TIERS.get(tier, self.AI_MODEL_TIERS["standard"])
|
||||||
|
|
||||||
# MCP (Model Context Protocol) integrations
|
# MCP (Model Context Protocol) integrations
|
||||||
ENABLE_MCP_MICROSOFT_LEARN: bool = True
|
ENABLE_MCP_MICROSOFT_LEARN: bool = True
|
||||||
|
|||||||
@@ -22,6 +22,27 @@ setup_logging()
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
async def archive_stale_ai_sessions():
|
||||||
|
"""Archive AI chat sessions with no activity for 30 days."""
|
||||||
|
from app.models.ai_chat_session import AIChatSession
|
||||||
|
from sqlalchemy import update
|
||||||
|
from datetime import datetime, timezone, timedelta
|
||||||
|
|
||||||
|
cutoff = datetime.now(timezone.utc) - timedelta(days=30)
|
||||||
|
async with async_session_maker() as db:
|
||||||
|
result = await db.execute(
|
||||||
|
update(AIChatSession)
|
||||||
|
.where(
|
||||||
|
AIChatSession.updated_at < cutoff,
|
||||||
|
AIChatSession.archived_at.is_(None),
|
||||||
|
AIChatSession.status != "abandoned",
|
||||||
|
)
|
||||||
|
.values(archived_at=datetime.now(timezone.utc))
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
logger.info(f"[archive] Archived {result.rowcount} stale AI chat sessions")
|
||||||
|
|
||||||
|
|
||||||
def _configure_seed_module(mod: object, api_url: str, email: str, password: str) -> None:
|
def _configure_seed_module(mod: object, api_url: str, email: str, password: str) -> None:
|
||||||
"""Set globals on a seed script module."""
|
"""Set globals on a seed script module."""
|
||||||
mod.API_BASE_URL = api_url # type: ignore[attr-defined]
|
mod.API_BASE_URL = api_url # type: ignore[attr-defined]
|
||||||
@@ -132,6 +153,15 @@ async def lifespan(app: FastAPI):
|
|||||||
replace_existing=True,
|
replace_existing=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Auto-archive stale AI chat sessions (daily at 3 AM)
|
||||||
|
scheduler.add_job(
|
||||||
|
archive_stale_ai_sessions,
|
||||||
|
"cron",
|
||||||
|
hour=3,
|
||||||
|
id="archive_stale_ai_sessions",
|
||||||
|
replace_existing=True,
|
||||||
|
)
|
||||||
|
|
||||||
# Auto-seed trees in background on PR environments
|
# Auto-seed trees in background on PR environments
|
||||||
seed_task = None
|
seed_task = None
|
||||||
if settings.SEED_ON_DEPLOY:
|
if settings.SEED_ON_DEPLOY:
|
||||||
|
|||||||
@@ -86,3 +86,14 @@ class AIChatSession(Base):
|
|||||||
default=lambda: datetime.now(timezone.utc),
|
default=lambda: datetime.now(timezone.utc),
|
||||||
onupdate=lambda: datetime.now(timezone.utc),
|
onupdate=lambda: datetime.now(timezone.utc),
|
||||||
)
|
)
|
||||||
|
# Editor-embedded session: links to a specific tree/flow
|
||||||
|
tree_id: Mapped[Optional[uuid.UUID]] = mapped_column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
ForeignKey("trees.id", ondelete="CASCADE"),
|
||||||
|
nullable=True,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
archived_at: Mapped[Optional[datetime]] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
nullable=True,
|
||||||
|
)
|
||||||
|
|||||||
55
backend/app/models/ai_suggestion.py
Normal file
55
backend/app/models/ai_suggestion.py
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
"""AI Suggestion model for tracking AI-applied changes to flows."""
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from sqlalchemy import DateTime, ForeignKey, String
|
||||||
|
from sqlalchemy.dialects.postgresql import JSONB, UUID
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.core.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class AISuggestion(Base):
|
||||||
|
__tablename__ = "ai_suggestions"
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||||
|
)
|
||||||
|
tree_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
ForeignKey("trees.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
ForeignKey("users.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
session_id: Mapped[Optional[uuid.UUID]] = mapped_column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
ForeignKey("ai_chat_sessions.id", ondelete="SET NULL"),
|
||||||
|
nullable=True,
|
||||||
|
)
|
||||||
|
action_type: Mapped[str] = mapped_column(
|
||||||
|
String(50), nullable=False
|
||||||
|
)
|
||||||
|
target_node_id: Mapped[Optional[str]] = mapped_column(
|
||||||
|
String(255), nullable=True
|
||||||
|
)
|
||||||
|
changes_json: Mapped[dict] = mapped_column(
|
||||||
|
JSONB, nullable=False, default=dict
|
||||||
|
)
|
||||||
|
status: Mapped[str] = mapped_column(
|
||||||
|
String(20), nullable=False, default="pending"
|
||||||
|
)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
default=lambda: datetime.now(timezone.utc),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
resolved_at: Mapped[Optional[datetime]] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=True
|
||||||
|
)
|
||||||
@@ -154,6 +154,13 @@ class Tree(Base):
|
|||||||
comment="Fork depth: 0 = original, 1 = direct fork, 2 = fork of fork, etc."
|
comment="Fork depth: 0 = original, 1 = direct fork, 2 = fork of fork, etc."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Import provenance
|
||||||
|
import_metadata: Mapped[Optional[dict[str, Any]]] = mapped_column(
|
||||||
|
JSONB,
|
||||||
|
nullable=True,
|
||||||
|
comment="Provenance metadata from .rfflow file import"
|
||||||
|
)
|
||||||
|
|
||||||
# Relationships
|
# Relationships
|
||||||
author: Mapped[Optional["User"]] = relationship("User", foreign_keys=[author_id], back_populates="trees")
|
author: Mapped[Optional["User"]] = relationship("User", foreign_keys=[author_id], back_populates="trees")
|
||||||
team: Mapped[Optional["Team"]] = relationship("Team", back_populates="trees")
|
team: Mapped[Optional["Team"]] = relationship("Team", back_populates="trees")
|
||||||
|
|||||||
@@ -14,12 +14,39 @@ class AIChatStartRequest(BaseModel):
|
|||||||
flow_type: Literal["troubleshooting", "procedural"] = Field(
|
flow_type: Literal["troubleshooting", "procedural"] = Field(
|
||||||
..., description="Type of flow to build"
|
..., description="Type of flow to build"
|
||||||
)
|
)
|
||||||
|
tree_id: Optional[str] = Field(
|
||||||
|
default=None,
|
||||||
|
description="ID of existing tree for editor-embedded sessions",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
VALID_ACTION_TYPES = Literal[
|
||||||
|
"generate_full",
|
||||||
|
"generate_branch",
|
||||||
|
"modify_node",
|
||||||
|
"add_steps",
|
||||||
|
"quick_action",
|
||||||
|
"open_chat",
|
||||||
|
"variable_inference",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
class AIChatMessageRequest(BaseModel):
|
class AIChatMessageRequest(BaseModel):
|
||||||
"""Send a user message in a chat session."""
|
"""Send a user message in a chat session."""
|
||||||
|
|
||||||
content: str = Field(..., min_length=1, max_length=5000)
|
content: str = Field(..., min_length=1, max_length=5000)
|
||||||
|
action_type: Optional[VALID_ACTION_TYPES] = Field(
|
||||||
|
default="open_chat",
|
||||||
|
description="Type of AI action to perform",
|
||||||
|
)
|
||||||
|
focal_node_id: Optional[str] = Field(
|
||||||
|
default=None,
|
||||||
|
description="ID of the node/step being acted on",
|
||||||
|
)
|
||||||
|
flow_context: Optional[dict[str, Any]] = Field(
|
||||||
|
default=None,
|
||||||
|
description="Live flow structure from the editor (tree structure, steps, intake form)",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class AIChatImportRequest(BaseModel):
|
class AIChatImportRequest(BaseModel):
|
||||||
|
|||||||
33
backend/app/schemas/ai_suggestion.py
Normal file
33
backend/app/schemas/ai_suggestion.py
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
"""Schemas for AI suggestion audit trail."""
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
class AISuggestionCreate(BaseModel):
|
||||||
|
tree_id: UUID
|
||||||
|
session_id: Optional[UUID] = None
|
||||||
|
action_type: str
|
||||||
|
target_node_id: Optional[str] = None
|
||||||
|
changes_json: dict = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class AISuggestionResponse(BaseModel):
|
||||||
|
id: UUID
|
||||||
|
tree_id: UUID
|
||||||
|
user_id: UUID
|
||||||
|
session_id: Optional[UUID]
|
||||||
|
action_type: str
|
||||||
|
target_node_id: Optional[str]
|
||||||
|
changes_json: dict
|
||||||
|
status: str
|
||||||
|
created_at: datetime
|
||||||
|
resolved_at: Optional[datetime]
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|
||||||
|
class AISuggestionResolve(BaseModel):
|
||||||
|
status: str = Field(..., pattern="^(accepted|dismissed)$")
|
||||||
@@ -161,6 +161,7 @@ class TreeResponse(TreeBase):
|
|||||||
created_at: datetime
|
created_at: datetime
|
||||||
updated_at: datetime
|
updated_at: datetime
|
||||||
usage_count: int
|
usage_count: int
|
||||||
|
import_metadata: Optional[dict[str, Any]] = None
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
from_attributes = True
|
from_attributes = True
|
||||||
|
|||||||
52
backend/app/schemas/tree_export.py
Normal file
52
backend/app/schemas/tree_export.py
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
"""Schemas for .rfflow file export and import."""
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional, Any
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from app.schemas.tree import TreeType
|
||||||
|
|
||||||
|
|
||||||
|
class FlowExportCategory(BaseModel):
|
||||||
|
"""Category info embedded in export file."""
|
||||||
|
name: str
|
||||||
|
slug: str
|
||||||
|
|
||||||
|
|
||||||
|
class FlowExportData(BaseModel):
|
||||||
|
"""The flow payload inside an .rfflow file."""
|
||||||
|
name: str
|
||||||
|
description: Optional[str] = None
|
||||||
|
tree_type: TreeType
|
||||||
|
version: int = 1
|
||||||
|
author_name: Optional[str] = None
|
||||||
|
category: Optional[FlowExportCategory] = None
|
||||||
|
tags: list[str] = []
|
||||||
|
tree_structure: dict[str, Any]
|
||||||
|
intake_form: Optional[list[dict[str, Any]]] = None
|
||||||
|
|
||||||
|
|
||||||
|
class FlowExportEnvelope(BaseModel):
|
||||||
|
"""Top-level .rfflow file structure."""
|
||||||
|
rfflow_version: str = "1.0"
|
||||||
|
exported_at: datetime
|
||||||
|
source_app: str = "ResolutionFlow"
|
||||||
|
flow: FlowExportData
|
||||||
|
|
||||||
|
|
||||||
|
class FlowImportRequest(BaseModel):
|
||||||
|
"""What the frontend sends after parsing a .rfflow file."""
|
||||||
|
rfflow_version: str = Field(..., description="Must be '1.0'")
|
||||||
|
exported_at: datetime
|
||||||
|
source_app: str = "ResolutionFlow"
|
||||||
|
flow: FlowExportData
|
||||||
|
|
||||||
|
|
||||||
|
class FlowImportResponse(BaseModel):
|
||||||
|
"""Response after importing a flow."""
|
||||||
|
tree_id: str
|
||||||
|
name: str
|
||||||
|
tree_type: str
|
||||||
|
status: str = "draft"
|
||||||
|
category_created: bool = False
|
||||||
|
tags_created: list[str] = []
|
||||||
|
validation_warnings: list[str] = []
|
||||||
@@ -40,8 +40,8 @@ deep expertise across the MSP technology stack:
|
|||||||
- Security: MFA, Conditional Access, EDR, backup/DR
|
- Security: MFA, Conditional Access, EDR, backup/DR
|
||||||
|
|
||||||
## How to Answer
|
## How to Answer
|
||||||
- **Be direct and actionable.** Engineers are mid-ticket — give them the answer, \
|
- **Be direct and actionable.** Engineers are mid-ticket — lead with the fix or next \
|
||||||
not a lecture. Lead with the fix, then explain why.
|
diagnostic step, then explain why in one sentence if helpful. Skip background unless asked.
|
||||||
- **Include specifics.** Exact commands, registry paths, config values, port numbers. \
|
- **Include specifics.** Exact commands, registry paths, config values, port numbers. \
|
||||||
Vague advice wastes time.
|
Vague advice wastes time.
|
||||||
- **Warn before you wreck.** If a step could cause downtime, data loss, or a lockout, \
|
- **Warn before you wreck.** If a step could cause downtime, data loss, or a lockout, \
|
||||||
@@ -51,6 +51,17 @@ bold for key terms. Engineers scan, they don't read essays.
|
|||||||
- **Say when you're unsure.** If you don't know the exact answer, say so. Suggest \
|
- **Say when you're unsure.** If you don't know the exact answer, say so. Suggest \
|
||||||
where to verify (vendor docs, a specific KB article) rather than guessing.
|
where to verify (vendor docs, a specific KB article) rather than guessing.
|
||||||
|
|
||||||
|
## How to Ask Questions
|
||||||
|
- **Default to a single focused question.** Ask what you need to know right now to make progress.
|
||||||
|
- **Use contextual bullets sparingly.** If the question could be ambiguous (e.g., "what error?" \
|
||||||
|
when there are multiple common patterns), add 2-3 sub-bullets to help the engineer recognize \
|
||||||
|
what you're asking for — but keep it short.
|
||||||
|
- **Multiple questions only when blocking.** If you genuinely cannot proceed without knowing \
|
||||||
|
two things (e.g., both the error message AND which users are affected), preface it clearly: \
|
||||||
|
"Before continuing troubleshooting, I need to know: 1) [question], 2) [question]." Use this rarely.
|
||||||
|
- **Avoid interrogation mode.** Don't fire off 5 questions in a row. Get one answer, make \
|
||||||
|
progress, then ask the next question if needed.
|
||||||
|
|
||||||
## Using the Team's Flow Library
|
## Using the Team's Flow Library
|
||||||
Your team has built troubleshooting flows in ResolutionFlow. When relevant flows \
|
Your team has built troubleshooting flows in ResolutionFlow. When relevant flows \
|
||||||
appear in the context below, reference them by name so the engineer can launch them \
|
appear in the context below, reference them by name so the engineer can launch them \
|
||||||
|
|||||||
116
backend/tests/test_ai_delta_response.py
Normal file
116
backend/tests/test_ai_delta_response.py
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
"""Tests for AI delta response parsing and action-type prompt dispatch."""
|
||||||
|
from app.core.ai_chat_service import _parse_delta, _build_action_prompt, _find_node_by_id
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_delta_from_response():
|
||||||
|
"""Service extracts [DELTA] markers from AI responses."""
|
||||||
|
response = '''Here's a new branch for that node.
|
||||||
|
|
||||||
|
[DELTA]
|
||||||
|
{"action": "add", "target_node_id": "check-dns", "nodes": [{"id": "verify-dns-server", "type": "decision", "question": "Is the DNS server responding?"}], "explanation": "Added DNS verification branch"}
|
||||||
|
[/DELTA]
|
||||||
|
|
||||||
|
Let me know if you'd like to adjust this.'''
|
||||||
|
|
||||||
|
parsed = _parse_delta(response)
|
||||||
|
assert parsed is not None
|
||||||
|
assert parsed["action"] == "add"
|
||||||
|
assert parsed["target_node_id"] == "check-dns"
|
||||||
|
assert len(parsed["nodes"]) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_delta_none_when_absent():
|
||||||
|
"""Returns None when no delta marker present."""
|
||||||
|
response = "Sure, I can explain that node. It checks connectivity."
|
||||||
|
parsed = _parse_delta(response)
|
||||||
|
assert parsed is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_delta_with_markdown_fences():
|
||||||
|
"""Handles delta JSON wrapped in markdown code fences."""
|
||||||
|
response = '''[DELTA]
|
||||||
|
```json
|
||||||
|
{"action": "modify", "target_node_id": "node-1", "nodes": [{"id": "node-1", "type": "action", "title": "Updated"}], "explanation": "Modified title"}
|
||||||
|
```
|
||||||
|
[/DELTA]'''
|
||||||
|
parsed = _parse_delta(response)
|
||||||
|
assert parsed is not None
|
||||||
|
assert parsed["action"] == "modify"
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_delta_invalid_json():
|
||||||
|
"""Returns None for invalid JSON inside delta markers."""
|
||||||
|
response = "[DELTA]not valid json[/DELTA]"
|
||||||
|
parsed = _parse_delta(response)
|
||||||
|
assert parsed is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_action_prompt_generate_branch():
|
||||||
|
"""Generate branch action includes focal node context."""
|
||||||
|
tree = {
|
||||||
|
"id": "root",
|
||||||
|
"type": "decision",
|
||||||
|
"question": "Is the server up?",
|
||||||
|
"children": [],
|
||||||
|
"options": [],
|
||||||
|
}
|
||||||
|
prompt = _build_action_prompt(
|
||||||
|
action_type="generate_branch",
|
||||||
|
focal_node_id="root",
|
||||||
|
tree_structure=tree,
|
||||||
|
flow_type="troubleshooting",
|
||||||
|
)
|
||||||
|
assert "root" in prompt
|
||||||
|
assert "generate" in prompt.lower() or "branch" in prompt.lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_action_prompt_open_chat():
|
||||||
|
"""Open chat action is general conversation."""
|
||||||
|
prompt = _build_action_prompt(
|
||||||
|
action_type="open_chat",
|
||||||
|
focal_node_id=None,
|
||||||
|
tree_structure={"id": "root", "type": "decision"},
|
||||||
|
flow_type="troubleshooting",
|
||||||
|
)
|
||||||
|
assert isinstance(prompt, str)
|
||||||
|
assert len(prompt) > 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_find_node_by_id_root():
|
||||||
|
"""Finds root node."""
|
||||||
|
tree = {"id": "root", "type": "decision", "children": []}
|
||||||
|
assert _find_node_by_id(tree, "root") is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_find_node_by_id_nested():
|
||||||
|
"""Finds nested child node."""
|
||||||
|
tree = {
|
||||||
|
"id": "root",
|
||||||
|
"type": "decision",
|
||||||
|
"children": [
|
||||||
|
{"id": "child-1", "type": "action", "children": []},
|
||||||
|
{"id": "child-2", "type": "solution", "children": []},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
found = _find_node_by_id(tree, "child-2")
|
||||||
|
assert found is not None
|
||||||
|
assert found["id"] == "child-2"
|
||||||
|
|
||||||
|
|
||||||
|
def test_find_node_by_id_not_found():
|
||||||
|
"""Returns None for non-existent node."""
|
||||||
|
tree = {"id": "root", "type": "decision", "children": []}
|
||||||
|
assert _find_node_by_id(tree, "nonexistent") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_find_node_by_id_in_steps():
|
||||||
|
"""Finds node in procedural steps array."""
|
||||||
|
tree = {
|
||||||
|
"steps": [
|
||||||
|
{"id": "step-1", "type": "procedure_step"},
|
||||||
|
{"id": "step-2", "type": "procedure_step"},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
found = _find_node_by_id(tree, "step-2")
|
||||||
|
assert found is not None
|
||||||
|
assert found["id"] == "step-2"
|
||||||
41
backend/tests/test_ai_suggestions.py
Normal file
41
backend/tests/test_ai_suggestions.py
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
"""Tests for AI suggestion endpoints."""
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_create_and_list_suggestions(client, auth_headers, test_tree):
|
||||||
|
"""Can create and list suggestions for a tree."""
|
||||||
|
tree_id = test_tree["id"]
|
||||||
|
|
||||||
|
# Create suggestion
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/v1/ai/suggestions",
|
||||||
|
json={
|
||||||
|
"tree_id": tree_id,
|
||||||
|
"action_type": "generate_branch",
|
||||||
|
"target_node_id": "some-node",
|
||||||
|
"changes_json": {"before": {}, "after": {"id": "new-node"}},
|
||||||
|
},
|
||||||
|
headers=auth_headers,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201
|
||||||
|
suggestion_id = resp.json()["id"]
|
||||||
|
assert resp.json()["status"] == "pending"
|
||||||
|
|
||||||
|
# List suggestions
|
||||||
|
resp = await client.get(
|
||||||
|
f"/api/v1/ai/suggestions/tree/{tree_id}",
|
||||||
|
headers=auth_headers,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert len(resp.json()) >= 1
|
||||||
|
|
||||||
|
# Resolve suggestion
|
||||||
|
resp = await client.patch(
|
||||||
|
f"/api/v1/ai/suggestions/{suggestion_id}",
|
||||||
|
json={"status": "accepted"},
|
||||||
|
headers=auth_headers,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["status"] == "accepted"
|
||||||
|
assert resp.json()["resolved_at"] is not None
|
||||||
24
backend/tests/test_config_model_tiers.py
Normal file
24
backend/tests/test_config_model_tiers.py
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
"""Tests for AI model tier configuration."""
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
|
||||||
|
def test_ai_model_tiers_exist():
|
||||||
|
assert "fast" in settings.AI_MODEL_TIERS
|
||||||
|
assert "standard" in settings.AI_MODEL_TIERS
|
||||||
|
|
||||||
|
|
||||||
|
def test_action_model_map_covers_all_actions():
|
||||||
|
valid_tiers = set(settings.AI_MODEL_TIERS.keys())
|
||||||
|
for action, tier in settings.ACTION_MODEL_MAP.items():
|
||||||
|
assert tier in valid_tiers, f"Action '{action}' maps to unknown tier '{tier}'"
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_model_for_action():
|
||||||
|
model = settings.get_model_for_action("generate_full")
|
||||||
|
assert isinstance(model, str)
|
||||||
|
assert len(model) > 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_model_for_action_unknown_falls_back():
|
||||||
|
model = settings.get_model_for_action("nonexistent_action")
|
||||||
|
assert model == settings.AI_MODEL_TIERS["standard"]
|
||||||
282
backend/tests/test_tree_transfer.py
Normal file
282
backend/tests/test_tree_transfer.py
Normal file
@@ -0,0 +1,282 @@
|
|||||||
|
"""Tests for flow export/import (.rfflow) endpoints."""
|
||||||
|
import pytest
|
||||||
|
from httpx import AsyncClient
|
||||||
|
|
||||||
|
|
||||||
|
# --- Helpers ---
|
||||||
|
|
||||||
|
TREE_DATA = {
|
||||||
|
"name": "DNS Troubleshooting",
|
||||||
|
"description": "Diagnose DNS resolution issues",
|
||||||
|
"category": "Networking",
|
||||||
|
"tree_structure": {
|
||||||
|
"id": "root",
|
||||||
|
"type": "decision",
|
||||||
|
"question": "Is DNS resolving?",
|
||||||
|
"options": [
|
||||||
|
{"id": "yes", "label": "Yes", "next_node_id": "sol1"},
|
||||||
|
{"id": "no", "label": "No", "next_node_id": "sol2"},
|
||||||
|
],
|
||||||
|
"children": [
|
||||||
|
{"id": "sol1", "type": "solution", "title": "DNS OK", "description": "DNS is working", "solution": "No action needed"},
|
||||||
|
{"id": "sol2", "type": "solution", "title": "DNS Fail", "description": "DNS is not resolving", "solution": "Check DNS server config"},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"tags": ["dns", "networking"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def create_tree_with_tags(client: AsyncClient, headers: dict, data: dict | None = None) -> dict:
|
||||||
|
"""Create a tree and return the response."""
|
||||||
|
resp = await client.post("/api/v1/trees", json=data or TREE_DATA, headers=headers)
|
||||||
|
assert resp.status_code == 201
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
|
||||||
|
# --- Export Tests ---
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_export_json_format(client, auth_headers, test_tree):
|
||||||
|
"""Export should return valid .rfflow JSON with correct structure."""
|
||||||
|
resp = await client.get(
|
||||||
|
f"/api/v1/trees/{test_tree['id']}/export",
|
||||||
|
headers=auth_headers,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert "attachment" in resp.headers.get("content-disposition", "")
|
||||||
|
assert ".rfflow" in resp.headers.get("content-disposition", "")
|
||||||
|
|
||||||
|
data = resp.json()
|
||||||
|
assert data["rfflow_version"] == "1.0"
|
||||||
|
assert data["source_app"] == "ResolutionFlow"
|
||||||
|
assert data["exported_at"] is not None
|
||||||
|
|
||||||
|
flow = data["flow"]
|
||||||
|
assert flow["name"] == test_tree["name"]
|
||||||
|
assert flow["tree_structure"] is not None
|
||||||
|
assert flow["tree_type"] == "troubleshooting"
|
||||||
|
|
||||||
|
# No IDs leaked
|
||||||
|
assert "id" not in flow or flow.get("id") is None
|
||||||
|
assert "author_id" not in flow
|
||||||
|
assert "account_id" not in flow
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_export_with_category_and_tags(client, auth_headers):
|
||||||
|
"""Export should include category and tag data."""
|
||||||
|
tree = await create_tree_with_tags(client, auth_headers)
|
||||||
|
resp = await client.get(
|
||||||
|
f"/api/v1/trees/{tree['id']}/export",
|
||||||
|
headers=auth_headers,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
flow = resp.json()["flow"]
|
||||||
|
assert len(flow["tags"]) == 2
|
||||||
|
assert "dns" in flow["tags"]
|
||||||
|
assert "networking" in flow["tags"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_export_access_control(client, auth_headers, test_admin, admin_auth_headers, test_tree):
|
||||||
|
"""Users should only export trees they can access."""
|
||||||
|
# Create a second user who can't access the tree
|
||||||
|
user2_data = {
|
||||||
|
"email": "other@example.com",
|
||||||
|
"password": "OtherPass123!",
|
||||||
|
"name": "Other User",
|
||||||
|
}
|
||||||
|
await client.post("/api/v1/auth/register", json=user2_data)
|
||||||
|
login_resp = await client.post("/api/v1/auth/login/json", json={
|
||||||
|
"email": user2_data["email"],
|
||||||
|
"password": user2_data["password"],
|
||||||
|
})
|
||||||
|
other_headers = {"Authorization": f"Bearer {login_resp.json()['access_token']}"}
|
||||||
|
|
||||||
|
resp = await client.get(
|
||||||
|
f"/api/v1/trees/{test_tree['id']}/export",
|
||||||
|
headers=other_headers,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_export_nonexistent_tree(client, auth_headers):
|
||||||
|
"""Export of non-existent tree returns 404."""
|
||||||
|
import uuid
|
||||||
|
resp = await client.get(
|
||||||
|
f"/api/v1/trees/{uuid.uuid4()}/export",
|
||||||
|
headers=auth_headers,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
# --- Import Tests ---
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_import_happy_path(client, auth_headers, test_tree):
|
||||||
|
"""Import should create a draft tree owned by the importing user."""
|
||||||
|
# First export
|
||||||
|
export_resp = await client.get(
|
||||||
|
f"/api/v1/trees/{test_tree['id']}/export",
|
||||||
|
headers=auth_headers,
|
||||||
|
)
|
||||||
|
rfflow_data = export_resp.json()
|
||||||
|
|
||||||
|
# Import
|
||||||
|
import_resp = await client.post(
|
||||||
|
"/api/v1/trees/import",
|
||||||
|
json=rfflow_data,
|
||||||
|
headers=auth_headers,
|
||||||
|
)
|
||||||
|
assert import_resp.status_code == 201
|
||||||
|
result = import_resp.json()
|
||||||
|
assert result["status"] == "draft"
|
||||||
|
assert result["name"] == test_tree["name"]
|
||||||
|
assert result["tree_id"] is not None
|
||||||
|
|
||||||
|
# Verify the created tree
|
||||||
|
tree_resp = await client.get(
|
||||||
|
f"/api/v1/trees/{result['tree_id']}",
|
||||||
|
headers=auth_headers,
|
||||||
|
)
|
||||||
|
assert tree_resp.status_code == 200
|
||||||
|
tree = tree_resp.json()
|
||||||
|
assert tree["status"] == "draft"
|
||||||
|
assert tree["import_metadata"] is not None
|
||||||
|
assert tree["import_metadata"]["source_app"] == "ResolutionFlow"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_import_with_name_override(client, auth_headers, test_tree):
|
||||||
|
"""Import with name_override should use the override name."""
|
||||||
|
export_resp = await client.get(
|
||||||
|
f"/api/v1/trees/{test_tree['id']}/export",
|
||||||
|
headers=auth_headers,
|
||||||
|
)
|
||||||
|
rfflow_data = export_resp.json()
|
||||||
|
|
||||||
|
import_resp = await client.post(
|
||||||
|
"/api/v1/trees/import?name_override=Custom%20Name",
|
||||||
|
json=rfflow_data,
|
||||||
|
headers=auth_headers,
|
||||||
|
)
|
||||||
|
assert import_resp.status_code == 201
|
||||||
|
assert import_resp.json()["name"] == "Custom Name"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_import_with_new_tags(client, auth_headers):
|
||||||
|
"""Import with new tags should create them automatically."""
|
||||||
|
rfflow = {
|
||||||
|
"rfflow_version": "1.0",
|
||||||
|
"exported_at": "2026-03-05T14:30:00+00:00",
|
||||||
|
"source_app": "ResolutionFlow",
|
||||||
|
"flow": {
|
||||||
|
"name": "Test Import Tags",
|
||||||
|
"description": "Testing tag creation",
|
||||||
|
"tree_type": "troubleshooting",
|
||||||
|
"version": 1,
|
||||||
|
"author_name": "Test Author",
|
||||||
|
"category": None,
|
||||||
|
"tags": ["brand-new-tag", "another-tag"],
|
||||||
|
"tree_structure": {
|
||||||
|
"id": "root",
|
||||||
|
"type": "decision",
|
||||||
|
"question": "Q?",
|
||||||
|
"options": [{"id": "a", "label": "A", "next_node_id": "s1"}],
|
||||||
|
"children": [{"id": "s1", "type": "solution", "title": "S", "description": "D", "solution": "S"}],
|
||||||
|
},
|
||||||
|
"intake_form": None,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
resp = await client.post("/api/v1/trees/import", json=rfflow, headers=auth_headers)
|
||||||
|
assert resp.status_code == 201
|
||||||
|
result = resp.json()
|
||||||
|
assert "brand-new-tag" in result["tags_created"]
|
||||||
|
assert "another-tag" in result["tags_created"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_import_with_category_creation(client, auth_headers):
|
||||||
|
"""Import with a new category should create it."""
|
||||||
|
rfflow = {
|
||||||
|
"rfflow_version": "1.0",
|
||||||
|
"exported_at": "2026-03-05T14:30:00+00:00",
|
||||||
|
"source_app": "ResolutionFlow",
|
||||||
|
"flow": {
|
||||||
|
"name": "Import Category Test",
|
||||||
|
"description": None,
|
||||||
|
"tree_type": "troubleshooting",
|
||||||
|
"version": 1,
|
||||||
|
"author_name": None,
|
||||||
|
"category": {"name": "New Category", "slug": "new-category"},
|
||||||
|
"tags": [],
|
||||||
|
"tree_structure": {
|
||||||
|
"id": "root",
|
||||||
|
"type": "decision",
|
||||||
|
"question": "Q?",
|
||||||
|
"options": [{"id": "a", "label": "A", "next_node_id": "s1"}],
|
||||||
|
"children": [{"id": "s1", "type": "solution", "title": "S", "description": "D", "solution": "S"}],
|
||||||
|
},
|
||||||
|
"intake_form": None,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
resp = await client.post("/api/v1/trees/import", json=rfflow, headers=auth_headers)
|
||||||
|
assert resp.status_code == 201
|
||||||
|
assert resp.json()["category_created"] is True
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_import_invalid_version(client, auth_headers):
|
||||||
|
"""Import with unsupported rfflow version should return 422."""
|
||||||
|
rfflow = {
|
||||||
|
"rfflow_version": "99.0",
|
||||||
|
"exported_at": "2026-03-05T14:30:00+00:00",
|
||||||
|
"source_app": "ResolutionFlow",
|
||||||
|
"flow": {
|
||||||
|
"name": "Bad Version",
|
||||||
|
"tree_type": "troubleshooting",
|
||||||
|
"version": 1,
|
||||||
|
"tags": [],
|
||||||
|
"tree_structure": {"id": "root", "type": "decision", "question": "Q?", "options": [], "children": []},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
resp = await client.post("/api/v1/trees/import", json=rfflow, headers=auth_headers)
|
||||||
|
assert resp.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_import_round_trip(client, auth_headers):
|
||||||
|
"""Export then import should produce a tree with matching data."""
|
||||||
|
original = await create_tree_with_tags(client, auth_headers)
|
||||||
|
|
||||||
|
# Export
|
||||||
|
export_resp = await client.get(
|
||||||
|
f"/api/v1/trees/{original['id']}/export",
|
||||||
|
headers=auth_headers,
|
||||||
|
)
|
||||||
|
rfflow = export_resp.json()
|
||||||
|
|
||||||
|
# Import
|
||||||
|
import_resp = await client.post(
|
||||||
|
"/api/v1/trees/import",
|
||||||
|
json=rfflow,
|
||||||
|
headers=auth_headers,
|
||||||
|
)
|
||||||
|
assert import_resp.status_code == 201
|
||||||
|
result = import_resp.json()
|
||||||
|
|
||||||
|
# Verify imported tree matches original structure
|
||||||
|
tree_resp = await client.get(
|
||||||
|
f"/api/v1/trees/{result['tree_id']}",
|
||||||
|
headers=auth_headers,
|
||||||
|
)
|
||||||
|
imported_tree = tree_resp.json()
|
||||||
|
assert imported_tree["name"] == original["name"]
|
||||||
|
assert imported_tree["tree_structure"]["id"] == original["tree_structure"]["id"]
|
||||||
|
assert imported_tree["tree_type"] == original["tree_type"]
|
||||||
|
assert imported_tree["status"] == "draft" # Always draft on import
|
||||||
361
docs/plans/2026-03-06-editor-embedded-flow-assist-design.md
Normal file
361
docs/plans/2026-03-06-editor-embedded-flow-assist-design.md
Normal file
@@ -0,0 +1,361 @@
|
|||||||
|
# Editor-Embedded Flow Assist - Design Document
|
||||||
|
|
||||||
|
> **Date:** 2026-03-06
|
||||||
|
> **Status:** Approved
|
||||||
|
> **Replaces:** Standalone AI Chat Builder (`/ai/chat`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Replace the standalone `/ai/chat` page with a context-aware AI side panel embedded directly in each editor (Troubleshooting + Procedural). The panel knows which node/step is focused, supports targeted and open-ended actions, and applies changes via a tiered suggestion system. Knowledge integration and variable inference are phased features built on the same panel architecture.
|
||||||
|
|
||||||
|
**Key Principles:**
|
||||||
|
- Context-aware: panel knows the full tree/step structure + focal node
|
||||||
|
- Targeted actions auto-apply; open-ended suggestions require acceptance
|
||||||
|
- Output-based thresholds determine suggestion UX
|
||||||
|
- Model routing is config-driven, not hardcoded
|
||||||
|
- Chat history persists per-flow, per-user
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Panel Layout & Behavior
|
||||||
|
|
||||||
|
### Dimensions & Styling
|
||||||
|
- **Width:** 320px fixed, right side
|
||||||
|
- **Styling:** Glassmorphism (`.glass-card-static` bg, backdrop blur, `border-l border-border`)
|
||||||
|
- **Z-index:** Same layer as node editor panel (not overlay)
|
||||||
|
|
||||||
|
### Single-Panel Rule
|
||||||
|
- **Tree editor:** AI panel occupies the right panel slot, closing the node editor panel. When AI panel closes, if a node was previously being edited, the node editor panel reopens for that node.
|
||||||
|
- **Procedural editor:** AI panel slides in from right, narrowing the step list (step list takes `flex-1`). No existing panel to replace.
|
||||||
|
|
||||||
|
### Top Section: Context Summary
|
||||||
|
- **Node/step selected:** Read-only summary showing type, title, question/description of the focused item.
|
||||||
|
- **No selection:** Flow summary showing name, node/step count, flow type.
|
||||||
|
- Switching selection updates the summary live.
|
||||||
|
|
||||||
|
### Tabs
|
||||||
|
- **Chat** — conversation + inline suggestions
|
||||||
|
- **Suggestions** — audit trail of all AI-applied changes to this flow (accepted, dismissed, pending)
|
||||||
|
|
||||||
|
### Visibility
|
||||||
|
- Hidden by default
|
||||||
|
- Auto-opens on: AI-assisted flow creation, right-click AI action, toolbar toggle
|
||||||
|
- Auto-contextual: opens with focal node already set when triggered via context menu
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Entry Points
|
||||||
|
|
||||||
|
### 1. Create Flow Dropdown (AI-Assisted)
|
||||||
|
- "Blank" or "AI-assisted" option per flow type (Troubleshooting, Project, Maintenance)
|
||||||
|
- **AI-assisted** shows a simple prompt dialog modal:
|
||||||
|
- Text area: "Describe the flow you want to build"
|
||||||
|
- Flow type already known from dropdown selection
|
||||||
|
- Loading state during generation
|
||||||
|
- On failure: error message + retry button (stays in dialog)
|
||||||
|
- On success: creates tree via API, navigates to editor with AI panel auto-opened and generation chat history loaded
|
||||||
|
- No multi-phase interview, no preview — just prompt and go
|
||||||
|
|
||||||
|
### 2. Right-Click Context Menu
|
||||||
|
- New `<ContextMenu>` component (no existing context menus in either editor)
|
||||||
|
- Positioned absolutely at right-click point
|
||||||
|
- Closes on click-away, Escape, or action selection
|
||||||
|
- **Tree editor items:** Generate branch, Add decision/action/solution, Explain node, Find known fixes, Delete
|
||||||
|
- **Procedural editor items:** Generate steps after, Add verification step, Expand step, Generate section, Delete
|
||||||
|
- Selecting an AI action sets the focal node/step and opens the AI panel
|
||||||
|
|
||||||
|
### 3. Toolbar Toggle
|
||||||
|
- "AI Assist" button in editor toolbar to manually open/close the panel
|
||||||
|
|
||||||
|
### 4. Existing Flows
|
||||||
|
- AI panel works on any flow — new or existing, AI-created or manually built
|
||||||
|
- No restriction to AI-created flows
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Suggestion & Apply System
|
||||||
|
|
||||||
|
### Ghost Node/Step Mechanics
|
||||||
|
|
||||||
|
Ghost nodes/steps are added to `treeStructure`/steps array with a `_suggestion: true` flag:
|
||||||
|
- Canvas/step list renders them normally (auto-layout works) but with **dashed borders + reduced opacity**
|
||||||
|
- Zundo temporal store **paused** while suggestions are pending
|
||||||
|
- On **accept**: remove `_suggestion` flag, unpause zundo (creates one clean undo point)
|
||||||
|
- On **dismiss**: remove ghost nodes from structure, unpause zundo (no undo point created)
|
||||||
|
- Ghost nodes participate in auto-layout and connection drawing but are visually distinct
|
||||||
|
|
||||||
|
### Addition vs Modification
|
||||||
|
|
||||||
|
| Change Type | Visual Treatment |
|
||||||
|
|---|---|
|
||||||
|
| **New nodes/steps** | Ghost nodes: dashed borders, reduced opacity |
|
||||||
|
| **Modified existing nodes** | Subtle highlight + badge showing what changed |
|
||||||
|
| **Modified selected node** | Before/after shown in chat message with Apply button (not inline ghost) |
|
||||||
|
|
||||||
|
### Output-Based Threshold
|
||||||
|
|
||||||
|
| Output Size | Behavior |
|
||||||
|
|---|---|
|
||||||
|
| **1 node/step** | Auto-apply + toast notification with undo link |
|
||||||
|
| **2-4 nodes/steps** | Individual ghost suggestions + "Accept All" shortcut button |
|
||||||
|
| **5+ nodes/steps** | Ghost suggestions grouped by branch (tree) or section (procedural) with "Accept Branch"/"Accept Section" and "Accept All" controls + summary card in panel |
|
||||||
|
|
||||||
|
All changes (accepted or dismissed) logged in the Suggestions tab as an audit trail.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Backend Action Types
|
||||||
|
|
||||||
|
Each message to the AI includes an `action_type` that determines prompt construction, response schema, and model routing:
|
||||||
|
|
||||||
|
| Action Type | Description | Model Tier | Response Format |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `generate_full` | Initial skeleton from prompt dialog | standard | Full tree structure or step array |
|
||||||
|
| `generate_branch` | Generate children for a specific node | standard | Subtree delta (node + children) |
|
||||||
|
| `modify_node` | Update a specific node's content | fast | Single node delta (before/after) |
|
||||||
|
| `add_steps` | Add steps after a specific step | standard | Step array delta |
|
||||||
|
| `quick_action` | Single-node operations (explain, expand) | fast | Single node delta or text response |
|
||||||
|
| `open_chat` | General conversation about the flow | standard | Text + optional delta |
|
||||||
|
| `variable_inference` | Detect implicit variables in step content | fast | Variable suggestions |
|
||||||
|
|
||||||
|
### Prompt Construction
|
||||||
|
|
||||||
|
Each action type gets a tailored system prompt:
|
||||||
|
- **Full tree context** always included (so AI understands the complete flow)
|
||||||
|
- **Focal node** highlighted when present (the specific node/step being acted on)
|
||||||
|
- **Action instruction** describes what the AI should return
|
||||||
|
- **Response schema** constrains output format (full tree, subtree delta, single node, text)
|
||||||
|
|
||||||
|
### Delta Response Format
|
||||||
|
|
||||||
|
For partial updates, the AI returns a delta object:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"action": "add" | "modify" | "delete",
|
||||||
|
"target_node_id": "node-to-modify-or-insert-after",
|
||||||
|
"nodes": [{ /* node objects */ }],
|
||||||
|
"explanation": "What was changed and why"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The frontend applies the delta to the tree structure and renders ghost nodes as appropriate.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Model Routing (Config-Driven)
|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
|
||||||
|
```python
|
||||||
|
# backend/app/core/config.py
|
||||||
|
AI_MODEL_TIERS = {
|
||||||
|
"fast": "claude-haiku-4-5-20251001",
|
||||||
|
"standard": "claude-sonnet-4-6-20250514",
|
||||||
|
}
|
||||||
|
|
||||||
|
ACTION_MODEL_MAP = {
|
||||||
|
"generate_full": "standard",
|
||||||
|
"generate_branch": "standard",
|
||||||
|
"modify_node": "fast",
|
||||||
|
"add_steps": "standard",
|
||||||
|
"quick_action": "fast",
|
||||||
|
"open_chat": "standard",
|
||||||
|
"variable_inference": "fast",
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Routing Logic
|
||||||
|
|
||||||
|
1. Message endpoint receives `action_type` parameter
|
||||||
|
2. Look up tier from `ACTION_MODEL_MAP`
|
||||||
|
3. Resolve model name from `AI_MODEL_TIERS`
|
||||||
|
4. Pass to Anthropic API call
|
||||||
|
|
||||||
|
Both tiers can map to the same model initially. Changing model assignment is a config change, not a code change.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Knowledge Integration (Phased)
|
||||||
|
|
||||||
|
### Phase 1 (Initial Release)
|
||||||
|
- Uses existing Microsoft Learn MCP server
|
||||||
|
- AI can cite KB articles, known issues, and official fix procedures in chat responses
|
||||||
|
- Citations rendered inline as collapsible cards with source URL and title
|
||||||
|
- AI response marker: `[KNOWLEDGE]{"title": "...", "url": "...", "excerpt": "..."}[/KNOWLEDGE]`
|
||||||
|
|
||||||
|
### Phase 2 (Future)
|
||||||
|
- Additional vendor documentation sources
|
||||||
|
- Community knowledge bases
|
||||||
|
- Proactive suggestions ("Microsoft released KB5034441 addressing this scenario")
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Chat Persistence
|
||||||
|
|
||||||
|
### Session Model
|
||||||
|
- `ai_chat_session` model extended with:
|
||||||
|
- `tree_id` FK (which flow this session belongs to)
|
||||||
|
- `archived_at` timestamp (null = active)
|
||||||
|
- Per-flow, per-user sessions: multiple engineers on the same flow get separate chat histories
|
||||||
|
- Session loads on panel open if one exists for this flow + user
|
||||||
|
|
||||||
|
### Suggestions Audit Trail
|
||||||
|
New `ai_suggestion` table:
|
||||||
|
|
||||||
|
| Column | Type | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `id` | UUID | Primary key |
|
||||||
|
| `tree_id` | UUID FK | Which flow |
|
||||||
|
| `user_id` | UUID FK | Who triggered |
|
||||||
|
| `session_id` | UUID FK | Which chat session |
|
||||||
|
| `action_type` | String | Action that generated this suggestion |
|
||||||
|
| `target_node_id` | String | Node/step acted on (nullable) |
|
||||||
|
| `changes_json` | JSONB | Before/after snapshot |
|
||||||
|
| `status` | Enum | `pending`, `accepted`, `dismissed` |
|
||||||
|
| `created_at` | DateTime(tz) | When suggested |
|
||||||
|
| `resolved_at` | DateTime(tz) | When accepted/dismissed (nullable) |
|
||||||
|
|
||||||
|
### Auto-Archive
|
||||||
|
- APScheduler task runs daily
|
||||||
|
- Archives sessions with no activity for 30 days (`archived_at = now()`)
|
||||||
|
- Archived sessions viewable in Suggestions tab but not resumable for chat
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Troubleshooting Editor Integration
|
||||||
|
|
||||||
|
### Panel Context
|
||||||
|
- Full tree structure included in AI context
|
||||||
|
- Focal node (when selected/right-clicked) highlighted in context
|
||||||
|
- Node summary at panel top shows: type icon, node ID, question/title, option count
|
||||||
|
|
||||||
|
### Context Menu Actions
|
||||||
|
| Action | Description | Model Tier |
|
||||||
|
|---|---|---|
|
||||||
|
| Generate branch | Create child nodes from this decision | standard |
|
||||||
|
| Add decision node | Add a decision child | fast |
|
||||||
|
| Add action node | Add an action child | fast |
|
||||||
|
| Add solution node | Add a solution child | fast |
|
||||||
|
| Explain node | AI explains what this node does | fast |
|
||||||
|
| Find known fixes | Search knowledge sources for this scenario | standard |
|
||||||
|
|
||||||
|
### Ghost Node Rendering
|
||||||
|
- Dashed `border-dashed border-primary/40` borders
|
||||||
|
- `opacity-60` on the node card
|
||||||
|
- Connection lines drawn with dashed stroke
|
||||||
|
- Accept/dismiss buttons overlaid on each ghost node
|
||||||
|
- "Accept All" button in the panel when 2+ ghost nodes
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Procedural Editor Integration
|
||||||
|
|
||||||
|
### Panel Context
|
||||||
|
- Full step list included in AI context
|
||||||
|
- Focal step (when selected/right-clicked) highlighted in context
|
||||||
|
- Step summary at panel top shows: step number, type badge, title, content type
|
||||||
|
|
||||||
|
### Context Menu Actions
|
||||||
|
| Action | Description | Model Tier |
|
||||||
|
|---|---|---|
|
||||||
|
| Generate steps after | Add steps following this one | standard |
|
||||||
|
| Add verification step | Insert a verification step | fast |
|
||||||
|
| Expand step | Break this step into substeps | standard |
|
||||||
|
| Generate section | Create a section header + steps | standard |
|
||||||
|
|
||||||
|
### Ghost Step Rendering
|
||||||
|
- Dashed left border (`border-l-2 border-dashed border-primary/40`)
|
||||||
|
- `opacity-60` background
|
||||||
|
- Accept/dismiss buttons on each ghost step
|
||||||
|
- Grouped by section when 5+ suggestions
|
||||||
|
|
||||||
|
### Intake Variable Detection (Three Tiers)
|
||||||
|
|
||||||
|
| Tier | Trigger | Timing | Model Tier |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **Explicit** | `[VAR:name]` syntax in step content | Immediate on content save | None (regex match) |
|
||||||
|
| **Inference** | Natural language suggests variable ("check the customer's server") | Debounced on step save/blur | fast |
|
||||||
|
| **Cross-step** | Same implicit variable in 2+ steps | On panel open + when steps modified | fast |
|
||||||
|
|
||||||
|
**Behavior:**
|
||||||
|
- Explicit: immediate inline suggestion card in panel ("Add `server_name` to intake form?")
|
||||||
|
- Inference: non-blocking suggestion in panel, lower confidence indicator
|
||||||
|
- Cross-step: promoted suggestion with gap flag ("Variable `server_name` used in steps 3, 7, 12 but not captured in intake form")
|
||||||
|
- Results cached per-session until step content changes
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What Gets Removed
|
||||||
|
|
||||||
|
| Item | Location |
|
||||||
|
|---|---|
|
||||||
|
| `AIChatBuilderPage.tsx` | `frontend/src/pages/` |
|
||||||
|
| `aiChatStore.ts` | `frontend/src/store/` |
|
||||||
|
| `ai-chat/` component directory | `frontend/src/components/` |
|
||||||
|
| `AIFlowBuilderModal` | `frontend/src/components/` |
|
||||||
|
| `/ai/chat` route | `frontend/src/router.tsx` |
|
||||||
|
| Flow type selection routing | URL params `?type=...` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What Gets Repurposed
|
||||||
|
|
||||||
|
| Item | Changes |
|
||||||
|
|---|---|
|
||||||
|
| `ai_chat_service.py` | Action-type dispatch, partial generation prompts, model routing, focal node context |
|
||||||
|
| `ai_tree_validator.py` | Validates AI-generated fragments (subtree, step batch) in addition to full trees |
|
||||||
|
| `ai_chat_session` model | Extended with `tree_id` FK, `archived_at` timestamp |
|
||||||
|
| AI chat endpoints | Tree-scoped sessions, `action_type` parameter, model tier routing |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What Gets Built (New)
|
||||||
|
|
||||||
|
| Item | Description |
|
||||||
|
|---|---|
|
||||||
|
| `EditorAIPanel` component | Shared panel with Chat + Suggestions tabs, node summary, input |
|
||||||
|
| `ContextMenu` component | Shared right-click menu for nodes and steps |
|
||||||
|
| `useEditorAI` hook | Panel state, focal node, suggestion management, ghost node lifecycle |
|
||||||
|
| Prompt dialog modal | Simple "describe your flow" modal for AI-assisted create |
|
||||||
|
| `ai_suggestion` DB model | Audit trail table + Alembic migration |
|
||||||
|
| Ghost node CSS | Dashed borders, reduced opacity, accept/dismiss overlays |
|
||||||
|
| Model tier config | `AI_MODEL_TIERS` + `ACTION_MODEL_MAP` in `config.py` |
|
||||||
|
| APScheduler archive task | Daily job to archive stale sessions |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What Gets Modified
|
||||||
|
|
||||||
|
| Item | Changes |
|
||||||
|
|---|---|
|
||||||
|
| `TreeEditorPage` | Right panel slot for AI, context menu handler, ghost node support |
|
||||||
|
| `TreeCanvas` / `TreeCanvasNode` | Ghost node rendering (dashed borders, overlays) |
|
||||||
|
| `ProceduralEditorPage` | Flex layout for AI panel, context menu on steps |
|
||||||
|
| `StepList` / `StepEditor` | Ghost step rendering |
|
||||||
|
| `treeEditorStore` | Ghost node state slice, zundo pause/resume, orphan bug fix |
|
||||||
|
| `proceduralEditorStore` | Ghost step state slice |
|
||||||
|
| `ai_chat_service.py` | Action-type dispatch, delta response format, model routing |
|
||||||
|
| `ai_chat_session` model | `tree_id` FK, `archived_at` |
|
||||||
|
| `config.py` | Model tier configuration |
|
||||||
|
| `CreateFlowDropdown` | AI-assisted option + prompt dialog trigger |
|
||||||
|
| `router.tsx` | Remove `/ai/chat` route |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Bug Fix (Included)
|
||||||
|
|
||||||
|
**File:** `frontend/src/store/treeEditorStore.ts` line 858
|
||||||
|
|
||||||
|
**Current code:**
|
||||||
|
```typescript
|
||||||
|
if (id !== 'root' && !referencedIds.has(id)) {
|
||||||
|
```
|
||||||
|
|
||||||
|
**Fixed code:**
|
||||||
|
```typescript
|
||||||
|
if (id !== state.treeStructure?.id && !referencedIds.has(id)) {
|
||||||
|
```
|
||||||
|
|
||||||
|
**Root cause:** Orphan check hardcodes `'root'` as the expected root node ID. AI-generated trees use descriptive IDs (e.g., `"verify-account-exists"`). Since the root is never referenced by any other node's `next_node_id`, it gets flagged as orphaned. This is a false positive.
|
||||||
2802
docs/plans/2026-03-06-editor-embedded-flow-assist-plan.md
Normal file
2802
docs/plans/2026-03-06-editor-embedded-flow-assist-plan.md
Normal file
File diff suppressed because it is too large
Load Diff
835
docs/plans/2026-03-06-procedural-flow-assist.md
Normal file
835
docs/plans/2026-03-06-procedural-flow-assist.md
Normal file
@@ -0,0 +1,835 @@
|
|||||||
|
# Procedural Flow Assist — AI Chat Builder Support
|
||||||
|
|
||||||
|
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||||
|
|
||||||
|
**Goal:** Make the Flow Assist AI chat builder correctly generate procedural/maintenance flows using the flat steps-array schema instead of the troubleshooting decision-tree schema.
|
||||||
|
|
||||||
|
**Architecture:** The AI chat service (`ai_chat_service.py`) currently has hardcoded troubleshooting-specific prompts (schema, interview protocol, response format). We add parallel procedural versions and dispatch based on `flow_type`. The AI validator gets a procedural counterpart. The frontend gets a procedural steps preview component and the store/page handle the different data shape.
|
||||||
|
|
||||||
|
**Tech Stack:** Python/FastAPI (backend), React/TypeScript (frontend), Zustand (state), Tailwind CSS (styling)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Context: Procedural vs Troubleshooting Structure
|
||||||
|
|
||||||
|
**Troubleshooting** flows use a recursive tree: `{ id, type: "decision", question, options, children: [...] }` — branching paths ending in solution nodes.
|
||||||
|
|
||||||
|
**Procedural** flows use a flat ordered array: `{ steps: [{ id, type, title, ... }, ...] }` — sequential steps with a `procedure_end` as the final step.
|
||||||
|
|
||||||
|
### Procedural Step Schema
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "unique-slug",
|
||||||
|
"type": "procedure_step | procedure_end | section_header",
|
||||||
|
"title": "Step title",
|
||||||
|
"description": "Detailed instructions (supports [VAR:variable_name] interpolation)",
|
||||||
|
"content_type": "action | informational | verification | warning",
|
||||||
|
"estimated_minutes": 5,
|
||||||
|
"commands": [{ "code": "Get-Service ...", "label": "Check service", "language": "powershell" }],
|
||||||
|
"expected_outcome": "What success looks like",
|
||||||
|
"verification_prompt": "Confirm the service is running",
|
||||||
|
"verification_type": "checkbox | text_input",
|
||||||
|
"warning_text": "Caution text for warning content_type",
|
||||||
|
"notes_enabled": true,
|
||||||
|
"reference_url": "https://docs.microsoft.com/...",
|
||||||
|
"section_header": "Optional section label"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Structural Rules
|
||||||
|
- `steps` array must be non-empty
|
||||||
|
- Each step needs `id`, `type`, `title`
|
||||||
|
- Valid types: `procedure_step`, `procedure_end`, `section_header`
|
||||||
|
- Exactly ONE `procedure_end` as the LAST step
|
||||||
|
- No duplicate step IDs
|
||||||
|
- `content_type` if present must be: `action`, `informational`, `verification`, `warning`
|
||||||
|
- Commands can be a string or array of `{ code, label?, language? }`
|
||||||
|
|
||||||
|
### Intake Form (Optional)
|
||||||
|
Procedural flows can have an intake form that captures variables before execution. Fields use `variable_name` (e.g., `server_name`) referenced in step descriptions as `[VAR:server_name]`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: Add Procedural System Prompts to AI Chat Service
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `backend/app/core/ai_chat_service.py`
|
||||||
|
|
||||||
|
**Step 1: Add procedural schema context constant**
|
||||||
|
|
||||||
|
After the existing `SCHEMA_CONTEXT` constant (~line 78), add:
|
||||||
|
|
||||||
|
```python
|
||||||
|
PROCEDURAL_SCHEMA_CONTEXT = """
|
||||||
|
PROCEDURAL STEP SCHEMA — This is what you are building:
|
||||||
|
|
||||||
|
Procedural flows are a FLAT ORDERED ARRAY of steps (NOT a branching tree). The structure is:
|
||||||
|
{"steps": [step1, step2, ..., end_step]}
|
||||||
|
|
||||||
|
Each step has a "type" field:
|
||||||
|
|
||||||
|
1. procedure_step — A task the engineer performs
|
||||||
|
Required: id (string), type ("procedure_step"), title (string)
|
||||||
|
Optional: description (string — detailed instructions, supports [VAR:variable_name] interpolation),
|
||||||
|
content_type ("action" | "informational" | "verification" | "warning"),
|
||||||
|
estimated_minutes (integer),
|
||||||
|
commands (array of {code: string, label?: string, language?: string}),
|
||||||
|
expected_outcome (string),
|
||||||
|
verification_prompt (string — question to confirm step completion),
|
||||||
|
verification_type ("checkbox" | "text_input"),
|
||||||
|
warning_text (string — caution text, used with content_type "warning"),
|
||||||
|
notes_enabled (boolean, default true),
|
||||||
|
reference_url (string — documentation link)
|
||||||
|
|
||||||
|
2. section_header — A visual divider to organize steps into phases
|
||||||
|
Required: id (string), type ("section_header"), title (string)
|
||||||
|
Optional: description (string)
|
||||||
|
|
||||||
|
3. procedure_end — The final completion marker (exactly ONE, always LAST)
|
||||||
|
Required: id (string), type ("procedure_end"), title (string)
|
||||||
|
Optional: description (string — completion summary text)
|
||||||
|
|
||||||
|
CONTENT TYPES for procedure_step:
|
||||||
|
- "action" (default): Executable task with commands — shows terminal icon
|
||||||
|
- "informational": Read-only context or reference info — shows info icon
|
||||||
|
- "verification": Requires engineer confirmation before proceeding — shows checkmark icon
|
||||||
|
- "warning": Highlighted caution/danger step — shows alert icon
|
||||||
|
|
||||||
|
STRUCTURAL RULES:
|
||||||
|
- Steps are executed in array order — position determines sequence
|
||||||
|
- All IDs must be unique strings (use descriptive slugs like "install-ad-ds-role")
|
||||||
|
- The LAST step MUST be type "procedure_end"
|
||||||
|
- Section headers group related steps visually but don't affect execution order
|
||||||
|
- Use [VAR:variable_name] in descriptions to reference intake form variables (e.g., "Configure IP on [VAR:server_name]")
|
||||||
|
|
||||||
|
COMMAND FORMAT:
|
||||||
|
Commands are arrays of objects, each with:
|
||||||
|
- code (required): The exact command syntax (PowerShell, CMD, bash, etc.)
|
||||||
|
- label (optional): Short description of what the command does
|
||||||
|
- language (optional): "powershell", "cmd", "bash", etc.
|
||||||
|
"""
|
||||||
|
|
||||||
|
PROCEDURAL_INTERVIEW_PROTOCOL = """
|
||||||
|
INTERVIEW PHASES — Follow this progression:
|
||||||
|
|
||||||
|
PHASE 1 - SCOPING (current_phase: scoping):
|
||||||
|
Understand what procedure this flow covers:
|
||||||
|
- What process is this flow for? (e.g., "new domain controller build", "Exchange migration", "firewall replacement")
|
||||||
|
- What is the target environment? (on-prem, hybrid, cloud, specific vendors?)
|
||||||
|
- Who will execute this? (Tier level, experience assumptions)
|
||||||
|
- What information will the engineer need before starting? (This becomes the intake form — server name, IP, domain, credentials, etc.)
|
||||||
|
Demonstrate expertise: "For a DC build, we'd typically need server name, IP, subnet, gateway, domain name, DSRM password, and whether this is the first DC or joining an existing domain."
|
||||||
|
DO NOT emit [STEPS_UPDATE] during scoping.
|
||||||
|
|
||||||
|
PHASE 2 - DISCOVERY (current_phase: discovery):
|
||||||
|
Build out the procedure step by step:
|
||||||
|
- Establish the major phases (these become section_headers)
|
||||||
|
- For each phase, work through the steps in execution order
|
||||||
|
- Capture specific commands with exact syntax
|
||||||
|
- Add verification steps where the engineer should confirm something before proceeding
|
||||||
|
- Add warning steps for anything destructive or irreversible
|
||||||
|
EMIT [STEPS_UPDATE] when you and the user have agreed on concrete steps. Include ALL steps discussed so far.
|
||||||
|
|
||||||
|
PHASE 3 - ENRICHMENT (current_phase: enrichment):
|
||||||
|
Circle back to improve existing steps:
|
||||||
|
- Add exact PowerShell/CLI commands with syntax
|
||||||
|
- Add expected_outcome for action steps
|
||||||
|
- Add verification prompts for critical checkpoints
|
||||||
|
- Add estimated_minutes for time-sensitive procedures
|
||||||
|
- Add reference_url links to relevant documentation
|
||||||
|
- Add warning_text for dangerous operations
|
||||||
|
- Suggest intake form variables for values that change per execution
|
||||||
|
EMIT [STEPS_UPDATE] when enriching steps.
|
||||||
|
|
||||||
|
PHASE 4 - REVIEW (current_phase: review):
|
||||||
|
Present a summary:
|
||||||
|
- Total step count by content_type
|
||||||
|
- Section-by-section outline
|
||||||
|
- Estimated total time
|
||||||
|
- List of intake form variables suggested
|
||||||
|
- Flag any gaps or areas needing more detail
|
||||||
|
EMIT [STEPS_UPDATE] only if the user requests changes.
|
||||||
|
|
||||||
|
TRANSITION between phases by emitting [PHASE:phase_name] when the conversation naturally moves to the next stage.
|
||||||
|
"""
|
||||||
|
|
||||||
|
PROCEDURAL_RESPONSE_FORMAT = """
|
||||||
|
RESPONSE FORMAT:
|
||||||
|
|
||||||
|
Your response is natural conversational text. When the step structure changes, include structured markers that will be parsed by the system (the user will NOT see these markers):
|
||||||
|
|
||||||
|
1. Steps update (only when structure changes — see phase rules above):
|
||||||
|
[STEPS_UPDATE]
|
||||||
|
{"steps": [...valid steps array...]}
|
||||||
|
[/STEPS_UPDATE]
|
||||||
|
|
||||||
|
2. Phase transition (when moving to next phase):
|
||||||
|
[PHASE:discovery]
|
||||||
|
|
||||||
|
3. Metadata capture (when you learn the flow's name, description, or tags):
|
||||||
|
[METADATA]
|
||||||
|
{"name": "...", "description": "...", "tags": ["..."]}
|
||||||
|
[/METADATA]
|
||||||
|
|
||||||
|
4. Intake form suggestion (when you identify variables the engineer will need):
|
||||||
|
[INTAKE_FORM]
|
||||||
|
[{"variable_name": "server_name", "label": "Server Name", "field_type": "text", "required": true, "placeholder": "e.g., DC01", "group_name": "Server Details", "display_order": 1}]
|
||||||
|
[/INTAKE_FORM]
|
||||||
|
|
||||||
|
IMPORTANT:
|
||||||
|
- Include [STEPS_UPDATE] sparingly. Only when concrete steps are established or modified.
|
||||||
|
- The steps update should be the COMPLETE working steps array, not a diff.
|
||||||
|
- Always include conversational text OUTSIDE the markers — never respond with only markers.
|
||||||
|
- The last step in the array MUST always be type "procedure_end".
|
||||||
|
"""
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Update `_build_system_prompt` to dispatch by flow_type**
|
||||||
|
|
||||||
|
Replace the existing `_build_system_prompt` function:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def _build_system_prompt(flow_type: str) -> str:
|
||||||
|
"""Assemble the full system prompt for the chat builder."""
|
||||||
|
if flow_type in ("procedural", "maintenance"):
|
||||||
|
flow_context = (
|
||||||
|
f"The user wants to build a {'MAINTENANCE' if flow_type == 'maintenance' else 'PROCEDURAL'} flow — "
|
||||||
|
"a step-by-step process guide that walks engineers through a procedure in sequence. "
|
||||||
|
"Steps are executed in order, not branching paths."
|
||||||
|
)
|
||||||
|
return f"{ROLE_PERSONA}\n\n{flow_context}\n\n{PROCEDURAL_SCHEMA_CONTEXT}\n\n{PROCEDURAL_INTERVIEW_PROTOCOL}\n\n{PROCEDURAL_RESPONSE_FORMAT}"
|
||||||
|
else:
|
||||||
|
flow_context = (
|
||||||
|
"The user wants to build a TROUBLESHOOTING flow — a diagnostic decision tree "
|
||||||
|
"that guides engineers through symptom identification, diagnostic checks, and "
|
||||||
|
"resolution steps."
|
||||||
|
)
|
||||||
|
return f"{ROLE_PERSONA}\n\n{flow_context}\n\n{SCHEMA_CONTEXT}\n\n{INTERVIEW_PROTOCOL}\n\n{RESPONSE_FORMAT}"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 3: Update `_parse_ai_response` to handle `[STEPS_UPDATE]` and `[INTAKE_FORM]`**
|
||||||
|
|
||||||
|
Add extraction for the new markers. After the `[METADATA]` extraction block:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Extract [STEPS_UPDATE]...[/STEPS_UPDATE]
|
||||||
|
steps_match = re.search(
|
||||||
|
r"\[STEPS_UPDATE\]\s*([\s\S]*?)\s*\[/STEPS_UPDATE\]", result["content"]
|
||||||
|
)
|
||||||
|
if steps_match:
|
||||||
|
try:
|
||||||
|
raw_json = _strip_markdown_fences(steps_match.group(1))
|
||||||
|
result["tree_update"] = json.loads(raw_json)
|
||||||
|
except (json.JSONDecodeError, ValueError) as e:
|
||||||
|
logger.warning("Failed to parse steps update JSON: %s", e)
|
||||||
|
result["content"] = result["content"][: steps_match.start()] + result["content"][steps_match.end() :]
|
||||||
|
else:
|
||||||
|
truncated_steps = re.search(r"\[STEPS_UPDATE\][\s\S]*$", result["content"])
|
||||||
|
if truncated_steps:
|
||||||
|
logger.warning("Truncated [STEPS_UPDATE] block detected — stripping from display")
|
||||||
|
result["content"] = result["content"][: truncated_steps.start()]
|
||||||
|
|
||||||
|
# Extract [INTAKE_FORM]...[/INTAKE_FORM]
|
||||||
|
intake_match = re.search(
|
||||||
|
r"\[INTAKE_FORM\]\s*([\s\S]*?)\s*\[/INTAKE_FORM\]", result["content"]
|
||||||
|
)
|
||||||
|
if intake_match:
|
||||||
|
try:
|
||||||
|
raw_json = _strip_markdown_fences(intake_match.group(1))
|
||||||
|
result["intake_form"] = json.loads(raw_json)
|
||||||
|
except (json.JSONDecodeError, ValueError) as e:
|
||||||
|
logger.warning("Failed to parse intake form JSON: %s", e)
|
||||||
|
result["content"] = result["content"][: intake_match.start()] + result["content"][intake_match.end() :]
|
||||||
|
else:
|
||||||
|
truncated_intake = re.search(r"\[INTAKE_FORM\][\s\S]*$", result["content"])
|
||||||
|
if truncated_intake:
|
||||||
|
logger.warning("Truncated [INTAKE_FORM] block detected — stripping from display")
|
||||||
|
result["content"] = result["content"][: truncated_intake.start()]
|
||||||
|
```
|
||||||
|
|
||||||
|
Also add `"intake_form": None` to the initial `result` dict.
|
||||||
|
|
||||||
|
**Step 4: Update `send_message` to validate procedural structure**
|
||||||
|
|
||||||
|
In `send_message()`, replace the tree_update validation block (~line 320-326):
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Validate tree update if present
|
||||||
|
tree_update = parsed["tree_update"]
|
||||||
|
if tree_update:
|
||||||
|
if session.flow_type in ("procedural", "maintenance"):
|
||||||
|
# Procedural: must have a steps array
|
||||||
|
if not isinstance(tree_update, dict) or not isinstance(tree_update.get("steps"), list):
|
||||||
|
logger.warning("AI steps update rejected: must have a steps array")
|
||||||
|
tree_update = None
|
||||||
|
else:
|
||||||
|
# Troubleshooting: root must be a decision node
|
||||||
|
if not isinstance(tree_update, dict) or tree_update.get("type") != "decision":
|
||||||
|
logger.warning("AI tree update rejected: root must be a decision node")
|
||||||
|
tree_update = None
|
||||||
|
elif not tree_update.get("id"):
|
||||||
|
logger.warning("AI tree update rejected: root node missing id")
|
||||||
|
tree_update = None
|
||||||
|
```
|
||||||
|
|
||||||
|
Also handle intake_form persistence after the metadata block:
|
||||||
|
|
||||||
|
```python
|
||||||
|
if parsed.get("intake_form"):
|
||||||
|
session.intake_form_draft = parsed["intake_form"]
|
||||||
|
```
|
||||||
|
|
||||||
|
Wait — `AIChatSession` may not have an `intake_form_draft` field. We'll store it in `tree_metadata` instead:
|
||||||
|
|
||||||
|
```python
|
||||||
|
if parsed.get("intake_form"):
|
||||||
|
merged = dict(session.tree_metadata) if session.tree_metadata else {}
|
||||||
|
merged["intake_form"] = parsed["intake_form"]
|
||||||
|
session.tree_metadata = merged
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 5: Update `generate_final_tree` for procedural flows**
|
||||||
|
|
||||||
|
Replace the `generation_instruction` string with flow-type-aware instructions:
|
||||||
|
|
||||||
|
```python
|
||||||
|
if session.flow_type in ("procedural", "maintenance"):
|
||||||
|
generation_instruction = """Based on our entire conversation, generate the COMPLETE and FINAL procedural steps JSON for this flow.
|
||||||
|
|
||||||
|
Requirements:
|
||||||
|
- Include ALL steps we discussed, organized into sections
|
||||||
|
- Use descriptive step IDs (slugs, not UUIDs)
|
||||||
|
- Each step needs: id, type, title, description
|
||||||
|
- Include commands with exact syntax where discussed
|
||||||
|
- Include content_type for each step (action, informational, verification, warning)
|
||||||
|
- Include estimated_minutes where discussed
|
||||||
|
- Include verification_prompt for verification steps
|
||||||
|
- Include warning_text for warning steps
|
||||||
|
- The LAST step MUST be type "procedure_end"
|
||||||
|
- Respond with ONLY the JSON — no conversational text, no markdown fences
|
||||||
|
|
||||||
|
Format: {"steps": [step1, step2, ..., end_step]}
|
||||||
|
|
||||||
|
Also provide metadata as a separate JSON object after the steps:
|
||||||
|
[METADATA]
|
||||||
|
{"name": "...", "description": "...", "tags": ["..."]}
|
||||||
|
[/METADATA]
|
||||||
|
|
||||||
|
If we discussed intake form variables, include them:
|
||||||
|
[INTAKE_FORM]
|
||||||
|
[{"variable_name": "...", "label": "...", "field_type": "text", "required": true, "display_order": 1}]
|
||||||
|
[/INTAKE_FORM]"""
|
||||||
|
else:
|
||||||
|
generation_instruction = """Based on our entire conversation, generate the COMPLETE and FINAL TreeStructure JSON for this flow.
|
||||||
|
...existing troubleshooting instruction..."""
|
||||||
|
```
|
||||||
|
|
||||||
|
Update the validation inside the generation loop to handle procedural:
|
||||||
|
|
||||||
|
```python
|
||||||
|
if not tree:
|
||||||
|
# ... existing retry logic ...
|
||||||
|
|
||||||
|
if session.flow_type in ("procedural", "maintenance"):
|
||||||
|
# Validate procedural structure
|
||||||
|
p_errors = validate_generated_procedural_steps(tree)
|
||||||
|
if p_errors:
|
||||||
|
if attempt == 0:
|
||||||
|
# ... retry with correction ...
|
||||||
|
continue
|
||||||
|
raise ValueError(f"Generated steps failed validation: {'; '.join(p_errors)}")
|
||||||
|
else:
|
||||||
|
errors = validate_generated_tree(tree)
|
||||||
|
if errors:
|
||||||
|
# ... existing retry logic ...
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 6: Run backend tests**
|
||||||
|
|
||||||
|
Run: `cd /home/michaelchihlas/dev/patherly/backend && python -m pytest tests/test_ai_chat.py -v --override-ini="addopts="`
|
||||||
|
Expected: All existing tests pass (they test troubleshooting flow).
|
||||||
|
|
||||||
|
**Step 7: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add backend/app/core/ai_chat_service.py
|
||||||
|
git commit -m "feat: add procedural flow prompts to AI chat builder"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: Add Procedural Validation to AI Tree Validator
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `backend/app/core/ai_tree_validator.py`
|
||||||
|
|
||||||
|
**Step 1: Add `validate_generated_procedural_steps` function**
|
||||||
|
|
||||||
|
Add after the existing `count_tree_stats` function:
|
||||||
|
|
||||||
|
```python
|
||||||
|
VALID_PROCEDURAL_STEP_TYPES = {"procedure_step", "procedure_end", "section_header"}
|
||||||
|
VALID_CONTENT_TYPES = {"action", "informational", "verification", "warning"}
|
||||||
|
|
||||||
|
|
||||||
|
def validate_generated_procedural_steps(tree: dict[str, Any]) -> list[str]:
|
||||||
|
"""Validate an AI-generated procedural steps structure.
|
||||||
|
|
||||||
|
Returns a list of error strings. Empty list means valid.
|
||||||
|
"""
|
||||||
|
errors: list[str] = []
|
||||||
|
|
||||||
|
if not isinstance(tree, dict):
|
||||||
|
return ["Steps structure must be a JSON object"]
|
||||||
|
|
||||||
|
steps = tree.get("steps")
|
||||||
|
if not isinstance(steps, list) or len(steps) == 0:
|
||||||
|
return ["Must have a non-empty 'steps' array"]
|
||||||
|
|
||||||
|
seen_ids: set[str] = set()
|
||||||
|
end_count = 0
|
||||||
|
step_count = 0
|
||||||
|
|
||||||
|
for i, step in enumerate(steps):
|
||||||
|
if not isinstance(step, dict):
|
||||||
|
errors.append(f"Step at index {i} is not an object")
|
||||||
|
continue
|
||||||
|
|
||||||
|
step_id = step.get("id")
|
||||||
|
step_type = step.get("type")
|
||||||
|
|
||||||
|
# Check ID
|
||||||
|
if not step_id:
|
||||||
|
errors.append(f"Step at index {i} missing 'id'")
|
||||||
|
elif step_id in seen_ids:
|
||||||
|
errors.append(f"Duplicate step ID: '{step_id}'")
|
||||||
|
else:
|
||||||
|
seen_ids.add(step_id)
|
||||||
|
|
||||||
|
# Check type
|
||||||
|
if step_type not in VALID_PROCEDURAL_STEP_TYPES:
|
||||||
|
errors.append(
|
||||||
|
f"Step '{step_id or i}' has invalid type '{step_type}'. "
|
||||||
|
f"Must be one of: {', '.join(sorted(VALID_PROCEDURAL_STEP_TYPES))}"
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Check title
|
||||||
|
if not step.get("title"):
|
||||||
|
errors.append(f"Step '{step_id}' missing 'title'")
|
||||||
|
|
||||||
|
# Content type validation
|
||||||
|
content_type = step.get("content_type")
|
||||||
|
if content_type and content_type not in VALID_CONTENT_TYPES:
|
||||||
|
errors.append(
|
||||||
|
f"Step '{step_id}' has invalid content_type '{content_type}'. "
|
||||||
|
f"Must be one of: {', '.join(sorted(VALID_CONTENT_TYPES))}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if step_type == "procedure_step":
|
||||||
|
step_count += 1
|
||||||
|
elif step_type == "procedure_end":
|
||||||
|
end_count += 1
|
||||||
|
|
||||||
|
# Structural checks
|
||||||
|
if end_count == 0:
|
||||||
|
errors.append("Must have exactly one 'procedure_end' step as the last step")
|
||||||
|
elif end_count > 1:
|
||||||
|
errors.append(f"Found {end_count} procedure_end steps, must have exactly 1")
|
||||||
|
|
||||||
|
if end_count == 1 and steps[-1].get("type") != "procedure_end":
|
||||||
|
errors.append("The procedure_end step must be the last step in the array")
|
||||||
|
|
||||||
|
if step_count < 2:
|
||||||
|
errors.append(
|
||||||
|
f"Flow has only {step_count} procedure steps. "
|
||||||
|
"Need at least 2 for a useful procedure."
|
||||||
|
)
|
||||||
|
|
||||||
|
if len(steps) > 100:
|
||||||
|
errors.append(f"Flow has {len(steps)} steps. Maximum 100 allowed.")
|
||||||
|
|
||||||
|
return errors
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Run tests**
|
||||||
|
|
||||||
|
Run: `cd /home/michaelchihlas/dev/patherly/backend && python -m pytest tests/ -k "procedural" -v --override-ini="addopts="`
|
||||||
|
Expected: Existing procedural tests pass.
|
||||||
|
|
||||||
|
**Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add backend/app/core/ai_tree_validator.py
|
||||||
|
git commit -m "feat: add procedural steps validator for AI-generated flows"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: Handle Intake Form + Procedural Import in AI Chat Endpoint
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `backend/app/api/endpoints/ai_chat.py`
|
||||||
|
|
||||||
|
**Step 1: Update the `import_tree` endpoint to handle intake form from metadata**
|
||||||
|
|
||||||
|
In the `import_tree` function (~line 393), after building the Tree object, check for intake form:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Extract intake form from metadata if present
|
||||||
|
intake_form = None
|
||||||
|
if metadata.get("intake_form"):
|
||||||
|
intake_form = metadata.pop("intake_form")
|
||||||
|
|
||||||
|
tree = Tree(
|
||||||
|
name=data.name or metadata.get("name", "AI-Generated Flow"),
|
||||||
|
description=data.description or metadata.get("description", ""),
|
||||||
|
tree_type=session.flow_type,
|
||||||
|
tree_structure=session.working_tree,
|
||||||
|
intake_form=intake_form,
|
||||||
|
author_id=current_user.id,
|
||||||
|
account_id=current_user.account_id,
|
||||||
|
category_id=data.category_id,
|
||||||
|
is_public=False,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Run tests**
|
||||||
|
|
||||||
|
Run: `cd /home/michaelchihlas/dev/patherly/backend && python -m pytest tests/test_ai_chat.py -v --override-ini="addopts="`
|
||||||
|
Expected: All tests pass.
|
||||||
|
|
||||||
|
**Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add backend/app/api/endpoints/ai_chat.py
|
||||||
|
git commit -m "feat: handle intake form in AI chat procedural import"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: Add Procedural Steps Preview Component (Frontend)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `frontend/src/components/ai-chat/StaticStepsPreview.tsx`
|
||||||
|
|
||||||
|
**Step 1: Create the procedural steps preview component**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import type { ProceduralStep } from '@/types'
|
||||||
|
import { Terminal, Info, CheckSquare, AlertTriangle, LayoutList } from 'lucide-react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
interface StaticStepsPreviewProps {
|
||||||
|
steps: ProceduralStep[]
|
||||||
|
name?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const CONTENT_TYPE_ICONS: Record<string, typeof Terminal> = {
|
||||||
|
action: Terminal,
|
||||||
|
informational: Info,
|
||||||
|
verification: CheckSquare,
|
||||||
|
warning: AlertTriangle,
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StaticStepsPreview({ steps, name }: StaticStepsPreviewProps) {
|
||||||
|
let stepNumber = 0
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col">
|
||||||
|
<div className="border-b border-border px-4 py-2">
|
||||||
|
<h3 className="text-sm font-semibold text-foreground">
|
||||||
|
Preview: {name || 'Untitled Flow'}
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{steps.filter((s) => s.type === 'procedure_step').length} steps
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 overflow-auto p-4">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{steps.map((step) => {
|
||||||
|
if (step.type === 'section_header') {
|
||||||
|
return (
|
||||||
|
<div key={step.id} className="pt-3 pb-1 first:pt-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<LayoutList className="h-3.5 w-3.5 text-primary" />
|
||||||
|
<span className="font-label text-[0.625rem] uppercase tracking-[0.1em] text-primary">
|
||||||
|
{step.title}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (step.type === 'procedure_end') {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={step.id}
|
||||||
|
className="mt-2 rounded-lg border border-emerald-500/20 bg-emerald-500/5 px-3 py-2"
|
||||||
|
>
|
||||||
|
<span className="text-xs font-medium text-emerald-400">
|
||||||
|
{step.title || 'Procedure Complete'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
stepNumber++
|
||||||
|
const contentType = step.content_type || 'action'
|
||||||
|
const Icon = CONTENT_TYPE_ICONS[contentType] || Terminal
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={step.id}
|
||||||
|
className={cn(
|
||||||
|
'rounded-lg border px-3 py-2 text-xs',
|
||||||
|
contentType === 'warning'
|
||||||
|
? 'border-amber-500/20 bg-amber-500/5'
|
||||||
|
: 'border-border bg-card'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<span className="mt-0.5 flex h-4 w-4 shrink-0 items-center justify-center rounded bg-primary/10 font-label text-[0.5rem] text-primary">
|
||||||
|
{stepNumber}
|
||||||
|
</span>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<Icon className={cn(
|
||||||
|
'h-3 w-3 shrink-0',
|
||||||
|
contentType === 'warning' ? 'text-amber-400' : 'text-muted-foreground'
|
||||||
|
)} />
|
||||||
|
<span className={cn(
|
||||||
|
'font-medium truncate',
|
||||||
|
contentType === 'warning' ? 'text-amber-400' : 'text-foreground'
|
||||||
|
)}>
|
||||||
|
{step.title}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{step.commands && (
|
||||||
|
<div className="mt-1 flex items-center gap-1 text-muted-foreground">
|
||||||
|
<Terminal className="h-2.5 w-2.5" />
|
||||||
|
<span className="font-label text-[0.5rem]">
|
||||||
|
{Array.isArray(step.commands) ? step.commands.length : 1} command{(Array.isArray(step.commands) ? step.commands.length : 1) !== 1 ? 's' : ''}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{step.estimated_minutes && (
|
||||||
|
<span className="shrink-0 font-label text-[0.5rem] text-muted-foreground">
|
||||||
|
~{step.estimated_minutes}m
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Run build**
|
||||||
|
|
||||||
|
Run: `cd /home/michaelchihlas/dev/patherly/frontend && npm run build`
|
||||||
|
Expected: Build passes.
|
||||||
|
|
||||||
|
**Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add frontend/src/components/ai-chat/StaticStepsPreview.tsx
|
||||||
|
git commit -m "feat: add procedural steps preview component for AI chat builder"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 5: Update AI Chat Store + Page for Procedural Flows
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `frontend/src/store/aiChatStore.ts`
|
||||||
|
- Modify: `frontend/src/pages/AIChatBuilderPage.tsx`
|
||||||
|
|
||||||
|
**Step 1: Update `AIChatState` interface and `sendMessage` handler in store**
|
||||||
|
|
||||||
|
In `aiChatStore.ts`, update the `workingTree` type to also accept procedural structure:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Change line 29:
|
||||||
|
workingTree: TreeStructure | { steps: ProceduralStep[] } | null
|
||||||
|
// Change line 33:
|
||||||
|
generatedTree: TreeStructure | { steps: ProceduralStep[] } | null
|
||||||
|
```
|
||||||
|
|
||||||
|
Add `ProceduralStep` to the imports:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import type {
|
||||||
|
ChatMessage,
|
||||||
|
InterviewPhase,
|
||||||
|
TreeStructure,
|
||||||
|
ProceduralStep,
|
||||||
|
} from '@/types'
|
||||||
|
```
|
||||||
|
|
||||||
|
Update `sendMessage` (~line 121-127) — the response handling already works because `working_tree` is stored as-is from the API. The cast just needs updating:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
workingTree: (response.working_tree as TreeStructure | { steps: ProceduralStep[] } | null) ?? state.workingTree,
|
||||||
|
```
|
||||||
|
|
||||||
|
And in `generateTree` (~line 142-143):
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
generatedTree: response.tree_structure as unknown as TreeStructure | { steps: ProceduralStep[] },
|
||||||
|
workingTree: response.tree_structure as unknown as TreeStructure | { steps: ProceduralStep[] },
|
||||||
|
```
|
||||||
|
|
||||||
|
And in `resumeSession` (~line 185-187):
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
workingTree: session.working_tree as TreeStructure | { steps: ProceduralStep[] } | null,
|
||||||
|
generatedTree: session.generated_tree as TreeStructure | { steps: ProceduralStep[] } | null,
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Update `AIChatBuilderPage.tsx` to render correct preview**
|
||||||
|
|
||||||
|
Add import for `StaticStepsPreview` and `ProceduralStep`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { StaticStepsPreview } from '@/components/ai-chat/StaticStepsPreview'
|
||||||
|
import type { ProceduralStep } from '@/types'
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace the preview tree logic (~line 116) and preview render (~line 143-151):
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const previewData = generatedTree || workingTree
|
||||||
|
|
||||||
|
// Determine if this is a procedural preview
|
||||||
|
const isProceduralPreview = previewData && 'steps' in previewData
|
||||||
|
|
||||||
|
// ... in the JSX:
|
||||||
|
{previewData ? (
|
||||||
|
isProceduralPreview ? (
|
||||||
|
<StaticStepsPreview
|
||||||
|
steps={(previewData as { steps: ProceduralStep[] }).steps}
|
||||||
|
name={treeMetadata?.name}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<StaticTreePreview
|
||||||
|
tree={previewData as TreeStructure}
|
||||||
|
name={treeMetadata?.name}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<EmptyPreview />
|
||||||
|
)}
|
||||||
|
```
|
||||||
|
|
||||||
|
Remove the now-unused `const previewTree = (generatedTree || workingTree) as TreeStructure | null` line.
|
||||||
|
|
||||||
|
**Step 3: Run build**
|
||||||
|
|
||||||
|
Run: `cd /home/michaelchihlas/dev/patherly/frontend && npm run build`
|
||||||
|
Expected: Build passes.
|
||||||
|
|
||||||
|
**Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add frontend/src/store/aiChatStore.ts frontend/src/pages/AIChatBuilderPage.tsx
|
||||||
|
git commit -m "feat: wire procedural steps preview into AI chat builder page"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 6: Update `generate_final_tree` Generation + Validation Wiring
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `backend/app/core/ai_chat_service.py`
|
||||||
|
|
||||||
|
This task ensures the full `generate_final_tree` function properly handles the procedural path end-to-end, including the retry loop and validation import.
|
||||||
|
|
||||||
|
**Step 1: Add import for the new validator**
|
||||||
|
|
||||||
|
```python
|
||||||
|
from app.core.ai_tree_validator import validate_generated_tree, validate_generated_procedural_steps
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Update the validation block in `generate_final_tree`**
|
||||||
|
|
||||||
|
Inside the `for attempt in range(2)` loop, after tree is extracted, replace the validation block:
|
||||||
|
|
||||||
|
```python
|
||||||
|
if session.flow_type in ("procedural", "maintenance"):
|
||||||
|
val_errors = validate_generated_procedural_steps(tree)
|
||||||
|
else:
|
||||||
|
val_errors = validate_generated_tree(tree)
|
||||||
|
|
||||||
|
if val_errors:
|
||||||
|
if attempt == 0:
|
||||||
|
provider_messages.append({"role": "assistant", "content": response_text})
|
||||||
|
correction = (
|
||||||
|
f"The generated structure has validation errors: {'; '.join(val_errors)}. "
|
||||||
|
"Please fix these issues and respond with the corrected JSON only."
|
||||||
|
)
|
||||||
|
provider_messages.append({"role": "user", "content": correction})
|
||||||
|
continue
|
||||||
|
raise ValueError(f"Generated structure failed validation: {'; '.join(val_errors)}")
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 3: Handle intake form from final generation**
|
||||||
|
|
||||||
|
After the `# Success` comment, before returning:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Extract intake form from metadata if present
|
||||||
|
if parsed.get("intake_form") and isinstance(parsed["intake_form"], list):
|
||||||
|
metadata["intake_form"] = parsed["intake_form"]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 4: Run backend tests**
|
||||||
|
|
||||||
|
Run: `cd /home/michaelchihlas/dev/patherly/backend && python -m pytest tests/test_ai_chat.py -v --override-ini="addopts="`
|
||||||
|
Expected: All tests pass.
|
||||||
|
|
||||||
|
**Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add backend/app/core/ai_chat_service.py
|
||||||
|
git commit -m "feat: wire procedural validation into AI chat generate flow"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 7: Final Integration Test + Build Verification
|
||||||
|
|
||||||
|
**Step 1: Run full backend test suite**
|
||||||
|
|
||||||
|
Run: `cd /home/michaelchihlas/dev/patherly/backend && python -m pytest tests/ --override-ini="addopts=" -v`
|
||||||
|
Expected: All tests pass.
|
||||||
|
|
||||||
|
**Step 2: Run frontend build**
|
||||||
|
|
||||||
|
Run: `cd /home/michaelchihlas/dev/patherly/frontend && npm run build`
|
||||||
|
Expected: Build passes with zero errors.
|
||||||
|
|
||||||
|
**Step 3: Final commit (if any remaining changes)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add -A
|
||||||
|
git commit -m "chore: final cleanup for procedural Flow Assist support"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Summary of Changes
|
||||||
|
|
||||||
|
| File | Change |
|
||||||
|
|------|--------|
|
||||||
|
| `backend/app/core/ai_chat_service.py` | Add procedural schema/protocol/format prompts, dispatch by flow_type, parse `[STEPS_UPDATE]` + `[INTAKE_FORM]`, validate procedural structure, procedural generation instruction |
|
||||||
|
| `backend/app/core/ai_tree_validator.py` | Add `validate_generated_procedural_steps()` function |
|
||||||
|
| `backend/app/api/endpoints/ai_chat.py` | Handle intake form in import endpoint |
|
||||||
|
| `frontend/src/components/ai-chat/StaticStepsPreview.tsx` | New procedural steps preview component |
|
||||||
|
| `frontend/src/store/aiChatStore.ts` | Widen `workingTree`/`generatedTree` types for procedural |
|
||||||
|
| `frontend/src/pages/AIChatBuilderPage.tsx` | Render `StaticStepsPreview` for procedural flows |
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
import { apiClient } from './client'
|
|
||||||
import type {
|
|
||||||
AIChatStartResponse,
|
|
||||||
AIChatMessageResponse,
|
|
||||||
AIChatSessionResponse,
|
|
||||||
AIChatGenerateResponse,
|
|
||||||
AIChatImportResponse,
|
|
||||||
} from '@/types'
|
|
||||||
|
|
||||||
export const aiChatApi = {
|
|
||||||
startSession: async (flowType: 'troubleshooting' | 'procedural'): Promise<AIChatStartResponse> => {
|
|
||||||
const { data } = await apiClient.post('/ai/chat/sessions', { flow_type: flowType })
|
|
||||||
return data
|
|
||||||
},
|
|
||||||
|
|
||||||
sendMessage: async (sessionId: string, content: string): Promise<AIChatMessageResponse> => {
|
|
||||||
const { data } = await apiClient.post(`/ai/chat/sessions/${sessionId}/messages`, { content })
|
|
||||||
return data
|
|
||||||
},
|
|
||||||
|
|
||||||
getSession: async (sessionId: string): Promise<AIChatSessionResponse> => {
|
|
||||||
const { data } = await apiClient.get(`/ai/chat/sessions/${sessionId}`)
|
|
||||||
return data
|
|
||||||
},
|
|
||||||
|
|
||||||
generateTree: async (sessionId: string): Promise<AIChatGenerateResponse> => {
|
|
||||||
const { data } = await apiClient.post(`/ai/chat/sessions/${sessionId}/generate`)
|
|
||||||
return data
|
|
||||||
},
|
|
||||||
|
|
||||||
importTree: async (
|
|
||||||
sessionId: string,
|
|
||||||
params?: { name?: string; description?: string; category_id?: string; tags?: string[] }
|
|
||||||
): Promise<AIChatImportResponse> => {
|
|
||||||
const { data } = await apiClient.post(`/ai/chat/sessions/${sessionId}/import`, params || {})
|
|
||||||
return data
|
|
||||||
},
|
|
||||||
|
|
||||||
abandonSession: async (sessionId: string): Promise<void> => {
|
|
||||||
await apiClient.delete(`/ai/chat/sessions/${sessionId}`)
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
export default aiChatApi
|
|
||||||
44
frontend/src/api/editorAI.ts
Normal file
44
frontend/src/api/editorAI.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { apiClient } from './client'
|
||||||
|
import type { AIActionType } from '@/types'
|
||||||
|
|
||||||
|
export interface SendMessageParams {
|
||||||
|
sessionId: string
|
||||||
|
content: string
|
||||||
|
actionType?: AIActionType
|
||||||
|
focalNodeId?: string | null
|
||||||
|
flowContext?: Record<string, unknown> | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export const editorAIApi = {
|
||||||
|
startSession: async (flowType: 'troubleshooting' | 'procedural', treeId?: string) => {
|
||||||
|
const { data } = await apiClient.post('/ai/chat/sessions', {
|
||||||
|
flow_type: flowType,
|
||||||
|
tree_id: treeId,
|
||||||
|
})
|
||||||
|
return data
|
||||||
|
},
|
||||||
|
|
||||||
|
sendMessage: async ({ sessionId, content, actionType, focalNodeId, flowContext }: SendMessageParams) => {
|
||||||
|
const { data } = await apiClient.post(`/ai/chat/sessions/${sessionId}/messages`, {
|
||||||
|
content,
|
||||||
|
action_type: actionType || 'open_chat',
|
||||||
|
focal_node_id: focalNodeId,
|
||||||
|
flow_context: flowContext || undefined,
|
||||||
|
})
|
||||||
|
return data
|
||||||
|
},
|
||||||
|
|
||||||
|
getSession: async (sessionId: string) => {
|
||||||
|
const { data } = await apiClient.get(`/ai/chat/sessions/${sessionId}`)
|
||||||
|
return data
|
||||||
|
},
|
||||||
|
|
||||||
|
generateFull: async (sessionId: string) => {
|
||||||
|
const { data } = await apiClient.post(`/ai/chat/sessions/${sessionId}/generate`)
|
||||||
|
return data
|
||||||
|
},
|
||||||
|
|
||||||
|
abandonSession: async (sessionId: string) => {
|
||||||
|
await apiClient.delete(`/ai/chat/sessions/${sessionId}`)
|
||||||
|
},
|
||||||
|
}
|
||||||
17
frontend/src/api/flowTransfer.ts
Normal file
17
frontend/src/api/flowTransfer.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import apiClient from './client'
|
||||||
|
import type { RFFlowFile, FlowImportResponse } from '@/types'
|
||||||
|
|
||||||
|
export const flowTransferApi = {
|
||||||
|
async exportFlow(treeId: string): Promise<Blob> {
|
||||||
|
const response = await apiClient.get(`/trees/${treeId}/export`, {
|
||||||
|
responseType: 'blob',
|
||||||
|
})
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
async importFlow(data: RFFlowFile, nameOverride?: string): Promise<FlowImportResponse> {
|
||||||
|
const params = nameOverride ? { name_override: nameOverride } : undefined
|
||||||
|
const response = await apiClient.post('/trees/import', data, { params })
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -17,6 +17,6 @@ export { targetListsApi } from './targetLists'
|
|||||||
export { maintenanceSchedulesApi, batchLaunchApi } from './maintenanceSchedules'
|
export { maintenanceSchedulesApi, batchLaunchApi } from './maintenanceSchedules'
|
||||||
export { default as feedbackApi } from './feedback'
|
export { default as feedbackApi } from './feedback'
|
||||||
export { default as aiBuilderApi } from './aiBuilder'
|
export { default as aiBuilderApi } from './aiBuilder'
|
||||||
export { default as aiChatApi } from './aiChat'
|
|
||||||
export { copilotApi } from './copilot'
|
export { copilotApi } from './copilot'
|
||||||
export { assistantChatApi } from './assistantChat'
|
export { assistantChatApi } from './assistantChat'
|
||||||
|
export { flowTransferApi } from './flowTransfer'
|
||||||
|
|||||||
@@ -1,168 +0,0 @@
|
|||||||
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(() => {
|
|
||||||
// Reset guard when wizard resets to foundation (Start Over or close)
|
|
||||||
if (phase === 'foundation') {
|
|
||||||
hasTriggeredScaffold.current = false
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (phase === 'scaffolding' && !hasTriggeredScaffold.current && !useAIFlowBuilderStore.getState().suggestedBranches.length) {
|
|
||||||
hasTriggeredScaffold.current = true
|
|
||||||
scaffold()
|
|
||||||
}
|
|
||||||
}, [phase, scaffold])
|
|
||||||
|
|
||||||
const handleClose = () => {
|
|
||||||
reset()
|
|
||||||
onClose()
|
|
||||||
}
|
|
||||||
|
|
||||||
const createTree = async () => {
|
|
||||||
if (!assembledTree) return null
|
|
||||||
try {
|
|
||||||
return await treesApi.create({
|
|
||||||
name: assembledTree.suggested_name,
|
|
||||||
description: assembledTree.suggested_description,
|
|
||||||
tree_structure: assembledTree.tree_structure,
|
|
||||||
tree_type: metadata.flow_type,
|
|
||||||
status: 'draft',
|
|
||||||
})
|
|
||||||
} catch {
|
|
||||||
toast.error('Failed to create flow. Please try again.')
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleOpenInEditor = async () => {
|
|
||||||
const tree = await createTree()
|
|
||||||
if (!tree) return
|
|
||||||
handleClose()
|
|
||||||
const editorPath =
|
|
||||||
metadata.flow_type === 'procedural'
|
|
||||||
? `/flows/${tree.id}/edit`
|
|
||||||
: `/trees/${tree.id}/edit`
|
|
||||||
navigate(editorPath)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleStartFlow = async () => {
|
|
||||||
const tree = await createTree()
|
|
||||||
if (!tree) return
|
|
||||||
handleClose()
|
|
||||||
const navigatePath =
|
|
||||||
metadata.flow_type === 'procedural'
|
|
||||||
? `/flows/${tree.id}/navigate`
|
|
||||||
: `/trees/${tree.id}/navigate`
|
|
||||||
navigate(navigatePath)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleBuildAnother = () => {
|
|
||||||
reset()
|
|
||||||
}
|
|
||||||
|
|
||||||
const getTitle = () => {
|
|
||||||
switch (phase) {
|
|
||||||
case 'foundation':
|
|
||||||
return 'Flow Assist'
|
|
||||||
case 'scaffolding':
|
|
||||||
case 'generating':
|
|
||||||
return 'AI Scaffold'
|
|
||||||
case 'detailing':
|
|
||||||
return 'Branch Detail'
|
|
||||||
case 'reviewing':
|
|
||||||
return 'Review & Assemble'
|
|
||||||
case 'error':
|
|
||||||
return 'Flow Assist'
|
|
||||||
default:
|
|
||||||
return 'Flow Assist'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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}
|
|
||||||
onStartFlow={handleStartFlow}
|
|
||||||
onBuildAnother={handleBuildAnother}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,259 +0,0 @@
|
|||||||
import { Check, RefreshCw, SkipForward, ChevronRight, ChevronLeft, Zap, Square } 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,
|
|
||||||
isGeneratingAll,
|
|
||||||
generateAllBranchDetails,
|
|
||||||
cancelGenerateAll,
|
|
||||||
} = 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
|
|
||||||
branchContext={
|
|
||||||
isGeneratingAll
|
|
||||||
? {
|
|
||||||
current: selectedBranches.filter((b) => b.steps).length + 1,
|
|
||||||
total: selectedBranches.length,
|
|
||||||
}
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
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 || isGeneratingAll}
|
|
||||||
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-4 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>
|
|
||||||
|
|
||||||
{/* Generate All — primary action, shown when multiple branches remain */}
|
|
||||||
{selectedBranches.filter((b) => !b.steps).length > 1 && (
|
|
||||||
isGeneratingAll ? (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={cancelGenerateAll}
|
|
||||||
className="flex items-center gap-2 rounded-lg border border-red-400/30 bg-red-400/10 px-5 py-2.5 text-sm font-medium text-red-400 hover:bg-red-400/20"
|
|
||||||
>
|
|
||||||
<Square className="h-4 w-4" />
|
|
||||||
Stop Generating
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={generateAllBranchDetails}
|
|
||||||
disabled={isLoading}
|
|
||||||
className="flex items-center gap-2 rounded-lg bg-gradient-brand px-5 py-2.5 text-sm font-semibold text-white shadow-lg shadow-primary/20 hover:opacity-90 disabled:opacity-50"
|
|
||||||
>
|
|
||||||
<Zap className="h-4 w-4" />
|
|
||||||
Generate All {selectedBranches.filter((b) => !b.steps).length} Branches
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Divider + secondary actions */}
|
|
||||||
{selectedBranches.filter((b) => !b.steps).length > 1 && (
|
|
||||||
<div className="flex items-center gap-3 text-xs text-muted-foreground">
|
|
||||||
<div className="h-px w-8 bg-border" />
|
|
||||||
or
|
|
||||||
<div className="h-px w-8 bg-border" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => handleGenerate(currentBranch.name)}
|
|
||||||
disabled={isLoading || isGeneratingAll}
|
|
||||||
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"
|
|
||||||
>
|
|
||||||
Generate this branch
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
if (viewingIndex < selectedBranches.length - 1) {
|
|
||||||
setViewingIndex(viewingIndex + 1)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
disabled={isGeneratingAll}
|
|
||||||
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-50"
|
|
||||||
>
|
|
||||||
<SkipForward className="h-3 w-3" />
|
|
||||||
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,292 +0,0 @@
|
|||||||
import { useState } from 'react'
|
|
||||||
import { GripVertical, Plus, X, Pencil, Check, RefreshCw } 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,
|
|
||||||
scaffold,
|
|
||||||
isLoading,
|
|
||||||
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 className="flex items-center justify-between">
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
AI suggested {suggestedBranches.length} branches. Select, reorder, rename, or add your own.
|
|
||||||
</p>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => scaffold()}
|
|
||||||
disabled={isLoading}
|
|
||||||
className="flex items-center gap-1.5 rounded-lg border border-border px-2.5 py-1.5 text-xs text-muted-foreground hover:bg-accent hover:text-foreground disabled:opacity-50"
|
|
||||||
title="Generate new suggestions"
|
|
||||||
>
|
|
||||||
<RefreshCw className={cn('h-3 w-3', isLoading && 'animate-spin')} />
|
|
||||||
Regenerate
|
|
||||||
</button>
|
|
||||||
</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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,163 +0,0 @@
|
|||||||
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
interface GeneratingAnimationProps {
|
|
||||||
branchContext?: { current: number; total: number }
|
|
||||||
}
|
|
||||||
|
|
||||||
export function GeneratingAnimation({ branchContext }: GeneratingAnimationProps) {
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col items-center justify-center gap-4 py-10">
|
|
||||||
{/* Spinner */}
|
|
||||||
<div className="h-8 w-8 animate-spin rounded-full border-2 border-border border-t-primary" />
|
|
||||||
|
|
||||||
{/* Branch context (Generate All mode) */}
|
|
||||||
{branchContext ? (
|
|
||||||
<>
|
|
||||||
<p className="text-xs font-label uppercase tracking-wide text-muted-foreground">
|
|
||||||
Branch {branchContext.current} of {branchContext.total}
|
|
||||||
</p>
|
|
||||||
<p className="text-sm text-muted-foreground">Generating branch detail...</p>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<p className="text-sm text-muted-foreground">Generating...</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,98 +0,0 @@
|
|||||||
import { GitBranch, Layers, CheckCircle, ArrowRight, RotateCcw, Play } from 'lucide-react'
|
|
||||||
import { useAIFlowBuilderStore } from '@/store/aiFlowBuilderStore'
|
|
||||||
import { cn } from '@/lib/utils'
|
|
||||||
|
|
||||||
interface TreePreviewCardProps {
|
|
||||||
onOpenInEditor: () => void
|
|
||||||
onStartFlow: () => void
|
|
||||||
onBuildAnother: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export function TreePreviewCard({ onOpenInEditor, onStartFlow, onBuildAnother }: TreePreviewCardProps) {
|
|
||||||
const { assembledTree, 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 flex-col gap-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onStartFlow}
|
|
||||||
disabled={isLoading}
|
|
||||||
className={cn(
|
|
||||||
'flex w-full 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 disabled:opacity-50'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Play className="h-4 w-4" />
|
|
||||||
Start Flow
|
|
||||||
</button>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onOpenInEditor}
|
|
||||||
disabled={isLoading}
|
|
||||||
className="flex flex-1 items-center justify-center gap-2 rounded-lg border border-border py-2 text-sm text-muted-foreground hover:bg-accent hover:text-foreground disabled:opacity-50"
|
|
||||||
>
|
|
||||||
<ArrowRight className="h-4 w-4" />
|
|
||||||
Open in Editor
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onBuildAnother}
|
|
||||||
className="flex items-center gap-2 rounded-lg border border-border px-4 py-2 text-sm text-muted-foreground hover:bg-accent hover:text-foreground"
|
|
||||||
>
|
|
||||||
<RotateCcw className="h-4 w-4" />
|
|
||||||
Build Another
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
import { useState, useRef, useCallback } from 'react'
|
|
||||||
import { Send } from 'lucide-react'
|
|
||||||
import { cn } from '@/lib/utils'
|
|
||||||
|
|
||||||
interface ChatInputProps {
|
|
||||||
onSend: (content: string) => void
|
|
||||||
disabled?: boolean
|
|
||||||
placeholder?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ChatInput({ onSend, disabled, placeholder = 'Type a message...' }: ChatInputProps) {
|
|
||||||
const [value, setValue] = useState('')
|
|
||||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
|
||||||
|
|
||||||
const handleSend = useCallback(() => {
|
|
||||||
const trimmed = value.trim()
|
|
||||||
if (!trimmed || disabled) return
|
|
||||||
onSend(trimmed)
|
|
||||||
setValue('')
|
|
||||||
if (textareaRef.current) {
|
|
||||||
textareaRef.current.style.height = 'auto'
|
|
||||||
}
|
|
||||||
}, [value, disabled, onSend])
|
|
||||||
|
|
||||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
|
||||||
if (e.key === 'Enter' && !e.shiftKey) {
|
|
||||||
e.preventDefault()
|
|
||||||
handleSend()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleInput = () => {
|
|
||||||
if (textareaRef.current) {
|
|
||||||
textareaRef.current.style.height = 'auto'
|
|
||||||
textareaRef.current.style.height = Math.min(textareaRef.current.scrollHeight, 160) + 'px'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex items-end gap-2 border-t border-border bg-card px-4 py-3">
|
|
||||||
<textarea
|
|
||||||
ref={textareaRef}
|
|
||||||
value={value}
|
|
||||||
onChange={(e) => setValue(e.target.value)}
|
|
||||||
onKeyDown={handleKeyDown}
|
|
||||||
onInput={handleInput}
|
|
||||||
placeholder={placeholder}
|
|
||||||
disabled={disabled}
|
|
||||||
rows={1}
|
|
||||||
className={cn(
|
|
||||||
'flex-1 resize-none rounded-lg border border-border bg-background px-3 py-2',
|
|
||||||
'text-sm text-foreground placeholder:text-muted-foreground',
|
|
||||||
'focus:border-primary focus:ring-1 focus:ring-primary/20 focus:outline-none',
|
|
||||||
'disabled:opacity-50 disabled:cursor-not-allowed',
|
|
||||||
'max-h-40'
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
onClick={handleSend}
|
|
||||||
disabled={disabled || !value.trim()}
|
|
||||||
className={cn(
|
|
||||||
'flex h-10 w-10 shrink-0 items-center justify-center rounded-lg',
|
|
||||||
'bg-gradient-brand text-white shadow-lg shadow-primary/20',
|
|
||||||
'hover:opacity-90 transition-opacity',
|
|
||||||
'disabled:opacity-50 disabled:cursor-not-allowed'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Send className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
import { Bot, User } from 'lucide-react'
|
|
||||||
import { MarkdownContent } from '@/components/ui/MarkdownContent'
|
|
||||||
import { cn } from '@/lib/utils'
|
|
||||||
import type { ChatMessage as ChatMessageType } from '@/types'
|
|
||||||
|
|
||||||
interface ChatMessageProps {
|
|
||||||
message: ChatMessageType
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ChatMessage({ message }: ChatMessageProps) {
|
|
||||||
const isAI = message.role === 'assistant'
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={cn('flex gap-3', isAI ? 'items-start' : 'items-start justify-end')}>
|
|
||||||
{isAI && (
|
|
||||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-primary/10">
|
|
||||||
<Bot className="h-4 w-4 text-primary" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
'max-w-[85%] rounded-xl px-4 py-3',
|
|
||||||
isAI
|
|
||||||
? 'bg-card border border-border'
|
|
||||||
: 'bg-primary/10 border border-primary/20'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{isAI ? (
|
|
||||||
<MarkdownContent content={message.content} className="text-sm" />
|
|
||||||
) : (
|
|
||||||
<p className="text-sm text-foreground whitespace-pre-wrap">{message.content}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{!isAI && (
|
|
||||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-accent">
|
|
||||||
<User className="h-4 w-4 text-foreground" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
import { useEffect, useRef } from 'react'
|
|
||||||
import { ChatMessage } from './ChatMessage'
|
|
||||||
import { ChatInput } from './ChatInput'
|
|
||||||
import { Spinner } from '@/components/common/Spinner'
|
|
||||||
import type { ChatMessage as ChatMessageType } from '@/types'
|
|
||||||
|
|
||||||
interface ChatPanelProps {
|
|
||||||
messages: ChatMessageType[]
|
|
||||||
isResponding: boolean
|
|
||||||
onSendMessage: (content: string) => void
|
|
||||||
disabled?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ChatPanel({ messages, isResponding, onSendMessage, disabled }: ChatPanelProps) {
|
|
||||||
const scrollRef = useRef<HTMLDivElement>(null)
|
|
||||||
|
|
||||||
// Auto-scroll to bottom on new messages
|
|
||||||
useEffect(() => {
|
|
||||||
if (scrollRef.current) {
|
|
||||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight
|
|
||||||
}
|
|
||||||
}, [messages, isResponding])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex h-full flex-col">
|
|
||||||
{/* Messages */}
|
|
||||||
<div ref={scrollRef} className="flex-1 overflow-y-auto px-4 py-4 space-y-4">
|
|
||||||
{messages.map((msg, i) => (
|
|
||||||
<ChatMessage key={i} message={msg} />
|
|
||||||
))}
|
|
||||||
{isResponding && (
|
|
||||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
|
||||||
<Spinner size="sm" />
|
|
||||||
<span>Thinking...</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Input */}
|
|
||||||
<ChatInput
|
|
||||||
onSend={onSendMessage}
|
|
||||||
disabled={disabled || isResponding}
|
|
||||||
placeholder={isResponding ? 'Waiting for response...' : 'Type a message...'}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,98 +0,0 @@
|
|||||||
import { Sparkles, Save, RotateCcw, Loader2 } from 'lucide-react'
|
|
||||||
import { PhaseIndicator } from './PhaseIndicator'
|
|
||||||
import { cn } from '@/lib/utils'
|
|
||||||
import type { InterviewPhase } from '@/types'
|
|
||||||
|
|
||||||
interface ChatToolbarProps {
|
|
||||||
currentPhase: InterviewPhase
|
|
||||||
status: 'idle' | 'active' | 'completed' | 'abandoned'
|
|
||||||
isGenerating: boolean
|
|
||||||
hasGeneratedTree: boolean
|
|
||||||
isSaving: boolean
|
|
||||||
onGenerate: () => void
|
|
||||||
onSave: () => void
|
|
||||||
onReset: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ChatToolbar({
|
|
||||||
currentPhase,
|
|
||||||
status,
|
|
||||||
isGenerating,
|
|
||||||
hasGeneratedTree,
|
|
||||||
isSaving,
|
|
||||||
onGenerate,
|
|
||||||
onSave,
|
|
||||||
onReset,
|
|
||||||
}: ChatToolbarProps) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-between border-b border-border bg-card px-4 py-2">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="flex items-center gap-2 text-sm font-medium text-foreground">
|
|
||||||
<Sparkles className="h-4 w-4 text-primary" />
|
|
||||||
Flow Assist
|
|
||||||
</div>
|
|
||||||
<PhaseIndicator currentPhase={currentPhase} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{status === 'active' && !hasGeneratedTree && (
|
|
||||||
<button
|
|
||||||
onClick={onGenerate}
|
|
||||||
disabled={isGenerating}
|
|
||||||
className={cn(
|
|
||||||
'flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-sm font-medium',
|
|
||||||
'bg-gradient-brand text-white shadow-lg shadow-primary/20',
|
|
||||||
'hover:opacity-90 transition-opacity',
|
|
||||||
'disabled:opacity-50 disabled:cursor-not-allowed'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{isGenerating ? (
|
|
||||||
<>
|
|
||||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
|
||||||
Generating...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Sparkles className="h-3.5 w-3.5" />
|
|
||||||
Generate Flow
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{hasGeneratedTree && (
|
|
||||||
<button
|
|
||||||
onClick={onSave}
|
|
||||||
disabled={isSaving}
|
|
||||||
className={cn(
|
|
||||||
'flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-sm font-medium',
|
|
||||||
'bg-gradient-brand text-white shadow-lg shadow-primary/20',
|
|
||||||
'hover:opacity-90 transition-opacity',
|
|
||||||
'disabled:opacity-50 disabled:cursor-not-allowed'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{isSaving ? (
|
|
||||||
<>
|
|
||||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
|
||||||
Saving...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Save className="h-3.5 w-3.5" />
|
|
||||||
Save to Flow Library
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={onReset}
|
|
||||||
className="flex items-center gap-1.5 rounded-lg border border-border px-3 py-1.5 text-sm text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
|
|
||||||
>
|
|
||||||
<RotateCcw className="h-3.5 w-3.5" />
|
|
||||||
Start Over
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
import { TreeDeciduous } from 'lucide-react'
|
|
||||||
|
|
||||||
export function EmptyPreview() {
|
|
||||||
return (
|
|
||||||
<div className="flex h-full flex-col items-center justify-center p-8 text-center">
|
|
||||||
<TreeDeciduous className="h-12 w-12 text-muted-foreground/30 mb-4" />
|
|
||||||
<h3 className="text-sm font-medium text-muted-foreground mb-1">Flow Preview</h3>
|
|
||||||
<p className="text-xs text-muted-foreground/70 max-w-48">
|
|
||||||
Your flow will appear here as you describe it to the AI
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
import { cn } from '@/lib/utils'
|
|
||||||
import type { InterviewPhase } from '@/types'
|
|
||||||
|
|
||||||
const PHASES: { key: InterviewPhase; label: string }[] = [
|
|
||||||
{ key: 'scoping', label: 'Scoping' },
|
|
||||||
{ key: 'discovery', label: 'Discovery' },
|
|
||||||
{ key: 'enrichment', label: 'Enrichment' },
|
|
||||||
{ key: 'review', label: 'Review' },
|
|
||||||
{ key: 'generation', label: 'Generate' },
|
|
||||||
]
|
|
||||||
|
|
||||||
interface PhaseIndicatorProps {
|
|
||||||
currentPhase: InterviewPhase
|
|
||||||
}
|
|
||||||
|
|
||||||
export function PhaseIndicator({ currentPhase }: PhaseIndicatorProps) {
|
|
||||||
const currentIndex = PHASES.findIndex((p) => p.key === currentPhase)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
{PHASES.map((phase, i) => {
|
|
||||||
const isActive = phase.key === currentPhase
|
|
||||||
const isCompleted = i < currentIndex
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div key={phase.key} className="flex items-center">
|
|
||||||
{i > 0 && (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
'mx-1 h-px w-4',
|
|
||||||
isCompleted ? 'bg-primary' : 'bg-border'
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
'font-label text-[0.6875rem] uppercase tracking-wide px-2 py-0.5 rounded',
|
|
||||||
isActive && 'text-primary bg-primary/10 font-medium',
|
|
||||||
isCompleted && 'text-primary',
|
|
||||||
!isActive && !isCompleted && 'text-muted-foreground'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{phase.label}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
import { useState, useMemo, useCallback } from 'react'
|
|
||||||
import { TreePreviewNode } from '@/components/tree-preview/TreePreviewNode'
|
|
||||||
import type { SharedLinksMap } from '@/components/tree-preview/TreePreviewPanel'
|
|
||||||
import type { TreeStructure } from '@/types'
|
|
||||||
|
|
||||||
interface StaticTreePreviewProps {
|
|
||||||
tree: TreeStructure
|
|
||||||
name?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
function findNodeInTree(nodeId: string, tree: TreeStructure): TreeStructure | null {
|
|
||||||
if (tree.id === nodeId) return tree
|
|
||||||
if (tree.children) {
|
|
||||||
for (const child of tree.children) {
|
|
||||||
const found = findNodeInTree(nodeId, child)
|
|
||||||
if (found) return found
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildSharedLinksMap(node: TreeStructure, map: SharedLinksMap = new Map()): SharedLinksMap {
|
|
||||||
const nodeLabel = node.type === 'decision' ? node.question : node.title
|
|
||||||
if (node.type === 'decision' && node.options) {
|
|
||||||
for (const opt of node.options) {
|
|
||||||
if (opt.next_node_id) {
|
|
||||||
const existing = map.get(opt.next_node_id) || []
|
|
||||||
existing.push({ id: node.id, label: nodeLabel || 'Untitled' })
|
|
||||||
map.set(opt.next_node_id, existing)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (node.type === 'action' && node.next_node_id) {
|
|
||||||
const existing = map.get(node.next_node_id) || []
|
|
||||||
existing.push({ id: node.id, label: nodeLabel || 'Untitled' })
|
|
||||||
map.set(node.next_node_id, existing)
|
|
||||||
}
|
|
||||||
if (node.children) {
|
|
||||||
for (const child of node.children) {
|
|
||||||
buildSharedLinksMap(child, map)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return map
|
|
||||||
}
|
|
||||||
|
|
||||||
export function StaticTreePreview({ tree, name }: StaticTreePreviewProps) {
|
|
||||||
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null)
|
|
||||||
|
|
||||||
const findNode = useCallback(
|
|
||||||
(nodeId: string) => findNodeInTree(nodeId, tree),
|
|
||||||
[tree]
|
|
||||||
)
|
|
||||||
|
|
||||||
const sharedLinksMap = useMemo(() => buildSharedLinksMap(tree), [tree])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex h-full flex-col">
|
|
||||||
<div className="border-b border-border px-4 py-2">
|
|
||||||
<h3 className="text-sm font-semibold text-foreground">
|
|
||||||
Preview: {name || 'Untitled Flow'}
|
|
||||||
</h3>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
Click a node to select
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 overflow-auto p-4">
|
|
||||||
<div className="inline-block min-w-full">
|
|
||||||
<TreePreviewNode
|
|
||||||
node={tree}
|
|
||||||
selectedNodeId={selectedNodeId}
|
|
||||||
onSelect={setSelectedNodeId}
|
|
||||||
depth={0}
|
|
||||||
findNode={findNode}
|
|
||||||
sharedLinksMap={sharedLinksMap}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
104
frontend/src/components/common/ContextMenu.tsx
Normal file
104
frontend/src/components/common/ContextMenu.tsx
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
import { useEffect, useRef, useCallback } from 'react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
export interface ContextMenuPosition {
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ContextMenuItem {
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
icon?: React.ReactNode
|
||||||
|
onClick: () => void
|
||||||
|
variant?: 'default' | 'danger'
|
||||||
|
separator?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ContextMenuProps {
|
||||||
|
position: ContextMenuPosition
|
||||||
|
items: ContextMenuItem[]
|
||||||
|
onClose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ContextMenu({ position, items, onClose }: ContextMenuProps) {
|
||||||
|
const menuRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
const handleClickOutside = useCallback(
|
||||||
|
(e: MouseEvent) => {
|
||||||
|
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[onClose]
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleKeyDown = useCallback(
|
||||||
|
(e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') onClose()
|
||||||
|
},
|
||||||
|
[onClose]
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
document.addEventListener('mousedown', handleClickOutside)
|
||||||
|
document.addEventListener('keydown', handleKeyDown)
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousedown', handleClickOutside)
|
||||||
|
document.removeEventListener('keydown', handleKeyDown)
|
||||||
|
}
|
||||||
|
}, [handleClickOutside, handleKeyDown])
|
||||||
|
|
||||||
|
// Adjust position to stay within viewport
|
||||||
|
useEffect(() => {
|
||||||
|
if (!menuRef.current) return
|
||||||
|
const rect = menuRef.current.getBoundingClientRect()
|
||||||
|
const el = menuRef.current
|
||||||
|
if (position.x + rect.width > window.innerWidth) {
|
||||||
|
el.style.left = `${position.x - rect.width}px`
|
||||||
|
}
|
||||||
|
if (position.y + rect.height > window.innerHeight) {
|
||||||
|
el.style.top = `${position.y - rect.height}px`
|
||||||
|
}
|
||||||
|
}, [position])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={menuRef}
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
zIndex: 100,
|
||||||
|
left: position.x,
|
||||||
|
top: position.y,
|
||||||
|
}}
|
||||||
|
className="min-w-[200px] rounded-xl border border-border bg-card p-1 shadow-lg backdrop-blur-md"
|
||||||
|
>
|
||||||
|
{items.map((item) => (
|
||||||
|
<div key={item.id}>
|
||||||
|
{item.separator && (
|
||||||
|
<div className="my-1 border-t border-border" />
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
item.onClick()
|
||||||
|
onClose()
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
'flex w-full items-center gap-2 rounded-lg px-3 py-2 text-sm transition-colors',
|
||||||
|
item.variant === 'danger'
|
||||||
|
? 'text-rose-400 hover:bg-rose-500/10'
|
||||||
|
: 'text-foreground hover:bg-[rgba(255,255,255,0.06)]'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{item.icon && (
|
||||||
|
<span className="flex h-4 w-4 items-center justify-center text-muted-foreground">
|
||||||
|
{item.icon}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{item.label}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,11 +1,15 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { Link } from 'react-router-dom'
|
import { Link, useNavigate } from 'react-router-dom'
|
||||||
import { Plus, ChevronDown, Sparkles, FolderTree, ListOrdered, Wrench } from 'lucide-react'
|
import { Plus, ChevronDown, Sparkles, FolderTree, ListOrdered, Wrench } from 'lucide-react'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
import { editorAIApi } from '@/api/editorAI'
|
||||||
|
import { apiClient } from '@/api/client'
|
||||||
|
import { AIPromptDialog } from '@/components/editor-ai/AIPromptDialog'
|
||||||
|
|
||||||
|
type AIFlowType = 'troubleshooting' | 'procedural' | 'maintenance'
|
||||||
|
|
||||||
interface CreateFlowDropdownProps {
|
interface CreateFlowDropdownProps {
|
||||||
aiEnabled: boolean
|
aiEnabled: boolean
|
||||||
onOpenAIBuilder: () => void
|
|
||||||
className?: string
|
className?: string
|
||||||
/** Button label — defaults to "Create Flow" */
|
/** Button label — defaults to "Create Flow" */
|
||||||
label?: string
|
label?: string
|
||||||
@@ -13,11 +17,49 @@ interface CreateFlowDropdownProps {
|
|||||||
|
|
||||||
export function CreateFlowDropdown({
|
export function CreateFlowDropdown({
|
||||||
aiEnabled,
|
aiEnabled,
|
||||||
onOpenAIBuilder,
|
|
||||||
className,
|
className,
|
||||||
label = 'Create Flow',
|
label = 'Create Flow',
|
||||||
}: CreateFlowDropdownProps) {
|
}: CreateFlowDropdownProps) {
|
||||||
const [showMenu, setShowMenu] = useState(false)
|
const [showMenu, setShowMenu] = useState(false)
|
||||||
|
const [aiPromptOpen, setAiPromptOpen] = useState(false)
|
||||||
|
const [aiPromptFlowType, setAiPromptFlowType] = useState<AIFlowType>('troubleshooting')
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
const handleAIGenerate = async (prompt: string) => {
|
||||||
|
// Start an AI session
|
||||||
|
const session = await editorAIApi.startSession(
|
||||||
|
aiPromptFlowType === 'maintenance' ? 'procedural' : aiPromptFlowType
|
||||||
|
)
|
||||||
|
const sessionId = session.id
|
||||||
|
|
||||||
|
// Send the user's prompt
|
||||||
|
await editorAIApi.sendMessage({
|
||||||
|
sessionId,
|
||||||
|
content: prompt,
|
||||||
|
actionType: 'generate_full',
|
||||||
|
})
|
||||||
|
|
||||||
|
// Generate the full flow
|
||||||
|
await editorAIApi.generateFull(sessionId)
|
||||||
|
|
||||||
|
// Import to create the tree
|
||||||
|
const { data: importResult } = await apiClient.post(
|
||||||
|
`/ai/chat/sessions/${sessionId}/import`,
|
||||||
|
{}
|
||||||
|
)
|
||||||
|
const treeId = importResult.tree_id
|
||||||
|
|
||||||
|
// Navigate to the editor
|
||||||
|
if (aiPromptFlowType === 'troubleshooting') {
|
||||||
|
navigate(`/trees/${treeId}/edit`, {
|
||||||
|
state: { aiPanelOpen: true, sessionId },
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
navigate(`/flows/${treeId}/edit`, {
|
||||||
|
state: { aiPanelOpen: true, sessionId },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn('relative', className)}>
|
<div className={cn('relative', className)}>
|
||||||
@@ -32,62 +74,107 @@ export function CreateFlowDropdown({
|
|||||||
{showMenu && (
|
{showMenu && (
|
||||||
<>
|
<>
|
||||||
<div className="fixed inset-0 z-10" onClick={() => setShowMenu(false)} />
|
<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">
|
<div className="absolute right-0 z-20 mt-1 w-64 rounded-lg border border-border bg-card p-1 shadow-xl backdrop-blur-sm">
|
||||||
|
{/* Troubleshooting */}
|
||||||
<Link
|
<Link
|
||||||
to="/trees/new"
|
to="/trees/new"
|
||||||
onClick={() => setShowMenu(false)}
|
onClick={() => setShowMenu(false)}
|
||||||
className="flex items-center gap-3 rounded-md px-3 py-2.5 text-sm text-foreground hover:bg-accent"
|
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" />
|
<FolderTree className="h-4 w-4 text-muted-foreground" />
|
||||||
<div>
|
<div className="flex-1">
|
||||||
<div className="font-medium">Troubleshooting Tree</div>
|
<div className="font-medium">Troubleshooting Tree</div>
|
||||||
<div className="text-xs text-muted-foreground">Branching decision flow</div>
|
<div className="text-xs text-muted-foreground">Branching decision flow</div>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
|
{aiEnabled && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setShowMenu(false)
|
||||||
|
setAiPromptFlowType('troubleshooting')
|
||||||
|
setAiPromptOpen(true)
|
||||||
|
}}
|
||||||
|
className="flex w-full items-center gap-3 rounded-md px-3 py-2 text-sm text-foreground hover:bg-accent"
|
||||||
|
>
|
||||||
|
<Sparkles className="h-3.5 w-3.5 text-primary ml-0.5" />
|
||||||
|
<div className="text-left">
|
||||||
|
<div className="text-xs text-primary font-medium">Build with Flow Assist</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="my-1 border-t border-border" />
|
||||||
|
|
||||||
|
{/* Procedural */}
|
||||||
<Link
|
<Link
|
||||||
to="/flows/new"
|
to="/flows/new"
|
||||||
onClick={() => setShowMenu(false)}
|
onClick={() => setShowMenu(false)}
|
||||||
className="flex items-center gap-3 rounded-md px-3 py-2.5 text-sm text-foreground hover:bg-accent"
|
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" />
|
<ListOrdered className="h-4 w-4 text-muted-foreground" />
|
||||||
<div>
|
<div className="flex-1">
|
||||||
<div className="font-medium">Procedural Flow</div>
|
<div className="font-medium">Procedural Flow</div>
|
||||||
<div className="text-xs text-muted-foreground">Step-by-step procedure</div>
|
<div className="text-xs text-muted-foreground">Step-by-step procedure</div>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
|
{aiEnabled && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setShowMenu(false)
|
||||||
|
setAiPromptFlowType('procedural')
|
||||||
|
setAiPromptOpen(true)
|
||||||
|
}}
|
||||||
|
className="flex w-full items-center gap-3 rounded-md px-3 py-2 text-sm text-foreground hover:bg-accent"
|
||||||
|
>
|
||||||
|
<Sparkles className="h-3.5 w-3.5 text-primary ml-0.5" />
|
||||||
|
<div className="text-left">
|
||||||
|
<div className="text-xs text-primary font-medium">Build with Flow Assist</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="my-1 border-t border-border" />
|
||||||
|
|
||||||
|
{/* Maintenance */}
|
||||||
<Link
|
<Link
|
||||||
to="/flows/new?type=maintenance"
|
to="/flows/new?type=maintenance"
|
||||||
onClick={() => setShowMenu(false)}
|
onClick={() => setShowMenu(false)}
|
||||||
className="flex items-center gap-3 rounded-md px-3 py-2.5 text-sm text-foreground hover:bg-accent"
|
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" />
|
<Wrench className="h-4 w-4 text-amber-400" />
|
||||||
<div>
|
<div className="flex-1">
|
||||||
<div className="font-medium">Maintenance Flow</div>
|
<div className="font-medium">Maintenance Flow</div>
|
||||||
<div className="text-xs text-muted-foreground">Scheduled multi-target tasks</div>
|
<div className="text-xs text-muted-foreground">Scheduled multi-target tasks</div>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
{aiEnabled && (
|
{aiEnabled && (
|
||||||
<>
|
|
||||||
<div className="my-1 border-t border-border" />
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setShowMenu(false)
|
setShowMenu(false)
|
||||||
onOpenAIBuilder()
|
setAiPromptFlowType('maintenance')
|
||||||
|
setAiPromptOpen(true)
|
||||||
}}
|
}}
|
||||||
className="flex w-full items-center gap-3 rounded-md px-3 py-2.5 text-sm text-foreground hover:bg-accent"
|
className="flex w-full items-center gap-3 rounded-md px-3 py-2 text-sm text-foreground hover:bg-accent"
|
||||||
>
|
>
|
||||||
<Sparkles className="h-4 w-4 text-primary" />
|
<Sparkles className="h-3.5 w-3.5 text-primary ml-0.5" />
|
||||||
<div className="text-left">
|
<div className="text-left">
|
||||||
<div className="font-medium">Flow Assist</div>
|
<div className="text-xs text-primary font-medium">Build with Flow Assist</div>
|
||||||
<div className="text-xs text-muted-foreground">AI-powered flow builder</div>
|
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<AIPromptDialog
|
||||||
|
isOpen={aiPromptOpen}
|
||||||
|
onClose={() => setAiPromptOpen(false)}
|
||||||
|
onGenerate={handleAIGenerate}
|
||||||
|
flowType={aiPromptFlowType}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -88,9 +88,9 @@ export function CopilotPanel({ isOpen, onClose, treeId, sessionId, currentNodeId
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="fixed right-0 top-0 bottom-0 z-50 flex flex-col border-l"
|
className="flex flex-col border-l shrink-0"
|
||||||
style={{
|
style={{
|
||||||
width: '400px',
|
width: '380px',
|
||||||
background: 'rgba(16, 17, 20, 0.95)',
|
background: 'rgba(16, 17, 20, 0.95)',
|
||||||
backdropFilter: 'var(--glass-blur)',
|
backdropFilter: 'var(--glass-blur)',
|
||||||
WebkitBackdropFilter: 'var(--glass-blur)',
|
WebkitBackdropFilter: 'var(--glass-blur)',
|
||||||
@@ -174,6 +174,7 @@ export function CopilotPanel({ isOpen, onClose, treeId, sessionId, currentNodeId
|
|||||||
<Send size={16} />
|
<Send size={16} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<p className="text-[0.625rem] text-muted-foreground mt-1.5 px-1">Shift + Enter for a new line</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
111
frontend/src/components/editor-ai/AIPromptDialog.tsx
Normal file
111
frontend/src/components/editor-ai/AIPromptDialog.tsx
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { Sparkles, Loader2, AlertCircle } from 'lucide-react'
|
||||||
|
|
||||||
|
interface AIPromptDialogProps {
|
||||||
|
isOpen: boolean
|
||||||
|
onClose: () => void
|
||||||
|
onGenerate: (prompt: string) => Promise<void>
|
||||||
|
flowType: 'troubleshooting' | 'procedural' | 'maintenance'
|
||||||
|
}
|
||||||
|
|
||||||
|
const FLOW_TYPE_LABELS = {
|
||||||
|
troubleshooting: 'Troubleshooting Flow',
|
||||||
|
procedural: 'Project Flow',
|
||||||
|
maintenance: 'Maintenance Flow',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AIPromptDialog({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
onGenerate,
|
||||||
|
flowType,
|
||||||
|
}: AIPromptDialogProps) {
|
||||||
|
const [prompt, setPrompt] = useState('')
|
||||||
|
const [isGenerating, setIsGenerating] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
if (!isOpen) return null
|
||||||
|
|
||||||
|
const handleGenerate = async () => {
|
||||||
|
if (!prompt.trim()) return
|
||||||
|
setIsGenerating(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
await onGenerate(prompt)
|
||||||
|
setPrompt('')
|
||||||
|
onClose()
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : 'Generation failed. Please try again.')
|
||||||
|
} finally {
|
||||||
|
setIsGenerating(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||||
|
{/* Backdrop */}
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
|
||||||
|
onClick={() => !isGenerating && onClose()}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Dialog */}
|
||||||
|
<div className="glass-card-static relative w-full max-w-lg p-6 shadow-xl">
|
||||||
|
<div className="flex items-center gap-2 mb-4">
|
||||||
|
<Sparkles className="h-5 w-5 text-primary" />
|
||||||
|
<h2 className="text-lg font-heading font-semibold text-foreground">
|
||||||
|
AI-Assisted {FLOW_TYPE_LABELS[flowType]}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-sm text-muted-foreground mb-4">
|
||||||
|
Describe what you want to build and AI will generate a starting structure for you.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<textarea
|
||||||
|
value={prompt}
|
||||||
|
onChange={(e) => setPrompt(e.target.value)}
|
||||||
|
placeholder={`Example: "A flow for troubleshooting VPN connectivity issues when users can't connect to the corporate network"`}
|
||||||
|
rows={4}
|
||||||
|
disabled={isGenerating}
|
||||||
|
className="w-full rounded-xl border border-border bg-background px-4 py-3 text-sm text-foreground placeholder:text-muted-foreground focus:border-[rgba(6,182,212,0.3)] focus:outline-none resize-none disabled:opacity-50"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="mt-3 flex items-start gap-2 rounded-lg bg-rose-500/10 border border-rose-500/20 px-3 py-2">
|
||||||
|
<AlertCircle className="h-4 w-4 text-rose-400 mt-0.5 shrink-0" />
|
||||||
|
<p className="text-sm text-rose-400">{error}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-4 flex justify-end gap-3">
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
disabled={isGenerating}
|
||||||
|
className="rounded-[10px] bg-[rgba(255,255,255,0.04)] border border-[rgba(255,255,255,0.06)] px-4 py-2 text-sm text-foreground hover:border-[rgba(255,255,255,0.12)] transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleGenerate}
|
||||||
|
disabled={!prompt.trim() || isGenerating}
|
||||||
|
className="flex items-center gap-2 rounded-[10px] bg-gradient-brand px-4 py-2 text-sm font-semibold text-[#101114] shadow-lg shadow-primary/20 hover:opacity-90 active:scale-[0.97] transition-all disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{isGenerating ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
Generating...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Sparkles className="h-4 w-4" />
|
||||||
|
Generate
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
96
frontend/src/components/editor-ai/ChatTab.tsx
Normal file
96
frontend/src/components/editor-ai/ChatTab.tsx
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
import { useRef, useEffect } from 'react'
|
||||||
|
import { Send, Sparkles, Loader2 } from 'lucide-react'
|
||||||
|
import { MarkdownContent } from '@/components/ui/MarkdownContent'
|
||||||
|
import type { EditorAIChatMessage } from '@/types'
|
||||||
|
|
||||||
|
interface ChatTabProps {
|
||||||
|
messages: EditorAIChatMessage[]
|
||||||
|
input: string
|
||||||
|
onInputChange: (value: string) => void
|
||||||
|
onSend: () => void
|
||||||
|
isLoading: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChatTab({ messages, input, onInputChange, onSend, isLoading }: ChatTabProps) {
|
||||||
|
const messagesEndRef = useRef<HTMLDivElement>(null)
|
||||||
|
const inputRef = useRef<HTMLTextAreaElement>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
|
||||||
|
}, [messages])
|
||||||
|
|
||||||
|
// Focus input when panel mounts
|
||||||
|
useEffect(() => {
|
||||||
|
requestAnimationFrame(() => inputRef.current?.focus())
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
|
e.preventDefault()
|
||||||
|
if (input.trim() && !isLoading) onSend()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-1 flex-col overflow-hidden">
|
||||||
|
{/* Messages */}
|
||||||
|
<div className="flex-1 overflow-y-auto px-4 py-3 space-y-3">
|
||||||
|
{messages.length === 0 && (
|
||||||
|
<div className="flex flex-col items-center justify-center h-full text-muted-foreground text-sm">
|
||||||
|
<Sparkles className="h-8 w-8 mb-2 opacity-40" />
|
||||||
|
<p>Ask me to help build your flow</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{messages.map((msg, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`max-w-[85%] rounded-xl px-3.5 py-2.5 text-[0.8125rem] leading-relaxed ${
|
||||||
|
msg.role === 'user'
|
||||||
|
? 'bg-primary/15 text-foreground'
|
||||||
|
: 'bg-[rgba(255,255,255,0.04)] text-foreground border border-[rgba(255,255,255,0.06)]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<MarkdownContent content={msg.content} className="text-[0.8125rem] leading-relaxed" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{isLoading && (
|
||||||
|
<div className="flex justify-start">
|
||||||
|
<div className="bg-[rgba(255,255,255,0.04)] border border-[rgba(255,255,255,0.06)] rounded-xl px-3.5 py-2.5">
|
||||||
|
<Loader2 size={16} className="animate-spin text-primary" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div ref={messagesEndRef} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Input */}
|
||||||
|
<div className="px-4 py-3 border-t shrink-0" style={{ borderColor: 'var(--glass-border)' }}>
|
||||||
|
<div className="flex items-end gap-2">
|
||||||
|
<textarea
|
||||||
|
ref={inputRef}
|
||||||
|
value={input}
|
||||||
|
onChange={(e) => onInputChange(e.target.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
placeholder="Ask AI to help..."
|
||||||
|
rows={1}
|
||||||
|
className="flex-1 resize-none rounded-xl border bg-card text-foreground text-[0.8125rem] placeholder:text-muted-foreground px-3.5 py-2.5 focus:outline-none focus:border-[rgba(6,182,212,0.3)]"
|
||||||
|
style={{ borderColor: 'var(--glass-border)' }}
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={onSend}
|
||||||
|
disabled={!input.trim() || isLoading}
|
||||||
|
className="bg-gradient-brand text-[#101114] p-2.5 rounded-xl hover:opacity-90 active:scale-[0.97] transition-all disabled:opacity-40"
|
||||||
|
>
|
||||||
|
<Send size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="text-[0.625rem] text-muted-foreground mt-1.5 px-1">Shift + Enter for a new line</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
114
frontend/src/components/editor-ai/EditorAIPanel.tsx
Normal file
114
frontend/src/components/editor-ai/EditorAIPanel.tsx
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { X, Sparkles } from 'lucide-react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { NodeSummary } from './NodeSummary'
|
||||||
|
import { ChatTab } from './ChatTab'
|
||||||
|
import { SuggestionsTab } from './SuggestionsTab'
|
||||||
|
import type { EditorAIChatMessage, AISuggestion } from '@/types'
|
||||||
|
|
||||||
|
interface EditorAIPanelProps {
|
||||||
|
isOpen: boolean
|
||||||
|
onClose: () => void
|
||||||
|
focalNode?: { id: string; type: string; question?: string; title?: string; description?: string } | null
|
||||||
|
flowName?: string
|
||||||
|
flowType?: string
|
||||||
|
nodeCount?: number
|
||||||
|
messages: EditorAIChatMessage[]
|
||||||
|
input: string
|
||||||
|
onInputChange: (value: string) => void
|
||||||
|
onSend: () => void
|
||||||
|
isLoading: boolean
|
||||||
|
suggestions: AISuggestion[]
|
||||||
|
}
|
||||||
|
|
||||||
|
type Tab = 'chat' | 'suggestions'
|
||||||
|
|
||||||
|
export function EditorAIPanel({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
focalNode,
|
||||||
|
flowName,
|
||||||
|
flowType,
|
||||||
|
nodeCount,
|
||||||
|
messages,
|
||||||
|
input,
|
||||||
|
onInputChange,
|
||||||
|
onSend,
|
||||||
|
isLoading,
|
||||||
|
suggestions,
|
||||||
|
}: EditorAIPanelProps) {
|
||||||
|
const [activeTab, setActiveTab] = useState<Tab>('chat')
|
||||||
|
|
||||||
|
if (!isOpen) return null
|
||||||
|
|
||||||
|
const pendingCount = suggestions.filter((s) => s.status === 'pending').length
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex h-full w-[380px] shrink-0 flex-col border-l"
|
||||||
|
style={{
|
||||||
|
background: 'rgba(16, 17, 20, 0.95)',
|
||||||
|
backdropFilter: 'var(--glass-blur)',
|
||||||
|
WebkitBackdropFilter: 'var(--glass-blur)',
|
||||||
|
borderColor: 'var(--glass-border)',
|
||||||
|
animation: 'slideInRight 200ms ease-out',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div
|
||||||
|
className="flex items-center justify-between px-4 py-3 border-b shrink-0"
|
||||||
|
style={{ borderColor: 'var(--glass-border)' }}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Sparkles size={16} className="text-primary" />
|
||||||
|
<span className="text-sm font-semibold text-foreground">AI Assist</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="p-1.5 rounded-lg hover:bg-[rgba(255,255,255,0.06)] text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
<X size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<NodeSummary node={focalNode} flowName={flowName} flowType={flowType} nodeCount={nodeCount} />
|
||||||
|
|
||||||
|
{/* Tabs */}
|
||||||
|
<div className="flex border-b shrink-0" style={{ borderColor: 'var(--glass-border)' }}>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('chat')}
|
||||||
|
className={cn(
|
||||||
|
'flex-1 px-3 py-2 text-xs font-medium transition-colors',
|
||||||
|
activeTab === 'chat'
|
||||||
|
? 'border-b-2 border-primary text-foreground'
|
||||||
|
: 'text-muted-foreground hover:text-foreground'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Chat
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('suggestions')}
|
||||||
|
className={cn(
|
||||||
|
'flex-1 px-3 py-2 text-xs font-medium transition-colors relative',
|
||||||
|
activeTab === 'suggestions'
|
||||||
|
? 'border-b-2 border-primary text-foreground'
|
||||||
|
: 'text-muted-foreground hover:text-foreground'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Suggestions
|
||||||
|
{pendingCount > 0 && (
|
||||||
|
<span className="ml-1.5 inline-flex h-4 min-w-4 items-center justify-center rounded-full bg-primary/20 px-1 text-[0.5625rem] text-primary">
|
||||||
|
{pendingCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{activeTab === 'chat' ? (
|
||||||
|
<ChatTab messages={messages} input={input} onInputChange={onInputChange} onSend={onSend} isLoading={isLoading} />
|
||||||
|
) : (
|
||||||
|
<SuggestionsTab suggestions={suggestions} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
59
frontend/src/components/editor-ai/NodeSummary.tsx
Normal file
59
frontend/src/components/editor-ai/NodeSummary.tsx
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import { HelpCircle, Zap, CheckCircle, FileText, Layout } from 'lucide-react'
|
||||||
|
|
||||||
|
interface NodeSummaryProps {
|
||||||
|
node?: { id: string; type: string; question?: string; title?: string; description?: string } | null
|
||||||
|
flowName?: string
|
||||||
|
flowType?: string
|
||||||
|
nodeCount?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const NODE_ICONS: Record<string, typeof HelpCircle> = {
|
||||||
|
decision: HelpCircle,
|
||||||
|
action: Zap,
|
||||||
|
solution: CheckCircle,
|
||||||
|
}
|
||||||
|
|
||||||
|
const NODE_COLORS: Record<string, string> = {
|
||||||
|
decision: 'text-blue-400',
|
||||||
|
action: 'text-yellow-400',
|
||||||
|
solution: 'text-green-400',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NodeSummary({ node, flowName, flowType, nodeCount }: NodeSummaryProps) {
|
||||||
|
if (!node) {
|
||||||
|
return (
|
||||||
|
<div className="border-b px-3 py-2.5" style={{ borderColor: 'var(--glass-border)' }}>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Layout className="h-3.5 w-3.5 text-muted-foreground" />
|
||||||
|
<span className="text-xs font-medium text-foreground truncate">
|
||||||
|
{flowName || 'Untitled Flow'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 flex items-center gap-3 font-label text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground">
|
||||||
|
<span>{flowType || 'flow'}</span>
|
||||||
|
{nodeCount !== undefined && <span>{nodeCount} nodes</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const Icon = NODE_ICONS[node.type] || FileText
|
||||||
|
const colorClass = NODE_COLORS[node.type] || 'text-muted-foreground'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="border-b px-3 py-2.5" style={{ borderColor: 'var(--glass-border)' }}>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Icon className={`h-3.5 w-3.5 ${colorClass}`} />
|
||||||
|
<span className="font-label text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground">
|
||||||
|
{node.type}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-sm font-medium text-foreground truncate">
|
||||||
|
{node.question || node.title || node.id}
|
||||||
|
</p>
|
||||||
|
{node.description && (
|
||||||
|
<p className="mt-0.5 text-xs text-muted-foreground truncate">{node.description}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
50
frontend/src/components/editor-ai/SuggestionsTab.tsx
Normal file
50
frontend/src/components/editor-ai/SuggestionsTab.tsx
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import { Check, X, Clock } from 'lucide-react'
|
||||||
|
import type { AISuggestion } from '@/types'
|
||||||
|
|
||||||
|
interface SuggestionsTabProps {
|
||||||
|
suggestions: AISuggestion[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_CONFIG = {
|
||||||
|
accepted: { icon: Check, color: 'text-emerald-400', label: 'Accepted' },
|
||||||
|
dismissed: { icon: X, color: 'text-rose-400', label: 'Dismissed' },
|
||||||
|
pending: { icon: Clock, color: 'text-amber-400', label: 'Pending' },
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export function SuggestionsTab({ suggestions }: SuggestionsTabProps) {
|
||||||
|
if (suggestions.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-1 items-center justify-center text-sm text-muted-foreground p-6">
|
||||||
|
No AI suggestions yet
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex-1 overflow-y-auto px-3 py-3 space-y-2">
|
||||||
|
{suggestions.map((s) => {
|
||||||
|
const config = STATUS_CONFIG[s.status]
|
||||||
|
const StatusIcon = config.icon
|
||||||
|
return (
|
||||||
|
<div key={s.id} className="rounded-lg border border-border bg-[rgba(255,255,255,0.02)] px-3 py-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="font-label text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground">
|
||||||
|
{s.action_type.replace(/_/g, ' ')}
|
||||||
|
</span>
|
||||||
|
<span className={`flex items-center gap-1 text-xs ${config.color}`}>
|
||||||
|
<StatusIcon className="h-3 w-3" />
|
||||||
|
{config.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{s.target_node_id && (
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground truncate">Node: {s.target_node_id}</p>
|
||||||
|
)}
|
||||||
|
<p className="mt-0.5 font-label text-[0.625rem] text-[#5a6170]">
|
||||||
|
{new Date(s.created_at).toLocaleDateString()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { LayoutGrid, Box, PenLine, Clock, FileText, Bookmark, BarChart3, Settings, PanelLeftClose, PanelLeftOpen, MessageSquareText, Sparkles, BotMessageSquare, BookOpen } from 'lucide-react'
|
import { LayoutGrid, Box, PenLine, Clock, FileText, Bookmark, BarChart3, Settings, PanelLeftClose, PanelLeftOpen, MessageSquareText, BotMessageSquare, BookOpen } from 'lucide-react'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { useUserPreferencesStore } from '@/store/userPreferencesStore'
|
import { useUserPreferencesStore } from '@/store/userPreferencesStore'
|
||||||
import { usePinnedFlowsStore } from '@/store/pinnedFlowsStore'
|
import { usePinnedFlowsStore } from '@/store/pinnedFlowsStore'
|
||||||
@@ -81,7 +81,6 @@ export function Sidebar() {
|
|||||||
<NavItem href="/my-trees" icon={PenLine} label="Flow Editor" collapsed />
|
<NavItem href="/my-trees" icon={PenLine} label="Flow Editor" collapsed />
|
||||||
<NavItem href="/sessions" icon={Clock} label="Sessions" badge={activeSessionCount || undefined} collapsed />
|
<NavItem href="/sessions" icon={Clock} label="Sessions" badge={activeSessionCount || undefined} collapsed />
|
||||||
<NavItem href="/shares" icon={FileText} label="Exports" collapsed />
|
<NavItem href="/shares" icon={FileText} label="Exports" collapsed />
|
||||||
<NavItem href="/ai/chat" icon={Sparkles} label="Flow Assist" collapsed />
|
|
||||||
<NavItem href="/assistant" icon={BotMessageSquare} label="AI Assistant" collapsed />
|
<NavItem href="/assistant" icon={BotMessageSquare} label="AI Assistant" collapsed />
|
||||||
<NavItem href="/step-library" icon={Bookmark} label="Step Library" collapsed />
|
<NavItem href="/step-library" icon={Bookmark} label="Step Library" collapsed />
|
||||||
<NavItem href="/analytics" icon={BarChart3} label="Analytics" collapsed />
|
<NavItem href="/analytics" icon={BarChart3} label="Analytics" collapsed />
|
||||||
@@ -114,7 +113,6 @@ export function Sidebar() {
|
|||||||
<NavItem href="/my-trees" icon={PenLine} label="Flow Editor" />
|
<NavItem href="/my-trees" icon={PenLine} label="Flow Editor" />
|
||||||
<NavItem href="/sessions" icon={Clock} label="Sessions" badge={activeSessionCount || undefined} />
|
<NavItem href="/sessions" icon={Clock} label="Sessions" badge={activeSessionCount || undefined} />
|
||||||
<NavItem href="/shares" icon={FileText} label="Exports" />
|
<NavItem href="/shares" icon={FileText} label="Exports" />
|
||||||
<NavItem href="/ai/chat" icon={Sparkles} label="Flow Assist" />
|
|
||||||
<NavItem href="/assistant" icon={BotMessageSquare} label="AI Assistant" />
|
<NavItem href="/assistant" icon={BotMessageSquare} label="AI Assistant" />
|
||||||
<NavItem href="/step-library" icon={Bookmark} label="Step Library" />
|
<NavItem href="/step-library" icon={Bookmark} label="Step Library" />
|
||||||
<NavItem href="/analytics" icon={BarChart3} label="Analytics" />
|
<NavItem href="/analytics" icon={BarChart3} label="Analytics" />
|
||||||
|
|||||||
100
frontend/src/components/library/ExportFlowModal.tsx
Normal file
100
frontend/src/components/library/ExportFlowModal.tsx
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { Download, X } from 'lucide-react'
|
||||||
|
import { flowTransferApi } from '@/api/flowTransfer'
|
||||||
|
import { toast } from '@/lib/toast'
|
||||||
|
|
||||||
|
interface ExportFlowModalProps {
|
||||||
|
treeId: string
|
||||||
|
treeName: string
|
||||||
|
onClose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function slugify(name: string): string {
|
||||||
|
return name.toLowerCase().trim().replace(/[^\w\s-]/g, '').replace(/[-\s]+/g, '-')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ExportFlowModal({ treeId, treeName, onClose }: ExportFlowModalProps) {
|
||||||
|
const [isExporting, setIsExporting] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') onClose()
|
||||||
|
}
|
||||||
|
document.addEventListener('keydown', handleKeyDown)
|
||||||
|
return () => document.removeEventListener('keydown', handleKeyDown)
|
||||||
|
}, [onClose])
|
||||||
|
|
||||||
|
const handleExport = async () => {
|
||||||
|
setIsExporting(true)
|
||||||
|
try {
|
||||||
|
const blob = await flowTransferApi.exportFlow(treeId)
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
const a = document.createElement('a')
|
||||||
|
a.href = url
|
||||||
|
a.download = `${slugify(treeName)}.rfflow`
|
||||||
|
document.body.appendChild(a)
|
||||||
|
a.click()
|
||||||
|
document.body.removeChild(a)
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
toast.success('Flow exported successfully')
|
||||||
|
onClose()
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Export failed:', err)
|
||||||
|
toast.error('Failed to export flow')
|
||||||
|
} finally {
|
||||||
|
setIsExporting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="w-full max-w-sm rounded-xl border border-border bg-card shadow-xl"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between border-b border-border px-5 py-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Download className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<h2 className="text-sm font-semibold text-foreground">Export Flow</h2>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
aria-label="Close"
|
||||||
|
className="rounded-md p-1 text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Body */}
|
||||||
|
<div className="px-5 py-4">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Export <span className="font-medium text-foreground">{treeName}</span> as a <code className="text-xs font-label">.rfflow</code> file (JSON format).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="flex justify-end gap-2 border-t border-border px-5 py-3">
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="rounded-lg border border-border px-4 py-2 text-sm text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleExport}
|
||||||
|
disabled={isExporting}
|
||||||
|
className="bg-gradient-brand flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium text-white shadow-lg shadow-primary/20 hover:opacity-90 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
<Download className="h-4 w-4" />
|
||||||
|
{isExporting ? 'Exporting…' : 'Download .rfflow'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
257
frontend/src/components/library/ImportFlowModal.tsx
Normal file
257
frontend/src/components/library/ImportFlowModal.tsx
Normal file
@@ -0,0 +1,257 @@
|
|||||||
|
import { useState, useEffect, useRef } from 'react'
|
||||||
|
import { FileUp, X, AlertTriangle } from 'lucide-react'
|
||||||
|
import { flowTransferApi } from '@/api/flowTransfer'
|
||||||
|
import { parseRFFlowFile, RFFlowParseError } from '@/lib/rfflowParser'
|
||||||
|
import type { RFFlowFile } from '@/types'
|
||||||
|
import { toast } from '@/lib/toast'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import { getTreeEditorPath } from '@/lib/routing'
|
||||||
|
|
||||||
|
interface ImportFlowModalProps {
|
||||||
|
onClose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const TYPE_LABELS: Record<string, string> = {
|
||||||
|
troubleshooting: 'Troubleshooting',
|
||||||
|
procedural: 'Project',
|
||||||
|
maintenance: 'Maintenance',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ImportFlowModal({ onClose }: ImportFlowModalProps) {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
const [step, setStep] = useState<'pick' | 'preview'>('pick')
|
||||||
|
const [parsed, setParsed] = useState<RFFlowFile | null>(null)
|
||||||
|
const [nameOverride, setNameOverride] = useState('')
|
||||||
|
const [parseError, setParseError] = useState<string | null>(null)
|
||||||
|
const [isImporting, setIsImporting] = useState(false)
|
||||||
|
const [isDragging, setIsDragging] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') onClose()
|
||||||
|
}
|
||||||
|
document.addEventListener('keydown', handleKeyDown)
|
||||||
|
return () => document.removeEventListener('keydown', handleKeyDown)
|
||||||
|
}, [onClose])
|
||||||
|
|
||||||
|
const handleFile = async (file: File) => {
|
||||||
|
setParseError(null)
|
||||||
|
|
||||||
|
if (!file.name.endsWith('.rfflow')) {
|
||||||
|
setParseError('File must have .rfflow extension')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const text = await file.text()
|
||||||
|
const data = parseRFFlowFile(text)
|
||||||
|
setParsed(data)
|
||||||
|
setNameOverride(data.flow.name)
|
||||||
|
setStep('preview')
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof RFFlowParseError) {
|
||||||
|
setParseError(err.message)
|
||||||
|
} else {
|
||||||
|
setParseError('Failed to read file')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0]
|
||||||
|
if (file) handleFile(file)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDrop = (e: React.DragEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setIsDragging(false)
|
||||||
|
const file = e.dataTransfer.files[0]
|
||||||
|
if (file) handleFile(file)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleImport = async () => {
|
||||||
|
if (!parsed) return
|
||||||
|
setIsImporting(true)
|
||||||
|
try {
|
||||||
|
const overrideName = nameOverride.trim() !== parsed.flow.name ? nameOverride.trim() : undefined
|
||||||
|
const result = await flowTransferApi.importFlow(parsed, overrideName)
|
||||||
|
toast.success(`Imported "${result.name}" as draft`)
|
||||||
|
onClose()
|
||||||
|
navigate(getTreeEditorPath(result.tree_id, result.tree_type))
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Import failed:', err)
|
||||||
|
toast.error('Failed to import flow')
|
||||||
|
} finally {
|
||||||
|
setIsImporting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="w-full max-w-md rounded-xl border border-border bg-card shadow-xl"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between border-b border-border px-5 py-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<FileUp className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<h2 className="text-sm font-semibold text-foreground">Import Flow</h2>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
aria-label="Close"
|
||||||
|
className="rounded-md p-1 text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Body */}
|
||||||
|
<div className="px-5 py-4">
|
||||||
|
{step === 'pick' && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex flex-col items-center justify-center rounded-lg border-2 border-dashed px-4 py-8 text-center transition-colors cursor-pointer',
|
||||||
|
isDragging
|
||||||
|
? 'border-primary/50 bg-primary/5'
|
||||||
|
: 'border-border hover:border-[rgba(255,255,255,0.12)]'
|
||||||
|
)}
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
onDragOver={(e) => { e.preventDefault(); setIsDragging(true) }}
|
||||||
|
onDragLeave={() => setIsDragging(false)}
|
||||||
|
onDrop={handleDrop}
|
||||||
|
>
|
||||||
|
<FileUp className="mb-2 h-8 w-8 text-muted-foreground" />
|
||||||
|
<p className="text-sm text-foreground">
|
||||||
|
Drop .rfflow file here or <span className="text-primary cursor-pointer">browse</span>
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">JSON format</p>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept=".rfflow"
|
||||||
|
onChange={handleFileChange}
|
||||||
|
className="hidden"
|
||||||
|
/>
|
||||||
|
{parseError && (
|
||||||
|
<div className="flex items-start gap-2 rounded-lg border border-rose-500/20 bg-rose-500/5 px-3 py-2">
|
||||||
|
<AlertTriangle className="mt-0.5 h-4 w-4 flex-shrink-0 text-rose-400" />
|
||||||
|
<p className="text-xs text-rose-400">{parseError}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 'preview' && parsed && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Editable name */}
|
||||||
|
<div>
|
||||||
|
<label htmlFor="import-name" className="mb-1.5 block text-xs font-medium text-muted-foreground">
|
||||||
|
Name
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="import-name"
|
||||||
|
type="text"
|
||||||
|
value={nameOverride}
|
||||||
|
onChange={(e) => setNameOverride(e.target.value)}
|
||||||
|
maxLength={255}
|
||||||
|
className={cn(
|
||||||
|
'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'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Flow info */}
|
||||||
|
<div className="space-y-2 text-xs">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-muted-foreground">Type:</span>
|
||||||
|
<span className="rounded bg-primary/10 px-2 py-0.5 font-label text-primary">
|
||||||
|
{TYPE_LABELS[parsed.flow.tree_type] || parsed.flow.tree_type}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{parsed.flow.description && (
|
||||||
|
<div>
|
||||||
|
<span className="text-muted-foreground">Description:</span>
|
||||||
|
<p className="mt-0.5 text-foreground line-clamp-2">{parsed.flow.description}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{parsed.flow.category && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-muted-foreground">Category:</span>
|
||||||
|
<span className="text-foreground">{parsed.flow.category.name}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{parsed.flow.tags.length > 0 && (
|
||||||
|
<div className="flex flex-wrap items-center gap-1.5">
|
||||||
|
<span className="text-muted-foreground">Tags:</span>
|
||||||
|
{parsed.flow.tags.map((tag) => (
|
||||||
|
<span key={tag} className="rounded bg-card border border-border px-2 py-0.5 font-label text-foreground">
|
||||||
|
{tag}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{parsed.flow.author_name && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-muted-foreground">Original author:</span>
|
||||||
|
<span className="text-foreground">{parsed.flow.author_name}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-muted-foreground">Version:</span>
|
||||||
|
<span className="text-foreground">v{parsed.flow.version}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Flow will be imported as a <span className="font-medium text-foreground">draft</span>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="flex justify-end gap-2 border-t border-border px-5 py-3">
|
||||||
|
{step === 'preview' && (
|
||||||
|
<button
|
||||||
|
onClick={() => { setStep('pick'); setParsed(null); setParseError(null) }}
|
||||||
|
className="mr-auto rounded-lg border border-border px-3 py-2 text-sm text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||||
|
>
|
||||||
|
Back
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="rounded-lg border border-border px-4 py-2 text-sm text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
{step === 'preview' && (
|
||||||
|
<button
|
||||||
|
onClick={handleImport}
|
||||||
|
disabled={isImporting || !nameOverride.trim()}
|
||||||
|
className="bg-gradient-brand flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium text-white shadow-lg shadow-primary/20 hover:opacity-90 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
<FileUp className="h-4 w-4" />
|
||||||
|
{isImporting ? 'Importing…' : 'Import as Draft'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
import { Pencil, Globe, Lock, Trash2, GitBranch, FileText, Wrench, Star } from 'lucide-react'
|
import { Pencil, Globe, Lock, Trash2, GitBranch, FileText, Wrench, Star, Download } from 'lucide-react'
|
||||||
import type { TreeListItem } from '@/types'
|
import type { TreeListItem } from '@/types'
|
||||||
import { TagBadges } from '@/components/common/TagBadges'
|
import { TagBadges } from '@/components/common/TagBadges'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
@@ -13,6 +13,7 @@ interface TreeGridViewProps {
|
|||||||
onFolderCreated: (parentId?: string | null) => void
|
onFolderCreated: (parentId?: string | null) => void
|
||||||
onDeleteTree: (tree: TreeListItem) => void
|
onDeleteTree: (tree: TreeListItem) => void
|
||||||
onForkTree?: (treeId: string) => void
|
onForkTree?: (treeId: string) => void
|
||||||
|
onExportTree?: (treeId: string) => void
|
||||||
pinnedTreeIds?: Set<string>
|
pinnedTreeIds?: Set<string>
|
||||||
onTogglePin?: (treeId: string) => void
|
onTogglePin?: (treeId: string) => void
|
||||||
pinLoadingTreeIds?: Set<string>
|
pinLoadingTreeIds?: Set<string>
|
||||||
@@ -24,6 +25,7 @@ export function TreeGridView({
|
|||||||
onTagClick,
|
onTagClick,
|
||||||
onDeleteTree,
|
onDeleteTree,
|
||||||
onForkTree,
|
onForkTree,
|
||||||
|
onExportTree,
|
||||||
pinnedTreeIds,
|
pinnedTreeIds,
|
||||||
onTogglePin,
|
onTogglePin,
|
||||||
pinLoadingTreeIds,
|
pinLoadingTreeIds,
|
||||||
@@ -111,6 +113,20 @@ export function TreeGridView({
|
|||||||
v{tree.version} · {tree.usage_count} uses
|
v{tree.version} · {tree.usage_count} uses
|
||||||
</span>
|
</span>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
{onExportTree && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onExportTree(tree.id)}
|
||||||
|
className={cn(
|
||||||
|
'rounded-md border border-border p-2 text-muted-foreground',
|
||||||
|
'hover:bg-accent hover:text-foreground'
|
||||||
|
)}
|
||||||
|
title="Export flow"
|
||||||
|
aria-label="Export flow"
|
||||||
|
>
|
||||||
|
<Download className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
{onForkTree && (
|
{onForkTree && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
import { Pencil, Globe, Lock, GitBranch, FileText, Trash2, Wrench, Star } from 'lucide-react'
|
import { Pencil, Globe, Lock, GitBranch, FileText, Trash2, Wrench, Star, Download } from 'lucide-react'
|
||||||
import type { TreeListItem } from '@/types'
|
import type { TreeListItem } from '@/types'
|
||||||
import { TagBadges } from '@/components/common/TagBadges'
|
import { TagBadges } from '@/components/common/TagBadges'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
@@ -13,6 +13,7 @@ interface TreeListViewProps {
|
|||||||
onFolderCreated: (parentId?: string | null) => void
|
onFolderCreated: (parentId?: string | null) => void
|
||||||
onDeleteTree: (tree: TreeListItem) => void
|
onDeleteTree: (tree: TreeListItem) => void
|
||||||
onForkTree?: (treeId: string) => void
|
onForkTree?: (treeId: string) => void
|
||||||
|
onExportTree?: (treeId: string) => void
|
||||||
pinnedTreeIds?: Set<string>
|
pinnedTreeIds?: Set<string>
|
||||||
onTogglePin?: (treeId: string) => void
|
onTogglePin?: (treeId: string) => void
|
||||||
pinLoadingTreeIds?: Set<string>
|
pinLoadingTreeIds?: Set<string>
|
||||||
@@ -24,6 +25,7 @@ export function TreeListView({
|
|||||||
onTagClick,
|
onTagClick,
|
||||||
onDeleteTree,
|
onDeleteTree,
|
||||||
onForkTree,
|
onForkTree,
|
||||||
|
onExportTree,
|
||||||
pinnedTreeIds,
|
pinnedTreeIds,
|
||||||
onTogglePin,
|
onTogglePin,
|
||||||
pinLoadingTreeIds,
|
pinLoadingTreeIds,
|
||||||
@@ -115,6 +117,20 @@ export function TreeListView({
|
|||||||
<Star size={16} fill={pinnedTreeIds?.has(tree.id) ? 'currentColor' : 'none'} />
|
<Star size={16} fill={pinnedTreeIds?.has(tree.id) ? 'currentColor' : 'none'} />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
{onExportTree && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onExportTree(tree.id)}
|
||||||
|
className={cn(
|
||||||
|
'rounded-md border border-border p-1.5 text-muted-foreground',
|
||||||
|
'hover:bg-accent hover:text-foreground'
|
||||||
|
)}
|
||||||
|
title="Export flow"
|
||||||
|
aria-label="Export flow"
|
||||||
|
>
|
||||||
|
<Download className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
{onForkTree && (
|
{onForkTree && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
import { Pencil, Globe, Lock, ChevronUp, ChevronDown, GitBranch, FileText, Trash2, Wrench, Star } from 'lucide-react'
|
import { Pencil, Globe, Lock, ChevronUp, ChevronDown, GitBranch, FileText, Trash2, Wrench, Star, Download } from 'lucide-react'
|
||||||
import type { TreeListItem } from '@/types'
|
import type { TreeListItem } from '@/types'
|
||||||
import { TagBadges } from '@/components/common/TagBadges'
|
import { TagBadges } from '@/components/common/TagBadges'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
@@ -15,6 +15,7 @@ interface TreeTableViewProps {
|
|||||||
onDeleteTree: (tree: TreeListItem) => void
|
onDeleteTree: (tree: TreeListItem) => void
|
||||||
onSortChange?: (sortBy: string) => void
|
onSortChange?: (sortBy: string) => void
|
||||||
onForkTree?: (treeId: string) => void
|
onForkTree?: (treeId: string) => void
|
||||||
|
onExportTree?: (treeId: string) => void
|
||||||
pinnedTreeIds?: Set<string>
|
pinnedTreeIds?: Set<string>
|
||||||
onTogglePin?: (treeId: string) => void
|
onTogglePin?: (treeId: string) => void
|
||||||
pinLoadingTreeIds?: Set<string>
|
pinLoadingTreeIds?: Set<string>
|
||||||
@@ -29,6 +30,7 @@ export function TreeTableView({
|
|||||||
onDeleteTree,
|
onDeleteTree,
|
||||||
onSortChange,
|
onSortChange,
|
||||||
onForkTree,
|
onForkTree,
|
||||||
|
onExportTree,
|
||||||
pinnedTreeIds,
|
pinnedTreeIds,
|
||||||
onTogglePin,
|
onTogglePin,
|
||||||
pinLoadingTreeIds,
|
pinLoadingTreeIds,
|
||||||
@@ -226,6 +228,20 @@ export function TreeTableView({
|
|||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
<div className="flex items-center justify-end gap-2">
|
<div className="flex items-center justify-end gap-2">
|
||||||
|
{onExportTree && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onExportTree(tree.id)}
|
||||||
|
className={cn(
|
||||||
|
'rounded-md border border-border p-1.5 text-muted-foreground',
|
||||||
|
'hover:bg-accent hover:text-foreground'
|
||||||
|
)}
|
||||||
|
title="Export flow"
|
||||||
|
aria-label="Export flow"
|
||||||
|
>
|
||||||
|
<Download className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
{onForkTree && (
|
{onForkTree && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -39,7 +39,11 @@ function SortableStepWrapper({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function StepList() {
|
interface StepListProps {
|
||||||
|
onStepContextMenu?: (e: React.MouseEvent, stepId: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StepList({ onStepContextMenu }: StepListProps) {
|
||||||
const {
|
const {
|
||||||
steps,
|
steps,
|
||||||
intakeForm,
|
intakeForm,
|
||||||
@@ -208,6 +212,7 @@ export function StepList() {
|
|||||||
const contentType = step.content_type || 'action'
|
const contentType = step.content_type || 'action'
|
||||||
const config = contentTypeConfig[contentType]
|
const config = contentTypeConfig[contentType]
|
||||||
const Icon = config.icon
|
const Icon = config.icon
|
||||||
|
const isGhost = !!(step as unknown as Record<string, unknown>)._suggestion
|
||||||
|
|
||||||
if (isExpanded) {
|
if (isExpanded) {
|
||||||
return (
|
return (
|
||||||
@@ -232,10 +237,13 @@ export function StepList() {
|
|||||||
{({ dragHandleProps }) => (
|
{({ dragHandleProps }) => (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'group flex items-center gap-2 rounded-xl border border-border px-3 py-2.5 transition-colors',
|
'group flex flex-col rounded-xl border border-border px-3 py-2.5 transition-colors',
|
||||||
'hover:border-primary/30 hover:bg-accent/50'
|
'hover:border-primary/30 hover:bg-accent/50',
|
||||||
|
isGhost && 'border-l-2 border-dashed !border-l-primary/40 opacity-60'
|
||||||
)}
|
)}
|
||||||
|
onContextMenu={(e) => onStepContextMenu?.(e, step.id)}
|
||||||
>
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="shrink-0 cursor-grab touch-none text-muted-foreground active:cursor-grabbing"
|
className="shrink-0 cursor-grab touch-none text-muted-foreground active:cursor-grabbing"
|
||||||
@@ -280,6 +288,30 @@ export function StepList() {
|
|||||||
<Trash2 className="h-3.5 w-3.5" />
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{isGhost && (
|
||||||
|
<div className="mt-2 flex gap-2 border-t border-border/50 pt-2">
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
// TODO: accept suggestion
|
||||||
|
}}
|
||||||
|
className="flex-1 rounded-md bg-emerald-500/20 px-2 py-1 text-xs text-emerald-400 hover:bg-emerald-500/30 transition-colors"
|
||||||
|
>
|
||||||
|
Accept
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
// TODO: dismiss suggestion
|
||||||
|
}}
|
||||||
|
className="flex-1 rounded-md bg-rose-500/20 px-2 py-1 text-xs text-rose-400 hover:bg-rose-500/30 transition-colors"
|
||||||
|
>
|
||||||
|
Dismiss
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</SortableStepWrapper>
|
</SortableStepWrapper>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -31,9 +31,10 @@ interface FlowCanvasProps {
|
|||||||
selectedNodeId: string | null
|
selectedNodeId: string | null
|
||||||
onNodeSelect: (nodeId: string | null) => void
|
onNodeSelect: (nodeId: string | null) => void
|
||||||
onSelectAnswerType: (nodeId: string, type: 'decision' | 'action' | 'solution') => void
|
onSelectAnswerType: (nodeId: string, type: 'decision' | 'action' | 'solution') => void
|
||||||
|
onNodeContextMenu?: (e: React.MouseEvent, nodeId: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
function FlowCanvasInner({ selectedNodeId, onNodeSelect, onSelectAnswerType }: FlowCanvasProps) {
|
function FlowCanvasInner({ selectedNodeId, onNodeSelect, onSelectAnswerType, onNodeContextMenu }: FlowCanvasProps) {
|
||||||
const { fitView, setCenter } = useReactFlow()
|
const { fitView, setCenter } = useReactFlow()
|
||||||
const { nodes: layoutNodes, edges: layoutEdges, collapsedNodeIds, toggleCollapse, onNodesMeasured } = useTreeLayout()
|
const { nodes: layoutNodes, edges: layoutEdges, collapsedNodeIds, toggleCollapse, onNodesMeasured } = useTreeLayout()
|
||||||
const [minimapVisible, setMinimapVisible] = useState(true)
|
const [minimapVisible, setMinimapVisible] = useState(true)
|
||||||
@@ -46,7 +47,7 @@ function FlowCanvasInner({ selectedNodeId, onNodeSelect, onSelectAnswerType }: F
|
|||||||
return {
|
return {
|
||||||
...n,
|
...n,
|
||||||
selected: n.id === selectedNodeId,
|
selected: n.id === selectedNodeId,
|
||||||
data: { ...data, onToggleCollapse: toggleCollapse },
|
data: { ...data, onToggleCollapse: toggleCollapse, onContextMenu: onNodeContextMenu },
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (n.type === 'answerStub') {
|
if (n.type === 'answerStub') {
|
||||||
@@ -59,7 +60,7 @@ function FlowCanvasInner({ selectedNodeId, onNodeSelect, onSelectAnswerType }: F
|
|||||||
}
|
}
|
||||||
return n
|
return n
|
||||||
})
|
})
|
||||||
}, [layoutNodes, selectedNodeId, toggleCollapse, onSelectAnswerType])
|
}, [layoutNodes, selectedNodeId, toggleCollapse, onSelectAnswerType, onNodeContextMenu])
|
||||||
|
|
||||||
const [nodes, setNodes, onNodesChange] = useNodesState(nodesWithCallbacks)
|
const [nodes, setNodes, onNodesChange] = useNodesState(nodesWithCallbacks)
|
||||||
const [edges, setEdges, onEdgesChange] = useEdgesState(layoutEdges)
|
const [edges, setEdges, onEdgesChange] = useEdgesState(layoutEdges)
|
||||||
|
|||||||
@@ -41,10 +41,14 @@ export interface FlowCanvasNodeData {
|
|||||||
hasValidationErrors: boolean
|
hasValidationErrors: boolean
|
||||||
isNew: boolean
|
isNew: boolean
|
||||||
onToggleCollapse: (nodeId: string) => void
|
onToggleCollapse: (nodeId: string) => void
|
||||||
|
onContextMenu?: (e: React.MouseEvent, nodeId: string) => void
|
||||||
|
onAcceptSuggestion?: (nodeId: string) => void
|
||||||
|
onDismissSuggestion?: (nodeId: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
function FlowCanvasNodeComponent({ data, selected }: NodeProps) {
|
function FlowCanvasNodeComponent({ data, selected }: NodeProps) {
|
||||||
const { node, hasChildren, isCollapsed, hasValidationErrors, isNew, onToggleCollapse } = data as unknown as FlowCanvasNodeData
|
const { node, hasChildren, isCollapsed, hasValidationErrors, isNew, onToggleCollapse, onContextMenu, onAcceptSuggestion, onDismissSuggestion } = data as unknown as FlowCanvasNodeData
|
||||||
|
const isGhost = !!(node as unknown as Record<string, unknown>)._suggestion
|
||||||
const nodeType = node.type as Exclude<NodeType, 'answer'>
|
const nodeType = node.type as Exclude<NodeType, 'answer'>
|
||||||
const config = NODE_TYPE_CONFIG[nodeType] ?? NODE_TYPE_CONFIG.decision
|
const config = NODE_TYPE_CONFIG[nodeType] ?? NODE_TYPE_CONFIG.decision
|
||||||
const Icon = config.icon
|
const Icon = config.icon
|
||||||
@@ -61,10 +65,12 @@ function FlowCanvasNodeComponent({ data, selected }: NodeProps) {
|
|||||||
<Handle type="target" position={Position.Top} className="!bg-border !w-2 !h-2 !border-0" />
|
<Handle type="target" position={Position.Top} className="!bg-border !w-2 !h-2 !border-0" />
|
||||||
|
|
||||||
<div
|
<div
|
||||||
|
onContextMenu={(e) => onContextMenu?.(e, node.id)}
|
||||||
className={cn(
|
className={cn(
|
||||||
'w-[280px] rounded-xl border border-border bg-card shadow-sm cursor-pointer transition-all',
|
'w-[280px] rounded-xl border border-border bg-card shadow-sm cursor-pointer transition-all',
|
||||||
config.borderClass,
|
config.borderClass,
|
||||||
selected && 'ring-1 ring-primary shadow-md'
|
selected && 'ring-1 ring-primary shadow-md',
|
||||||
|
isGhost && 'border-dashed !border-primary/40 opacity-60'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
@@ -142,6 +148,30 @@ function FlowCanvasNodeComponent({ data, selected }: NodeProps) {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Ghost node accept/dismiss overlay */}
|
||||||
|
{isGhost && (
|
||||||
|
<div className="mt-2 flex gap-2 border-t border-border/50 pt-2 px-3 pb-2">
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
onAcceptSuggestion?.(node.id)
|
||||||
|
}}
|
||||||
|
className="flex-1 rounded-md bg-emerald-500/20 px-2 py-1 text-xs text-emerald-400 hover:bg-emerald-500/30 transition-colors"
|
||||||
|
>
|
||||||
|
Accept
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
onDismissSuggestion?.(node.id)
|
||||||
|
}}
|
||||||
|
className="flex-1 rounded-md bg-rose-500/20 px-2 py-1 text-xs text-rose-400 hover:bg-rose-500/30 transition-colors"
|
||||||
|
>
|
||||||
|
Dismiss
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Source handle at bottom */}
|
{/* Source handle at bottom */}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ interface TreeEditorLayoutProps {
|
|||||||
editingNodeId: string | null
|
editingNodeId: string | null
|
||||||
onNodeSelect: (nodeId: string | null) => void
|
onNodeSelect: (nodeId: string | null) => void
|
||||||
onSelectAnswerType: (nodeId: string, type: 'decision' | 'action' | 'solution') => void
|
onSelectAnswerType: (nodeId: string, type: 'decision' | 'action' | 'solution') => void
|
||||||
|
onNodeContextMenu?: (e: React.MouseEvent, nodeId: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TreeEditorLayout({
|
export function TreeEditorLayout({
|
||||||
@@ -28,6 +29,7 @@ export function TreeEditorLayout({
|
|||||||
editingNodeId,
|
editingNodeId,
|
||||||
onNodeSelect,
|
onNodeSelect,
|
||||||
onSelectAnswerType,
|
onSelectAnswerType,
|
||||||
|
onNodeContextMenu,
|
||||||
}: TreeEditorLayoutProps) {
|
}: TreeEditorLayoutProps) {
|
||||||
const editorMode = useTreeEditorStore(s => s.editorMode)
|
const editorMode = useTreeEditorStore(s => s.editorMode)
|
||||||
|
|
||||||
@@ -70,6 +72,7 @@ export function TreeEditorLayout({
|
|||||||
selectedNodeId={editingNodeId}
|
selectedNodeId={editingNodeId}
|
||||||
onNodeSelect={onNodeSelect}
|
onNodeSelect={onNodeSelect}
|
||||||
onSelectAnswerType={onSelectAnswerType}
|
onSelectAnswerType={onSelectAnswerType}
|
||||||
|
onNodeContextMenu={onNodeContextMenu}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
161
frontend/src/hooks/useEditorAI.ts
Normal file
161
frontend/src/hooks/useEditorAI.ts
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
import { useState, useCallback, useRef } from 'react'
|
||||||
|
import { editorAIApi } from '@/api/editorAI'
|
||||||
|
import type {
|
||||||
|
AIActionType,
|
||||||
|
EditorAIChatMessage,
|
||||||
|
AISuggestion,
|
||||||
|
ContextMenuPosition,
|
||||||
|
} from '@/types'
|
||||||
|
|
||||||
|
interface UseEditorAIOptions {
|
||||||
|
flowType: 'troubleshooting' | 'procedural'
|
||||||
|
treeId?: string | null
|
||||||
|
/** Returns the live flow structure from the editor for AI context */
|
||||||
|
getFlowContext?: () => Record<string, unknown> | null
|
||||||
|
/** Called when the AI response contains a working_tree update */
|
||||||
|
onFlowUpdate?: (workingTree: Record<string, unknown>, metadata?: Record<string, unknown> | null) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useEditorAI({ flowType, treeId, getFlowContext, onFlowUpdate }: UseEditorAIOptions) {
|
||||||
|
const [isOpen, setIsOpen] = useState(false)
|
||||||
|
const [focalNodeId, setFocalNodeId] = useState<string | null>(null)
|
||||||
|
const [contextMenu, setContextMenu] = useState<{
|
||||||
|
position: ContextMenuPosition
|
||||||
|
nodeId: string
|
||||||
|
} | null>(null)
|
||||||
|
const [sessionId, setSessionId] = useState<string | null>(null)
|
||||||
|
const [messages, setMessages] = useState<EditorAIChatMessage[]>([])
|
||||||
|
const [input, setInput] = useState('')
|
||||||
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
|
const [suggestions, setSuggestions] = useState<AISuggestion[]>([])
|
||||||
|
|
||||||
|
const pendingActionRef = useRef<AIActionType>('open_chat')
|
||||||
|
|
||||||
|
const ensureSession = useCallback(async () => {
|
||||||
|
if (sessionId) return sessionId
|
||||||
|
try {
|
||||||
|
const result = await editorAIApi.startSession(flowType, treeId || undefined)
|
||||||
|
setSessionId(result.session_id)
|
||||||
|
if (result.greeting) {
|
||||||
|
setMessages((prev) => [
|
||||||
|
...prev,
|
||||||
|
{
|
||||||
|
role: 'assistant' as const,
|
||||||
|
content: result.greeting,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
])
|
||||||
|
}
|
||||||
|
return result.session_id
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}, [sessionId, flowType, treeId])
|
||||||
|
|
||||||
|
const openPanel = useCallback((nodeId?: string, actionType?: AIActionType) => {
|
||||||
|
setIsOpen(true)
|
||||||
|
if (nodeId) setFocalNodeId(nodeId)
|
||||||
|
if (actionType) pendingActionRef.current = actionType
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const closePanel = useCallback(() => {
|
||||||
|
setIsOpen(false)
|
||||||
|
setContextMenu(null)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const openContextMenu = useCallback((e: React.MouseEvent, nodeId: string) => {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
setContextMenu({ position: { x: e.clientX, y: e.clientY }, nodeId })
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const closeContextMenu = useCallback(() => {
|
||||||
|
setContextMenu(null)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const sendMessage = useCallback(async () => {
|
||||||
|
if (!input.trim() || isLoading) return
|
||||||
|
|
||||||
|
const currentInput = input
|
||||||
|
const currentAction = pendingActionRef.current
|
||||||
|
const currentFocalNodeId = focalNodeId
|
||||||
|
|
||||||
|
const userMessage: EditorAIChatMessage = {
|
||||||
|
role: 'user',
|
||||||
|
content: currentInput,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
action_type: currentAction,
|
||||||
|
}
|
||||||
|
setMessages((prev) => [...prev, userMessage])
|
||||||
|
setInput('')
|
||||||
|
setIsLoading(true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const sid = await ensureSession()
|
||||||
|
if (!sid) return
|
||||||
|
|
||||||
|
const result = await editorAIApi.sendMessage({
|
||||||
|
sessionId: sid,
|
||||||
|
content: currentInput,
|
||||||
|
actionType: currentAction,
|
||||||
|
focalNodeId: currentFocalNodeId,
|
||||||
|
flowContext: getFlowContext?.() || null,
|
||||||
|
})
|
||||||
|
|
||||||
|
setMessages((prev) => [
|
||||||
|
...prev,
|
||||||
|
{
|
||||||
|
role: 'assistant',
|
||||||
|
content: result.content,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
// Apply AI-generated flow structure to the editor
|
||||||
|
if (result.working_tree && onFlowUpdate) {
|
||||||
|
onFlowUpdate(result.working_tree, result.tree_metadata || null)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setMessages((prev) => [
|
||||||
|
...prev,
|
||||||
|
{
|
||||||
|
role: 'assistant',
|
||||||
|
content: 'Sorry, something went wrong. Please try again.',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
])
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false)
|
||||||
|
pendingActionRef.current = 'open_chat'
|
||||||
|
}
|
||||||
|
}, [input, isLoading, ensureSession, focalNodeId, getFlowContext, onFlowUpdate])
|
||||||
|
|
||||||
|
const triggerAction = useCallback(
|
||||||
|
(nodeId: string, actionType: AIActionType, prompt: string) => {
|
||||||
|
setFocalNodeId(nodeId)
|
||||||
|
pendingActionRef.current = actionType
|
||||||
|
setInput(prompt)
|
||||||
|
setIsOpen(true)
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
isOpen,
|
||||||
|
openPanel,
|
||||||
|
closePanel,
|
||||||
|
focalNodeId,
|
||||||
|
setFocalNodeId,
|
||||||
|
contextMenu,
|
||||||
|
openContextMenu,
|
||||||
|
closeContextMenu,
|
||||||
|
messages,
|
||||||
|
input,
|
||||||
|
setInput,
|
||||||
|
sendMessage,
|
||||||
|
isLoading,
|
||||||
|
suggestions,
|
||||||
|
setSuggestions,
|
||||||
|
triggerAction,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -162,6 +162,11 @@
|
|||||||
to { transform: translateY(0); opacity: 1; }
|
to { transform: translateY(0); opacity: 1; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@keyframes slideInRight {
|
||||||
|
from { transform: translateX(100%); }
|
||||||
|
to { transform: translateX(0); }
|
||||||
|
}
|
||||||
|
|
||||||
@keyframes fadeInRight {
|
@keyframes fadeInRight {
|
||||||
from { transform: translateX(30px); opacity: 0; }
|
from { transform: translateX(30px); opacity: 0; }
|
||||||
to { transform: translateX(0); opacity: 1; }
|
to { transform: translateX(0); opacity: 1; }
|
||||||
|
|||||||
52
frontend/src/lib/rfflowParser.ts
Normal file
52
frontend/src/lib/rfflowParser.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import type { RFFlowFile, FlowExportData } from '@/types'
|
||||||
|
|
||||||
|
export class RFFlowParseError extends Error {
|
||||||
|
constructor(message: string) {
|
||||||
|
super(message)
|
||||||
|
this.name = 'RFFlowParseError'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseRFFlowFile(content: string): RFFlowFile {
|
||||||
|
const trimmed = content.trim()
|
||||||
|
|
||||||
|
if (!trimmed.startsWith('{')) {
|
||||||
|
throw new RFFlowParseError('Invalid file format. Expected JSON.')
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(trimmed)
|
||||||
|
validateEnvelope(data)
|
||||||
|
return data as RFFlowFile
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof RFFlowParseError) throw err
|
||||||
|
throw new RFFlowParseError(`Invalid JSON: ${(err as Error).message}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateEnvelope(data: unknown): asserts data is RFFlowFile {
|
||||||
|
const obj = data as Record<string, unknown>
|
||||||
|
|
||||||
|
if (!obj.rfflow_version) {
|
||||||
|
throw new RFFlowParseError('Missing rfflow_version')
|
||||||
|
}
|
||||||
|
if (obj.rfflow_version !== '1.0') {
|
||||||
|
throw new RFFlowParseError(`Unsupported version: ${obj.rfflow_version}. Only 1.0 is supported.`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const flow = obj.flow as Record<string, unknown> | undefined
|
||||||
|
if (!flow) {
|
||||||
|
throw new RFFlowParseError('Missing flow data')
|
||||||
|
}
|
||||||
|
if (!flow.name) {
|
||||||
|
throw new RFFlowParseError('Flow must have a name')
|
||||||
|
}
|
||||||
|
if (!flow.tree_structure) {
|
||||||
|
throw new RFFlowParseError('Flow must have a tree_structure')
|
||||||
|
}
|
||||||
|
|
||||||
|
const validTypes: FlowExportData['tree_type'][] = ['troubleshooting', 'procedural', 'maintenance']
|
||||||
|
if (!validTypes.includes(flow.tree_type as FlowExportData['tree_type'])) {
|
||||||
|
throw new RFFlowParseError(`Invalid tree_type: ${flow.tree_type}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,158 +0,0 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react'
|
|
||||||
import { useNavigate, useSearchParams } from 'react-router-dom'
|
|
||||||
import { useAIChatStore } from '@/store/aiChatStore'
|
|
||||||
import { ChatPanel } from '@/components/ai-chat/ChatPanel'
|
|
||||||
import { ChatToolbar } from '@/components/ai-chat/ChatToolbar'
|
|
||||||
import { EmptyPreview } from '@/components/ai-chat/EmptyPreview'
|
|
||||||
import { StaticTreePreview } from '@/components/ai-chat/StaticTreePreview'
|
|
||||||
import { Spinner } from '@/components/common/Spinner'
|
|
||||||
import { getTreeEditorPath } from '@/lib/routing'
|
|
||||||
import { toast } from '@/lib/toast'
|
|
||||||
import type { TreeStructure } from '@/types'
|
|
||||||
|
|
||||||
export function AIChatBuilderPage() {
|
|
||||||
const navigate = useNavigate()
|
|
||||||
const [searchParams, setSearchParams] = useSearchParams()
|
|
||||||
const flowType = searchParams.get('type') === 'procedural' ? 'procedural' : 'troubleshooting'
|
|
||||||
|
|
||||||
const {
|
|
||||||
sessionId,
|
|
||||||
status,
|
|
||||||
currentPhase,
|
|
||||||
messages,
|
|
||||||
isResponding,
|
|
||||||
workingTree,
|
|
||||||
treeMetadata,
|
|
||||||
generatedTree,
|
|
||||||
isGenerating,
|
|
||||||
error,
|
|
||||||
startSession,
|
|
||||||
sendMessage,
|
|
||||||
generateTree,
|
|
||||||
importToEditor,
|
|
||||||
abandonSession,
|
|
||||||
resumeSession,
|
|
||||||
} = useAIChatStore()
|
|
||||||
|
|
||||||
// Start or resume session on mount
|
|
||||||
useEffect(() => {
|
|
||||||
const resumeId = searchParams.get('session')
|
|
||||||
if (resumeId && !sessionId) {
|
|
||||||
resumeSession(resumeId)
|
|
||||||
} else if (!sessionId && status === 'idle') {
|
|
||||||
startSession(flowType)
|
|
||||||
}
|
|
||||||
}, []) // eslint-disable-line react-hooks/exhaustive-deps
|
|
||||||
|
|
||||||
// Store sessionId in URL for resume support
|
|
||||||
useEffect(() => {
|
|
||||||
if (sessionId && !searchParams.get('session')) {
|
|
||||||
setSearchParams((prev) => {
|
|
||||||
const next = new URLSearchParams(prev)
|
|
||||||
next.set('session', sessionId)
|
|
||||||
return next
|
|
||||||
}, { replace: true })
|
|
||||||
}
|
|
||||||
}, [sessionId, searchParams, setSearchParams])
|
|
||||||
|
|
||||||
const handleSendMessage = useCallback(
|
|
||||||
(content: string) => {
|
|
||||||
sendMessage(content)
|
|
||||||
},
|
|
||||||
[sendMessage]
|
|
||||||
)
|
|
||||||
|
|
||||||
const [isSaving, setIsSaving] = useState(false)
|
|
||||||
|
|
||||||
const handleGenerate = useCallback(() => {
|
|
||||||
generateTree()
|
|
||||||
}, [generateTree])
|
|
||||||
|
|
||||||
const handleSave = useCallback(async () => {
|
|
||||||
if (isSaving) return
|
|
||||||
setIsSaving(true)
|
|
||||||
try {
|
|
||||||
const treeId = await importToEditor({
|
|
||||||
name: treeMetadata?.name,
|
|
||||||
description: treeMetadata?.description,
|
|
||||||
tags: treeMetadata?.tags,
|
|
||||||
})
|
|
||||||
const path = getTreeEditorPath(treeId, flowType)
|
|
||||||
navigate(path)
|
|
||||||
toast.success('Flow saved to library')
|
|
||||||
} catch {
|
|
||||||
toast.error('Failed to save flow')
|
|
||||||
} finally {
|
|
||||||
setIsSaving(false)
|
|
||||||
}
|
|
||||||
}, [isSaving, importToEditor, treeMetadata, flowType, navigate])
|
|
||||||
|
|
||||||
const handleReset = useCallback(async () => {
|
|
||||||
await abandonSession()
|
|
||||||
// Clear session from URL
|
|
||||||
setSearchParams((prev) => {
|
|
||||||
const next = new URLSearchParams(prev)
|
|
||||||
next.delete('session')
|
|
||||||
return next
|
|
||||||
}, { replace: true })
|
|
||||||
startSession(flowType)
|
|
||||||
}, [abandonSession, startSession, flowType, setSearchParams])
|
|
||||||
|
|
||||||
// Show error toast
|
|
||||||
useEffect(() => {
|
|
||||||
if (error) {
|
|
||||||
toast.error(error)
|
|
||||||
}
|
|
||||||
}, [error])
|
|
||||||
|
|
||||||
if (status === 'idle' && !sessionId) {
|
|
||||||
return (
|
|
||||||
<div className="flex h-full items-center justify-center">
|
|
||||||
<Spinner />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const previewTree = (generatedTree || workingTree) as TreeStructure | null
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex h-full flex-col">
|
|
||||||
<ChatToolbar
|
|
||||||
currentPhase={currentPhase}
|
|
||||||
status={status}
|
|
||||||
isGenerating={isGenerating}
|
|
||||||
hasGeneratedTree={!!generatedTree}
|
|
||||||
isSaving={isSaving}
|
|
||||||
onGenerate={handleGenerate}
|
|
||||||
onSave={handleSave}
|
|
||||||
onReset={handleReset}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="flex flex-1 overflow-hidden">
|
|
||||||
{/* Left panel: Chat (60%) */}
|
|
||||||
<div className="flex w-3/5 flex-col border-r border-border max-lg:w-full">
|
|
||||||
<ChatPanel
|
|
||||||
messages={messages}
|
|
||||||
isResponding={isResponding}
|
|
||||||
onSendMessage={handleSendMessage}
|
|
||||||
disabled={status !== 'active'}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Right panel: Tree preview (40%) — hidden below 1024px */}
|
|
||||||
<div className="w-2/5 overflow-hidden bg-background max-lg:hidden">
|
|
||||||
{previewTree ? (
|
|
||||||
<StaticTreePreview
|
|
||||||
tree={previewTree}
|
|
||||||
name={treeMetadata?.name}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<EmptyPreview />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default AIChatBuilderPage
|
|
||||||
@@ -233,7 +233,8 @@ export default function AssistantChatPage() {
|
|||||||
|
|
||||||
{/* Input */}
|
{/* Input */}
|
||||||
<div className="px-6 py-4 border-t shrink-0" style={{ borderColor: 'var(--glass-border)' }}>
|
<div className="px-6 py-4 border-t shrink-0" style={{ borderColor: 'var(--glass-border)' }}>
|
||||||
<div className="flex items-end gap-3 max-w-3xl mx-auto">
|
<div className="max-w-3xl mx-auto">
|
||||||
|
<div className="flex items-end gap-3">
|
||||||
<textarea
|
<textarea
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
value={input}
|
value={input}
|
||||||
@@ -267,6 +268,8 @@ export default function AssistantChatPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<p className="text-[0.625rem] text-muted-foreground mt-1.5 px-1">Shift + Enter for a new line</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { useNavigate, Link } from 'react-router-dom'
|
import { useNavigate, Link } from 'react-router-dom'
|
||||||
import { Play, Pencil, Share2, Trash2, GitBranch, Clock, TrendingUp, FolderTree, Plus, ListOrdered, ChevronDown, Wrench, Sparkles } from 'lucide-react'
|
import { Play, Pencil, Share2, Trash2, GitBranch, Clock, TrendingUp, FolderTree, Plus, ListOrdered, ChevronDown, Wrench } from 'lucide-react'
|
||||||
import { treesApi } from '@/api/trees'
|
import { treesApi } from '@/api/trees'
|
||||||
import { sessionsApi } from '@/api/sessions'
|
import { sessionsApi } from '@/api/sessions'
|
||||||
import type { TreeListItem } from '@/types'
|
import type { TreeListItem } from '@/types'
|
||||||
@@ -12,8 +12,6 @@ import { cn } from '@/lib/utils'
|
|||||||
import { useAuthStore } from '@/store/authStore'
|
import { useAuthStore } from '@/store/authStore'
|
||||||
import { usePermissions } from '@/hooks/usePermissions'
|
import { usePermissions } from '@/hooks/usePermissions'
|
||||||
import { toast } from '@/lib/toast'
|
import { toast } from '@/lib/toast'
|
||||||
import { AIFlowBuilderModal } from '@/components/ai-builder/AIFlowBuilderModal'
|
|
||||||
import { aiBuilderApi } from '@/api/aiBuilder'
|
|
||||||
import { ForkModal } from '@/components/library/ForkModal'
|
import { ForkModal } from '@/components/library/ForkModal'
|
||||||
|
|
||||||
interface TreeWithStats extends TreeListItem {
|
interface TreeWithStats extends TreeListItem {
|
||||||
@@ -35,18 +33,12 @@ export function MyTreesPage() {
|
|||||||
const [treeToShare, setTreeToShare] = useState<TreeWithStats | null>(null)
|
const [treeToShare, setTreeToShare] = useState<TreeWithStats | null>(null)
|
||||||
const [showShareModal, setShowShareModal] = useState(false)
|
const [showShareModal, setShowShareModal] = useState(false)
|
||||||
const [showCreateMenu, setShowCreateMenu] = useState(false)
|
const [showCreateMenu, setShowCreateMenu] = useState(false)
|
||||||
const [showAIBuilder, setShowAIBuilder] = useState(false)
|
|
||||||
const [aiEnabled, setAiEnabled] = useState(false)
|
|
||||||
const [forkTarget, setForkTarget] = useState<TreeWithStats | null>(null)
|
const [forkTarget, setForkTarget] = useState<TreeWithStats | null>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadMyTrees()
|
loadMyTrees()
|
||||||
}, [user?.id])
|
}, [user?.id])
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
aiBuilderApi.getQuota().then((q) => setAiEnabled(q.ai_enabled)).catch(() => {})
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const loadMyTrees = async () => {
|
const loadMyTrees = async () => {
|
||||||
if (!user?.id) return
|
if (!user?.id) return
|
||||||
setIsLoading(true)
|
setIsLoading(true)
|
||||||
@@ -178,25 +170,6 @@ export function MyTreesPage() {
|
|||||||
<div className="text-xs text-muted-foreground">Scheduled multi-target tasks</div>
|
<div className="text-xs text-muted-foreground">Scheduled multi-target tasks</div>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</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">Flow Assist</div>
|
|
||||||
<div className="text-xs text-muted-foreground">AI-powered flow builder</div>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -425,11 +398,6 @@ export function MyTreesPage() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* AI Flow Builder Modal */}
|
|
||||||
<AIFlowBuilderModal
|
|
||||||
isOpen={showAIBuilder}
|
|
||||||
onClose={() => setShowAIBuilder(false)}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useState, useCallback } from 'react'
|
import { useEffect, useState, useCallback } from 'react'
|
||||||
import { useParams, useNavigate, useSearchParams } from 'react-router-dom'
|
import { useParams, useNavigate, useSearchParams } from 'react-router-dom'
|
||||||
import { Save, ArrowLeft, ListOrdered, Wrench, Settings, FileText, Calendar } from 'lucide-react'
|
import { Save, ArrowLeft, ListOrdered, Wrench, Settings, FileText, Calendar, Sparkles, Layers } from 'lucide-react'
|
||||||
import { treesApi } from '@/api/trees'
|
import { treesApi } from '@/api/trees'
|
||||||
import { useProceduralEditorStore } from '@/store/proceduralEditorStore'
|
import { useProceduralEditorStore } from '@/store/proceduralEditorStore'
|
||||||
import { CollapsibleEditorSection } from '@/components/procedural-editor/CollapsibleEditorSection'
|
import { CollapsibleEditorSection } from '@/components/procedural-editor/CollapsibleEditorSection'
|
||||||
@@ -10,8 +10,12 @@ import { getScheduleSummary } from '@/components/procedural-editor/scheduleUtils
|
|||||||
import { StepList } from '@/components/procedural-editor/StepList'
|
import { StepList } from '@/components/procedural-editor/StepList'
|
||||||
import { TagInput } from '@/components/common/TagInput'
|
import { TagInput } from '@/components/common/TagInput'
|
||||||
import { Spinner } from '@/components/common/Spinner'
|
import { Spinner } from '@/components/common/Spinner'
|
||||||
|
import { EditorAIPanel } from '@/components/editor-ai/EditorAIPanel'
|
||||||
|
import { ContextMenu } from '@/components/common/ContextMenu'
|
||||||
|
import { useEditorAI } from '@/hooks/useEditorAI'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
import { toast } from '@/lib/toast'
|
import { toast } from '@/lib/toast'
|
||||||
import type { TreeType, MaintenanceSchedule, TargetList } from '@/types'
|
import type { TreeType, MaintenanceSchedule, TargetList, ProceduralStep, IntakeFormField } from '@/types'
|
||||||
|
|
||||||
type SectionKey = 'details' | 'intake' | 'schedule'
|
type SectionKey = 'details' | 'intake' | 'schedule'
|
||||||
|
|
||||||
@@ -42,8 +46,34 @@ export function ProceduralEditorPage() {
|
|||||||
setIsSaving,
|
setIsSaving,
|
||||||
markSaved,
|
markSaved,
|
||||||
getTreeForSave,
|
getTreeForSave,
|
||||||
|
replaceSteps,
|
||||||
} = useProceduralEditorStore()
|
} = useProceduralEditorStore()
|
||||||
|
|
||||||
|
const steps = useProceduralEditorStore(s => s.steps)
|
||||||
|
|
||||||
|
const handleFlowUpdate = useCallback((workingTree: Record<string, unknown>, metadata?: Record<string, unknown> | null) => {
|
||||||
|
const stepsData = workingTree.steps as ProceduralStep[] | undefined
|
||||||
|
if (stepsData && Array.isArray(stepsData)) {
|
||||||
|
// Intake form may be in working_tree or in metadata
|
||||||
|
const intakeData = (workingTree.intake_form || metadata?.intake_form) as IntakeFormField[] | undefined
|
||||||
|
replaceSteps(stepsData, intakeData)
|
||||||
|
}
|
||||||
|
}, [replaceSteps])
|
||||||
|
|
||||||
|
const editorAI = useEditorAI({
|
||||||
|
flowType: 'procedural',
|
||||||
|
treeId: id,
|
||||||
|
getFlowContext: useCallback(() => {
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
steps: steps as unknown as Record<string, unknown>[],
|
||||||
|
intake_form: intakeForm,
|
||||||
|
}
|
||||||
|
}, [steps, intakeForm, name, description]),
|
||||||
|
onFlowUpdate: handleFlowUpdate,
|
||||||
|
})
|
||||||
|
|
||||||
const isMaintenance = treeType === 'maintenance'
|
const isMaintenance = treeType === 'maintenance'
|
||||||
const flowLabel = isMaintenance ? 'Maintenance Flow' : 'Procedure'
|
const flowLabel = isMaintenance ? 'Maintenance Flow' : 'Procedure'
|
||||||
|
|
||||||
@@ -150,7 +180,9 @@ export function ProceduralEditorPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col overflow-hidden">
|
<div className="flex h-full overflow-hidden">
|
||||||
|
{/* Main content column */}
|
||||||
|
<div className="flex min-w-0 flex-1 flex-col overflow-hidden">
|
||||||
{/* Toolbar — sticky */}
|
{/* Toolbar — sticky */}
|
||||||
<div className="flex shrink-0 items-center justify-between border-b border-border bg-card px-4 py-2">
|
<div className="flex shrink-0 items-center justify-between border-b border-border bg-card px-4 py-2">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
@@ -175,6 +207,19 @@ export function ProceduralEditorPage() {
|
|||||||
{isDirty && (
|
{isDirty && (
|
||||||
<span className="text-xs text-muted-foreground">Unsaved changes</span>
|
<span className="text-xs text-muted-foreground">Unsaved changes</span>
|
||||||
)}
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => editorAI.isOpen ? editorAI.closePanel() : editorAI.openPanel()}
|
||||||
|
title="Toggle AI Assist panel"
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-1.5 rounded-md border border-border px-3 py-2 text-sm font-medium transition-colors',
|
||||||
|
editorAI.isOpen
|
||||||
|
? 'bg-primary/10 text-primary border-primary/30'
|
||||||
|
: 'text-muted-foreground hover:bg-accent hover:text-foreground'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Sparkles className="h-4 w-4" />
|
||||||
|
AI Assist
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleSave('draft')}
|
onClick={() => handleSave('draft')}
|
||||||
disabled={isSaving}
|
disabled={isSaving}
|
||||||
@@ -272,10 +317,55 @@ export function ProceduralEditorPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Step List — flex-1, scrolls independently */}
|
{/* Step List */}
|
||||||
<div className="min-h-0 flex-1 overflow-y-auto px-4 py-4">
|
<div className="min-h-0 flex-1 overflow-y-auto px-4 py-4">
|
||||||
<StepList />
|
<StepList onStepContextMenu={editorAI.openContextMenu} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{editorAI.contextMenu && (
|
||||||
|
<ContextMenu
|
||||||
|
position={editorAI.contextMenu.position}
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
id: 'generate-steps',
|
||||||
|
label: 'Generate Steps After',
|
||||||
|
icon: <Sparkles className="h-4 w-4" />,
|
||||||
|
onClick: () => editorAI.triggerAction(
|
||||||
|
editorAI.contextMenu!.nodeId,
|
||||||
|
'add_steps',
|
||||||
|
`Generate steps after this step`
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'expand-step',
|
||||||
|
label: 'Expand Step',
|
||||||
|
icon: <Layers className="h-4 w-4" />,
|
||||||
|
onClick: () => editorAI.triggerAction(
|
||||||
|
editorAI.contextMenu!.nodeId,
|
||||||
|
'quick_action',
|
||||||
|
`Expand this step into detailed substeps`
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
onClose={editorAI.closeContextMenu}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>{/* end main content column */}
|
||||||
|
|
||||||
|
<EditorAIPanel
|
||||||
|
isOpen={editorAI.isOpen}
|
||||||
|
onClose={editorAI.closePanel}
|
||||||
|
focalNode={null}
|
||||||
|
flowName={name}
|
||||||
|
flowType={isMaintenance ? 'maintenance' : 'procedural'}
|
||||||
|
nodeCount={steps.length}
|
||||||
|
messages={editorAI.messages}
|
||||||
|
input={editorAI.input}
|
||||||
|
onInputChange={editorAI.setInput}
|
||||||
|
onSend={editorAI.sendMessage}
|
||||||
|
isLoading={editorAI.isLoading}
|
||||||
|
suggestions={editorAI.suggestions}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -599,7 +599,7 @@ export function ProceduralNavigationPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right panel - step detail */}
|
{/* Right panel - step detail + copilot */}
|
||||||
<div className="min-h-0 flex-1 overflow-y-auto overscroll-y-contain p-4 sm:p-6">
|
<div className="min-h-0 flex-1 overflow-y-auto overscroll-y-contain p-4 sm:p-6">
|
||||||
{currentStep && (
|
{currentStep && (
|
||||||
<StepDetail
|
<StepDetail
|
||||||
@@ -636,6 +636,17 @@ export function ProceduralNavigationPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* AI Copilot - in-flow panel */}
|
||||||
|
{treeId && copilotOpen && (
|
||||||
|
<CopilotPanel
|
||||||
|
isOpen={copilotOpen}
|
||||||
|
onClose={() => setCopilotOpen(false)}
|
||||||
|
treeId={treeId}
|
||||||
|
sessionId={session?.id}
|
||||||
|
currentNodeId={runtimeSteps[currentStepIndex]?.id}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* CSAT Modal */}
|
{/* CSAT Modal */}
|
||||||
@@ -708,18 +719,9 @@ export function ProceduralNavigationPage() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* AI Copilot */}
|
{/* AI Copilot toggle button */}
|
||||||
{treeId && (
|
{treeId && (
|
||||||
<>
|
|
||||||
<CopilotToggle isOpen={copilotOpen} onToggle={() => setCopilotOpen(true)} />
|
<CopilotToggle isOpen={copilotOpen} onToggle={() => setCopilotOpen(true)} />
|
||||||
<CopilotPanel
|
|
||||||
isOpen={copilotOpen}
|
|
||||||
onClose={() => setCopilotOpen(false)}
|
|
||||||
treeId={treeId}
|
|
||||||
sessionId={session?.id}
|
|
||||||
currentNodeId={runtimeSteps[currentStepIndex]?.id}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import { TreeGridView } from '@/components/library/TreeGridView'
|
|||||||
import { TreeListView } from '@/components/library/TreeListView'
|
import { TreeListView } from '@/components/library/TreeListView'
|
||||||
import { TreeTableView } from '@/components/library/TreeTableView'
|
import { TreeTableView } from '@/components/library/TreeTableView'
|
||||||
import { ViewToggle } from '@/components/library/ViewToggle'
|
import { ViewToggle } from '@/components/library/ViewToggle'
|
||||||
import { AIFlowBuilderModal } from '@/components/ai-builder/AIFlowBuilderModal'
|
|
||||||
import { CreateFlowDropdown } from '@/components/common/CreateFlowDropdown'
|
import { CreateFlowDropdown } from '@/components/common/CreateFlowDropdown'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { toast } from '@/lib/toast'
|
import { toast } from '@/lib/toast'
|
||||||
@@ -67,7 +67,6 @@ export function QuickStartPage() {
|
|||||||
const [showAllFavorites, setShowAllFavorites] = useState(false)
|
const [showAllFavorites, setShowAllFavorites] = useState(false)
|
||||||
|
|
||||||
// AI Builder
|
// AI Builder
|
||||||
const [showAIBuilder, setShowAIBuilder] = useState(false)
|
|
||||||
const { aiEnabled } = useCachedQuota()
|
const { aiEnabled } = useCachedQuota()
|
||||||
|
|
||||||
// Tab state
|
// Tab state
|
||||||
@@ -454,7 +453,7 @@ export function QuickStartPage() {
|
|||||||
{activeTab === 'mine' && canCreateTrees && (
|
{activeTab === 'mine' && canCreateTrees && (
|
||||||
<CreateFlowDropdown
|
<CreateFlowDropdown
|
||||||
aiEnabled={aiEnabled}
|
aiEnabled={aiEnabled}
|
||||||
onOpenAIBuilder={() => setShowAIBuilder(true)}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<ViewToggle view={dashboardMyFlowsView} onChange={setDashboardMyFlowsView} />
|
<ViewToggle view={dashboardMyFlowsView} onChange={setDashboardMyFlowsView} />
|
||||||
@@ -481,7 +480,7 @@ export function QuickStartPage() {
|
|||||||
{activeTab === 'mine' && canCreateTrees && (
|
{activeTab === 'mine' && canCreateTrees && (
|
||||||
<CreateFlowDropdown
|
<CreateFlowDropdown
|
||||||
aiEnabled={aiEnabled}
|
aiEnabled={aiEnabled}
|
||||||
onOpenAIBuilder={() => setShowAIBuilder(true)}
|
|
||||||
label="Create your first flow"
|
label="Create your first flow"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -646,13 +645,6 @@ export function QuickStartPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* AI Builder Modal */}
|
|
||||||
{showAIBuilder && (
|
|
||||||
<AIFlowBuilderModal
|
|
||||||
isOpen={showAIBuilder}
|
|
||||||
onClose={() => setShowAIBuilder(false)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useEffect, useState, useCallback } from 'react'
|
import { useEffect, useState, useCallback, useRef } from 'react'
|
||||||
import { useParams, useNavigate, useBlocker } from 'react-router-dom'
|
import { useParams, useNavigate, useBlocker } from 'react-router-dom'
|
||||||
import { useStore } from 'zustand'
|
import { useStore } from 'zustand'
|
||||||
import { Undo2, Redo2, Save, CheckCircle2, Monitor, FileText, Code2, LayoutList, BarChart3, Settings } from 'lucide-react'
|
import { Undo2, Redo2, Save, CheckCircle2, Monitor, FileText, Code2, LayoutList, BarChart3, Settings, Download, Sparkles } from 'lucide-react'
|
||||||
import { getMonacoEditor } from '@/components/tree-editor/code-mode'
|
import { getMonacoEditor } from '@/components/tree-editor/code-mode'
|
||||||
import { treesApi } from '@/api/trees'
|
import { treesApi } from '@/api/trees'
|
||||||
import { treeMarkdownApi } from '@/api/treeMarkdown'
|
import { treeMarkdownApi } from '@/api/treeMarkdown'
|
||||||
@@ -16,6 +16,11 @@ import { Spinner } from '@/components/common/Spinner'
|
|||||||
import { cn, safeGetItem } from '@/lib/utils'
|
import { cn, safeGetItem } from '@/lib/utils'
|
||||||
import { toast } from '@/lib/toast'
|
import { toast } from '@/lib/toast'
|
||||||
import { FlowAnalyticsPanel } from '@/components/analytics/FlowAnalyticsPanel'
|
import { FlowAnalyticsPanel } from '@/components/analytics/FlowAnalyticsPanel'
|
||||||
|
import { ExportFlowModal } from '@/components/library/ExportFlowModal'
|
||||||
|
import { EditorAIPanel } from '@/components/editor-ai/EditorAIPanel'
|
||||||
|
import { ContextMenu } from '@/components/common/ContextMenu'
|
||||||
|
import { useEditorAI } from '@/hooks/useEditorAI'
|
||||||
|
import { findNodeInTree } from '@/store/treeEditorStore'
|
||||||
|
|
||||||
/** Recursively check if any node in the tree has type 'answer' */
|
/** Recursively check if any node in the tree has type 'answer' */
|
||||||
function hasAnswerNodes(node: TreeStructure): boolean {
|
function hasAnswerNodes(node: TreeStructure): boolean {
|
||||||
@@ -31,11 +36,13 @@ export function TreeEditorPage() {
|
|||||||
|
|
||||||
const {
|
const {
|
||||||
name,
|
name,
|
||||||
|
description,
|
||||||
isDirty,
|
isDirty,
|
||||||
isLoading,
|
isLoading,
|
||||||
isSaving,
|
isSaving,
|
||||||
validationErrors,
|
validationErrors,
|
||||||
editorMode,
|
editorMode,
|
||||||
|
treeStructure,
|
||||||
initNewTree,
|
initNewTree,
|
||||||
loadTree,
|
loadTree,
|
||||||
loadDraft,
|
loadDraft,
|
||||||
@@ -48,7 +55,10 @@ export function TreeEditorPage() {
|
|||||||
setSaving,
|
setSaving,
|
||||||
selectNode,
|
selectNode,
|
||||||
updateNode,
|
updateNode,
|
||||||
|
deleteNode,
|
||||||
setEditorMode,
|
setEditorMode,
|
||||||
|
getAllNodeIds,
|
||||||
|
replaceTreeStructure,
|
||||||
} = useTreeEditorStore()
|
} = useTreeEditorStore()
|
||||||
|
|
||||||
// Access undo/redo from temporal store
|
// Access undo/redo from temporal store
|
||||||
@@ -61,6 +71,8 @@ export function TreeEditorPage() {
|
|||||||
const [editingNodeId, setEditingNodeId] = useState<string | null>(null)
|
const [editingNodeId, setEditingNodeId] = useState<string | null>(null)
|
||||||
const [isFixing, setIsFixing] = useState(false)
|
const [isFixing, setIsFixing] = useState(false)
|
||||||
const [fixProposals, setFixProposals] = useState<AIFixProposal[] | null>(null)
|
const [fixProposals, setFixProposals] = useState<AIFixProposal[] | null>(null)
|
||||||
|
const [showExportModal, setShowExportModal] = useState(false)
|
||||||
|
const [importMetadata, setImportMetadata] = useState<Record<string, string | null> | null>(null)
|
||||||
|
|
||||||
// Mobile detection
|
// Mobile detection
|
||||||
const [isMobile, setIsMobile] = useState(false)
|
const [isMobile, setIsMobile] = useState(false)
|
||||||
@@ -71,6 +83,38 @@ export function TreeEditorPage() {
|
|||||||
return () => window.removeEventListener('resize', check)
|
return () => window.removeEventListener('resize', check)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
// AI Assist panel
|
||||||
|
const handleFlowUpdate = useCallback((workingTree: Record<string, unknown>) => {
|
||||||
|
// For troubleshooting flows, working_tree is the tree structure directly
|
||||||
|
if (workingTree.type && workingTree.id) {
|
||||||
|
replaceTreeStructure(workingTree as unknown as TreeStructure)
|
||||||
|
}
|
||||||
|
}, [replaceTreeStructure])
|
||||||
|
|
||||||
|
const editorAI = useEditorAI({
|
||||||
|
flowType: 'troubleshooting',
|
||||||
|
treeId: id,
|
||||||
|
getFlowContext: useCallback(() => {
|
||||||
|
if (!treeStructure) return null
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
tree_structure: treeStructure as unknown as Record<string, unknown>,
|
||||||
|
}
|
||||||
|
}, [treeStructure, name, description]),
|
||||||
|
onFlowUpdate: handleFlowUpdate,
|
||||||
|
})
|
||||||
|
|
||||||
|
const previousEditingNodeRef = useRef<string | null>(null)
|
||||||
|
|
||||||
|
const handleAIPanelClose = useCallback(() => {
|
||||||
|
editorAI.closePanel()
|
||||||
|
if (previousEditingNodeRef.current) {
|
||||||
|
setEditingNodeId(previousEditingNodeRef.current)
|
||||||
|
previousEditingNodeRef.current = null
|
||||||
|
}
|
||||||
|
}, [editorAI])
|
||||||
|
|
||||||
// Calculate if there are blocking errors
|
// Calculate if there are blocking errors
|
||||||
const hasBlockingErrors = validationErrors.some(e => e.severity === 'error')
|
const hasBlockingErrors = validationErrors.some(e => e.severity === 'error')
|
||||||
|
|
||||||
@@ -166,6 +210,7 @@ export function TreeEditorPage() {
|
|||||||
}
|
}
|
||||||
loadTree(tree)
|
loadTree(tree)
|
||||||
setTreeStatus(tree.status) // Load status from existing tree
|
setTreeStatus(tree.status) // Load status from existing tree
|
||||||
|
if (tree.import_metadata) setImportMetadata(tree.import_metadata)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to load tree:', err)
|
console.error('Failed to load tree:', err)
|
||||||
toast.error('Failed to load flow')
|
toast.error('Failed to load flow')
|
||||||
@@ -477,7 +522,9 @@ export function TreeEditorPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col overflow-hidden">
|
<div className="flex h-full overflow-hidden">
|
||||||
|
{/* Main content column */}
|
||||||
|
<div className="flex min-w-0 flex-1 flex-col overflow-hidden">
|
||||||
|
|
||||||
{/* Draft Restore Prompt */}
|
{/* Draft Restore Prompt */}
|
||||||
{showDraftPrompt && (
|
{showDraftPrompt && (
|
||||||
@@ -687,6 +734,45 @@ export function TreeEditorPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Validate */}
|
{/* Validate */}
|
||||||
|
{isEditMode && (
|
||||||
|
<button
|
||||||
|
onClick={() => setShowExportModal(true)}
|
||||||
|
title="Export flow as .rfflow file"
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-2 rounded-md border border-border bg-card px-3 py-2 text-sm font-medium text-muted-foreground',
|
||||||
|
'hover:bg-accent hover:text-foreground'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Download className="h-4 w-4" />
|
||||||
|
Export
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* AI Assist toggle */}
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
if (editorAI.isOpen) {
|
||||||
|
handleAIPanelClose()
|
||||||
|
} else {
|
||||||
|
if (editingNodeId) {
|
||||||
|
previousEditingNodeRef.current = editingNodeId
|
||||||
|
setEditingNodeId(null)
|
||||||
|
}
|
||||||
|
editorAI.openPanel()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
title="Toggle AI Assist panel"
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-2 rounded-md border border-border px-3 py-2 text-sm font-medium transition-colors',
|
||||||
|
editorAI.isOpen
|
||||||
|
? 'bg-primary/10 text-primary border-primary/30'
|
||||||
|
: 'bg-card text-muted-foreground hover:bg-accent hover:text-foreground'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Sparkles className="h-4 w-4" />
|
||||||
|
AI Assist
|
||||||
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={handleManualValidate}
|
onClick={handleManualValidate}
|
||||||
disabled={isSaving}
|
disabled={isSaving}
|
||||||
@@ -742,15 +828,29 @@ export function TreeEditorPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Import provenance */}
|
||||||
|
{importMetadata && (
|
||||||
|
<div className="mx-4 mb-2 flex items-center gap-2 text-xs font-label text-muted-foreground">
|
||||||
|
<FileText className="h-3 w-3" />
|
||||||
|
<span>
|
||||||
|
Imported{importMetadata.original_author_name ? ` from ${importMetadata.original_author_name}` : ''}
|
||||||
|
{importMetadata.imported_at ? ` on ${new Date(importMetadata.imported_at).toLocaleDateString()}` : ''}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Main Editor */}
|
{/* Main Editor */}
|
||||||
|
<div className="min-h-0 flex-1 overflow-hidden">
|
||||||
<TreeEditorLayout
|
<TreeEditorLayout
|
||||||
isMobile={isMobile}
|
isMobile={isMobile}
|
||||||
isMetadataOpen={isMetadataOpen}
|
isMetadataOpen={isMetadataOpen}
|
||||||
onCloseMetadata={() => setIsMetadataOpen(false)}
|
onCloseMetadata={() => setIsMetadataOpen(false)}
|
||||||
editingNodeId={editingNodeId}
|
editingNodeId={editorAI.isOpen ? null : editingNodeId}
|
||||||
onNodeSelect={handleNodeSelect}
|
onNodeSelect={handleNodeSelect}
|
||||||
onSelectAnswerType={handleSelectAnswerType}
|
onSelectAnswerType={handleSelectAnswerType}
|
||||||
|
onNodeContextMenu={editorAI.openContextMenu}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Flow Analytics Panel (collapsible) */}
|
{/* Flow Analytics Panel (collapsible) */}
|
||||||
{showAnalytics && id && (
|
{showAnalytics && id && (
|
||||||
@@ -768,6 +868,87 @@ export function TreeEditorPage() {
|
|||||||
onClose={handleCloseFixModal}
|
onClose={handleCloseFixModal}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Export Modal */}
|
||||||
|
{showExportModal && id && (
|
||||||
|
<ExportFlowModal
|
||||||
|
treeId={id}
|
||||||
|
treeName={name || 'flow'}
|
||||||
|
onClose={() => setShowExportModal(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* AI Context Menu */}
|
||||||
|
{editorAI.contextMenu && (
|
||||||
|
<ContextMenu
|
||||||
|
position={editorAI.contextMenu.position}
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
id: 'generate-branch',
|
||||||
|
label: 'Generate Branch',
|
||||||
|
icon: <Sparkles className="h-4 w-4" />,
|
||||||
|
onClick: () => {
|
||||||
|
if (editingNodeId) {
|
||||||
|
previousEditingNodeRef.current = editingNodeId
|
||||||
|
setEditingNodeId(null)
|
||||||
|
}
|
||||||
|
editorAI.triggerAction(
|
||||||
|
editorAI.contextMenu!.nodeId,
|
||||||
|
'generate_branch',
|
||||||
|
`Generate a branch from this node`
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'explain',
|
||||||
|
label: 'Explain Node',
|
||||||
|
icon: <Sparkles className="h-4 w-4" />,
|
||||||
|
onClick: () => {
|
||||||
|
if (editingNodeId) {
|
||||||
|
previousEditingNodeRef.current = editingNodeId
|
||||||
|
setEditingNodeId(null)
|
||||||
|
}
|
||||||
|
editorAI.triggerAction(
|
||||||
|
editorAI.contextMenu!.nodeId,
|
||||||
|
'quick_action',
|
||||||
|
`Explain what this node does`
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'sep1',
|
||||||
|
label: '',
|
||||||
|
onClick: () => {},
|
||||||
|
separator: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'delete',
|
||||||
|
label: 'Delete Node',
|
||||||
|
variant: 'danger' as const,
|
||||||
|
onClick: () => deleteNode(editorAI.contextMenu!.nodeId),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
onClose={editorAI.closeContextMenu}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>{/* end main content column */}
|
||||||
|
|
||||||
|
<EditorAIPanel
|
||||||
|
isOpen={editorAI.isOpen}
|
||||||
|
onClose={handleAIPanelClose}
|
||||||
|
focalNode={editorAI.focalNodeId && treeStructure
|
||||||
|
? findNodeInTree(editorAI.focalNodeId, treeStructure)
|
||||||
|
: null}
|
||||||
|
flowName={name}
|
||||||
|
flowType="troubleshooting"
|
||||||
|
nodeCount={treeStructure ? getAllNodeIds().length : 0}
|
||||||
|
messages={editorAI.messages}
|
||||||
|
input={editorAI.input}
|
||||||
|
onInputChange={editorAI.setInput}
|
||||||
|
onSend={editorAI.sendMessage}
|
||||||
|
isLoading={editorAI.isLoading}
|
||||||
|
suggestions={editorAI.suggestions}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useState, useCallback, useMemo } from 'react'
|
import { useEffect, useState, useCallback, useMemo } from 'react'
|
||||||
import { useNavigate, useSearchParams } from 'react-router-dom'
|
import { useNavigate, useSearchParams } from 'react-router-dom'
|
||||||
import { X, RotateCcw, Play, Sparkles } from 'lucide-react'
|
import { X, RotateCcw, Play, FileUp } from 'lucide-react'
|
||||||
import { treesApi } from '@/api/trees'
|
import { treesApi } from '@/api/trees'
|
||||||
import { categoriesApi } from '@/api/categories'
|
import { categoriesApi } from '@/api/categories'
|
||||||
import { foldersApi } from '@/api/folders'
|
import { foldersApi } from '@/api/folders'
|
||||||
@@ -8,6 +8,8 @@ import { sessionsApi } from '@/api/sessions'
|
|||||||
import type { TreeListItem, CategoryListItem, FolderListItem, Session } from '@/types'
|
import type { TreeListItem, CategoryListItem, FolderListItem, Session } from '@/types'
|
||||||
import { FolderEditModal } from '@/components/library/FolderEditModal'
|
import { FolderEditModal } from '@/components/library/FolderEditModal'
|
||||||
import { ForkModal } from '@/components/library/ForkModal'
|
import { ForkModal } from '@/components/library/ForkModal'
|
||||||
|
import { ExportFlowModal } from '@/components/library/ExportFlowModal'
|
||||||
|
import { ImportFlowModal } from '@/components/library/ImportFlowModal'
|
||||||
import { ConfirmDialog } from '@/components/common/ConfirmDialog'
|
import { ConfirmDialog } from '@/components/common/ConfirmDialog'
|
||||||
import { TreeGridView } from '@/components/library/TreeGridView'
|
import { TreeGridView } from '@/components/library/TreeGridView'
|
||||||
import { TreeListView } from '@/components/library/TreeListView'
|
import { TreeListView } from '@/components/library/TreeListView'
|
||||||
@@ -20,7 +22,6 @@ import { usePermissions } from '@/hooks/usePermissions'
|
|||||||
import { useUserPreferencesStore } from '@/store/userPreferencesStore'
|
import { useUserPreferencesStore } from '@/store/userPreferencesStore'
|
||||||
import { usePinnedFlowsStore } from '@/store/pinnedFlowsStore'
|
import { usePinnedFlowsStore } from '@/store/pinnedFlowsStore'
|
||||||
import { useCachedQuota } from '@/hooks/useCachedQuota'
|
import { useCachedQuota } from '@/hooks/useCachedQuota'
|
||||||
import { AIFlowBuilderModal } from '@/components/ai-builder/AIFlowBuilderModal'
|
|
||||||
import { CreateFlowDropdown } from '@/components/common/CreateFlowDropdown'
|
import { CreateFlowDropdown } from '@/components/common/CreateFlowDropdown'
|
||||||
import { Spinner } from '@/components/common/Spinner'
|
import { Spinner } from '@/components/common/Spinner'
|
||||||
import { EmptyState } from '@/components/common/EmptyState'
|
import { EmptyState } from '@/components/common/EmptyState'
|
||||||
@@ -76,8 +77,12 @@ export function TreeLibraryPage() {
|
|||||||
// Fork modal state
|
// Fork modal state
|
||||||
const [forkTarget, setForkTarget] = useState<TreeListItem | null>(null)
|
const [forkTarget, setForkTarget] = useState<TreeListItem | null>(null)
|
||||||
|
|
||||||
|
// Import/Export modal state
|
||||||
|
const [showImportModal, setShowImportModal] = useState(false)
|
||||||
|
const [exportTarget, setExportTarget] = useState<TreeListItem | null>(null)
|
||||||
|
|
||||||
// AI builder state
|
// AI builder state
|
||||||
const [showAIBuilder, setShowAIBuilder] = useState(false)
|
|
||||||
const { aiEnabled } = useCachedQuota()
|
const { aiEnabled } = useCachedQuota()
|
||||||
|
|
||||||
// Pin store
|
// Pin store
|
||||||
@@ -250,6 +255,11 @@ export function TreeLibraryPage() {
|
|||||||
if (tree) setForkTarget(tree)
|
if (tree) setForkTarget(tree)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleExportTree = (treeId: string) => {
|
||||||
|
const tree = trees.find((t) => t.id === treeId)
|
||||||
|
if (tree) setExportTarget(tree)
|
||||||
|
}
|
||||||
|
|
||||||
const hasActiveFilters =
|
const hasActiveFilters =
|
||||||
selectedCategoryId || selectedTags.length > 0 || searchQuery || selectedFolderId
|
selectedCategoryId || selectedTags.length > 0 || searchQuery || selectedFolderId
|
||||||
|
|
||||||
@@ -273,18 +283,16 @@ export function TreeLibraryPage() {
|
|||||||
</div>
|
</div>
|
||||||
{canCreateTrees && (
|
{canCreateTrees && (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{aiEnabled && (
|
|
||||||
<button
|
<button
|
||||||
onClick={() => navigate('/ai/chat')}
|
onClick={() => setShowImportModal(true)}
|
||||||
className="flex items-center gap-2 rounded-lg bg-gradient-brand px-4 py-2 text-sm font-medium text-white shadow-lg shadow-primary/20 hover:opacity-90 transition-opacity"
|
className="flex items-center gap-2 rounded-lg border border-border bg-[rgba(255,255,255,0.04)] px-4 py-2 text-sm font-medium text-foreground hover:border-[rgba(255,255,255,0.12)] transition-colors"
|
||||||
>
|
>
|
||||||
<Sparkles className="h-4 w-4" />
|
<FileUp className="h-4 w-4" />
|
||||||
Flow Assist
|
Import
|
||||||
</button>
|
</button>
|
||||||
)}
|
|
||||||
<CreateFlowDropdown
|
<CreateFlowDropdown
|
||||||
aiEnabled={aiEnabled}
|
aiEnabled={aiEnabled}
|
||||||
onOpenAIBuilder={() => setShowAIBuilder(true)}
|
|
||||||
label="Create New"
|
label="Create New"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -493,6 +501,7 @@ export function TreeLibraryPage() {
|
|||||||
setShowDeleteConfirm(true)
|
setShowDeleteConfirm(true)
|
||||||
}}
|
}}
|
||||||
onForkTree={handleForkTree}
|
onForkTree={handleForkTree}
|
||||||
|
onExportTree={handleExportTree}
|
||||||
pinnedTreeIds={pinnedTreeIds}
|
pinnedTreeIds={pinnedTreeIds}
|
||||||
onTogglePin={togglePin}
|
onTogglePin={togglePin}
|
||||||
pinLoadingTreeIds={pinLoadingTreeIds}
|
pinLoadingTreeIds={pinLoadingTreeIds}
|
||||||
@@ -509,6 +518,7 @@ export function TreeLibraryPage() {
|
|||||||
setShowDeleteConfirm(true)
|
setShowDeleteConfirm(true)
|
||||||
}}
|
}}
|
||||||
onForkTree={handleForkTree}
|
onForkTree={handleForkTree}
|
||||||
|
onExportTree={handleExportTree}
|
||||||
pinnedTreeIds={pinnedTreeIds}
|
pinnedTreeIds={pinnedTreeIds}
|
||||||
onTogglePin={togglePin}
|
onTogglePin={togglePin}
|
||||||
pinLoadingTreeIds={pinLoadingTreeIds}
|
pinLoadingTreeIds={pinLoadingTreeIds}
|
||||||
@@ -530,6 +540,7 @@ export function TreeLibraryPage() {
|
|||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
onForkTree={handleForkTree}
|
onForkTree={handleForkTree}
|
||||||
|
onExportTree={handleExportTree}
|
||||||
pinnedTreeIds={pinnedTreeIds}
|
pinnedTreeIds={pinnedTreeIds}
|
||||||
onTogglePin={togglePin}
|
onTogglePin={togglePin}
|
||||||
pinLoadingTreeIds={pinLoadingTreeIds}
|
pinLoadingTreeIds={pinLoadingTreeIds}
|
||||||
@@ -567,13 +578,6 @@ export function TreeLibraryPage() {
|
|||||||
isLoading={isDeleting}
|
isLoading={isDeleting}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* AI Builder Modal */}
|
|
||||||
{showAIBuilder && (
|
|
||||||
<AIFlowBuilderModal
|
|
||||||
isOpen={showAIBuilder}
|
|
||||||
onClose={() => setShowAIBuilder(false)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{forkTarget && (
|
{forkTarget && (
|
||||||
<ForkModal
|
<ForkModal
|
||||||
@@ -582,6 +586,20 @@ export function TreeLibraryPage() {
|
|||||||
onClose={() => setForkTarget(null)}
|
onClose={() => setForkTarget(null)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{exportTarget && (
|
||||||
|
<ExportFlowModal
|
||||||
|
treeId={exportTarget.id}
|
||||||
|
treeName={exportTarget.name}
|
||||||
|
onClose={() => setExportTarget(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showImportModal && (
|
||||||
|
<ImportFlowModal
|
||||||
|
onClose={() => { setShowImportModal(false); loadTrees() }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -626,9 +626,9 @@ export function TreeNavigationPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full">
|
<div className="h-full flex overflow-hidden">
|
||||||
{/* Main Content */}
|
{/* Main Content */}
|
||||||
<div className={cn('h-full overflow-y-auto px-4 py-8 transition-[padding] duration-200', scratchpadOpen && 'sm:pr-[440px]')}>
|
<div className={cn('h-full flex-1 min-w-0 overflow-y-auto px-4 py-8 transition-[padding] duration-200', scratchpadOpen && 'sm:pr-[440px]')}>
|
||||||
<div className="mx-auto max-w-4xl">
|
<div className="mx-auto max-w-4xl">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="mb-6 flex items-center justify-between">
|
<div className="mb-6 flex items-center justify-between">
|
||||||
@@ -1264,6 +1264,17 @@ export function TreeNavigationPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* AI Copilot - in-flow panel */}
|
||||||
|
{treeId && copilotOpen && (
|
||||||
|
<CopilotPanel
|
||||||
|
isOpen={copilotOpen}
|
||||||
|
onClose={() => setCopilotOpen(false)}
|
||||||
|
treeId={treeId}
|
||||||
|
sessionId={session?.id}
|
||||||
|
currentNodeId={currentNodeId}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Scratchpad Sidebar */}
|
{/* Scratchpad Sidebar */}
|
||||||
{session && (
|
{session && (
|
||||||
<ScratchpadSidebar
|
<ScratchpadSidebar
|
||||||
@@ -1274,18 +1285,9 @@ export function TreeNavigationPage() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* AI Copilot */}
|
{/* AI Copilot toggle button */}
|
||||||
{treeId && (
|
{treeId && (
|
||||||
<>
|
|
||||||
<CopilotToggle isOpen={copilotOpen} onToggle={() => setCopilotOpen(true)} />
|
<CopilotToggle isOpen={copilotOpen} onToggle={() => setCopilotOpen(true)} />
|
||||||
<CopilotPanel
|
|
||||||
isOpen={copilotOpen}
|
|
||||||
onClose={() => setCopilotOpen(false)}
|
|
||||||
treeId={treeId}
|
|
||||||
sessionId={session?.id}
|
|
||||||
currentNodeId={currentNodeId}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -36,7 +36,6 @@ const TeamAnalyticsPage = lazy(() => import('@/pages/TeamAnalyticsPage'))
|
|||||||
const MyAnalyticsPage = lazy(() => import('@/pages/MyAnalyticsPage'))
|
const MyAnalyticsPage = lazy(() => import('@/pages/MyAnalyticsPage'))
|
||||||
const FeedbackPage = lazy(() => import('@/pages/FeedbackPage'))
|
const FeedbackPage = lazy(() => import('@/pages/FeedbackPage'))
|
||||||
const StepLibraryPage = lazy(() => import('@/pages/StepLibraryPage'))
|
const StepLibraryPage = lazy(() => import('@/pages/StepLibraryPage'))
|
||||||
const AIChatBuilderPage = lazy(() => import('@/pages/AIChatBuilderPage'))
|
|
||||||
const AssistantChatPage = lazy(() => import('@/pages/AssistantChatPage'))
|
const AssistantChatPage = lazy(() => import('@/pages/AssistantChatPage'))
|
||||||
const GuidesHubPage = lazy(() => import('@/pages/GuidesHubPage'))
|
const GuidesHubPage = lazy(() => import('@/pages/GuidesHubPage'))
|
||||||
const GuideDetailPage = lazy(() => import('@/pages/GuideDetailPage'))
|
const GuideDetailPage = lazy(() => import('@/pages/GuideDetailPage'))
|
||||||
@@ -291,14 +290,6 @@ export const router = createBrowserRouter([
|
|||||||
</Suspense>
|
</Suspense>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
|
||||||
path: 'ai/chat',
|
|
||||||
element: (
|
|
||||||
<Suspense fallback={<PageLoader />}>
|
|
||||||
<AIChatBuilderPage />
|
|
||||||
</Suspense>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
path: 'assistant',
|
path: 'assistant',
|
||||||
element: (
|
element: (
|
||||||
|
|||||||
@@ -1,196 +0,0 @@
|
|||||||
import { create } from 'zustand'
|
|
||||||
import { AxiosError } from 'axios'
|
|
||||||
import { aiChatApi } from '@/api/aiChat'
|
|
||||||
import type {
|
|
||||||
ChatMessage,
|
|
||||||
InterviewPhase,
|
|
||||||
TreeStructure,
|
|
||||||
} from '@/types'
|
|
||||||
|
|
||||||
interface TreeMetadata {
|
|
||||||
name?: string
|
|
||||||
description?: string
|
|
||||||
tags?: string[]
|
|
||||||
category_id?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
interface AIChatState {
|
|
||||||
// Session
|
|
||||||
sessionId: string | null
|
|
||||||
status: 'idle' | 'active' | 'completed' | 'abandoned'
|
|
||||||
currentPhase: InterviewPhase
|
|
||||||
flowType: 'troubleshooting' | 'procedural' | null
|
|
||||||
|
|
||||||
// Conversation
|
|
||||||
messages: ChatMessage[]
|
|
||||||
isResponding: boolean
|
|
||||||
|
|
||||||
// Progressive tree
|
|
||||||
workingTree: TreeStructure | null
|
|
||||||
treeMetadata: TreeMetadata | null
|
|
||||||
|
|
||||||
// Final generation
|
|
||||||
generatedTree: TreeStructure | null
|
|
||||||
isGenerating: boolean
|
|
||||||
importedTreeId: string | null
|
|
||||||
|
|
||||||
// Error
|
|
||||||
error: string | null
|
|
||||||
|
|
||||||
// Actions
|
|
||||||
startSession: (flowType: 'troubleshooting' | 'procedural') => Promise<void>
|
|
||||||
sendMessage: (content: string) => Promise<void>
|
|
||||||
generateTree: () => Promise<void>
|
|
||||||
importToEditor: (params?: { name?: string; description?: string; category_id?: string; tags?: string[] }) => Promise<string>
|
|
||||||
abandonSession: () => Promise<void>
|
|
||||||
resumeSession: (sessionId: string) => Promise<void>
|
|
||||||
reset: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
const initialState = {
|
|
||||||
sessionId: null,
|
|
||||||
status: 'idle' as const,
|
|
||||||
currentPhase: 'scoping' as InterviewPhase,
|
|
||||||
flowType: null,
|
|
||||||
messages: [],
|
|
||||||
isResponding: false,
|
|
||||||
workingTree: null,
|
|
||||||
treeMetadata: null,
|
|
||||||
generatedTree: null,
|
|
||||||
isGenerating: false,
|
|
||||||
importedTreeId: null,
|
|
||||||
error: null,
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractErrorMessage(e: unknown, fallback: string): string {
|
|
||||||
if (e instanceof AxiosError && e.response?.data?.detail) {
|
|
||||||
const detail = e.response.data.detail
|
|
||||||
return typeof detail === 'string' ? detail : detail.message || fallback
|
|
||||||
}
|
|
||||||
if (e instanceof Error) return e.message
|
|
||||||
return fallback
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useAIChatStore = create<AIChatState>((set, get) => ({
|
|
||||||
...initialState,
|
|
||||||
|
|
||||||
startSession: async (flowType) => {
|
|
||||||
set({ ...initialState, status: 'active', flowType, isResponding: true, error: null })
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await aiChatApi.startSession(flowType)
|
|
||||||
set({
|
|
||||||
sessionId: response.session_id,
|
|
||||||
currentPhase: response.current_phase,
|
|
||||||
messages: [{
|
|
||||||
role: 'assistant',
|
|
||||||
content: response.greeting,
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
}],
|
|
||||||
isResponding: false,
|
|
||||||
})
|
|
||||||
} catch (e: unknown) {
|
|
||||||
set({ error: extractErrorMessage(e, 'Failed to start session'), isResponding: false, status: 'idle' })
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
sendMessage: async (content) => {
|
|
||||||
const { sessionId, messages, isResponding } = get()
|
|
||||||
if (!sessionId || isResponding) return
|
|
||||||
|
|
||||||
const userMessage: ChatMessage = {
|
|
||||||
role: 'user',
|
|
||||||
content,
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
}
|
|
||||||
|
|
||||||
set({
|
|
||||||
messages: [...messages, userMessage],
|
|
||||||
isResponding: true,
|
|
||||||
error: null,
|
|
||||||
})
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await aiChatApi.sendMessage(sessionId, content)
|
|
||||||
const aiMessage: ChatMessage = {
|
|
||||||
role: 'assistant',
|
|
||||||
content: response.content,
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
}
|
|
||||||
|
|
||||||
set((state) => ({
|
|
||||||
messages: [...state.messages, aiMessage],
|
|
||||||
currentPhase: response.current_phase,
|
|
||||||
workingTree: (response.working_tree as TreeStructure | null) ?? state.workingTree,
|
|
||||||
treeMetadata: (response.tree_metadata as TreeMetadata | null) ?? state.treeMetadata,
|
|
||||||
isResponding: false,
|
|
||||||
}))
|
|
||||||
} catch (e: unknown) {
|
|
||||||
set({ error: extractErrorMessage(e, 'Failed to send message'), isResponding: false })
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
generateTree: async () => {
|
|
||||||
const { sessionId, isGenerating } = get()
|
|
||||||
if (!sessionId || isGenerating) return
|
|
||||||
|
|
||||||
set({ isGenerating: true, error: null })
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await aiChatApi.generateTree(sessionId)
|
|
||||||
set({
|
|
||||||
generatedTree: response.tree_structure as unknown as TreeStructure,
|
|
||||||
workingTree: response.tree_structure as unknown as TreeStructure,
|
|
||||||
treeMetadata: response.tree_metadata as TreeMetadata,
|
|
||||||
status: 'completed',
|
|
||||||
isGenerating: false,
|
|
||||||
})
|
|
||||||
} catch (e: unknown) {
|
|
||||||
set({ error: extractErrorMessage(e, 'Failed to generate tree'), isGenerating: false })
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
importToEditor: async (params) => {
|
|
||||||
const { sessionId } = get()
|
|
||||||
if (!sessionId) throw new Error('No active session')
|
|
||||||
|
|
||||||
const response = await aiChatApi.importTree(sessionId, params)
|
|
||||||
set({ importedTreeId: response.tree_id })
|
|
||||||
return response.tree_id
|
|
||||||
},
|
|
||||||
|
|
||||||
abandonSession: async () => {
|
|
||||||
const { sessionId } = get()
|
|
||||||
if (!sessionId) return
|
|
||||||
|
|
||||||
try {
|
|
||||||
await aiChatApi.abandonSession(sessionId)
|
|
||||||
} catch {
|
|
||||||
// Best effort — session may have already expired
|
|
||||||
}
|
|
||||||
set({ ...initialState })
|
|
||||||
},
|
|
||||||
|
|
||||||
resumeSession: async (sessionId) => {
|
|
||||||
set({ isResponding: true, error: null })
|
|
||||||
|
|
||||||
try {
|
|
||||||
const session = await aiChatApi.getSession(sessionId)
|
|
||||||
set({
|
|
||||||
sessionId: session.session_id,
|
|
||||||
status: session.status === 'active' ? 'active' : (session.status as 'completed' | 'abandoned'),
|
|
||||||
currentPhase: session.current_phase,
|
|
||||||
flowType: session.flow_type,
|
|
||||||
messages: session.conversation_history as ChatMessage[],
|
|
||||||
workingTree: session.working_tree as TreeStructure | null,
|
|
||||||
treeMetadata: session.tree_metadata as TreeMetadata | null,
|
|
||||||
generatedTree: session.generated_tree as TreeStructure | null,
|
|
||||||
isResponding: false,
|
|
||||||
})
|
|
||||||
} catch (e: unknown) {
|
|
||||||
set({ error: extractErrorMessage(e, 'Failed to resume session'), isResponding: false })
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
reset: () => set({ ...initialState }),
|
|
||||||
}))
|
|
||||||
@@ -1,237 +0,0 @@
|
|||||||
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
|
|
||||||
isGeneratingAll: boolean
|
|
||||||
stopGeneratingAll: 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>
|
|
||||||
generateAllBranchDetails: () => Promise<void>
|
|
||||||
cancelGenerateAll: () => 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,
|
|
||||||
isGeneratingAll: false,
|
|
||||||
stopGeneratingAll: 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 })
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
generateAllBranchDetails: async () => {
|
|
||||||
const { selectedBranches, generateBranchDetail } = get()
|
|
||||||
const undetailed = selectedBranches.filter((b) => !b.steps)
|
|
||||||
if (undetailed.length === 0) return
|
|
||||||
|
|
||||||
set({ isGeneratingAll: true, stopGeneratingAll: false, error: null })
|
|
||||||
|
|
||||||
for (const branch of undetailed) {
|
|
||||||
if (get().stopGeneratingAll) break
|
|
||||||
// Set currentBranchIndex so tabs show the active branch
|
|
||||||
const idx = get().selectedBranches.findIndex((b) => b.name === branch.name)
|
|
||||||
if (idx !== -1) set({ currentBranchIndex: idx })
|
|
||||||
await generateBranchDetail(branch.name)
|
|
||||||
// If generateBranchDetail set phase to 'error', stop
|
|
||||||
if (get().phase === 'error') break
|
|
||||||
}
|
|
||||||
|
|
||||||
set({ isGeneratingAll: false })
|
|
||||||
},
|
|
||||||
|
|
||||||
cancelGenerateAll: () => {
|
|
||||||
set({ stopGeneratingAll: true })
|
|
||||||
},
|
|
||||||
|
|
||||||
reset: () => {
|
|
||||||
set({
|
|
||||||
phase: 'foundation',
|
|
||||||
conversationId: null,
|
|
||||||
metadata: { ...initialMetadata },
|
|
||||||
suggestedBranches: [],
|
|
||||||
selectedBranches: [],
|
|
||||||
currentBranchIndex: 0,
|
|
||||||
assembledTree: null,
|
|
||||||
isLoading: false,
|
|
||||||
isGeneratingAll: false,
|
|
||||||
stopGeneratingAll: 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'
|
|
||||||
}
|
|
||||||
@@ -112,6 +112,9 @@ interface ProceduralEditorState {
|
|||||||
updateField: (index: number, updates: Partial<IntakeFormField>) => void
|
updateField: (index: number, updates: Partial<IntakeFormField>) => void
|
||||||
moveField: (fromIndex: number, toIndex: number) => void
|
moveField: (fromIndex: number, toIndex: number) => void
|
||||||
|
|
||||||
|
// Actions - AI Integration
|
||||||
|
replaceSteps: (steps: ProceduralStep[], intakeForm?: IntakeFormField[]) => void
|
||||||
|
|
||||||
// Actions - Save
|
// Actions - Save
|
||||||
setIsSaving: (saving: boolean) => void
|
setIsSaving: (saving: boolean) => void
|
||||||
markSaved: () => void
|
markSaved: () => void
|
||||||
@@ -341,6 +344,15 @@ export const useProceduralEditorStore = create<ProceduralEditorState>()(
|
|||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// AI Integration
|
||||||
|
replaceSteps: (steps, intakeForm) => {
|
||||||
|
set((state) => {
|
||||||
|
state.steps = steps
|
||||||
|
if (intakeForm) state.intakeForm = intakeForm
|
||||||
|
state.isDirty = true
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
// Save
|
// Save
|
||||||
setIsSaving: (saving) => set((state) => { state.isSaving = saving }),
|
setIsSaving: (saving) => set((state) => { state.isSaving = saving }),
|
||||||
markSaved: () => set((state) => { state.isDirty = false }),
|
markSaved: () => set((state) => { state.isDirty = false }),
|
||||||
|
|||||||
@@ -283,6 +283,9 @@ interface TreeEditorState {
|
|||||||
syncMarkdownToTree: () => void
|
syncMarkdownToTree: () => void
|
||||||
syncTreeToMarkdown: () => void
|
syncTreeToMarkdown: () => void
|
||||||
|
|
||||||
|
// Actions - AI Integration
|
||||||
|
replaceTreeStructure: (structure: TreeStructure) => void
|
||||||
|
|
||||||
// Actions - State
|
// Actions - State
|
||||||
setLoading: (loading: boolean) => void
|
setLoading: (loading: boolean) => void
|
||||||
setSaving: (saving: boolean) => void
|
setSaving: (saving: boolean) => void
|
||||||
@@ -855,7 +858,7 @@ export const useTreeEditorStore = create<TreeEditorState>()(
|
|||||||
|
|
||||||
// Check for orphaned nodes (not root and not referenced)
|
// Check for orphaned nodes (not root and not referenced)
|
||||||
allNodeIds.forEach(id => {
|
allNodeIds.forEach(id => {
|
||||||
if (id !== 'root' && !referencedIds.has(id)) {
|
if (id !== state.treeStructure?.id && !referencedIds.has(id)) {
|
||||||
// Check if it's a direct child of another node (via children array)
|
// Check if it's a direct child of another node (via children array)
|
||||||
let isChildOfAny = false
|
let isChildOfAny = false
|
||||||
const checkIfChild = (node: TreeStructure) => {
|
const checkIfChild = (node: TreeStructure) => {
|
||||||
@@ -1015,6 +1018,15 @@ export const useTreeEditorStore = create<TreeEditorState>()(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// AI Integration
|
||||||
|
replaceTreeStructure: (structure: TreeStructure) => {
|
||||||
|
set((state) => {
|
||||||
|
state.treeStructure = structure
|
||||||
|
state.selectedNodeId = structure.id
|
||||||
|
state.isDirty = true
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
setLoading: (loading: boolean) => {
|
setLoading: (loading: boolean) => {
|
||||||
set((state) => { state.isLoading = loading })
|
set((state) => { state.isLoading = loading })
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,43 +0,0 @@
|
|||||||
export type InterviewPhase = 'scoping' | 'discovery' | 'enrichment' | 'review' | 'generation'
|
|
||||||
|
|
||||||
export interface ChatMessage {
|
|
||||||
role: 'user' | 'assistant'
|
|
||||||
content: string
|
|
||||||
timestamp: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AIChatStartResponse {
|
|
||||||
session_id: string
|
|
||||||
greeting: string
|
|
||||||
current_phase: InterviewPhase
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AIChatMessageResponse {
|
|
||||||
content: string
|
|
||||||
current_phase: InterviewPhase
|
|
||||||
working_tree: Record<string, unknown> | null
|
|
||||||
tree_metadata: Record<string, unknown> | null
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AIChatSessionResponse {
|
|
||||||
session_id: string
|
|
||||||
status: 'active' | 'completed' | 'abandoned'
|
|
||||||
current_phase: InterviewPhase
|
|
||||||
flow_type: 'troubleshooting' | 'procedural'
|
|
||||||
conversation_history: ChatMessage[]
|
|
||||||
working_tree: Record<string, unknown> | null
|
|
||||||
tree_metadata: Record<string, unknown> | null
|
|
||||||
message_count: number
|
|
||||||
generated_tree: Record<string, unknown> | null
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AIChatGenerateResponse {
|
|
||||||
tree_structure: Record<string, unknown>
|
|
||||||
tree_metadata: Record<string, unknown>
|
|
||||||
status: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AIChatImportResponse {
|
|
||||||
tree_id: string
|
|
||||||
tree_type: string
|
|
||||||
}
|
|
||||||
62
frontend/src/types/editor-ai.ts
Normal file
62
frontend/src/types/editor-ai.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
export type AIActionType =
|
||||||
|
| 'generate_full'
|
||||||
|
| 'generate_branch'
|
||||||
|
| 'modify_node'
|
||||||
|
| 'add_steps'
|
||||||
|
| 'quick_action'
|
||||||
|
| 'open_chat'
|
||||||
|
| 'variable_inference'
|
||||||
|
|
||||||
|
export interface AIDelta {
|
||||||
|
action: 'add' | 'modify' | 'delete'
|
||||||
|
target_node_id: string
|
||||||
|
nodes: Record<string, unknown>[]
|
||||||
|
explanation: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AISuggestion {
|
||||||
|
id: string
|
||||||
|
action_type: AIActionType
|
||||||
|
target_node_id: string | null
|
||||||
|
changes_json: {
|
||||||
|
before?: Record<string, unknown>
|
||||||
|
after?: Record<string, unknown>
|
||||||
|
delta?: AIDelta
|
||||||
|
}
|
||||||
|
status: 'pending' | 'accepted' | 'dismissed'
|
||||||
|
created_at: string
|
||||||
|
resolved_at: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EditorAIChatMessage {
|
||||||
|
role: 'user' | 'assistant'
|
||||||
|
content: string
|
||||||
|
timestamp: string
|
||||||
|
action_type?: AIActionType
|
||||||
|
delta?: AIDelta
|
||||||
|
knowledge?: KnowledgeCitation[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KnowledgeCitation {
|
||||||
|
title: string
|
||||||
|
url: string
|
||||||
|
excerpt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ContextMenuAction {
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
icon: string
|
||||||
|
action_type: AIActionType
|
||||||
|
description?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ContextMenuPosition {
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SuggestionMarker {
|
||||||
|
_suggestion?: true
|
||||||
|
_suggestion_id?: string
|
||||||
|
}
|
||||||
33
frontend/src/types/flowTransfer.ts
Normal file
33
frontend/src/types/flowTransfer.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
export interface FlowExportCategory {
|
||||||
|
name: string
|
||||||
|
slug: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FlowExportData {
|
||||||
|
name: string
|
||||||
|
description: string | null
|
||||||
|
tree_type: 'troubleshooting' | 'procedural' | 'maintenance'
|
||||||
|
version: number
|
||||||
|
author_name: string | null
|
||||||
|
category: FlowExportCategory | null
|
||||||
|
tags: string[]
|
||||||
|
tree_structure: Record<string, unknown>
|
||||||
|
intake_form: Record<string, unknown>[] | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RFFlowFile {
|
||||||
|
rfflow_version: string
|
||||||
|
exported_at: string
|
||||||
|
source_app: string
|
||||||
|
flow: FlowExportData
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FlowImportResponse {
|
||||||
|
tree_id: string
|
||||||
|
name: string
|
||||||
|
tree_type: string
|
||||||
|
status: string
|
||||||
|
category_created: boolean
|
||||||
|
tags_created: string[]
|
||||||
|
validation_warnings: string[]
|
||||||
|
}
|
||||||
@@ -55,12 +55,21 @@ export type {
|
|||||||
AIFixValidationError,
|
AIFixValidationError,
|
||||||
} from './ai-fix'
|
} from './ai-fix'
|
||||||
|
|
||||||
|
|
||||||
export type {
|
export type {
|
||||||
InterviewPhase,
|
RFFlowFile,
|
||||||
ChatMessage,
|
FlowExportData,
|
||||||
AIChatStartResponse,
|
FlowExportCategory,
|
||||||
AIChatMessageResponse,
|
FlowImportResponse,
|
||||||
AIChatSessionResponse,
|
} from './flowTransfer'
|
||||||
AIChatGenerateResponse,
|
|
||||||
AIChatImportResponse,
|
export type {
|
||||||
} from './ai-chat'
|
AIActionType,
|
||||||
|
AIDelta,
|
||||||
|
AISuggestion,
|
||||||
|
EditorAIChatMessage,
|
||||||
|
KnowledgeCitation,
|
||||||
|
ContextMenuAction,
|
||||||
|
ContextMenuPosition,
|
||||||
|
SuggestionMarker,
|
||||||
|
} from './editor-ai'
|
||||||
|
|||||||
@@ -176,6 +176,12 @@ export interface Tree {
|
|||||||
updated_at: string
|
updated_at: string
|
||||||
usage_count: number
|
usage_count: number
|
||||||
fork_info: ForkInfo | null
|
fork_info: ForkInfo | null
|
||||||
|
import_metadata: {
|
||||||
|
original_author_name?: string | null
|
||||||
|
exported_at?: string
|
||||||
|
imported_at?: string
|
||||||
|
source_app?: string
|
||||||
|
} | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TreeListItem {
|
export interface TreeListItem {
|
||||||
|
|||||||
Reference in New Issue
Block a user