diff --git a/.gitignore b/.gitignore index a547bf3..a9b7dad 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,6 @@ dist-ssr *.njsproj *.sln *.sw? + +# Python cache +__pycache__/ diff --git a/package-lock.json b/package-lock.json index b72de8c..df13162 100644 --- a/package-lock.json +++ b/package-lock.json @@ -72,7 +72,6 @@ "integrity": "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", @@ -1738,7 +1737,6 @@ "integrity": "sha512-AwAfQ2Wa5bCx9WP8nZL2uMZWod7J7/JSplxbTmBQ5ms6QpqNYm672H0Vu9ZVKVngQ+ii4R/byguVEUZQyeg44g==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.0.2" } @@ -1780,7 +1778,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -1902,7 +1899,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "caniuse-lite": "^1.0.30001726", "electron-to-chromium": "^1.5.173", @@ -2117,7 +2113,6 @@ "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "license": "ISC", - "peer": true, "engines": { "node": ">=12" } @@ -2337,7 +2332,6 @@ "integrity": "sha512-QldCVh/ztyKJJZLr4jXNUByx3gR+TDYZCRXEktiZoUR3PGy4qCmSbkxcIle8GEwGpb5JBZazlaJ/CxLidXdEbQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.12.1", @@ -3180,7 +3174,6 @@ "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -3248,7 +3241,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz", "integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -3258,7 +3250,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz", "integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.26.0" }, @@ -3523,7 +3514,6 @@ "integrity": "sha512-SkaSguuS7nnmV7mfJ8l81JGBFV7Gvzp8IzgE8A8t23+AxuNX61Q5H1Tpz5efduSN7NHC8nQXD3sKQKZAu5mNEA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.6", diff --git a/src/App.jsx b/src/App.jsx index 5f3df15..5afa541 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -1,5 +1,6 @@ // 📦 Imports import React, { useState, useRef } from 'react'; +import SettingsModal from './SettingsModal'; import ReactFlow, { Background, Controls, @@ -17,6 +18,18 @@ const initialNodes = []; const initialEdges = []; let nodeId = 1; +const defaultSystemPrompt = `You are an expert in cause-and-effect analysis. + +Given a news headline or event, break it down into a chain of consequences, like a domino effect. + +Each node must: +- Be short (max 15 words) +- Represent one specific consequence +- Be categorized as either: 'positive', 'neutral', or 'negative' +- Be logically linked to the prompt + +Return only 3 outputs: one of each type.`; + const getColor = (type) => { switch (type) { case 'positive': return '#00c853'; @@ -27,7 +40,7 @@ const getColor = (type) => { }; // 🤖 AI Logic with Domino Effect Thinking (Updated: Using Puter AI API) -const createAIChildren = async (text, parentId, setNodes, setEdges, stopGenerationRef) => { +const createAIChildren = async (text, parentId, setNodes, setEdges, stopGenerationRef, systemPrompt) => { try { if (stopGenerationRef.current) return; const res = await axios.post( @@ -37,17 +50,7 @@ const createAIChildren = async (text, parentId, setNodes, setEdges, stopGenerati messages: [ { role: 'system', - content: `You are an expert in cause-and-effect analysis. - -Given a news headline or event, break it down into a chain of consequences, like a domino effect. - -Each node must: -- Be short (max 15 words) -- Represent one specific consequence -- Be categorized as either: 'positive', 'neutral', or 'negative' -- Be logically linked to the prompt - -Return only 3 outputs: one of each type.` + content: systemPrompt }, { role: 'user', content: text } ] @@ -96,7 +99,7 @@ Return only 3 outputs: one of each type.` // Recursively expand each child one level deep for (const n of newNodes) { if (stopGenerationRef.current) break; - await createAIChildren(n.data.label, n.id, setNodes, setEdges, stopGenerationRef); + await createAIChildren(n.data.label, n.id, setNodes, setEdges, stopGenerationRef, systemPrompt); } } catch (err) { @@ -113,6 +116,10 @@ export default function App() { const [edges, setEdges] = useState(initialEdges); const [prompt, setPrompt] = useState(''); const [loading, setLoading] = useState(false); + const [isSettingsOpen, setIsSettingsOpen] = useState(false); + const [systemPrompt, setSystemPrompt] = useState( + localStorage.getItem('customSystemPrompt') || defaultSystemPrompt + ); const stopGenerationRef = useRef(false); const reactFlowWrapper = useRef(null); @@ -126,6 +133,16 @@ export default function App() { } }; + const handleSaveSystemPrompt = (newPrompt) => { + setSystemPrompt(newPrompt); + localStorage.setItem('customSystemPrompt', newPrompt); + }; + + const handleResetSystemPrompt = () => { + setSystemPrompt(defaultSystemPrompt); + localStorage.removeItem('customSystemPrompt'); + }; + const handleSubmit = async () => { if (!prompt.trim()) return; setLoading(true); @@ -150,7 +167,7 @@ export default function App() { setNodes([rootNode]); setEdges([]); - await createAIChildren(prompt, rootId, setNodes, setEdges, stopGenerationRef); + await createAIChildren(prompt, rootId, setNodes, setEdges, stopGenerationRef, systemPrompt); setPrompt(''); setLoading(false); @@ -164,7 +181,7 @@ export default function App() { const onNodeClick = async (_event, node) => { stopGenerationRef.current = false; setLoading(true); - await createAIChildren(node.data.label, node.id, setNodes, setEdges, stopGenerationRef); + await createAIChildren(node.data.label, node.id, setNodes, setEdges, stopGenerationRef, systemPrompt); setLoading(false); }; @@ -246,6 +263,20 @@ export default function App() { > Export as PNG + setIsSettingsOpen(true)} + style={{ + padding: '10px 20px', + backgroundColor: '#6c757d', + color: 'white', + border: 'none', + borderRadius: '8px', + fontWeight: 'bold', + cursor: 'pointer', + }} + > + Settings + @@ -265,6 +296,13 @@ export default function App() { + setIsSettingsOpen(false)} + onSave={handleSaveSystemPrompt} + onReset={handleResetSystemPrompt} + systemPrompt={systemPrompt} + /> ); } diff --git a/src/SettingsModal.jsx b/src/SettingsModal.jsx new file mode 100644 index 0000000..7c789fe --- /dev/null +++ b/src/SettingsModal.jsx @@ -0,0 +1,118 @@ +import React, { useState, useEffect } from 'react'; + +const SettingsModal = ({ isOpen, onClose, onSave, onReset, systemPrompt }) => { + const [prompt, setPrompt] = useState(systemPrompt); + + useEffect(() => { + setPrompt(systemPrompt); + }, [systemPrompt, isOpen]); + + const handleSave = () => { + onSave(prompt); + onClose(); + }; + + const handleReset = () => { + onReset(); + onClose(); + }; + + if (!isOpen) return null; + + return ( + + + AI Settings + System Prompt: + setPrompt(e.target.value)} + style={styles.textarea} + placeholder="Enter custom AI system prompt..." + /> + + Save + Reset to Default + Close + + + + ); +}; + +const styles = { + overlay: { + position: 'fixed', + top: 0, + left: 0, + right: 0, + bottom: 0, + backgroundColor: 'rgba(0, 0, 0, 0.7)', + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + zIndex: 1000, + }, + modal: { + backgroundColor: '#333', + padding: '2rem', + borderRadius: '12px', + width: '500px', + color: '#fff', + boxShadow: '0 5px 15px rgba(0,0,0,0.3)', + }, + header: { + marginTop: 0, + marginBottom: '1.5rem', + borderBottom: '1px solid #555', + paddingBottom: '1rem', + }, + label: { + marginBottom: '0.5rem', + color: '#ccc', + }, + textarea: { + width: '100%', + minHeight: '150px', + padding: '10px', + borderRadius: '8px', + border: '1px solid #555', + color: '#fff', + backgroundColor: '#444', + marginBottom: '1.5rem', + boxSizing: 'border-box', + fontSize: '1rem', + }, + buttonContainer: { + display: 'flex', + justifyContent: 'flex-end', + gap: '0.75rem', + }, + saveButton: { + padding: '10px 20px', + backgroundColor: '#00bcd4', + color: 'white', + border: 'none', + borderRadius: '8px', + fontWeight: 'bold', + cursor: 'pointer', + }, + resetButton: { + padding: '10px 20px', + backgroundColor: '#ff4d4d', + color: '#fff', + border: 'none', + borderRadius: '8px', + cursor: 'pointer', + }, + closeButton: { + padding: '10px 20px', + backgroundColor: '#666', + color: 'white', + border: 'none', + borderRadius: '8px', + cursor: 'pointer', + }, +}; + +export default SettingsModal; diff --git a/tests/test_app.py b/tests/test_app.py new file mode 100644 index 0000000..29d373e --- /dev/null +++ b/tests/test_app.py @@ -0,0 +1,65 @@ +import re +from playwright.sync_api import Page, expect +import pytest + +def test_custom_system_prompt(page: Page): + # Start the dev server + # This is handled by the test runner + + # Navigate to the application + page.goto("http://localhost:5173") + + # Open the settings modal + page.click("button:has-text('Settings')") + + # Set a custom system prompt + custom_prompt = "You are a helpful assistant." + page.fill("textarea", custom_prompt) + + # Save the prompt + page.click("button:has-text('Save')") + + # Verify the prompt is saved to localStorage + local_storage_prompt = page.evaluate("localStorage.getItem('customSystemPrompt')") + assert local_storage_prompt == custom_prompt + + # Reload the page + page.reload() + + # Verify the prompt is loaded from localStorage + # Re-open the settings modal to check the value + page.click("button:has-text('Settings')") + expect(page.locator("textarea")).to_have_value(custom_prompt) + page.click("button:has-text('Close')") + + # Mock the AI API call to check if the custom prompt is used + page.route( + "https://api.puter.com/v1/chat/completions", + lambda route: route.fulfill( + status=200, + json={ + "choices": [ + { + "message": { + "content": "1. First idea\n2. Second idea\n3. Third idea" + } + } + ] + }, + ), + ) + + + # Assert that the mocked API was called with the correct custom prompt + # by triggering an AI call inside a request expectation block. + with page.expect_request("https://api.puter.com/v1/chat/completions") as request_info: + page.fill("input[type='text']", "Test prompt") + page.click("button:has-text('Generate Node')") + + request = request_info.value + body = request.post_data_json + + system_message = next((msg for msg in body["messages"] if msg["role"] == "system"), None) + + assert system_message is not None + assert system_message["content"] == custom_prompt
System Prompt: