CI surfaced react-hooks/set-state-in-effect on the synchronous setState(computeState(token)) inside the useEffect body. The earlier shape mirrored token -> state via an effect, which is exactly the "you might not need an effect" pattern React 19's eslint rule now flags. Switch to derived state: compute during render, use a useReducer tick to force re-render on the 30s cadence (so relative timestamps stay current even when token props don't change). Same observable behavior, no cascading renders. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2803 lines
79 KiB
Markdown
2803 lines
79 KiB
Markdown
# Editor-Embedded Flow Assist - Implementation Plan
|
|
|
|
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
|
|
|
**Goal:** Replace the standalone `/ai/chat` page with context-aware AI side panels embedded in the Troubleshooting and Procedural editors, supporting targeted actions, ghost node suggestions, and config-driven model routing.
|
|
|
|
**Architecture:** Backend extends existing AI chat service with action-type dispatch, delta responses, and model tier routing. Frontend adds a shared `EditorAIPanel` component, `ContextMenu` component, and `useEditorAI` hook that integrate into both editors. Ghost nodes use a `_suggestion` flag in tree/step structures with zundo pause/resume for clean undo history.
|
|
|
|
**Tech Stack:** FastAPI, SQLAlchemy, Alembic, React 19, Zustand (zundo), Tailwind CSS, Anthropic/Google AI SDK
|
|
|
|
**Design doc:** `docs/plans/2026-03-06-editor-embedded-flow-assist-design.md`
|
|
|
|
---
|
|
|
|
## Phase 1: Bug Fix + Backend Foundation
|
|
|
|
### Task 1: Fix Orphan Validation False Positive
|
|
|
|
**Files:**
|
|
- Modify: `frontend/src/store/treeEditorStore.ts:858`
|
|
|
|
**Step 1: Locate and fix the bug**
|
|
|
|
In `frontend/src/store/treeEditorStore.ts`, line 858, change:
|
|
|
|
```typescript
|
|
// BEFORE (line 858)
|
|
if (id !== 'root' && !referencedIds.has(id)) {
|
|
|
|
// AFTER
|
|
if (id !== state.treeStructure?.id && !referencedIds.has(id)) {
|
|
```
|
|
|
|
The root node's ID is not always `'root'` — AI-generated trees use descriptive IDs like `"verify-account-exists"`. The root is never referenced by `next_node_id` so it gets flagged as orphaned.
|
|
|
|
**Step 2: Verify the fix**
|
|
|
|
Run: `cd frontend && npm run build`
|
|
Expected: Build succeeds with no type errors.
|
|
|
|
**Step 3: Commit**
|
|
|
|
```bash
|
|
git add frontend/src/store/treeEditorStore.ts
|
|
git commit -m "fix: use actual root node ID in orphan validation check"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 2: Add Model Tier Configuration
|
|
|
|
**Files:**
|
|
- Modify: `backend/app/core/config.py:77-85`
|
|
|
|
**Step 1: Write the failing test**
|
|
|
|
Create test in `backend/tests/test_config_model_tiers.py`:
|
|
|
|
```python
|
|
"""Tests for AI model tier configuration."""
|
|
from app.core.config import settings
|
|
|
|
|
|
def test_ai_model_tiers_exist():
|
|
"""Model tier config has fast and standard entries."""
|
|
assert "fast" in settings.AI_MODEL_TIERS
|
|
assert "standard" in settings.AI_MODEL_TIERS
|
|
|
|
|
|
def test_action_model_map_covers_all_actions():
|
|
"""Every action type maps to a valid tier."""
|
|
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():
|
|
"""get_model_for_action resolves tier to model name."""
|
|
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():
|
|
"""Unknown action types fall back to standard tier."""
|
|
model = settings.get_model_for_action("nonexistent_action")
|
|
assert model == settings.AI_MODEL_TIERS["standard"]
|
|
```
|
|
|
|
**Step 2: Run test to verify it fails**
|
|
|
|
Run: `cd backend && python -m pytest tests/test_config_model_tiers.py -v`
|
|
Expected: FAIL — `AI_MODEL_TIERS` attribute does not exist.
|
|
|
|
**Step 3: Add model tier config to Settings class**
|
|
|
|
In `backend/app/core/config.py`, after the existing AI config lines (~line 85), add:
|
|
|
|
```python
|
|
# Model tier routing
|
|
AI_MODEL_TIERS: dict[str, str] = {
|
|
"fast": "claude-haiku-4-5-20251001",
|
|
"standard": "claude-sonnet-4-6-20250514",
|
|
}
|
|
|
|
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."""
|
|
tier = self.ACTION_MODEL_MAP.get(action_type, "standard")
|
|
return self.AI_MODEL_TIERS.get(tier, self.AI_MODEL_TIERS["standard"])
|
|
```
|
|
|
|
**Step 4: Run test to verify it passes**
|
|
|
|
Run: `cd backend && python -m pytest tests/test_config_model_tiers.py -v`
|
|
Expected: All 4 tests PASS.
|
|
|
|
**Step 5: Commit**
|
|
|
|
```bash
|
|
git add backend/app/core/config.py backend/tests/test_config_model_tiers.py
|
|
git commit -m "feat: add config-driven AI model tier routing"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 3: Extend AI Chat Session Model
|
|
|
|
**Files:**
|
|
- Modify: `backend/app/models/ai_chat_session.py`
|
|
- Create: `backend/alembic/versions/051_extend_ai_chat_session.py`
|
|
|
|
**Step 1: Add columns to AIChatSession model**
|
|
|
|
In `backend/app/models/ai_chat_session.py`, add after the existing column definitions:
|
|
|
|
```python
|
|
# Editor-embedded session: links to a specific tree/flow
|
|
tree_id: Mapped[Optional[uuid.UUID]] = mapped_column(
|
|
ForeignKey("trees.id", ondelete="CASCADE"),
|
|
nullable=True,
|
|
index=True,
|
|
)
|
|
archived_at: Mapped[Optional[datetime]] = mapped_column(
|
|
DateTime(timezone=True),
|
|
nullable=True,
|
|
)
|
|
```
|
|
|
|
Add the relationship (if `Tree` model import is needed, add it):
|
|
|
|
```python
|
|
tree: Mapped[Optional["Tree"]] = relationship("Tree", foreign_keys=[tree_id])
|
|
```
|
|
|
|
**Step 2: Create the Alembic migration**
|
|
|
|
Run: `cd backend && alembic revision -m "extend ai chat session with tree_id and archived_at" --rev-id=051`
|
|
|
|
Then edit the generated file `backend/alembic/versions/051_extend_ai_chat_session.py`:
|
|
|
|
```python
|
|
"""extend ai chat session with tree_id and archived_at
|
|
|
|
Revision ID: 051
|
|
"""
|
|
from alembic import op
|
|
import sqlalchemy as sa
|
|
|
|
revision = "051"
|
|
down_revision = "050" # Verify this matches the actual previous migration
|
|
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")
|
|
```
|
|
|
|
**Step 3: Run migration**
|
|
|
|
Run: `cd backend && alembic upgrade head`
|
|
Expected: Migration applies successfully.
|
|
|
|
**Step 4: Verify with psql**
|
|
|
|
Run: `docker exec -it patherly_postgres psql -U postgres -d patherly -c "\d ai_chat_sessions" | grep -E "tree_id|archived_at"`
|
|
Expected: Both columns visible.
|
|
|
|
**Step 5: Commit**
|
|
|
|
```bash
|
|
git add backend/app/models/ai_chat_session.py backend/alembic/versions/051_extend_ai_chat_session.py
|
|
git commit -m "feat: extend AI chat session with tree_id and archived_at"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 4: Create AI Suggestion Model + Migration
|
|
|
|
**Files:**
|
|
- Create: `backend/app/models/ai_suggestion.py`
|
|
- Create: `backend/alembic/versions/052_add_ai_suggestion_table.py`
|
|
- Modify: `backend/alembic/env.py` (import new model)
|
|
|
|
**Step 1: Create the model**
|
|
|
|
Create `backend/app/models/ai_suggestion.py`:
|
|
|
|
```python
|
|
"""AI Suggestion model for tracking AI-applied changes to flows."""
|
|
import uuid
|
|
from datetime import datetime, timezone
|
|
|
|
from sqlalchemy import DateTime, ForeignKey, String, Text
|
|
from sqlalchemy.dialects.postgresql import JSONB, UUID
|
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
|
|
|
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(
|
|
ForeignKey("trees.id", ondelete="CASCADE"),
|
|
nullable=False,
|
|
index=True,
|
|
)
|
|
user_id: Mapped[uuid.UUID] = mapped_column(
|
|
ForeignKey("users.id", ondelete="CASCADE"),
|
|
nullable=False,
|
|
index=True,
|
|
)
|
|
session_id: Mapped[uuid.UUID] = mapped_column(
|
|
ForeignKey("ai_chat_sessions.id", ondelete="SET NULL"),
|
|
nullable=True,
|
|
)
|
|
action_type: Mapped[str] = mapped_column(
|
|
String(50), nullable=False
|
|
)
|
|
target_node_id: Mapped[str | None] = 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"
|
|
) # pending, accepted, dismissed
|
|
created_at: Mapped[datetime] = mapped_column(
|
|
DateTime(timezone=True),
|
|
default=lambda: datetime.now(timezone.utc),
|
|
nullable=False,
|
|
)
|
|
resolved_at: Mapped[datetime | None] = mapped_column(
|
|
DateTime(timezone=True), nullable=True
|
|
)
|
|
|
|
# Relationships
|
|
tree: Mapped["Tree"] = relationship("Tree", foreign_keys=[tree_id])
|
|
user: Mapped["User"] = relationship("User", foreign_keys=[user_id])
|
|
session: Mapped["AIChatSession"] = relationship(
|
|
"AIChatSession", foreign_keys=[session_id]
|
|
)
|
|
```
|
|
|
|
**Step 2: Import in alembic/env.py**
|
|
|
|
In `backend/alembic/env.py`, add with the other model imports:
|
|
|
|
```python
|
|
from app.models.ai_suggestion import AISuggestion # noqa: F401
|
|
```
|
|
|
|
**Step 3: Create migration manually**
|
|
|
|
Create `backend/alembic/versions/052_add_ai_suggestion_table.py`:
|
|
|
|
```python
|
|
"""add ai suggestion table
|
|
|
|
Revision ID: 052
|
|
"""
|
|
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")
|
|
```
|
|
|
|
**Step 4: Run migration**
|
|
|
|
Run: `cd backend && alembic upgrade head`
|
|
Expected: Table created successfully.
|
|
|
|
**Step 5: Verify**
|
|
|
|
Run: `docker exec -it patherly_postgres psql -U postgres -d patherly -c "\d ai_suggestions"`
|
|
Expected: Table schema displayed with all columns.
|
|
|
|
**Step 6: Commit**
|
|
|
|
```bash
|
|
git add backend/app/models/ai_suggestion.py backend/alembic/versions/052_add_ai_suggestion_table.py backend/alembic/env.py
|
|
git commit -m "feat: add AI suggestion audit trail table"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 5: Add Action Type to AI Chat Schemas + Endpoints
|
|
|
|
**Files:**
|
|
- Modify: `backend/app/schemas/ai_chat.py`
|
|
- Modify: `backend/app/api/endpoints/ai_chat.py`
|
|
|
|
**Step 1: Write the failing test**
|
|
|
|
Add to `backend/tests/test_ai_chat.py` (or create new file `backend/tests/test_ai_chat_action_types.py`):
|
|
|
|
```python
|
|
"""Tests for action-type routing in AI chat endpoints."""
|
|
import pytest
|
|
from unittest.mock import AsyncMock, PropertyMock, patch
|
|
|
|
|
|
@pytest.fixture
|
|
def _enable_ai(monkeypatch):
|
|
"""Enable AI for tests without API key."""
|
|
from app.core.config import Settings
|
|
monkeypatch.setattr(Settings, "ai_enabled", PropertyMock(return_value=True))
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_message_with_action_type(client, auth_headers, _enable_ai):
|
|
"""Messages can include an action_type parameter."""
|
|
# Start a session first
|
|
with patch("app.api.endpoints.ai_chat.ai_chat_service") as mock_svc:
|
|
mock_svc.start_chat_session = AsyncMock(return_value={
|
|
"session_id": "test-123",
|
|
"greeting": "Hello",
|
|
"current_phase": "scoping",
|
|
})
|
|
start_resp = await client.post(
|
|
"/api/v1/ai/chat/sessions",
|
|
json={"flow_type": "troubleshooting"},
|
|
headers=auth_headers,
|
|
)
|
|
assert start_resp.status_code == 200
|
|
|
|
# Send message with action_type
|
|
mock_svc.send_message = AsyncMock(return_value={
|
|
"content": "Here's a branch",
|
|
"current_phase": "enrichment",
|
|
"working_tree": None,
|
|
"tree_metadata": None,
|
|
})
|
|
resp = await client.post(
|
|
f"/api/v1/ai/chat/sessions/test-123/messages",
|
|
json={
|
|
"content": "Generate a branch from this node",
|
|
"action_type": "generate_branch",
|
|
"focal_node_id": "check-connectivity",
|
|
},
|
|
headers=auth_headers,
|
|
)
|
|
assert resp.status_code == 200
|
|
```
|
|
|
|
**Step 2: Run test to verify it fails**
|
|
|
|
Run: `cd backend && python -m pytest tests/test_ai_chat_action_types.py -v`
|
|
Expected: FAIL — `action_type` not accepted in schema.
|
|
|
|
**Step 3: Update schemas**
|
|
|
|
In `backend/app/schemas/ai_chat.py`, update `AIChatMessageRequest`:
|
|
|
|
```python
|
|
from typing import Literal, Optional
|
|
|
|
VALID_ACTION_TYPES = Literal[
|
|
"generate_full",
|
|
"generate_branch",
|
|
"modify_node",
|
|
"add_steps",
|
|
"quick_action",
|
|
"open_chat",
|
|
"variable_inference",
|
|
]
|
|
|
|
|
|
class AIChatMessageRequest(BaseModel):
|
|
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, determines model tier and prompt",
|
|
)
|
|
focal_node_id: Optional[str] = Field(
|
|
default=None,
|
|
description="ID of the node/step being acted on (for targeted actions)",
|
|
)
|
|
```
|
|
|
|
Also update `AIChatStartRequest` to accept optional `tree_id`:
|
|
|
|
```python
|
|
class AIChatStartRequest(BaseModel):
|
|
flow_type: Literal["troubleshooting", "procedural"]
|
|
tree_id: Optional[str] = Field(
|
|
default=None,
|
|
description="ID of existing tree to attach this session to (editor-embedded mode)",
|
|
)
|
|
```
|
|
|
|
**Step 4: Update endpoint to pass action_type through**
|
|
|
|
In `backend/app/api/endpoints/ai_chat.py`, update the `send_message` endpoint to pass `action_type` and `focal_node_id` to the service:
|
|
|
|
```python
|
|
# In the send_message endpoint, after extracting request data:
|
|
result = await ai_chat_service.send_message(
|
|
session_id=session_id,
|
|
user_message=data.content,
|
|
user_id=str(current_user.id),
|
|
db=db,
|
|
action_type=data.action_type,
|
|
focal_node_id=data.focal_node_id,
|
|
)
|
|
```
|
|
|
|
In the `start_session` endpoint, pass `tree_id` if provided:
|
|
|
|
```python
|
|
# When creating session, pass tree_id
|
|
result = await ai_chat_service.start_chat_session(
|
|
user_id=str(current_user.id),
|
|
flow_type=data.flow_type,
|
|
db=db,
|
|
account_id=str(current_user.account_id) if current_user.account_id else None,
|
|
tree_id=data.tree_id,
|
|
)
|
|
```
|
|
|
|
**Step 5: Update ai_chat_service.send_message signature**
|
|
|
|
In `backend/app/core/ai_chat_service.py`, update `send_message` to accept the new params (add `action_type: str = "open_chat"` and `focal_node_id: str | None = None` to the signature). For now, just accept them — prompt dispatch will come in a later task.
|
|
|
|
Also update `start_chat_session` to accept and store `tree_id`:
|
|
|
|
```python
|
|
async def start_chat_session(
|
|
self,
|
|
user_id: str,
|
|
flow_type: str,
|
|
db: AsyncSession,
|
|
account_id: str | None = None,
|
|
tree_id: str | None = None,
|
|
) -> dict:
|
|
# ... existing code ...
|
|
session = AIChatSession(
|
|
# ... existing fields ...
|
|
tree_id=uuid.UUID(tree_id) if tree_id else None,
|
|
)
|
|
```
|
|
|
|
**Step 6: Run tests**
|
|
|
|
Run: `cd backend && python -m pytest tests/test_ai_chat_action_types.py -v`
|
|
Expected: PASS.
|
|
|
|
Run: `cd backend && python -m pytest tests/test_ai_chat.py -v`
|
|
Expected: Existing tests still PASS.
|
|
|
|
**Step 7: Commit**
|
|
|
|
```bash
|
|
git add backend/app/schemas/ai_chat.py backend/app/api/endpoints/ai_chat.py backend/app/core/ai_chat_service.py backend/tests/test_ai_chat_action_types.py
|
|
git commit -m "feat: add action_type and focal_node_id to AI chat message API"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 6: Add Model Tier Routing to AI Chat Service
|
|
|
|
**Files:**
|
|
- Modify: `backend/app/core/ai_chat_service.py`
|
|
|
|
**Step 1: Write the failing test**
|
|
|
|
Add to `backend/tests/test_ai_chat_action_types.py`:
|
|
|
|
```python
|
|
def test_model_routing_uses_action_type():
|
|
"""Service selects model based on action_type."""
|
|
from app.core.config import settings
|
|
|
|
# Fast actions should use fast model
|
|
fast_model = settings.get_model_for_action("quick_action")
|
|
assert fast_model == settings.AI_MODEL_TIERS["fast"]
|
|
|
|
# Standard actions should use standard model
|
|
standard_model = settings.get_model_for_action("generate_branch")
|
|
assert standard_model == settings.AI_MODEL_TIERS["standard"]
|
|
```
|
|
|
|
**Step 2: Run test — should pass (config already done in Task 2)**
|
|
|
|
Run: `cd backend && python -m pytest tests/test_ai_chat_action_types.py::test_model_routing_uses_action_type -v`
|
|
Expected: PASS (config was added in Task 2).
|
|
|
|
**Step 3: Update AI chat service to use model routing**
|
|
|
|
In `backend/app/core/ai_chat_service.py`, find where the AI model is selected (look for `settings.AI_MODEL` or `settings.AI_MODEL_ANTHROPIC` or `settings.AI_MODEL_GEMINI`). Replace the hardcoded model selection with:
|
|
|
|
```python
|
|
# Instead of:
|
|
# model = settings.AI_MODEL_ANTHROPIC (or similar)
|
|
|
|
# Use:
|
|
model = settings.get_model_for_action(action_type)
|
|
```
|
|
|
|
This applies to both `send_message` and `generate_final_tree` methods. The `generate_final_tree` method should use `action_type="generate_full"`.
|
|
|
|
**Important:** The service may use either Anthropic or Gemini provider. The model tier config currently has Anthropic model names. If using Gemini provider, fall back to `settings.AI_MODEL_GEMINI`. Add a provider check:
|
|
|
|
```python
|
|
def _get_model(self, action_type: str) -> str:
|
|
"""Get the model name for this action, respecting provider."""
|
|
if settings.AI_PROVIDER == "gemini":
|
|
return settings.AI_MODEL_GEMINI
|
|
return settings.get_model_for_action(action_type)
|
|
```
|
|
|
|
**Step 4: Run full AI chat tests**
|
|
|
|
Run: `cd backend && python -m pytest tests/test_ai_chat.py tests/test_ai_chat_action_types.py -v`
|
|
Expected: All PASS.
|
|
|
|
**Step 5: Commit**
|
|
|
|
```bash
|
|
git add backend/app/core/ai_chat_service.py backend/tests/test_ai_chat_action_types.py
|
|
git commit -m "feat: route AI model selection through action-type config"
|
|
```
|
|
|
|
---
|
|
|
|
## Phase 2: Core Frontend Infrastructure
|
|
|
|
### Task 7: Create Frontend Types for Editor AI
|
|
|
|
**Files:**
|
|
- Create: `frontend/src/types/editor-ai.ts`
|
|
- Modify: `frontend/src/types/index.ts`
|
|
|
|
**Step 1: Create the types file**
|
|
|
|
Create `frontend/src/types/editor-ai.ts`:
|
|
|
|
```typescript
|
|
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 EditorAISessionResponse {
|
|
session_id: string
|
|
status: 'active' | 'completed' | 'archived'
|
|
flow_type: 'troubleshooting' | 'procedural'
|
|
conversation_history: EditorAIChatMessage[]
|
|
tree_id: string | null
|
|
message_count: number
|
|
}
|
|
|
|
export interface ContextMenuAction {
|
|
id: string
|
|
label: string
|
|
icon: string // Lucide icon name
|
|
action_type: AIActionType
|
|
description?: string
|
|
}
|
|
|
|
export interface ContextMenuPosition {
|
|
x: number
|
|
y: number
|
|
}
|
|
|
|
/** Ghost node/step marker — mixed into TreeStructure or ProceduralStep */
|
|
export interface SuggestionMarker {
|
|
_suggestion?: true
|
|
_suggestion_id?: string // links to AISuggestion.id
|
|
}
|
|
```
|
|
|
|
**Step 2: Export from types/index.ts**
|
|
|
|
Add to `frontend/src/types/index.ts`:
|
|
|
|
```typescript
|
|
export type {
|
|
AIActionType,
|
|
AIDelta,
|
|
AISuggestion,
|
|
EditorAIChatMessage,
|
|
KnowledgeCitation,
|
|
EditorAISessionResponse,
|
|
ContextMenuAction,
|
|
ContextMenuPosition,
|
|
SuggestionMarker,
|
|
} from './editor-ai'
|
|
```
|
|
|
|
**Step 3: Verify build**
|
|
|
|
Run: `cd frontend && npm run build`
|
|
Expected: Build succeeds.
|
|
|
|
**Step 4: Commit**
|
|
|
|
```bash
|
|
git add frontend/src/types/editor-ai.ts frontend/src/types/index.ts
|
|
git commit -m "feat: add TypeScript types for editor-embedded AI"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 8: Create ContextMenu Component
|
|
|
|
**Files:**
|
|
- Create: `frontend/src/components/common/ContextMenu.tsx`
|
|
|
|
**Step 1: Create the component**
|
|
|
|
Create `frontend/src/components/common/ContextMenu.tsx`:
|
|
|
|
```tsx
|
|
import { useEffect, useRef, useCallback } from 'react'
|
|
import { cn } from '@/lib/utils'
|
|
import type { ContextMenuPosition } from '@/types'
|
|
|
|
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
|
|
const adjustedStyle = (() => {
|
|
const style: React.CSSProperties = {
|
|
position: 'fixed',
|
|
zIndex: 100,
|
|
left: position.x,
|
|
top: position.y,
|
|
}
|
|
if (menuRef.current) {
|
|
const rect = menuRef.current.getBoundingClientRect()
|
|
if (position.x + rect.width > window.innerWidth) {
|
|
style.left = position.x - rect.width
|
|
}
|
|
if (position.y + rect.height > window.innerHeight) {
|
|
style.top = position.y - rect.height
|
|
}
|
|
}
|
|
return style
|
|
})()
|
|
|
|
return (
|
|
<div
|
|
ref={menuRef}
|
|
style={adjustedStyle}
|
|
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>
|
|
)
|
|
}
|
|
```
|
|
|
|
**Step 2: Verify build**
|
|
|
|
Run: `cd frontend && npm run build`
|
|
Expected: Build succeeds.
|
|
|
|
**Step 3: Commit**
|
|
|
|
```bash
|
|
git add frontend/src/components/common/ContextMenu.tsx
|
|
git commit -m "feat: add shared ContextMenu component"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 9: Create EditorAIPanel Component (Shell)
|
|
|
|
**Files:**
|
|
- Create: `frontend/src/components/editor-ai/EditorAIPanel.tsx`
|
|
- Create: `frontend/src/components/editor-ai/ChatTab.tsx`
|
|
- Create: `frontend/src/components/editor-ai/SuggestionsTab.tsx`
|
|
- Create: `frontend/src/components/editor-ai/NodeSummary.tsx`
|
|
|
|
**Step 1: Create NodeSummary component**
|
|
|
|
Create `frontend/src/components/editor-ai/NodeSummary.tsx`:
|
|
|
|
```tsx
|
|
import { HelpCircle, Zap, CheckCircle, FileText, Layout } from 'lucide-react'
|
|
import type { TreeStructure } from '@/types'
|
|
|
|
interface NodeSummaryProps {
|
|
node?: TreeStructure | null
|
|
flowName?: string
|
|
flowType?: 'troubleshooting' | 'procedural' | 'maintenance'
|
|
nodeCount?: number
|
|
}
|
|
|
|
const NODE_ICONS = {
|
|
decision: HelpCircle,
|
|
action: Zap,
|
|
solution: CheckCircle,
|
|
}
|
|
|
|
const NODE_COLORS = {
|
|
decision: 'text-blue-400',
|
|
action: 'text-yellow-400',
|
|
solution: 'text-green-400',
|
|
}
|
|
|
|
export function NodeSummary({ node, flowName, flowType, nodeCount }: NodeSummaryProps) {
|
|
if (!node) {
|
|
// Flow summary when no node selected
|
|
return (
|
|
<div className="border-b border-border px-3 py-2.5">
|
|
<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 text-[0.625rem] text-muted-foreground font-label uppercase tracking-wider">
|
|
<span>{flowType || 'flow'}</span>
|
|
{nodeCount !== undefined && <span>{nodeCount} nodes</span>}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
const Icon = NODE_ICONS[node.type as keyof typeof NODE_ICONS] || FileText
|
|
const colorClass = NODE_COLORS[node.type as keyof typeof NODE_COLORS] || 'text-muted-foreground'
|
|
|
|
return (
|
|
<div className="border-b border-border px-3 py-2.5">
|
|
<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-wider 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>
|
|
)
|
|
}
|
|
```
|
|
|
|
**Step 2: Create ChatTab component**
|
|
|
|
Create `frontend/src/components/editor-ai/ChatTab.tsx`:
|
|
|
|
```tsx
|
|
import { useRef, useEffect } from 'react'
|
|
import { Send, Sparkles } from 'lucide-react'
|
|
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)
|
|
|
|
useEffect(() => {
|
|
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
|
|
}, [messages])
|
|
|
|
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-3 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={`text-sm rounded-lg px-3 py-2 ${
|
|
msg.role === 'user'
|
|
? 'ml-6 bg-primary/15 text-foreground'
|
|
: 'mr-2 bg-[rgba(255,255,255,0.04)] border border-border text-foreground'
|
|
}`}
|
|
>
|
|
<p className="whitespace-pre-wrap">{msg.content}</p>
|
|
</div>
|
|
))}
|
|
{isLoading && (
|
|
<div className="mr-2 bg-[rgba(255,255,255,0.04)] border border-border rounded-lg px-3 py-2">
|
|
<div className="flex gap-1">
|
|
<div className="h-1.5 w-1.5 rounded-full bg-primary/60 animate-bounce" />
|
|
<div className="h-1.5 w-1.5 rounded-full bg-primary/60 animate-bounce [animation-delay:0.15s]" />
|
|
<div className="h-1.5 w-1.5 rounded-full bg-primary/60 animate-bounce [animation-delay:0.3s]" />
|
|
</div>
|
|
</div>
|
|
)}
|
|
<div ref={messagesEndRef} />
|
|
</div>
|
|
|
|
{/* Input */}
|
|
<div className="shrink-0 border-t border-border p-3">
|
|
<div className="flex items-end gap-2">
|
|
<textarea
|
|
value={input}
|
|
onChange={(e) => onInputChange(e.target.value)}
|
|
onKeyDown={handleKeyDown}
|
|
placeholder="Ask AI to help..."
|
|
rows={1}
|
|
className="flex-1 resize-none rounded-lg border border-border bg-card px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:border-[rgba(6,182,212,0.3)] focus:outline-none"
|
|
/>
|
|
<button
|
|
onClick={onSend}
|
|
disabled={!input.trim() || isLoading}
|
|
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-gradient-brand text-[#101114] transition-all hover:opacity-90 active:scale-[0.97] disabled:opacity-40"
|
|
>
|
|
<Send className="h-4 w-4" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
```
|
|
|
|
**Step 3: Create SuggestionsTab component**
|
|
|
|
Create `frontend/src/components/editor-ai/SuggestionsTab.tsx`:
|
|
|
|
```tsx
|
|
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' },
|
|
}
|
|
|
|
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-wider 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 text-[0.625rem] text-[#5a6170]">
|
|
{new Date(s.created_at).toLocaleDateString()}
|
|
</p>
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
)
|
|
}
|
|
```
|
|
|
|
**Step 4: Create the main EditorAIPanel component**
|
|
|
|
Create `frontend/src/components/editor-ai/EditorAIPanel.tsx`:
|
|
|
|
```tsx
|
|
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 { TreeStructure, EditorAIChatMessage, AISuggestion } from '@/types'
|
|
|
|
interface EditorAIPanelProps {
|
|
isOpen: boolean
|
|
onClose: () => void
|
|
// Context
|
|
focalNode?: TreeStructure | null
|
|
flowName?: string
|
|
flowType?: 'troubleshooting' | 'procedural' | 'maintenance'
|
|
nodeCount?: number
|
|
// Chat state
|
|
messages: EditorAIChatMessage[]
|
|
input: string
|
|
onInputChange: (value: string) => void
|
|
onSend: () => void
|
|
isLoading: boolean
|
|
// Suggestions
|
|
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-[320px] shrink-0 flex-col border-l border-border bg-[rgba(24,26,31,0.55)] backdrop-blur-[var(--glass-blur)]">
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between border-b border-border px-3 py-2.5 shrink-0">
|
|
<div className="flex items-center gap-2">
|
|
<Sparkles className="h-4 w-4 text-primary" />
|
|
<span className="text-sm font-medium text-foreground">AI Assist</span>
|
|
</div>
|
|
<button
|
|
onClick={onClose}
|
|
className="flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground hover:bg-[rgba(255,255,255,0.06)] hover:text-foreground transition-colors"
|
|
>
|
|
<X className="h-3.5 w-3.5" />
|
|
</button>
|
|
</div>
|
|
|
|
{/* Node/Flow Summary */}
|
|
<NodeSummary
|
|
node={focalNode}
|
|
flowName={flowName}
|
|
flowType={flowType}
|
|
nodeCount={nodeCount}
|
|
/>
|
|
|
|
{/* Tabs */}
|
|
<div className="flex border-b border-border shrink-0">
|
|
<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>
|
|
|
|
{/* Tab Content */}
|
|
{activeTab === 'chat' ? (
|
|
<ChatTab
|
|
messages={messages}
|
|
input={input}
|
|
onInputChange={onInputChange}
|
|
onSend={onSend}
|
|
isLoading={isLoading}
|
|
/>
|
|
) : (
|
|
<SuggestionsTab suggestions={suggestions} />
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
```
|
|
|
|
**Step 5: Verify build**
|
|
|
|
Run: `cd frontend && npm run build`
|
|
Expected: Build succeeds.
|
|
|
|
**Step 6: Commit**
|
|
|
|
```bash
|
|
git add frontend/src/components/editor-ai/
|
|
git commit -m "feat: add EditorAIPanel component with Chat and Suggestions tabs"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 10: Create useEditorAI Hook
|
|
|
|
**Files:**
|
|
- Create: `frontend/src/hooks/useEditorAI.ts`
|
|
- Create: `frontend/src/api/editorAI.ts`
|
|
|
|
**Step 1: Create the API client**
|
|
|
|
Create `frontend/src/api/editorAI.ts`:
|
|
|
|
```typescript
|
|
import { apiClient } from './client'
|
|
import type { AIActionType } from '@/types'
|
|
|
|
export interface SendMessageParams {
|
|
sessionId: string
|
|
content: string
|
|
actionType?: AIActionType
|
|
focalNodeId?: string | 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 }: SendMessageParams) => {
|
|
const { data } = await apiClient.post(`/ai/chat/sessions/${sessionId}/messages`, {
|
|
content,
|
|
action_type: actionType || 'open_chat',
|
|
focal_node_id: focalNodeId,
|
|
})
|
|
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}`)
|
|
},
|
|
}
|
|
```
|
|
|
|
**Step 2: Create the hook**
|
|
|
|
Create `frontend/src/hooks/useEditorAI.ts`:
|
|
|
|
```typescript
|
|
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
|
|
}
|
|
|
|
export function useEditorAI({ flowType, treeId }: UseEditorAIOptions) {
|
|
// Panel state
|
|
const [isOpen, setIsOpen] = useState(false)
|
|
const [focalNodeId, setFocalNodeId] = useState<string | null>(null)
|
|
|
|
// Context menu
|
|
const [contextMenu, setContextMenu] = useState<{
|
|
position: ContextMenuPosition
|
|
nodeId: string
|
|
} | null>(null)
|
|
|
|
// Chat state
|
|
const [sessionId, setSessionId] = useState<string | null>(null)
|
|
const [messages, setMessages] = useState<EditorAIChatMessage[]>([])
|
|
const [input, setInput] = useState('')
|
|
const [isLoading, setIsLoading] = useState(false)
|
|
|
|
// Suggestions
|
|
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 userMessage: EditorAIChatMessage = {
|
|
role: 'user',
|
|
content: input,
|
|
timestamp: new Date().toISOString(),
|
|
action_type: pendingActionRef.current,
|
|
}
|
|
setMessages((prev) => [...prev, userMessage])
|
|
setInput('')
|
|
setIsLoading(true)
|
|
|
|
try {
|
|
const sid = await ensureSession()
|
|
if (!sid) return
|
|
|
|
const result = await editorAIApi.sendMessage({
|
|
sessionId: sid,
|
|
content: input,
|
|
actionType: pendingActionRef.current,
|
|
focalNodeId: focalNodeId,
|
|
})
|
|
|
|
const assistantMessage: EditorAIChatMessage = {
|
|
role: 'assistant',
|
|
content: result.content,
|
|
timestamp: new Date().toISOString(),
|
|
}
|
|
setMessages((prev) => [...prev, assistantMessage])
|
|
|
|
// TODO: Parse delta from response and create ghost nodes
|
|
} 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])
|
|
|
|
const triggerAction = useCallback(
|
|
(nodeId: string, actionType: AIActionType, prompt: string) => {
|
|
setFocalNodeId(nodeId)
|
|
pendingActionRef.current = actionType
|
|
setInput(prompt)
|
|
setIsOpen(true)
|
|
// Auto-send after a tick so panel is visible
|
|
setTimeout(() => {
|
|
sendMessage()
|
|
}, 100)
|
|
},
|
|
[sendMessage]
|
|
)
|
|
|
|
return {
|
|
// Panel
|
|
isOpen,
|
|
openPanel,
|
|
closePanel,
|
|
focalNodeId,
|
|
setFocalNodeId,
|
|
|
|
// Context menu
|
|
contextMenu,
|
|
openContextMenu,
|
|
closeContextMenu,
|
|
|
|
// Chat
|
|
messages,
|
|
input,
|
|
setInput,
|
|
sendMessage,
|
|
isLoading,
|
|
|
|
// Suggestions
|
|
suggestions,
|
|
setSuggestions,
|
|
|
|
// Actions
|
|
triggerAction,
|
|
}
|
|
}
|
|
```
|
|
|
|
**Step 3: Verify build**
|
|
|
|
Run: `cd frontend && npm run build`
|
|
Expected: Build succeeds.
|
|
|
|
**Step 4: Commit**
|
|
|
|
```bash
|
|
git add frontend/src/hooks/useEditorAI.ts frontend/src/api/editorAI.ts
|
|
git commit -m "feat: add useEditorAI hook and editorAI API client"
|
|
```
|
|
|
|
---
|
|
|
|
## Phase 3: Tree Editor Integration
|
|
|
|
### Task 11: Add AI Panel to Tree Editor Layout
|
|
|
|
**Files:**
|
|
- Modify: `frontend/src/pages/TreeEditorPage.tsx`
|
|
|
|
**Step 1: Read the current file to understand its full structure**
|
|
|
|
Read `frontend/src/pages/TreeEditorPage.tsx` before modifying.
|
|
|
|
**Step 2: Add imports and hook**
|
|
|
|
At the top of `TreeEditorPage.tsx`, add:
|
|
|
|
```typescript
|
|
import { EditorAIPanel } from '@/components/editor-ai/EditorAIPanel'
|
|
import { ContextMenu } from '@/components/common/ContextMenu'
|
|
import { useEditorAI } from '@/hooks/useEditorAI'
|
|
import { Sparkles } from 'lucide-react' // may already be imported
|
|
```
|
|
|
|
Inside the component, after existing hooks:
|
|
|
|
```typescript
|
|
const editorAI = useEditorAI({
|
|
flowType: 'troubleshooting',
|
|
treeId: id,
|
|
})
|
|
```
|
|
|
|
**Step 3: Add AI Assist toolbar button**
|
|
|
|
In the toolbar section (around line 706, after the Export button), add:
|
|
|
|
```tsx
|
|
<button
|
|
onClick={() => editorAI.isOpen ? editorAI.closePanel() : editorAI.openPanel()}
|
|
className={cn(
|
|
'flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-sm transition-colors',
|
|
editorAI.isOpen
|
|
? 'bg-primary/10 text-primary'
|
|
: 'text-muted-foreground hover:bg-[rgba(255,255,255,0.06)] hover:text-foreground'
|
|
)}
|
|
title="Toggle AI Assist"
|
|
>
|
|
<Sparkles className="h-4 w-4" />
|
|
<span className="hidden lg:inline">AI Assist</span>
|
|
</button>
|
|
```
|
|
|
|
**Step 4: Handle single-panel rule**
|
|
|
|
When AI panel opens, close the node editor. When AI panel closes, reopen node editor if a node was selected:
|
|
|
|
```typescript
|
|
// Track the node that was being edited when AI panel opened
|
|
const previousEditingNodeRef = useRef<string | null>(null)
|
|
|
|
// When AI panel opens, remember editing node and close editor
|
|
useEffect(() => {
|
|
if (editorAI.isOpen && editingNodeId) {
|
|
previousEditingNodeRef.current = editingNodeId
|
|
setEditingNodeId(null)
|
|
}
|
|
}, [editorAI.isOpen])
|
|
|
|
// When AI panel closes, restore previous editing node
|
|
const handleAIPanelClose = () => {
|
|
editorAI.closePanel()
|
|
if (previousEditingNodeRef.current) {
|
|
setEditingNodeId(previousEditingNodeRef.current)
|
|
previousEditingNodeRef.current = null
|
|
}
|
|
}
|
|
```
|
|
|
|
**Step 5: Add panel to layout**
|
|
|
|
After the existing editor layout area (around line 782), add the AI panel alongside:
|
|
|
|
```tsx
|
|
<div className="flex flex-1 overflow-hidden">
|
|
{/* Existing editor layout takes flex-1 */}
|
|
<div className="flex-1 overflow-hidden">
|
|
<TreeEditorLayout
|
|
isMobile={isMobile}
|
|
isMetadataOpen={isMetadataOpen}
|
|
onCloseMetadata={() => setIsMetadataOpen(false)}
|
|
editingNodeId={editorAI.isOpen ? null : editingNodeId}
|
|
onNodeSelect={handleNodeSelect}
|
|
onSelectAnswerType={handleSelectAnswerType}
|
|
onNodeContextMenu={editorAI.openContextMenu}
|
|
/>
|
|
</div>
|
|
|
|
{/* AI Panel */}
|
|
<EditorAIPanel
|
|
isOpen={editorAI.isOpen}
|
|
onClose={handleAIPanelClose}
|
|
focalNode={editorAI.focalNodeId ? findNode(editorAI.focalNodeId, treeStructure) : null}
|
|
flowName={name}
|
|
flowType="troubleshooting"
|
|
nodeCount={treeStructure ? getAllNodeIds(treeStructure).length : 0}
|
|
messages={editorAI.messages}
|
|
input={editorAI.input}
|
|
onInputChange={editorAI.setInput}
|
|
onSend={editorAI.sendMessage}
|
|
isLoading={editorAI.isLoading}
|
|
suggestions={editorAI.suggestions}
|
|
/>
|
|
</div>
|
|
```
|
|
|
|
**Note:** The `onNodeContextMenu` prop will need to be threaded through `TreeEditorLayout` → `TreeCanvas` → `TreeCanvasNode`. This happens in Task 12.
|
|
|
|
**Step 6: Add context menu rendering**
|
|
|
|
At the bottom of the component, before the closing fragment:
|
|
|
|
```tsx
|
|
{editorAI.contextMenu && (
|
|
<ContextMenu
|
|
position={editorAI.contextMenu.position}
|
|
items={[
|
|
{
|
|
id: 'generate-branch',
|
|
label: 'Generate Branch',
|
|
icon: <Sparkles className="h-4 w-4" />,
|
|
onClick: () => editorAI.triggerAction(
|
|
editorAI.contextMenu!.nodeId,
|
|
'generate_branch',
|
|
`Generate a branch from node "${editorAI.contextMenu!.nodeId}"`
|
|
),
|
|
},
|
|
{
|
|
id: 'explain',
|
|
label: 'Explain Node',
|
|
icon: <HelpCircle className="h-4 w-4" />,
|
|
onClick: () => editorAI.triggerAction(
|
|
editorAI.contextMenu!.nodeId,
|
|
'quick_action',
|
|
`Explain what node "${editorAI.contextMenu!.nodeId}" does`
|
|
),
|
|
},
|
|
{ id: 'sep1', label: '', onClick: () => {}, separator: true },
|
|
{
|
|
id: 'delete',
|
|
label: 'Delete Node',
|
|
variant: 'danger' as const,
|
|
onClick: () => {
|
|
// Use existing delete logic
|
|
deleteNode(editorAI.contextMenu!.nodeId)
|
|
},
|
|
},
|
|
]}
|
|
onClose={editorAI.closeContextMenu}
|
|
/>
|
|
)}
|
|
```
|
|
|
|
**Step 7: Verify build**
|
|
|
|
Run: `cd frontend && npm run build`
|
|
Expected: Build succeeds (some props like `onNodeContextMenu` may need stubbing if TreeEditorLayout doesn't accept it yet — add it as an optional prop first).
|
|
|
|
**Step 8: Commit**
|
|
|
|
```bash
|
|
git add frontend/src/pages/TreeEditorPage.tsx
|
|
git commit -m "feat: integrate AI panel and context menu in tree editor"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 12: Thread Context Menu Through Tree Canvas
|
|
|
|
**Files:**
|
|
- Modify: `frontend/src/components/tree-editor/TreeEditorLayout.tsx` (or wherever TreeCanvas is rendered)
|
|
- Modify: `frontend/src/components/tree-editor/TreeCanvas.tsx`
|
|
- Modify: `frontend/src/components/tree-editor/TreeCanvasNode.tsx`
|
|
|
|
**Step 1: Read each file to understand prop threading**
|
|
|
|
Read all three files before modifying.
|
|
|
|
**Step 2: Add onNodeContextMenu prop through the chain**
|
|
|
|
In `TreeCanvasNode.tsx`, add `onContextMenu` handler to the card's root div:
|
|
|
|
```tsx
|
|
// In TreeCanvasNode props interface, add:
|
|
onContextMenu?: (e: React.MouseEvent, nodeId: string) => void
|
|
|
|
// On the card container div, add:
|
|
onContextMenu={(e) => onContextMenu?.(e, node.id)}
|
|
```
|
|
|
|
Thread this prop up through `TreeCanvas.tsx` and `TreeEditorLayout.tsx` from the `TreeEditorPage`.
|
|
|
|
**Step 3: Verify build**
|
|
|
|
Run: `cd frontend && npm run build`
|
|
Expected: Build succeeds.
|
|
|
|
**Step 4: Commit**
|
|
|
|
```bash
|
|
git add frontend/src/components/tree-editor/
|
|
git commit -m "feat: thread context menu handler through tree canvas components"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 13: Add Ghost Node Rendering to TreeCanvasNode
|
|
|
|
**Files:**
|
|
- Modify: `frontend/src/components/tree-editor/TreeCanvasNode.tsx`
|
|
|
|
**Step 1: Read the current TreeCanvasNode styling**
|
|
|
|
Read `frontend/src/components/tree-editor/TreeCanvasNode.tsx`.
|
|
|
|
**Step 2: Add ghost node styling**
|
|
|
|
In `TreeCanvasNode.tsx`, detect the `_suggestion` flag and apply ghost styling:
|
|
|
|
```tsx
|
|
// After determining node type config (around line 90):
|
|
const isGhost = !!(node as Record<string, unknown>)._suggestion
|
|
|
|
// On the card container, modify className to conditionally add ghost styles:
|
|
className={cn(
|
|
'group relative rounded-xl border bg-card p-3 transition-all',
|
|
config.borderClass,
|
|
isSelected && 'ring-2 ring-primary/40',
|
|
hasErrors && 'ring-2 ring-rose-500/40',
|
|
hasWarnings && !hasErrors && 'ring-2 ring-amber-500/40',
|
|
// Ghost node styling
|
|
isGhost && 'border-dashed border-primary/40 opacity-60',
|
|
)}
|
|
```
|
|
|
|
**Step 3: Add accept/dismiss overlay for ghost nodes**
|
|
|
|
Inside the card, at the bottom, add:
|
|
|
|
```tsx
|
|
{isGhost && (
|
|
<div className="mt-2 flex gap-2 border-t border-border/50 pt-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>
|
|
)}
|
|
```
|
|
|
|
Add `onAcceptSuggestion` and `onDismissSuggestion` to the component's props interface and thread them from the parent.
|
|
|
|
**Step 4: Verify build**
|
|
|
|
Run: `cd frontend && npm run build`
|
|
Expected: Build succeeds.
|
|
|
|
**Step 5: Commit**
|
|
|
|
```bash
|
|
git add frontend/src/components/tree-editor/TreeCanvasNode.tsx
|
|
git commit -m "feat: add ghost node rendering with accept/dismiss controls"
|
|
```
|
|
|
|
---
|
|
|
|
## Phase 4: Procedural Editor Integration
|
|
|
|
### Task 14: Add AI Panel to Procedural Editor Layout
|
|
|
|
**Files:**
|
|
- Modify: `frontend/src/pages/ProceduralEditorPage.tsx`
|
|
|
|
**Step 1: Read the current file**
|
|
|
|
Read `frontend/src/pages/ProceduralEditorPage.tsx` to understand its layout.
|
|
|
|
**Step 2: Add imports and hook**
|
|
|
|
```typescript
|
|
import { EditorAIPanel } from '@/components/editor-ai/EditorAIPanel'
|
|
import { ContextMenu } from '@/components/common/ContextMenu'
|
|
import { useEditorAI } from '@/hooks/useEditorAI'
|
|
import { Sparkles } from 'lucide-react'
|
|
```
|
|
|
|
```typescript
|
|
const editorAI = useEditorAI({
|
|
flowType: isMaintenance ? 'procedural' : 'procedural', // maintenance uses procedural
|
|
treeId: treeId,
|
|
})
|
|
```
|
|
|
|
**Step 3: Wrap the step list area in a flex container**
|
|
|
|
The procedural editor has no existing side panel. Wrap the main content area in a flex container so the AI panel can sit alongside:
|
|
|
|
```tsx
|
|
<div className="flex flex-1 overflow-hidden">
|
|
<div className="flex-1 overflow-y-auto">
|
|
{/* Existing step list, intake form, etc. */}
|
|
</div>
|
|
|
|
<EditorAIPanel
|
|
isOpen={editorAI.isOpen}
|
|
onClose={editorAI.closePanel}
|
|
focalNode={null} // TODO: adapt for steps
|
|
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>
|
|
```
|
|
|
|
**Step 4: Add AI Assist toolbar button**
|
|
|
|
Add the same toolbar toggle button as the tree editor (in the procedural editor's toolbar area).
|
|
|
|
**Step 5: Add context menu for steps**
|
|
|
|
Add `onContextMenu` handler to step items in `StepList` that opens the context menu with procedural-specific actions:
|
|
|
|
```tsx
|
|
{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 step "${editorAI.contextMenu!.nodeId}"`
|
|
),
|
|
},
|
|
{
|
|
id: 'expand-step',
|
|
label: 'Expand Step',
|
|
icon: <Layers className="h-4 w-4" />,
|
|
onClick: () => editorAI.triggerAction(
|
|
editorAI.contextMenu!.nodeId,
|
|
'quick_action',
|
|
`Expand step "${editorAI.contextMenu!.nodeId}" into detailed substeps`
|
|
),
|
|
},
|
|
]}
|
|
onClose={editorAI.closeContextMenu}
|
|
/>
|
|
)}
|
|
```
|
|
|
|
**Step 6: Verify build**
|
|
|
|
Run: `cd frontend && npm run build`
|
|
Expected: Build succeeds.
|
|
|
|
**Step 7: Commit**
|
|
|
|
```bash
|
|
git add frontend/src/pages/ProceduralEditorPage.tsx
|
|
git commit -m "feat: integrate AI panel and context menu in procedural editor"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 15: Add Ghost Step Rendering to StepList
|
|
|
|
**Files:**
|
|
- Modify: `frontend/src/components/procedural-editor/StepList.tsx`
|
|
|
|
**Step 1: Read the current StepList component**
|
|
|
|
Read `frontend/src/components/procedural-editor/StepList.tsx`.
|
|
|
|
**Step 2: Add ghost step styling**
|
|
|
|
For each step item in the list, detect `_suggestion` and apply ghost styling:
|
|
|
|
```tsx
|
|
const isGhost = !!(step as Record<string, unknown>)._suggestion
|
|
|
|
// On the step container:
|
|
className={cn(
|
|
'rounded-xl border bg-card p-3',
|
|
// Ghost step styling
|
|
isGhost && 'border-l-2 border-dashed border-primary/40 opacity-60',
|
|
)}
|
|
```
|
|
|
|
**Step 3: Add accept/dismiss controls for ghost steps**
|
|
|
|
```tsx
|
|
{isGhost && (
|
|
<div className="mt-2 flex gap-2">
|
|
<button
|
|
onClick={() => onAcceptSuggestion?.(step.id)}
|
|
className="flex-1 rounded-md bg-emerald-500/20 px-2 py-1 text-xs text-emerald-400 hover:bg-emerald-500/30"
|
|
>
|
|
Accept
|
|
</button>
|
|
<button
|
|
onClick={() => onDismissSuggestion?.(step.id)}
|
|
className="flex-1 rounded-md bg-rose-500/20 px-2 py-1 text-xs text-rose-400 hover:bg-rose-500/30"
|
|
>
|
|
Dismiss
|
|
</button>
|
|
</div>
|
|
)}
|
|
```
|
|
|
|
**Step 4: Verify build**
|
|
|
|
Run: `cd frontend && npm run build`
|
|
Expected: Build succeeds.
|
|
|
|
**Step 5: Commit**
|
|
|
|
```bash
|
|
git add frontend/src/components/procedural-editor/StepList.tsx
|
|
git commit -m "feat: add ghost step rendering with accept/dismiss in step list"
|
|
```
|
|
|
|
---
|
|
|
|
## Phase 5: AI-Assisted Create + Prompt Dialog
|
|
|
|
### Task 16: Create AI Prompt Dialog Modal
|
|
|
|
**Files:**
|
|
- Create: `frontend/src/components/editor-ai/AIPromptDialog.tsx`
|
|
|
|
**Step 1: Create the component**
|
|
|
|
Create `frontend/src/components/editor-ai/AIPromptDialog.tsx`:
|
|
|
|
```tsx
|
|
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="relative w-full max-w-lg rounded-2xl border border-border bg-card 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>
|
|
)
|
|
}
|
|
```
|
|
|
|
**Step 2: Verify build**
|
|
|
|
Run: `cd frontend && npm run build`
|
|
Expected: Build succeeds.
|
|
|
|
**Step 3: Commit**
|
|
|
|
```bash
|
|
git add frontend/src/components/editor-ai/AIPromptDialog.tsx
|
|
git commit -m "feat: add AI prompt dialog for AI-assisted flow creation"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 17: Update CreateFlowDropdown
|
|
|
|
**Files:**
|
|
- Modify: `frontend/src/components/common/CreateFlowDropdown.tsx`
|
|
|
|
**Step 1: Read the current file**
|
|
|
|
Read `frontend/src/components/common/CreateFlowDropdown.tsx`.
|
|
|
|
**Step 2: Replace `/ai/chat` navigation with prompt dialog**
|
|
|
|
Remove the `navigate('/ai/chat')` and `navigate('/ai/chat?type=procedural')` calls. Instead:
|
|
|
|
1. Add state for the prompt dialog:
|
|
```tsx
|
|
const [aiPromptOpen, setAiPromptOpen] = useState(false)
|
|
const [aiPromptFlowType, setAiPromptFlowType] = useState<'troubleshooting' | 'procedural' | 'maintenance'>('troubleshooting')
|
|
```
|
|
|
|
2. Replace AI builder menu items to open the dialog:
|
|
```tsx
|
|
// Instead of navigate('/ai/chat')
|
|
onClick={() => {
|
|
setAiPromptFlowType('troubleshooting')
|
|
setAiPromptOpen(true)
|
|
}}
|
|
```
|
|
|
|
3. Add the `AIPromptDialog` at the end of the component:
|
|
```tsx
|
|
<AIPromptDialog
|
|
isOpen={aiPromptOpen}
|
|
onClose={() => setAiPromptOpen(false)}
|
|
flowType={aiPromptFlowType}
|
|
onGenerate={async (prompt) => {
|
|
// 1. Start session with generate_full action
|
|
// 2. Send the prompt
|
|
// 3. Generate the tree
|
|
// 4. Import to editor
|
|
// 5. Navigate to editor with AI panel open flag
|
|
const session = await editorAIApi.startSession(aiPromptFlowType)
|
|
await editorAIApi.sendMessage({
|
|
sessionId: session.session_id,
|
|
content: prompt,
|
|
actionType: 'generate_full',
|
|
})
|
|
const result = await editorAIApi.generateFull(session.session_id)
|
|
// Import the generated tree
|
|
const imported = await apiClient.post(`/ai/chat/sessions/${session.session_id}/import`, {})
|
|
// Navigate to editor with AI panel open
|
|
const path = aiPromptFlowType === 'troubleshooting'
|
|
? `/trees/${imported.data.tree_id}/edit`
|
|
: `/flows/${imported.data.tree_id}/edit`
|
|
navigate(path, { state: { aiPanelOpen: true, sessionId: session.session_id } })
|
|
}}
|
|
/>
|
|
```
|
|
|
|
**Step 3: Verify build**
|
|
|
|
Run: `cd frontend && npm run build`
|
|
Expected: Build succeeds.
|
|
|
|
**Step 4: Commit**
|
|
|
|
```bash
|
|
git add frontend/src/components/common/CreateFlowDropdown.tsx
|
|
git commit -m "feat: replace /ai/chat navigation with AI prompt dialog in create dropdown"
|
|
```
|
|
|
|
---
|
|
|
|
## Phase 6: Remove Old Standalone Flow Assist
|
|
|
|
### Task 18: Remove Old AI Chat Components
|
|
|
|
**Files:**
|
|
- Delete: `frontend/src/pages/AIChatBuilderPage.tsx`
|
|
- Delete: `frontend/src/store/aiChatStore.ts`
|
|
- Delete: `frontend/src/components/ai-chat/` (entire directory)
|
|
- Modify: `frontend/src/router.tsx` (remove `/ai/chat` route)
|
|
- Delete: Any `AIFlowBuilderModal` component (search for it)
|
|
|
|
**Step 1: Search for all references to removed files**
|
|
|
|
Search for imports of `AIChatBuilderPage`, `aiChatStore`, `ai-chat/` components, and `AIFlowBuilderModal` across the frontend.
|
|
|
|
**Step 2: Remove the route**
|
|
|
|
In `frontend/src/router.tsx`, remove:
|
|
- The lazy import: `const AIChatBuilderPage = lazy(() => import('@/pages/AIChatBuilderPage'))`
|
|
- The route entry: `{ path: 'ai/chat', element: ... }`
|
|
|
|
**Step 3: Remove any sidebar/nav references to `/ai/chat`**
|
|
|
|
Search for `/ai/chat` in navigation components and remove links.
|
|
|
|
**Step 4: Delete the files**
|
|
|
|
```bash
|
|
rm frontend/src/pages/AIChatBuilderPage.tsx
|
|
rm frontend/src/store/aiChatStore.ts
|
|
rm -rf frontend/src/components/ai-chat/
|
|
```
|
|
|
|
Also search for and remove `AIFlowBuilderModal` if it exists:
|
|
```bash
|
|
# Search: grep -r "AIFlowBuilderModal" frontend/src/
|
|
# Delete the file if found
|
|
```
|
|
|
|
**Step 5: Fix any remaining import errors**
|
|
|
|
Run: `cd frontend && npm run build`
|
|
Fix any broken imports referencing deleted files.
|
|
|
|
**Step 6: Verify build clean**
|
|
|
|
Run: `cd frontend && npm run build`
|
|
Expected: Build succeeds with no errors.
|
|
|
|
**Step 7: Commit**
|
|
|
|
```bash
|
|
git add -A
|
|
git commit -m "refactor: remove standalone Flow Assist page and old AI chat components"
|
|
```
|
|
|
|
---
|
|
|
|
## Phase 7: Backend Action-Type Prompt Dispatch
|
|
|
|
### Task 19: Add Delta Response Parsing to AI Chat Service
|
|
|
|
**Files:**
|
|
- Modify: `backend/app/core/ai_chat_service.py`
|
|
|
|
**Step 1: Write the failing test**
|
|
|
|
Create `backend/tests/test_ai_delta_response.py`:
|
|
|
|
```python
|
|
"""Tests for AI delta response parsing."""
|
|
from app.core.ai_chat_service import AIChatService
|
|
|
|
|
|
def test_parse_delta_from_response():
|
|
"""Service extracts [DELTA] markers from AI responses."""
|
|
service = AIChatService()
|
|
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 = service._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."""
|
|
service = AIChatService()
|
|
response = "Sure, I can explain that node. It checks connectivity."
|
|
parsed = service._parse_delta(response)
|
|
assert parsed is None
|
|
```
|
|
|
|
**Step 2: Run test to verify it fails**
|
|
|
|
Run: `cd backend && python -m pytest tests/test_ai_delta_response.py -v`
|
|
Expected: FAIL — `_parse_delta` does not exist.
|
|
|
|
**Step 3: Add _parse_delta method**
|
|
|
|
In `backend/app/core/ai_chat_service.py`, add:
|
|
|
|
```python
|
|
import re
|
|
import json
|
|
|
|
def _parse_delta(self, 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 = self._strip_markdown_fences(match.group(1).strip())
|
|
try:
|
|
return json.loads(raw)
|
|
except json.JSONDecodeError:
|
|
return None
|
|
```
|
|
|
|
**Step 4: Run test to verify it passes**
|
|
|
|
Run: `cd backend && python -m pytest tests/test_ai_delta_response.py -v`
|
|
Expected: All PASS.
|
|
|
|
**Step 5: Commit**
|
|
|
|
```bash
|
|
git add backend/app/core/ai_chat_service.py backend/tests/test_ai_delta_response.py
|
|
git commit -m "feat: add delta response parsing to AI chat service"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 20: Add Action-Type Prompt Dispatch
|
|
|
|
**Files:**
|
|
- Modify: `backend/app/core/ai_chat_service.py`
|
|
|
|
**Step 1: Write the failing test**
|
|
|
|
Add to `backend/tests/test_ai_delta_response.py`:
|
|
|
|
```python
|
|
def test_build_action_prompt_generate_branch():
|
|
"""Generate branch action includes focal node context."""
|
|
service = AIChatService()
|
|
tree = {
|
|
"id": "root",
|
|
"type": "decision",
|
|
"question": "Is the server up?",
|
|
"children": [],
|
|
"options": [],
|
|
}
|
|
prompt = service._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."""
|
|
service = AIChatService()
|
|
prompt = service._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
|
|
```
|
|
|
|
**Step 2: Run test to verify it fails**
|
|
|
|
Run: `cd backend && python -m pytest tests/test_ai_delta_response.py -v`
|
|
Expected: FAIL — `_build_action_prompt` does not exist.
|
|
|
|
**Step 3: Add the method**
|
|
|
|
In `backend/app/core/ai_chat_service.py`, add:
|
|
|
|
```python
|
|
def _build_action_prompt(
|
|
self,
|
|
action_type: str,
|
|
focal_node_id: str | None,
|
|
tree_structure: dict,
|
|
flow_type: str,
|
|
) -> str:
|
|
"""Build action-specific system prompt supplement."""
|
|
import json
|
|
|
|
tree_json = json.dumps(tree_structure, indent=2)
|
|
|
|
focal_context = ""
|
|
if focal_node_id:
|
|
focal_node = self._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 _find_node_by_id(self, 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 = self._find_node_by_id(child, node_id)
|
|
if found:
|
|
return found
|
|
# For procedural steps
|
|
for step in tree.get("steps", []):
|
|
if step.get("id") == node_id:
|
|
return step
|
|
return None
|
|
```
|
|
|
|
**Step 4: Run tests**
|
|
|
|
Run: `cd backend && python -m pytest tests/test_ai_delta_response.py -v`
|
|
Expected: All PASS.
|
|
|
|
**Step 5: Integrate into send_message**
|
|
|
|
In the `send_message` method, after building the system prompt, append the action prompt:
|
|
|
|
```python
|
|
# In send_message, after building the base system prompt:
|
|
if action_type and action_type != "open_chat":
|
|
action_supplement = self._build_action_prompt(
|
|
action_type=action_type,
|
|
focal_node_id=focal_node_id,
|
|
tree_structure=session.working_tree or {},
|
|
flow_type=session.flow_type,
|
|
)
|
|
# Append to system prompt or add as a user context message
|
|
```
|
|
|
|
**Step 6: Run full test suite**
|
|
|
|
Run: `cd backend && python -m pytest tests/test_ai_chat.py tests/test_ai_delta_response.py -v`
|
|
Expected: All PASS.
|
|
|
|
**Step 7: Commit**
|
|
|
|
```bash
|
|
git add backend/app/core/ai_chat_service.py backend/tests/test_ai_delta_response.py
|
|
git commit -m "feat: add action-type prompt dispatch with delta response format"
|
|
```
|
|
|
|
---
|
|
|
|
## Phase 8: Suggestion Endpoints + Archive
|
|
|
|
### Task 21: Add AI Suggestion CRUD Endpoints
|
|
|
|
**Files:**
|
|
- Create: `backend/app/schemas/ai_suggestion.py`
|
|
- Create: `backend/app/api/endpoints/ai_suggestions.py`
|
|
- Modify: `backend/app/api/router.py`
|
|
|
|
**Step 1: Create schemas**
|
|
|
|
Create `backend/app/schemas/ai_suggestion.py`:
|
|
|
|
```python
|
|
"""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)$")
|
|
```
|
|
|
|
**Step 2: Create endpoints**
|
|
|
|
Create `backend/app/api/endpoints/ai_suggestions.py`:
|
|
|
|
```python
|
|
"""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
|
|
```
|
|
|
|
**Step 3: Register in router**
|
|
|
|
In `backend/app/api/router.py`, add:
|
|
|
|
```python
|
|
from app.api.endpoints.ai_suggestions import router as ai_suggestions_router
|
|
|
|
api_router.include_router(ai_suggestions_router)
|
|
```
|
|
|
|
**Step 4: Write tests**
|
|
|
|
Create `backend/tests/test_ai_suggestions.py`:
|
|
|
|
```python
|
|
"""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
|
|
```
|
|
|
|
**Step 5: Run tests**
|
|
|
|
Run: `cd backend && python -m pytest tests/test_ai_suggestions.py -v`
|
|
Expected: All PASS.
|
|
|
|
**Step 6: Commit**
|
|
|
|
```bash
|
|
git add backend/app/schemas/ai_suggestion.py backend/app/api/endpoints/ai_suggestions.py backend/app/api/router.py backend/tests/test_ai_suggestions.py
|
|
git commit -m "feat: add AI suggestion audit trail endpoints"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 22: Add Session Auto-Archive APScheduler Task
|
|
|
|
**Files:**
|
|
- Modify: `backend/app/main.py` (or wherever APScheduler is configured)
|
|
|
|
**Step 1: Find existing APScheduler setup**
|
|
|
|
Search for `APScheduler` or `add_job` in `backend/app/main.py` or `backend/app/core/`.
|
|
|
|
**Step 2: Add the archive job**
|
|
|
|
Add a new scheduled job function:
|
|
|
|
```python
|
|
async def archive_stale_ai_sessions():
|
|
"""Archive AI chat sessions with no activity for 30 days."""
|
|
from app.core.database import async_session_maker
|
|
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:
|
|
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()
|
|
```
|
|
|
|
Register it in the scheduler (follow existing pattern):
|
|
|
|
```python
|
|
scheduler.add_job(
|
|
archive_stale_ai_sessions,
|
|
'cron',
|
|
hour=3, # Run daily at 3 AM
|
|
id='archive_stale_ai_sessions',
|
|
replace_existing=True,
|
|
)
|
|
```
|
|
|
|
**Step 3: Verify the app starts**
|
|
|
|
Run: `cd backend && uvicorn app.main:app --reload`
|
|
Expected: App starts without errors, job registered.
|
|
|
|
**Step 4: Commit**
|
|
|
|
```bash
|
|
git add backend/app/main.py
|
|
git commit -m "feat: add APScheduler task to auto-archive stale AI chat sessions"
|
|
```
|
|
|
|
---
|
|
|
|
## Phase 9: Final Integration + Build Verification
|
|
|
|
### Task 23: Frontend Build Verification
|
|
|
|
**Step 1: Run full frontend build**
|
|
|
|
Run: `cd frontend && npm run build`
|
|
Expected: Build succeeds with zero errors.
|
|
|
|
**Step 2: Fix any remaining issues**
|
|
|
|
Address any TypeScript errors, unused imports, or missing type exports.
|
|
|
|
**Step 3: Commit fixes if needed**
|
|
|
|
```bash
|
|
git add -A
|
|
git commit -m "fix: resolve build issues from editor AI integration"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 24: Backend Test Suite Verification
|
|
|
|
**Step 1: Run full backend test suite**
|
|
|
|
Run: `cd backend && python -m pytest --override-ini="addopts=" -v`
|
|
Expected: All tests PASS. No regressions.
|
|
|
|
**Step 2: Fix any failures**
|
|
|
|
Address any test failures from the changes.
|
|
|
|
**Step 3: Commit fixes if needed**
|
|
|
|
```bash
|
|
git add -A
|
|
git commit -m "fix: resolve test regressions from editor AI integration"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 25: Update Documentation
|
|
|
|
**Files:**
|
|
- Modify: `CURRENT-STATE.md`
|
|
- Modify: `CLAUDE.md` (if new patterns/lessons discovered)
|
|
|
|
**Step 1: Update CURRENT-STATE.md**
|
|
|
|
Move "AI chat session conclusion" items and add:
|
|
- **In Progress:** Editor-Embedded Flow Assist (AI panel in tree + procedural editors)
|
|
- **Recently Completed:** (update as appropriate)
|
|
|
|
**Step 2: Update CLAUDE.md if needed**
|
|
|
|
Add any new lessons learned (e.g., ghost node pattern, zundo pause/resume, action-type routing).
|
|
|
|
**Step 3: Commit**
|
|
|
|
```bash
|
|
git add CURRENT-STATE.md CLAUDE.md
|
|
git commit -m "docs: update project status for editor-embedded Flow Assist"
|
|
```
|
|
|
|
---
|
|
|
|
## Summary
|
|
|
|
| Phase | Tasks | Description |
|
|
|---|---|---|
|
|
| **1** | 1-6 | Bug fix + backend foundation (config, models, migrations, schemas) |
|
|
| **2** | 7-10 | Frontend infrastructure (types, ContextMenu, EditorAIPanel, useEditorAI hook) |
|
|
| **3** | 11-13 | Tree editor integration (panel layout, context menu threading, ghost nodes) |
|
|
| **4** | 14-15 | Procedural editor integration (panel layout, ghost steps) |
|
|
| **5** | 16-17 | AI-assisted create (prompt dialog, CreateFlowDropdown update) |
|
|
| **6** | 18 | Remove old standalone Flow Assist |
|
|
| **7** | 19-20 | Backend action-type prompt dispatch + delta response |
|
|
| **8** | 21-22 | Suggestion endpoints + auto-archive |
|
|
| **9** | 23-25 | Build verification + docs |
|
|
|
|
**Deferred to future work:**
|
|
- Knowledge integration (Phase 1: Microsoft Learn MCP)
|
|
- Intake variable inference (tiers 2-3: natural language + cross-step)
|
|
- Ghost node grouping for 5+ suggestions ("Accept Branch" controls)
|
|
- Before/after diff view for modified-selected-node in chat
|