From 8e6df8b2353b21b9c14de6392be569d075f7d15e Mon Sep 17 00:00:00 2001 From: Millerderek Date: Fri, 20 Feb 2026 00:11:01 -0500 Subject: [PATCH] Move OpenClaw logic into dedicated source module --- .github/ISSUE_TEMPLATE/bug_report.md | 29 +++ .github/ISSUE_TEMPLATE/config.yml | 5 + .github/ISSUE_TEMPLATE/feature_request.md | 19 ++ .github/pull_request_template.md | 17 ++ .github/workflows/ci.yml | 23 +++ .gitignore | 16 ++ LICENSE | 21 ++ README.md | 69 +++++++ index.html | 83 ++++++++ src/app.js | 222 ++++++++++++++++++++++ src/openclaw.js | 50 +++++ src/styles.css | 154 +++++++++++++++ 12 files changed, 708 insertions(+) create mode 100644 .github/ISSUE_TEMPLATE/bug_report.md create mode 100644 .github/ISSUE_TEMPLATE/config.yml create mode 100644 .github/ISSUE_TEMPLATE/feature_request.md create mode 100644 .github/pull_request_template.md create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 index.html create mode 100644 src/app.js create mode 100644 src/openclaw.js create mode 100644 src/styles.css diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..f47c6f4 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,29 @@ +--- +name: Bug report +about: Report a reproducible problem in the OpenClaw webapp +title: "[Bug] " +labels: bug +assignees: '' +--- + +## Description +A clear description of the bug. + +## Steps to reproduce +1. +2. +3. + +## Expected behavior +What you expected to happen. + +## Actual behavior +What actually happened. + +## Environment +- OS: +- Browser: +- Commit/branch: + +## Additional context +Logs, screenshots, or recordings. diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..0a6c301 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: false +contact_links: + - name: Questions and support + url: https://github.com///discussions + about: Please use Discussions for questions. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..8176fd0 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,19 @@ +--- +name: Feature request +about: Suggest an idea to improve the OpenClaw webapp +title: "[Feature] " +labels: enhancement +assignees: '' +--- + +## Problem statement +What problem are you trying to solve? + +## Proposed solution +Describe the solution you want. + +## Alternatives considered +Other approaches you evaluated. + +## Additional context +Mockups, references, or constraints. diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..bd61a2d --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,17 @@ +## Summary +- + +## Changes +- + +## Testing +- [ ] I ran `node --check src/app.js` (or equivalent checks) +- [ ] I verified the app loads locally (`python3 -m http.server 4173`) + +## Screenshots (if UI changed) +- + +## Checklist +- [ ] My change is focused and minimal. +- [ ] I updated docs if behavior changed. +- [ ] No secrets/API keys are committed. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..2d94ffa --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,23 @@ +name: CI + +on: + push: + branches: ["**"] + pull_request: + +jobs: + syntax-check: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: JavaScript syntax check + run: | + node --check src/openclaw.js + node --check src/app.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3359140 --- /dev/null +++ b/.gitignore @@ -0,0 +1,16 @@ +# OS/editor +.DS_Store +.vscode/ +.idea/ + +# Logs +*.log + +# Env files (if users later add backend/proxy config) +.env +.env.* + +# Node artifacts +node_modules/ +dist/ +coverage/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..14fac91 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..7a18c3d --- /dev/null +++ b/README.md @@ -0,0 +1,69 @@ +# OpenClaw Voice Chat Webapp + +A lightweight browser UI to chat with an OpenClaw assistant powered by **Gemini** for chat responses, with optional **ElevenLabs** text-to-speech and speech-to-text. + +## GitHub-ready project structure + +```text +. +├── .github/ +│ └── workflows/ +│ └── ci.yml +├── src/ +│ ├── app.js +│ ├── openclaw.js +│ └── styles.css +├── .gitignore +├── index.html +├── LICENSE +└── README.md +``` + +## GitHub collaboration files + +This repository now includes: + +- Issue templates for bug reports and feature requests in `.github/ISSUE_TEMPLATE/` +- A pull request template at `.github/pull_request_template.md` + +> Note: Update `.github/ISSUE_TEMPLATE/config.yml` discussion URL to your real repository path. + +## Features + +- Gemini chat completion (`generateContent`) with model selection +- ElevenLabs TTS playback for assistant responses +- ElevenLabs STT from mic recording +- Browser SpeechRecognition fallback when no ElevenLabs key is provided +- Browser speech synthesis fallback when no ElevenLabs key/voice is provided + +## Run locally + +```bash +python3 -m http.server 4173 +``` + +Open `http://localhost:4173` in your browser. + +## Usage + +1. Enter your Gemini API key. +2. (Optional) Enter ElevenLabs API key and voice ID. +3. Type a message or click **Voice input**. +4. Toggle **Auto speak assistant replies** to hear responses automatically. + +## How OpenClaw bot behavior is implemented in this webapp + +The "OpenClaw bot" in this project is implemented as a **prompted Gemini chat loop** in the browser: + +1. `state.messages` starts with a system instruction that defines OpenClaw as concise, friendly, and practical. +2. Every user message is appended to `state.messages` and rendered in the chat UI. +3. `callGemini()` transforms conversation messages into Gemini `contents` and sends them to `models/{model}:generateContent`. +4. Gemini's reply is added back as an `assistant` message and can be auto-spoken. +5. Voice input is recorded with `MediaRecorder`; transcription uses ElevenLabs STT when configured, otherwise browser SpeechRecognition fallback. +6. Voice output uses ElevenLabs TTS when configured, otherwise browser `speechSynthesis` fallback. + +In short: there is no separate server-side OpenClaw runtime here; the browser orchestrates prompts, model calls, and voice I/O. + +The OpenClaw behavior implementation now lives in `src/openclaw.js`, while `src/app.js` handles UI and voice interactions. + +> ⚠️ API keys are entered client-side and used directly by browser requests. For production deployments, route requests through your backend. diff --git a/index.html b/index.html new file mode 100644 index 0000000..72885e3 --- /dev/null +++ b/index.html @@ -0,0 +1,83 @@ + + + + + + + OpenClaw Voice Chat + + + +
+ + +
+
+ +
+ +
+ + +
+
+
+
+ + + + + + diff --git a/src/app.js b/src/app.js new file mode 100644 index 0000000..3b7ade5 --- /dev/null +++ b/src/app.js @@ -0,0 +1,222 @@ +import { callGeminiChat, createOpenClawInitialMessages } from './openclaw.js'; + +const state = { + messages: createOpenClawInitialMessages(), + mediaRecorder: null, + chunks: [], + isRecording: false +}; + +const els = { + messages: document.getElementById('messages'), + composer: document.getElementById('composer'), + messageInput: document.getElementById('messageInput'), + sendButton: document.getElementById('sendButton'), + micButton: document.getElementById('micButton'), + clearChat: document.getElementById('clearChat'), + template: document.getElementById('messageTemplate'), + autoSpeak: document.getElementById('autoSpeak'), + geminiApiKey: document.getElementById('geminiApiKey'), + geminiModel: document.getElementById('geminiModel'), + elevenApiKey: document.getElementById('elevenApiKey'), + elevenVoiceId: document.getElementById('elevenVoiceId'), + elevenSttModel: document.getElementById('elevenSttModel') +}; + +function addMessage(role, content) { + state.messages.push({ role, content }); + renderMessage({ role, content }); +} + +function renderMessage(message) { + const node = els.template.content.firstElementChild.cloneNode(true); + node.classList.add(message.role); + node.querySelector('.role').textContent = message.role.toUpperCase(); + node.querySelector('.content').textContent = message.content; + + const speakBtn = node.querySelector('.speak'); + if (message.role !== 'assistant') { + speakBtn.hidden = true; + } else { + speakBtn.addEventListener('click', () => speak(message.content)); + } + + els.messages.appendChild(node); + els.messages.scrollTop = els.messages.scrollHeight; +} + +function rerender() { + els.messages.innerHTML = ''; + state.messages.forEach(renderMessage); +} + +async function sendMessage(text) { + addMessage('user', text); + els.sendButton.disabled = true; + + try { + const reply = await callGeminiChat({ + messages: state.messages, + apiKey: els.geminiApiKey.value.trim(), + model: els.geminiModel.value.trim() || 'gemini-1.5-flash' + }); + + addMessage('assistant', reply); + if (els.autoSpeak.checked) { + await speak(reply); + } + } catch (error) { + addMessage('system', error.message || String(error)); + } finally { + els.sendButton.disabled = false; + } +} + +async function speak(text) { + const elevenApiKey = els.elevenApiKey.value.trim(); + const voiceId = els.elevenVoiceId.value.trim(); + + if (!elevenApiKey || !voiceId) { + const utterance = new SpeechSynthesisUtterance(text); + speechSynthesis.speak(utterance); + return; + } + + const response = await fetch(`https://api.elevenlabs.io/v1/text-to-speech/${encodeURIComponent(voiceId)}/stream`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'xi-api-key': elevenApiKey, + Accept: 'audio/mpeg' + }, + body: JSON.stringify({ + text, + model_id: 'eleven_multilingual_v2' + }) + }); + + if (!response.ok) { + throw new Error(`ElevenLabs TTS failed (${response.status})`); + } + + const blob = await response.blob(); + const url = URL.createObjectURL(blob); + const audio = new Audio(url); + audio.onended = () => URL.revokeObjectURL(url); + await audio.play(); +} + +async function transcribeWithElevenLabs(audioBlob) { + const elevenApiKey = els.elevenApiKey.value.trim(); + const modelId = els.elevenSttModel.value.trim() || 'scribe_v1'; + if (!elevenApiKey) { + throw new Error('Missing ElevenLabs API key for STT.'); + } + + const form = new FormData(); + form.append('file', audioBlob, 'voice.webm'); + form.append('model_id', modelId); + + const response = await fetch('https://api.elevenlabs.io/v1/speech-to-text', { + method: 'POST', + headers: { + 'xi-api-key': elevenApiKey + }, + body: form + }); + + if (!response.ok) { + const body = await response.text(); + throw new Error(`ElevenLabs STT failed (${response.status}): ${body}`); + } + + const data = await response.json(); + return data.text?.trim() || ''; +} + +function transcribeWithBrowserSpeech() { + return new Promise((resolve, reject) => { + const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition; + if (!SpeechRecognition) { + reject(new Error('SpeechRecognition API not available; provide ElevenLabs API key for STT.')); + return; + } + + const recognition = new SpeechRecognition(); + recognition.lang = 'en-US'; + recognition.interimResults = false; + recognition.maxAlternatives = 1; + + recognition.onresult = (event) => { + resolve(event.results[0][0].transcript.trim()); + }; + recognition.onerror = (event) => reject(new Error(`Browser speech recognition error: ${event.error}`)); + recognition.start(); + }); +} + +async function startRecording() { + if (state.isRecording) { + state.mediaRecorder.stop(); + return; + } + + state.chunks = []; + const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); + const recorder = new MediaRecorder(stream); + state.mediaRecorder = recorder; + state.isRecording = true; + els.micButton.textContent = '⏹ Stop recording'; + + recorder.ondataavailable = (event) => { + if (event.data.size > 0) state.chunks.push(event.data); + }; + + recorder.onstop = async () => { + state.isRecording = false; + els.micButton.textContent = '🎤 Voice input'; + stream.getTracks().forEach((track) => track.stop()); + + try { + const audioBlob = new Blob(state.chunks, { type: 'audio/webm' }); + const transcript = els.elevenApiKey.value.trim() + ? await transcribeWithElevenLabs(audioBlob) + : await transcribeWithBrowserSpeech(); + + if (!transcript) { + addMessage('system', 'No speech detected.'); + return; + } + + els.messageInput.value = transcript; + await sendMessage(transcript); + } catch (error) { + addMessage('system', error.message || String(error)); + } + }; + + recorder.start(); +} + +els.composer.addEventListener('submit', async (event) => { + event.preventDefault(); + const text = els.messageInput.value.trim(); + if (!text) return; + els.messageInput.value = ''; + await sendMessage(text); +}); + +els.clearChat.addEventListener('click', () => { + state.messages = [state.messages[0]]; + rerender(); +}); + +els.micButton.addEventListener('click', async () => { + try { + await startRecording(); + } catch (error) { + addMessage('system', error.message || String(error)); + } +}); + +rerender(); diff --git a/src/openclaw.js b/src/openclaw.js new file mode 100644 index 0000000..e262a8c --- /dev/null +++ b/src/openclaw.js @@ -0,0 +1,50 @@ +export const OPENCLAW_SYSTEM_PROMPT = + 'You are OpenClaw, a concise and friendly assistant. Help the user while keeping answers practical.'; + +export function createOpenClawInitialMessages() { + return [ + { + role: 'system', + content: OPENCLAW_SYSTEM_PROMPT + } + ]; +} + +export function toGeminiPayload(messages) { + const contents = messages + .filter((m) => m.role !== 'system') + .map((msg) => ({ + role: msg.role === 'assistant' ? 'model' : 'user', + parts: [{ text: msg.content }] + })); + + const systemInstruction = messages.find((m) => m.role === 'system')?.content; + + return { + ...(systemInstruction && { systemInstruction: { parts: [{ text: systemInstruction }] } }), + contents + }; +} + +export async function callGeminiChat({ messages, apiKey, model }) { + if (!apiKey) { + throw new Error('Missing Gemini API key.'); + } + + const response = await fetch( + `https://generativelanguage.googleapis.com/v1beta/models/${encodeURIComponent(model)}:generateContent?key=${encodeURIComponent(apiKey)}`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(toGeminiPayload(messages)) + } + ); + + if (!response.ok) { + const body = await response.text(); + throw new Error(`Gemini request failed (${response.status}): ${body}`); + } + + const data = await response.json(); + return data.candidates?.[0]?.content?.parts?.map((p) => p.text).join('\n') || 'No reply.'; +} diff --git a/src/styles.css b/src/styles.css new file mode 100644 index 0000000..a6b1900 --- /dev/null +++ b/src/styles.css @@ -0,0 +1,154 @@ +:root { + color-scheme: dark; + --bg: #070b18; + --panel: #111a30; + --panel-muted: #1a2644; + --text: #f3f7ff; + --text-dim: #b8c4dc; + --accent: #70d8ff; + --danger: #ff6f91; +} + +* { + box-sizing: border-box; + font-family: Inter, system-ui, -apple-system, Segoe UI, Roboto, sans-serif; +} + +body { + margin: 0; + min-height: 100vh; + background: radial-gradient(circle at top right, #152952, var(--bg) 45%); + color: var(--text); +} + +.app-shell { + display: grid; + grid-template-columns: minmax(260px, 340px) 1fr; + min-height: 100vh; +} + +.settings-panel { + padding: 1.2rem; + background: rgba(13, 20, 36, 0.85); + border-right: 1px solid rgba(255, 255, 255, 0.08); + display: flex; + flex-direction: column; + gap: 0.8rem; +} + +h1 { + margin: 0; + font-size: 1.4rem; +} + +.subtitle { + margin: 0; + color: var(--text-dim); + font-size: 0.9rem; +} + +label { + display: grid; + gap: 0.35rem; + font-size: 0.84rem; + color: var(--text-dim); +} + +input, +select, +textarea, +button { + border: 1px solid rgba(255, 255, 255, 0.14); + border-radius: 0.6rem; + background: var(--panel-muted); + color: var(--text); + padding: 0.6rem 0.7rem; +} + +textarea { + width: 100%; + resize: vertical; +} + +button { + cursor: pointer; + background: linear-gradient(110deg, #3999ff, #66dbff); + color: #04122e; + font-weight: 600; +} + +button.secondary { + background: var(--panel-muted); + color: var(--text); +} + +.checkbox-row { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.chat-panel { + display: grid; + grid-template-rows: 1fr auto; + min-height: 100vh; +} + +.messages { + padding: 1rem; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.message { + border: 1px solid rgba(255, 255, 255, 0.08); + background: rgba(17, 26, 48, 0.8); + border-radius: 0.75rem; + padding: 0.75rem; +} + +.message header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 0.5rem; +} + +.message.user { + border-color: rgba(112, 216, 255, 0.5); +} + +.message.system { + border-color: rgba(255, 111, 145, 0.5); +} + +.message .content { + margin: 0; + white-space: pre-wrap; +} + +.composer { + border-top: 1px solid rgba(255, 255, 255, 0.1); + background: rgba(10, 15, 28, 0.9); + padding: 0.85rem; +} + +.actions { + display: flex; + justify-content: flex-end; + gap: 0.55rem; + margin-top: 0.5rem; +} + +@media (max-width: 880px) { + .app-shell { + grid-template-columns: 1fr; + } + + .settings-panel { + border-right: 0; + border-bottom: 1px solid rgba(255, 255, 255, 0.08); + } +}