Merge pull request #118 from patherly/feat/flowpilot-message-bar-and-script-builder
feat: FlowPilot message bar + AI Script Builder + Library reorg
This commit was merged in pull request #118.
This commit is contained in:
24
CHANGELOG.md
24
CHANGELOG.md
@@ -11,6 +11,30 @@ All notable changes to ResolutionFlow are documented here.
|
||||
|
||||
---
|
||||
|
||||
## [0.10.0] - 2026-03-21
|
||||
|
||||
### Added
|
||||
- **AI Script Builder** — chat-style page (`/script-builder`) for generating PowerShell, Bash, and Python scripts from natural language descriptions, with fullscreen preview modal and save-to-library flow
|
||||
- **FlowPilot message bar** — always-visible chat input at bottom of guided sessions, replacing hidden "None of these" escape hatch
|
||||
- **FlowPilot → Script Builder handoff** — FlowPilot detects custom script needs and offers "Open Script Builder" button with context pre-filled via sessionStorage
|
||||
- **Script Library reorganization** — "My Scripts" / "Team Scripts" tabs, prominent "Build a New Script" button, `language` column on templates
|
||||
- **Session close/abandon** — "Close" button in FlowPilot action bar sets status to `abandoned` and redirects to Active Sessions
|
||||
- **Unified session history** — merged Flow Sessions and AI Sessions into single view on Active Sessions page
|
||||
|
||||
### Changed
|
||||
- FlowPilot now asks user preference (GUI walkthrough vs script) before suggesting scripted solutions
|
||||
- Script Builder messages normalized into separate `script_builder_messages` table (from JSONB array)
|
||||
- Step card action types use typed content helpers instead of unsafe `as string` casts
|
||||
- Message bar width expanded and repositioned above action bar
|
||||
- Date range filters use end-of-day timestamps to include same-day items
|
||||
|
||||
### Fixed
|
||||
- Missing `useNavigate` import causing Railway build failure
|
||||
- FlowPilot message bar hidden behind fixed action bar
|
||||
- Date filter excluding items created on the selected end date
|
||||
|
||||
---
|
||||
|
||||
## [0.9.0] - 2026-03-21
|
||||
|
||||
### Added
|
||||
|
||||
42
CLAUDE.md
42
CLAUDE.md
@@ -356,6 +356,16 @@ gh run view <id> --json jobs --jq '.jobs[] | {name: .name, conclusion: .conclusi
|
||||
|
||||
**82. `bun` requires PATH setup on devserver01:** `export BUN_INSTALL="$HOME/.bun" && export PATH="$BUN_INSTALL/bin:$PATH"`. The gstack browse binary and Playwright need this. Chromium system deps: `libatk1.0-0 libatk-bridge2.0-0 libcups2 libxkbcommon0 libatspi2.0-0 libxcomposite1 libxdamage1 libxfixes3 libxrandr2 libgbm1 libasound2`.
|
||||
|
||||
**83. FlowPilot ActionBar is `position: fixed; bottom: 0`:** Any UI element placed in normal document flow below the session content will be hidden behind it. New fixed-position elements (like the message bar) must use `bottom: 68px` (action bar height) and the same `left: var(--sidebar-w)` pattern. The conversation column uses `pb-32` for clearance.
|
||||
|
||||
**84. AI session `abandoned` status is fully wired:** `POST /ai-sessions/{id}/abandon` sets status to `abandoned` with optional `reason` param. Frontend: `aiSessionsApi.abandonSession()`, `useFlowPilotSession().abandonSession()`, "Close" button in `FlowPilotActionBar`. Redirects to `/sessions` after closing.
|
||||
|
||||
**85. Date range filter end dates must use end-of-day:** `toDate.toISOString()` sends midnight (start of day), excluding items created later that day. Always set `toDate.setHours(23, 59, 59, 999)` before sending. For string-based date inputs (AI sessions), append `T23:59:59.999Z`. See `SessionHistoryPage.tsx`.
|
||||
|
||||
**86. Script Builder system:** AI-powered script generation at `/script-builder`. Chat-style interface generates PowerShell/Bash/Python scripts from natural language. Backend: `ScriptBuilderSession` model, `script_builder_service.py`, endpoints at `/scripts/builder/`. Frontend: `ScriptBuilderPage`, `ScriptCodeBlock`, `ScriptPreviewModal`, `SaveToLibraryDialog`. FlowPilot can hand off to Script Builder via `action_type: "open_script_builder"` with `sessionStorage` context passing.
|
||||
|
||||
**87. FlowPilot must ask GUI vs script preference:** When a task can be done via GUI or script (e.g., creating AD users), FlowPilot must ask the engineer which approach they prefer BEFORE suggesting either. Never assume the user wants a script. See `FLOWPILOT_SYSTEM_PROMPT` rules in `flowpilot_engine.py`.
|
||||
|
||||
---
|
||||
|
||||
## RBAC & Permissions
|
||||
@@ -439,6 +449,38 @@ When a feature, fix, or significant piece of work is finished and merged/committ
|
||||
|
||||
---
|
||||
|
||||
## gstack (Browser & Workflow Skills)
|
||||
|
||||
**Web browsing:** Always use the `/browse` skill from gstack for all web browsing needs. Never use `mcp__claude-in-chrome__*` tools.
|
||||
|
||||
**Available skills:**
|
||||
|
||||
| Skill | Purpose |
|
||||
|-------|---------|
|
||||
| `/office-hours` | Brainstorm new ideas (YC-style office hours) |
|
||||
| `/plan-ceo-review` | CEO/founder-mode plan review (scope, ambition) |
|
||||
| `/plan-eng-review` | Engineering plan review (architecture, edge cases) |
|
||||
| `/plan-design-review` | Design plan review (UI/UX critique) |
|
||||
| `/design-consultation` | Create a design system / DESIGN.md |
|
||||
| `/review` | Pre-landing PR code review |
|
||||
| `/ship` | Ship workflow (tests, review, PR creation) |
|
||||
| `/browse` | Headless browser for QA testing and site dogfooding |
|
||||
| `/qa` | Systematic QA testing + auto-fix bugs found |
|
||||
| `/qa-only` | QA report only (no fixes) |
|
||||
| `/design-review` | Visual QA — find and fix design inconsistencies |
|
||||
| `/setup-browser-cookies` | Import cookies from real browser for authenticated testing |
|
||||
| `/retro` | Weekly engineering retrospective |
|
||||
| `/investigate` | Systematic debugging with root cause analysis |
|
||||
| `/document-release` | Post-ship documentation updates |
|
||||
| `/codex` | Second opinion via OpenAI Codex CLI |
|
||||
| `/careful` | Safety guardrails for destructive commands |
|
||||
| `/freeze` | Restrict edits to a specific directory |
|
||||
| `/guard` | Full safety mode (careful + freeze) |
|
||||
| `/unfreeze` | Remove edit restrictions |
|
||||
| `/gstack-upgrade` | Upgrade gstack to latest version |
|
||||
|
||||
---
|
||||
|
||||
## Deployment (Railway)
|
||||
|
||||
- **Production:** `resolutionflow.com` (frontend), `api.resolutionflow.com` (backend)
|
||||
|
||||
@@ -24,6 +24,7 @@ from app.models.ai_session import AISession # noqa: F401
|
||||
from app.models.ai_session_step import AISessionStep # noqa: F401
|
||||
from app.models.psa_post_log import PsaPostLog # noqa: F401
|
||||
from app.models.psa_member_mapping import PsaMemberMapping # noqa: F401
|
||||
from app.models.script_builder_session import ScriptBuilderSession, ScriptBuilderMessage # noqa: F401
|
||||
from app.core.config import settings
|
||||
|
||||
# this is the Alembic Config object
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
"""add_script_builder_sessions_table
|
||||
|
||||
Revision ID: 062
|
||||
Revises: f0aad74ea51b
|
||||
Create Date: 2026-03-21 20:54:41.621748
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '062'
|
||||
down_revision: Union[str, None] = 'f0aad74ea51b'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table('script_builder_sessions',
|
||||
sa.Column('id', sa.UUID(), nullable=False),
|
||||
sa.Column('user_id', sa.UUID(), nullable=False),
|
||||
sa.Column('team_id', sa.UUID(), nullable=True),
|
||||
sa.Column('language', sa.String(length=30), nullable=False, comment='Script language: powershell, bash, python'),
|
||||
sa.Column('title', sa.String(length=200), nullable=True, comment='Auto-generated from first AI response'),
|
||||
sa.Column('messages', postgresql.JSONB(astext_type=sa.Text()), nullable=False, comment='Array of {role, content, script?, script_filename?, timestamp}'),
|
||||
sa.Column('latest_script', sa.Text(), nullable=True, comment='Most recent generated script for quick access'),
|
||||
sa.Column('latest_script_filename', sa.String(length=200), nullable=True, comment='Filename of the latest generated script'),
|
||||
sa.Column('message_count', sa.Integer(), nullable=False),
|
||||
sa.Column('ai_session_id', sa.UUID(), nullable=True, comment='Link to FlowPilot session if launched from there'),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(['ai_session_id'], ['ai_sessions.id'], ondelete='SET NULL'),
|
||||
sa.ForeignKeyConstraint(['team_id'], ['teams.id'], ondelete='SET NULL'),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_script_builder_sessions_user_id'), 'script_builder_sessions', ['user_id'], unique=False)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index(op.f('ix_script_builder_sessions_user_id'), table_name='script_builder_sessions')
|
||||
op.drop_table('script_builder_sessions')
|
||||
@@ -0,0 +1,55 @@
|
||||
"""add_language_to_script_templates_and_ai_generated_category
|
||||
|
||||
Revision ID: 063
|
||||
Revises: 062
|
||||
Create Date: 2026-03-21 21:13:32.239533
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '063'
|
||||
down_revision: Union[str, None] = '062'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Add language column to script_templates
|
||||
op.add_column(
|
||||
'script_templates',
|
||||
sa.Column(
|
||||
'language',
|
||||
sa.String(length=30),
|
||||
nullable=True,
|
||||
comment='Script language: powershell, bash, python',
|
||||
),
|
||||
)
|
||||
|
||||
# Seed "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
|
||||
""")
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute(sa.text("DELETE FROM script_categories WHERE slug = 'ai-generated'"))
|
||||
op.drop_column('script_templates', 'language')
|
||||
@@ -0,0 +1,128 @@
|
||||
"""normalize script builder messages
|
||||
|
||||
Revision ID: 064
|
||||
Revises: 063
|
||||
Create Date: 2026-03-21 22:00:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects.postgresql import UUID, JSONB
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '064'
|
||||
down_revision: Union[str, None] = '063'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# 1. Create the new script_builder_messages table
|
||||
op.create_table(
|
||||
'script_builder_messages',
|
||||
sa.Column('id', UUID(as_uuid=True), primary_key=True),
|
||||
sa.Column('session_id', UUID(as_uuid=True), sa.ForeignKey('script_builder_sessions.id', ondelete='CASCADE'), nullable=False, index=True),
|
||||
sa.Column('role', sa.String(20), nullable=False, comment='user or assistant'),
|
||||
sa.Column('content', sa.Text(), nullable=False),
|
||||
sa.Column('script', sa.Text(), nullable=True, comment='Extracted script from AI response'),
|
||||
sa.Column('script_filename', sa.String(200), nullable=True),
|
||||
sa.Column('line_count', sa.Integer(), nullable=True),
|
||||
sa.Column('input_tokens', sa.Integer(), nullable=True),
|
||||
sa.Column('output_tokens', sa.Integer(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||
)
|
||||
|
||||
# 2. Migrate existing JSONB messages to the new table (if any exist)
|
||||
conn = op.get_bind()
|
||||
sessions = conn.execute(
|
||||
sa.text("SELECT id, messages FROM script_builder_sessions WHERE messages IS NOT NULL AND messages != '[]'::jsonb")
|
||||
).fetchall()
|
||||
|
||||
for session_row in sessions:
|
||||
session_id = session_row[0]
|
||||
messages = session_row[1]
|
||||
if not messages:
|
||||
continue
|
||||
for msg in messages:
|
||||
conn.execute(
|
||||
sa.text("""
|
||||
INSERT INTO script_builder_messages (id, session_id, role, content, script, script_filename, line_count, input_tokens, output_tokens, created_at)
|
||||
VALUES (gen_random_uuid(), :session_id, :role, :content, :script, :script_filename, :line_count, :input_tokens, :output_tokens, COALESCE(:timestamp::timestamptz, NOW()))
|
||||
"""),
|
||||
{
|
||||
"session_id": session_id,
|
||||
"role": msg.get("role", "user"),
|
||||
"content": msg.get("content", ""),
|
||||
"script": msg.get("script"),
|
||||
"script_filename": msg.get("script_filename"),
|
||||
"line_count": msg.get("line_count"),
|
||||
"input_tokens": msg.get("input_tokens"),
|
||||
"output_tokens": msg.get("output_tokens"),
|
||||
"timestamp": msg.get("timestamp"),
|
||||
},
|
||||
)
|
||||
|
||||
# 3. Drop the old columns
|
||||
op.drop_column('script_builder_sessions', 'messages')
|
||||
op.drop_column('script_builder_sessions', 'message_count')
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# 1. Re-add the JSONB columns
|
||||
op.add_column('script_builder_sessions', sa.Column('messages', JSONB, nullable=False, server_default='[]'))
|
||||
op.add_column('script_builder_sessions', sa.Column('message_count', sa.Integer(), nullable=False, server_default='0'))
|
||||
|
||||
# 2. Migrate data back from the messages table to JSONB
|
||||
conn = op.get_bind()
|
||||
sessions = conn.execute(
|
||||
sa.text("SELECT DISTINCT session_id FROM script_builder_messages")
|
||||
).fetchall()
|
||||
|
||||
for (session_id,) in sessions:
|
||||
messages = conn.execute(
|
||||
sa.text("""
|
||||
SELECT role, content, script, script_filename, line_count, input_tokens, output_tokens, created_at
|
||||
FROM script_builder_messages
|
||||
WHERE session_id = :session_id
|
||||
ORDER BY created_at ASC
|
||||
"""),
|
||||
{"session_id": session_id},
|
||||
).fetchall()
|
||||
|
||||
json_messages = []
|
||||
user_count = 0
|
||||
for msg in messages:
|
||||
entry = {
|
||||
"role": msg[0],
|
||||
"content": msg[1],
|
||||
"timestamp": msg[7].isoformat() if msg[7] else None,
|
||||
}
|
||||
if msg[2]: # script
|
||||
entry["script"] = msg[2]
|
||||
if msg[3]: # script_filename
|
||||
entry["script_filename"] = msg[3]
|
||||
if msg[4] is not None: # line_count
|
||||
entry["line_count"] = msg[4]
|
||||
if msg[5] is not None: # input_tokens
|
||||
entry["input_tokens"] = msg[5]
|
||||
if msg[6] is not None: # output_tokens
|
||||
entry["output_tokens"] = msg[6]
|
||||
json_messages.append(entry)
|
||||
if msg[0] == "user":
|
||||
user_count += 1
|
||||
|
||||
import json
|
||||
conn.execute(
|
||||
sa.text("UPDATE script_builder_sessions SET messages = :messages::jsonb, message_count = :count WHERE id = :id"),
|
||||
{"messages": json.dumps(json_messages), "count": user_count, "id": session_id},
|
||||
)
|
||||
|
||||
# 3. Drop the messages table
|
||||
op.drop_table('script_builder_messages')
|
||||
|
||||
# 4. Remove server defaults added for downgrade
|
||||
op.alter_column('script_builder_sessions', 'messages', server_default=None)
|
||||
op.alter_column('script_builder_sessions', 'message_count', server_default=None)
|
||||
@@ -396,6 +396,34 @@ async def resume_session(
|
||||
await db.commit()
|
||||
|
||||
|
||||
# ── Abandon / Close ──
|
||||
|
||||
@router.post("/{session_id}/abandon", status_code=204)
|
||||
@limiter.limit("15/minute")
|
||||
async def abandon_session(
|
||||
request: Request,
|
||||
session_id: UUID,
|
||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
_: None = Depends(require_engineer_or_admin),
|
||||
reason: str | None = None,
|
||||
):
|
||||
"""Close a session without resolving or escalating."""
|
||||
try:
|
||||
await flowpilot_engine.abandon_session(
|
||||
session_id=session_id,
|
||||
user_id=current_user.id,
|
||||
reason=reason,
|
||||
db=db,
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
||||
except PermissionError as e:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(e))
|
||||
|
||||
await db.commit()
|
||||
|
||||
|
||||
# ── Escalation Queue ──
|
||||
|
||||
@router.get("/escalation-queue", response_model=list[AISessionSummary])
|
||||
|
||||
188
backend/app/api/endpoints/script_builder.py
Normal file
188
backend/app/api/endpoints/script_builder.py
Normal file
@@ -0,0 +1,188 @@
|
||||
"""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.models.script_builder_session import ScriptBuilderSession
|
||||
from app.schemas.script_builder import (
|
||||
ScriptBuilderCreateRequest,
|
||||
ScriptBuilderMessageRequest,
|
||||
ScriptBuilderMessageResponse,
|
||||
ScriptBuilderMessageSchema,
|
||||
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
|
||||
|
||||
|
||||
def _session_to_detail(session: ScriptBuilderSession) -> ScriptBuilderSessionDetail:
|
||||
"""Convert a session ORM object (with message_records loaded) to detail schema."""
|
||||
messages = [
|
||||
ScriptBuilderMessageSchema.model_validate(m)
|
||||
for m in session.message_records
|
||||
]
|
||||
return ScriptBuilderSessionDetail(
|
||||
id=session.id,
|
||||
language=session.language,
|
||||
title=session.title,
|
||||
messages=messages,
|
||||
latest_script=session.latest_script,
|
||||
latest_script_filename=session.latest_script_filename,
|
||||
message_count=len([m for m in messages if m.role == "user"]),
|
||||
ai_session_id=session.ai_session_id,
|
||||
created_at=session.created_at,
|
||||
updated_at=session.updated_at,
|
||||
)
|
||||
|
||||
|
||||
def _session_to_summary(session: ScriptBuilderSession) -> ScriptBuilderSessionSummary:
|
||||
"""Convert a session ORM object to summary schema (no messages needed)."""
|
||||
return ScriptBuilderSessionSummary(
|
||||
id=session.id,
|
||||
language=session.language,
|
||||
title=session.title,
|
||||
message_count=0, # Summary doesn't eagerly load messages
|
||||
latest_script_filename=session.latest_script_filename,
|
||||
created_at=session.created_at,
|
||||
updated_at=session.updated_at,
|
||||
)
|
||||
|
||||
|
||||
@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()
|
||||
# Re-fetch with message_records loaded
|
||||
session = await script_builder_service.get_session(db, session.id, current_user.id)
|
||||
return _session_to_detail(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 [_session_to_summary(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 _session_to_detail(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)
|
||||
@@ -76,6 +76,8 @@ async def list_templates(
|
||||
search: Optional[str] = Query(None),
|
||||
tags: Optional[str] = Query(None, description="Comma-separated tags"),
|
||||
managed: Optional[bool] = Query(None, description="If true, return only templates this user can edit"),
|
||||
mine: bool = Query(False, description="If true, return only templates created by the current user"),
|
||||
shared: bool = Query(False, description="If true, return only templates shared with the user's team"),
|
||||
) -> list[ScriptTemplateListItem]:
|
||||
query = (
|
||||
select(ScriptTemplate)
|
||||
@@ -116,6 +118,12 @@ async def list_templates(
|
||||
# engineers see only their own
|
||||
query = query.where(ScriptTemplate.created_by == current_user.id)
|
||||
|
||||
if mine:
|
||||
query = query.where(ScriptTemplate.created_by == current_user.id)
|
||||
|
||||
if shared:
|
||||
query = query.where(ScriptTemplate.team_id == current_user.team_id)
|
||||
|
||||
result = await db.execute(query.order_by(ScriptTemplate.name))
|
||||
templates = result.scalars().all()
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ from app.api.endpoints import notifications
|
||||
from app.api.endpoints import public_templates
|
||||
from app.api.endpoints import admin_gallery
|
||||
from app.api.endpoints import uploads
|
||||
from app.api.endpoints import script_builder
|
||||
|
||||
api_router = APIRouter()
|
||||
|
||||
@@ -81,3 +82,4 @@ api_router.include_router(notifications.router)
|
||||
api_router.include_router(public_templates.router)
|
||||
api_router.include_router(admin_gallery.router)
|
||||
api_router.include_router(uploads.router)
|
||||
api_router.include_router(script_builder.router)
|
||||
|
||||
@@ -104,6 +104,7 @@ class Settings(BaseSettings):
|
||||
"open_chat": "standard",
|
||||
"variable_inference": "fast",
|
||||
"kb_convert": "standard",
|
||||
"script_build": "standard",
|
||||
}
|
||||
|
||||
def get_model_for_action(self, action_type: str) -> str:
|
||||
|
||||
111
backend/app/models/script_builder_session.py
Normal file
111
backend/app/models/script_builder_session.py
Normal file
@@ -0,0 +1,111 @@
|
||||
"""Script Builder session and message models.
|
||||
|
||||
Tracks AI-powered script generation conversations.
|
||||
"""
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional, TYPE_CHECKING
|
||||
|
||||
from sqlalchemy import String, Text, DateTime, ForeignKey, Integer
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
|
||||
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."""
|
||||
__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",
|
||||
)
|
||||
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",
|
||||
)
|
||||
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")
|
||||
message_records: Mapped[list["ScriptBuilderMessage"]] = relationship(
|
||||
"ScriptBuilderMessage",
|
||||
back_populates="session",
|
||||
order_by="ScriptBuilderMessage.created_at",
|
||||
cascade="all, delete-orphan",
|
||||
)
|
||||
|
||||
|
||||
class ScriptBuilderMessage(Base):
|
||||
"""A single message in a Script Builder conversation."""
|
||||
__tablename__ = "script_builder_messages"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||
)
|
||||
session_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("script_builder_sessions.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
role: Mapped[str] = mapped_column(
|
||||
String(20), nullable=False, comment="user or assistant"
|
||||
)
|
||||
content: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
script: Mapped[Optional[str]] = mapped_column(
|
||||
Text, nullable=True, comment="Extracted script from AI response"
|
||||
)
|
||||
script_filename: Mapped[Optional[str]] = mapped_column(
|
||||
String(200), nullable=True
|
||||
)
|
||||
line_count: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
|
||||
input_tokens: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
|
||||
output_tokens: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
|
||||
)
|
||||
|
||||
# Relationship
|
||||
session: Mapped["ScriptBuilderSession"] = relationship(
|
||||
"ScriptBuilderSession", back_populates="message_records"
|
||||
)
|
||||
@@ -52,6 +52,10 @@ class ScriptTemplate(Base):
|
||||
description: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||
use_case: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||
script_body: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
language: Mapped[Optional[str]] = mapped_column(
|
||||
String(30), nullable=True, default="powershell",
|
||||
comment="Script language: powershell, bash, python",
|
||||
)
|
||||
parameters_schema: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict)
|
||||
default_values: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict)
|
||||
validation_rules: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict)
|
||||
|
||||
84
backend/app/schemas/script_builder.py
Normal file
84
backend/app/schemas/script_builder.py
Normal file
@@ -0,0 +1,84 @@
|
||||
"""Pydantic schemas for the AI Script Builder."""
|
||||
from datetime import datetime
|
||||
from typing import 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 ScriptBuilderMessageSchema(BaseModel):
|
||||
"""A single message in a builder session."""
|
||||
id: UUID
|
||||
role: str
|
||||
content: str
|
||||
script: str | None = None
|
||||
script_filename: str | None = None
|
||||
line_count: int | None = None
|
||||
input_tokens: int | None = None
|
||||
output_tokens: int | None = None
|
||||
created_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
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 = 0
|
||||
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[ScriptBuilderMessageSchema] = []
|
||||
latest_script: str | None = None
|
||||
latest_script_filename: str | None = None
|
||||
message_count: int = 0
|
||||
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
|
||||
@@ -62,6 +62,7 @@ class ScriptTemplateCreate(BaseModel):
|
||||
estimated_runtime: Optional[str] = None
|
||||
requires_elevation: bool = False
|
||||
requires_modules: list[str] = Field(default_factory=list)
|
||||
language: str | None = None
|
||||
|
||||
class ScriptTemplateUpdate(BaseModel):
|
||||
name: Optional[str] = Field(None, min_length=1, max_length=200)
|
||||
@@ -93,6 +94,7 @@ class ScriptTemplateListItem(BaseModel):
|
||||
requires_modules: list[str]
|
||||
is_verified: bool
|
||||
usage_count: int
|
||||
language: str | None = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
@@ -48,7 +48,7 @@ Your response MUST be a valid JSON object with one of these shapes:
|
||||
{"type": "question", "content": "Brief description", "reasoning": "Internal why", "context_message": "Shown to engineer", "options": [{"label": "Human text", "value": "machine_value", "followup_hint": "or null"}], "allow_free_text": true, "allow_skip": true, "confidence": 0.65}
|
||||
|
||||
2. Suggested action:
|
||||
{"type": "action", "content": "What to do", "reasoning": "Internal why", "context_message": "Here's what to try", "action_type": "instruction | script_generation | verification | info_request", "expected_outcome": "What success looks like", "confidence": 0.78}
|
||||
{"type": "action", "content": "What to do", "reasoning": "Internal why", "context_message": "Here's what to try", "action_type": "instruction | script_generation | verification | info_request | open_script_builder", "expected_outcome": "What success looks like", "confidence": 0.78}
|
||||
|
||||
3. Resolution suggestion:
|
||||
{"type": "resolution_suggestion", "content": "Summary of what we did", "reasoning": "Internal why", "resolution_summary": "Issue was caused by X, fixed by Y", "confidence": 0.92, "follow_up_recommendations": ["Monitor for 24 hours"]}\
|
||||
@@ -75,7 +75,9 @@ Every response must have a "type" field: "question", "action", or "resolution_su
|
||||
- Always include relevant context in context_message — explain WHY you're asking
|
||||
- confidence is a float 0.0-1.0 reflecting how certain you are about the diagnosis path
|
||||
- When multiple symptoms point to one root cause with >90% confidence, suggest resolution
|
||||
- If you detect the engineer needs a PowerShell script, suggest a script_generation action
|
||||
- When a task can be accomplished via script OR through a GUI (e.g., Active Directory Users & Computers, Microsoft 365 Admin Center), ALWAYS ask the engineer which approach they prefer BEFORE suggesting either. Present options like "Would you like me to guide you through the GUI, or would you prefer a script to automate this?" Never assume the engineer wants a script.
|
||||
- Only suggest a script_generation action AFTER the engineer has confirmed they want a script-based approach
|
||||
- When the engineer wants 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).
|
||||
- Never suggest restarting or rebooting as a first step — diagnose first
|
||||
- Be specific: "Check Event Viewer > System > source NTFS" not "check the logs"
|
||||
|
||||
@@ -791,6 +793,29 @@ async def resume_session(
|
||||
await db.flush()
|
||||
|
||||
|
||||
async def abandon_session(
|
||||
session_id: UUID,
|
||||
user_id: UUID,
|
||||
reason: Optional[str],
|
||||
db: AsyncSession,
|
||||
) -> None:
|
||||
"""Close a session without resolving or escalating.
|
||||
|
||||
Used when the engineer no longer needs help, figured it out on their own,
|
||||
or the session is no longer relevant.
|
||||
"""
|
||||
session = await _load_session(session_id, user_id, db)
|
||||
|
||||
if session.status not in ("active", "paused"):
|
||||
raise ValueError(f"Cannot close session in status: {session.status}")
|
||||
|
||||
session.status = "abandoned"
|
||||
session.resolved_at = datetime.now(timezone.utc)
|
||||
if reason:
|
||||
session.resolution_notes = reason
|
||||
await db.flush()
|
||||
|
||||
|
||||
async def rate_session(
|
||||
session_id: UUID,
|
||||
rating: int,
|
||||
|
||||
377
backend/app/services/script_builder_service.py
Normal file
377
backend/app/services/script_builder_service.py
Normal file
@@ -0,0 +1,377 @@
|
||||
"""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 sqlalchemy.orm import selectinload
|
||||
|
||||
from app.core.ai_provider import get_ai_provider
|
||||
from app.core.config import settings
|
||||
from app.models.script_builder_session import ScriptBuilderSession, ScriptBuilderMessage
|
||||
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,
|
||||
)
|
||||
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."""
|
||||
# Count existing messages for the session
|
||||
msg_count_result = await db.execute(
|
||||
select(func.count(ScriptBuilderMessage.id)).where(
|
||||
ScriptBuilderMessage.session_id == session.id,
|
||||
ScriptBuilderMessage.role == "user",
|
||||
)
|
||||
)
|
||||
user_msg_count = msg_count_result.scalar_one()
|
||||
|
||||
if user_msg_count >= MAX_MESSAGES_PER_SESSION:
|
||||
raise ValueError(f"Session has reached the maximum of {MAX_MESSAGES_PER_SESSION} messages.")
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
# Create user message record
|
||||
user_msg = ScriptBuilderMessage(
|
||||
session_id=session.id,
|
||||
role="user",
|
||||
content=user_content,
|
||||
created_at=now,
|
||||
)
|
||||
db.add(user_msg)
|
||||
await db.flush()
|
||||
|
||||
# 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 — get last 20 messages for context window
|
||||
recent_result = await db.execute(
|
||||
select(ScriptBuilderMessage)
|
||||
.where(ScriptBuilderMessage.session_id == session.id)
|
||||
.order_by(ScriptBuilderMessage.created_at.desc())
|
||||
.limit(20)
|
||||
)
|
||||
recent_msgs = list(reversed(recent_result.scalars().all()))
|
||||
ai_messages = [{"role": m.role, "content": m.content} for m in recent_msgs]
|
||||
|
||||
# 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
|
||||
|
||||
# Create assistant message record
|
||||
assistant_msg = ScriptBuilderMessage(
|
||||
session_id=session.id,
|
||||
role="assistant",
|
||||
content=ai_text,
|
||||
script=script,
|
||||
script_filename=filename,
|
||||
line_count=line_count,
|
||||
input_tokens=input_tokens,
|
||||
output_tokens=output_tokens,
|
||||
created_at=datetime.now(timezone.utc),
|
||||
)
|
||||
db.add(assistant_msg)
|
||||
|
||||
# Update session denormalized fields
|
||||
if script:
|
||||
session.latest_script = script
|
||||
session.latest_script_filename = filename
|
||||
if not session.title:
|
||||
# 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)
|
||||
.options(selectinload(ScriptBuilderSession.message_records))
|
||||
.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,
|
||||
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
|
||||
666
backend/tests/test_script_builder.py
Normal file
666
backend/tests/test_script_builder.py
Normal file
@@ -0,0 +1,666 @@
|
||||
# backend/tests/test_script_builder.py
|
||||
"""Tests for the Script Builder API endpoints."""
|
||||
import pytest
|
||||
import uuid as uuid_mod
|
||||
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_mod.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_mod.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"]
|
||||
|
||||
|
||||
class TestScriptBuilderMaxSessions:
|
||||
"""Test the per-user session limit (MAX_SESSIONS_PER_USER = 5)."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_max_sessions_limit(self, client, auth_headers):
|
||||
"""Creating a 6th session should return 400."""
|
||||
# Create 5 sessions (the maximum)
|
||||
for i in range(5):
|
||||
resp = await client.post(
|
||||
"/api/v1/scripts/builder/sessions",
|
||||
json={"language": "powershell"},
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert resp.status_code == 201, f"Session {i+1} should succeed"
|
||||
|
||||
# 6th session should be rejected
|
||||
resp = await client.post(
|
||||
"/api/v1/scripts/builder/sessions",
|
||||
json={"language": "powershell"},
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
assert "Maximum" in resp.json()["detail"]
|
||||
assert "5" in resp.json()["detail"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_max_sessions_after_delete_allows_new(self, client, auth_headers):
|
||||
"""After deleting a session, creating a new one should succeed."""
|
||||
session_ids = []
|
||||
for _ in range(5):
|
||||
resp = await client.post(
|
||||
"/api/v1/scripts/builder/sessions",
|
||||
json={"language": "powershell"},
|
||||
headers=auth_headers,
|
||||
)
|
||||
session_ids.append(resp.json()["id"])
|
||||
|
||||
# Delete one
|
||||
await client.delete(
|
||||
f"/api/v1/scripts/builder/sessions/{session_ids[0]}",
|
||||
headers=auth_headers,
|
||||
)
|
||||
|
||||
# Now creating a new session should work
|
||||
resp = await client.post(
|
||||
"/api/v1/scripts/builder/sessions",
|
||||
json={"language": "bash"},
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
|
||||
|
||||
class TestScriptBuilderMaxMessages:
|
||||
"""Test the per-session message limit (MAX_MESSAGES_PER_SESSION = 30)."""
|
||||
|
||||
@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\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.mark.asyncio
|
||||
async def test_max_messages_limit(
|
||||
self, client, auth_headers, test_db, _enable_ai, _mock_ai_response
|
||||
):
|
||||
"""Sending a message when session has 30 user messages should return 400."""
|
||||
# Create a session
|
||||
create_resp = await client.post(
|
||||
"/api/v1/scripts/builder/sessions",
|
||||
json={"language": "powershell"},
|
||||
headers=auth_headers,
|
||||
)
|
||||
session_id = create_resp.json()["id"]
|
||||
|
||||
# Insert 30 user message rows directly into the DB to reach the limit
|
||||
for i in range(30):
|
||||
await test_db.execute(
|
||||
sa.text("""
|
||||
INSERT INTO script_builder_messages (id, session_id, role, content, created_at)
|
||||
VALUES (:id, :sid, 'user', :content, NOW())
|
||||
"""),
|
||||
{
|
||||
"id": str(uuid_mod.uuid4()),
|
||||
"sid": session_id,
|
||||
"content": f"message {i+1}",
|
||||
},
|
||||
)
|
||||
await test_db.commit()
|
||||
|
||||
# Now sending a message should fail with 400
|
||||
resp = await client.post(
|
||||
f"/api/v1/scripts/builder/sessions/{session_id}/messages",
|
||||
json={"content": "One more message"},
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
assert "maximum" in resp.json()["detail"].lower()
|
||||
|
||||
|
||||
class TestScriptBuilderSlugCollision:
|
||||
"""Test that saving a script with a colliding slug generates a unique slug."""
|
||||
|
||||
@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):
|
||||
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_slug_collision_appends_suffix(
|
||||
self, client, auth_headers, test_db, _enable_ai, _mock_ai_response, _seed_ai_category
|
||||
):
|
||||
"""When a slug already exists, the saved template gets a UUID-suffixed slug."""
|
||||
# Pre-create a template with slug "test-script" to cause collision
|
||||
user_resp = await client.get("/api/v1/auth/me", headers=auth_headers)
|
||||
user_id = user_resp.json()["id"]
|
||||
await test_db.execute(
|
||||
sa.text("""
|
||||
INSERT INTO script_templates
|
||||
(id, category_id, created_by, name, slug, script_body,
|
||||
parameters_schema, default_values, validation_rules, tags,
|
||||
complexity, is_active, version, usage_count, created_at, updated_at)
|
||||
VALUES
|
||||
(:id, 'a0000000-0000-0000-0000-000000000001'::uuid, :uid,
|
||||
'Test Script', 'test-script', 'echo hello',
|
||||
'{"parameters": []}', '{}', '{}', '["powershell"]',
|
||||
'beginner', true, 1, 0, NOW(), NOW())
|
||||
"""),
|
||||
{"id": str(uuid_mod.uuid4()), "uid": user_id},
|
||||
)
|
||||
await test_db.commit()
|
||||
|
||||
# Create a builder 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 with a name that would generate slug "test-script"
|
||||
resp = await client.post(
|
||||
f"/api/v1/scripts/builder/sessions/{session_id}/save",
|
||||
json={"name": "Test Script"},
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
data = resp.json()
|
||||
# The slug should start with "test-script-" (base + UUID suffix)
|
||||
assert data["slug"].startswith("test-script-")
|
||||
assert data["slug"] != "test-script"
|
||||
|
||||
|
||||
class TestScriptTemplateFilters:
|
||||
"""Test mine/shared query filters on GET /scripts/templates."""
|
||||
|
||||
@pytest.fixture
|
||||
async def _seed_category(self, test_db):
|
||||
"""Seed a script category for template creation."""
|
||||
await test_db.execute(
|
||||
sa.text("""
|
||||
INSERT INTO script_categories (id, name, slug, description, icon, sort_order, is_active, created_at, updated_at)
|
||||
VALUES (
|
||||
'b0000000-0000-0000-0000-000000000001'::uuid,
|
||||
'General', 'general', 'General scripts', 'code', 0, true, NOW(), NOW()
|
||||
)
|
||||
ON CONFLICT (slug) DO NOTHING
|
||||
""")
|
||||
)
|
||||
await test_db.commit()
|
||||
|
||||
@pytest.fixture
|
||||
async def second_user_headers(self, client, test_db):
|
||||
"""Create a second user on the same team as the test user and return their auth headers."""
|
||||
# Register second user
|
||||
user_data = {
|
||||
"email": "second@example.com",
|
||||
"password": "TestPassword123!",
|
||||
"name": "Second User",
|
||||
}
|
||||
resp = await client.post("/api/v1/auth/register", json=user_data)
|
||||
assert resp.status_code in (200, 201)
|
||||
|
||||
# Login to get headers
|
||||
login_resp = await client.post(
|
||||
"/api/v1/auth/login/json",
|
||||
json={"email": user_data["email"], "password": user_data["password"]},
|
||||
)
|
||||
assert login_resp.status_code == 200
|
||||
token = login_resp.json()["access_token"]
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mine_filter(
|
||||
self, client, auth_headers, test_db, test_user, second_user_headers, _seed_category
|
||||
):
|
||||
"""mine=true returns only templates created by the current user."""
|
||||
user_resp = await client.get("/api/v1/auth/me", headers=auth_headers)
|
||||
user_id = user_resp.json()["id"]
|
||||
|
||||
second_resp = await client.get("/api/v1/auth/me", headers=second_user_headers)
|
||||
second_user_id = second_resp.json()["id"]
|
||||
|
||||
cat_id = "b0000000-0000-0000-0000-000000000001"
|
||||
|
||||
# Create template owned by test user
|
||||
await test_db.execute(
|
||||
sa.text("""
|
||||
INSERT INTO script_templates
|
||||
(id, category_id, created_by, team_id, name, slug, script_body,
|
||||
parameters_schema, default_values, validation_rules, tags,
|
||||
complexity, is_active, version, usage_count, created_at, updated_at)
|
||||
VALUES
|
||||
(:id, :cat, :uid, NULL,
|
||||
'My Script', 'my-script', 'echo mine',
|
||||
'{"parameters": []}', '{}', '{}', '[]',
|
||||
'beginner', true, 1, 0, NOW(), NOW())
|
||||
"""),
|
||||
{"id": str(uuid_mod.uuid4()), "cat": cat_id, "uid": user_id},
|
||||
)
|
||||
|
||||
# Create template owned by second user (no team_id, so visible to all)
|
||||
await test_db.execute(
|
||||
sa.text("""
|
||||
INSERT INTO script_templates
|
||||
(id, category_id, created_by, team_id, name, slug, script_body,
|
||||
parameters_schema, default_values, validation_rules, tags,
|
||||
complexity, is_active, version, usage_count, created_at, updated_at)
|
||||
VALUES
|
||||
(:id, :cat, :uid, NULL,
|
||||
'Other Script', 'other-script', 'echo other',
|
||||
'{"parameters": []}', '{}', '{}', '[]',
|
||||
'beginner', true, 1, 0, NOW(), NOW())
|
||||
"""),
|
||||
{"id": str(uuid_mod.uuid4()), "cat": cat_id, "uid": second_user_id},
|
||||
)
|
||||
await test_db.commit()
|
||||
|
||||
# mine=true should only return the test user's template
|
||||
resp = await client.get(
|
||||
"/api/v1/scripts/templates?mine=true",
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert len(data) == 1
|
||||
assert data[0]["name"] == "My Script"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_shared_filter(
|
||||
self, client, auth_headers, test_db, test_user, _seed_category
|
||||
):
|
||||
"""shared=true returns only templates shared with the user's team."""
|
||||
user_resp = await client.get("/api/v1/auth/me", headers=auth_headers)
|
||||
user_id = user_resp.json()["id"]
|
||||
team_id = user_resp.json().get("team_id")
|
||||
|
||||
cat_id = "b0000000-0000-0000-0000-000000000001"
|
||||
|
||||
# Template shared with user's team
|
||||
await test_db.execute(
|
||||
sa.text("""
|
||||
INSERT INTO script_templates
|
||||
(id, category_id, created_by, team_id, name, slug, script_body,
|
||||
parameters_schema, default_values, validation_rules, tags,
|
||||
complexity, is_active, version, usage_count, created_at, updated_at)
|
||||
VALUES
|
||||
(:id, :cat, :uid, :tid,
|
||||
'Team Script', 'team-script', 'echo team',
|
||||
'{"parameters": []}', '{}', '{}', '[]',
|
||||
'beginner', true, 1, 0, NOW(), NOW())
|
||||
"""),
|
||||
{"id": str(uuid_mod.uuid4()), "cat": cat_id, "uid": user_id, "tid": team_id},
|
||||
)
|
||||
|
||||
# Template NOT shared (no team_id)
|
||||
await test_db.execute(
|
||||
sa.text("""
|
||||
INSERT INTO script_templates
|
||||
(id, category_id, created_by, team_id, name, slug, script_body,
|
||||
parameters_schema, default_values, validation_rules, tags,
|
||||
complexity, is_active, version, usage_count, created_at, updated_at)
|
||||
VALUES
|
||||
(:id, :cat, :uid, NULL,
|
||||
'Personal Script', 'personal-script', 'echo personal',
|
||||
'{"parameters": []}', '{}', '{}', '[]',
|
||||
'beginner', true, 1, 0, NOW(), NOW())
|
||||
"""),
|
||||
{"id": str(uuid_mod.uuid4()), "cat": cat_id, "uid": user_id},
|
||||
)
|
||||
await test_db.commit()
|
||||
|
||||
# shared=true should only return the team-shared template
|
||||
resp = await client.get(
|
||||
"/api/v1/scripts/templates?shared=true",
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
names = [t["name"] for t in data]
|
||||
assert "Team Script" in names
|
||||
assert "Personal Script" not in names
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,339 @@
|
||||
# FlowPilot Message Bar & AI Script Builder
|
||||
|
||||
> **Date:** 2026-03-21
|
||||
> **Status:** Approved
|
||||
> **Scope:** Three interconnected features — FlowPilot always-visible message bar, standalone AI Script Builder, and Script Library reorganization
|
||||
|
||||
---
|
||||
|
||||
## Problem
|
||||
|
||||
1. **FlowPilot sessions lack a visible text input.** When FlowPilot asks the user to provide detailed information (e.g., AD user details for script building), the only way to type a response is a tiny "None of these — let me describe" link that expands into a textarea. Users don't notice it and feel stuck.
|
||||
|
||||
2. **No AI-powered script generation from natural language.** The existing script system is template-based — users pick a pre-built template and fill in parameters. There's no way to say "build me a script that does X" and get a custom script generated.
|
||||
|
||||
3. **Script Library lacks personal/team separation.** Scripts are either personal or team-shared via a toggle, but the UI doesn't clearly separate "My Scripts" from "Team Scripts." There's also no prominent entry point to create new scripts.
|
||||
|
||||
---
|
||||
|
||||
## Feature 1: Always-Visible Message Bar (FlowPilot Sessions)
|
||||
|
||||
### What Changes
|
||||
|
||||
Replace the hidden "None of these — let me describe" escape hatch with a persistent chat-style input bar pinned to the bottom of the FlowPilot session view.
|
||||
|
||||
### Current Flow
|
||||
|
||||
- Option buttons render inside the step card
|
||||
- A small "None of these — let me describe" text link appears below options (controlled by `allow_free_text` flag)
|
||||
- Clicking the link reveals a `RichTextInput` textarea with Submit/Cancel buttons
|
||||
- If the user doesn't notice the link, they have no way to respond with free text
|
||||
|
||||
### New Flow
|
||||
|
||||
- Option buttons still render inside the step card (unchanged)
|
||||
- A persistent message input bar is pinned to the bottom of the session view, **outside** step cards
|
||||
- Always visible, always ready — styled like Claude Desktop's input bar
|
||||
- Typing and submitting sends a `free_text_input` response to the current step (same backend flow, no backend changes needed)
|
||||
- The "None of these — let me describe" link is removed entirely from `FlowPilotStepCard`
|
||||
- The message bar is disabled when: FlowPilot is processing, the session is completed/resolved/escalated, or no current step exists
|
||||
|
||||
### Component Changes
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `FlowPilotStepCard.tsx` | Remove the free text escape hatch block (the `{!isResolutionSuggestion && step.allow_free_text && ...}` conditional and its children) |
|
||||
| `FlowPilotSession.tsx` | Add `FlowPilotMessageBar` component, pinned to bottom of session area, above the Resolve/Escalate action bar |
|
||||
| New: `FlowPilotMessageBar.tsx` | Persistent input bar using existing `RichTextInput`. Rounded input with placeholder "Type a message...", send button. Calls `onRespond({ free_text_input })` |
|
||||
|
||||
### Visual Design
|
||||
|
||||
- Rounded input field with `bg-card border-border` styling, consistent with the app's glass design system
|
||||
- Send button with `bg-gradient-brand` (cyan gradient)
|
||||
- Placeholder text: "Type a message..."
|
||||
- Pinned to bottom of session content area, above Resolve/Escalate bar
|
||||
- Disabled state (dimmed, no interaction) when processing or session is closed
|
||||
|
||||
### Step Targeting
|
||||
|
||||
The message bar always submits to the current step at the moment of submission (same behavior as the existing free text mechanism). If the step changes between when the user starts typing and when they click Send, the submission targets the new current step. This is acceptable because FlowPilot won't advance steps without a user response.
|
||||
|
||||
### Respecting `allow_free_text: false`
|
||||
|
||||
Certain step types explicitly set `allow_free_text: false` — specifically resolution suggestions and escalation briefings, where the AI expects a structured response (accept/reject). The message bar must respect this flag: when `allow_free_text` is `false` on the current step, the message bar is **disabled** (visually dimmed, input blocked) rather than hidden. This preserves visibility while preventing engineers from bypassing structured response constraints.
|
||||
|
||||
### Backend Changes
|
||||
|
||||
None. The `free_text_input` field in `StepResponseRequest` already handles this. The `allow_free_text` flag on steps continues to control whether the message bar is enabled/disabled for a given step.
|
||||
|
||||
---
|
||||
|
||||
## Feature 2: AI Script Builder (Standalone)
|
||||
|
||||
### Overview
|
||||
|
||||
A new standalone page accessible from the sidebar that lets users describe what they need in natural language, and an AI generates a custom script. Users can iterate on the script through multi-turn conversation, then save it to the Script Library.
|
||||
|
||||
### User Experience
|
||||
|
||||
1. User clicks "Script Builder" in the sidebar nav
|
||||
2. Selects a language (PowerShell, Bash, Python) via pill-style selector at the top
|
||||
3. Types a description of what they need in a chat-style input: *"I need a script that pulls all GPOs linked to a domain, showing name, link location, and enforcement status"*
|
||||
4. AI responds in the conversation with an explanation and a collapsed code preview (first few lines + line count)
|
||||
5. Inline action buttons on each code block: **"View Full Script"**, **"Copy"**, **"Save to Library"**
|
||||
6. **"View Full Script"** opens a fullscreen modal overlay with the complete syntax-highlighted script, Copy/Save buttons in the header, line count and metadata in the footer, and a "Close & Return to Chat" button
|
||||
7. User can type follow-up messages to refine: *"Add CSV export"*, *"Handle multiple domains"* — AI responds with an updated script
|
||||
8. **"Save to Library"** opens a dialog for: name, description (optional), category, and whether to share with team
|
||||
|
||||
### Sidebar Navigation
|
||||
|
||||
- **Label:** "Script Builder"
|
||||
- **Section:** "Knowledge" group (alongside Flows, Step Library, Scripts)
|
||||
- **Icon:** TBD (a Lucide icon that conveys "build/generate" — e.g., `Wand2`, `Sparkles`, or `Terminal`)
|
||||
- **Mobile nav:** Also add to `mobileNavItems` array in `AppLayout.tsx`
|
||||
|
||||
### Frontend Architecture
|
||||
|
||||
| Component | Purpose |
|
||||
|-----------|---------|
|
||||
| `ScriptBuilderPage.tsx` | New page at `/script-builder`. Chat-style layout with language selector |
|
||||
| `ScriptBuilderChat.tsx` | Chat message list — user messages (right-aligned, cyan tint) and AI responses (left-aligned, glass card) |
|
||||
| `ScriptBuilderInput.tsx` | Chat input bar at bottom with send button |
|
||||
| `ScriptCodeBlock.tsx` | Collapsed code preview in AI messages with View/Copy/Save buttons |
|
||||
| `ScriptPreviewModal.tsx` | Fullscreen modal overlay for viewing complete script with syntax highlighting |
|
||||
| `SaveToLibraryDialog.tsx` | Dialog for naming and categorizing a script before saving |
|
||||
| Route in `router.tsx` | `/script-builder` inside `ProtectedRoute`/`AppLayout` children |
|
||||
|
||||
### Backend Architecture
|
||||
|
||||
#### New Service: `script_builder_service.py`
|
||||
|
||||
Location: `backend/app/services/script_builder_service.py`
|
||||
|
||||
Responsibilities:
|
||||
- Manages AI conversation for script generation
|
||||
- Language-specific system prompts with syntax knowledge, best practices, error handling patterns, security considerations
|
||||
- Multi-turn conversation support — maintains message history per session
|
||||
- Extracts generated script from AI response (parses code fences)
|
||||
- On save: auto-detects parameters from the generated script to populate `parameters_schema` (reuses existing `ParameterCandidate` analysis pattern)
|
||||
|
||||
Uses the existing `AnthropicProvider` with `standard` model tier (Sonnet) via `settings.get_model_for_action()`.
|
||||
|
||||
#### Syntax Highlighting
|
||||
|
||||
Use `react-syntax-highlighter` with the `oneDark` theme (already a common choice in React ecosystems, lightweight). Supports PowerShell, Bash, and Python out of the box. Used for both the inline collapsed code preview (first 5 lines shown) and the fullscreen modal.
|
||||
|
||||
#### Inline Code Preview
|
||||
|
||||
The collapsed code block in AI chat messages shows:
|
||||
- Filename (auto-generated by AI, e.g., `Get-LinkedGPOs.ps1`)
|
||||
- First 5 lines of the script with syntax highlighting
|
||||
- Total line count (e.g., "42 lines")
|
||||
- Clicking anywhere on the code block opens the fullscreen modal (same as "View Full Script" button)
|
||||
|
||||
#### New Model: `ScriptBuilderSession`
|
||||
|
||||
Location: `backend/app/models/script_builder_session.py`
|
||||
|
||||
| Column | Type | Description |
|
||||
|--------|------|-------------|
|
||||
| `id` | UUID | Primary key |
|
||||
| `user_id` | UUID FK | Creator |
|
||||
| `team_id` | UUID FK (nullable) | Team context |
|
||||
| `language` | String(30) | Selected language (powershell, bash, python) |
|
||||
| `title` | String(200, nullable) | Auto-generated from first message |
|
||||
| `messages` | JSONB | Array of `{role, content, timestamp}` |
|
||||
| `latest_script` | Text (nullable) | Most recent generated script (for quick access) |
|
||||
| `ai_session_id` | UUID FK → `ai_sessions.id` (nullable) | Link to FlowPilot session if launched from there |
|
||||
| `created_at` | DateTime(tz) | |
|
||||
| `updated_at` | DateTime(tz) | |
|
||||
|
||||
#### New Endpoints: `/scripts/builder/`
|
||||
|
||||
| Method | Endpoint | Purpose |
|
||||
|--------|----------|---------|
|
||||
| `POST` | `/scripts/builder/sessions` | Start a new builder session. Body: `{ language }`. Returns session with ID |
|
||||
| `POST` | `/scripts/builder/sessions/{id}/messages` | Send user message, get AI response with generated script. Body: `{ content }`. Returns `{ message, script, script_filename, line_count }` |
|
||||
| `GET` | `/scripts/builder/sessions` | List user's recent builder sessions (paginated). Returns lightweight schema **without** `messages` array — only `id`, `title`, `language`, `created_at`, `updated_at` |
|
||||
| `GET` | `/scripts/builder/sessions/{id}` | Get full session with message history (for resuming). Returns complete `messages` JSONB |
|
||||
| `POST` | `/scripts/builder/sessions/{id}/save` | Save latest script to library. Body: `SaveToLibraryRequest { name, description?, category_id?, share_with_team? }`. Service layer pulls `script_body` from session's `latest_script` and combines with user-provided metadata to create `ScriptTemplate`. Uses a dedicated `SaveToLibraryRequest` schema (not `ScriptTemplateCreate`) |
|
||||
| `DELETE` | `/scripts/builder/sessions/{id}` | Delete a builder session. Owner only. |
|
||||
|
||||
#### Language-Specific System Prompts
|
||||
|
||||
Each language gets a tailored system prompt injected into the AI conversation:
|
||||
|
||||
- **PowerShell:** Advanced function patterns, `param()` blocks, `CmdletBinding`, module imports, error handling with `try/catch/finally`, `Write-Verbose`/`Write-Error`, pipeline support, `.SYNOPSIS`/`.DESCRIPTION` comment-based help
|
||||
- **Bash:** Shebang lines, `set -euo pipefail`, argument parsing with `getopts` or positional params, error handling, shellcheck-clean output, POSIX compatibility notes
|
||||
- **Python:** Type hints, argparse for CLI scripts, `if __name__ == "__main__"` guard, logging module, docstrings, virtual environment considerations
|
||||
|
||||
### Frontend API Client
|
||||
|
||||
New module: `frontend/src/api/scriptBuilder.ts`
|
||||
|
||||
```typescript
|
||||
export const scriptBuilderApi = {
|
||||
createSession(language: string): Promise<ScriptBuilderSession>,
|
||||
sendMessage(sessionId: string, content: string): Promise<ScriptBuilderMessage>,
|
||||
listSessions(params?: PaginationParams): Promise<PaginatedResponse<ScriptBuilderSession>>,
|
||||
getSession(sessionId: string): Promise<ScriptBuilderSessionDetail>,
|
||||
saveToLibrary(sessionId: string, data: SaveToLibraryRequest): Promise<ScriptTemplateDetail>,
|
||||
}
|
||||
```
|
||||
|
||||
### FlowPilot Integration
|
||||
|
||||
When FlowPilot detects the user needs a custom script (no matching template):
|
||||
|
||||
1. FlowPilot responds with a step: *"It sounds like you need a custom script for this. I can help you build one."*
|
||||
2. The step includes an action button: **"Open Script Builder"**
|
||||
3. Clicking stores context in `sessionStorage` (`{ from_session, prompt, language }`) and opens `/script-builder?from=flowpilot` in a **new browser tab**. The Script Builder reads and clears `sessionStorage` on mount. This avoids URL length limits for long prompts.
|
||||
4. The `ScriptBuilderSession` stores the `ai_session_id` for traceability
|
||||
5. The FlowPilot session continues independently
|
||||
|
||||
Existing template-based flow (`InSessionScriptGenerator`) remains unchanged for when FlowPilot matches an existing template.
|
||||
|
||||
---
|
||||
|
||||
## Feature 3: Script Library Reorganization
|
||||
|
||||
### Tab Structure
|
||||
|
||||
Two tabs at the top of the Script Library page (`/scripts`):
|
||||
|
||||
- **My Scripts** — scripts where `created_by == current_user`. Includes both AI-generated saves and manually created templates.
|
||||
- **Team Scripts** — scripts where the template is shared to the team (`team_id == current_team`, shared by any team member).
|
||||
|
||||
### "Build a New Script" Button
|
||||
|
||||
A prominent action button at the top of the library page (next to the tab bar or in the header area). Styled with `bg-gradient-brand` (primary CTA). Clicking routes to `/script-builder`.
|
||||
|
||||
### Data Model Changes
|
||||
|
||||
The existing `ScriptTemplate` model needs two additions:
|
||||
|
||||
- **`language` column** (String(30), nullable, default `'powershell'`): Stores the script language. Without this, saved scripts lose their language metadata and can't be filtered by language in the library. Added via migration.
|
||||
- **Default "AI Generated" category**: The existing `category_id` column is `NOT NULL` with a `RESTRICT` FK. Rather than making it nullable (which would break existing queries), create a default "AI Generated" category via migration seed data. The save dialog auto-selects this category when no other is chosen.
|
||||
|
||||
Existing fields used for library filtering:
|
||||
|
||||
- `created_by` (user ID) — used for "My Scripts" filter
|
||||
- `team_id` (team ID, nullable) — used for "Team Scripts" filter
|
||||
- Team sharing toggle already exists
|
||||
|
||||
The frontend filtering changes:
|
||||
|
||||
- **My Scripts tab:** `GET /scripts/templates?mine=true` (new query param, filters by `created_by`)
|
||||
- **Team Scripts tab:** `GET /scripts/templates?shared=true` (existing behavior, filters by `team_id`)
|
||||
|
||||
### Library Backend Changes
|
||||
|
||||
Add `mine` query parameter to `GET /scripts/templates` endpoint. When `mine=true`, filter by `created_by == current_user.id` regardless of team sharing status.
|
||||
|
||||
### Save to Library Dialog
|
||||
|
||||
When triggered from the Script Builder's "Save to Library" button:
|
||||
|
||||
| Field | Type | Required | Notes |
|
||||
|-------|------|----------|-------|
|
||||
| Name | Text input | Yes | Auto-suggested from AI-generated filename |
|
||||
| Description | Textarea | No | Auto-suggested from AI's explanation |
|
||||
| Category | Select dropdown | No | Defaults to "AI Generated" category if not selected |
|
||||
| Share with team | Toggle | No | Default off. Shares to user's team |
|
||||
|
||||
On save, the backend:
|
||||
|
||||
1. Creates a `ScriptTemplate` with the generated script as `script_body` (pulled from session's `latest_script`)
|
||||
2. Sets `language` from the builder session's language
|
||||
3. Auto-detects parameters from the script to populate `parameters_schema`
|
||||
4. Sets `created_by` to current user
|
||||
5. Sets `category_id` to selected category or default "AI Generated" category
|
||||
6. If "Share with team" is on, sets `team_id` to user's team
|
||||
|
||||
---
|
||||
|
||||
## Implementation Phases
|
||||
|
||||
### Phase 1: Always-Visible Message Bar
|
||||
|
||||
- Remove free text escape hatch from `FlowPilotStepCard`
|
||||
- Create `FlowPilotMessageBar` component
|
||||
- Add to `FlowPilotSession` layout
|
||||
- No backend changes
|
||||
|
||||
### Phase 2: Script Builder Core
|
||||
|
||||
- New `ScriptBuilderSession` model + migration (includes `language` column on `ScriptTemplate` + "AI Generated" seed category)
|
||||
- `script_builder_service.py` with language-specific prompts
|
||||
- API endpoints for sessions and messages
|
||||
- Frontend: `ScriptBuilderPage`, chat components, code block with syntax highlighting via `react-syntax-highlighter`
|
||||
- Sidebar nav entry (desktop + mobile)
|
||||
- Route setup
|
||||
|
||||
### Phase 3: Script Preview Modal & Save Flow
|
||||
|
||||
- `ScriptPreviewModal` fullscreen overlay
|
||||
- `SaveToLibraryDialog` with auto-parameter detection
|
||||
- Backend save endpoint (creates `ScriptTemplate` via `SaveToLibraryRequest` schema)
|
||||
|
||||
### Phase 4: Library Reorganization
|
||||
|
||||
- My Scripts / Team Scripts tabs
|
||||
- "Build a New Script" button routing to Script Builder
|
||||
- Backend `mine` filter on templates endpoint
|
||||
|
||||
### Phase 5: FlowPilot Integration
|
||||
|
||||
- FlowPilot prompt updates to detect custom script needs
|
||||
- "Open Script Builder" action button in step cards
|
||||
- Context pre-fill via `sessionStorage`
|
||||
- `ai_session_id` linking
|
||||
|
||||
---
|
||||
|
||||
## Operational Constraints
|
||||
|
||||
### Rate Limiting & Token Budget
|
||||
|
||||
- **Max messages per session:** 30 (same safety limit as FlowPilot sessions)
|
||||
- **Max concurrent builder sessions per user:** 5 active sessions
|
||||
- **Rate limit:** 10 messages per minute per user (uses existing `@limiter.limit()` decorator, disabled when `DEBUG=True`)
|
||||
- **Context window management:** When conversation history exceeds ~80% of the model's context window, the service truncates older messages (keeping the system prompt and last 10 exchanges). The `latest_script` field ensures the most recent script is always accessible even if early messages are truncated.
|
||||
|
||||
### Session Lifecycle
|
||||
|
||||
- Builder sessions have no explicit expiration — they persist until the user deletes them
|
||||
- The list endpoint returns sessions ordered by `updated_at` descending
|
||||
- A "New Conversation" button on the Script Builder page starts a fresh session
|
||||
- Recent sessions are accessible via a collapsible sidebar/drawer on the Script Builder page (not a separate page)
|
||||
- Session title is auto-generated after the first AI response (AI summarizes the intent)
|
||||
|
||||
## Error Handling
|
||||
|
||||
### AI Failures
|
||||
|
||||
| Scenario | Handling |
|
||||
| ---- | ---- |
|
||||
| AI response has no code block | Display the AI's text response normally. Show a subtle hint: "No script was generated. Try being more specific about what you need." |
|
||||
| AI request times out (120s) | Show error toast: "Script generation timed out. Please try again." Keep conversation intact so user can retry. |
|
||||
| AI returns malformed/unparseable response | Log the raw response for debugging. Show generic error: "Something went wrong generating your script. Please try again." |
|
||||
| Context window exceeded | Auto-truncate older messages and retry once. If still fails, prompt user to start a new session. |
|
||||
| `AnthropicProvider` rate limited (429) | Show error with retry-after hint: "AI service is busy. Please wait a moment and try again." |
|
||||
|
||||
### Save Failures
|
||||
|
||||
| Scenario | Handling |
|
||||
| ---- | ---- |
|
||||
| No `latest_script` on session | Disable "Save to Library" button. Show tooltip: "Generate a script first." |
|
||||
| `category_id` constraint violation | Should not occur (default "AI Generated" category). If it does, return 400 with clear message. |
|
||||
| Duplicate template name | Return 409 conflict. Dialog shows inline error: "A script with this name already exists." |
|
||||
|
||||
---
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- Script execution/testing within ResolutionFlow (scripts are generated and copied, not run)
|
||||
- Script version history (save creates a new template, no versioning on iterations)
|
||||
- Marketplace/community script sharing beyond team level
|
||||
- Languages beyond PowerShell, Bash, Python (extensible later)
|
||||
- Script Builder conversation branching (linear conversation only)
|
||||
143
frontend/package-lock.json
generated
143
frontend/package-lock.json
generated
@@ -18,6 +18,7 @@
|
||||
"@sentry/vite-plugin": "^5.1.1",
|
||||
"@stripe/stripe-js": "^8.7.0",
|
||||
"@tailwindcss/vite": "^4.2.1",
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
"@xyflow/react": "^12.10.0",
|
||||
"axios": "^1.13.4",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
@@ -33,6 +34,7 @@
|
||||
"react-helmet-async": "^3.0.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-router-dom": "^7.13.0",
|
||||
"react-syntax-highlighter": "^16.1.1",
|
||||
"recharts": "^3.7.0",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
@@ -362,7 +364,6 @@
|
||||
"version": "7.28.6",
|
||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz",
|
||||
"integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
@@ -2991,6 +2992,12 @@
|
||||
"undici-types": "~7.16.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/prismjs": {
|
||||
"version": "1.26.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/prismjs/-/prismjs-1.26.6.tgz",
|
||||
"integrity": "sha512-vqlvI7qlMvcCBbVe0AKAb4f97//Hy0EBTaiW8AalRnG/xAN5zOiWWyrNqNXeq8+KAuvRewjCVY1+IPxk4RdNYw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/react": {
|
||||
"version": "19.2.10",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.10.tgz",
|
||||
@@ -3010,6 +3017,15 @@
|
||||
"@types/react": "^19.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/react-syntax-highlighter": {
|
||||
"version": "15.5.13",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-syntax-highlighter/-/react-syntax-highlighter-15.5.13.tgz",
|
||||
"integrity": "sha512-uLGJ87j6Sz8UaBAooU0T6lWJ0dBmjZgN1PZTrj05TNql2/XpC6+4HhMT5syIdFUUt+FASfCeLLv4kBygNU+8qA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/react": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/trusted-types": {
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
|
||||
@@ -4837,6 +4853,19 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fault": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/fault/-/fault-1.0.4.tgz",
|
||||
"integrity": "sha512-CJ0HCB5tL5fYTEA7ToAq5+kTwd++Borf1/bifxd9iT70QcXr4MRrO3Llf8Ifs70q+SJcGHFtnIE/Nw6giCtECA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"format": "^0.2.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/fdir": {
|
||||
"version": "6.5.0",
|
||||
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
||||
@@ -4946,6 +4975,14 @@
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/format": {
|
||||
"version": "0.2.2",
|
||||
"resolved": "https://registry.npmjs.org/format/-/format-0.2.2.tgz",
|
||||
"integrity": "sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww==",
|
||||
"engines": {
|
||||
"node": ">=0.4.x"
|
||||
}
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||
@@ -5171,6 +5208,19 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/hast-util-parse-selector": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/hast-util-parse-selector/-/hast-util-parse-selector-4.0.0.tgz",
|
||||
"integrity": "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/hast": "^3.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/hast-util-to-jsx-runtime": {
|
||||
"version": "2.3.6",
|
||||
"resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz",
|
||||
@@ -5211,6 +5261,23 @@
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/hastscript": {
|
||||
"version": "9.0.1",
|
||||
"resolved": "https://registry.npmjs.org/hastscript/-/hastscript-9.0.1.tgz",
|
||||
"integrity": "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/hast": "^3.0.0",
|
||||
"comma-separated-tokens": "^2.0.0",
|
||||
"hast-util-parse-selector": "^4.0.0",
|
||||
"property-information": "^7.0.0",
|
||||
"space-separated-tokens": "^2.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/hermes-estree": {
|
||||
"version": "0.25.1",
|
||||
"resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz",
|
||||
@@ -5228,6 +5295,21 @@
|
||||
"hermes-estree": "0.25.1"
|
||||
}
|
||||
},
|
||||
"node_modules/highlight.js": {
|
||||
"version": "10.7.3",
|
||||
"resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-10.7.3.tgz",
|
||||
"integrity": "sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==",
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/highlightjs-vue": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/highlightjs-vue/-/highlightjs-vue-1.0.0.tgz",
|
||||
"integrity": "sha512-PDEfEF102G23vHmPhLyPboFCD+BkMGu+GuJe2d9/eH4FsCwvgBpnc9n0pGE+ffKdph38s6foEZiEjdgHdzp+IA==",
|
||||
"license": "CC0-1.0"
|
||||
},
|
||||
"node_modules/html-encoding-sniffer": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz",
|
||||
@@ -5998,6 +6080,20 @@
|
||||
"loose-envify": "cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/lowlight": {
|
||||
"version": "1.20.0",
|
||||
"resolved": "https://registry.npmjs.org/lowlight/-/lowlight-1.20.0.tgz",
|
||||
"integrity": "sha512-8Ktj+prEb1RoCPkEOrPMYUN/nCggB7qAWe3a7OpMjWQkh3l2RD5wKRQ+o8Q8YuI9RG/xs95waaI/E6ym/7NsTw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fault": "^1.0.0",
|
||||
"highlight.js": "~10.7.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/lru-cache": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
|
||||
@@ -7196,6 +7292,15 @@
|
||||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/prismjs": {
|
||||
"version": "1.30.0",
|
||||
"resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz",
|
||||
"integrity": "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/progress": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz",
|
||||
@@ -7428,6 +7533,26 @@
|
||||
"react-dom": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/react-syntax-highlighter": {
|
||||
"version": "16.1.1",
|
||||
"resolved": "https://registry.npmjs.org/react-syntax-highlighter/-/react-syntax-highlighter-16.1.1.tgz",
|
||||
"integrity": "sha512-PjVawBGy80C6YbC5DDZJeUjBmC7skaoEUdvfFQediQHgCL7aKyVHe57SaJGfQsloGDac+gCpTfRdtxzWWKmCXA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.28.4",
|
||||
"highlight.js": "^10.4.1",
|
||||
"highlightjs-vue": "^1.0.0",
|
||||
"lowlight": "^1.17.0",
|
||||
"prismjs": "^1.30.0",
|
||||
"refractor": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 16.20.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">= 0.14.0"
|
||||
}
|
||||
},
|
||||
"node_modules/recharts": {
|
||||
"version": "3.7.0",
|
||||
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.7.0.tgz",
|
||||
@@ -7497,6 +7622,22 @@
|
||||
"redux": "^5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/refractor": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/refractor/-/refractor-5.0.0.tgz",
|
||||
"integrity": "sha512-QXOrHQF5jOpjjLfiNk5GFnWhRXvxjUVnlFxkeDmewR5sXkr3iM46Zo+CnRR8B+MDVqkULW4EcLVcRBNOPXHosw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/hast": "^3.0.0",
|
||||
"@types/prismjs": "^1.0.0",
|
||||
"hastscript": "^9.0.0",
|
||||
"parse-entities": "^4.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/remark-parse": {
|
||||
"version": "11.0.0",
|
||||
"resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz",
|
||||
|
||||
@@ -31,6 +31,7 @@
|
||||
"@sentry/vite-plugin": "^5.1.1",
|
||||
"@stripe/stripe-js": "^8.7.0",
|
||||
"@tailwindcss/vite": "^4.2.1",
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
"@xyflow/react": "^12.10.0",
|
||||
"axios": "^1.13.4",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
@@ -46,6 +47,7 @@
|
||||
"react-helmet-async": "^3.0.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-router-dom": "^7.13.0",
|
||||
"react-syntax-highlighter": "^16.1.1",
|
||||
"recharts": "^3.7.0",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
@@ -62,8 +64,8 @@
|
||||
"@types/node": "^24.10.9",
|
||||
"@types/react": "^19.2.5",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitest/coverage-v8": "^4.0.18",
|
||||
"@vitejs/plugin-react": "^5.1.1",
|
||||
"@vitest/coverage-v8": "^4.0.18",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.4.24",
|
||||
|
||||
@@ -96,6 +96,12 @@ export const aiSessionsApi = {
|
||||
await apiClient.post(`/ai-sessions/${sessionId}/resume`)
|
||||
},
|
||||
|
||||
async abandonSession(sessionId: string, reason?: string): Promise<void> {
|
||||
await apiClient.post(`/ai-sessions/${sessionId}/abandon`, null, {
|
||||
params: reason ? { reason } : undefined,
|
||||
})
|
||||
},
|
||||
|
||||
async pickupSession(sessionId: string, data: PickupSessionRequest): Promise<StepResponseResponse> {
|
||||
const response = await apiClient.post<StepResponseResponse>(
|
||||
`/ai-sessions/${sessionId}/pickup`,
|
||||
|
||||
@@ -30,3 +30,4 @@ export { flowpilotAnalyticsApi } from './flowpilotAnalytics'
|
||||
export { notificationsApi } from './notifications'
|
||||
export { publicTemplatesApi } from './publicTemplates'
|
||||
export { uploadsApi, default as uploadsApiDefault } from './uploads'
|
||||
export { scriptBuilderApi } from './scriptBuilder'
|
||||
|
||||
47
frontend/src/api/scriptBuilder.ts
Normal file
47
frontend/src/api/scriptBuilder.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
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
|
||||
},
|
||||
}
|
||||
@@ -20,6 +20,8 @@ export const scriptsApi = {
|
||||
category_slug?: string
|
||||
search?: string
|
||||
tags?: string // Phase 3: comma-separated tag filter
|
||||
mine?: boolean
|
||||
shared?: boolean
|
||||
}): Promise<ScriptTemplateListItem[]> {
|
||||
const response = await apiClient.get<ScriptTemplateListItem[]>('/scripts/templates', { params })
|
||||
return response.data
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState } from 'react'
|
||||
import { CheckCircle2, ArrowUpRight, Pause } from 'lucide-react'
|
||||
import { CheckCircle2, ArrowUpRight, Pause, X } from 'lucide-react'
|
||||
import { EscalateModal } from './EscalateModal'
|
||||
import type { ResolveSessionRequest, EscalateSessionRequest, SessionDocumentation } from '@/types/ai-session'
|
||||
|
||||
@@ -12,6 +12,7 @@ interface FlowPilotActionBarProps {
|
||||
onResolve: (data: ResolveSessionRequest) => Promise<SessionDocumentation>
|
||||
onEscalate: (data: EscalateSessionRequest) => Promise<SessionDocumentation>
|
||||
onPause?: () => Promise<void>
|
||||
onAbandon?: () => Promise<void>
|
||||
}
|
||||
|
||||
export function FlowPilotActionBar({
|
||||
@@ -23,9 +24,11 @@ export function FlowPilotActionBar({
|
||||
onResolve,
|
||||
onEscalate,
|
||||
onPause,
|
||||
onAbandon,
|
||||
}: FlowPilotActionBarProps) {
|
||||
const [showResolve, setShowResolve] = useState(false)
|
||||
const [showEscalate, setShowEscalate] = useState(false)
|
||||
const [showAbandon, setShowAbandon] = useState(false)
|
||||
const [resolutionSummary, setResolutionSummary] = useState('')
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
@@ -51,6 +54,18 @@ export function FlowPilotActionBar({
|
||||
}
|
||||
}
|
||||
|
||||
const handleAbandon = async () => {
|
||||
if (onAbandon) {
|
||||
setSubmitting(true)
|
||||
try {
|
||||
await onAbandon()
|
||||
setShowAbandon(false)
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Bottom bar — fixed to viewport bottom, works regardless of height chain */}
|
||||
@@ -76,16 +91,28 @@ export function FlowPilotActionBar({
|
||||
Escalate
|
||||
</button>
|
||||
</div>
|
||||
{onPause && (
|
||||
<button
|
||||
onClick={handlePause}
|
||||
disabled={isProcessing || submitting}
|
||||
className="flex items-center justify-center gap-2 rounded-lg bg-[rgba(255,255,255,0.04)] border border-[rgba(255,255,255,0.06)] px-4 py-2 min-h-[44px] text-sm font-medium text-muted-foreground hover:text-foreground hover:border-[rgba(255,255,255,0.12)] disabled:opacity-40 disabled:pointer-events-none transition-colors sm:ml-auto"
|
||||
>
|
||||
<Pause size={16} />
|
||||
Pause
|
||||
</button>
|
||||
)}
|
||||
<div className="flex gap-2 sm:ml-auto">
|
||||
{onPause && (
|
||||
<button
|
||||
onClick={handlePause}
|
||||
disabled={isProcessing || submitting}
|
||||
className="flex items-center justify-center gap-2 rounded-lg bg-[rgba(255,255,255,0.04)] border border-[rgba(255,255,255,0.06)] px-4 py-2 min-h-[44px] text-sm font-medium text-muted-foreground hover:text-foreground hover:border-[rgba(255,255,255,0.12)] disabled:opacity-40 disabled:pointer-events-none transition-colors"
|
||||
>
|
||||
<Pause size={16} />
|
||||
Pause
|
||||
</button>
|
||||
)}
|
||||
{onAbandon && (
|
||||
<button
|
||||
onClick={() => setShowAbandon(true)}
|
||||
disabled={isProcessing || submitting}
|
||||
className="flex items-center justify-center gap-2 rounded-lg bg-[rgba(255,255,255,0.04)] border border-[rgba(255,255,255,0.06)] px-4 py-2 min-h-[44px] text-sm font-medium text-muted-foreground hover:text-foreground hover:border-[rgba(255,255,255,0.12)] disabled:opacity-40 disabled:pointer-events-none transition-colors"
|
||||
>
|
||||
<X size={16} />
|
||||
Close
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Resolve modal */}
|
||||
@@ -121,6 +148,33 @@ export function FlowPilotActionBar({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Close/Abandon confirmation */}
|
||||
{showAbandon && (
|
||||
<div className="fixed inset-0 z-50 flex items-end sm:items-center justify-center bg-black/60 backdrop-blur-sm">
|
||||
<div className="glass-card-static w-full max-w-full sm:max-w-lg mx-0 sm:mx-4 p-4 sm:p-6 rounded-t-2xl sm:rounded-2xl">
|
||||
<h3 className="font-heading text-lg font-semibold text-foreground mb-1">Close Session</h3>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Are you sure you want to close this session? The session history will be kept but it won't count as resolved.
|
||||
</p>
|
||||
<div className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
|
||||
<button
|
||||
onClick={() => setShowAbandon(false)}
|
||||
className="rounded-lg px-4 py-2 min-h-[44px] text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleAbandon}
|
||||
disabled={submitting}
|
||||
className="rounded-lg bg-rose-500/20 border border-rose-500/30 px-4 py-2 min-h-[44px] text-sm font-medium text-rose-400 hover:bg-rose-500/30 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{submitting ? 'Closing...' : 'Close Session'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Escalate modal */}
|
||||
<EscalateModal
|
||||
open={showEscalate}
|
||||
|
||||
82
frontend/src/components/flowpilot/FlowPilotMessageBar.tsx
Normal file
82
frontend/src/components/flowpilot/FlowPilotMessageBar.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
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('')
|
||||
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)
|
||||
const textarea = e.target
|
||||
textarea.style.height = 'auto'
|
||||
textarea.style.height = `${Math.min(textarea.scrollHeight, 120)}px`
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed bottom-[68px] right-0 lg:right-72 z-40 px-4 sm:px-6 lg:px-8 pb-2"
|
||||
style={{ left: 'var(--sidebar-w, 0px)' }}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-end gap-2 rounded-xl border p-3 transition-colors',
|
||||
isDisabled
|
||||
? 'border-border/50 opacity-50'
|
||||
: 'border-border focus-within:border-[rgba(6,182,212,0.3)]'
|
||||
)}
|
||||
style={{ background: 'rgba(16, 17, 20, 0.95)', backdropFilter: 'blur(16px)' }}
|
||||
>
|
||||
<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()}
|
||||
aria-label="Send message"
|
||||
className={cn(
|
||||
'flex h-9 w-9 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={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import type {
|
||||
import { ConfidenceIndicator } from './ConfidenceIndicator'
|
||||
import { FlowPilotStepCard } from './FlowPilotStepCard'
|
||||
import { FlowPilotActionBar } from './FlowPilotActionBar'
|
||||
import { FlowPilotMessageBar } from './FlowPilotMessageBar'
|
||||
import { SessionDocView } from './SessionDocView'
|
||||
import { SessionTicketCard } from './SessionTicketCard'
|
||||
import { SimilarSessions } from './SimilarSessions'
|
||||
@@ -35,6 +36,7 @@ interface FlowPilotSessionProps {
|
||||
onEscalate: (data: EscalateSessionRequest) => Promise<SessionDocumentation>
|
||||
onPause?: () => Promise<void>
|
||||
onResume?: () => Promise<void>
|
||||
onAbandon?: () => Promise<void>
|
||||
onRate: (rating: number) => void
|
||||
onReloadSession?: () => Promise<void>
|
||||
}
|
||||
@@ -55,6 +57,7 @@ export function FlowPilotSession({
|
||||
onEscalate,
|
||||
onPause,
|
||||
onResume,
|
||||
onAbandon,
|
||||
onRate,
|
||||
onReloadSession,
|
||||
}: FlowPilotSessionProps) {
|
||||
@@ -193,8 +196,8 @@ export function FlowPilotSession({
|
||||
|
||||
{/* Main content area: conversation + sidebar */}
|
||||
<div className="flex flex-1 min-h-0">
|
||||
{/* Conversation column — pb-20 provides clearance for the fixed action bar */}
|
||||
<div ref={scrollRef} className="flex-1 overflow-y-auto p-3 pb-20 sm:p-4 sm:pb-20 lg:p-6 lg:pb-20">
|
||||
{/* Conversation column — pb-32 provides clearance for the fixed message bar + action bar */}
|
||||
<div ref={scrollRef} className="flex-1 overflow-y-auto p-3 pb-32 sm:p-4 sm:pb-32 lg:p-6 lg:pb-32">
|
||||
<div className="mx-auto max-w-2xl space-y-3">
|
||||
{allSteps.map((step) => (
|
||||
<FlowPilotStepCard
|
||||
@@ -300,6 +303,15 @@ export function FlowPilotSession({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Message bar */}
|
||||
{session.status === 'active' && (
|
||||
<FlowPilotMessageBar
|
||||
onRespond={onRespond}
|
||||
isProcessing={isProcessing}
|
||||
disabled={currentStep?.allow_free_text === false}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Action bar */}
|
||||
{session.status === 'active' && (
|
||||
<FlowPilotActionBar
|
||||
@@ -311,6 +323,7 @@ export function FlowPilotSession({
|
||||
onResolve={onResolve}
|
||||
onEscalate={onEscalate}
|
||||
onPause={onPause}
|
||||
onAbandon={onAbandon}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -2,9 +2,8 @@ import { useState } from 'react'
|
||||
import { MessageSquare, Zap, CheckCircle2, SkipForward, ChevronDown, ChevronUp } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { AISessionStepResponse, StepResponseRequest } from '@/types/ai-session'
|
||||
import type { FileUploadResponse } from '@/types/upload'
|
||||
import { isScriptGenerationAction, isScriptBuilderAction, getActionType } from '@/types/ai-session'
|
||||
import { MarkdownContent } from '@/components/ui/MarkdownContent'
|
||||
import { RichTextInput } from '@/components/common/RichTextInput'
|
||||
import { FlowPilotOptions } from './FlowPilotOptions'
|
||||
import { InSessionScriptGenerator } from './InSessionScriptGenerator'
|
||||
|
||||
@@ -27,10 +26,7 @@ const STEP_TYPE_ICONS = {
|
||||
} as const
|
||||
|
||||
export function FlowPilotStepCard({ step, isCurrentStep, isProcessing, sessionId, onRespond }: FlowPilotStepCardProps) {
|
||||
const [freeText, setFreeText] = useState('')
|
||||
const [showFreeText, setShowFreeText] = useState(false)
|
||||
const [isCollapsed, setIsCollapsed] = useState(!isCurrentStep)
|
||||
const [_freeTextUploads, setFreeTextUploads] = useState<FileUploadResponse[]>([])
|
||||
|
||||
const content = step.content as Record<string, unknown>
|
||||
const stepText = (content.text as string) || ''
|
||||
@@ -42,13 +38,6 @@ export function FlowPilotStepCard({ step, isCurrentStep, isProcessing, sessionId
|
||||
onRespond({ selected_option: value })
|
||||
}
|
||||
|
||||
const handleFreeTextSubmit = () => {
|
||||
if (!freeText.trim()) return
|
||||
onRespond({ free_text_input: freeText.trim() })
|
||||
setFreeText('')
|
||||
setShowFreeText(false)
|
||||
}
|
||||
|
||||
const handleSkip = () => {
|
||||
onRespond({ was_skipped: true })
|
||||
}
|
||||
@@ -169,18 +158,36 @@ export function FlowPilotStepCard({ step, isCurrentStep, isProcessing, sessionId
|
||||
)}
|
||||
|
||||
{/* In-session script generator */}
|
||||
{!isResolutionSuggestion && (content.action_type as string) === 'script_generation' && sessionId && (
|
||||
{!isResolutionSuggestion && isScriptGenerationAction(content) && sessionId && (
|
||||
<InSessionScriptGenerator
|
||||
templateId={(content.template_id as string) || ''}
|
||||
preFilledParams={(content.pre_filled_params as Record<string, string>) || {}}
|
||||
instructions={(content.instructions as string) || stepText}
|
||||
templateId={content.template_id || ''}
|
||||
preFilledParams={content.pre_filled_params || {}}
|
||||
instructions={content.instructions || stepText}
|
||||
sessionId={sessionId}
|
||||
onRespond={onRespond}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Script Builder handoff */}
|
||||
{!isResolutionSuggestion && step.step_type === 'action' && isScriptBuilderAction(content) && (
|
||||
<button
|
||||
onClick={() => {
|
||||
sessionStorage.setItem('scriptBuilderContext', JSON.stringify({
|
||||
from_session: sessionId,
|
||||
prompt: content.script_prompt || '',
|
||||
language: content.script_language || 'powershell',
|
||||
}))
|
||||
window.open('/script-builder?from=flowpilot', '_blank')
|
||||
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>
|
||||
)}
|
||||
|
||||
{/* Action step buttons */}
|
||||
{!isResolutionSuggestion && step.step_type === 'action' && (content.action_type as string) !== 'script_generation' && (
|
||||
{!isResolutionSuggestion && step.step_type === 'action' && getActionType(content) !== 'script_generation' && getActionType(content) !== 'open_script_builder' && (
|
||||
<div className="flex flex-col gap-2 sm:flex-row">
|
||||
<button
|
||||
onClick={() => handleActionComplete(true)}
|
||||
@@ -197,46 +204,6 @@ export function FlowPilotStepCard({ step, isCurrentStep, isProcessing, sessionId
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Free text escape hatch */}
|
||||
{!isResolutionSuggestion && step.allow_free_text && (
|
||||
<>
|
||||
{!showFreeText ? (
|
||||
<button
|
||||
onClick={() => setShowFreeText(true)}
|
||||
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
None of these — let me describe
|
||||
</button>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<RichTextInput
|
||||
value={freeText}
|
||||
onChange={setFreeText}
|
||||
onFilesChange={setFreeTextUploads}
|
||||
sessionId={sessionId}
|
||||
placeholder="Describe what you're seeing..."
|
||||
rows={3}
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={handleFreeTextSubmit}
|
||||
disabled={!freeText.trim()}
|
||||
className="rounded-lg bg-gradient-brand px-4 py-1.5 text-sm font-semibold text-[#101114] hover:opacity-90 disabled:opacity-50 transition-opacity"
|
||||
>
|
||||
Submit
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setShowFreeText(false); setFreeText('') }}
|
||||
className="rounded-lg px-4 py-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Skip option */}
|
||||
{!isResolutionSuggestion && step.allow_skip && (
|
||||
<button
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import { useLocation, useNavigate, Link } from 'react-router-dom'
|
||||
import { Menu, X, LayoutGrid, Clock, Network, AlertTriangle, Code2, BarChart3, Settings, LogOut, Shield, Library } from 'lucide-react'
|
||||
import { Menu, X, LayoutGrid, Clock, Network, AlertTriangle, Code2, Wand2, BarChart3, Settings, LogOut, Shield, Library } from 'lucide-react'
|
||||
import { useAuthStore } from '@/store/authStore'
|
||||
import { usePermissions } from '@/hooks/usePermissions'
|
||||
import { useUserPreferencesStore } from '@/store/userPreferencesStore'
|
||||
@@ -57,6 +57,7 @@ export function AppLayout() {
|
||||
{ path: '/trees', label: 'Flows', icon: Network },
|
||||
{ path: '/step-library', label: 'Step Library', icon: Library },
|
||||
{ path: '/scripts', label: 'Scripts', icon: Code2 },
|
||||
{ path: '/script-builder', label: 'Script Builder', icon: Wand2 },
|
||||
{ path: '/analytics', label: 'Analytics', icon: BarChart3 },
|
||||
{ path: '/account', label: 'Account', icon: Settings },
|
||||
]
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useLocation } from 'react-router-dom'
|
||||
import {
|
||||
LayoutGrid, Network, Clock, FileOutput, BarChart3, TrendingUp,
|
||||
Settings, PanelLeftClose, PanelLeftOpen, MessageSquareText, ListChecks,
|
||||
BookOpen, Code2, Library, AlertTriangle,
|
||||
BookOpen, Code2, Library, AlertTriangle, Wand2,
|
||||
} from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useUserPreferencesStore } from '@/store/userPreferencesStore'
|
||||
@@ -19,6 +19,7 @@ const NAV_COLORS = {
|
||||
exports: '#60a5fa', // blue-400
|
||||
stepLib: '#fb923c', // orange-400
|
||||
scripts: '#2dd4bf', // teal-400
|
||||
scriptBuilder: '#e879f9', // fuchsia-400
|
||||
analytics: '#38bdf8', // sky-400
|
||||
guides: '#a3e635', // lime-400
|
||||
feedback: '#818cf8', // indigo-400
|
||||
@@ -83,6 +84,7 @@ export function Sidebar() {
|
||||
<NavItem href="/trees" icon={Network} label="Flows" matchPaths={['/trees', '/flows', '/my-trees']} iconColor={NAV_COLORS.flows} collapsed />
|
||||
<NavItem href="/step-library" icon={Library} label="Step Library" iconColor={NAV_COLORS.stepLib} collapsed />
|
||||
<NavItem href="/scripts" icon={Code2} label="Scripts" iconColor={NAV_COLORS.scripts} collapsed />
|
||||
<NavItem href="/script-builder" icon={Wand2} label="Script Builder" iconColor={NAV_COLORS.scriptBuilder} collapsed />
|
||||
<NavItem href="/review-queue" icon={ListChecks} label="Review Queue" iconColor="#fbbf24" collapsed />
|
||||
<NavItem href="/shares" icon={FileOutput} label="Exports" iconColor={NAV_COLORS.exports} collapsed />
|
||||
<NavItem href="/analytics" icon={BarChart3} label="Analytics" iconColor={NAV_COLORS.analytics} collapsed />
|
||||
@@ -124,6 +126,7 @@ export function Sidebar() {
|
||||
/>
|
||||
<NavItem href="/step-library" icon={Library} label="Step Library" iconColor={NAV_COLORS.stepLib} />
|
||||
<NavItem href="/scripts" icon={Code2} label="Scripts" iconColor={NAV_COLORS.scripts} />
|
||||
<NavItem href="/script-builder" icon={Wand2} label="Script Builder" iconColor={NAV_COLORS.scriptBuilder} />
|
||||
<NavItem href="/review-queue" icon={ListChecks} label="Review Queue" iconColor="#fbbf24" />
|
||||
|
||||
{/* Insights */}
|
||||
|
||||
186
frontend/src/components/script-builder/SaveToLibraryDialog.tsx
Normal file
186
frontend/src/components/script-builder/SaveToLibraryDialog.tsx
Normal file
@@ -0,0 +1,186 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { X, Loader2 } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { scriptBuilderApi, scriptsApi } from '@/api'
|
||||
import type { ScriptCategoryResponse } from '@/types'
|
||||
|
||||
interface SaveToLibraryDialogProps {
|
||||
sessionId: string
|
||||
defaultName: string
|
||||
defaultDescription?: string
|
||||
onClose: () => void
|
||||
onSaved: () => void
|
||||
}
|
||||
|
||||
export function SaveToLibraryDialog({
|
||||
sessionId,
|
||||
defaultName,
|
||||
defaultDescription,
|
||||
onClose,
|
||||
onSaved,
|
||||
}: SaveToLibraryDialogProps) {
|
||||
const [name, setName] = useState(defaultName)
|
||||
const [description, setDescription] = useState(defaultDescription || '')
|
||||
const [categoryId, setCategoryId] = useState('')
|
||||
const [shareWithTeam, setShareWithTeam] = useState(false)
|
||||
const [categories, setCategories] = useState<ScriptCategoryResponse[]>([])
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
scriptsApi.getCategories().then(setCategories).catch(() => {})
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose()
|
||||
}
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
return () => document.removeEventListener('keydown', handleKeyDown)
|
||||
}, [onClose])
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!name.trim()) return
|
||||
|
||||
setIsSaving(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
await scriptBuilderApi.saveToLibrary(sessionId, {
|
||||
name: name.trim(),
|
||||
description: description.trim() || undefined,
|
||||
category_id: categoryId || undefined,
|
||||
share_with_team: shareWithTeam,
|
||||
})
|
||||
onSaved()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to save script. Please try again.')
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 bg-black/80 backdrop-blur-sm flex items-center justify-center"
|
||||
onClick={(e) => { if (e.target === e.currentTarget) onClose() }}
|
||||
>
|
||||
<div className="glass-card-static max-w-md w-full mx-4 rounded-xl overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-5 py-3.5 border-b border-[rgba(255,255,255,0.06)]">
|
||||
<h3 className="text-sm font-heading font-bold text-foreground">Save to Library</h3>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1.5 rounded-lg text-muted-foreground hover:text-foreground hover:bg-[rgba(255,255,255,0.06)] transition-colors"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<form onSubmit={handleSubmit} className="p-5 space-y-4">
|
||||
{/* Name */}
|
||||
<div>
|
||||
<label className="font-label text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground mb-1.5 block">
|
||||
Name *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
required
|
||||
className={cn(
|
||||
"w-full rounded-[10px] px-3 py-2 text-sm",
|
||||
"border border-border bg-card text-foreground placeholder:text-muted-foreground",
|
||||
"focus:outline-none focus:border-[rgba(6,182,212,0.3)] transition-colors"
|
||||
)}
|
||||
placeholder="Script name"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div>
|
||||
<label className="font-label text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground mb-1.5 block">
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
rows={3}
|
||||
className={cn(
|
||||
"w-full rounded-[10px] px-3 py-2 text-sm resize-none",
|
||||
"border border-border bg-card text-foreground placeholder:text-muted-foreground",
|
||||
"focus:outline-none focus:border-[rgba(6,182,212,0.3)] transition-colors"
|
||||
)}
|
||||
placeholder="What does this script do?"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Category */}
|
||||
<div>
|
||||
<label className="font-label text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground mb-1.5 block">
|
||||
Category
|
||||
</label>
|
||||
<select
|
||||
value={categoryId}
|
||||
onChange={(e) => setCategoryId(e.target.value)}
|
||||
className={cn(
|
||||
"w-full rounded-[10px] px-3 py-2 text-sm",
|
||||
"border border-border bg-card text-foreground",
|
||||
"focus:outline-none focus:border-[rgba(6,182,212,0.3)] transition-colors"
|
||||
)}
|
||||
>
|
||||
<option value="">No category</option>
|
||||
{categories.map((cat) => (
|
||||
<option key={cat.id} value={cat.id}>{cat.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Share with team */}
|
||||
<label className="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={shareWithTeam}
|
||||
onChange={(e) => setShareWithTeam(e.target.checked)}
|
||||
className="w-4 h-4 rounded border-border bg-card text-cyan-500 focus:ring-cyan-500/20"
|
||||
/>
|
||||
<span className="text-sm text-foreground">Share with team</span>
|
||||
</label>
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<p className="text-xs text-rose-400">{error}</p>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center justify-end gap-2 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className={cn(
|
||||
"px-4 py-2 rounded-[10px] text-sm font-medium transition-colors",
|
||||
"bg-[rgba(255,255,255,0.04)] border border-[rgba(255,255,255,0.06)] text-foreground hover:border-[rgba(255,255,255,0.12)]"
|
||||
)}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!name.trim() || isSaving}
|
||||
className={cn(
|
||||
"flex items-center gap-2 px-4 py-2 rounded-[10px] text-sm font-semibold transition-all",
|
||||
"bg-gradient-brand text-[#101114] hover:opacity-90 active:scale-[0.97]",
|
||||
"disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
)}
|
||||
>
|
||||
{isSaving && <Loader2 size={14} className="animate-spin" />}
|
||||
{isSaving ? 'Saving...' : 'Save to Library'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
114
frontend/src/components/script-builder/ScriptBuilderChat.tsx
Normal file
114
frontend/src/components/script-builder/ScriptBuilderChat.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { Bot, User, Loader2 } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { MarkdownContent } from '@/components/ui/MarkdownContent'
|
||||
import { ScriptCodeBlock } from './ScriptCodeBlock'
|
||||
import type { ScriptBuilderMessage } from '@/types'
|
||||
|
||||
interface ScriptBuilderChatProps {
|
||||
messages: ScriptBuilderMessage[]
|
||||
language: string
|
||||
onViewScript: (script: string, filename: string | null) => void
|
||||
onSaveScript: () => void
|
||||
isLoading: boolean
|
||||
}
|
||||
|
||||
export function ScriptBuilderChat({
|
||||
messages,
|
||||
language,
|
||||
onViewScript,
|
||||
onSaveScript,
|
||||
isLoading,
|
||||
}: ScriptBuilderChatProps) {
|
||||
const bottomRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
bottomRef.current?.scrollIntoView({ behavior: 'smooth' })
|
||||
}, [messages.length, isLoading])
|
||||
|
||||
if (messages.length === 0 && !isLoading) {
|
||||
return (
|
||||
<div className="flex-1 flex items-center justify-center p-8">
|
||||
<div className="text-center max-w-md">
|
||||
<div className="w-14 h-14 rounded-2xl bg-gradient-brand flex items-center justify-center mx-auto mb-4">
|
||||
<Bot size={28} className="text-[#101114]" />
|
||||
</div>
|
||||
<h2 className="text-lg font-heading font-bold text-foreground mb-2">
|
||||
Script Builder
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||
Describe the script you need and AI will generate it for you. You can iterate on the script,
|
||||
preview it, and save it to your library.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-4">
|
||||
{messages.map((msg, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className={cn(
|
||||
"flex gap-3",
|
||||
msg.role === 'user' ? "justify-end" : "justify-start"
|
||||
)}
|
||||
>
|
||||
{msg.role === 'assistant' && (
|
||||
<div className="shrink-0 w-8 h-8 rounded-lg bg-[rgba(6,182,212,0.1)] flex items-center justify-center mt-0.5">
|
||||
<Bot size={16} className="text-cyan-400" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"max-w-[85%] rounded-xl px-4 py-3 text-sm",
|
||||
msg.role === 'user'
|
||||
? "bg-[rgba(6,182,212,0.08)] border border-[rgba(6,182,212,0.15)] text-foreground"
|
||||
: "glass-card-static"
|
||||
)}
|
||||
>
|
||||
{msg.role === 'assistant' ? (
|
||||
<>
|
||||
<MarkdownContent content={msg.content} />
|
||||
{msg.script && (
|
||||
<ScriptCodeBlock
|
||||
script={msg.script}
|
||||
filename={msg.script_filename ?? null}
|
||||
lineCount={msg.line_count ?? null}
|
||||
language={language}
|
||||
onViewFull={() => onViewScript(msg.script!, msg.script_filename ?? null)}
|
||||
onSave={onSaveScript}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<p className="whitespace-pre-wrap">{msg.content}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{msg.role === 'user' && (
|
||||
<div className="shrink-0 w-8 h-8 rounded-lg bg-[rgba(255,255,255,0.06)] flex items-center justify-center mt-0.5">
|
||||
<User size={16} className="text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{isLoading && (
|
||||
<div className="flex gap-3 justify-start">
|
||||
<div className="shrink-0 w-8 h-8 rounded-lg bg-[rgba(6,182,212,0.1)] flex items-center justify-center">
|
||||
<Bot size={16} className="text-cyan-400" />
|
||||
</div>
|
||||
<div className="glass-card-static rounded-xl px-4 py-3 text-sm flex items-center gap-2">
|
||||
<Loader2 size={14} className="animate-spin text-cyan-400" />
|
||||
<span className="text-muted-foreground">Generating script...</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div ref={bottomRef} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { useState, useRef, useCallback, useEffect } from 'react'
|
||||
import { Send } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface ScriptBuilderInputProps {
|
||||
onSend: (content: string) => void
|
||||
disabled: boolean
|
||||
placeholder?: string
|
||||
}
|
||||
|
||||
export function ScriptBuilderInput({
|
||||
onSend,
|
||||
disabled,
|
||||
placeholder = 'Describe the script you need...',
|
||||
}: ScriptBuilderInputProps) {
|
||||
const [value, setValue] = useState('')
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
|
||||
const adjustHeight = useCallback(() => {
|
||||
const textarea = textareaRef.current
|
||||
if (!textarea) return
|
||||
textarea.style.height = 'auto'
|
||||
textarea.style.height = `${Math.min(textarea.scrollHeight, 120)}px`
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
adjustHeight()
|
||||
}, [value, adjustHeight])
|
||||
|
||||
const handleSend = () => {
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed || disabled) return
|
||||
onSend(trimmed)
|
||||
setValue('')
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
handleSend()
|
||||
}
|
||||
}
|
||||
|
||||
const canSend = value.trim().length > 0 && !disabled
|
||||
|
||||
return (
|
||||
<div className="flex items-end gap-2 p-3 border-t" style={{ borderColor: 'var(--glass-border)' }}>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
rows={1}
|
||||
className={cn(
|
||||
"flex-1 resize-none rounded-xl px-4 py-2.5 text-sm",
|
||||
"bg-card border border-border text-foreground placeholder:text-muted-foreground",
|
||||
"focus:outline-none focus:border-[rgba(6,182,212,0.3)] transition-colors",
|
||||
"disabled:opacity-50"
|
||||
)}
|
||||
style={{ maxHeight: 120 }}
|
||||
/>
|
||||
<button
|
||||
onClick={handleSend}
|
||||
disabled={!canSend}
|
||||
className={cn(
|
||||
"shrink-0 flex items-center justify-center w-10 h-10 rounded-xl transition-all",
|
||||
canSend
|
||||
? "bg-gradient-brand text-[#101114] hover:opacity-90 active:scale-[0.97]"
|
||||
: "bg-[rgba(255,255,255,0.04)] text-[#5a6170] cursor-not-allowed"
|
||||
)}
|
||||
>
|
||||
<Send size={18} />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
129
frontend/src/components/script-builder/ScriptCodeBlock.tsx
Normal file
129
frontend/src/components/script-builder/ScriptCodeBlock.tsx
Normal file
@@ -0,0 +1,129 @@
|
||||
import { useState } from 'react'
|
||||
import { Light as SyntaxHighlighter } from 'react-syntax-highlighter'
|
||||
import powershell from 'react-syntax-highlighter/dist/esm/languages/hljs/powershell'
|
||||
import bash from 'react-syntax-highlighter/dist/esm/languages/hljs/bash'
|
||||
import python from 'react-syntax-highlighter/dist/esm/languages/hljs/python'
|
||||
import atomOneDark from 'react-syntax-highlighter/dist/esm/styles/hljs/atom-one-dark'
|
||||
import { Eye, Copy, Check, BookmarkPlus } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
SyntaxHighlighter.registerLanguage('powershell', powershell)
|
||||
SyntaxHighlighter.registerLanguage('bash', bash)
|
||||
SyntaxHighlighter.registerLanguage('python', python)
|
||||
|
||||
const LANGUAGE_MAP: Record<string, string> = {
|
||||
powershell: 'powershell',
|
||||
bash: 'bash',
|
||||
python: 'python',
|
||||
}
|
||||
|
||||
interface ScriptCodeBlockProps {
|
||||
script: string
|
||||
filename: string | null
|
||||
lineCount: number | null
|
||||
language: string
|
||||
onViewFull: () => void
|
||||
onSave: () => void
|
||||
}
|
||||
|
||||
export function ScriptCodeBlock({
|
||||
script,
|
||||
filename,
|
||||
lineCount,
|
||||
language,
|
||||
onViewFull,
|
||||
onSave,
|
||||
}: ScriptCodeBlockProps) {
|
||||
const [copied, setCopied] = useState(false)
|
||||
const lines = script.split('\n')
|
||||
const previewLines = lines.slice(0, 5).join('\n')
|
||||
const remainingLines = lines.length - 5
|
||||
const hlLanguage = LANGUAGE_MAP[language] || 'powershell'
|
||||
|
||||
const handleCopy = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
try {
|
||||
await navigator.clipboard.writeText(script)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
} catch {
|
||||
// clipboard not available
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-3 rounded-lg border bg-[rgba(0,0,0,0.3)] border-[rgba(255,255,255,0.06)] overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-3 py-2 border-b border-[rgba(255,255,255,0.06)]">
|
||||
<span className="font-label text-xs text-cyan-400 truncate">
|
||||
{filename || 'script'}
|
||||
</span>
|
||||
{lineCount != null && (
|
||||
<span className="font-label text-[0.625rem] text-muted-foreground ml-2 shrink-0">
|
||||
{lineCount} lines
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Code preview — clickable */}
|
||||
<button
|
||||
onClick={onViewFull}
|
||||
className="block w-full text-left cursor-pointer hover:bg-[rgba(255,255,255,0.02)] transition-colors"
|
||||
>
|
||||
<SyntaxHighlighter
|
||||
language={hlLanguage}
|
||||
style={atomOneDark}
|
||||
customStyle={{
|
||||
background: 'transparent',
|
||||
padding: '12px',
|
||||
margin: 0,
|
||||
fontSize: '0.8125rem',
|
||||
lineHeight: '1.5',
|
||||
}}
|
||||
wrapLongLines
|
||||
>
|
||||
{previewLines}
|
||||
</SyntaxHighlighter>
|
||||
{remainingLines > 0 && (
|
||||
<div className="px-3 pb-2 font-label text-[0.625rem] text-[#5a6170]">
|
||||
{"··· "}{remainingLines} more line{remainingLines !== 1 ? 's' : ''}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="flex items-center gap-2 px-3 py-2 border-t border-[rgba(255,255,255,0.06)]">
|
||||
<button
|
||||
onClick={onViewFull}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 px-3 py-1.5 rounded-[10px] text-xs font-semibold transition-all",
|
||||
"bg-gradient-brand text-[#101114] hover:opacity-90 active:scale-[0.97]"
|
||||
)}
|
||||
>
|
||||
<Eye size={14} />
|
||||
View Full Script
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 px-3 py-1.5 rounded-[10px] text-xs font-medium transition-colors",
|
||||
"bg-[rgba(255,255,255,0.04)] border border-[rgba(255,255,255,0.06)] text-foreground hover:border-[rgba(255,255,255,0.12)]"
|
||||
)}
|
||||
>
|
||||
{copied ? <Check size={14} className="text-emerald-400" /> : <Copy size={14} />}
|
||||
{copied ? 'Copied' : 'Copy'}
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onSave() }}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 px-3 py-1.5 rounded-[10px] text-xs font-medium transition-colors",
|
||||
"bg-emerald-500/10 border border-emerald-500/20 text-emerald-400 hover:bg-emerald-500/15"
|
||||
)}
|
||||
>
|
||||
<BookmarkPlus size={14} />
|
||||
Save to Library
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
145
frontend/src/components/script-builder/ScriptPreviewModal.tsx
Normal file
145
frontend/src/components/script-builder/ScriptPreviewModal.tsx
Normal file
@@ -0,0 +1,145 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Light as SyntaxHighlighter } from 'react-syntax-highlighter'
|
||||
import atomOneDark from 'react-syntax-highlighter/dist/esm/styles/hljs/atom-one-dark'
|
||||
import { X, Copy, Check, BookmarkPlus } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const LANGUAGE_MAP: Record<string, string> = {
|
||||
powershell: 'powershell',
|
||||
bash: 'bash',
|
||||
python: 'python',
|
||||
}
|
||||
|
||||
const LANGUAGE_LABELS: Record<string, string> = {
|
||||
powershell: 'PowerShell',
|
||||
bash: 'Bash',
|
||||
python: 'Python',
|
||||
}
|
||||
|
||||
interface ScriptPreviewModalProps {
|
||||
script: string
|
||||
filename: string | null
|
||||
language: string
|
||||
onClose: () => void
|
||||
onSave: () => void
|
||||
}
|
||||
|
||||
export function ScriptPreviewModal({
|
||||
script,
|
||||
filename,
|
||||
language,
|
||||
onClose,
|
||||
onSave,
|
||||
}: ScriptPreviewModalProps) {
|
||||
const [copied, setCopied] = useState(false)
|
||||
const hlLanguage = LANGUAGE_MAP[language] || 'powershell'
|
||||
const lineCount = script.split('\n').length
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose()
|
||||
}
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
return () => document.removeEventListener('keydown', handleKeyDown)
|
||||
}, [onClose])
|
||||
|
||||
const handleCopy = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(script)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
} catch {
|
||||
// clipboard not available
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 bg-black/80 backdrop-blur-sm flex items-center justify-center"
|
||||
onClick={(e) => { if (e.target === e.currentTarget) onClose() }}
|
||||
>
|
||||
<div className="bg-[#18191f] rounded-xl border border-[rgba(255,255,255,0.08)] max-w-[900px] w-full mx-4 max-h-[85vh] flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-5 py-3.5 border-b border-[rgba(255,255,255,0.06)]">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<span className="font-label text-sm text-cyan-400 truncate">
|
||||
{filename || 'script'}
|
||||
</span>
|
||||
<span className="shrink-0 font-label text-[0.625rem] uppercase tracking-wider px-2 py-0.5 rounded-full bg-[rgba(255,255,255,0.06)] text-muted-foreground">
|
||||
{LANGUAGE_LABELS[language] || language}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 px-3 py-1.5 rounded-[10px] text-xs font-medium transition-colors",
|
||||
"bg-[rgba(255,255,255,0.04)] border border-[rgba(255,255,255,0.06)] text-foreground hover:border-[rgba(255,255,255,0.12)]"
|
||||
)}
|
||||
>
|
||||
{copied ? <Check size={14} className="text-emerald-400" /> : <Copy size={14} />}
|
||||
{copied ? 'Copied' : 'Copy'}
|
||||
</button>
|
||||
<button
|
||||
onClick={onSave}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 px-3 py-1.5 rounded-[10px] text-xs font-medium transition-colors",
|
||||
"bg-emerald-500/10 border border-emerald-500/20 text-emerald-400 hover:bg-emerald-500/15"
|
||||
)}
|
||||
>
|
||||
<BookmarkPlus size={14} />
|
||||
Save to Library
|
||||
</button>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1.5 rounded-lg text-muted-foreground hover:text-foreground hover:bg-[rgba(255,255,255,0.06)] transition-colors"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Code body */}
|
||||
<div className="flex-1 overflow-auto min-h-0">
|
||||
<SyntaxHighlighter
|
||||
language={hlLanguage}
|
||||
style={atomOneDark}
|
||||
showLineNumbers
|
||||
customStyle={{
|
||||
background: 'transparent',
|
||||
padding: '16px',
|
||||
margin: 0,
|
||||
fontSize: '0.8125rem',
|
||||
lineHeight: '1.6',
|
||||
}}
|
||||
lineNumberStyle={{
|
||||
color: '#5a6170',
|
||||
minWidth: '2.5em',
|
||||
paddingRight: '1em',
|
||||
userSelect: 'none',
|
||||
}}
|
||||
wrapLongLines
|
||||
>
|
||||
{script}
|
||||
</SyntaxHighlighter>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-between px-5 py-3 border-t border-[rgba(255,255,255,0.06)]">
|
||||
<span className="font-label text-[0.625rem] text-muted-foreground">
|
||||
{lineCount} line{lineCount !== 1 ? 's' : ''}
|
||||
</span>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className={cn(
|
||||
"px-4 py-1.5 rounded-[10px] text-xs font-medium transition-colors",
|
||||
"bg-[rgba(255,255,255,0.04)] border border-[rgba(255,255,255,0.06)] text-foreground hover:border-[rgba(255,255,255,0.12)]"
|
||||
)}
|
||||
>
|
||||
Close & Return to Chat
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -29,6 +29,7 @@ export interface UseFlowPilotSession {
|
||||
escalateSession: (data: EscalateSessionRequest) => Promise<SessionDocumentation>
|
||||
pauseSession: () => Promise<void>
|
||||
resumeOwnSession: () => Promise<void>
|
||||
abandonSession: () => Promise<void>
|
||||
rateSession: (rating: number, feedback?: string) => Promise<void>
|
||||
loadSession: (sessionId: string) => Promise<void>
|
||||
|
||||
@@ -190,6 +191,18 @@ export function useFlowPilotSession(): UseFlowPilotSession {
|
||||
}
|
||||
}, [session])
|
||||
|
||||
const abandonSession = useCallback(async () => {
|
||||
if (!session) return
|
||||
try {
|
||||
await aiSessionsApi.abandonSession(session.id)
|
||||
setSession(prev => prev ? { ...prev, status: 'abandoned' } : null)
|
||||
setCurrentStep(null)
|
||||
toast.success('Session closed')
|
||||
} catch {
|
||||
toast.error('Failed to close session')
|
||||
}
|
||||
}, [session])
|
||||
|
||||
const rateSession = useCallback(async (rating: number, feedback?: string) => {
|
||||
if (!session) return
|
||||
try {
|
||||
@@ -245,6 +258,7 @@ export function useFlowPilotSession(): UseFlowPilotSession {
|
||||
escalateSession,
|
||||
pauseSession,
|
||||
resumeOwnSession,
|
||||
abandonSession,
|
||||
rateSession,
|
||||
loadSession,
|
||||
isActive,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useParams, useSearchParams, useLocation, useBlocker } from 'react-router-dom'
|
||||
import { useParams, useSearchParams, useLocation, useBlocker, useNavigate } from 'react-router-dom'
|
||||
import { Sparkles, Loader2, AlertTriangle } from 'lucide-react'
|
||||
import { useFlowPilotSession } from '@/hooks/useFlowPilotSession'
|
||||
import { FlowPilotIntake, FlowPilotSession, SessionBriefing } from '@/components/flowpilot'
|
||||
@@ -9,6 +9,7 @@ import { toast } from '@/lib/toast'
|
||||
export default function FlowPilotSessionPage() {
|
||||
const { sessionId } = useParams<{ sessionId?: string }>()
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const prefill = (location.state as { prefill?: string } | null)?.prefill || ''
|
||||
const isPickup = searchParams.get('pickup') === 'true'
|
||||
@@ -230,6 +231,10 @@ export default function FlowPilotSessionPage() {
|
||||
onEscalate={fp.escalateSession}
|
||||
onPause={fp.pauseSession}
|
||||
onResume={fp.resumeOwnSession}
|
||||
onAbandon={async () => {
|
||||
await fp.abandonSession()
|
||||
navigate('/sessions')
|
||||
}}
|
||||
onRate={fp.rateSession}
|
||||
onReloadSession={() => fp.loadSession(fp.session!.id)}
|
||||
/>
|
||||
|
||||
204
frontend/src/pages/ScriptBuilderPage.tsx
Normal file
204
frontend/src/pages/ScriptBuilderPage.tsx
Normal file
@@ -0,0 +1,204 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
import { Terminal } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { scriptBuilderApi } from '@/api'
|
||||
import { ScriptBuilderChat } from '@/components/script-builder/ScriptBuilderChat'
|
||||
import { ScriptBuilderInput } from '@/components/script-builder/ScriptBuilderInput'
|
||||
import { ScriptPreviewModal } from '@/components/script-builder/ScriptPreviewModal'
|
||||
import { SaveToLibraryDialog } from '@/components/script-builder/SaveToLibraryDialog'
|
||||
import type { ScriptBuilderSessionDetail, ScriptBuilderMessage } from '@/types'
|
||||
|
||||
const LANGUAGES = [
|
||||
{ value: 'powershell', label: 'PowerShell' },
|
||||
{ value: 'bash', label: 'Bash' },
|
||||
{ value: 'python', label: 'Python' },
|
||||
] as const
|
||||
|
||||
export default function ScriptBuilderPage() {
|
||||
const [searchParams] = useSearchParams()
|
||||
const [session, setSession] = useState<ScriptBuilderSessionDetail | null>(null)
|
||||
const [messages, setMessages] = useState<ScriptBuilderMessage[]>([])
|
||||
const [language, setLanguage] = useState('powershell')
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [previewScript, setPreviewScript] = useState<{ script: string; filename: string | null } | null>(null)
|
||||
const [showSaveDialog, setShowSaveDialog] = useState(false)
|
||||
const [handoffProcessed, setHandoffProcessed] = useState(false)
|
||||
|
||||
const hasMessages = messages.length > 0
|
||||
|
||||
// Handle FlowPilot handoff on mount
|
||||
useEffect(() => {
|
||||
if (handoffProcessed) return
|
||||
setHandoffProcessed(true)
|
||||
|
||||
const contextRaw = sessionStorage.getItem('scriptBuilderContext')
|
||||
if (!contextRaw) return
|
||||
|
||||
try {
|
||||
const context = JSON.parse(contextRaw) as {
|
||||
from_session?: string
|
||||
prompt?: string
|
||||
language?: string
|
||||
}
|
||||
sessionStorage.removeItem('scriptBuilderContext')
|
||||
|
||||
if (context.language) {
|
||||
setLanguage(context.language)
|
||||
}
|
||||
if (context.prompt) {
|
||||
// Auto-send the prompt
|
||||
handleSend(context.prompt, context.language || 'powershell')
|
||||
}
|
||||
} catch {
|
||||
sessionStorage.removeItem('scriptBuilderContext')
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
// Suppress unused searchParams warning — used to detect ?from=flowpilot context
|
||||
void searchParams
|
||||
|
||||
const handleSend = async (content: string, langOverride?: string) => {
|
||||
const effectiveLanguage = langOverride || language
|
||||
|
||||
// Optimistically add user message
|
||||
const userMessage: ScriptBuilderMessage = {
|
||||
role: 'user',
|
||||
content,
|
||||
created_at: new Date().toISOString(),
|
||||
}
|
||||
setMessages((prev) => [...prev, userMessage])
|
||||
setIsLoading(true)
|
||||
|
||||
try {
|
||||
// Create session if needed
|
||||
let currentSession = session
|
||||
if (!currentSession) {
|
||||
currentSession = await scriptBuilderApi.createSession(effectiveLanguage)
|
||||
setSession(currentSession)
|
||||
}
|
||||
|
||||
// Send message
|
||||
const response = await scriptBuilderApi.sendMessage(currentSession.id, content)
|
||||
|
||||
const assistantMessage: ScriptBuilderMessage = {
|
||||
role: 'assistant',
|
||||
content: response.content,
|
||||
script: response.script,
|
||||
script_filename: response.script_filename,
|
||||
line_count: response.line_count,
|
||||
created_at: response.timestamp,
|
||||
}
|
||||
setMessages((prev) => [...prev, assistantMessage])
|
||||
} catch (err) {
|
||||
// Add error message
|
||||
const errorMessage: ScriptBuilderMessage = {
|
||||
role: 'assistant',
|
||||
content: `An error occurred: ${err instanceof Error ? err.message : 'Failed to generate response. Please try again.'}`,
|
||||
created_at: new Date().toISOString(),
|
||||
}
|
||||
setMessages((prev) => [...prev, errorMessage])
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleViewScript = (script: string, filename: string | null) => {
|
||||
setPreviewScript({ script, filename })
|
||||
}
|
||||
|
||||
const handleSaveScript = () => {
|
||||
setShowSaveDialog(true)
|
||||
}
|
||||
|
||||
const handleSaved = () => {
|
||||
setShowSaveDialog(false)
|
||||
}
|
||||
|
||||
// Derive default name from session title or filename
|
||||
const defaultSaveName = session?.title
|
||||
|| session?.latest_script_filename
|
||||
|| 'Untitled Script'
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full min-h-0">
|
||||
{/* Header with language selector */}
|
||||
<div className="shrink-0 px-6 py-4 border-b" style={{ borderColor: 'var(--glass-border)' }}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="flex items-center justify-center w-9 h-9 rounded-xl bg-[rgba(232,121,249,0.1)]">
|
||||
<Terminal size={18} className="text-fuchsia-400" />
|
||||
</span>
|
||||
<div>
|
||||
<h1 className="text-base font-heading font-bold text-foreground">Script Builder</h1>
|
||||
<p className="text-xs text-muted-foreground">Describe what you need, AI generates the script</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Language pills */}
|
||||
<div className="flex items-center gap-1 p-0.5 rounded-lg bg-[rgba(255,255,255,0.03)] border border-[rgba(255,255,255,0.06)]">
|
||||
{LANGUAGES.map((lang) => (
|
||||
<button
|
||||
key={lang.value}
|
||||
onClick={() => !hasMessages && setLanguage(lang.value)}
|
||||
disabled={hasMessages}
|
||||
className={cn(
|
||||
"px-3 py-1.5 rounded-md text-xs font-label font-medium transition-all",
|
||||
language === lang.value
|
||||
? "bg-gradient-brand text-[#101114]"
|
||||
: hasMessages
|
||||
? "text-[#5a6170] cursor-not-allowed"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-[rgba(255,255,255,0.04)]"
|
||||
)}
|
||||
>
|
||||
{lang.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Chat area */}
|
||||
<ScriptBuilderChat
|
||||
messages={messages}
|
||||
language={language}
|
||||
onViewScript={handleViewScript}
|
||||
onSaveScript={handleSaveScript}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
|
||||
{/* Input */}
|
||||
<div className="shrink-0">
|
||||
<ScriptBuilderInput
|
||||
onSend={(content) => handleSend(content)}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Preview modal */}
|
||||
{previewScript && (
|
||||
<ScriptPreviewModal
|
||||
script={previewScript.script}
|
||||
filename={previewScript.filename}
|
||||
language={language}
|
||||
onClose={() => setPreviewScript(null)}
|
||||
onSave={() => {
|
||||
setPreviewScript(null)
|
||||
setShowSaveDialog(true)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Save dialog */}
|
||||
{showSaveDialog && session && (
|
||||
<SaveToLibraryDialog
|
||||
sessionId={session.id}
|
||||
defaultName={defaultSaveName}
|
||||
onClose={() => setShowSaveDialog(false)}
|
||||
onSaved={handleSaved}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { Terminal, Settings } from 'lucide-react'
|
||||
import { Terminal, Settings, Wand2 } from 'lucide-react'
|
||||
import { useScriptGeneratorStore } from '@/store/scriptGeneratorStore'
|
||||
import { usePermissions } from '@/hooks/usePermissions'
|
||||
import { ScriptFilterBar } from '@/components/scripts/ScriptFilterBar'
|
||||
@@ -8,10 +8,13 @@ import { ScriptTemplateList } from '@/components/scripts/ScriptTemplateList'
|
||||
import { ScriptConfigurePane } from '@/components/scripts/ScriptConfigurePane'
|
||||
import { ScriptPreview } from '@/components/scripts/ScriptPreview'
|
||||
|
||||
type LibraryTab = 'mine' | 'team'
|
||||
|
||||
export default function ScriptLibraryPage() {
|
||||
const [paneMode, setPaneMode] = useState<'browse' | 'configure'>('browse')
|
||||
// inputValue owned here so it survives Configure ↔ Browse transitions
|
||||
const [inputValue, setInputValue] = useState('')
|
||||
const [activeTab, setActiveTab] = useState<LibraryTab>('mine')
|
||||
|
||||
const loadCategories = useScriptGeneratorStore(s => s.loadCategories)
|
||||
const loadTemplates = useScriptGeneratorStore(s => s.loadTemplates)
|
||||
@@ -24,8 +27,18 @@ export default function ScriptLibraryPage() {
|
||||
const canGenerate = isEngineer
|
||||
|
||||
useEffect(() => {
|
||||
loadCategories().then(() => loadTemplates())
|
||||
}, [loadCategories, loadTemplates])
|
||||
loadCategories().then(() => {
|
||||
const filters = activeTab === 'mine' ? { mine: true } : { shared: true }
|
||||
loadTemplates(filters)
|
||||
})
|
||||
}, [loadCategories, loadTemplates, activeTab])
|
||||
|
||||
const onTabChange = (tab: LibraryTab) => {
|
||||
setActiveTab(tab)
|
||||
setInputValue('')
|
||||
setSearch('')
|
||||
setPaneMode('browse')
|
||||
}
|
||||
|
||||
const onClearSearch = () => {
|
||||
setInputValue('')
|
||||
@@ -42,23 +55,55 @@ export default function ScriptLibraryPage() {
|
||||
setPaneMode('browse')
|
||||
}
|
||||
|
||||
const tabClass = (tab: LibraryTab) =>
|
||||
tab === activeTab
|
||||
? 'border-b-2 border-primary text-foreground'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 p-6 h-full">
|
||||
{/* Page header */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-heading font-bold text-foreground">Script Library</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Browse PowerShell templates, fill in parameters, and generate ready-to-run scripts.
|
||||
</p>
|
||||
{isEngineer && (
|
||||
<Link
|
||||
to="/scripts/manage"
|
||||
className="inline-flex items-center gap-1.5 text-xs text-primary bg-primary/10 hover:bg-primary/15 px-2.5 py-1 rounded-full transition-colors mt-2 group"
|
||||
>
|
||||
<Settings size={12} className="group-hover:rotate-90 transition-transform duration-300" />
|
||||
Manage Templates
|
||||
</Link>
|
||||
)}
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-heading font-bold text-foreground">Script Library</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Browse PowerShell templates, fill in parameters, and generate ready-to-run scripts.
|
||||
</p>
|
||||
{isEngineer && (
|
||||
<Link
|
||||
to="/scripts/manage"
|
||||
className="inline-flex items-center gap-1.5 text-xs text-primary bg-primary/10 hover:bg-primary/15 px-2.5 py-1 rounded-full transition-colors mt-2 group"
|
||||
>
|
||||
<Settings size={12} className="group-hover:rotate-90 transition-transform duration-300" />
|
||||
Manage Templates
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
<Link
|
||||
to="/script-builder"
|
||||
className="inline-flex items-center gap-2 bg-gradient-brand text-[#101114] font-semibold rounded-[10px] px-4 py-2 shadow-lg shadow-primary/20 hover:opacity-90 active:scale-[0.97] transition-all"
|
||||
>
|
||||
<Wand2 size={16} />
|
||||
Build a New Script
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Tab bar */}
|
||||
<div className="flex gap-6 border-b border-border">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onTabChange('mine')}
|
||||
className={`pb-2 text-sm font-medium transition-colors ${tabClass('mine')}`}
|
||||
>
|
||||
My Scripts
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onTabChange('team')}
|
||||
className={`pb-2 text-sm font-medium transition-colors ${tabClass('team')}`}
|
||||
>
|
||||
Team Scripts
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Two-column layout */}
|
||||
|
||||
@@ -21,8 +21,6 @@ export function SessionHistoryPage() {
|
||||
const navigate = useNavigate()
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
|
||||
// Top-level tab: flow sessions vs AI sessions
|
||||
const [sessionType, setSessionType] = useState<'flow' | 'ai'>('flow')
|
||||
const [aiSessions, setAiSessions] = useState<AISessionSummary[]>([])
|
||||
const [aiLoading, setAiLoading] = useState(false)
|
||||
const [aiSearchInput, setAiSearchInput] = useState('')
|
||||
@@ -122,13 +120,16 @@ export function SessionHistoryPage() {
|
||||
if (filters.dateRange?.from) {
|
||||
const fromDate = filters.dateRange.from
|
||||
const toDate = filters.dateRange.to || filters.dateRange.from
|
||||
// Set end-of-day on the "to" date so sessions created that day are included
|
||||
const toDateEnd = new Date(toDate)
|
||||
toDateEnd.setHours(23, 59, 59, 999)
|
||||
|
||||
if (filters.dateType === 'started') {
|
||||
params.started_after = fromDate.toISOString()
|
||||
params.started_before = toDate.toISOString()
|
||||
params.started_before = toDateEnd.toISOString()
|
||||
} else {
|
||||
params.completed_after = fromDate.toISOString()
|
||||
params.completed_before = toDate.toISOString()
|
||||
params.completed_before = toDateEnd.toISOString()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -166,9 +167,8 @@ export function SessionHistoryPage() {
|
||||
setSearchParams(params, { replace: true })
|
||||
}, [filters, setSearchParams])
|
||||
|
||||
// Load AI sessions when tab is active or filters change
|
||||
// Load AI sessions always
|
||||
useEffect(() => {
|
||||
if (sessionType !== 'ai') return
|
||||
let cancelled = false
|
||||
const loadAiSessions = async () => {
|
||||
setAiLoading(true)
|
||||
@@ -179,7 +179,7 @@ export function SessionHistoryPage() {
|
||||
problem_domain: aiFilters.problem_domain || undefined,
|
||||
confidence_tier: aiFilters.confidence_tier || undefined,
|
||||
date_from: aiFilters.date_from || undefined,
|
||||
date_to: aiFilters.date_to || undefined,
|
||||
date_to: aiFilters.date_to ? `${aiFilters.date_to}T23:59:59.999Z` : undefined,
|
||||
})
|
||||
if (!cancelled) setAiSessions(data)
|
||||
} catch {
|
||||
@@ -190,7 +190,7 @@ export function SessionHistoryPage() {
|
||||
}
|
||||
loadAiSessions()
|
||||
return () => { cancelled = true }
|
||||
}, [sessionType, aiFilters])
|
||||
}, [aiFilters])
|
||||
|
||||
const handleFilterChange = (newFilters: SessionFilterState) => {
|
||||
setFilters(newFilters)
|
||||
@@ -267,66 +267,55 @@ export function SessionHistoryPage() {
|
||||
return labels[outcome] ?? outcome
|
||||
}
|
||||
|
||||
const hasAiFiltersActive = !!(aiSearchInput || aiFilters.q || aiFilters.problem_domain || aiFilters.confidence_tier || aiFilters.date_from || aiFilters.date_to)
|
||||
const hasFlowFiltersActive = !!(filters.ticketNumber || filters.clientName || filters.treeName || filters.dateRange?.from)
|
||||
|
||||
// Determine section visibility
|
||||
const showAiSection = aiLoading || aiSessions.length > 0 || hasAiFiltersActive
|
||||
const showFlowSection = isLoading || sessions.length > 0 || hasFlowFiltersActive
|
||||
const showCombinedEmpty = !showAiSection && !showFlowSection
|
||||
|
||||
return (
|
||||
<div className="overflow-y-auto h-full">
|
||||
<PageMeta title="Session History" />
|
||||
<PageMeta title="Sessions" />
|
||||
<div className="container mx-auto px-4 py-6 sm:px-6 sm:py-8">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-2xl font-heading font-bold text-foreground sm:text-3xl">Session History</h1>
|
||||
<h1 className="text-2xl font-heading font-bold text-foreground sm:text-3xl">Sessions</h1>
|
||||
<p className="mt-2 text-muted-foreground">
|
||||
Search and filter your troubleshooting sessions
|
||||
View and manage all your sessions
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Session type toggle */}
|
||||
<div className="mb-4 flex gap-1 rounded-lg bg-card/50 p-1 w-fit border border-border">
|
||||
<button
|
||||
onClick={() => setSessionType('flow')}
|
||||
className={cn(
|
||||
'rounded-md px-4 py-1.5 text-sm font-medium transition-colors',
|
||||
sessionType === 'flow'
|
||||
? 'bg-primary/10 text-foreground'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
Flow Sessions
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setSessionType('ai')}
|
||||
className={cn(
|
||||
'rounded-md px-4 py-1.5 text-sm font-medium transition-colors',
|
||||
sessionType === 'ai'
|
||||
? 'bg-primary/10 text-foreground'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
AI Sessions
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Filter Tabs (flow sessions only) */}
|
||||
{sessionType === 'flow' && (
|
||||
<div className="mb-6 flex gap-2 border-b border-border">
|
||||
{(['active', 'prepared', 'completed', 'all'] as const).map((tab) => (
|
||||
<button
|
||||
key={tab}
|
||||
onClick={() => setFilter(tab)}
|
||||
className={cn(
|
||||
'px-4 py-2 text-sm font-medium transition-colors',
|
||||
filter === tab
|
||||
? 'border-b-2 border-primary text-foreground'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
{tab.charAt(0).toUpperCase() + tab.slice(1)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{showCombinedEmpty && (
|
||||
<EmptyState
|
||||
illustration={<SessionIllustration />}
|
||||
title="No sessions yet"
|
||||
description="Start a flow or FlowPilot session to begin. All your sessions will appear here."
|
||||
action={
|
||||
<div className="flex gap-3">
|
||||
<Link
|
||||
to="/trees"
|
||||
className="inline-flex items-center gap-2 rounded-[10px] bg-gradient-brand px-5 py-2.5 text-sm font-semibold text-[#101114] shadow-lg shadow-primary/20 hover:opacity-90 active:scale-[0.97] transition-all"
|
||||
>
|
||||
Start a Flow
|
||||
</Link>
|
||||
<Link
|
||||
to="/pilot"
|
||||
className="inline-flex items-center gap-2 rounded-[10px] border border-border bg-[rgba(255,255,255,0.04)] px-5 py-2.5 text-sm font-semibold text-foreground hover:border-[rgba(255,255,255,0.12)] transition-all"
|
||||
>
|
||||
Start AI Session
|
||||
</Link>
|
||||
</div>
|
||||
}
|
||||
learnMoreLink="/guides/sessions"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* AI Sessions view */}
|
||||
{sessionType === 'ai' && (
|
||||
{/* FlowPilot Sessions Section */}
|
||||
{showAiSection && (
|
||||
<>
|
||||
<h2 className="font-label text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground mb-3">FlowPilot Sessions</h2>
|
||||
|
||||
{/* AI Session Filter Bar */}
|
||||
<div className="glass-card-static p-3 mb-4">
|
||||
<div className="flex flex-wrap gap-3 items-center">
|
||||
@@ -400,7 +389,7 @@ export function SessionHistoryPage() {
|
||||
</div>
|
||||
|
||||
{/* Clear filters */}
|
||||
{(aiSearchInput || aiFilters.q || aiFilters.problem_domain || aiFilters.confidence_tier || aiFilters.date_from || aiFilters.date_to) && (
|
||||
{hasAiFiltersActive && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setAiSearchInput('')
|
||||
@@ -419,36 +408,21 @@ export function SessionHistoryPage() {
|
||||
<Spinner />
|
||||
</div>
|
||||
) : aiSessions.length === 0 ? (
|
||||
(aiSearchInput || aiFilters.q || aiFilters.problem_domain || aiFilters.confidence_tier || aiFilters.date_from || aiFilters.date_to) ? (
|
||||
<EmptyState
|
||||
title="No sessions match your filters"
|
||||
description="Try adjusting your search or filters."
|
||||
action={
|
||||
<button
|
||||
onClick={() => {
|
||||
setAiSearchInput('')
|
||||
setAiFilters({ q: '', problem_domain: '', confidence_tier: '', date_from: '', date_to: '' })
|
||||
}}
|
||||
className="text-foreground hover:underline text-sm"
|
||||
>
|
||||
Clear all filters
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<EmptyState
|
||||
title="No AI sessions yet"
|
||||
description="Start a FlowPilot session to get AI-guided troubleshooting. Sessions will appear here."
|
||||
action={
|
||||
<Link
|
||||
to="/pilot"
|
||||
className="inline-flex items-center gap-2 rounded-[10px] bg-gradient-brand px-5 py-2.5 text-sm font-semibold text-[#101114] shadow-lg shadow-primary/20 hover:opacity-90 active:scale-[0.97] transition-all"
|
||||
>
|
||||
Start AI Session
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
)
|
||||
<EmptyState
|
||||
title="No sessions match your filters"
|
||||
description="Try adjusting your search or filters."
|
||||
action={
|
||||
<button
|
||||
onClick={() => {
|
||||
setAiSearchInput('')
|
||||
setAiFilters({ q: '', problem_domain: '', confidence_tier: '', date_from: '', date_to: '' })
|
||||
}}
|
||||
className="text-foreground hover:underline text-sm"
|
||||
>
|
||||
Clear all filters
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{aiSessions.map((s) => (
|
||||
@@ -456,12 +430,37 @@ export function SessionHistoryPage() {
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Divider between sections */}
|
||||
{showFlowSection && (
|
||||
<div className="my-8 border-t border-border" />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Flow Sessions Content */}
|
||||
{sessionType === 'flow' && (
|
||||
{/* Flow Sessions Section */}
|
||||
{showFlowSection && (
|
||||
<>
|
||||
<h2 className="font-label text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground mb-3">Flow Sessions</h2>
|
||||
|
||||
{/* Filter Tabs */}
|
||||
<div className="mb-6 flex gap-2 border-b border-border">
|
||||
{(['active', 'prepared', 'completed', 'all'] as const).map((tab) => (
|
||||
<button
|
||||
key={tab}
|
||||
onClick={() => setFilter(tab)}
|
||||
className={cn(
|
||||
'px-4 py-2 text-sm font-medium transition-colors',
|
||||
filter === tab
|
||||
? 'border-b-2 border-primary text-foreground'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
{tab.charAt(0).toUpperCase() + tab.slice(1)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
<SessionFilters
|
||||
filters={filters}
|
||||
|
||||
@@ -52,6 +52,7 @@ const FlowPilotSessionPage = lazy(() => import('@/pages/FlowPilotSessionPage'))
|
||||
const EscalationQueuePage = lazy(() => import('@/pages/EscalationQueuePage'))
|
||||
const ReviewQueuePage = lazy(() => import('@/pages/ReviewQueuePage'))
|
||||
const FlowPilotAnalyticsPage = lazy(() => import('@/pages/FlowPilotAnalyticsPage'))
|
||||
const ScriptBuilderPage = lazy(() => import('@/pages/ScriptBuilderPage'))
|
||||
const KBAcceleratorPage = lazy(() => import('@/pages/KBAcceleratorPage'))
|
||||
const GuidesHubPage = lazy(() => import('@/pages/GuidesHubPage'))
|
||||
const GuideDetailPage = lazy(() => import('@/pages/GuideDetailPage'))
|
||||
@@ -190,6 +191,7 @@ export const router = sentryCreateBrowserRouter([
|
||||
{ path: 'step-library', element: page(StepLibraryPage) },
|
||||
{ path: 'scripts', element: page(ScriptLibraryPage) },
|
||||
{ path: 'scripts/manage', element: page(ScriptManagePage) },
|
||||
{ path: 'script-builder', element: page(ScriptBuilderPage) },
|
||||
{ path: 'kb-accelerator', element: page(KBAcceleratorPage) },
|
||||
{ path: 'assistant', element: page(AssistantChatPage) },
|
||||
{ path: 'flow-assist', element: page(FlowAssistPage) },
|
||||
|
||||
@@ -30,7 +30,7 @@ interface ScriptGeneratorState {
|
||||
|
||||
// Actions
|
||||
loadCategories: () => Promise<void>
|
||||
loadTemplates: () => Promise<void>
|
||||
loadTemplates: (filters?: { mine?: boolean; shared?: boolean }) => Promise<void>
|
||||
selectTemplate: (id: string) => Promise<void>
|
||||
setCategory: (id: string | null) => void
|
||||
setSearch: (query: string) => void
|
||||
@@ -67,14 +67,16 @@ export const useScriptGeneratorStore = create<ScriptGeneratorState>()((set, get)
|
||||
}
|
||||
},
|
||||
|
||||
loadTemplates: async () => {
|
||||
loadTemplates: async (filters) => {
|
||||
set({ isLoadingTemplates: true })
|
||||
try {
|
||||
const { activeCategoryId, categories, searchQuery } = get()
|
||||
const category = categories.find(c => c.id === activeCategoryId)
|
||||
const params: { category_slug?: string; search?: string } = {}
|
||||
const params: { category_slug?: string; search?: string; mine?: boolean; shared?: boolean } = {}
|
||||
if (category) params.category_slug = category.slug
|
||||
if (searchQuery) params.search = searchQuery
|
||||
if (filters?.mine) params.mine = true
|
||||
if (filters?.shared) params.shared = true
|
||||
const templates = await scriptsApi.getTemplates(params)
|
||||
set({ templates, isLoadingTemplates: false })
|
||||
} catch {
|
||||
|
||||
@@ -41,6 +41,33 @@ export interface AISessionStepResponse {
|
||||
confidence_score: number
|
||||
}
|
||||
|
||||
// ── Step content type guards ──
|
||||
|
||||
export interface ScriptGenerationContent {
|
||||
action_type: 'script_generation'
|
||||
template_id?: string
|
||||
pre_filled_params?: Record<string, string>
|
||||
instructions?: string
|
||||
}
|
||||
|
||||
export interface ScriptBuilderContent {
|
||||
action_type: 'open_script_builder'
|
||||
script_prompt?: string
|
||||
script_language?: string
|
||||
}
|
||||
|
||||
export function isScriptGenerationAction(content: Record<string, unknown>): content is Record<string, unknown> & ScriptGenerationContent {
|
||||
return content.action_type === 'script_generation'
|
||||
}
|
||||
|
||||
export function isScriptBuilderAction(content: Record<string, unknown>): content is Record<string, unknown> & ScriptBuilderContent {
|
||||
return content.action_type === 'open_script_builder'
|
||||
}
|
||||
|
||||
export function getActionType(content: Record<string, unknown>): string | undefined {
|
||||
return typeof content.action_type === 'string' ? content.action_type : undefined
|
||||
}
|
||||
|
||||
export interface StepResponseRequest {
|
||||
selected_option?: string | null
|
||||
free_text_input?: string | null
|
||||
|
||||
@@ -93,6 +93,7 @@ export type {
|
||||
} from './kbAccelerator'
|
||||
|
||||
export * from './scripts'
|
||||
export * from './script-builder'
|
||||
export * from './integrations'
|
||||
export * from './notification'
|
||||
export type * from './public-templates'
|
||||
|
||||
50
frontend/src/types/script-builder.ts
Normal file
50
frontend/src/types/script-builder.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
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 {
|
||||
id?: string
|
||||
role: 'user' | 'assistant'
|
||||
content: string
|
||||
script?: string | null
|
||||
script_filename?: string | null
|
||||
line_count?: number | null
|
||||
input_tokens?: number | null
|
||||
output_tokens?: number | null
|
||||
created_at: 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
|
||||
}
|
||||
@@ -23,6 +23,7 @@ export interface ScriptTemplateListItem {
|
||||
requires_modules: string[]
|
||||
is_verified: boolean
|
||||
usage_count: number
|
||||
language?: string | null
|
||||
}
|
||||
|
||||
export interface ScriptParameterOption {
|
||||
|
||||
Reference in New Issue
Block a user