feat: flow export/import + procedural Flow Assist (#96)

* feat: add flow export/import backend (migration, endpoints, schemas)

Add .rfflow file export/import support:
- Migration 050: import_metadata JSONB column on trees
- GET /trees/{id}/export?format=json|xml endpoint
- POST /trees/import endpoint (creates draft, resolves categories/tags)
- FlowExportEnvelope, FlowImportRequest/Response schemas
- import_metadata field on TreeResponse

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: add flow export/import frontend + backend tests

Frontend:
- ExportFlowModal with JSON/XML format selection + download
- ImportFlowModal with drag-drop file picker + preview step
- rfflowParser for client-side JSON/XML .rfflow parsing
- Export buttons on editor toolbar and library action menus
- Import button on library page next to Create New
- Provenance display for imported flows in editor
- flowTransfer API client + types

Backend:
- Fix regex->pattern deprecation in export endpoint
- 12 integration tests covering export, import, round-trip,
  access control, tag/category creation, version validation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor: remove XML export, JSON-only for .rfflow files

- Remove XML builder, format query param, and XML tests
- Simplify ExportFlowModal (no format picker)
- Simplify rfflowParser (JSON-only)
- Remove format field from schemas and types

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: match Flow Assist chat input to AI Assistant styling + strengthen one-question prompt

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: add procedural flow support to AI chat builder (Flow Assist)

- Add procedural-specific system prompts (schema, interview protocol, response format)
- Dispatch prompts by flow_type: procedural/maintenance use flat steps schema, troubleshooting uses decision tree schema
- Parse [STEPS_UPDATE] and [INTAKE_FORM] markers in AI responses
- Add validate_generated_procedural_steps() validator
- Handle intake form extraction in AI chat import endpoint
- Add StaticStepsPreview component for procedural flow preview
- Update store and page to render correct preview by flow type

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: add flow type selection to Flow Assist entry points

- CreateFlowDropdown now shows "Build with Flow Assist" under each flow type
- Library page "Flow Assist" button respects current type filter
- Clean up unused AIFlowBuilderModal references

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* docs: update CLAUDE.md with AI chat builder and intake form learnings

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: refine assistant chat prompt for concise answers and focused questions

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: switch AI provider to Claude Sonnet 4.6 + add shift+enter hint to chat inputs

- Default AI_PROVIDER changed from gemini to anthropic
- AI_MODEL and AI_MODEL_ANTHROPIC updated to claude-sonnet-4-6
- Added "Shift + Enter for a new line" hint below all chat textareas

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* docs: update CLAUDE.md with AI provider and chat input learnings

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* docs: add editor-embedded Flow Assist design document

Design for replacing the standalone /ai/chat page with context-aware
AI side panels embedded in each editor (Troubleshooting + Procedural).
Covers ghost node suggestion system, output-based thresholds,
config-driven model routing, knowledge integration, and per-flow
chat persistence.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* docs: add editor-embedded Flow Assist implementation plan

25-task plan across 9 phases covering backend foundation, frontend
infrastructure, tree/procedural editor integration, AI-assisted create,
old code removal, action-type dispatch, suggestion audit trail, and
build verification.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: use actual root node ID in orphan validation check

AI-generated trees use descriptive IDs (e.g., "verify-account-exists")
instead of "root", causing the root node to be falsely flagged as orphaned.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: add config-driven AI model tier routing

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: extend AI chat session with tree_id and archived_at

Add tree_id FK (CASCADE) for editor-embedded sessions and archived_at
timestamp column to ai_chat_sessions table.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: add AI suggestion audit trail table

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: add action_type and focal_node_id to AI chat message API

- Add VALID_ACTION_TYPES literal and action_type/focal_node_id fields to
  AIChatMessageRequest schema
- Add tree_id field to AIChatStartRequest schema for editor-embedded sessions
- Update send_message() signature with action_type and focal_node_id params
- Update start_chat_session() signature with tree_id param
- Pass new params through endpoints to service functions
- All new params have defaults so existing behavior is unchanged

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: route AI model selection through action-type config

Update get_ai_provider() to accept an optional model override parameter
(applied only to AnthropicProvider; Gemini always uses its own model).
Thread action_type-based model resolution through send_message() and
generate_final_tree() in the AI chat service.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: add TypeScript types for editor-embedded AI

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: add shared ContextMenu component

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: add useEditorAI hook and editorAI API client

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: add EditorAIPanel component with Chat and Suggestions tabs

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: integrate AI panel, context menu, and ghost nodes in tree editor

- Add AI Assist panel toggle button to tree editor toolbar
- Wire EditorAIPanel alongside TreeEditorLayout with single-panel rule
- Thread onNodeContextMenu through TreeEditorLayout → FlowCanvas → FlowCanvasNode
- Add right-click context menu with Generate Branch, Explain Node, Delete actions
- Add ghost node detection (_suggestion flag) with dashed border + opacity styling
- Add Accept/Dismiss overlay buttons on ghost nodes for future suggestion handling

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: integrate AI panel, context menu, and ghost steps in procedural editor

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: add AI prompt dialog and wire into CreateFlowDropdown

Replace navigation to /ai/chat with an inline AIPromptDialog modal
that collects a single prompt, generates a flow via the editor AI API,
imports it, and navigates to the editor with the AI panel open.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: add glassmorphism to AI prompt dialog + maintenance Flow Assist button

- Use .glass-card-static on AIPromptDialog card for consistent design system
- Add "Build with Flow Assist" button to maintenance section in CreateFlowDropdown

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor: remove standalone Flow Assist page and old AI chat components

Remove the old /ai/chat page, AI wizard modal, and all associated
components/stores/types now replaced by the editor-embedded AI panel.

Deleted:
- AIChatBuilderPage, ai-chat/ components, aiChatStore, aiChat API, ai-chat types
- AIFlowBuilderModal, ai-builder/ components, aiFlowBuilderStore

Cleaned up:
- Router (removed /ai/chat route)
- Sidebar (removed Flow Assist nav item)
- MyTreesPage (removed AI builder modal and button)
- TreeLibraryPage (removed Flow Assist button)
- API and type barrel exports

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: add delta response parsing and action-type prompt dispatch

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: add AI suggestion audit trail endpoints

Create/list/resolve endpoints for tracking AI-applied changes to flows.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: add APScheduler task to auto-archive stale AI chat sessions

Archives AI chat sessions with no activity for 30 days, runs daily at 3 AM.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* docs: update project status for editor-embedded Flow Assist

- Add Editor-Embedded Flow Assist to CURRENT-STATE.md in-progress items
- Update CLAUDE.md: fix stale lessons (#41, #46), add new patterns (#47 editor AI architecture, #48 orphan validation)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: use correct model alias in AI_MODEL_TIERS standard tier

The dated model ID `claude-sonnet-4-6-20250514` was causing 502 errors.
Use the alias `claude-sonnet-4-6` which matches AI_MODEL_ANTHROPIC.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: send live flow context to AI Assist for full editor awareness

The AI panel now sends the current tree structure (troubleshooting) or
steps + intake form (procedural/maintenance) with each message. This
gives the AI full visibility into node details, questions, descriptions,
options, and intake form fields — not just the node ID.

- Backend: add flow_context param to schema, endpoint, and service
- Frontend: add getFlowContext callback to useEditorAI hook
- TreeEditorPage: passes treeStructure as flow context
- ProceduralEditorPage: passes steps + intakeForm as flow context

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: include flow name and description in AI Assist context

Both editors now send name and description alongside the flow structure,
so the AI can reference what the flow is about when responding.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: increase AI timeout to 120s and limit retries to 1

The 45s timeout was too short for generation tasks with full flow
context in the system prompt. The Anthropic SDK's default 2 retries
caused requests to hang for ~136s before failing. Now: 120s timeout
with max 1 retry = faster failure if it does timeout.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: wire AI-generated flow structures into editor stores

The useEditorAI hook was ignoring result.working_tree from AI responses,
so generated steps/trees never appeared in the editor. Now:
- useEditorAI calls onFlowUpdate when working_tree is present in response
- ProceduralEditorPage handles steps + intake form updates via replaceSteps
- TreeEditorPage handles tree structure updates via replaceTreeStructure
- Both stores have new bulk-replace methods for AI integration

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* docs: add lessons learned for full-stack integration, Anthropic retries, model tiers

#49 Always verify frontend consumes backend response fields
#50 Anthropic SDK max_retries=1 to avoid 3× timeout
#51 AI model tier routing via settings.get_model_for_action()

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: move AI Assist panel to full-height side layout in both editors

The AI panel was nested inside the content area, only spanning the
step list / canvas section. Now it sits at the outermost flex level,
spanning the full page height alongside all content (toolbar,
collapsible sections, steps/canvas). This prevents the panel from
overlapping content and lets the editor area properly shrink.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: AI Assist panel as fixed right drawer (matching Copilot/Scratchpad)

Convert EditorAIPanel from in-flow flex child to fixed right-side drawer
overlay, same pattern as CopilotPanel and ScratchpadSidebar. The panel
is fixed at right:0 spanning full viewport height, and editor pages add
pr-[380px] padding when open so content shifts left without overlap.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: AI Assist panel sits below topbar with slide-in animation

- Panel now uses top:56px to sit below the app shell topbar instead of
  covering it (matches the main-content grid cell area)
- Added slideInRight CSS animation for smooth drawer entrance
- Editor pages use dynamic paddingRight via PANEL_WIDTH constant
- ChatTab upgraded: markdown rendering, CopilotPanel-style message
  bubbles, auto-focus input, Shift+Enter hint
- All borders use --glass-border for consistent glassmorphism

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: AI Assist panel as in-flow flex sibling (not fixed/overlay)

Replace fixed positioning with in-flow flex layout. The outermost div
is now a horizontal flex row: content column (flex-1 min-w-0) + panel
(w-[380px] shrink-0). When the panel opens, the content column
automatically shrinks — no padding hacks or z-index stacking needed.
This guarantees the content shifts left and stays fully visible.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: AI Copilot panel as in-flow flex sibling in session navigation pages

Changed CopilotPanel from fixed overlay to flex layout sibling so it
pushes main content instead of covering it during active sessions.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* docs: remove duplicate CLAUDE.md lessons #47-48

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit was merged in pull request #96.
This commit is contained in:
chihlasm
2026-03-07 15:51:37 -05:00
committed by GitHub
parent 0fb1ef33a0
commit 0dc6123c0c
90 changed files with 7637 additions and 2472 deletions

View File

@@ -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

View File

@@ -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) |

View File

@@ -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

View 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')

View 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")

View 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")

View File

@@ -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,

View 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

View 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,
)

