feat: add Script Builder page with chat UI, code blocks, preview modal, and save dialog
- ScriptCodeBlock: collapsed code preview with syntax highlighting (first 5 lines) - ScriptBuilderInput: auto-resize chat input with Enter-to-send - ScriptBuilderChat: message list with markdown rendering and code blocks - ScriptPreviewModal: fullscreen script viewer with line numbers - SaveToLibraryDialog: save script with name, description, category, team sharing - ScriptBuilderPage: language selector, session management, FlowPilot handoff - Added route, sidebar nav item (fuchsia), and mobile nav entry Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
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",
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
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,
|
||||
timestamp: 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,
|
||||
timestamp: 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.'}`,
|
||||
timestamp: 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>
|
||||
)
|
||||
}
|
||||
@@ -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) },
|
||||
|
||||
Reference in New Issue
Block a user