598 lines
22 KiB
HTML
598 lines
22 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<meta name="theme-color" content="#43a047">
|
|
<meta name="color-scheme" content="light dark">
|
|
<title>Using Free LLM & Text To Speech Artificial Intelligence Service | uncloseai.com</title>
|
|
|
|
<!-- PicoCSS -->
|
|
<link rel="stylesheet" href="/css/pico.classless.min.css">
|
|
<!-- ChunkFive Font -->
|
|
<link rel="stylesheet" href="/css/chunkfive/stylesheet.css" type="text/css" charset="utf-8" />
|
|
<!-- Sidebar Theme -->
|
|
<link rel="stylesheet" href="/css/sidebar-theme.css">
|
|
|
|
<!-- Highlight.js for syntax highlighting -->
|
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.10.0/styles/a11y-dark.min.css" />
|
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.6.0/highlight.min.js"></script>
|
|
|
|
<!-- Theme Switcher Script -->
|
|
<script>
|
|
function switchTheme(theme) {
|
|
if (theme === "auto") {
|
|
document.documentElement.removeAttribute('data-theme');
|
|
} else {
|
|
document.documentElement.setAttribute('data-theme', theme);
|
|
}
|
|
|
|
// Reload Remarkbox iframe with new theme if it exists
|
|
var iframe = document.getElementById('remarkbox-iframe');
|
|
if (iframe && window.buildRbSrc) {
|
|
iframe.src = buildRbSrc();
|
|
}
|
|
}
|
|
|
|
// Disable custom styling for uncloseai.js - use PicoCSS instead
|
|
window.UNCLOSEAI_CUSTOM_STYLING = false;
|
|
</script>
|
|
|
|
<script src="https://uncloseai.com/uncloseai.js" type="module"></script>
|
|
<!--
|
|
<script defer data-domain="ai.unturf.com" src="https://analytics.unturf.com/js/plausible.js"></script>
|
|
-->
|
|
</head>
|
|
|
|
<body>
|
|
<!-- Mobile menu toggle -->
|
|
<button class="sidebar-toggle" onclick="document.querySelector('.sidebar').classList.toggle('open')">
|
|
☰
|
|
</button>
|
|
|
|
<!-- Left sidebar -->
|
|
<aside class="sidebar">
|
|
<div class="table-of-contents">
|
|
<h2>📋 Table of Contents</h2>
|
|
<nav>
|
|
<ul>
|
|
<li><a href="#introduction" class="active">Introducing uncloseai & Hermes & TTS Speech Endpoints</a></li>
|
|
<li><a href="#client-side">Try the uncloseai. Button</a></li>
|
|
<li><a href="#hermes-model">Using the Language Models</a>
|
|
<ul>
|
|
<li><a href="#python-client">Python Client Installation</a></li>
|
|
<li><a href="#python-hermes">Python Examples</a></li>
|
|
<li><a href="#python-non-streaming">Python Non-Streaming</a></li>
|
|
<li><a href="#python-streaming">Python Streaming</a></li>
|
|
<li><a href="#nodejs-client">Node.js Client Installation</a></li>
|
|
<li><a href="#nodejs-examples">Node.js Examples</a></li>
|
|
<li><a href="#nodejs-non-streaming">Node.js Non-Streaming</a></li>
|
|
<li><a href="#nodejs-streaming">Node.js Streaming</a></li>
|
|
</ul>
|
|
</li>
|
|
<li><a href="#tts">Using the Text To Speech Endpoint</a>
|
|
<ul>
|
|
<li><a href="#python-tts">Python TTS Example</a></li>
|
|
<li><a href="#nodejs-tts">Node.js TTS Example</a></li>
|
|
</ul>
|
|
</li>
|
|
<li><a href="#inference">How we run inference</a></li>
|
|
<li><a href="#open-source">Open Source & Public Domain</a></li>
|
|
<li><a href="#discussions">Questions & Comments & Discussions</a></li>
|
|
</ul>
|
|
</nav>
|
|
|
|
<!--<p style="margin-top: 2rem;"><strong>🎮 Looking for examples?</strong><br><a href="https://ai.unturf.com/demo.html">Visit our demo page →</a></p>-->
|
|
</div>
|
|
</aside>
|
|
|
|
<!-- Main content -->
|
|
<main>
|
|
<header>
|
|
<hgroup>
|
|
<a href="https://uncloseai.com"><h1 class="unturf" style="font-family: 'ChunkFiveRegular';">uncloseai.</h1></a>
|
|
<p>Welcome to uncloseai.com - Free LLM & Text To Speech Artificial Intelligence Service</p>
|
|
</hgroup>
|
|
<nav>
|
|
<ul>
|
|
<li><a href="#" onclick="switchTheme('auto')">Auto</a></li>
|
|
<li><a href="#" onclick="switchTheme('light')">Light</a></li>
|
|
<li><a href="#" onclick="switchTheme('dark')">Dark</a></li>
|
|
</ul>
|
|
</nav>
|
|
</header>
|
|
<h2 id="introduction">Introducing uncloseai & Hermes & Qwen & TTS Speech Endpoints</h2>
|
|
<p>At <strong>uncloseai.</strong>, we offer free AI services powered by multiple AI models and a TTS (Text-to-Speech) endpoint. Our mission is to provide accessible AI tools for everyone, embodying the principles of both free as in beer & free as in freedom. You can interact with our models without any cost, and you are encouraged to contribute and build upon the open-source code & models that we use.</p>
|
|
|
|
<p><strong>Available Models:</strong></p>
|
|
<ul>
|
|
<li><a href="https://nousresearch.com/hermes3/" target="_blank">NousResearch/Hermes-3-Llama-3.1-8B</a> - General purpose conversational AI at <code>https://hermes.ai.unturf.com/v1</code></li>
|
|
<li>Qwen 3 Coder 30B - Specialized coding model at <code>https://qwen.ai.unturf.com/v1</code></li>
|
|
</ul>
|
|
|
|
<p>We intend to be a drop in replacement, you can use the existing open source OpenAI client to communicate with us.</p>
|
|
|
|
<h2 id="client-side">Try the uncloseai. Button</h2>
|
|
|
|
<p>This page includes the <a href="https://uncloseai.com/uncloseai.js" target="_blank">uncloseai.js</a> script which adds a floating "uncloseai." button in the bottom right corner. Click it to interact with Hermes AI directly from your browser.</p>
|
|
|
|
<p>This is a web client-only solution - no server required. Add AI capabilities to any static site or CDN with just a couple lines of JavaScript and HTML.</p>
|
|
|
|
<h3 id="installation">✨ One-Line Installation</h3>
|
|
<p>Add this script tag to your HTML to get the same floating AI button on your site:</p>
|
|
<pre><code class="html"><script src="https://uncloseai.com/uncloseai.js" type="module"></script></code></pre>
|
|
|
|
<h2 id="hermes-model">Using the Language Models</h2>
|
|
|
|
<h3 id="python-client">Python Client Installation</h3>
|
|
<p>To install the OpenAI package for Python, use <code>pip</code>:</p>
|
|
<pre><code>pip install openai</code></pre>
|
|
|
|
<h3 id="python-hermes">Python Examples</h3>
|
|
<h3 id="python-non-streaming">Python Non-Streaming</h3>
|
|
|
|
<p><strong>Using Hermes (General Purpose):</strong></p>
|
|
<pre><code class="python"># Python Fizzbuzz Example with Hermes
|
|
from openai import OpenAI
|
|
|
|
client = OpenAI(base_url="https://hermes.ai.unturf.com/v1", api_key="choose-any-value")
|
|
|
|
MODEL = "adamo1139/Hermes-3-Llama-3.1-8B-FP8-Dynamic"
|
|
|
|
messages = [{"role": "user", "content": "Give a Python Fizzbuzz solution in one line of code?"}]
|
|
|
|
response = client.chat.completions.create(
|
|
model=MODEL,
|
|
messages=messages,
|
|
temperature=0.5,
|
|
max_tokens=150
|
|
)
|
|
|
|
print(response.choices[0].message.content)
|
|
</code></pre>
|
|
|
|
<p><strong>Using Qwen 3 Coder (Specialized for Coding):</strong></p>
|
|
<pre><code class="python"># Python Fizzbuzz Example with Qwen 3 Coder
|
|
from openai import OpenAI
|
|
|
|
client = OpenAI(base_url="https://qwen.ai.unturf.com/v1", api_key="choose-any-value")
|
|
|
|
MODEL = "hf.co/unsloth/Qwen3-Coder-30B-A3B-Instruct-GGUF:Q4_K_M"
|
|
|
|
messages = [{"role": "user", "content": "Give a Python Fizzbuzz solution in one line of code?"}]
|
|
|
|
response = client.chat.completions.create(
|
|
model=MODEL,
|
|
messages=messages,
|
|
temperature=0.5,
|
|
max_tokens=150
|
|
)
|
|
|
|
print(response.choices[0].message.content)
|
|
</code></pre>
|
|
|
|
<h3 id="python-streaming">Python Streaming</h3>
|
|
|
|
<p><strong>Using Hermes (General Purpose):</strong></p>
|
|
<pre><code class="python"># Streaming response in Python with Hermes
|
|
from openai import OpenAI
|
|
|
|
client = OpenAI(base_url="https://hermes.ai.unturf.com/v1", api_key="choose-any-value")
|
|
|
|
MODEL = "adamo1139/Hermes-3-Llama-3.1-8B-FP8-Dynamic"
|
|
|
|
messages = [
|
|
{"role": "user", "content": "Give a Python Fizzbuzz solution in one line of code?"}
|
|
]
|
|
|
|
response = client.chat.completions.create(
|
|
model=MODEL,
|
|
messages=messages,
|
|
temperature=0.5,
|
|
max_tokens=150,
|
|
stream=True, # Enable streaming
|
|
)
|
|
|
|
for chunk in response:
|
|
if hasattr(chunk.choices[0].delta, "content"):
|
|
print(chunk.choices[0].delta.content, end="")
|
|
</code></pre>
|
|
|
|
<p><strong>Using Qwen 3 Coder (Specialized for Coding):</strong></p>
|
|
<pre><code class="python"># Streaming response in Python with Qwen 3 Coder
|
|
from openai import OpenAI
|
|
|
|
client = OpenAI(base_url="https://qwen.ai.unturf.com/v1", api_key="choose-any-value")
|
|
|
|
MODEL = "hf.co/unsloth/Qwen3-Coder-30B-A3B-Instruct-GGUF:Q4_K_M"
|
|
|
|
messages = [
|
|
{"role": "user", "content": "Give a Python Fizzbuzz solution in one line of code?"}
|
|
]
|
|
|
|
response = client.chat.completions.create(
|
|
model=MODEL,
|
|
messages=messages,
|
|
temperature=0.5,
|
|
max_tokens=150,
|
|
stream=True, # Enable streaming
|
|
)
|
|
|
|
for chunk in response:
|
|
if hasattr(chunk.choices[0].delta, "content"):
|
|
print(chunk.choices[0].delta.content, end="")
|
|
</code></pre>
|
|
|
|
<h3 id="nodejs-client">Node.js Client Installation</h3>
|
|
<p>To install the OpenAI package for Node.js, you can use <code>npm</code> in your <code>package.json</code>:</p>
|
|
<pre><code>{
|
|
"dependencies": {
|
|
"openai": "^v4.67.3" // Use the latest version
|
|
}
|
|
}
|
|
</code></pre>
|
|
<p>Run the following command to install it:</p>
|
|
<pre><code>npm install</code></pre>
|
|
|
|
<h3 id="nodejs-examples">Node.js Examples</h3>
|
|
<h3 id="nodejs-non-streaming">Node.js Non-Streaming</h3>
|
|
|
|
<p><strong>Using Hermes (General Purpose):</strong></p>
|
|
<pre><code class="javascript">const OpenAI = require('openai');
|
|
|
|
const client = new OpenAI({
|
|
baseURL: "https://hermes.ai.unturf.com/v1",
|
|
apiKey: "dummy-api-key",
|
|
});
|
|
|
|
const MODEL = "adamo1139/Hermes-3-Llama-3.1-8B-FP8-Dynamic";
|
|
|
|
const messages = [{"role": "user", "content": "Give a Python Fizzbuzz solution in one line of code?"}];
|
|
|
|
async function getResponse() {
|
|
try {
|
|
const response = await client.chat.completions.create({
|
|
model: MODEL,
|
|
messages: messages,
|
|
temperature: 0.5,
|
|
max_tokens: 150,
|
|
});
|
|
|
|
console.log(response.choices[0].message.content);
|
|
} catch (error) {
|
|
console.error("Error:", error.response ? error.response.data : error.message);
|
|
}
|
|
}
|
|
|
|
getResponse();
|
|
</code></pre>
|
|
|
|
<p><strong>Using Qwen 3 Coder (Specialized for Coding):</strong></p>
|
|
<pre><code class="javascript">const OpenAI = require('openai');
|
|
|
|
const client = new OpenAI({
|
|
baseURL: "https://qwen.ai.unturf.com/v1",
|
|
apiKey: "dummy-api-key",
|
|
});
|
|
|
|
const MODEL = "hf.co/unsloth/Qwen3-Coder-30B-A3B-Instruct-GGUF:Q4_K_M";
|
|
|
|
const messages = [{"role": "user", "content": "Give a Python Fizzbuzz solution in one line of code?"}];
|
|
|
|
async function getResponse() {
|
|
try {
|
|
const response = await client.chat.completions.create({
|
|
model: MODEL,
|
|
messages: messages,
|
|
temperature: 0.5,
|
|
max_tokens: 150,
|
|
});
|
|
|
|
console.log(response.choices[0].message.content);
|
|
} catch (error) {
|
|
console.error("Error:", error.response ? error.response.data : error.message);
|
|
}
|
|
}
|
|
|
|
getResponse();
|
|
</code></pre>
|
|
|
|
<h3 id="nodejs-streaming">Node.js Streaming</h3>
|
|
|
|
<p><strong>Using Hermes (General Purpose):</strong></p>
|
|
<pre><code class="javascript">
|
|
const OpenAI = require('openai');
|
|
|
|
const client = new OpenAI({
|
|
baseURL: "https://hermes.ai.unturf.com/v1",
|
|
apiKey: "dummy-api-key",
|
|
});
|
|
|
|
const MODEL = "adamo1139/Hermes-3-Llama-3.1-8B-FP8-Dynamic";
|
|
|
|
const messages = [{"role": "user", "content": "Give a Python Fizzbuzz solution in one line of code?"}];
|
|
|
|
async function streamResponse() {
|
|
try {
|
|
const stream = await client.chat.completions.create({
|
|
model: MODEL,
|
|
messages: messages,
|
|
temperature: 0.5,
|
|
max_tokens: 150,
|
|
stream: true, // Enable streaming
|
|
});
|
|
|
|
// Use async iterator to read each chunk
|
|
for await (const chunk of stream) {
|
|
const msg = chunk.choices[0].delta.content;
|
|
process.stdout.write(msg); // Print each chunk as it arrives
|
|
}
|
|
} catch (error) {
|
|
console.error("Error:", error.response ? error.response.data : error.message);
|
|
}
|
|
}
|
|
|
|
streamResponse();
|
|
</code></pre>
|
|
|
|
<p><strong>Using Qwen 3 Coder (Specialized for Coding):</strong></p>
|
|
<pre><code class="javascript">
|
|
const OpenAI = require('openai');
|
|
|
|
const client = new OpenAI({
|
|
baseURL: "https://qwen.ai.unturf.com/v1",
|
|
apiKey: "dummy-api-key",
|
|
});
|
|
|
|
const MODEL = "hf.co/unsloth/Qwen3-Coder-30B-A3B-Instruct-GGUF:Q4_K_M";
|
|
|
|
const messages = [{"role": "user", "content": "Give a Python Fizzbuzz solution in one line of code?"}];
|
|
|
|
async function streamResponse() {
|
|
try {
|
|
const stream = await client.chat.completions.create({
|
|
model: MODEL,
|
|
messages: messages,
|
|
temperature: 0.5,
|
|
max_tokens: 150,
|
|
stream: true, // Enable streaming
|
|
});
|
|
|
|
// Use async iterator to read each chunk
|
|
for await (const chunk of stream) {
|
|
const msg = chunk.choices[0].delta.content;
|
|
process.stdout.write(msg); // Print each chunk as it arrives
|
|
}
|
|
} catch (error) {
|
|
console.error("Error:", error.response ? error.response.data : error.message);
|
|
}
|
|
}
|
|
|
|
streamResponse();
|
|
</code></pre>
|
|
|
|
<h2 id="tts">Using the Text To Speech Endpoint</h2>
|
|
<h3 id="python-tts">Python TTS Example</h3>
|
|
<pre><code class="python"># TTS Speech Example in Python
|
|
import openai
|
|
|
|
client = openai.OpenAI(
|
|
api_key = "YOLO",
|
|
base_url = "https://speech.ai.unturf.com/v1",
|
|
)
|
|
|
|
with client.audio.speech.with_streaming_response.create(
|
|
model="tts-1",
|
|
voice="alloy",
|
|
speed=0.9,
|
|
input="I think so therefore, Today is a wonderful day to build something people love!"
|
|
) as response:
|
|
response.stream_to_file("speech.mp3")
|
|
</code></pre>
|
|
|
|
<h3 id="nodejs-tts">Node.js TTS Example</h3>
|
|
<pre><code class="javascript">const OpenAI = require('openai');
|
|
|
|
const client = new OpenAI({
|
|
baseURL: "https://speech.ai.unturf.com/v1",
|
|
apiKey: "YOLO",
|
|
});
|
|
|
|
async function getSpeech() {
|
|
try {
|
|
const response = await client.audio.speech.with_streaming_response.create({
|
|
model: "tts-1",
|
|
voice: "alloy",
|
|
speed: 0.9,
|
|
input: "I think so therefore, Today is a wonderful day to build something people love!"
|
|
});
|
|
|
|
response.stream_to_file("speech.mp3");
|
|
} catch (error) {
|
|
console.error("Error:", error.response ? error.response.data : error.message);
|
|
}
|
|
}
|
|
|
|
getSpeech();
|
|
</code></pre>
|
|
|
|
<h2 id="inference">How we run inference</h2>
|
|
|
|
<p>This section is optional. This is only if you wanted to try to contribute idle GPU time to the project or if you wanted to reproduce everything in your own cluster.</p>
|
|
|
|
<p>We use vLLM to run models, currently full f16 safetensors. We make sure to use a virtualenv to hold the dependencies.</p>
|
|
<p>We are considering supporting ollama for better quant support.</p>
|
|
<p>Stand up a replica cluster on a new domain.</p>
|
|
<pre><code>
|
|
sudo apt-get install gcc python3.12-dev python3.12-venv
|
|
cd ~
|
|
python3 -m venv env
|
|
source env/bin/activate
|
|
pip install vllm
|
|
python -m vllm.entrypoints.openai.api_server --model adamo1139/Hermes-3-Llama-3.1-8B-FP8-Dynamic --host 0.0.0.0 --port 18888 --max-model-len 82000
|
|
</code></pre>
|
|
|
|
<p>The Speech endpoint or TTS uses <a href="https://github.com/matatonic/openedai-speech?tab=readme-ov-file#nvidia-gpu-cuda">openedai-speech</a> running via Docker.
|
|
|
|
<p>If you want to see how we setup the proxy, check out <a href="https://git.unturf.com/-/snippets/3">/etc/caddy/Caddyfile</a></p>
|
|
|
|
<pre><code>
|
|
|
|
ai.unturf.com {
|
|
root * /opt/www
|
|
file_server
|
|
log {
|
|
output file /var/log/caddy/ai.unturf.com.log {
|
|
roll_size 50mb
|
|
roll_keep 5
|
|
}
|
|
}
|
|
tls {
|
|
on_demand
|
|
}
|
|
}
|
|
|
|
hermes.ai.unturf.com {
|
|
reverse_proxy <removed>:18888
|
|
log {
|
|
output file /var/log/caddy/hermes.ai.unturf.com.log {
|
|
roll_size 50mb
|
|
roll_keep 5
|
|
}
|
|
}
|
|
tls {
|
|
on_demand
|
|
}
|
|
}
|
|
|
|
speech.ai.unturf.com {
|
|
reverse_proxy <removed>:8000
|
|
log {
|
|
output file /var/log/caddy/speech.ai.unturf.com.log {
|
|
roll_size 50mb
|
|
roll_keep 5
|
|
}
|
|
}
|
|
tls {
|
|
on_demand
|
|
}
|
|
}
|
|
|
|
</code></pre>
|
|
|
|
<p>We will likely implement a rate limit based on client IP address.</p>
|
|
|
|
<h2 id="open-source">Open Source & Public Domain</h2>
|
|
|
|
<p><strong>uncloseai.</strong> is completely <strong>public domain</strong> - free as in beer, free as in freedom. AI for all, internet for all, open web for everyone.</p>
|
|
|
|
<p>📦 <strong>Source Code:</strong> <a href="https://git.unturf.com/engineering/unturf/uncloseai.com" target="_blank">https://git.unturf.com/engineering/unturf/uncloseai.com</a></p>
|
|
|
|
<p>📄 <strong>Full Documentation:</strong> See README.rst in the repository for complete technical details, architecture, and contribution guidelines.</p>
|
|
|
|
<h2 id="discussions">Questions & Comments & Discussions</h2>
|
|
|
|
Use the <a href="https://www.remarkbox.com" target="_blank">Remarkbox</a> below to tell us what you think!
|
|
|
|
<div id="remarkbox-div">
|
|
<noscript>
|
|
<iframe id=remarkbox-iframe src="https://my.remarkbox.com/embed?nojs=true" style="height:600px;width:100%;border:none!important" tabindex=0></iframe>
|
|
</noscript>
|
|
</div>
|
|
<script src="https://my.remarkbox.com/static/js/iframe-resizer/iframeResizer.min.js"></script>
|
|
<script>
|
|
var rb_owner_key = "944c8dfa-8b2b-11ef-af0e-29ab4fb285a0";
|
|
var thread_uri = window.location.href;
|
|
var thread_title = window.document.title;
|
|
var thread_fragment = window.location.hash;
|
|
|
|
function getCurrentTheme() {
|
|
// Check if data-theme is set on documentElement
|
|
var theme = document.documentElement.getAttribute('data-theme');
|
|
if (theme) {
|
|
return theme; // 'light' or 'dark'
|
|
}
|
|
// If auto, detect system preference
|
|
if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
|
|
return 'dark';
|
|
}
|
|
return 'light';
|
|
}
|
|
|
|
function buildRbSrc() {
|
|
var mode = getCurrentTheme();
|
|
return "https://my.remarkbox.com/embed" +
|
|
"?rb_owner_key=" + rb_owner_key +
|
|
"&thread_title=" + encodeURI(thread_title) +
|
|
"&thread_uri=" + encodeURIComponent(thread_uri) +
|
|
"&mode=" + mode +
|
|
thread_fragment;
|
|
}
|
|
|
|
var rb_src = buildRbSrc();
|
|
|
|
function create_remarkbox_iframe() {
|
|
var ifrm = document.createElement("iframe");
|
|
ifrm.setAttribute("id", "remarkbox-iframe");
|
|
ifrm.setAttribute("scrolling", "no");
|
|
ifrm.setAttribute("src", rb_src);
|
|
ifrm.setAttribute("frameborder", "0");
|
|
ifrm.setAttribute("tabindex", "0");
|
|
ifrm.setAttribute("title", "Remarkbox");
|
|
ifrm.style.width = "100%";
|
|
document.getElementById("remarkbox-div").appendChild(ifrm);
|
|
}
|
|
create_remarkbox_iframe();
|
|
iFrameResize(
|
|
{
|
|
checkOrigin: ["https://my.remarkbox.com"],
|
|
inPageLinks: true,
|
|
initCallback: function(e) { e.iFrameResizer.moveToAnchor(thread_fragment) }
|
|
},
|
|
document.getElementById("remarkbox-iframe")
|
|
);
|
|
</script>
|
|
|
|
<script>hljs.highlightAll();</script>
|
|
|
|
<footer>
|
|
<small>Stylesheets by <a href="https://picocss.com" target="_blank">PicoCSS</a></small>
|
|
<small>& <a href="https://highlightjs.org/" target="_blank">highlight.js</a></small>
|
|
</footer>
|
|
|
|
<script>
|
|
// Simple active link highlighting
|
|
document.querySelectorAll('.table-of-contents a').forEach(link => {
|
|
link.addEventListener('click', function() {
|
|
document.querySelectorAll('.table-of-contents a').forEach(a => a.classList.remove('active'));
|
|
this.classList.add('active');
|
|
});
|
|
});
|
|
|
|
// Highlight current section on scroll
|
|
const sections = document.querySelectorAll('h2[id], h3[id]');
|
|
const navLinks = document.querySelectorAll('.table-of-contents a');
|
|
|
|
window.addEventListener('scroll', () => {
|
|
let current = '';
|
|
sections.forEach(section => {
|
|
const sectionTop = section.offsetTop;
|
|
const sectionHeight = section.clientHeight;
|
|
if (window.scrollY >= sectionTop - 100) {
|
|
current = section.getAttribute('id');
|
|
}
|
|
});
|
|
|
|
navLinks.forEach(link => {
|
|
link.classList.remove('active');
|
|
if (link.getAttribute('href') === '#' + current) {
|
|
link.classList.add('active');
|
|
}
|
|
});
|
|
});
|
|
</script>
|
|
</main>
|
|
</body>
|
|
|
|
</html>
|