View File

@@ -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
) )

View File

@@ -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)

View File

@@ -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,18 +140,139 @@ 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."""
flow_context = ( if flow_type in ("procedural", "maintenance"):
"The user wants to build a TROUBLESHOOTING flow — a diagnostic decision tree " flow_context = (
"that guides engineers through symptom identification, diagnostic checks, and " "The user wants to build a PROCEDURAL flow — a step-by-step process guide "
"resolution steps." "with ordered phases, verification checkpoints, and optional intake form variables. "
if flow_type == "troubleshooting" "This is NOT a branching decision tree — it is a flat, sequential procedure."
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"
f"{PROCEDURAL_SCHEMA_CONTEXT}\n\n{PROCEDURAL_INTERVIEW_PROTOCOL}\n\n{PROCEDURAL_RESPONSE_FORMAT}"
return f"{ROLE_PERSONA}\n\n{flow_context}\n\n{SCHEMA_CONTEXT}\n\n{INTERVIEW_PROTOCOL}\n\n{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}"
def _strip_markdown_fences(text: str) -> str: def _strip_markdown_fences(text: str) -> str:
@@ -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,12 +586,19 @@ 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 not isinstance(tree_update, dict) or tree_update.get("type") != "decision": if session.flow_type in ("procedural", "maintenance"):
logger.warning("AI tree update rejected: root must be a decision node") # Procedural: must be a dict with a "steps" list
tree_update = None if not isinstance(tree_update, dict) or not isinstance(tree_update.get("steps"), list):
elif not tree_update.get("id"): logger.warning("AI steps update rejected: must be a dict with a 'steps' list")
logger.warning("AI tree update rejected: root node missing id") tree_update = None
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
# Update session state # Update session state
history.append({"role": "assistant", "content": parsed["content"], "timestamp": now_iso}) history.append({"role": "assistant", "content": parsed["content"], "timestamp": now_iso})
@@ -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,7 +647,33 @@ async def generate_final_tree(
for msg in session.conversation_history for msg in session.conversation_history
] ]
generation_instruction = """Based on our entire conversation, generate the COMPLETE and FINAL TreeStructure JSON for this flow. 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.
Requirements: Requirements:
- Include ALL branches, steps, and solutions we discussed - Include ALL branches, steps, and solutions we discussed
@@ -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)

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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:

