diff --git a/.github/actions/setup-python-playwright/action.yml b/.github/actions/setup-python-playwright/action.yml new file mode 100644 index 0000000..a0fac48 --- /dev/null +++ b/.github/actions/setup-python-playwright/action.yml @@ -0,0 +1,23 @@ +name: Setup Python Playwright Environment +description: Install Python dependencies and Playwright browsers for testing. +inputs: + python-version: + description: Python version to install. + required: false + default: '3.11' +runs: + using: composite + steps: + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: ${{ inputs.python-version }} + - name: Install Python dependencies + shell: bash + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + - name: Install Playwright browsers + shell: bash + run: | + python -m playwright install --with-deps chromium diff --git a/.github/scripts/build_static.py b/.github/scripts/build_static.py index b6a9288..95ed76f 100755 --- a/.github/scripts/build_static.py +++ b/.github/scripts/build_static.py @@ -38,6 +38,7 @@ def main() -> int: dist.mkdir(parents=True, exist_ok=True) result = {"status": "success", "copied": []} + exit_code = 0 try: result["copied"] = copy_static_files(dist) if not result["copied"]: @@ -50,8 +51,9 @@ def main() -> int: result["status"] = "failure" result["message"] = str(exc) print(f"::error::Static site build failed: {exc}") + exit_code = 1 Path("build-results.json").write_text(json.dumps(result)) - return 0 + return exit_code if __name__ == "__main__": raise SystemExit(main()) diff --git a/.github/scripts/run_tests.py b/.github/scripts/run_tests.py index 064be2a..192d954 100755 --- a/.github/scripts/run_tests.py +++ b/.github/scripts/run_tests.py @@ -44,7 +44,7 @@ def main() -> int: else: print("All tests passed.") - return 0 + return exit_code if __name__ == "__main__": diff --git a/.github/scripts/write_summary.py b/.github/scripts/write_summary.py new file mode 100755 index 0000000..25479d6 --- /dev/null +++ b/.github/scripts/write_summary.py @@ -0,0 +1,128 @@ +#!/usr/bin/env python3 +"""Aggregate CI results into a GitHub Actions step summary.""" + +from __future__ import annotations + +import argparse +import json +import os +from pathlib import Path +from typing import Any + + +def load_json(path: Path) -> dict[str, Any] | None: + if not path.exists() or not path.is_file(): + return None + try: + return json.loads(path.read_text()) + except json.JSONDecodeError as exc: # noqa: TRY003 + print(f"::warning::Unable to parse JSON from {path}: {exc}") + return None + + +def render_tests_section(data: dict[str, Any] | None) -> list[str]: + lines = ["## Test Results", ""] + if not data: + lines.append("No test results were produced.") + return lines + + tests = data.get("tests") or [] + if not tests: + lines.append("No tests were discovered under /tests.") + return lines + + lines.append(f"Processed {len(tests)} test file(s).") + lines.append("") + lines.append("| Test file | Status |") + lines.append("| --- | --- |") + for entry in tests: + name = entry.get("name", "unknown") + status = (entry.get("status") or "unknown").lower() + if status == "passed": + emoji = "✅" + elif status == "failed": + emoji = "❌" + else: + emoji = "⚠️" + lines.append(f"| {name} | {emoji} {status.title()} |") + return lines + + +def render_build_section(data: dict[str, Any] | None) -> list[str]: + lines = ["## Build Status", ""] + if not data: + lines.append("No build output was produced.") + return lines + + status = (data.get("status") or "unknown").lower() + message = data.get("message") + copied = data.get("copied") or [] + + if status == "success": + emoji = "✅" + elif status == "warning": + emoji = "⚠️" + else: + emoji = "❌" + lines.append(f"Overall build result: {emoji} {status.title()}") + + if copied: + lines.append("") + lines.append("### Copied static files") + for item in copied: + lines.append(f"- {item}") + + if message: + lines.append("") + lines.append(f"Details: {message}") + + return lines + + +def append_summary(sections: list[list[str]]) -> None: + summary_path = os.environ.get("GITHUB_STEP_SUMMARY") + text = "\n".join(line for section in sections for line in (*section, "")) + print(text) + if summary_path: + with open(summary_path, "a", encoding="utf-8") as handle: + handle.write(text + "\n") + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Aggregate CI results into a GitHub Actions step summary.", + ) + parser.add_argument( + "--include-tests", + action="store_true", + help="Include the tests section in the summary.", + ) + parser.add_argument( + "--include-build", + action="store_true", + help="Include the build section in the summary.", + ) + args = parser.parse_args() + if not args.include_tests and not args.include_build: + args.include_tests = True + args.include_build = True + return args + + +def main() -> None: + args = parse_args() + repo_root = Path(".") + tests_data = load_json(repo_root / "test-results.json") + build_data = load_json(repo_root / "build-results.json") + + sections: list[list[str]] = [] + if args.include_tests: + sections.append(render_tests_section(tests_data)) + if args.include_build and (build_data or (repo_root / "build-results.json").exists()): + sections.append(render_build_section(build_data)) + + append_summary(sections) + + +if __name__ == "__main__": + main() diff --git a/.github/workflows/main-branch.yml b/.github/workflows/main-branch.yml index 2e15c9e..1bb95e3 100644 --- a/.github/workflows/main-branch.yml +++ b/.github/workflows/main-branch.yml @@ -12,24 +12,35 @@ permissions: concurrency: group: pages - cancel-in-progress: false + cancel-in-progress: true jobs: - build: + build_artifacts: name: Build and Upload Artifacts runs-on: ubuntu-latest + outputs: + status: ${{ steps.collect.outputs.status }} + results: ${{ steps.collect.outputs.results }} steps: - name: Checkout repository uses: actions/checkout@v4 + + - name: Set up Python environment + uses: ./.github/actions/setup-python-playwright + - name: Build static site - run: | - python .github/scripts/build_static.py + id: build-site + continue-on-error: true + run: python .github/scripts/build_static.py + - name: Upload Pages artifact + if: steps.build-site.outcome == 'success' uses: actions/upload-pages-artifact@v3 with: path: dist - - name: Prepare build status output - id: prepare_build_status + + - name: Collect build metadata + id: collect run: | python - <<'PY' import base64 @@ -37,80 +48,78 @@ jobs: import os from pathlib import Path - data = json.loads(Path('build-results.json').read_text()) - encoded = base64.b64encode(json.dumps(data).encode()).decode() - with open(os.environ['GITHUB_OUTPUT'], 'a', encoding='utf-8') as fh: - fh.write(f"encoded={encoded}\n") + path = Path("build-results.json") + data: dict[str, object] = {} + status = "missing" + if path.exists(): + data = json.loads(path.read_text()) + status = data.get("status", "unknown") + encoded = base64.b64encode(json.dumps(data).encode("utf-8")).decode("utf-8") + with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as handle: + handle.write(f"status={status}\n") + handle.write(f"results={encoded}\n") PY - outputs: - status: ${{ steps.prepare_build_status.outputs.encoded }} - report-build: + report_build_status: name: Report Build Status - needs: build runs-on: ubuntu-latest + needs: build_artifacts if: always() steps: - - name: Report Build Status + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Restore build results + env: + BUILD_RESULTS_B64: ${{ needs.build_artifacts.outputs.results }} run: | python - <<'PY' import base64 import json import os + from pathlib import Path - encoded = "${{ needs.build.outputs.status }}" - if not encoded: - report = ["## Build Status", "", "No build status information was captured."] - else: - data = json.loads(base64.b64decode(encoded).decode()) - status = data.get('status', 'unknown').lower() - emoji = '✅' if status == 'success' else ('⚠️' if status == 'warning' else '❌') - details = data.get('message') - copied = data.get('copied', []) - report = [ - "## Build Status", - "", - f"Overall build result: {emoji} {status.title()}", - ] - if copied: - report.append("") - report.append("### Copied static files") - for item in copied: - report.append(f"- {item}") - if details: - report.append("") - report.append(f"Details: {details}") - - summary_path = os.environ.get('GITHUB_STEP_SUMMARY') - if summary_path: - with open(summary_path, 'a', encoding='utf-8') as fh: - fh.write("\n".join(report) + "\n") - print("\n".join(report)) + raw = os.environ.get("BUILD_RESULTS_B64", "") + data: dict[str, object] = {} + if raw: + try: + decoded = base64.b64decode(raw.encode("utf-8")).decode("utf-8") + if decoded: + data = json.loads(decoded) + except Exception as exc: # noqa: BLE001 + data = {"status": "unknown", "message": f"Unable to decode build results: {exc}"} + Path("build-results.json").write_text(json.dumps(data)) PY - run-tests: + - name: Report Build Status + run: python .github/scripts/write_summary.py --include-build + + - name: Fail if build failed + if: needs.build_artifacts.outputs.status == 'failure' + run: exit 1 + + tests: name: Run Tests - needs: build runs-on: ubuntu-latest + needs: build_artifacts + if: always() + outputs: + status: ${{ steps.collect.outputs.status }} + results: ${{ steps.collect.outputs.results }} steps: - name: Checkout repository uses: actions/checkout@v4 - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: '3.11' - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install -r requirements.txt pytest - - name: Install Playwright browsers - run: | - python -m playwright install --with-deps chromium - - name: Execute tests sequentially - run: | - python .github/scripts/run_tests.py - - name: Prepare test results output - id: prepare_results + + - name: Set up Python environment + uses: ./.github/actions/setup-python-playwright + + - name: Run Tests + id: run-tests + continue-on-error: true + run: python .github/scripts/run_tests.py + + - name: Collect test metadata + id: collect run: | python - <<'PY' import base64 @@ -118,60 +127,61 @@ jobs: import os from pathlib import Path - data = json.loads(Path('test-results.json').read_text()) - encoded = base64.b64encode(json.dumps(data).encode()).decode() - with open(os.environ['GITHUB_OUTPUT'], 'a', encoding='utf-8') as fh: - fh.write(f"encoded={encoded}\n") + path = Path("test-results.json") + data: dict[str, object] = {} + status = "missing" + if path.exists(): + data = json.loads(path.read_text()) + status = data.get("overall_status", "unknown") + encoded = base64.b64encode(json.dumps(data).encode("utf-8")).decode("utf-8") + with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as handle: + handle.write(f"status={status}\n") + handle.write(f"results={encoded}\n") PY - outputs: - results: ${{ steps.prepare_results.outputs.encoded }} - report-tests: + report_tests_statuses: name: Report Tests Statuses - needs: run-tests runs-on: ubuntu-latest + needs: tests if: always() steps: - - name: Report Tests Statuses + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Restore test results + env: + TEST_RESULTS_B64: ${{ needs.tests.outputs.results }} run: | python - <<'PY' import base64 import json import os + from pathlib import Path - encoded = "${{ needs.run-tests.outputs.results }}" - if not encoded: - summary = "No test results were produced." - table_lines = [] - else: - data = json.loads(base64.b64decode(encoded).decode()) - tests = data.get('tests', []) - if not tests: - summary = "No tests were discovered under /tests." - table_lines = [] - else: - summary = f"Processed {len(tests)} test(s)." - table_lines = ["| Test | Status |", "| --- | --- |"] - for item in tests: - status = item.get('status', 'unknown').lower() - emoji = '✅' if status == 'passed' else ('❌' if status == 'failed' else '⚠️') - table_lines.append(f"| {item.get('name', 'unknown')} | {emoji} {status.title()} |") - - report_lines = ["## Main Branch Test Results", "", summary] - if table_lines: - report_lines.extend(["", *table_lines]) - - summary_path = os.environ.get('GITHUB_STEP_SUMMARY') - if summary_path: - with open(summary_path, 'a', encoding='utf-8') as fh: - fh.write("\n".join(report_lines) + "\n") - print("\n".join(report_lines)) + raw = os.environ.get("TEST_RESULTS_B64", "") + data: dict[str, object] = {} + if raw: + try: + decoded = base64.b64decode(raw.encode("utf-8")).decode("utf-8") + if decoded: + data = json.loads(decoded) + except Exception as exc: # noqa: BLE001 + data = {"tests": [], "overall_status": "failed", "message": f"Unable to decode test results: {exc}"} + Path("test-results.json").write_text(json.dumps(data)) PY + - name: Report Tests Statuses + run: python .github/scripts/write_summary.py --include-tests + + - name: Fail if tests failed + if: needs.tests.outputs.status == 'failed' + run: exit 1 + deploy: name: Deploy to Pages - needs: run-tests runs-on: ubuntu-latest + needs: build_artifacts + if: needs.build_artifacts.outputs.status == 'success' environment: name: github-pages steps: diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index 55db2c7..2574fa8 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -12,75 +12,15 @@ jobs: steps: - name: Checkout repository uses: actions/checkout@v4 - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: '3.11' - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install -r requirements.txt pytest - - name: Install Playwright browsers - run: | - python -m playwright install --with-deps chromium - - name: Execute tests sequentially - run: | - python .github/scripts/run_tests.py - - name: Prepare test results output - id: prepare_results - run: | - python - <<'PY' - import base64 - import json - import os - from pathlib import Path - data = json.loads(Path('test-results.json').read_text()) - encoded = base64.b64encode(json.dumps(data).encode()).decode() - with open(os.environ['GITHUB_OUTPUT'], 'a', encoding='utf-8') as fh: - fh.write(f"encoded={encoded}\n") - PY - outputs: - results: ${{ steps.prepare_results.outputs.encoded }} + - name: Set up Python environment + uses: ./.github/actions/setup-python-playwright - report: - name: Report Tests Statuses - needs: run-tests - runs-on: ubuntu-latest - if: always() - steps: - - name: Report Tests Statuses - run: | - python - <<'PY' - import base64 - import json - import os + - name: Run Tests + id: run-tests + continue-on-error: true + run: python .github/scripts/run_tests.py - encoded = "${{ needs.run-tests.outputs.results }}" - if not encoded: - summary = "No test results were produced." - table_lines = [] - else: - data = json.loads(base64.b64decode(encoded).decode()) - tests = data.get('tests', []) - if not tests: - summary = "No tests were discovered under /tests." - table_lines = [] - else: - summary = f"Processed {len(tests)} test(s)." - table_lines = ["| Test | Status |", "| --- | --- |"] - for item in tests: - status = item.get('status', 'unknown').lower() - emoji = '✅' if status == 'passed' else ('❌' if status == 'failed' else '⚠️') - table_lines.append(f"| {item.get('name', 'unknown')} | {emoji} {status.title()} |") - - report_lines = ["## Pull Request Test Results", "", summary] - if table_lines: - report_lines.extend(["", *table_lines]) - - summary_path = os.environ.get('GITHUB_STEP_SUMMARY') - if summary_path: - with open(summary_path, 'a', encoding='utf-8') as fh: - fh.write("\n".join(report_lines) + "\n") - print("\n".join(report_lines)) - PY + - name: Report Tests Statuses + if: always() + run: python .github/scripts/write_summary.py --include-tests diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..635bd8d --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +# Build artifacts +/dist/ +/build-results.json +/test-results.json + +# Python cache +__pycache__/ +*.pyc + +# Playwright artifacts +/playwright-report/ +/.pytest_cache/ diff --git a/AI/README.md b/AI/README.md deleted file mode 100644 index 8533f8f..0000000 --- a/AI/README.md +++ /dev/null @@ -1,40 +0,0 @@ -# Talk to Unity - -Talk to Unity is a browser-based voice companion that connects visitors with the Unity AI Lab experience. The project ships as a static site, so it can be hosted on GitHub Pages or any web server that can serve HTML, CSS, and JavaScript. - -## What you get - -- A guided landing page with live dependency checks for secure context, speech recognition, speech synthesis, and microphone access. -- A voice-first interface where users can unmute the microphone, talk to Unity, and hear spoken responses. -- Lightweight assets that run fully in the browser with no additional backend services required. - -## Requirements - -To enjoy the full experience make sure you have: - -- A modern browser with support for the Web Speech APIs (Chrome or Edge work best, Safari on desktop also supports the features). -- HTTPS hosting or `localhost` so the secure-context check passes. -- Speakers or headphones so you can hear Unity’s replies. -- A microphone that the browser is allowed to access. - -## Running locally - -1. Clone this repository. - `git clone https://github.com/Unity-Lab-AI/Talk-to-Unity.git` -2. Serve the files from a local web server so the secure-context requirement is satisfied. Any simple static server works, for example: - `python -m http.server 8000` -3. Visit `http://localhost:8000` in a compatible browser, open `index.html`, and follow the landing page instructions to launch the voice lab. - -## Project structure - -- `index.html` – landing page markup and the application shell. -- `style.css` – theme styles for both the landing page and the voice interface. -- `app.js` – dependency checks, voice controls, and Unity interaction logic. -- `ai-instruct.txt` – system prompt loaded by the application. - -## Helpful links - -- Unity AI Lab home: https://unityailab.online -- Repository on GitHub: https://github.com/Unity-Lab-AI/Talk-to-Unity - -Feel free to customize the landing copy, extend the dependency checks, or swap in different voice prompts to match your own deployment. diff --git a/AI/ai-instruct.txt b/AI/ai-instruct.txt deleted file mode 100644 index ebc0206..0000000 --- a/AI/ai-instruct.txt +++ /dev/null @@ -1 +0,0 @@ -You are Unity, a helpful AI assistant. \ No newline at end of file diff --git a/AI/app.js b/AI/app.js index 96d8bf8..f8faf41 100644 --- a/AI/app.js +++ b/AI/app.js @@ -1,4 +1,12 @@ (() => { + if (typeof window !== 'undefined') { + window.addEventListener('error', (event) => { + if (event?.message) { + console.error('Talk to Unity runtime error:', event.message, event.error?.stack || ''); + } + }); + } + const landingSection = document.getElementById('landing'); const appRoot = document.getElementById('app-root'); const heroStage = document.getElementById('hero-stage'); @@ -49,6 +57,7 @@ const synth = typeof window !== 'undefined' ? window.speechSynthesis : undefined; let compatibilityBanner = null; let dependencyState = { results: [], allMet: false, missing: [] }; + let unmuteInProgress = false; const dependencyChecks = [ { @@ -415,12 +424,12 @@ } applyTheme(currentTheme); + setupSpeechRecognition(); if (!systemPromptLoaded) { await loadSystemPrompt(); } - setupSpeechRecognition(); updateMuteIndicator(); if (auto) { @@ -459,16 +468,17 @@ if (announce) { speak('Microphone muted.'); } - } else { - setCircleState(userCircle, { - listening: true, - label: 'Listening for your voice' - }); - if (announce) { - speak('Microphone unmuted.'); - } + return true; } - return; + + setCircleState(userCircle, { + listening: true, + label: 'Listening for your voice' + }); + if (announce) { + speak('Microphone unmuted.'); + } + return true; } if (muted) { @@ -493,7 +503,7 @@ speak('Microphone muted.'); } - return; + return true; } if (!hasMicPermission) { @@ -503,7 +513,7 @@ if (announce) { speak('Microphone permission is required to unmute.'); } - return; + return false; } } @@ -517,7 +527,7 @@ if (announce) { speak('Microphone is already listening.'); } - return; + return true; } isMuted = false; @@ -541,12 +551,14 @@ if (announce) { speak('Unable to start microphone recognition.'); } - return; + return false; } if (announce) { speak('Microphone unmuted.'); } + + return true; } function applyTheme(theme, { announce = false, force = false } = {}) { @@ -611,15 +623,24 @@ return; } - try { - const response = await fetch(resolveAssetPath('ai-instruct.txt')); - systemPrompt = await response.text(); - } catch (error) { - console.error('Error fetching system prompt:', error); - systemPrompt = 'You are Unity, a helpful AI assistant.'; - } finally { - systemPromptLoaded = true; + const candidatePaths = ['../ai-instruct.txt', 'ai-instruct.txt']; + for (const path of candidatePaths) { + try { + const response = await fetch(resolveAssetPath(path)); + if (!response.ok) { + continue; + } + + systemPrompt = await response.text(); + systemPromptLoaded = true; + return; + } catch (error) { + console.error('Error fetching system prompt from', path, error); + } } + + systemPrompt = 'You are Unity, a helpful AI assistant.'; + systemPromptLoaded = true; } function setupSpeechRecognition() { @@ -743,6 +764,14 @@ label: `Microphone error: ${event.error}` }); }; + + if (!isMuted) { + try { + recognition.start(); + } catch (error) { + console.error('Failed to start recognition after initialization:', error); + } + } } async function initializeVoiceControl() { @@ -845,33 +874,64 @@ } } - async function attemptUnmute() { - await setMutedState(false); + function showMicrophonePermissionRequest() { + if (!muteIndicator) { + return; + } + + muteIndicator.dataset.state = 'pending'; + muteIndicator.setAttribute('aria-label', 'Requesting microphone permission…'); + if (indicatorText) { + indicatorText.textContent = 'Requesting microphone permission…'; + } + } + + async function attemptUnmute({ announce = false } = {}) { + if (unmuteInProgress) { + return; + } + + const shouldShowProgress = isMuted && !hasMicPermission; + if (shouldShowProgress) { + showMicrophonePermissionRequest(); + } + + unmuteInProgress = true; + try { + await setMutedState(false, { announce }); + } finally { + unmuteInProgress = false; + updateMuteIndicator(); + } } function handleMuteToggle(event) { event?.stopPropagation(); if (isMuted) { - attemptUnmute(); + void attemptUnmute(); return; } - setMutedState(true); + void setMutedState(true); } muteIndicator?.addEventListener('click', handleMuteToggle); - document.addEventListener('click', () => { - if (isMuted) { - attemptUnmute(); + const handleAmbientUnmute = () => { + if (!isMuted || unmuteInProgress) { + return; } - }); + + void attemptUnmute(); + }; + + document.addEventListener('click', handleAmbientUnmute); document.addEventListener('keydown', (event) => { - if ((event.key === 'Enter' || event.key === ' ') && isMuted) { + if ((event.key === 'Enter' || event.key === ' ') && isMuted && !unmuteInProgress) { event.preventDefault(); - attemptUnmute(); + void attemptUnmute(); } }); @@ -929,12 +989,8 @@ .replace(/\ \[^]]*\bcommand\b[^]]*\]/gi, ' ') .replace(/\([^)]*\bcommand\b[^)]*\)/gi, ' ') .replace(/<[^>]*\bcommand\b[^>]*>/gi, ' ') - .replace(/\bcommands?\s*[:=-]\s*[a-z0-9_, -\s-]+ -/gi, ' ') - .replace(/\bactions?\s*[:=-]\s*[a-z0-9_, -\s-]+ -/gi, ' ') + .replace(/\bcommands?\s*[:=-]\s*[a-z0-9_,\s-]+/gi, ' ') + .replace(/\bactions?\s*[:=-]\s*[a-z0-9_,\s-]+/gi, ' ') .replace(/\b(?:execute|run)\s+command\s*(?:[:=-]\s*)?[a-z0-9_-]*/gi, ' ') .replace(/\bcommand\s*(?:[:=-]\s*|\s+)(?:[a-z0-9_-]+(?:\s+[a-z0-9_-]+)*)?/gi, ' '); @@ -1173,7 +1229,7 @@ } function escapeRegExp(value) { - return value.replace(/[-/\\^$*+?.()|[\{\}]/g, '\\$&'); + return value.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&'); } function removeImageReferences(text, imageUrl) { @@ -1757,4 +1813,13 @@ window.open(imageUrl, '_blank'); speak('Image opened in new tab.'); } + + if (typeof window !== 'undefined') { + Object.assign(window, { + applyTheme, + setMutedState, + startApplication, + attemptUnmute + }); + } })(); diff --git a/AI/index.html b/AI/index.html index dad0f83..325bd22 100644 --- a/AI/index.html +++ b/AI/index.html @@ -13,17 +13,25 @@ - +