From ee9895de5d161e1863d366744e272d701984a0d5 Mon Sep 17 00:00:00 2001 From: chihlasm Date: Fri, 6 Mar 2026 00:03:59 -0500 Subject: [PATCH 01/43] 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 --- .../050_add_import_metadata_to_trees.py | 23 ++ backend/app/api/endpoints/tree_transfer.py | 333 ++++++++++++++++++ backend/app/api/endpoints/trees.py | 3 +- backend/app/api/router.py | 2 + backend/app/models/tree.py | 7 + backend/app/schemas/tree.py | 1 + backend/app/schemas/tree_export.py | 54 +++ 7 files changed, 422 insertions(+), 1 deletion(-) create mode 100644 backend/alembic/versions/050_add_import_metadata_to_trees.py create mode 100644 backend/app/api/endpoints/tree_transfer.py create mode 100644 backend/app/schemas/tree_export.py diff --git a/backend/alembic/versions/050_add_import_metadata_to_trees.py b/backend/alembic/versions/050_add_import_metadata_to_trees.py new file mode 100644 index 00000000..6c75390e --- /dev/null +++ b/backend/alembic/versions/050_add_import_metadata_to_trees.py @@ -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') diff --git a/backend/app/api/endpoints/tree_transfer.py b/backend/app/api/endpoints/tree_transfer.py new file mode 100644 index 00000000..4d335173 --- /dev/null +++ b/backend/app/api/endpoints/tree_transfer.py @@ -0,0 +1,333 @@ +"""Flow export/import endpoints (.rfflow files).""" +import json +import logging +import re +import xml.etree.ElementTree as ET +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) + + +def _build_xml(envelope: FlowExportEnvelope) -> str: + """Build XML representation of an .rfflow export.""" + root = ET.Element("rfflow") + root.set("version", envelope.rfflow_version) + + ET.SubElement(root, "exported_at").text = envelope.exported_at.isoformat() + ET.SubElement(root, "source_app").text = envelope.source_app + ET.SubElement(root, "format").text = "xml" + + flow_el = ET.SubElement(root, "flow") + flow = envelope.flow + + ET.SubElement(flow_el, "name").text = flow.name + ET.SubElement(flow_el, "description").text = flow.description or "" + ET.SubElement(flow_el, "tree_type").text = flow.tree_type + ET.SubElement(flow_el, "version").text = str(flow.version) + ET.SubElement(flow_el, "author_name").text = flow.author_name or "" + + if flow.category: + cat_el = ET.SubElement(flow_el, "category") + ET.SubElement(cat_el, "name").text = flow.category.name + ET.SubElement(cat_el, "slug").text = flow.category.slug + + tags_el = ET.SubElement(flow_el, "tags") + for tag in flow.tags: + ET.SubElement(tags_el, "tag").text = tag + + # Store tree_structure as JSON string in CDATA-safe text + ts_el = ET.SubElement(flow_el, "tree_structure") + ts_el.text = json.dumps(flow.tree_structure) + + if flow.intake_form: + if_el = ET.SubElement(flow_el, "intake_form") + if_el.text = json.dumps(flow.intake_form) + + ET.indent(root) + return ET.tostring(root, encoding="unicode", xml_declaration=True) + + +# --- 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)], + format: str = Query("json", regex="^(json|xml)$"), +): + """Export a tree as a downloadable .rfflow file (JSON or XML).""" + # 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", + format=format, + flow=flow_data, + ) + + slug = _slugify(tree.name) + + # Audit log + await log_audit(db, current_user.id, "tree.export", "tree", tree.id, {"format": format}) + await db.commit() + + if format == "xml": + content = _build_xml(envelope) + return Response( + content=content, + media_type="application/xml", + headers={"Content-Disposition": f'attachment; filename="{slug}.rfflow"'}, + ) + + # JSON + 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, + ) diff --git a/backend/app/api/endpoints/trees.py b/backend/app/api/endpoints/trees.py index 4ec8717d..c9fe3027 100644 --- a/backend/app/api/endpoints/trees.py +++ b/backend/app/api/endpoints/trees.py @@ -116,7 +116,8 @@ def build_full_tree_response(tree: Tree, parent_tree: Tree = None) -> TreeRespon version=tree.version, usage_count=tree.usage_count, created_at=tree.created_at, - updated_at=tree.updated_at + updated_at=tree.updated_at, + import_metadata=tree.import_metadata ) diff --git a/backend/app/api/router.py b/backend/app/api/router.py index 256d0de9..503357eb 100644 --- a/backend/app/api/router.py +++ b/backend/app/api/router.py @@ -12,6 +12,7 @@ from app.api.endpoints import copilot from app.api.endpoints import assistant_chat from app.api.endpoints import survey from app.api.endpoints import admin_survey +from app.api.endpoints import tree_transfer api_router = APIRouter() @@ -48,3 +49,4 @@ api_router.include_router(copilot.router) api_router.include_router(assistant_chat.router) api_router.include_router(survey.router) api_router.include_router(admin_survey.router) +api_router.include_router(tree_transfer.router) diff --git a/backend/app/models/tree.py b/backend/app/models/tree.py index 825e1d6c..eb05576c 100644 --- a/backend/app/models/tree.py +++ b/backend/app/models/tree.py @@ -154,6 +154,13 @@ class Tree(Base): 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 author: Mapped[Optional["User"]] = relationship("User", foreign_keys=[author_id], back_populates="trees") team: Mapped[Optional["Team"]] = relationship("Team", back_populates="trees") diff --git a/backend/app/schemas/tree.py b/backend/app/schemas/tree.py index dc9cf116..c9c8546b 100644 --- a/backend/app/schemas/tree.py +++ b/backend/app/schemas/tree.py @@ -161,6 +161,7 @@ class TreeResponse(TreeBase): created_at: datetime updated_at: datetime usage_count: int + import_metadata: Optional[dict[str, Any]] = None class Config: from_attributes = True diff --git a/backend/app/schemas/tree_export.py b/backend/app/schemas/tree_export.py new file mode 100644 index 00000000..dbdbef76 --- /dev/null +++ b/backend/app/schemas/tree_export.py @@ -0,0 +1,54 @@ +"""Schemas for .rfflow file export and import.""" +from datetime import datetime +from typing import Optional, Any, Literal +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" + format: Literal["json", "xml"] = "json" + 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" + format: Literal["json", "xml"] = "json" + 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] = [] -- 2.49.1 From 39677a384137a1cad8e9207a1be6c2dc97157f4f Mon Sep 17 00:00:00 2001 From: chihlasm Date: Fri, 6 Mar 2026 00:18:10 -0500 Subject: [PATCH 02/43] 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 --- backend/app/api/endpoints/tree_transfer.py | 2 +- backend/tests/test_tree_transfer.py | 364 ++++++++++++++++++ frontend/src/api/flowTransfer.ts | 18 + frontend/src/api/index.ts | 1 + .../components/library/ExportFlowModal.tsx | 132 +++++++ .../components/library/ImportFlowModal.tsx | 257 +++++++++++++ .../src/components/library/TreeGridView.tsx | 18 +- .../src/components/library/TreeListView.tsx | 18 +- .../src/components/library/TreeTableView.tsx | 18 +- frontend/src/lib/rfflowParser.ts | 153 ++++++++ frontend/src/pages/TreeEditorPage.tsx | 40 +- frontend/src/pages/TreeLibraryPage.tsx | 37 +- frontend/src/types/flowTransfer.ts | 34 ++ frontend/src/types/index.ts | 7 + frontend/src/types/tree.ts | 6 + 15 files changed, 1099 insertions(+), 6 deletions(-) create mode 100644 backend/tests/test_tree_transfer.py create mode 100644 frontend/src/api/flowTransfer.ts create mode 100644 frontend/src/components/library/ExportFlowModal.tsx create mode 100644 frontend/src/components/library/ImportFlowModal.tsx create mode 100644 frontend/src/lib/rfflowParser.ts create mode 100644 frontend/src/types/flowTransfer.ts diff --git a/backend/app/api/endpoints/tree_transfer.py b/backend/app/api/endpoints/tree_transfer.py index 4d335173..38a4123c 100644 --- a/backend/app/api/endpoints/tree_transfer.py +++ b/backend/app/api/endpoints/tree_transfer.py @@ -89,7 +89,7 @@ async def export_tree( tree_id: UUID, db: Annotated[AsyncSession, Depends(get_db)], current_user: Annotated[User, Depends(get_current_active_user)], - format: str = Query("json", regex="^(json|xml)$"), + format: str = Query("json", pattern="^(json|xml)$"), ): """Export a tree as a downloadable .rfflow file (JSON or XML).""" # Load tree with relationships + author name diff --git a/backend/tests/test_tree_transfer.py b/backend/tests/test_tree_transfer.py new file mode 100644 index 00000000..d211e9f9 --- /dev/null +++ b/backend/tests/test_tree_transfer.py @@ -0,0 +1,364 @@ +"""Tests for flow export/import (.rfflow) endpoints.""" +import json +import xml.etree.ElementTree as ET +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?format=json", + 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["format"] == "json" + 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_xml_format(client, auth_headers, test_tree): + """Export as XML should be parseable and contain all required fields.""" + resp = await client.get( + f"/api/v1/trees/{test_tree['id']}/export?format=xml", + headers=auth_headers, + ) + assert resp.status_code == 200 + assert ".rfflow" in resp.headers.get("content-disposition", "") + + # Parse XML + root = ET.fromstring(resp.text) + assert root.tag == "rfflow" + assert root.get("version") == "1.0" + + flow_el = root.find("flow") + assert flow_el is not None + assert flow_el.find("name").text == test_tree["name"] + + # tree_structure should be valid JSON + ts_text = flow_el.find("tree_structure").text + ts = json.loads(ts_text) + assert ts["id"] == "root" + + +@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?format=json", + 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']}"} + + # The other user is in a different account, so can't see the tree + resp = await client.get( + f"/api/v1/trees/{test_tree['id']}/export?format=json", + 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?format=json", + 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?format=json", + 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?format=json", + 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", + "format": "json", + "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", + "format": "json", + "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", + "format": "json", + "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?format=json", + 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 + + +@pytest.mark.asyncio +async def test_import_xml_round_trip(client, auth_headers): + """Export as XML then re-import the parsed structure should work.""" + original = await create_tree_with_tags(client, auth_headers) + + # Export as XML + export_resp = await client.get( + f"/api/v1/trees/{original['id']}/export?format=xml", + headers=auth_headers, + ) + assert export_resp.status_code == 200 + + # Parse XML to build import request (simulating frontend parser) + root = ET.fromstring(export_resp.text) + flow_el = root.find("flow") + tags = [t.text for t in flow_el.find("tags").findall("tag")] + ts = json.loads(flow_el.find("tree_structure").text) + + cat_el = flow_el.find("category") + category = None + if cat_el is not None: + cat_name = cat_el.find("name") + cat_slug = cat_el.find("slug") + if cat_name is not None and cat_slug is not None: + category = {"name": cat_name.text, "slug": cat_slug.text} + + rfflow = { + "rfflow_version": root.get("version"), + "exported_at": root.find("exported_at").text, + "source_app": root.find("source_app").text, + "format": "xml", + "flow": { + "name": flow_el.find("name").text, + "description": flow_el.find("description").text or None, + "tree_type": flow_el.find("tree_type").text, + "version": int(flow_el.find("version").text), + "author_name": flow_el.find("author_name").text or None, + "category": category, + "tags": tags, + "tree_structure": ts, + "intake_form": None, + }, + } + + import_resp = await client.post("/api/v1/trees/import", json=rfflow, headers=auth_headers) + assert import_resp.status_code == 201 + result = import_resp.json() + assert result["name"] == original["name"] diff --git a/frontend/src/api/flowTransfer.ts b/frontend/src/api/flowTransfer.ts new file mode 100644 index 00000000..42e40c63 --- /dev/null +++ b/frontend/src/api/flowTransfer.ts @@ -0,0 +1,18 @@ +import apiClient from './client' +import type { RFFlowFile, FlowImportResponse } from '@/types' + +export const flowTransferApi = { + async exportFlow(treeId: string, format: 'json' | 'xml' = 'json'): Promise { + const response = await apiClient.get(`/trees/${treeId}/export`, { + params: { format }, + responseType: 'blob', + }) + return response.data + }, + + async importFlow(data: RFFlowFile, nameOverride?: string): Promise { + const params = nameOverride ? { name_override: nameOverride } : undefined + const response = await apiClient.post('/trees/import', data, { params }) + return response.data + }, +} diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index 1fbade11..420aafb0 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -20,3 +20,4 @@ export { default as aiBuilderApi } from './aiBuilder' export { default as aiChatApi } from './aiChat' export { copilotApi } from './copilot' export { assistantChatApi } from './assistantChat' +export { flowTransferApi } from './flowTransfer' diff --git a/frontend/src/components/library/ExportFlowModal.tsx b/frontend/src/components/library/ExportFlowModal.tsx new file mode 100644 index 00000000..710d2acd --- /dev/null +++ b/frontend/src/components/library/ExportFlowModal.tsx @@ -0,0 +1,132 @@ +import { useState, useEffect } from 'react' +import { Download, X } from 'lucide-react' +import { flowTransferApi } from '@/api/flowTransfer' +import { toast } from '@/lib/toast' +import { cn } from '@/lib/utils' + +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 [format, setFormat] = useState<'json' | 'xml'>('json') + 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, format) + 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 ( +
+
e.stopPropagation()} + > + {/* Header */} +
+
+ +

Export Flow

+
+ +
+ + {/* Body */} +
+

+ Export {treeName} as an .rfflow file. +

+ +
+ Format + {(['json', 'xml'] as const).map((fmt) => ( + + ))} +
+
+ + {/* Footer */} +
+ + +
+
+
+ ) +} diff --git a/frontend/src/components/library/ImportFlowModal.tsx b/frontend/src/components/library/ImportFlowModal.tsx new file mode 100644 index 00000000..da442e4f --- /dev/null +++ b/frontend/src/components/library/ImportFlowModal.tsx @@ -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 = { + troubleshooting: 'Troubleshooting', + procedural: 'Project', + maintenance: 'Maintenance', +} + +export function ImportFlowModal({ onClose }: ImportFlowModalProps) { + const navigate = useNavigate() + const fileInputRef = useRef(null) + const [step, setStep] = useState<'pick' | 'preview'>('pick') + const [parsed, setParsed] = useState(null) + const [nameOverride, setNameOverride] = useState('') + const [parseError, setParseError] = useState(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) => { + 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 ( +
+
e.stopPropagation()} + > + {/* Header */} +
+
+ +

Import Flow

+
+ +
+ + {/* Body */} +
+ {step === 'pick' && ( +
+
fileInputRef.current?.click()} + onDragOver={(e) => { e.preventDefault(); setIsDragging(true) }} + onDragLeave={() => setIsDragging(false)} + onDrop={handleDrop} + > + +

+ Drop .rfflow file here or browse +

+

JSON or XML format

+
+ + {parseError && ( +
+ +

{parseError}

+
+ )} +
+ )} + + {step === 'preview' && parsed && ( +
+ {/* Editable name */} +
+ + 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' + )} + /> +
+ + {/* Flow info */} +
+
+ Type: + + {TYPE_LABELS[parsed.flow.tree_type] || parsed.flow.tree_type} + +
+ + {parsed.flow.description && ( +
+ Description: +

{parsed.flow.description}

+
+ )} + + {parsed.flow.category && ( +
+ Category: + {parsed.flow.category.name} +
+ )} + + {parsed.flow.tags.length > 0 && ( +
+ Tags: + {parsed.flow.tags.map((tag) => ( + + {tag} + + ))} +
+ )} + + {parsed.flow.author_name && ( +
+ Original author: + {parsed.flow.author_name} +
+ )} + +
+ Version: + v{parsed.flow.version} +
+
+ +

+ Flow will be imported as a draft. +

+
+ )} +
+ + {/* Footer */} +
+ {step === 'preview' && ( + + )} + + {step === 'preview' && ( + + )} +
+
+
+ ) +} diff --git a/frontend/src/components/library/TreeGridView.tsx b/frontend/src/components/library/TreeGridView.tsx index dd1a3c1c..3fb34531 100644 --- a/frontend/src/components/library/TreeGridView.tsx +++ b/frontend/src/components/library/TreeGridView.tsx @@ -1,5 +1,5 @@ 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 { TagBadges } from '@/components/common/TagBadges' import { cn } from '@/lib/utils' @@ -13,6 +13,7 @@ interface TreeGridViewProps { onFolderCreated: (parentId?: string | null) => void onDeleteTree: (tree: TreeListItem) => void onForkTree?: (treeId: string) => void + onExportTree?: (treeId: string) => void pinnedTreeIds?: Set onTogglePin?: (treeId: string) => void pinLoadingTreeIds?: Set @@ -24,6 +25,7 @@ export function TreeGridView({ onTagClick, onDeleteTree, onForkTree, + onExportTree, pinnedTreeIds, onTogglePin, pinLoadingTreeIds, @@ -111,6 +113,20 @@ export function TreeGridView({ v{tree.version} · {tree.usage_count} uses
+ {onExportTree && ( + + )} {onForkTree && ( )} + {onExportTree && ( + + )} {onForkTree && ( + )} {onForkTree && ( + )} +
) } diff --git a/frontend/src/pages/TreeLibraryPage.tsx b/frontend/src/pages/TreeLibraryPage.tsx index 6aa05e99..1cbc7014 100644 --- a/frontend/src/pages/TreeLibraryPage.tsx +++ b/frontend/src/pages/TreeLibraryPage.tsx @@ -1,6 +1,6 @@ import { useEffect, useState, useCallback, useMemo } from 'react' import { useNavigate, useSearchParams } from 'react-router-dom' -import { X, RotateCcw, Play, Sparkles } from 'lucide-react' +import { X, RotateCcw, Play, Sparkles, FileUp } from 'lucide-react' import { treesApi } from '@/api/trees' import { categoriesApi } from '@/api/categories' import { foldersApi } from '@/api/folders' @@ -8,6 +8,8 @@ import { sessionsApi } from '@/api/sessions' import type { TreeListItem, CategoryListItem, FolderListItem, Session } from '@/types' import { FolderEditModal } from '@/components/library/FolderEditModal' 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 { TreeGridView } from '@/components/library/TreeGridView' import { TreeListView } from '@/components/library/TreeListView' @@ -76,6 +78,10 @@ export function TreeLibraryPage() { // Fork modal state const [forkTarget, setForkTarget] = useState(null) + // Import/Export modal state + const [showImportModal, setShowImportModal] = useState(false) + const [exportTarget, setExportTarget] = useState(null) + // AI builder state const [showAIBuilder, setShowAIBuilder] = useState(false) const { aiEnabled } = useCachedQuota() @@ -250,6 +256,11 @@ export function TreeLibraryPage() { if (tree) setForkTarget(tree) } + const handleExportTree = (treeId: string) => { + const tree = trees.find((t) => t.id === treeId) + if (tree) setExportTarget(tree) + } + const hasActiveFilters = selectedCategoryId || selectedTags.length > 0 || searchQuery || selectedFolderId @@ -282,6 +293,13 @@ export function TreeLibraryPage() { Flow Assist )} + setShowAIBuilder(true)} @@ -493,6 +511,7 @@ export function TreeLibraryPage() { setShowDeleteConfirm(true) }} onForkTree={handleForkTree} + onExportTree={handleExportTree} pinnedTreeIds={pinnedTreeIds} onTogglePin={togglePin} pinLoadingTreeIds={pinLoadingTreeIds} @@ -509,6 +528,7 @@ export function TreeLibraryPage() { setShowDeleteConfirm(true) }} onForkTree={handleForkTree} + onExportTree={handleExportTree} pinnedTreeIds={pinnedTreeIds} onTogglePin={togglePin} pinLoadingTreeIds={pinLoadingTreeIds} @@ -530,6 +550,7 @@ export function TreeLibraryPage() { ) }} onForkTree={handleForkTree} + onExportTree={handleExportTree} pinnedTreeIds={pinnedTreeIds} onTogglePin={togglePin} pinLoadingTreeIds={pinLoadingTreeIds} @@ -582,6 +603,20 @@ export function TreeLibraryPage() { onClose={() => setForkTarget(null)} /> )} + + {exportTarget && ( + setExportTarget(null)} + /> + )} + + {showImportModal && ( + { setShowImportModal(false); loadTrees() }} + /> + )} ) } diff --git a/frontend/src/types/flowTransfer.ts b/frontend/src/types/flowTransfer.ts new file mode 100644 index 00000000..dd7e10db --- /dev/null +++ b/frontend/src/types/flowTransfer.ts @@ -0,0 +1,34 @@ +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 + intake_form: Record[] | null +} + +export interface RFFlowFile { + rfflow_version: string + exported_at: string + source_app: string + format: 'json' | 'xml' + flow: FlowExportData +} + +export interface FlowImportResponse { + tree_id: string + name: string + tree_type: string + status: string + category_created: boolean + tags_created: string[] + validation_warnings: string[] +} diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 6d2098a3..9a2cde9f 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -64,3 +64,10 @@ export type { AIChatGenerateResponse, AIChatImportResponse, } from './ai-chat' + +export type { + RFFlowFile, + FlowExportData, + FlowExportCategory, + FlowImportResponse, +} from './flowTransfer' diff --git a/frontend/src/types/tree.ts b/frontend/src/types/tree.ts index f9bd54ff..9294f6d9 100644 --- a/frontend/src/types/tree.ts +++ b/frontend/src/types/tree.ts @@ -176,6 +176,12 @@ export interface Tree { updated_at: string usage_count: number fork_info: ForkInfo | null + import_metadata: { + original_author_name?: string | null + exported_at?: string + imported_at?: string + source_app?: string + } | null } export interface TreeListItem { -- 2.49.1 From 88c1553c5daef3ccc1d99bfd1a4f635238f61fc5 Mon Sep 17 00:00:00 2001 From: chihlasm Date: Fri, 6 Mar 2026 00:56:07 -0500 Subject: [PATCH 03/43] 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 --- backend/app/api/endpoints/tree_transfer.py | 56 +-------- backend/app/schemas/tree_export.py | 4 +- backend/tests/test_tree_transfer.py | 96 ++------------- frontend/src/api/flowTransfer.ts | 3 +- .../components/library/ExportFlowModal.tsx | 40 +------ .../components/library/ImportFlowModal.tsx | 2 +- frontend/src/lib/rfflowParser.ts | 113 +----------------- frontend/src/types/flowTransfer.ts | 1 - 8 files changed, 22 insertions(+), 293 deletions(-) diff --git a/backend/app/api/endpoints/tree_transfer.py b/backend/app/api/endpoints/tree_transfer.py index 38a4123c..9dee0abe 100644 --- a/backend/app/api/endpoints/tree_transfer.py +++ b/backend/app/api/endpoints/tree_transfer.py @@ -1,8 +1,6 @@ """Flow export/import endpoints (.rfflow files).""" -import json import logging import re -import xml.etree.ElementTree as ET from datetime import datetime, timezone from typing import Annotated, Optional from uuid import UUID @@ -43,45 +41,6 @@ def _slugify(name: str) -> str: return re.sub(r'[-\s]+', '-', slug) -def _build_xml(envelope: FlowExportEnvelope) -> str: - """Build XML representation of an .rfflow export.""" - root = ET.Element("rfflow") - root.set("version", envelope.rfflow_version) - - ET.SubElement(root, "exported_at").text = envelope.exported_at.isoformat() - ET.SubElement(root, "source_app").text = envelope.source_app - ET.SubElement(root, "format").text = "xml" - - flow_el = ET.SubElement(root, "flow") - flow = envelope.flow - - ET.SubElement(flow_el, "name").text = flow.name - ET.SubElement(flow_el, "description").text = flow.description or "" - ET.SubElement(flow_el, "tree_type").text = flow.tree_type - ET.SubElement(flow_el, "version").text = str(flow.version) - ET.SubElement(flow_el, "author_name").text = flow.author_name or "" - - if flow.category: - cat_el = ET.SubElement(flow_el, "category") - ET.SubElement(cat_el, "name").text = flow.category.name - ET.SubElement(cat_el, "slug").text = flow.category.slug - - tags_el = ET.SubElement(flow_el, "tags") - for tag in flow.tags: - ET.SubElement(tags_el, "tag").text = tag - - # Store tree_structure as JSON string in CDATA-safe text - ts_el = ET.SubElement(flow_el, "tree_structure") - ts_el.text = json.dumps(flow.tree_structure) - - if flow.intake_form: - if_el = ET.SubElement(flow_el, "intake_form") - if_el.text = json.dumps(flow.intake_form) - - ET.indent(root) - return ET.tostring(root, encoding="unicode", xml_declaration=True) - - # --- Export --- @router.get("/{tree_id}/export") @@ -89,9 +48,8 @@ async def export_tree( tree_id: UUID, db: Annotated[AsyncSession, Depends(get_db)], current_user: Annotated[User, Depends(get_current_active_user)], - format: str = Query("json", pattern="^(json|xml)$"), ): - """Export a tree as a downloadable .rfflow file (JSON or XML).""" + """Export a tree as a downloadable .rfflow JSON file.""" # Load tree with relationships + author name result = await db.execute( select(Tree) @@ -139,25 +97,15 @@ async def export_tree( rfflow_version="1.0", exported_at=datetime.now(timezone.utc), source_app="ResolutionFlow", - format=format, flow=flow_data, ) slug = _slugify(tree.name) # Audit log - await log_audit(db, current_user.id, "tree.export", "tree", tree.id, {"format": format}) + await log_audit(db, current_user.id, "tree.export", "tree", tree.id) await db.commit() - if format == "xml": - content = _build_xml(envelope) - return Response( - content=content, - media_type="application/xml", - headers={"Content-Disposition": f'attachment; filename="{slug}.rfflow"'}, - ) - - # JSON content = envelope.model_dump_json(indent=2) return Response( content=content, diff --git a/backend/app/schemas/tree_export.py b/backend/app/schemas/tree_export.py index dbdbef76..730467e0 100644 --- a/backend/app/schemas/tree_export.py +++ b/backend/app/schemas/tree_export.py @@ -1,6 +1,6 @@ """Schemas for .rfflow file export and import.""" from datetime import datetime -from typing import Optional, Any, Literal +from typing import Optional, Any from pydantic import BaseModel, Field from app.schemas.tree import TreeType @@ -30,7 +30,6 @@ class FlowExportEnvelope(BaseModel): rfflow_version: str = "1.0" exported_at: datetime source_app: str = "ResolutionFlow" - format: Literal["json", "xml"] = "json" flow: FlowExportData @@ -39,7 +38,6 @@ class FlowImportRequest(BaseModel): rfflow_version: str = Field(..., description="Must be '1.0'") exported_at: datetime source_app: str = "ResolutionFlow" - format: Literal["json", "xml"] = "json" flow: FlowExportData diff --git a/backend/tests/test_tree_transfer.py b/backend/tests/test_tree_transfer.py index d211e9f9..0a18d7bf 100644 --- a/backend/tests/test_tree_transfer.py +++ b/backend/tests/test_tree_transfer.py @@ -1,6 +1,4 @@ """Tests for flow export/import (.rfflow) endpoints.""" -import json -import xml.etree.ElementTree as ET import pytest from httpx import AsyncClient @@ -41,7 +39,7 @@ async def create_tree_with_tags(client: AsyncClient, headers: dict, data: dict | 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?format=json", + f"/api/v1/trees/{test_tree['id']}/export", headers=auth_headers, ) assert resp.status_code == 200 @@ -51,7 +49,6 @@ async def test_export_json_format(client, auth_headers, test_tree): data = resp.json() assert data["rfflow_version"] == "1.0" assert data["source_app"] == "ResolutionFlow" - assert data["format"] == "json" assert data["exported_at"] is not None flow = data["flow"] @@ -65,37 +62,12 @@ async def test_export_json_format(client, auth_headers, test_tree): assert "account_id" not in flow -@pytest.mark.asyncio -async def test_export_xml_format(client, auth_headers, test_tree): - """Export as XML should be parseable and contain all required fields.""" - resp = await client.get( - f"/api/v1/trees/{test_tree['id']}/export?format=xml", - headers=auth_headers, - ) - assert resp.status_code == 200 - assert ".rfflow" in resp.headers.get("content-disposition", "") - - # Parse XML - root = ET.fromstring(resp.text) - assert root.tag == "rfflow" - assert root.get("version") == "1.0" - - flow_el = root.find("flow") - assert flow_el is not None - assert flow_el.find("name").text == test_tree["name"] - - # tree_structure should be valid JSON - ts_text = flow_el.find("tree_structure").text - ts = json.loads(ts_text) - assert ts["id"] == "root" - - @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?format=json", + f"/api/v1/trees/{tree['id']}/export", headers=auth_headers, ) assert resp.status_code == 200 @@ -121,9 +93,8 @@ async def test_export_access_control(client, auth_headers, test_admin, admin_aut }) other_headers = {"Authorization": f"Bearer {login_resp.json()['access_token']}"} - # The other user is in a different account, so can't see the tree resp = await client.get( - f"/api/v1/trees/{test_tree['id']}/export?format=json", + f"/api/v1/trees/{test_tree['id']}/export", headers=other_headers, ) assert resp.status_code == 403 @@ -134,7 +105,7 @@ 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?format=json", + f"/api/v1/trees/{uuid.uuid4()}/export", headers=auth_headers, ) assert resp.status_code == 404 @@ -147,7 +118,7 @@ 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?format=json", + f"/api/v1/trees/{test_tree['id']}/export", headers=auth_headers, ) rfflow_data = export_resp.json() @@ -180,7 +151,7 @@ async def test_import_happy_path(client, auth_headers, test_tree): 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?format=json", + f"/api/v1/trees/{test_tree['id']}/export", headers=auth_headers, ) rfflow_data = export_resp.json() @@ -201,7 +172,6 @@ async def test_import_with_new_tags(client, auth_headers): "rfflow_version": "1.0", "exported_at": "2026-03-05T14:30:00+00:00", "source_app": "ResolutionFlow", - "format": "json", "flow": { "name": "Test Import Tags", "description": "Testing tag creation", @@ -235,7 +205,6 @@ async def test_import_with_category_creation(client, auth_headers): "rfflow_version": "1.0", "exported_at": "2026-03-05T14:30:00+00:00", "source_app": "ResolutionFlow", - "format": "json", "flow": { "name": "Import Category Test", "description": None, @@ -267,7 +236,6 @@ async def test_import_invalid_version(client, auth_headers): "rfflow_version": "99.0", "exported_at": "2026-03-05T14:30:00+00:00", "source_app": "ResolutionFlow", - "format": "json", "flow": { "name": "Bad Version", "tree_type": "troubleshooting", @@ -288,7 +256,7 @@ async def test_import_round_trip(client, auth_headers): # Export export_resp = await client.get( - f"/api/v1/trees/{original['id']}/export?format=json", + f"/api/v1/trees/{original['id']}/export", headers=auth_headers, ) rfflow = export_resp.json() @@ -312,53 +280,3 @@ async def test_import_round_trip(client, auth_headers): 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 - - -@pytest.mark.asyncio -async def test_import_xml_round_trip(client, auth_headers): - """Export as XML then re-import the parsed structure should work.""" - original = await create_tree_with_tags(client, auth_headers) - - # Export as XML - export_resp = await client.get( - f"/api/v1/trees/{original['id']}/export?format=xml", - headers=auth_headers, - ) - assert export_resp.status_code == 200 - - # Parse XML to build import request (simulating frontend parser) - root = ET.fromstring(export_resp.text) - flow_el = root.find("flow") - tags = [t.text for t in flow_el.find("tags").findall("tag")] - ts = json.loads(flow_el.find("tree_structure").text) - - cat_el = flow_el.find("category") - category = None - if cat_el is not None: - cat_name = cat_el.find("name") - cat_slug = cat_el.find("slug") - if cat_name is not None and cat_slug is not None: - category = {"name": cat_name.text, "slug": cat_slug.text} - - rfflow = { - "rfflow_version": root.get("version"), - "exported_at": root.find("exported_at").text, - "source_app": root.find("source_app").text, - "format": "xml", - "flow": { - "name": flow_el.find("name").text, - "description": flow_el.find("description").text or None, - "tree_type": flow_el.find("tree_type").text, - "version": int(flow_el.find("version").text), - "author_name": flow_el.find("author_name").text or None, - "category": category, - "tags": tags, - "tree_structure": ts, - "intake_form": None, - }, - } - - import_resp = await client.post("/api/v1/trees/import", json=rfflow, headers=auth_headers) - assert import_resp.status_code == 201 - result = import_resp.json() - assert result["name"] == original["name"] diff --git a/frontend/src/api/flowTransfer.ts b/frontend/src/api/flowTransfer.ts index 42e40c63..f1bb82be 100644 --- a/frontend/src/api/flowTransfer.ts +++ b/frontend/src/api/flowTransfer.ts @@ -2,9 +2,8 @@ import apiClient from './client' import type { RFFlowFile, FlowImportResponse } from '@/types' export const flowTransferApi = { - async exportFlow(treeId: string, format: 'json' | 'xml' = 'json'): Promise { + async exportFlow(treeId: string): Promise { const response = await apiClient.get(`/trees/${treeId}/export`, { - params: { format }, responseType: 'blob', }) return response.data diff --git a/frontend/src/components/library/ExportFlowModal.tsx b/frontend/src/components/library/ExportFlowModal.tsx index 710d2acd..5d044dce 100644 --- a/frontend/src/components/library/ExportFlowModal.tsx +++ b/frontend/src/components/library/ExportFlowModal.tsx @@ -2,7 +2,6 @@ import { useState, useEffect } from 'react' import { Download, X } from 'lucide-react' import { flowTransferApi } from '@/api/flowTransfer' import { toast } from '@/lib/toast' -import { cn } from '@/lib/utils' interface ExportFlowModalProps { treeId: string @@ -15,7 +14,6 @@ function slugify(name: string): string { } export function ExportFlowModal({ treeId, treeName, onClose }: ExportFlowModalProps) { - const [format, setFormat] = useState<'json' | 'xml'>('json') const [isExporting, setIsExporting] = useState(false) useEffect(() => { @@ -29,7 +27,7 @@ export function ExportFlowModal({ treeId, treeName, onClose }: ExportFlowModalPr const handleExport = async () => { setIsExporting(true) try { - const blob = await flowTransferApi.exportFlow(treeId, format) + const blob = await flowTransferApi.exportFlow(treeId) const url = URL.createObjectURL(blob) const a = document.createElement('a') a.href = url @@ -73,40 +71,10 @@ export function ExportFlowModal({ treeId, treeName, onClose }: ExportFlowModalPr {/* Body */} -
-

- Export {treeName} as an .rfflow file. +

+

+ Export {treeName} as a .rfflow file (JSON format).

- -
- Format - {(['json', 'xml'] as const).map((fmt) => ( - - ))} -
{/* Footer */} diff --git a/frontend/src/components/library/ImportFlowModal.tsx b/frontend/src/components/library/ImportFlowModal.tsx index da442e4f..22be13cb 100644 --- a/frontend/src/components/library/ImportFlowModal.tsx +++ b/frontend/src/components/library/ImportFlowModal.tsx @@ -132,7 +132,7 @@ export function ImportFlowModal({ onClose }: ImportFlowModalProps) {

Drop .rfflow file here or browse

-

JSON or XML format

+

JSON format

') - } - - const getText = (parent: Element, tag: string): string => { - const el = parent.querySelector(`:scope > ${tag}`) - return el?.textContent?.trim() ?? '' - } - - const rfflowVersion = root.getAttribute('version') || getText(root, 'rfflow_version') || '1.0' - const exportedAt = getText(root, 'exported_at') - const sourceApp = getText(root, 'source_app') || 'ResolutionFlow' - - const flowEl = root.querySelector(':scope > flow') - if (!flowEl) { - throw new RFFlowParseError('Missing element') - } - - // Parse category - let category: FlowExportCategory | null = null - const catEl = flowEl.querySelector(':scope > category') - if (catEl) { - const catName = getText(catEl, 'name') - const catSlug = getText(catEl, 'slug') - if (catName && catSlug) { - category = { name: catName, slug: catSlug } - } - } - - // Parse tags - const tags: string[] = [] - const tagsEl = flowEl.querySelector(':scope > tags') - if (tagsEl) { - tagsEl.querySelectorAll(':scope > tag').forEach((tagEl) => { - const val = tagEl.textContent?.trim() - if (val) tags.push(val) - }) - } - - // Parse tree_structure (stored as JSON string) - const tsText = getText(flowEl, 'tree_structure') - let treeStructure: Record - try { - treeStructure = JSON.parse(tsText) - } catch { - throw new RFFlowParseError('Invalid tree_structure JSON in XML') - } - - // Parse intake_form (optional, stored as JSON string) - let intakeForm: Record[] | null = null - const ifText = getText(flowEl, 'intake_form') - if (ifText) { - try { - intakeForm = JSON.parse(ifText) - } catch { - throw new RFFlowParseError('Invalid intake_form JSON in XML') - } - } - - const flow: FlowExportData = { - name: getText(flowEl, 'name'), - description: getText(flowEl, 'description') || null, - tree_type: getText(flowEl, 'tree_type') as FlowExportData['tree_type'], - version: parseInt(getText(flowEl, 'version') || '1', 10), - author_name: getText(flowEl, 'author_name') || null, - category, - tags, - tree_structure: treeStructure, - intake_form: intakeForm, - } - - const result: RFFlowFile = { - rfflow_version: rfflowVersion, - exported_at: exportedAt, - source_app: sourceApp, - format: 'xml', - flow, - } - - validateEnvelope(result) - return result -} - function validateEnvelope(data: unknown): asserts data is RFFlowFile { const obj = data as Record @@ -146,8 +45,8 @@ function validateEnvelope(data: unknown): asserts data is RFFlowFile { throw new RFFlowParseError('Flow must have a tree_structure') } - const validTypes = ['troubleshooting', 'procedural', 'maintenance'] - if (!validTypes.includes(flow.tree_type as string)) { + 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}`) } } diff --git a/frontend/src/types/flowTransfer.ts b/frontend/src/types/flowTransfer.ts index dd7e10db..a9a31d21 100644 --- a/frontend/src/types/flowTransfer.ts +++ b/frontend/src/types/flowTransfer.ts @@ -19,7 +19,6 @@ export interface RFFlowFile { rfflow_version: string exported_at: string source_app: string - format: 'json' | 'xml' flow: FlowExportData } -- 2.49.1 From 07a723c687d534ff03ec1c2cac834f55ecab420d Mon Sep 17 00:00:00 2001 From: chihlasm Date: Fri, 6 Mar 2026 01:05:28 -0500 Subject: [PATCH 04/43] fix: match Flow Assist chat input to AI Assistant styling + strengthen one-question prompt Co-Authored-By: Claude Opus 4.6 --- backend/app/core/ai_chat_service.py | 2 +- frontend/src/components/ai-chat/ChatInput.tsx | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/backend/app/core/ai_chat_service.py b/backend/app/core/ai_chat_service.py index 948f4701..cd960a90 100644 --- a/backend/app/core/ai_chat_service.py +++ b/backend/app/core/ai_chat_service.py @@ -44,7 +44,7 @@ CRITICAL BEHAVIORS: - 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?" - 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.""" SCHEMA_CONTEXT = """ diff --git a/frontend/src/components/ai-chat/ChatInput.tsx b/frontend/src/components/ai-chat/ChatInput.tsx index c10f4970..f89173d0 100644 --- a/frontend/src/components/ai-chat/ChatInput.tsx +++ b/frontend/src/components/ai-chat/ChatInput.tsx @@ -46,14 +46,15 @@ export function ChatInput({ onSend, disabled, placeholder = 'Type a message...' onInput={handleInput} placeholder={placeholder} disabled={disabled} - rows={1} + rows={3} className={cn( - 'flex-1 resize-none rounded-lg border border-border bg-background px-3 py-2', + 'flex-1 resize-none rounded-xl border bg-card px-4 py-3', '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' )} + style={{ borderColor: 'var(--glass-border)' }} /> + )} + +
+ + {/* Procedural */} setShowMenu(false)} className="flex items-center gap-3 rounded-md px-3 py-2.5 text-sm text-foreground hover:bg-accent" > -
+
Procedural Flow
Step-by-step procedure
+ {aiEnabled && ( + + )} + +
+ + {/* Maintenance */} setShowMenu(false)} className="flex items-center gap-3 rounded-md px-3 py-2.5 text-sm text-foreground hover:bg-accent" > -
+
Maintenance Flow
Scheduled multi-target tasks
- {aiEnabled && ( - <> -
- - - )}
)} diff --git a/frontend/src/pages/QuickStartPage.tsx b/frontend/src/pages/QuickStartPage.tsx index deb86fee..999ae934 100644 --- a/frontend/src/pages/QuickStartPage.tsx +++ b/frontend/src/pages/QuickStartPage.tsx @@ -17,7 +17,7 @@ import { TreeGridView } from '@/components/library/TreeGridView' import { TreeListView } from '@/components/library/TreeListView' import { TreeTableView } from '@/components/library/TreeTableView' import { ViewToggle } from '@/components/library/ViewToggle' -import { AIFlowBuilderModal } from '@/components/ai-builder/AIFlowBuilderModal' + import { CreateFlowDropdown } from '@/components/common/CreateFlowDropdown' import { cn } from '@/lib/utils' import { toast } from '@/lib/toast' @@ -67,7 +67,6 @@ export function QuickStartPage() { const [showAllFavorites, setShowAllFavorites] = useState(false) // AI Builder - const [showAIBuilder, setShowAIBuilder] = useState(false) const { aiEnabled } = useCachedQuota() // Tab state @@ -454,7 +453,7 @@ export function QuickStartPage() { {activeTab === 'mine' && canCreateTrees && ( setShowAIBuilder(true)} + /> )} @@ -481,7 +480,7 @@ export function QuickStartPage() { {activeTab === 'mine' && canCreateTrees && ( setShowAIBuilder(true)} + label="Create your first flow" /> )} @@ -646,13 +645,6 @@ export function QuickStartPage() {
)} - {/* AI Builder Modal */} - {showAIBuilder && ( - setShowAIBuilder(false)} - /> - )}
) } diff --git a/frontend/src/pages/TreeLibraryPage.tsx b/frontend/src/pages/TreeLibraryPage.tsx index 1cbc7014..8756764d 100644 --- a/frontend/src/pages/TreeLibraryPage.tsx +++ b/frontend/src/pages/TreeLibraryPage.tsx @@ -22,7 +22,6 @@ import { usePermissions } from '@/hooks/usePermissions' import { useUserPreferencesStore } from '@/store/userPreferencesStore' import { usePinnedFlowsStore } from '@/store/pinnedFlowsStore' import { useCachedQuota } from '@/hooks/useCachedQuota' -import { AIFlowBuilderModal } from '@/components/ai-builder/AIFlowBuilderModal' import { CreateFlowDropdown } from '@/components/common/CreateFlowDropdown' import { Spinner } from '@/components/common/Spinner' import { EmptyState } from '@/components/common/EmptyState' @@ -83,7 +82,7 @@ export function TreeLibraryPage() { const [exportTarget, setExportTarget] = useState(null) // AI builder state - const [showAIBuilder, setShowAIBuilder] = useState(false) + const { aiEnabled } = useCachedQuota() // Pin store @@ -286,7 +285,7 @@ export function TreeLibraryPage() {
{aiEnabled && ( setShowAIBuilder(true)} + label="Create New" />
@@ -588,13 +587,6 @@ export function TreeLibraryPage() { isLoading={isDeleting} /> - {/* AI Builder Modal */} - {showAIBuilder && ( - setShowAIBuilder(false)} - /> - )} {forkTarget && ( Date: Fri, 6 Mar 2026 14:28:14 -0500 Subject: [PATCH 07/43] docs: update CLAUDE.md with AI chat builder and intake form learnings Co-Authored-By: Claude Opus 4.6 --- CLAUDE.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index 0446a7bf..0d03f2d3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -319,6 +319,12 @@ navigate(`/trees/${newTree.id}/edit`) **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` navigates directly to Flow Assist:** No `onOpenAIBuilder` callback — it uses `navigate('/ai/chat?type=procedural')` etc. The `AIFlowBuilderModal` is the old wizard modal (separate from the chat-based Flow Assist page at `/ai/chat`). + --- ## RBAC & Permissions -- 2.49.1 From 1001f8799204ab2aae4c76189a40bf68e7d300b2 Mon Sep 17 00:00:00 2001 From: Michael Chihlas Date: Fri, 6 Mar 2026 17:47:30 -0500 Subject: [PATCH 08/43] fix: refine assistant chat prompt for concise answers and focused questions Co-Authored-By: Claude Opus 4.6 --- backend/app/services/assistant_chat_service.py | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/backend/app/services/assistant_chat_service.py b/backend/app/services/assistant_chat_service.py index 22d8bb26..86390608 100644 --- a/backend/app/services/assistant_chat_service.py +++ b/backend/app/services/assistant_chat_service.py @@ -40,8 +40,8 @@ deep expertise across the MSP technology stack: - Security: MFA, Conditional Access, EDR, backup/DR ## How to Answer -- **Be direct and actionable.** Engineers are mid-ticket — give them the answer, \ -not a lecture. Lead with the fix, then explain why. +- **Be direct and actionable.** Engineers are mid-ticket — lead with the fix or next \ +diagnostic step, then explain why in one sentence if helpful. Skip background unless asked. - **Include specifics.** Exact commands, registry paths, config values, port numbers. \ Vague advice wastes time. - **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 \ 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 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 \ -- 2.49.1 From 5d97d0f9fe8ad34367bf6f753fc5a8e12f0885ec Mon Sep 17 00:00:00 2001 From: Michael Chihlas Date: Fri, 6 Mar 2026 18:16:17 -0500 Subject: [PATCH 09/43] 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 --- backend/app/core/config.py | 6 +- frontend/src/components/ai-chat/ChatInput.tsx | 5 +- .../src/components/copilot/CopilotPanel.tsx | 1 + frontend/src/pages/AssistantChatPage.tsx | 61 ++++++++++--------- 4 files changed, 40 insertions(+), 33 deletions(-) diff --git a/backend/app/core/config.py b/backend/app/core/config.py index f1b0edc2..a55d2e96 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -74,15 +74,15 @@ class Settings(BaseSettings): # AI Flow Builder 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_MAX_CALLS_PER_FLOW: int = 10 AI_REQUEST_TIMEOUT_SECONDS: int = 45 # AI Provider selection - AI_PROVIDER: str = "gemini" # "gemini" or "anthropic" + AI_PROVIDER: str = "anthropic" # "gemini" or "anthropic" GOOGLE_AI_API_KEY: Optional[str] = None 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" # MCP (Model Context Protocol) integrations ENABLE_MCP_MICROSOFT_LEARN: bool = True diff --git a/frontend/src/components/ai-chat/ChatInput.tsx b/frontend/src/components/ai-chat/ChatInput.tsx index f89173d0..987b6b8c 100644 --- a/frontend/src/components/ai-chat/ChatInput.tsx +++ b/frontend/src/components/ai-chat/ChatInput.tsx @@ -37,7 +37,8 @@ export function ChatInput({ onSend, disabled, placeholder = 'Type a message...' } return ( -
+
+