View File

@@ -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,
)

View 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
)

View File

@@ -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")

View File

@@ -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):

View 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)$")

View File

@@ -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

View 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] = []

View File

@@ -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 \

View 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"

View 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

View 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"]

View 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

View 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.

File diff suppressed because it is too large Load Diff

View 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 |

View File

@@ -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

View 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}`)
},
}

View 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
},
}

View File

@@ -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'

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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"
>
&times;
</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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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">
&quot;{assembledTree.suggested_name}&quot; 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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View 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>
)
}

View File

@@ -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 && (
<> <button
<div className="my-1 border-t border-border" /> type="button"
<button onClick={() => {
type="button" setShowMenu(false)
onClick={() => { setAiPromptFlowType('maintenance')
setShowMenu(false) setAiPromptOpen(true)
onOpenAIBuilder() }}
}} className="flex w-full items-center gap-3 rounded-md px-3 py-2 text-sm text-foreground hover:bg-accent"
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-3.5 w-3.5 text-primary ml-0.5" />
<Sparkles className="h-4 w-4 text-primary" /> <div className="text-left">
<div className="text-left"> <div className="text-xs text-primary font-medium">Build with Flow Assist</div>
<div className="font-medium">Flow Assist</div> </div>
<div className="text-xs text-muted-foreground">AI-powered flow builder</div> </button>
</div>
</button>
</>
)} )}
</div> </div>
</> </>
)} )}
<AIPromptDialog
isOpen={aiPromptOpen}
onClose={() => setAiPromptOpen(false)}
onGenerate={handleAIGenerate}
flowType={aiPromptFlowType}
/>
</div> </div>
) )
} }

View File

@@ -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>
) )

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View File

@@ -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" />

View 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>
)
}

View 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>
)
}

View File

@@ -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"

View File

@@ -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"

View File

@@ -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"

View File

@@ -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,53 +237,80 @@ 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)}
> >
<button <div className="flex items-center gap-2">
type="button" <button
className="shrink-0 cursor-grab touch-none text-muted-foreground active:cursor-grabbing" type="button"
aria-label={`Drag to reorder step ${stepNumber}: ${step.title || 'Untitled step'}`} className="shrink-0 cursor-grab touch-none text-muted-foreground active:cursor-grabbing"
{...dragHandleProps} aria-label={`Drag to reorder step ${stepNumber}: ${step.title || 'Untitled step'}`}
> {...dragHandleProps}
<GripVertical className="h-4 w-4" /> >
</button> <GripVertical className="h-4 w-4" />
</button>
<span className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-accent text-xs font-medium text-muted-foreground"> <span className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-accent text-xs font-medium text-muted-foreground">
{stepNumber} {stepNumber}
</span>
<span className={cn('shrink-0', config.color)}>
<Icon className="h-3.5 w-3.5" />
</span>
<span
className="min-w-0 flex-1 cursor-pointer truncate text-sm text-foreground"
onClick={() => setExpandedStepId(step.id)}
>
{step.title || 'Untitled step'}
</span>
{step.estimated_minutes && (
<span className="shrink-0 text-[10px] text-muted-foreground">
~{step.estimated_minutes}m
</span> </span>
<span className={cn('shrink-0', config.color)}>
<Icon className="h-3.5 w-3.5" />
</span>
<span
className="min-w-0 flex-1 cursor-pointer truncate text-sm text-foreground"
onClick={() => setExpandedStepId(step.id)}
>
{step.title || 'Untitled step'}
</span>
{step.estimated_minutes && (
<span className="shrink-0 text-[10px] text-muted-foreground">
~{step.estimated_minutes}m
</span>
)}
<button
onClick={() => setExpandedStepId(step.id)}
className="shrink-0 rounded p-1 text-muted-foreground hover:bg-accent hover:text-foreground"
>
<ChevronDown className="h-3.5 w-3.5" />
</button>
<button
onClick={() => removeStep(step.id)}
className="shrink-0 rounded p-1 text-muted-foreground opacity-0 hover:bg-red-500/20 hover:text-red-400 group-hover:opacity-100"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</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>
)} )}
<button
onClick={() => setExpandedStepId(step.id)}
className="shrink-0 rounded p-1 text-muted-foreground hover:bg-accent hover:text-foreground"
>
<ChevronDown className="h-3.5 w-3.5" />
</button>
<button
onClick={() => removeStep(step.id)}
className="shrink-0 rounded p-1 text-muted-foreground opacity-0 hover:bg-red-500/20 hover:text-red-400 group-hover:opacity-100"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div> </div>
)} )}
</SortableStepWrapper> </SortableStepWrapper>

View File

@@ -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)

View File

@@ -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 */}

View File

@@ -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>

View 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,
}
}

View File

@@ -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; }

View 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}`)
}
}

View File

@@ -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

View File

@@ -233,39 +233,42 @@ 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">
<textarea <div className="flex items-end gap-3">
ref={inputRef} <textarea
value={input} ref={inputRef}
onChange={e => setInput(e.target.value)} value={input}
onKeyDown={handleKeyDown} onChange={e => setInput(e.target.value)}
placeholder="Ask about IT, networking, troubleshooting..." onKeyDown={handleKeyDown}
rows={3} placeholder="Ask about IT, networking, troubleshooting..."
className="flex-1 resize-none rounded-xl border bg-card text-foreground text-sm placeholder:text-muted-foreground px-4 py-3 focus:outline-none focus:border-[rgba(6,182,212,0.3)]" rows={3}
style={{ borderColor: 'var(--glass-border)' }} className="flex-1 resize-none rounded-xl border bg-card text-foreground text-sm placeholder:text-muted-foreground px-4 py-3 focus:outline-none focus:border-[rgba(6,182,212,0.3)]"
disabled={loading} style={{ borderColor: 'var(--glass-border)' }}
/> disabled={loading}
<div className="flex flex-col gap-2"> />
<button <div className="flex flex-col gap-2">
onClick={handleSend}
disabled={!input.trim() || loading}
className="bg-gradient-brand text-[#101114] p-3 rounded-xl hover:opacity-90 active:scale-[0.97] transition-all disabled:opacity-40"
title="Send message"
>
<Send size={18} />
</button>
{messages.length >= 2 && (
<button <button
onClick={() => setShowConclude(true)} onClick={handleSend}
disabled={loading} disabled={!input.trim() || loading}
className="p-3 rounded-xl border text-muted-foreground hover:text-amber-400 hover:border-amber-400/30 hover:bg-amber-400/10 transition-all disabled:opacity-40" className="bg-gradient-brand text-[#101114] p-3 rounded-xl hover:opacity-90 active:scale-[0.97] transition-all disabled:opacity-40"
style={{ borderColor: 'var(--glass-border)' }} title="Send message"
title="Conclude session"
> >
<Flag size={18} /> <Send size={18} />
</button> </button>
)} {messages.length >= 2 && (
<button
onClick={() => setShowConclude(true)}
disabled={loading}
className="p-3 rounded-xl border text-muted-foreground hover:text-amber-400 hover:border-amber-400/30 hover:bg-amber-400/10 transition-all disabled:opacity-40"
style={{ borderColor: 'var(--glass-border)' }}
title="Conclude session"
>
<Flag size={18} />
</button>
)}
</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> </div>
</> </>

View File

@@ -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>
) )
} }

View File

@@ -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>
) )
} }

View File

@@ -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>
) )

View File

@@ -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>
) )
} }

View File

@@ -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 */}
<TreeEditorLayout <div className="min-h-0 flex-1 overflow-hidden">
isMobile={isMobile} <TreeEditorLayout
isMetadataOpen={isMetadataOpen} isMobile={isMobile}
onCloseMetadata={() => setIsMetadataOpen(false)} isMetadataOpen={isMetadataOpen}
editingNodeId={editingNodeId} onCloseMetadata={() => setIsMetadataOpen(false)}
onNodeSelect={handleNodeSelect} editingNodeId={editorAI.isOpen ? null : editingNodeId}
onSelectAnswerType={handleSelectAnswerType} onNodeSelect={handleNodeSelect}
/> 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>
) )
} }

View File

@@ -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={() => setShowImportModal(true)}
onClick={() => navigate('/ai/chat')} 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"
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" >
> <FileUp className="h-4 w-4" />
<Sparkles className="h-4 w-4" /> Import
Flow Assist </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>
) )
} }

View File

@@ -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>
) )

View File

@@ -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: (

View File

@@ -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 }),
}))

View File

@@ -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'
}

View File

@@ -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 }),

View File

@@ -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 })
}, },

View File

@@ -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
}

View 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
}

View 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[]
}

View File

@@ -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'

View File

@@ -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 {