1991 lines
66 KiB
Markdown
1991 lines
66 KiB
Markdown
# FlowPilot Message Bar & AI Script Builder — Implementation Plan
|
|
|
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
|
|
**Goal:** Add an always-visible message bar to FlowPilot sessions, build a standalone AI Script Builder with chat-style UX and fullscreen preview modal, reorganize the Script Library with My/Team tabs, and connect FlowPilot to the Script Builder.
|
|
|
|
**Architecture:** Five phases executed sequentially. Phase 1 is frontend-only (message bar). Phases 2-3 add the Script Builder (new model, service, endpoints, chat page, preview modal, save flow). Phase 4 reorganizes the existing Script Library. Phase 5 wires FlowPilot to the Script Builder.
|
|
|
|
**Tech Stack:** FastAPI, SQLAlchemy 2.0 (async), Alembic, Anthropic Claude API (Sonnet), React 19, TypeScript, Zustand, Tailwind CSS, react-syntax-highlighter, Lucide React
|
|
|
|
**Spec:** `docs/superpowers/specs/2026-03-21-flowpilot-message-bar-and-script-builder-design.md`
|
|
|
|
---
|
|
|
|
## File Map
|
|
|
|
### Phase 1 — Message Bar
|
|
|
|
| Action | File |
|
|
| ------ | ---- |
|
|
| Create | `frontend/src/components/flowpilot/FlowPilotMessageBar.tsx` |
|
|
| Modify | `frontend/src/components/flowpilot/FlowPilotSession.tsx` |
|
|
| Modify | `frontend/src/components/flowpilot/FlowPilotStepCard.tsx` |
|
|
|
|
### Phase 2 — Script Builder Core (Backend)
|
|
|
|
| Action | File |
|
|
| ------ | ---- |
|
|
| Create | `backend/app/models/script_builder_session.py` |
|
|
| Modify | `backend/alembic/env.py` (import new model) |
|
|
| Create | `backend/alembic/versions/<id>_add_script_builder_sessions.py` (migration) |
|
|
| Create | `backend/app/schemas/script_builder.py` |
|
|
| Create | `backend/app/services/script_builder_service.py` |
|
|
| Create | `backend/app/api/endpoints/script_builder.py` |
|
|
| Modify | `backend/app/api/router.py` (register new router) |
|
|
| Modify | `backend/app/core/config.py` (add `script_build` action to `ACTION_MODEL_MAP`) |
|
|
| Create | `backend/tests/test_script_builder.py` |
|
|
|
|
### Phase 2 — Script Builder Core (Frontend)
|
|
|
|
| Action | File |
|
|
| ------ | ---- |
|
|
| Create | `frontend/src/types/script-builder.ts` |
|
|
| Modify | `frontend/src/types/index.ts` (export new types) |
|
|
| Create | `frontend/src/api/scriptBuilder.ts` |
|
|
| Modify | `frontend/src/api/index.ts` (export new API module) |
|
|
| Create | `frontend/src/pages/ScriptBuilderPage.tsx` |
|
|
| Create | `frontend/src/components/script-builder/ScriptBuilderChat.tsx` |
|
|
| Create | `frontend/src/components/script-builder/ScriptBuilderInput.tsx` |
|
|
| Create | `frontend/src/components/script-builder/ScriptCodeBlock.tsx` |
|
|
| Modify | `frontend/src/router.tsx` (add route) |
|
|
| Modify | `frontend/src/components/layout/Sidebar.tsx` (add nav item) |
|
|
| Modify | `frontend/src/components/layout/AppLayout.tsx` (add mobile nav item) |
|
|
|
|
### Phase 3 — Preview Modal & Save Flow
|
|
|
|
| Action | File |
|
|
| ------ | ---- |
|
|
| Create | `frontend/src/components/script-builder/ScriptPreviewModal.tsx` |
|
|
| Create | `frontend/src/components/script-builder/SaveToLibraryDialog.tsx` |
|
|
| Modify | `frontend/src/components/script-builder/ScriptCodeBlock.tsx` (wire modal + save) |
|
|
| Modify | `frontend/src/api/scriptBuilder.ts` (add save method) |
|
|
|
|
### Phase 4 — Library Reorganization
|
|
|
|
| Action | File |
|
|
| ------ | ---- |
|
|
| Create | `backend/alembic/versions/<id>_add_language_and_ai_generated_category.py` (migration) |
|
|
| Modify | `backend/app/models/script_template.py` (add `language` column) |
|
|
| Modify | `backend/app/schemas/script_template.py` (add `language` field, `SaveToLibraryRequest`) |
|
|
| Modify | `backend/app/api/endpoints/scripts.py` (add `mine` filter) |
|
|
| Modify | `frontend/src/pages/ScriptLibraryPage.tsx` (tabs + Build button) |
|
|
| Modify | `frontend/src/types/scripts.ts` (add `language` field) |
|
|
|
|
### Phase 5 — FlowPilot Integration
|
|
|
|
| Action | File |
|
|
| ------ | ---- |
|
|
| Modify | `backend/app/services/flowpilot_engine.py` (prompt update for script detection) |
|
|
| Modify | `frontend/src/components/flowpilot/FlowPilotStepCard.tsx` (Script Builder action button) |
|
|
|
|
---
|
|
|
|
## Phase 1: Always-Visible Message Bar
|
|
|
|
### Task 1.1: Create FlowPilotMessageBar Component
|
|
|
|
**Files:**
|
|
|
|
- Create: `frontend/src/components/flowpilot/FlowPilotMessageBar.tsx`
|
|
|
|
- [ ] **Step 1: Create the message bar component**
|
|
|
|
```tsx
|
|
// frontend/src/components/flowpilot/FlowPilotMessageBar.tsx
|
|
import { useState, useRef, useCallback } from 'react'
|
|
import { Send } from 'lucide-react'
|
|
import { cn } from '@/lib/utils'
|
|
import type { StepResponseRequest } from '@/types/ai-session'
|
|
|
|
interface FlowPilotMessageBarProps {
|
|
onRespond: (response: StepResponseRequest) => void
|
|
disabled?: boolean
|
|
isProcessing?: boolean
|
|
}
|
|
|
|
export function FlowPilotMessageBar({ onRespond, disabled = false, isProcessing = false }: FlowPilotMessageBarProps) {
|
|
const [message, setMessage] = useState('')
|
|
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
|
|
|
const isDisabled = disabled || isProcessing
|
|
|
|
const handleSubmit = useCallback(() => {
|
|
const trimmed = message.trim()
|
|
if (!trimmed || isDisabled) return
|
|
onRespond({ free_text_input: trimmed })
|
|
setMessage('')
|
|
// Reset textarea height
|
|
if (textareaRef.current) {
|
|
textareaRef.current.style.height = 'auto'
|
|
}
|
|
}, [message, isDisabled, onRespond])
|
|
|
|
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
|
if (e.key === 'Enter' && !e.shiftKey) {
|
|
e.preventDefault()
|
|
handleSubmit()
|
|
}
|
|
}, [handleSubmit])
|
|
|
|
const handleInput = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
|
setMessage(e.target.value)
|
|
// Auto-resize textarea
|
|
const textarea = e.target
|
|
textarea.style.height = 'auto'
|
|
textarea.style.height = `${Math.min(textarea.scrollHeight, 120)}px`
|
|
}, [])
|
|
|
|
return (
|
|
<div className="px-3 sm:px-4 lg:px-5 pb-3">
|
|
<div className={cn(
|
|
'flex items-end gap-2 rounded-xl border bg-card p-2 transition-colors',
|
|
isDisabled
|
|
? 'border-border/50 opacity-50'
|
|
: 'border-border focus-within:border-[rgba(6,182,212,0.3)]'
|
|
)}>
|
|
<textarea
|
|
ref={textareaRef}
|
|
value={message}
|
|
onChange={handleInput}
|
|
onKeyDown={handleKeyDown}
|
|
placeholder={isProcessing ? 'FlowPilot is thinking...' : 'Type a message...'}
|
|
disabled={isDisabled}
|
|
rows={1}
|
|
className="flex-1 resize-none bg-transparent text-sm text-foreground placeholder:text-muted-foreground focus:outline-none disabled:cursor-not-allowed py-1.5 px-2"
|
|
/>
|
|
<button
|
|
onClick={handleSubmit}
|
|
disabled={isDisabled || !message.trim()}
|
|
className={cn(
|
|
'flex h-8 w-8 shrink-0 items-center justify-center rounded-lg transition-all',
|
|
message.trim() && !isDisabled
|
|
? 'bg-gradient-brand text-[#101114] hover:opacity-90 active:scale-[0.97]'
|
|
: 'bg-[rgba(255,255,255,0.04)] text-muted-foreground cursor-not-allowed'
|
|
)}
|
|
>
|
|
<Send size={14} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Verify the component compiles**
|
|
|
|
Run: `cd frontend && npx tsc --noEmit --pretty 2>&1 | head -20`
|
|
Expected: No errors related to `FlowPilotMessageBar`
|
|
|
|
### Task 1.2: Integrate Message Bar into FlowPilotSession
|
|
|
|
**Files:**
|
|
|
|
- Modify: `frontend/src/components/flowpilot/FlowPilotSession.tsx`
|
|
|
|
- [ ] **Step 1: Read the current FlowPilotSession component**
|
|
|
|
Read: `frontend/src/components/flowpilot/FlowPilotSession.tsx`
|
|
Identify: Where the step cards are rendered and where `FlowPilotActionBar` is placed.
|
|
|
|
- [ ] **Step 2: Import and add FlowPilotMessageBar**
|
|
|
|
Add import at top:
|
|
|
|
```tsx
|
|
import { FlowPilotMessageBar } from './FlowPilotMessageBar'
|
|
```
|
|
|
|
Add the message bar between the step card list and the action bar. It should be inside the conversation column, after the scrollable step list, pinned to the bottom. The message bar should:
|
|
|
|
- Receive the same `onRespond` callback used by step cards
|
|
- Be disabled when `isProcessing` is true
|
|
- Be disabled when `session.status !== 'active'`
|
|
- Be disabled when the current step has `allow_free_text === false`
|
|
|
|
The `allow_free_text` check requires reading the current step. Use the last step in `allSteps` (the current step) to check the flag.
|
|
|
|
- [ ] **Step 3: Verify the component compiles**
|
|
|
|
Run: `cd frontend && npx tsc --noEmit --pretty 2>&1 | head -20`
|
|
Expected: No errors
|
|
|
|
### Task 1.3: Remove Free Text Escape Hatch from FlowPilotStepCard
|
|
|
|
**Files:**
|
|
|
|
- Modify: `frontend/src/components/flowpilot/FlowPilotStepCard.tsx`
|
|
|
|
- [ ] **Step 1: Read the current file**
|
|
|
|
Read: `frontend/src/components/flowpilot/FlowPilotStepCard.tsx`
|
|
Identify: The free text escape hatch block — the `{!isResolutionSuggestion && step.allow_free_text && ...}` conditional (around lines 201-238).
|
|
|
|
- [ ] **Step 2: Remove the free text escape hatch block**
|
|
|
|
Remove the entire `{!isResolutionSuggestion && step.allow_free_text && ...}` block (the "None of these — let me describe" button, the `RichTextInput`, and the Submit/Cancel buttons).
|
|
|
|
Also remove unused state and imports that were only used by the escape hatch:
|
|
|
|
- `const [freeText, setFreeText] = useState('')`
|
|
- `const [showFreeText, setShowFreeText] = useState(false)`
|
|
- `const [_freeTextUploads, setFreeTextUploads] = useState<FileUploadResponse[]>([])`
|
|
- `import { RichTextInput } from '@/components/common/RichTextInput'`
|
|
- `import type { FileUploadResponse } from '@/types/upload'`
|
|
- The `handleFreeTextSubmit` function
|
|
|
|
Keep `handleOptionSelect`, `handleSkip`, `handleActionComplete`, `handleResolutionResponse` — those are still used.
|
|
|
|
- [ ] **Step 3: Verify build passes**
|
|
|
|
Run: `cd frontend && npm run build 2>&1 | tail -20`
|
|
Expected: Build succeeds with no errors
|
|
|
|
- [ ] **Step 4: Commit Phase 1**
|
|
|
|
```bash
|
|
git add frontend/src/components/flowpilot/FlowPilotMessageBar.tsx \
|
|
frontend/src/components/flowpilot/FlowPilotSession.tsx \
|
|
frontend/src/components/flowpilot/FlowPilotStepCard.tsx
|
|
git commit -m "feat(flowpilot): add always-visible message bar, remove hidden free text escape hatch
|
|
|
|
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>"
|
|
```
|
|
|
|
---
|
|
|
|
## Phase 2: Script Builder Core — Backend
|
|
|
|
### Task 2.1: Create ScriptBuilderSession Model
|
|
|
|
**Files:**
|
|
|
|
- Create: `backend/app/models/script_builder_session.py`
|
|
- Modify: `backend/alembic/env.py`
|
|
|
|
- [ ] **Step 1: Create the model**
|
|
|
|
```python
|
|
# backend/app/models/script_builder_session.py
|
|
"""Script Builder session model.
|
|
|
|
Tracks AI-powered script generation conversations.
|
|
"""
|
|
import uuid
|
|
from datetime import datetime, timezone
|
|
from typing import Optional, Any, TYPE_CHECKING
|
|
|
|
from sqlalchemy import String, Text, DateTime, ForeignKey, Integer
|
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
|
from sqlalchemy.dialects.postgresql import UUID, JSONB
|
|
|
|
from app.core.database import Base
|
|
|
|
if TYPE_CHECKING:
|
|
from app.models.user import User
|
|
|
|
|
|
class ScriptBuilderSession(Base):
|
|
"""A conversation session in the AI Script Builder.
|
|
|
|
Each session tracks a multi-turn conversation where the user
|
|
describes what script they need and the AI generates it.
|
|
"""
|
|
__tablename__ = "script_builder_sessions"
|
|
|
|
id: Mapped[uuid.UUID] = mapped_column(
|
|
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
|
)
|
|
user_id: Mapped[uuid.UUID] = mapped_column(
|
|
UUID(as_uuid=True),
|
|
ForeignKey("users.id", ondelete="CASCADE"),
|
|
nullable=False,
|
|
index=True,
|
|
)
|
|
team_id: Mapped[Optional[uuid.UUID]] = mapped_column(
|
|
UUID(as_uuid=True),
|
|
ForeignKey("teams.id", ondelete="SET NULL"),
|
|
nullable=True,
|
|
)
|
|
language: Mapped[str] = mapped_column(
|
|
String(30), nullable=False, default="powershell",
|
|
comment="Script language: powershell, bash, python",
|
|
)
|
|
title: Mapped[Optional[str]] = mapped_column(
|
|
String(200), nullable=True,
|
|
comment="Auto-generated from first AI response",
|
|
)
|
|
messages: Mapped[list[dict[str, Any]]] = mapped_column(
|
|
JSONB, nullable=False, default=list,
|
|
comment="Array of {role, content, script?, script_filename?, timestamp}",
|
|
)
|
|
latest_script: Mapped[Optional[str]] = mapped_column(
|
|
Text, nullable=True,
|
|
comment="Most recent generated script for quick access",
|
|
)
|
|
latest_script_filename: Mapped[Optional[str]] = mapped_column(
|
|
String(200), nullable=True,
|
|
comment="Filename of the latest generated script",
|
|
)
|
|
message_count: Mapped[int] = mapped_column(
|
|
Integer, nullable=False, default=0,
|
|
)
|
|
ai_session_id: Mapped[Optional[uuid.UUID]] = mapped_column(
|
|
UUID(as_uuid=True),
|
|
ForeignKey("ai_sessions.id", ondelete="SET NULL"),
|
|
nullable=True,
|
|
comment="Link to FlowPilot session if launched from there",
|
|
)
|
|
|
|
created_at: Mapped[datetime] = mapped_column(
|
|
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
|
|
)
|
|
updated_at: Mapped[datetime] = mapped_column(
|
|
DateTime(timezone=True),
|
|
default=lambda: datetime.now(timezone.utc),
|
|
onupdate=lambda: datetime.now(timezone.utc),
|
|
)
|
|
|
|
# Relationships
|
|
user: Mapped["User"] = relationship("User")
|
|
```
|
|
|
|
- [ ] **Step 2: Import model in alembic/env.py**
|
|
|
|
Read `backend/alembic/env.py` and add the import alongside existing model imports:
|
|
|
|
```python
|
|
from app.models.script_builder_session import ScriptBuilderSession # noqa: F401
|
|
```
|
|
|
|
- [ ] **Step 3: Generate migration**
|
|
|
|
Run: `cd backend && alembic revision --autogenerate -m "add_script_builder_sessions_table" --rev-id=062`
|
|
|
|
- [ ] **Step 4: Review the generated migration**
|
|
|
|
Read the generated migration file. Verify it creates the `script_builder_sessions` table with all columns and indexes. Remove any unrelated DROP/ALTER operations (per CLAUDE.md lesson #17).
|
|
|
|
- [ ] **Step 5: Run migration**
|
|
|
|
Run: `cd backend && alembic upgrade head`
|
|
Expected: Migration applies successfully
|
|
|
|
- [ ] **Step 6: Commit**
|
|
|
|
```bash
|
|
git add backend/app/models/script_builder_session.py \
|
|
backend/alembic/env.py \
|
|
backend/alembic/versions/
|
|
git commit -m "feat: add ScriptBuilderSession model and migration
|
|
|
|
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>"
|
|
```
|
|
|
|
### Task 2.2: Create Pydantic Schemas
|
|
|
|
**Files:**
|
|
|
|
- Create: `backend/app/schemas/script_builder.py`
|
|
|
|
- [ ] **Step 1: Create the schemas**
|
|
|
|
```python
|
|
# backend/app/schemas/script_builder.py
|
|
"""Pydantic schemas for the AI Script Builder."""
|
|
from datetime import datetime
|
|
from typing import Any, Optional
|
|
from uuid import UUID
|
|
|
|
from pydantic import BaseModel, Field
|
|
|
|
|
|
class ScriptBuilderCreateRequest(BaseModel):
|
|
"""Request to start a new builder session."""
|
|
language: str = Field(
|
|
default="powershell",
|
|
pattern=r"^(powershell|bash|python)$",
|
|
description="Script language",
|
|
)
|
|
|
|
|
|
class ScriptBuilderMessageRequest(BaseModel):
|
|
"""User message in a builder session."""
|
|
content: str = Field(min_length=1, max_length=5000)
|
|
|
|
|
|
class ScriptBuilderMessageResponse(BaseModel):
|
|
"""AI response to a builder message."""
|
|
role: str = "assistant"
|
|
content: str
|
|
script: str | None = None
|
|
script_filename: str | None = None
|
|
line_count: int | None = None
|
|
timestamp: datetime
|
|
|
|
model_config = {"from_attributes": True}
|
|
|
|
|
|
class ScriptBuilderSessionSummary(BaseModel):
|
|
"""Lightweight session for list views (no messages)."""
|
|
id: UUID
|
|
language: str
|
|
title: str | None = None
|
|
message_count: int
|
|
latest_script_filename: str | None = None
|
|
created_at: datetime
|
|
updated_at: datetime
|
|
|
|
model_config = {"from_attributes": True}
|
|
|
|
|
|
class ScriptBuilderSessionDetail(BaseModel):
|
|
"""Full session with message history."""
|
|
id: UUID
|
|
language: str
|
|
title: str | None = None
|
|
messages: list[dict[str, Any]]
|
|
latest_script: str | None = None
|
|
latest_script_filename: str | None = None
|
|
message_count: int
|
|
ai_session_id: UUID | None = None
|
|
created_at: datetime
|
|
updated_at: datetime
|
|
|
|
model_config = {"from_attributes": True}
|
|
|
|
|
|
class SaveToLibraryRequest(BaseModel):
|
|
"""Request to save a generated script to the Script Library."""
|
|
name: str = Field(min_length=1, max_length=200)
|
|
description: str | None = None
|
|
category_id: UUID | None = None
|
|
share_with_team: bool = False
|
|
```
|
|
|
|
- [ ] **Step 2: Verify schemas compile**
|
|
|
|
Run: `cd backend && python -c "from app.schemas.script_builder import *; print('OK')"`
|
|
Expected: `OK`
|
|
|
|
### Task 2.3: Create Script Builder Service
|
|
|
|
**Files:**
|
|
|
|
- Create: `backend/app/services/script_builder_service.py`
|
|
- Modify: `backend/app/core/config.py`
|
|
|
|
- [ ] **Step 1: Add script_build action to config.py**
|
|
|
|
Read `backend/app/core/config.py` and find the `ACTION_MODEL_MAP` dict. Add:
|
|
|
|
```python
|
|
"script_build": "standard",
|
|
```
|
|
|
|
- [ ] **Step 2: Create the service**
|
|
|
|
```python
|
|
# backend/app/services/script_builder_service.py
|
|
"""AI Script Builder service — generates scripts from natural language descriptions."""
|
|
import logging
|
|
import re
|
|
from datetime import datetime, timezone
|
|
from typing import Optional
|
|
from uuid import UUID
|
|
|
|
from sqlalchemy import select, func
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.core.ai_provider import get_ai_provider
|
|
from app.core.config import settings
|
|
from app.models.script_builder_session import ScriptBuilderSession
|
|
from app.schemas.script_builder import (
|
|
ScriptBuilderMessageResponse,
|
|
ScriptBuilderSessionDetail,
|
|
ScriptBuilderSessionSummary,
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
MAX_MESSAGES_PER_SESSION = 30
|
|
|
|
LANGUAGE_PROMPTS = {
|
|
"powershell": """\
|
|
You are an expert PowerShell script writer for MSP (Managed Service Provider) environments.
|
|
|
|
## Script Standards
|
|
- Use Advanced Functions with CmdletBinding and param() blocks
|
|
- Include comment-based help (.SYNOPSIS, .DESCRIPTION, .PARAMETER, .EXAMPLE)
|
|
- Use try/catch/finally for error handling
|
|
- Use Write-Verbose for diagnostic output, Write-Error for failures
|
|
- Support pipeline input where appropriate
|
|
- Use approved PowerShell verbs (Get-, Set-, New-, Remove-, etc.)
|
|
- Import required modules at the top (e.g., Import-Module ActiveDirectory)
|
|
- Use [Parameter(Mandatory=$true)] for required params
|
|
- Default to UTF-8 output for exports
|
|
|
|
## Security
|
|
- Never hardcode credentials — use Get-Credential or SecureString params
|
|
- Use -WhatIf and -Confirm support via SupportsShouldProcess
|
|
- Validate input with ValidateSet, ValidatePattern, ValidateRange
|
|
""",
|
|
"bash": """\
|
|
You are an expert Bash script writer for Linux/macOS system administration.
|
|
|
|
## Script Standards
|
|
- Start with #!/bin/bash
|
|
- Use set -euo pipefail for safety
|
|
- Parse arguments with getopts or positional parameters
|
|
- Include a usage() function for --help
|
|
- Use functions for logical grouping
|
|
- Quote all variable expansions ("$var" not $var)
|
|
- Use [[ ]] for conditionals (not [ ])
|
|
- Add comments explaining non-obvious logic
|
|
- Use lowercase_with_underscores for variable names
|
|
- Exit with meaningful exit codes (0=success, 1=general error, 2=usage error)
|
|
|
|
## Security
|
|
- Never store passwords in scripts — use environment variables or prompts
|
|
- Validate all user inputs
|
|
- Use mktemp for temporary files
|
|
""",
|
|
"python": """\
|
|
You are an expert Python script writer for IT automation and system administration.
|
|
|
|
## Script Standards
|
|
- Use Python 3.10+ syntax
|
|
- Add type hints to all function signatures
|
|
- Use argparse for CLI argument parsing
|
|
- Include if __name__ == "__main__": guard
|
|
- Use logging module (not print) for diagnostic output
|
|
- Use docstrings for functions and modules
|
|
- Use pathlib.Path instead of os.path
|
|
- Handle exceptions with specific exception types
|
|
- Use f-strings for string formatting
|
|
- Follow PEP 8 naming conventions
|
|
|
|
## Security
|
|
- Never hardcode secrets — use environment variables or config files
|
|
- Validate and sanitize all user inputs
|
|
- Use subprocess.run() with shell=False (never shell=True with user input)
|
|
""",
|
|
}
|
|
|
|
SYSTEM_PROMPT_TEMPLATE = """\
|
|
{language_prompt}
|
|
|
|
## Response Format
|
|
Respond conversationally. When generating a script:
|
|
1. Briefly explain what the script does and any assumptions
|
|
2. Include the complete script in a single fenced code block with the language tag
|
|
3. Suggest a filename (e.g., `Get-LinkedGPOs.ps1`)
|
|
|
|
When the user asks for modifications, generate the COMPLETE updated script (not a diff).
|
|
|
|
## Context
|
|
The user is an MSP engineer using ResolutionFlow. They need scripts for managing client infrastructure.
|
|
Keep scripts practical, production-ready, and well-documented.\
|
|
"""
|
|
|
|
|
|
def _extract_script_from_response(content: str, language: str) -> tuple[str | None, str | None]:
|
|
"""Extract code block and filename from AI response.
|
|
|
|
Returns (script, filename) tuple.
|
|
"""
|
|
# Map language to code fence tags
|
|
lang_tags = {
|
|
"powershell": ["powershell", "ps1", "pwsh"],
|
|
"bash": ["bash", "sh", "shell"],
|
|
"python": ["python", "py", "python3"],
|
|
}
|
|
tags = lang_tags.get(language, [language])
|
|
|
|
# Try each language-specific tag, then fall back to generic fence
|
|
script = None
|
|
for tag in tags:
|
|
pattern = rf"```{tag}\s*\n(.*?)```"
|
|
match = re.search(pattern, content, re.DOTALL | re.IGNORECASE)
|
|
if match:
|
|
script = match.group(1).strip()
|
|
break
|
|
else:
|
|
# Try generic code fence
|
|
pattern = r"```\s*\n(.*?)```"
|
|
match = re.search(pattern, content, re.DOTALL)
|
|
script = match.group(1).strip() if match else None
|
|
|
|
# Extract filename suggestion
|
|
filename = None
|
|
ext_map = {"powershell": ".ps1", "bash": ".sh", "python": ".py"}
|
|
ext = ext_map.get(language, ".txt")
|
|
filename_pattern = rf"`([A-Za-z0-9_\-]+{re.escape(ext)})`"
|
|
fname_match = re.search(filename_pattern, content)
|
|
if fname_match:
|
|
filename = fname_match.group(1)
|
|
|
|
return script, filename
|
|
|
|
|
|
async def create_session(
|
|
db: AsyncSession,
|
|
user_id: UUID,
|
|
team_id: UUID | None,
|
|
language: str,
|
|
initial_prompt: str | None = None,
|
|
) -> ScriptBuilderSession:
|
|
"""Create a new Script Builder session."""
|
|
session = ScriptBuilderSession(
|
|
user_id=user_id,
|
|
team_id=team_id,
|
|
language=language,
|
|
messages=[],
|
|
message_count=0,
|
|
)
|
|
db.add(session)
|
|
await db.flush()
|
|
|
|
# If initial prompt provided (e.g., from FlowPilot), send first message
|
|
if initial_prompt:
|
|
await send_message(db, session, initial_prompt)
|
|
|
|
return session
|
|
|
|
|
|
async def send_message(
|
|
db: AsyncSession,
|
|
session: ScriptBuilderSession,
|
|
user_content: str,
|
|
) -> ScriptBuilderMessageResponse:
|
|
"""Send a user message and get AI response with generated script."""
|
|
if session.message_count >= MAX_MESSAGES_PER_SESSION:
|
|
raise ValueError(f"Session has reached the maximum of {MAX_MESSAGES_PER_SESSION} messages.")
|
|
|
|
now = datetime.now(timezone.utc)
|
|
|
|
# Append user message
|
|
user_msg = {
|
|
"role": "user",
|
|
"content": user_content,
|
|
"timestamp": now.isoformat(),
|
|
}
|
|
messages = list(session.messages or [])
|
|
messages.append(user_msg)
|
|
|
|
# Build system prompt
|
|
language_prompt = LANGUAGE_PROMPTS.get(session.language, LANGUAGE_PROMPTS["powershell"])
|
|
system_prompt = SYSTEM_PROMPT_TEMPLATE.format(language_prompt=language_prompt)
|
|
|
|
# Build conversation for AI (just role + content)
|
|
ai_messages = [{"role": m["role"], "content": m["content"]} for m in messages]
|
|
|
|
# Context window management: keep last 20 messages (10 exchanges)
|
|
if len(ai_messages) > 20:
|
|
ai_messages = ai_messages[-20:]
|
|
|
|
# Call AI
|
|
model = settings.get_model_for_action("script_build")
|
|
provider = get_ai_provider(model=model)
|
|
ai_text, input_tokens, output_tokens = await provider.generate_text(
|
|
system_prompt=system_prompt,
|
|
messages=ai_messages,
|
|
max_tokens=8192,
|
|
)
|
|
|
|
# Extract script from response
|
|
script, filename = _extract_script_from_response(ai_text, session.language)
|
|
line_count = len(script.splitlines()) if script else None
|
|
|
|
# Build assistant message
|
|
assistant_msg = {
|
|
"role": "assistant",
|
|
"content": ai_text,
|
|
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
"input_tokens": input_tokens,
|
|
"output_tokens": output_tokens,
|
|
}
|
|
if script:
|
|
assistant_msg["script"] = script
|
|
assistant_msg["script_filename"] = filename
|
|
assistant_msg["line_count"] = line_count
|
|
|
|
messages.append(assistant_msg)
|
|
|
|
# Update session
|
|
session.messages = messages
|
|
session.message_count = len([m for m in messages if m["role"] == "user"])
|
|
if script:
|
|
session.latest_script = script
|
|
session.latest_script_filename = filename
|
|
if not session.title and len(messages) >= 2:
|
|
# Auto-generate title from first user message (truncate)
|
|
first_user = user_content[:100]
|
|
session.title = first_user if len(user_content) <= 100 else first_user + "..."
|
|
session.updated_at = datetime.now(timezone.utc)
|
|
|
|
await db.flush()
|
|
|
|
return ScriptBuilderMessageResponse(
|
|
role="assistant",
|
|
content=ai_text,
|
|
script=script,
|
|
script_filename=filename,
|
|
line_count=line_count,
|
|
timestamp=datetime.now(timezone.utc),
|
|
)
|
|
|
|
|
|
async def get_session(
|
|
db: AsyncSession,
|
|
session_id: UUID,
|
|
user_id: UUID,
|
|
) -> ScriptBuilderSession | None:
|
|
"""Get a session by ID, ensuring the user owns it."""
|
|
result = await db.execute(
|
|
select(ScriptBuilderSession).where(
|
|
ScriptBuilderSession.id == session_id,
|
|
ScriptBuilderSession.user_id == user_id,
|
|
)
|
|
)
|
|
return result.scalar_one_or_none()
|
|
|
|
|
|
async def list_sessions(
|
|
db: AsyncSession,
|
|
user_id: UUID,
|
|
limit: int = 20,
|
|
offset: int = 0,
|
|
) -> list[ScriptBuilderSession]:
|
|
"""List user's builder sessions ordered by updated_at desc."""
|
|
result = await db.execute(
|
|
select(ScriptBuilderSession)
|
|
.where(ScriptBuilderSession.user_id == user_id)
|
|
.order_by(ScriptBuilderSession.updated_at.desc())
|
|
.limit(limit)
|
|
.offset(offset)
|
|
)
|
|
return list(result.scalars().all())
|
|
|
|
|
|
async def delete_session(
|
|
db: AsyncSession,
|
|
session_id: UUID,
|
|
user_id: UUID,
|
|
) -> bool:
|
|
"""Delete a builder session. Returns True if deleted."""
|
|
session = await get_session(db, session_id, user_id)
|
|
if not session:
|
|
return False
|
|
await db.delete(session)
|
|
await db.flush()
|
|
return True
|
|
|
|
|
|
async def count_user_sessions(db: AsyncSession, user_id: UUID) -> int:
|
|
"""Count active builder sessions for a user."""
|
|
result = await db.execute(
|
|
select(func.count(ScriptBuilderSession.id)).where(
|
|
ScriptBuilderSession.user_id == user_id
|
|
)
|
|
)
|
|
return result.scalar_one()
|
|
|
|
|
|
async def save_to_library(
|
|
db: AsyncSession,
|
|
session: ScriptBuilderSession,
|
|
name: str,
|
|
description: str | None,
|
|
category_id: UUID | None,
|
|
share_with_team: bool,
|
|
user_id: UUID,
|
|
team_id: UUID | None,
|
|
) -> "ScriptTemplate":
|
|
"""Save the latest generated script to the Script Library as a ScriptTemplate."""
|
|
import uuid as uuid_mod
|
|
from app.models.script_template import ScriptTemplate, ScriptCategory
|
|
|
|
if not session.latest_script:
|
|
raise ValueError("No script has been generated in this session yet")
|
|
|
|
# Resolve category: use provided, or find "AI Generated" default
|
|
resolved_category_id = category_id
|
|
if not resolved_category_id:
|
|
result = await db.execute(
|
|
select(ScriptCategory.id).where(ScriptCategory.slug == "ai-generated")
|
|
)
|
|
default_cat = result.scalar_one_or_none()
|
|
if not default_cat:
|
|
raise ValueError("Default 'AI Generated' category not found. Run migrations.")
|
|
resolved_category_id = default_cat
|
|
|
|
# Generate unique slug
|
|
base_slug = name.lower().replace(" ", "-").replace("_", "-")[:80]
|
|
base_slug = re.sub(r"[^a-z0-9\-]", "", base_slug)
|
|
slug = base_slug
|
|
# Check uniqueness
|
|
existing = await db.execute(
|
|
select(ScriptTemplate.id).where(ScriptTemplate.slug == slug)
|
|
)
|
|
if existing.scalar_one_or_none():
|
|
slug = f"{base_slug}-{uuid_mod.uuid4().hex[:6]}"
|
|
|
|
template = ScriptTemplate(
|
|
id=uuid_mod.uuid4(),
|
|
category_id=resolved_category_id,
|
|
created_by=user_id,
|
|
team_id=team_id if share_with_team else None,
|
|
name=name,
|
|
slug=slug,
|
|
description=description,
|
|
script_body=session.latest_script,
|
|
language=session.language,
|
|
parameters_schema={"parameters": []},
|
|
default_values={},
|
|
validation_rules={},
|
|
tags=[session.language, "ai-generated"],
|
|
complexity="intermediate",
|
|
is_verified=False,
|
|
is_active=True,
|
|
version=1,
|
|
usage_count=0,
|
|
)
|
|
db.add(template)
|
|
await db.flush()
|
|
return template
|
|
```
|
|
|
|
- [ ] **Step 3: Verify service compiles**
|
|
|
|
Run: `cd backend && python -c "from app.services.script_builder_service import *; print('OK')"`
|
|
Expected: `OK`
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
git add backend/app/services/script_builder_service.py \
|
|
backend/app/schemas/script_builder.py \
|
|
backend/app/core/config.py
|
|
git commit -m "feat: add Script Builder service with language-specific prompts
|
|
|
|
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>"
|
|
```
|
|
|
|
### Task 2.4: Create API Endpoints
|
|
|
|
**Files:**
|
|
|
|
- Create: `backend/app/api/endpoints/script_builder.py`
|
|
- Modify: `backend/app/api/router.py`
|
|
|
|
- [ ] **Step 1: Create the endpoints**
|
|
|
|
```python
|
|
# backend/app/api/endpoints/script_builder.py
|
|
"""Script Builder API endpoints."""
|
|
from typing import Annotated
|
|
from uuid import UUID
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Request
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.core.database import get_db
|
|
from app.core.rate_limit import limiter
|
|
from app.api.deps import get_current_active_user
|
|
from app.models.user import User
|
|
from app.schemas.script_builder import (
|
|
ScriptBuilderCreateRequest,
|
|
ScriptBuilderMessageRequest,
|
|
ScriptBuilderMessageResponse,
|
|
ScriptBuilderSessionDetail,
|
|
ScriptBuilderSessionSummary,
|
|
SaveToLibraryRequest,
|
|
)
|
|
from app.schemas.script_template import ScriptTemplateDetail
|
|
from app.services import script_builder_service
|
|
|
|
router = APIRouter(prefix="/scripts/builder", tags=["script-builder"])
|
|
|
|
MAX_SESSIONS_PER_USER = 5
|
|
|
|
|
|
@router.post("/sessions", response_model=ScriptBuilderSessionDetail, status_code=201)
|
|
async def create_session(
|
|
data: ScriptBuilderCreateRequest,
|
|
db: Annotated[AsyncSession, Depends(get_db)],
|
|
current_user: Annotated[User, Depends(get_current_active_user)],
|
|
) -> ScriptBuilderSessionDetail:
|
|
"""Start a new Script Builder session."""
|
|
# Enforce max concurrent sessions
|
|
count = await script_builder_service.count_user_sessions(db, current_user.id)
|
|
if count >= MAX_SESSIONS_PER_USER:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail=f"Maximum of {MAX_SESSIONS_PER_USER} builder sessions allowed. Delete an old session first.",
|
|
)
|
|
|
|
session = await script_builder_service.create_session(
|
|
db=db,
|
|
user_id=current_user.id,
|
|
team_id=current_user.team_id,
|
|
language=data.language,
|
|
)
|
|
await db.commit()
|
|
return ScriptBuilderSessionDetail.model_validate(session)
|
|
|
|
|
|
@router.get("/sessions", response_model=list[ScriptBuilderSessionSummary])
|
|
async def list_sessions(
|
|
db: Annotated[AsyncSession, Depends(get_db)],
|
|
current_user: Annotated[User, Depends(get_current_active_user)],
|
|
limit: int = 20,
|
|
offset: int = 0,
|
|
) -> list[ScriptBuilderSessionSummary]:
|
|
"""List user's recent builder sessions (lightweight, no messages)."""
|
|
sessions = await script_builder_service.list_sessions(
|
|
db=db, user_id=current_user.id, limit=limit, offset=offset
|
|
)
|
|
return [ScriptBuilderSessionSummary.model_validate(s) for s in sessions]
|
|
|
|
|
|
@router.get("/sessions/{session_id}", response_model=ScriptBuilderSessionDetail)
|
|
async def get_session(
|
|
session_id: UUID,
|
|
db: Annotated[AsyncSession, Depends(get_db)],
|
|
current_user: Annotated[User, Depends(get_current_active_user)],
|
|
) -> ScriptBuilderSessionDetail:
|
|
"""Get full session detail with message history."""
|
|
session = await script_builder_service.get_session(db, session_id, current_user.id)
|
|
if not session:
|
|
raise HTTPException(status_code=404, detail="Session not found")
|
|
return ScriptBuilderSessionDetail.model_validate(session)
|
|
|
|
|
|
@router.post(
|
|
"/sessions/{session_id}/messages",
|
|
response_model=ScriptBuilderMessageResponse,
|
|
)
|
|
@limiter.limit("10/minute")
|
|
async def send_message(
|
|
request: Request,
|
|
session_id: UUID,
|
|
data: ScriptBuilderMessageRequest,
|
|
db: Annotated[AsyncSession, Depends(get_db)],
|
|
current_user: Annotated[User, Depends(get_current_active_user)],
|
|
) -> ScriptBuilderMessageResponse:
|
|
"""Send a message and get AI-generated script response."""
|
|
session = await script_builder_service.get_session(db, session_id, current_user.id)
|
|
if not session:
|
|
raise HTTPException(status_code=404, detail="Session not found")
|
|
|
|
try:
|
|
response = await script_builder_service.send_message(db, session, data.content)
|
|
except ValueError as e:
|
|
raise HTTPException(status_code=400, detail=str(e))
|
|
|
|
await db.commit()
|
|
return response
|
|
|
|
|
|
@router.delete("/sessions/{session_id}", status_code=204)
|
|
async def delete_session(
|
|
session_id: UUID,
|
|
db: Annotated[AsyncSession, Depends(get_db)],
|
|
current_user: Annotated[User, Depends(get_current_active_user)],
|
|
) -> None:
|
|
"""Delete a builder session."""
|
|
deleted = await script_builder_service.delete_session(db, session_id, current_user.id)
|
|
if not deleted:
|
|
raise HTTPException(status_code=404, detail="Session not found")
|
|
await db.commit()
|
|
|
|
|
|
@router.post(
|
|
"/sessions/{session_id}/save",
|
|
response_model=ScriptTemplateDetail,
|
|
status_code=201,
|
|
)
|
|
async def save_to_library(
|
|
session_id: UUID,
|
|
data: SaveToLibraryRequest,
|
|
db: Annotated[AsyncSession, Depends(get_db)],
|
|
current_user: Annotated[User, Depends(get_current_active_user)],
|
|
) -> ScriptTemplateDetail:
|
|
"""Save the latest generated script to the Script Library."""
|
|
session = await script_builder_service.get_session(db, session_id, current_user.id)
|
|
if not session:
|
|
raise HTTPException(status_code=404, detail="Session not found")
|
|
|
|
try:
|
|
template = await script_builder_service.save_to_library(
|
|
db=db,
|
|
session=session,
|
|
name=data.name,
|
|
description=data.description,
|
|
category_id=data.category_id,
|
|
share_with_team=data.share_with_team,
|
|
user_id=current_user.id,
|
|
team_id=current_user.team_id,
|
|
)
|
|
except ValueError as e:
|
|
raise HTTPException(status_code=400, detail=str(e))
|
|
|
|
await db.commit()
|
|
await db.refresh(template)
|
|
return ScriptTemplateDetail.model_validate(template)
|
|
```
|
|
|
|
- [ ] **Step 2: Register the router**
|
|
|
|
Read `backend/app/api/router.py` and add:
|
|
|
|
```python
|
|
from app.api.endpoints.script_builder import router as script_builder_router
|
|
# ... in the include_router section:
|
|
router.include_router(script_builder_router)
|
|
```
|
|
|
|
- [ ] **Step 3: Verify server starts**
|
|
|
|
Run: `cd backend && timeout 10 python -c "from app.api.endpoints.script_builder import router; print('Router OK')" 2>&1`
|
|
Expected: `Router OK`
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
git add backend/app/api/endpoints/script_builder.py \
|
|
backend/app/api/router.py
|
|
git commit -m "feat: add Script Builder API endpoints
|
|
|
|
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>"
|
|
```
|
|
|
|
### Task 2.5: Write Backend Tests
|
|
|
|
**Files:**
|
|
|
|
- Create: `backend/tests/test_script_builder.py`
|
|
|
|
- [ ] **Step 1: Create test file**
|
|
|
|
```python
|
|
# backend/tests/test_script_builder.py
|
|
"""Tests for the Script Builder API endpoints."""
|
|
import pytest
|
|
import uuid
|
|
from unittest.mock import AsyncMock, patch, PropertyMock
|
|
|
|
import sqlalchemy as sa
|
|
|
|
|
|
class TestScriptBuilderSessions:
|
|
"""Test Script Builder session CRUD."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_session(self, client, auth_headers):
|
|
"""Creating a builder session returns a valid session."""
|
|
resp = await client.post(
|
|
"/api/v1/scripts/builder/sessions",
|
|
json={"language": "powershell"},
|
|
headers=auth_headers,
|
|
)
|
|
assert resp.status_code == 201
|
|
data = resp.json()
|
|
assert data["language"] == "powershell"
|
|
assert data["messages"] == []
|
|
assert data["message_count"] == 0
|
|
assert data["title"] is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_session_invalid_language(self, client, auth_headers):
|
|
"""Invalid language is rejected."""
|
|
resp = await client.post(
|
|
"/api/v1/scripts/builder/sessions",
|
|
json={"language": "cobol"},
|
|
headers=auth_headers,
|
|
)
|
|
assert resp.status_code == 422
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_session_bash(self, client, auth_headers):
|
|
"""Bash language is accepted."""
|
|
resp = await client.post(
|
|
"/api/v1/scripts/builder/sessions",
|
|
json={"language": "bash"},
|
|
headers=auth_headers,
|
|
)
|
|
assert resp.status_code == 201
|
|
assert resp.json()["language"] == "bash"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_list_sessions_empty(self, client, auth_headers):
|
|
"""Listing sessions when none exist returns empty list."""
|
|
resp = await client.get(
|
|
"/api/v1/scripts/builder/sessions",
|
|
headers=auth_headers,
|
|
)
|
|
assert resp.status_code == 200
|
|
assert resp.json() == []
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_list_sessions_returns_summaries(self, client, auth_headers):
|
|
"""Listed sessions are lightweight (no messages field)."""
|
|
# Create a session first
|
|
await client.post(
|
|
"/api/v1/scripts/builder/sessions",
|
|
json={"language": "powershell"},
|
|
headers=auth_headers,
|
|
)
|
|
resp = await client.get(
|
|
"/api/v1/scripts/builder/sessions",
|
|
headers=auth_headers,
|
|
)
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert len(data) == 1
|
|
assert "messages" not in data[0]
|
|
assert "language" in data[0]
|
|
assert "title" in data[0]
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_session_detail(self, client, auth_headers):
|
|
"""Getting a session by ID returns full detail with messages."""
|
|
create_resp = await client.post(
|
|
"/api/v1/scripts/builder/sessions",
|
|
json={"language": "python"},
|
|
headers=auth_headers,
|
|
)
|
|
session_id = create_resp.json()["id"]
|
|
|
|
resp = await client.get(
|
|
f"/api/v1/scripts/builder/sessions/{session_id}",
|
|
headers=auth_headers,
|
|
)
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert data["id"] == session_id
|
|
assert "messages" in data
|
|
assert data["language"] == "python"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_session_not_found(self, client, auth_headers):
|
|
"""Getting a nonexistent session returns 404."""
|
|
fake_id = str(uuid.uuid4())
|
|
resp = await client.get(
|
|
f"/api/v1/scripts/builder/sessions/{fake_id}",
|
|
headers=auth_headers,
|
|
)
|
|
assert resp.status_code == 404
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_delete_session(self, client, auth_headers):
|
|
"""Deleting a session removes it."""
|
|
create_resp = await client.post(
|
|
"/api/v1/scripts/builder/sessions",
|
|
json={"language": "powershell"},
|
|
headers=auth_headers,
|
|
)
|
|
session_id = create_resp.json()["id"]
|
|
|
|
resp = await client.delete(
|
|
f"/api/v1/scripts/builder/sessions/{session_id}",
|
|
headers=auth_headers,
|
|
)
|
|
assert resp.status_code == 204
|
|
|
|
# Verify it's gone
|
|
resp = await client.get(
|
|
f"/api/v1/scripts/builder/sessions/{session_id}",
|
|
headers=auth_headers,
|
|
)
|
|
assert resp.status_code == 404
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_delete_session_not_found(self, client, auth_headers):
|
|
"""Deleting a nonexistent session returns 404."""
|
|
fake_id = str(uuid.uuid4())
|
|
resp = await client.delete(
|
|
f"/api/v1/scripts/builder/sessions/{fake_id}",
|
|
headers=auth_headers,
|
|
)
|
|
assert resp.status_code == 404
|
|
|
|
|
|
class TestScriptBuilderMessages:
|
|
"""Test sending messages (requires AI mock)."""
|
|
|
|
@pytest.fixture
|
|
def _enable_ai(self):
|
|
"""Mock AI as enabled for tests without API key."""
|
|
with patch.object(
|
|
type(__import__("app.core.config", fromlist=["settings"]).settings),
|
|
"ai_enabled",
|
|
new_callable=PropertyMock,
|
|
return_value=True,
|
|
):
|
|
yield
|
|
|
|
@pytest.fixture
|
|
def _mock_ai_response(self):
|
|
"""Mock the AI provider to return a script response."""
|
|
mock_response = (
|
|
'Here is your script:\n\n```powershell\nGet-Process | Format-Table\n```\n\nSaved as `Get-Processes.ps1`.',
|
|
100, # input_tokens
|
|
200, # output_tokens
|
|
)
|
|
with patch("app.services.script_builder_service.get_ai_provider") as mock:
|
|
provider = AsyncMock()
|
|
provider.generate_text = AsyncMock(return_value=mock_response)
|
|
mock.return_value = provider
|
|
yield
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_message(self, client, auth_headers, _enable_ai, _mock_ai_response):
|
|
"""Sending a message returns AI response with script."""
|
|
# Create session
|
|
create_resp = await client.post(
|
|
"/api/v1/scripts/builder/sessions",
|
|
json={"language": "powershell"},
|
|
headers=auth_headers,
|
|
)
|
|
session_id = create_resp.json()["id"]
|
|
|
|
# Send message
|
|
resp = await client.post(
|
|
f"/api/v1/scripts/builder/sessions/{session_id}/messages",
|
|
json={"content": "List all running processes"},
|
|
headers=auth_headers,
|
|
)
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert data["role"] == "assistant"
|
|
assert data["script"] is not None
|
|
assert "Get-Process" in data["script"]
|
|
assert data["script_filename"] == "Get-Processes.ps1"
|
|
assert data["line_count"] == 1
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_message_updates_session(self, client, auth_headers, _enable_ai, _mock_ai_response):
|
|
"""Sending a message updates session state."""
|
|
create_resp = await client.post(
|
|
"/api/v1/scripts/builder/sessions",
|
|
json={"language": "powershell"},
|
|
headers=auth_headers,
|
|
)
|
|
session_id = create_resp.json()["id"]
|
|
|
|
await client.post(
|
|
f"/api/v1/scripts/builder/sessions/{session_id}/messages",
|
|
json={"content": "List processes"},
|
|
headers=auth_headers,
|
|
)
|
|
|
|
# Check session was updated
|
|
resp = await client.get(
|
|
f"/api/v1/scripts/builder/sessions/{session_id}",
|
|
headers=auth_headers,
|
|
)
|
|
data = resp.json()
|
|
assert data["message_count"] == 1
|
|
assert data["latest_script"] is not None
|
|
assert len(data["messages"]) == 2 # user + assistant
|
|
assert data["title"] is not None
|
|
|
|
|
|
class TestScriptBuilderSaveToLibrary:
|
|
"""Test saving generated scripts to the library."""
|
|
|
|
@pytest.fixture
|
|
def _enable_ai(self):
|
|
with patch.object(
|
|
type(__import__("app.core.config", fromlist=["settings"]).settings),
|
|
"ai_enabled",
|
|
new_callable=PropertyMock,
|
|
return_value=True,
|
|
):
|
|
yield
|
|
|
|
@pytest.fixture
|
|
def _mock_ai_response(self):
|
|
mock_response = (
|
|
'Here is your script:\n\n```powershell\nGet-Process | Format-Table\n```\n\nSaved as `Get-Processes.ps1`.',
|
|
100, 200,
|
|
)
|
|
with patch("app.services.script_builder_service.get_ai_provider") as mock:
|
|
provider = AsyncMock()
|
|
provider.generate_text = AsyncMock(return_value=mock_response)
|
|
mock.return_value = provider
|
|
yield
|
|
|
|
@pytest.fixture
|
|
async def _seed_ai_category(self, test_db):
|
|
"""Seed the AI Generated category for save tests."""
|
|
await test_db.execute(
|
|
sa.text("""
|
|
INSERT INTO script_categories (id, name, slug, description, icon, sort_order, is_active, created_at, updated_at)
|
|
VALUES (
|
|
'a0000000-0000-0000-0000-000000000001'::uuid,
|
|
'AI Generated', 'ai-generated', 'Scripts from AI', 'sparkles', 100, true, NOW(), NOW()
|
|
)
|
|
ON CONFLICT (slug) DO NOTHING
|
|
""")
|
|
)
|
|
await test_db.commit()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_save_to_library_no_script(self, client, auth_headers):
|
|
"""Cannot save if no script has been generated."""
|
|
create_resp = await client.post(
|
|
"/api/v1/scripts/builder/sessions",
|
|
json={"language": "powershell"},
|
|
headers=auth_headers,
|
|
)
|
|
session_id = create_resp.json()["id"]
|
|
|
|
resp = await client.post(
|
|
f"/api/v1/scripts/builder/sessions/{session_id}/save",
|
|
json={"name": "Test Script"},
|
|
headers=auth_headers,
|
|
)
|
|
assert resp.status_code == 400
|
|
assert "No script" in resp.json()["detail"]
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_save_to_library_success(
|
|
self, client, auth_headers, _enable_ai, _mock_ai_response, _seed_ai_category
|
|
):
|
|
"""Saving a generated script creates a ScriptTemplate."""
|
|
# Create session and generate a script
|
|
create_resp = await client.post(
|
|
"/api/v1/scripts/builder/sessions",
|
|
json={"language": "powershell"},
|
|
headers=auth_headers,
|
|
)
|
|
session_id = create_resp.json()["id"]
|
|
|
|
await client.post(
|
|
f"/api/v1/scripts/builder/sessions/{session_id}/messages",
|
|
json={"content": "List processes"},
|
|
headers=auth_headers,
|
|
)
|
|
|
|
# Save to library
|
|
resp = await client.post(
|
|
f"/api/v1/scripts/builder/sessions/{session_id}/save",
|
|
json={"name": "My Process Script", "share_with_team": False},
|
|
headers=auth_headers,
|
|
)
|
|
assert resp.status_code == 201
|
|
data = resp.json()
|
|
assert data["name"] == "My Process Script"
|
|
assert "ai-generated" in data["tags"]
|
|
```
|
|
|
|
- [ ] **Step 2: Run the tests**
|
|
|
|
Run: `cd backend && python -m pytest tests/test_script_builder.py -v --override-ini="addopts=" 2>&1 | tail -30`
|
|
Expected: All tests pass
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add backend/tests/test_script_builder.py
|
|
git commit -m "test: add Script Builder API tests
|
|
|
|
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>"
|
|
```
|
|
|
|
### Task 2.6: Create Frontend Types and API Client
|
|
|
|
**Files:**
|
|
|
|
- Create: `frontend/src/types/script-builder.ts`
|
|
- Modify: `frontend/src/types/index.ts`
|
|
- Create: `frontend/src/api/scriptBuilder.ts`
|
|
- Modify: `frontend/src/api/index.ts`
|
|
|
|
- [ ] **Step 1: Create TypeScript types**
|
|
|
|
```typescript
|
|
// frontend/src/types/script-builder.ts
|
|
export interface ScriptBuilderSessionSummary {
|
|
id: string
|
|
language: string
|
|
title: string | null
|
|
message_count: number
|
|
latest_script_filename: string | null
|
|
created_at: string
|
|
updated_at: string
|
|
}
|
|
|
|
export interface ScriptBuilderSessionDetail {
|
|
id: string
|
|
language: string
|
|
title: string | null
|
|
messages: ScriptBuilderMessage[]
|
|
latest_script: string | null
|
|
latest_script_filename: string | null
|
|
message_count: number
|
|
ai_session_id: string | null
|
|
created_at: string
|
|
updated_at: string
|
|
}
|
|
|
|
export interface ScriptBuilderMessage {
|
|
role: 'user' | 'assistant'
|
|
content: string
|
|
script?: string | null
|
|
script_filename?: string | null
|
|
line_count?: number | null
|
|
timestamp: string
|
|
}
|
|
|
|
export interface ScriptBuilderMessageResponse {
|
|
role: 'assistant'
|
|
content: string
|
|
script: string | null
|
|
script_filename: string | null
|
|
line_count: number | null
|
|
timestamp: string
|
|
}
|
|
|
|
export interface SaveToLibraryRequest {
|
|
name: string
|
|
description?: string
|
|
category_id?: string
|
|
share_with_team?: boolean
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Export from types/index.ts**
|
|
|
|
Add to `frontend/src/types/index.ts`:
|
|
|
|
```typescript
|
|
export * from './script-builder'
|
|
```
|
|
|
|
- [ ] **Step 3: Create API client**
|
|
|
|
```typescript
|
|
// frontend/src/api/scriptBuilder.ts
|
|
import { apiClient } from './client'
|
|
import type {
|
|
ScriptBuilderSessionSummary,
|
|
ScriptBuilderSessionDetail,
|
|
ScriptBuilderMessageResponse,
|
|
SaveToLibraryRequest,
|
|
} from '@/types'
|
|
import type { ScriptTemplateDetail } from '@/types'
|
|
|
|
export const scriptBuilderApi = {
|
|
async createSession(language: string): Promise<ScriptBuilderSessionDetail> {
|
|
const { data } = await apiClient.post('/scripts/builder/sessions', { language })
|
|
return data
|
|
},
|
|
|
|
async listSessions(limit = 20, offset = 0): Promise<ScriptBuilderSessionSummary[]> {
|
|
const { data } = await apiClient.get('/scripts/builder/sessions', {
|
|
params: { limit, offset },
|
|
})
|
|
return data
|
|
},
|
|
|
|
async getSession(sessionId: string): Promise<ScriptBuilderSessionDetail> {
|
|
const { data } = await apiClient.get(`/scripts/builder/sessions/${sessionId}`)
|
|
return data
|
|
},
|
|
|
|
async sendMessage(sessionId: string, content: string): Promise<ScriptBuilderMessageResponse> {
|
|
const { data } = await apiClient.post(
|
|
`/scripts/builder/sessions/${sessionId}/messages`,
|
|
{ content }
|
|
)
|
|
return data
|
|
},
|
|
|
|
async deleteSession(sessionId: string): Promise<void> {
|
|
await apiClient.delete(`/scripts/builder/sessions/${sessionId}`)
|
|
},
|
|
|
|
async saveToLibrary(sessionId: string, req: SaveToLibraryRequest): Promise<ScriptTemplateDetail> {
|
|
const { data } = await apiClient.post(
|
|
`/scripts/builder/sessions/${sessionId}/save`,
|
|
req
|
|
)
|
|
return data
|
|
},
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Export from api/index.ts**
|
|
|
|
Add to `frontend/src/api/index.ts`:
|
|
|
|
```typescript
|
|
export { scriptBuilderApi } from './scriptBuilder'
|
|
```
|
|
|
|
- [ ] **Step 5: Verify build**
|
|
|
|
Run: `cd frontend && npx tsc --noEmit --pretty 2>&1 | head -20`
|
|
Expected: No errors
|
|
|
|
- [ ] **Step 6: Commit**
|
|
|
|
```bash
|
|
git add frontend/src/types/script-builder.ts \
|
|
frontend/src/types/index.ts \
|
|
frontend/src/api/scriptBuilder.ts \
|
|
frontend/src/api/index.ts
|
|
git commit -m "feat: add Script Builder frontend types and API client
|
|
|
|
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>"
|
|
```
|
|
|
|
### Task 2.7: Create Script Builder Page and Components
|
|
|
|
**Files:**
|
|
|
|
- Create: `frontend/src/components/script-builder/ScriptBuilderChat.tsx`
|
|
- Create: `frontend/src/components/script-builder/ScriptBuilderInput.tsx`
|
|
- Create: `frontend/src/components/script-builder/ScriptCodeBlock.tsx`
|
|
- Create: `frontend/src/pages/ScriptBuilderPage.tsx`
|
|
- Modify: `frontend/src/router.tsx`
|
|
- Modify: `frontend/src/components/layout/Sidebar.tsx`
|
|
- Modify: `frontend/src/components/layout/AppLayout.tsx`
|
|
|
|
- [ ] **Step 1: Install react-syntax-highlighter**
|
|
|
|
Run: `cd frontend && npm install react-syntax-highlighter && npm install -D @types/react-syntax-highlighter`
|
|
|
|
- [ ] **Step 2: Create ScriptCodeBlock component**
|
|
|
|
Create `frontend/src/components/script-builder/ScriptCodeBlock.tsx` — displays a collapsed code preview (first 5 lines + line count) with "View Full Script", "Copy", and "Save to Library" buttons. Uses `react-syntax-highlighter` with `oneDark` theme. Props: `script: string`, `filename: string | null`, `lineCount: number | null`, `language: string`, `onViewFull: () => void`, `onSave: () => void`.
|
|
|
|
The component should:
|
|
- Show the filename and line count in a header row
|
|
- Render first 5 lines with syntax highlighting
|
|
- Show "··· N more lines" if truncated
|
|
- Have Copy button that copies full script to clipboard
|
|
- Have View Full Script button (primary, calls `onViewFull`)
|
|
- Have Save to Library button (emerald-colored, calls `onSave`)
|
|
- The entire code area should be clickable to open the full view
|
|
|
|
- [ ] **Step 3: Create ScriptBuilderInput component**
|
|
|
|
Create `frontend/src/components/script-builder/ScriptBuilderInput.tsx` — chat input bar. Same auto-resize textarea pattern as `FlowPilotMessageBar` but with the Script Builder styling. Props: `onSend: (content: string) => void`, `disabled: boolean`, `placeholder?: string`.
|
|
|
|
- [ ] **Step 4: Create ScriptBuilderChat component**
|
|
|
|
Create `frontend/src/components/script-builder/ScriptBuilderChat.tsx` — renders the message list. User messages right-aligned with cyan tint, AI messages left-aligned with glass card styling. AI messages that contain a script render `ScriptCodeBlock` inline. Props: `messages: ScriptBuilderMessage[]`, `language: string`, `onViewScript: (script: string, filename: string | null) => void`, `onSaveScript: () => void`.
|
|
|
|
Auto-scroll to bottom when new messages are added (use `useEffect` + `scrollIntoView` on a bottom ref).
|
|
|
|
- [ ] **Step 5: Create ScriptBuilderPage**
|
|
|
|
Create `frontend/src/pages/ScriptBuilderPage.tsx` — main page component. Layout:
|
|
- Language selector pills at top (PowerShell, Bash, Python) — only changeable before first message
|
|
- `ScriptBuilderChat` in the center (flex-1, overflow-y-auto)
|
|
- `ScriptBuilderInput` pinned to bottom
|
|
- Session history drawer (optional, can be a simple "Recent" dropdown or sidebar)
|
|
|
|
State management via local `useState` (not Zustand — per CLAUDE.md lesson #41 pattern for chat-style pages):
|
|
- `session: ScriptBuilderSessionDetail | null`
|
|
- `messages: ScriptBuilderMessage[]`
|
|
- `language: string` (default "powershell")
|
|
- `isLoading: boolean`
|
|
- `previewScript: { script: string, filename: string | null } | null` (for modal)
|
|
- `showSaveDialog: boolean`
|
|
|
|
On mount: check for `sessionStorage` key `scriptBuilderContext` (FlowPilot handoff). If found, parse it, create session with that language, and auto-send the initial prompt.
|
|
|
|
Handle `?from=flowpilot` URL param to know this was a FlowPilot handoff.
|
|
|
|
- [ ] **Step 6: Add route to router.tsx**
|
|
|
|
Read `frontend/src/router.tsx`. Add the Script Builder route inside the protected layout children:
|
|
|
|
```tsx
|
|
{ path: 'script-builder', element: page(ScriptBuilderPage) },
|
|
```
|
|
|
|
Add the lazy import at the top:
|
|
|
|
```tsx
|
|
const ScriptBuilderPage = lazy(() => import('@/pages/ScriptBuilderPage'))
|
|
```
|
|
|
|
- [ ] **Step 7: Add sidebar nav item**
|
|
|
|
Read `frontend/src/components/layout/Sidebar.tsx`. Add a "Script Builder" nav item in the Knowledge section, after Scripts. Use the `Wand2` icon from Lucide and a new color in `NAV_COLORS`:
|
|
|
|
```tsx
|
|
scriptBuilder: '#e879f9', // fuchsia-400
|
|
```
|
|
|
|
Add in both collapsed and expanded nav sections:
|
|
|
|
```tsx
|
|
<NavItem href="/script-builder" icon={Wand2} label="Script Builder" iconColor={NAV_COLORS.scriptBuilder} />
|
|
```
|
|
|
|
- [ ] **Step 8: Add mobile nav item**
|
|
|
|
Read `frontend/src/components/layout/AppLayout.tsx`. Add to the `mobileNavItems` array:
|
|
|
|
```tsx
|
|
{ path: '/script-builder', label: 'Script Builder', icon: Wand2 },
|
|
```
|
|
|
|
Import `Wand2` from `lucide-react`.
|
|
|
|
- [ ] **Step 9: Verify build**
|
|
|
|
Run: `cd frontend && npm run build 2>&1 | tail -20`
|
|
Expected: Build succeeds
|
|
|
|
- [ ] **Step 10: Commit**
|
|
|
|
```bash
|
|
git add frontend/src/components/script-builder/ \
|
|
frontend/src/pages/ScriptBuilderPage.tsx \
|
|
frontend/src/router.tsx \
|
|
frontend/src/components/layout/Sidebar.tsx \
|
|
frontend/src/components/layout/AppLayout.tsx \
|
|
frontend/package.json frontend/package-lock.json
|
|
git commit -m "feat: add Script Builder page with chat UI, code blocks, and nav
|
|
|
|
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>"
|
|
```
|
|
|
|
---
|
|
|
|
## Phase 3: Script Preview Modal & Save Flow
|
|
|
|
### Task 3.1: Create ScriptPreviewModal
|
|
|
|
**Files:**
|
|
|
|
- Create: `frontend/src/components/script-builder/ScriptPreviewModal.tsx`
|
|
|
|
- [ ] **Step 1: Create the fullscreen modal**
|
|
|
|
Create `frontend/src/components/script-builder/ScriptPreviewModal.tsx` — fullscreen overlay modal for viewing complete scripts. Structure:
|
|
|
|
- Fixed overlay with `bg-black/80 backdrop-blur-sm z-50`
|
|
- Modal container: `bg-[#18191f] rounded-xl border border-[rgba(255,255,255,0.08)]` centered, max-width ~900px, max-height ~85vh
|
|
- Header: filename (JetBrains Mono, cyan), language badge, Copy button (secondary), Save to Library button (emerald), close button (X)
|
|
- Body: full script with `react-syntax-highlighter`, scrollable, line numbers enabled
|
|
- Footer: line count + "Generated just now" metadata, "Close & Return to Chat" button (secondary)
|
|
|
|
Props: `script: string`, `filename: string | null`, `language: string`, `onClose: () => void`, `onSave: () => void`.
|
|
|
|
Handle Escape key to close. Handle click outside to close.
|
|
|
|
- [ ] **Step 2: Verify build**
|
|
|
|
Run: `cd frontend && npx tsc --noEmit --pretty 2>&1 | head -20`
|
|
Expected: No errors
|
|
|
|
### Task 3.2: Create SaveToLibraryDialog
|
|
|
|
**Files:**
|
|
|
|
- Create: `frontend/src/components/script-builder/SaveToLibraryDialog.tsx`
|
|
|
|
- [ ] **Step 1: Create the save dialog**
|
|
|
|
Create `frontend/src/components/script-builder/SaveToLibraryDialog.tsx` — modal dialog for saving scripts to the library. Structure:
|
|
|
|
- Standard modal overlay (reuse the app's modal pattern)
|
|
- Form fields: Name (text, required, auto-filled from filename), Description (textarea, optional), Category (select dropdown, loads from `scriptsApi.getCategories()`), Share with team (toggle, default off)
|
|
- Submit button: "Save to Library" (primary, bg-gradient-brand)
|
|
- Cancel button (secondary)
|
|
- Loading state while saving
|
|
- Error handling: show inline error on 409 conflict (duplicate name)
|
|
|
|
Props: `sessionId: string`, `defaultName: string`, `defaultDescription?: string`, `onClose: () => void`, `onSaved: () => void`.
|
|
|
|
Calls `scriptBuilderApi.saveToLibrary()` on submit.
|
|
|
|
- [ ] **Step 2: Wire modal and dialog into ScriptBuilderPage**
|
|
|
|
Modify `frontend/src/pages/ScriptBuilderPage.tsx` to render:
|
|
- `ScriptPreviewModal` when `previewScript` is set
|
|
- `SaveToLibraryDialog` when `showSaveDialog` is true
|
|
|
|
Pass the appropriate handlers to `ScriptBuilderChat` → `ScriptCodeBlock` for opening the modal and save dialog.
|
|
|
|
- [ ] **Step 3: Verify build**
|
|
|
|
Run: `cd frontend && npm run build 2>&1 | tail -20`
|
|
Expected: Build succeeds
|
|
|
|
- [ ] **Step 4: Commit Phase 3**
|
|
|
|
```bash
|
|
git add frontend/src/components/script-builder/ScriptPreviewModal.tsx \
|
|
frontend/src/components/script-builder/SaveToLibraryDialog.tsx \
|
|
frontend/src/components/script-builder/ScriptCodeBlock.tsx \
|
|
frontend/src/pages/ScriptBuilderPage.tsx
|
|
git commit -m "feat: add script preview modal and save-to-library dialog
|
|
|
|
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>"
|
|
```
|
|
|
|
---
|
|
|
|
## Phase 4: Library Reorganization
|
|
|
|
### Task 4.1: Add Language Column and AI Generated Category (Migration)
|
|
|
|
**Files:**
|
|
|
|
- Create: migration file
|
|
- Modify: `backend/app/models/script_template.py`
|
|
- Modify: `backend/app/schemas/script_template.py`
|
|
|
|
- [ ] **Step 1: Add language column to ScriptTemplate model**
|
|
|
|
Read `backend/app/models/script_template.py`. Add after the `script_body` column:
|
|
|
|
```python
|
|
language: Mapped[Optional[str]] = mapped_column(
|
|
String(30), nullable=True, default="powershell",
|
|
comment="Script language: powershell, bash, python",
|
|
)
|
|
```
|
|
|
|
- [ ] **Step 2: Add language field to schemas**
|
|
|
|
Read `backend/app/schemas/script_template.py`. Add `language: str | None = None` to `ScriptTemplateListItem`, `ScriptTemplateDetail`, and `ScriptTemplateCreate`.
|
|
|
|
- [ ] **Step 3: Create migration**
|
|
|
|
Run: `cd backend && alembic revision --autogenerate -m "add_language_to_script_templates_and_ai_generated_category" --rev-id=063`
|
|
|
|
- [ ] **Step 4: Review and edit the migration**
|
|
|
|
Read the generated migration. It should add the `language` column. Manually add seed data for the "AI Generated" category in the `upgrade()` function:
|
|
|
|
```python
|
|
# Seed the "AI Generated" category
|
|
op.execute(
|
|
sa.text("""
|
|
INSERT INTO script_categories (id, name, slug, description, icon, sort_order, is_active, created_at, updated_at)
|
|
VALUES (
|
|
'a0000000-0000-0000-0000-000000000001'::uuid,
|
|
'AI Generated',
|
|
'ai-generated',
|
|
'Scripts generated by the AI Script Builder',
|
|
'sparkles',
|
|
100,
|
|
true,
|
|
NOW(),
|
|
NOW()
|
|
)
|
|
ON CONFLICT (slug) DO NOTHING
|
|
""")
|
|
)
|
|
```
|
|
|
|
Add the reverse in `downgrade()`:
|
|
|
|
```python
|
|
op.execute(sa.text("DELETE FROM script_categories WHERE slug = 'ai-generated'"))
|
|
```
|
|
|
|
Remove any unrelated autogenerated operations.
|
|
|
|
- [ ] **Step 5: Run migration**
|
|
|
|
Run: `cd backend && alembic upgrade head`
|
|
Expected: Migration applies successfully
|
|
|
|
- [ ] **Step 6: Commit**
|
|
|
|
```bash
|
|
git add backend/app/models/script_template.py \
|
|
backend/app/schemas/script_template.py \
|
|
backend/alembic/versions/
|
|
git commit -m "feat: add language column to script_templates and seed AI Generated category
|
|
|
|
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>"
|
|
```
|
|
|
|
### Task 4.2: Add Mine Filter to Scripts Endpoint
|
|
|
|
**Files:**
|
|
|
|
- Modify: `backend/app/api/endpoints/scripts.py`
|
|
|
|
- [ ] **Step 1: Read the templates list endpoint**
|
|
|
|
Read `backend/app/api/endpoints/scripts.py` and find the `list_templates` endpoint.
|
|
|
|
- [ ] **Step 2: Add mine and shared query parameters**
|
|
|
|
Add `mine: bool = False` and `shared: bool = False` parameters to the endpoint:
|
|
- When `mine=True`, add `.where(ScriptTemplate.created_by == current_user.id)` filter
|
|
- When `shared=True`, add `.where(ScriptTemplate.team_id == current_user.team_id)` filter (only team-shared scripts)
|
|
|
|
- [ ] **Step 3: Run existing script tests**
|
|
|
|
Run: `cd backend && python -m pytest tests/test_scripts.py -v --override-ini="addopts=" 2>&1 | tail -20`
|
|
Expected: All existing tests still pass
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
git add backend/app/api/endpoints/scripts.py
|
|
git commit -m "feat: add mine and shared filters to script templates list endpoint
|
|
|
|
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>"
|
|
```
|
|
|
|
### Task 4.3: Reorganize Script Library Frontend
|
|
|
|
**Files:**
|
|
|
|
- Modify: `frontend/src/pages/ScriptLibraryPage.tsx`
|
|
- Modify: `frontend/src/types/scripts.ts`
|
|
|
|
- [ ] **Step 1: Add language field to frontend types**
|
|
|
|
Read `frontend/src/types/scripts.ts`. Add `language?: string | null` to `ScriptTemplateListItem` and `ScriptTemplateDetail`.
|
|
|
|
- [ ] **Step 2: Read the Script Library page**
|
|
|
|
Read `frontend/src/pages/ScriptLibraryPage.tsx` to understand its current structure.
|
|
|
|
- [ ] **Step 3: Add tabs and Build button**
|
|
|
|
Modify `ScriptLibraryPage.tsx`:
|
|
|
|
1. Add a "Build a New Script" button at the top of the page, styled with `bg-gradient-brand`. Uses `Link` to `/script-builder`.
|
|
2. Add tab bar below the header: "My Scripts" | "Team Scripts"
|
|
3. "My Scripts" tab: calls `scriptsApi.getTemplates({ mine: true })`
|
|
4. "Team Scripts" tab: calls `scriptsApi.getTemplates({ shared: true })`
|
|
5. Default to "My Scripts" tab
|
|
|
|
Use local state for the active tab. Add `mine` param to the `getTemplates` API call.
|
|
|
|
- [ ] **Step 4: Update scripts API client for mine and shared params**
|
|
|
|
Read `frontend/src/api/scripts.ts`. Add `mine?: boolean` and `shared?: boolean` to the `getTemplates` params object.
|
|
|
|
- [ ] **Step 5: Verify build**
|
|
|
|
Run: `cd frontend && npm run build 2>&1 | tail -20`
|
|
Expected: Build succeeds
|
|
|
|
- [ ] **Step 6: Commit**
|
|
|
|
```bash
|
|
git add frontend/src/pages/ScriptLibraryPage.tsx \
|
|
frontend/src/types/scripts.ts \
|
|
frontend/src/api/scripts.ts
|
|
git commit -m "feat: add My Scripts/Team Scripts tabs and Build button to Script Library
|
|
|
|
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>"
|
|
```
|
|
|
|
---
|
|
|
|
## Phase 5: FlowPilot Integration
|
|
|
|
### Task 5.1: Add Script Builder Handoff to FlowPilot
|
|
|
|
**Files:**
|
|
|
|
- Modify: `backend/app/services/flowpilot_engine.py`
|
|
- Modify: `frontend/src/components/flowpilot/FlowPilotStepCard.tsx`
|
|
|
|
- [ ] **Step 1: Update FlowPilot system prompt**
|
|
|
|
Read `backend/app/services/flowpilot_engine.py` and find the `STRUCTURED_OUTPUT_SCHEMA` and `FLOWPILOT_SYSTEM_PROMPT` constants. Add guidance for the AI to detect script needs:
|
|
|
|
In the `RULES` section of `FLOWPILOT_SYSTEM_PROMPT`, add:
|
|
|
|
```text
|
|
- When the engineer needs a custom script that doesn't match an existing template, suggest opening the Script Builder. Use action_type "open_script_builder" with a "script_prompt" field containing a clear description of what the script should do, and a "script_language" field (powershell, bash, or python).
|
|
```
|
|
|
|
In `STRUCTURED_OUTPUT_SCHEMA`, add to the action type:
|
|
|
|
```text
|
|
action_type values: "instruction | script_generation | verification | info_request | open_script_builder"
|
|
```
|
|
|
|
- [ ] **Step 2: Handle open_script_builder action in FlowPilotStepCard**
|
|
|
|
Read `frontend/src/components/flowpilot/FlowPilotStepCard.tsx`. In the action step buttons section (around the `handleActionComplete` area), add a new conditional for `action_type === 'open_script_builder'`:
|
|
|
|
```tsx
|
|
{!isResolutionSuggestion && step.step_type === 'action' && (content.action_type as string) === 'open_script_builder' && (
|
|
<button
|
|
onClick={() => {
|
|
// Store context in sessionStorage for Script Builder to pick up
|
|
sessionStorage.setItem('scriptBuilderContext', JSON.stringify({
|
|
from_session: sessionId,
|
|
prompt: (content.script_prompt as string) || '',
|
|
language: (content.script_language as string) || 'powershell',
|
|
}))
|
|
window.open('/script-builder?from=flowpilot', '_blank')
|
|
// Mark action as completed
|
|
onRespond({ action_result: { success: true, details: 'Opened Script Builder' } })
|
|
}}
|
|
className="flex-1 min-h-[44px] rounded-lg bg-gradient-brand px-4 py-2.5 text-sm font-semibold text-[#101114] hover:opacity-90 active:scale-[0.97] transition-all"
|
|
>
|
|
Open Script Builder
|
|
</button>
|
|
)}
|
|
```
|
|
|
|
- [ ] **Step 3: Verify build**
|
|
|
|
Run: `cd frontend && npm run build 2>&1 | tail -20`
|
|
Expected: Build succeeds
|
|
|
|
- [ ] **Step 4: Run all backend tests**
|
|
|
|
Run: `cd backend && python -m pytest --override-ini="addopts=" 2>&1 | tail -10`
|
|
Expected: All tests pass
|
|
|
|
- [ ] **Step 5: Commit Phase 5**
|
|
|
|
```bash
|
|
git add backend/app/services/flowpilot_engine.py \
|
|
frontend/src/components/flowpilot/FlowPilotStepCard.tsx
|
|
git commit -m "feat: connect FlowPilot to Script Builder with handoff action
|
|
|
|
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>"
|
|
```
|
|
|
|
---
|
|
|
|
## Final Verification
|
|
|
|
- [ ] **Step 1: Run full backend test suite**
|
|
|
|
Run: `cd backend && python -m pytest --override-ini="addopts=" -v 2>&1 | tail -30`
|
|
Expected: All tests pass
|
|
|
|
- [ ] **Step 2: Run frontend build**
|
|
|
|
Run: `cd frontend && npm run build 2>&1 | tail -20`
|
|
Expected: Build succeeds with no errors
|
|
|
|
- [ ] **Step 3: Manual smoke test checklist**
|
|
|
|
1. Start backend + frontend dev servers
|
|
2. Open FlowPilot session — verify message bar is visible at bottom, can type and submit
|
|
3. Verify message bar is disabled during resolution suggestion steps
|
|
4. Navigate to Script Builder via sidebar — verify page loads with language selector
|
|
5. Type a script request — verify AI responds with code block
|
|
6. Click "View Full Script" — verify fullscreen modal opens
|
|
7. Click "Save to Library" — verify dialog with name/category/share fields
|
|
8. Navigate to Scripts library — verify "My Scripts" / "Team Scripts" tabs and "Build a New Script" button
|