diff --git a/.gitignore b/.gitignore index a5a48de..e3a85a3 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ node_modules target .DS_Store webui/src/wasm +webui/data diff --git a/webui/src/app/api/get-script/route.ts b/webui/src/app/api/get-script/route.ts new file mode 100644 index 0000000..7c49849 --- /dev/null +++ b/webui/src/app/api/get-script/route.ts @@ -0,0 +1,54 @@ +import { NextRequest, NextResponse } from "next/server"; +import { readFile } from "fs/promises"; +import { join } from "path"; +import { existsSync } from "fs"; +import { SAVE_CONFIG } from "@/lib/saveConfig"; + +export async function GET(request: NextRequest) { + try { + const { searchParams } = new URL(request.url); + const name = searchParams.get("name"); + + if (!name) { + return NextResponse.json( + { error: "Script name is required" }, + { status: 400 } + ); + } + + // Validate filename (same validation as save) + if (!SAVE_CONFIG.FILENAME_REGEX.test(name)) { + return NextResponse.json( + { error: "Invalid script name" }, + { status: 400 } + ); + } + + const saveDir = join(process.cwd(), SAVE_CONFIG.SAVE_DIRECTORY); + const filename = `${name}.rhai`; + const filepath = join(saveDir, filename); + + // Check if file exists + if (!existsSync(filepath)) { + return NextResponse.json( + { error: `Script "${name}" not found` }, + { status: 404 } + ); + } + + // Read and return file content + const content = await readFile(filepath, "utf8"); + + return NextResponse.json({ + name, + filename, + content, + }); + } catch (error) { + console.error("Get script error:", error); + return NextResponse.json( + { error: "Failed to read script" }, + { status: 500 } + ); + } +} diff --git a/webui/src/app/api/list-scripts/route.ts b/webui/src/app/api/list-scripts/route.ts new file mode 100644 index 0000000..2586e39 --- /dev/null +++ b/webui/src/app/api/list-scripts/route.ts @@ -0,0 +1,31 @@ +import { NextResponse } from "next/server"; +import { readdir } from "fs/promises"; +import { join } from "path"; +import { existsSync } from "fs"; +import { SAVE_CONFIG } from "@/lib/saveConfig"; + +export async function GET() { + try { + const saveDir = join(process.cwd(), SAVE_CONFIG.SAVE_DIRECTORY); + + // If save directory doesn't exist, return empty array + if (!existsSync(saveDir)) { + return NextResponse.json({ scripts: [] }); + } + + // Read directory and filter for .rhai files + const files = await readdir(saveDir); + const scripts = files + .filter((file) => file.endsWith(".rhai")) + .map((file) => file.replace(".rhai", "")) + .sort(); // Sort alphabetically + + return NextResponse.json({ scripts }); + } catch (error) { + console.error("List scripts error:", error); + return NextResponse.json( + { error: "Failed to list scripts" }, + { status: 500 } + ); + } +} diff --git a/webui/src/app/api/save-script/route.ts b/webui/src/app/api/save-script/route.ts new file mode 100644 index 0000000..5c46a0d --- /dev/null +++ b/webui/src/app/api/save-script/route.ts @@ -0,0 +1,86 @@ +import { NextRequest, NextResponse } from "next/server"; +import { writeFile, mkdir } from "fs/promises"; +import { join } from "path"; +import { existsSync } from "fs"; +import { SAVE_CONFIG } from "@/lib/saveConfig"; + +export async function POST(request: NextRequest) { + try { + // Check if saving is enabled + if (!SAVE_CONFIG.ENABLE_SAVE) { + return NextResponse.json( + { error: "Script saving is disabled" }, + { status: 403 } + ); + } + + const { name, content, secret } = await request.json(); + + // Validate secret + if (secret !== SAVE_CONFIG.SAVE_SECRET) { + return NextResponse.json( + { error: "Invalid save secret" }, + { status: 401 } + ); + } + + // Validate required fields + if (!name || !content) { + return NextResponse.json( + { error: "Name and content are required" }, + { status: 400 } + ); + } + + // Validate filename + if (name.length > SAVE_CONFIG.MAX_FILENAME_LENGTH) { + return NextResponse.json( + { + error: `Filename must be ${SAVE_CONFIG.MAX_FILENAME_LENGTH} characters or less`, + }, + { status: 400 } + ); + } + + if (!SAVE_CONFIG.FILENAME_REGEX.test(name)) { + return NextResponse.json( + { + error: "Filename can only contain alphanumerics, underscores, spaces, and hyphens", + }, + { status: 400 } + ); + } + + // Ensure save directory exists + const saveDir = join(process.cwd(), SAVE_CONFIG.SAVE_DIRECTORY); + if (!existsSync(saveDir)) { + await mkdir(saveDir, { recursive: true }); + } + + // Check if file already exists + const filename = `${name}.rhai`; + const filepath = join(saveDir, filename); + + if (existsSync(filepath)) { + return NextResponse.json( + { error: `A script named "${name}" already exists` }, + { status: 409 } + ); + } + + // Save the file + await writeFile(filepath, content, "utf8"); + + return NextResponse.json({ + success: true, + message: `Script saved as ${filename}`, + filename, + }); + } catch (error) { + console.error("Save script error:", error); + return NextResponse.json( + { error: "Failed to save script" }, + { status: 500 } + ); + } +} diff --git a/webui/src/components/Playground.tsx b/webui/src/components/Playground.tsx index 624a1fa..c7f550b 100644 --- a/webui/src/components/Playground.tsx +++ b/webui/src/components/Playground.tsx @@ -73,6 +73,13 @@ export default function Playground() { const [bulkRounds, setBulkRounds] = useState(1000); const [selectedAgent, setSelectedAgent] = useState("Random"); const [isHelpOpen, setIsHelpOpen] = useState(false); + const [scriptName, setScriptName] = useState(""); + const [saveSecret, setSaveSecret] = useState(""); + const [isSaving, setIsSaving] = useState(false); + const [availableAgents, setAvailableAgents] = useState([]); + const [savedScripts, setSavedScripts] = useState>( + {} + ); const editorRef = useRef(null); const resultRef = useRef(null); @@ -81,6 +88,65 @@ export default function Playground() { const runDisabled = isScriptRunning || !isEditorReady; const stopDisabled = !isScriptRunning; + // Fetch saved scripts and update available agents + const loadSavedScripts = useCallback(async () => { + try { + const response = await fetch("/api/list-scripts"); + const data = await response.json(); + + if (response.ok) { + const scripts = data.scripts || []; + const scriptContents: Record = {}; + + // Fetch content for each saved script + await Promise.all( + scripts.map(async (scriptName: string) => { + try { + const scriptResponse = await fetch( + `/api/get-script?name=${encodeURIComponent( + scriptName + )}` + ); + const scriptData = await scriptResponse.json(); + + if (scriptResponse.ok) { + scriptContents[scriptName] = scriptData.content; + } + } catch (error) { + console.error( + `Failed to load script ${scriptName}:`, + error + ); + } + }) + ); + + setSavedScripts(scriptContents); + + // Combine hardcoded agents with saved scripts, ensuring Self and Random are first + const combinedAgents = [ + "Self", + "Random", + ...Object.keys(AGENTS).filter( + (key) => key !== "Self" && key !== "Random" + ), + ...scripts, + ]; + + setAvailableAgents(combinedAgents); + } + } catch (error) { + console.error("Failed to load saved scripts:", error); + // Fallback to hardcoded agents only + setAvailableAgents(Object.keys(AGENTS)); + } + }, []); + + // Load saved scripts on component mount + useEffect(() => { + loadSavedScripts(); + }, [loadSavedScripts]); + const runHuman = useCallback(async () => { if (resultRef.current) { resultRef.current.value = ""; @@ -141,11 +207,15 @@ export default function Playground() { terminalRef.current?.clear(); terminalRef.current?.focus(); - const agentCode = AGENTS[selectedAgent as keyof typeof AGENTS]; + // Get script content from either hardcoded agents or saved scripts + const hardcodedAgent = AGENTS[selectedAgent as keyof typeof AGENTS]; + const savedScript = savedScripts[selectedAgent]; + + const agentScript = hardcodedAgent || savedScript; const blueScript = - agentCode || (editorRef.current?.getValue() ?? ""); + agentScript || (editorRef.current?.getValue() ?? ""); const redScript = editorRef.current?.getValue() ?? ""; - const opponentName = agentCode ? selectedAgent : "script"; + const opponentName = agentScript ? selectedAgent : "script"; await startScriptBulk( redScript, @@ -182,12 +252,87 @@ export default function Playground() { } setIsScriptRunning(false); - }, [runDisabled, bulkRounds, selectedAgent]); + }, [runDisabled, bulkRounds, selectedAgent, savedScripts]); const stopScriptHandler = useCallback(() => { stopScript(); }, []); + const saveScript = useCallback(async () => { + if (!scriptName.trim()) { + alert("Please enter a script name"); + return; + } + + if (!saveSecret.trim()) { + alert("Please enter the save secret"); + return; + } + + if (!editorRef.current) { + alert("No script content to save"); + return; + } + + setIsSaving(true); + + try { + const response = await fetch("/api/save-script", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + name: scriptName.trim(), + content: editorRef.current.getValue(), + secret: saveSecret.trim(), + }), + }); + + const result = await response.json(); + + if (response.ok) { + alert(result.message); + setScriptName(""); + // Reload saved scripts to include the new one + loadSavedScripts(); + } else { + alert(result.error || "Failed to save script"); + } + } catch (error) { + console.error("Save error:", error); + alert("Network error: Failed to save script"); + } + + setIsSaving(false); + }, [scriptName, saveSecret, loadSavedScripts]); + + const copyScriptToEditor = useCallback(() => { + if (!selectedAgent || selectedAgent === "Self") { + alert("Please select a script to copy to the editor"); + return; + } + + // Get the script content + const hardcodedAgent = AGENTS[selectedAgent as keyof typeof AGENTS]; + const savedScript = savedScripts[selectedAgent]; + const scriptContent = hardcodedAgent || savedScript; + + if (!scriptContent) { + alert("No script content available for the selected agent"); + return; + } + + // Warn user about losing current content + const confirmed = confirm( + `This will replace your current script with "${selectedAgent}". Your current work will be lost. Continue?` + ); + + if (confirmed && editorRef.current) { + editorRef.current.setValue(scriptContent); + } + }, [selectedAgent, savedScripts]); + return (
@@ -250,12 +395,69 @@ export default function Playground() {
+ +
+ +
+ +
+
+ + + setScriptName( + e.target.value + ) + } + maxLength={32} + /> +
+ +
+ + + setSaveSecret( + e.target.value + ) + } + /> +
+ +
+ +
+
} /> diff --git a/webui/src/lib/saveConfig.ts b/webui/src/lib/saveConfig.ts new file mode 100644 index 0000000..c238e65 --- /dev/null +++ b/webui/src/lib/saveConfig.ts @@ -0,0 +1,8 @@ +export const SAVE_CONFIG = { + ENABLE_SAVE: true, + SAVE_SECRET: "save", + + SAVE_DIRECTORY: "./data/scripts", + MAX_FILENAME_LENGTH: 32, + FILENAME_REGEX: /^[a-zA-Z0-9_\s-]+$/, +} as const; diff --git a/webui/src/styles/Playground.module.css b/webui/src/styles/Playground.module.css index 7ad95a8..1943538 100644 --- a/webui/src/styles/Playground.module.css +++ b/webui/src/styles/Playground.module.css @@ -117,6 +117,31 @@ margin-bottom: 6px; } +.saveInput { + width: 100%; + padding: 6px 8px; + background: #3c3c3c; + color: #e0e0e0; + border: 1px solid #555; + border-radius: 3px; + font-size: 13px; + outline: none; +} + +.saveInput:focus { + border-color: #007acc; + box-shadow: 0 0 3px rgba(0, 122, 204, 0.3); +} + +.saveInput::placeholder { + color: #999; +} + +.saveSection { + margin-top: 24px; + padding-top: 16px; + border-top: 1px solid #444; +} .helpPanel { padding: 16px;