Spaces:
Runtime error
Runtime error
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>Novel Prose Generator</title> | |
| <link rel="icon" type="image/x-icon" href="/static/favicon.ico"> | |
| <script src="https://cdn.tailwindcss.com"></script> | |
| <style> | |
| /* Custom styles for progress bar, copy button, and file upload */ | |
| .progress-bar { | |
| width: 200px; | |
| height: 20px; | |
| background-color: #4b5563; | |
| border-radius: 4px; | |
| overflow: hidden; | |
| display: none; | |
| margin-left: 16px; | |
| } | |
| .progress-bar.active { | |
| display: block; | |
| } | |
| .progress-bar-fill { | |
| height: 100%; | |
| background-color: #4f46e5; | |
| width: 0%; | |
| transition: width 0.1s ease-in-out; | |
| animation: progress 2s linear infinite; | |
| } | |
| @keyframes progress { | |
| 0% { width: 0%; } | |
| 50% { width: 80%; } | |
| 100% { width: 0%; } | |
| } | |
| .progress-bar-text { | |
| position: absolute; | |
| top: 50%; | |
| transform: translateY(-50%); | |
| left: 230px; | |
| color: #ffffff; | |
| font-size: 0.875rem; | |
| display: none; | |
| } | |
| .progress-bar-text.active { | |
| display: block; | |
| } | |
| .tooltip { | |
| position: relative; | |
| } | |
| .tooltip .tooltip-text { | |
| visibility: hidden; | |
| width: 120px; | |
| background-color: #4b5563; | |
| color: #fff; | |
| text-align: center; | |
| border-radius: 6px; | |
| padding: 5px; | |
| position: absolute; | |
| z-index: 1; | |
| bottom: 125%; | |
| left: 50%; | |
| margin-left: -60px; | |
| opacity: 0; | |
| transition: opacity 0.3s; | |
| } | |
| .tooltip:hover .tooltip-text { | |
| visibility: visible; | |
| opacity: 1; | |
| } | |
| .error-message { | |
| color: #f87171; | |
| font-size: 0.875rem; | |
| margin-top: 0.25rem; | |
| } | |
| </style> | |
| </head> | |
| <body class="bg-gray-900 text-gray-100 min-h-screen flex flex-col"> | |
| <!-- Header --> | |
| <header class="bg-gray-800 py-4 shadow-md"> | |
| <div class="container mx-auto px-4"> | |
| <h1 class="text-2xl font-bold">Novel Prose Generator</h1> | |
| </div> | |
| </header> | |
| <!-- Main Content --> | |
| <main class="container mx-auto px-4 py-8 flex-grow"> | |
| <div class="grid grid-cols-1 md:grid-cols-2 gap-8"> | |
| <!-- Input Form --> | |
| <div class="bg-gray-800 p-6 rounded-lg shadow-lg"> | |
| <h2 class="text-xl font-semibold mb-4">Input Details</h2> | |
| <form id="proseForm" class="space-y-4" aria-label="Prose Generation Form"> | |
| <div> | |
| <label for="projectId" class="block text-sm font-medium">Project ID</label> | |
| <input type="text" id="projectId" name="projectId" value="Beyond the Cradle" required class="mt-1 block w-full bg-gray-700 border-gray-600 rounded-md p-2 text-gray-100 focus:ring-indigo-500 focus:border-indigo-500" aria-required="true"> | |
| <p id="projectIdError" class="error-message hidden">Project ID is required.</p> | |
| </div> | |
| <div> | |
| <label for="model" class="block text-sm font-medium">Model</label> | |
| <select id="model" name="model" class="mt-1 block w-full bg-gray-700 border-gray-600 rounded-md p-2 text-gray-100 focus:ring-indigo-500 focus:border-indigo-500" aria-label="Select AI model"> | |
| <option value="deepseek/deepseek-r1-0528:free">Deepseek(Free)</option> | |
| <option value="anthropic/claude-3.5-sonnet">Claude 3.5 Sonnet</option> | |
| <option value="openai/gpt-4o">GPT-4o</option> | |
| <option value="meta-llama/llama-3.1-70b-instruct">LLaMA 3.1 70B Instruct</option> | |
| </select> | |
| </div> | |
| <div> | |
| <label for="manuscriptMode" class="block text-sm font-medium">Manuscript Mode</label> | |
| <select id="manuscriptMode" name="manuscriptMode" class="mt-1 block w-full bg-gray-700 border-gray-600 rounded-md p-2 text-gray-100 focus:ring-indig | |
| o-500 focus:border-indigo-500" aria-label="Select manuscript mode"> | |
| <option value="summary">Summary</option> | |
| <option value="full">Full</option> | |
| <option value="excerpts">Excerpts</option> | |
| </select> | |
| </div> | |
| <div> | |
| <label for="manuscript" class="block text-sm font-medium">Manuscript</label> | |
| <textarea id="manuscript" name="manuscript" rows="6" maxlength="150000" class="mt-1 block w-full bg-gray-700 border-gray-600 rounded-md p-2 text-gray-100 focus:ring-indigo-500 focus:border-indigo-500" placeholder="Paste your manuscript here (up to 150,000 characters)..." aria-describedby="manuscriptCharCount"></textarea> | |
| <p id="manuscriptCharCount" class="text-sm text-gray-400 mt-1">Characters: <span id="manuscriptCount">0</span>/150,000</p> | |
| <input type="file" id="manuscriptFile" accept=".txt" class="mt-2 block w-full text-gray-100" aria-label="Upload manuscript file"> | |
| <p id="manuscriptFileError" class="error-message hidden">File must be a .txt file under 150,000 characters.</p> | |
| </div> | |
| <div> | |
| <label for="outline" class="block text-sm font-medium">Outline</label> | |
| <textarea id="outline" name="outline" rows="4" class="mt-1 block w-full bg-gray-700 border-gray-600 rounded-md p-2 text-gray-100 focus:ring-indigo-500 focus:border-indigo-500" placeholder="Enter your story outline..."></textarea> | |
| </div> | |
| <div> | |
| <label for="characters" class="block text-sm font-medium">Characters</label> | |
| <textarea id="characters" name="characters" rows="4" class="mt-1 block w-full bg-gray-700 border-gray-600 rounded-md p-2 text-gray-100 focus:ring-indigo-500 focus:border-indigo-500" placeholder="Describe your characters..."></textarea> | |
| </div> | |
| <div> | |
| <label for="prompt" class="block text-sm font-medium">Prompt</label> | |
| <textarea id="prompt" name="prompt" rows="4" required class="mt-1 block w-full bg-gray-700 border-gray-600 rounded-md p-2 text-gray-100 focus:ring-indigo-500 focus:border-indigo-500" placeholder="Enter your specific prose generation prompt..." aria-required="true"></textarea> | |
| <p id="promptError" class="error-message hidden">Prompt is required.</p> | |
| </div> | |
| <div class="flex space-x-4"> | |
| <button type="submit" id="generateBtn" class="bg-indigo-600 hover:bg-indigo-700 text-white font-bold py-2 px-4 rounded-md" aria-label="Generate prose">Generate Prose</button> | |
| <button type="button" id="loadInputsBtn" class="bg-gray-600 hover:bg-gray-700 text-white font-bold py-2 px-4 rounded-md" aria-label="Load latest inputs">Load Latest Inputs</button> | |
| </div> | |
| </form> | |
| </div> | |
| <!-- Output Section --> | |
| <div class="bg-gray-800 p-6 rounded-lg shadow-lg"> | |
| <div class="flex items-center mb-4"> | |
| <h2 class="text-xl font-semibold">Generated Prose</h2> | |
| <div id="progressBar" class="progress-bar" role="progressbar" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100"> | |
| <div class="progress-bar-fill"></div> | |
| </div> | |
| <span id="progressText" class="progress-bar-text">Processing manuscript...</span> | |
| <div class="tooltip ml-auto"> | |
| <button id="copyBtn" class="bg-gray-600 hover:bg-gray-700 text-white p-2 rounded-md hidden" aria-label="Copy generated prose"> | |
| <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"> | |
| <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"></path> | |
| </svg> | |
| </button> | |
| <span class="tooltip-text">Copy to Clipboard</span> | |
| </div> | |
| </div> | |
| <div id="proseOutput" class="bg-gray-700 p-4 rounded-md text-gray-100 h-96 overflow-y-auto" role="region" aria-live="polite"></div> | |
| <div id="errorOutput" class="text-red-400 mt-2 hidden" role="alert"></div> | |
| <div id="tokenUsage" class="mt-4 text-sm text-gray-400 hidden"> | |
| Estimated Tokens Used: <span id="tokenCount">0</span> | |
| </div> | |
| </div> | |
| </div> | |
| </main> | |
| <!-- Footer --> | |
| <footer class="bg-gray-800 py-4 mt-auto"> | |
| <div class="container mx-auto px-4 text-center text-gray-400"> | |
| <p>© 2025 Novel Prose Generator. Powered by Hugging Face and OpenRouter.</p> | |
| </div> | |
| </footer> | |
| <script> | |
| // Estimate token count (heuristic: 1 word ≈ 1.33 tokens for English) | |
| function estimateTokens(text) { | |
| if (!text) return 0; | |
| const words = text.trim().split(/\s+/).length; | |
| return Math.round(words * 1.33); | |
| } | |
| // Show progress bar | |
| function showProgress(message = "Processing manuscript...") { | |
| document.getElementById('progressText').textContent = message; | |
| document.getElementById('progressBar').classList.add('active'); | |
| document.getElementById('progressText').classList.add('active'); | |
| } | |
| // Hide progress bar | |
| function hideProgress() { | |
| document.getElementById('progressBar').classList.remove('active'); | |
| document.getElementById('progressText').classList.remove('active'); | |
| } | |
| // Show error message | |
| function showError(elementId, message) { | |
| const errorElement = document.getElementById(elementId); | |
| errorElement.textContent = message; | |
| errorElement.classList.remove('hidden'); | |
| } | |
| // Hide error message | |
| function hideError(elementId) { | |
| const errorElement = document.getElementById(elementId); | |
| errorElement.classList.add('hidden'); | |
| } | |
| // Copy prose to clipboard | |
| async function copyProse() { | |
| const proseOutput = document.getElementById('proseOutput').textContent; | |
| if (!proseOutput) { | |
| alert('No prose to copy!'); | |
| return; | |
| } | |
| try { | |
| await navigator.clipboard.writeText(proseOutput); | |
| const tooltipText = document.querySelector('.tooltip-text'); | |
| tooltipText.textContent = 'Copied!'; | |
| setTimeout(() => { | |
| tooltipText.textContent = 'Copy to Clipboard'; | |
| }, 2000); | |
| } catch (error) { | |
| console.error('Error copying prose:', error); | |
| alert('Failed to copy prose: ' + error.message); | |
| } | |
| } | |
| // Update manuscript character count | |
| function updateManuscriptCount() { | |
| const manuscript = document.getElementById('manuscript').value; | |
| document.getElementById('manuscriptCount').textContent = manuscript.length; | |
| } | |
| // Handle file upload | |
| document.getElementById('manuscriptFile').addEventListener('change', async (e) => { | |
| const file = e.target.files[0]; | |
| if (!file) return; | |
| if (!file.name.endsWith('.txt')) { | |
| showError('manuscriptFileError', 'File must be a .txt file.'); | |
| e.target.value = ''; | |
| return; | |
| } | |
| try { | |
| const text = await file.text(); | |
| if (text.length > 150000) { | |
| showError('manuscriptFileError', 'File exceeds 150,000 characters.'); | |
| e.target.value = ''; | |
| return; | |
| } | |
| document.getElementById('manuscript').value = text; | |
| updateManuscriptCount(); | |
| hideError('manuscriptFileError'); | |
| } catch (error) { | |
| showError('manuscriptFileError', 'Error reading file: ' + error.message); | |
| e.target.value = ''; | |
| } | |
| }); | |
| // Handle manuscript input | |
| document.getElementById('manuscript').addEventListener('input', () => { | |
| updateManuscriptCount(); | |
| hideError('manuscriptFileError'); | |
| }); | |
| // Validate form | |
| function validateForm(data) { | |
| let valid = true; | |
| if (!data.projectId) { | |
| showError('projectIdError', 'Project ID is required.'); | |
| valid = false; | |
| } else { | |
| hideError('projectIdError'); | |
| } | |
| if (!data.prompt) { | |
| showError('promptError', 'Prompt is required.'); | |
| valid = false; | |
| } else { | |
| hideError('promptError'); | |
| } | |
| return valid; | |
| } | |
| // Handle form submission | |
| document.getElementById('proseForm').addEventListener('submit', async (e) => { | |
| e.preventDefault(); | |
| hideError('errorOutput'); | |
| const formData = new FormData(e.target); | |
| const data = { | |
| model: formData.get('model'), | |
| manuscript: formData.get('manuscript'), | |
| outline: formData.get('outline'), | |
| characters: formData.get('characters'), | |
| prompt: formData.get('prompt'), | |
| projectId: formData.get('projectId'), | |
| manuscriptMode: formData.get('manuscriptMode') | |
| }; | |
| if (!validateForm(data)) { | |
| return; | |
| } | |
| // Client-side manuscript chunking (send up to 50,000 chars to avoid server overload) | |
| const maxChunkSize = 50000; | |
| const manuscriptChunks = []; | |
| for (let i = 0; i < data.manuscript.length; i += maxChunkSize) { | |
| manuscriptChunks.push(data.manuscript.slice(i, i + maxChunkSize)); | |
| } | |
| data.manuscript = manuscriptChunks[0] || ''; // Send first chunk only for now | |
| showProgress(); | |
| try { | |
| const response = await fetch('/generate', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify(data) | |
| }); | |
| if (!response.ok) { | |
| const errorData = await response.json(); | |
| throw new Error(errorData.detail || `HTTP error! status: ${response.status}`); | |
| } | |
| const proseOutput = document.getElementById('proseOutput'); | |
| proseOutput.textContent = ''; | |
| let fullText = ''; | |
| // Stream the response | |
| const reader = response.body.getReader(); | |
| const decoder = new TextDecoder(); | |
| while (true) { | |
| const { done, value } = await reader.read(); | |
| if (done) break; | |
| const chunk = decoder.decode(value, { stream: true }); | |
| fullText += chunk; | |
| proseOutput.textContent = fullText; | |
| proseOutput.scrollTop = proseOutput.scrollHeight; | |
| } | |
| // Update token usage | |
| const tokenCount = estimateTokens(fullText); | |
| document.getElementById('tokenCount').textContent = tokenCount; | |
| document.getElementById('tokenUsage').classList.remove('hidden'); | |
| // Show copy button | |
| document.getElementById('copyBtn').classList.remove('hidden'); | |
| // Check for server-side errors (e.g., summarization errors) | |
| if (fullText.includes('Error:') || fullText.includes('Failed to generate')) { | |
| showError('errorOutput', 'Server error occurred. Check logs for details.'); | |
| } | |
| } catch (error) { | |
| console.error('Error generating prose:', error); | |
| document.getElementById('proseOutput').textContent = `Error: ${error.message}`; | |
| showError('errorOutput', error.message); | |
| } finally { | |
| hideProgress(); | |
| } | |
| }); | |
| // Handle load latest inputs | |
| document.getElementById('loadInputsBtn').addEventListener('click', async () => { | |
| const projectId = document.getElementById('projectId').value; | |
| if (!projectId) { | |
| showError('projectIdError', 'Please enter a Project ID.'); | |
| return; | |
| } | |
| showProgress('Loading inputs...'); | |
| hideError('errorOutput'); | |
| try { | |
| const response = await fetch(`/inputs/${encodeURIComponent(projectId)}`); | |
| if (!response.ok) { | |
| throw new Error(`HTTP error! status: ${response.status}`); | |
| } | |
| const data = await response.json(); | |
| document.getElementById('manuscript').value = data.manuscript || ''; | |
| updateManuscriptCount(); | |
| document.getElementById('outline').value = data.outline || ''; | |
| document.getElementById('characters').value = data.characters || ''; | |
| // Do not overwrite prompt to preserve user input | |
| hideError('projectIdError'); | |
| } catch (error) { | |
| console.error('Error loading inputs:', error); | |
| showError('errorOutput', `Error loading inputs: ${error.message}`); | |
| } finally { | |
| hideProgress(); | |
| } | |
| }); | |
| // Handle copy button click | |
| document.getElementById('copyBtn').addEventListener('click', copyProse); | |
| // Initialize manuscript character count | |
| updateManuscriptCount(); | |
| </script> | |
| </body> | |
| </html> |