---
name: custom-ai-chat-ui
description: Build custom web-based AI chat interfaces with OpenRouter integration — HTML/JS templates, vision support, model selection, and local server setup.
version: 1.0.0
author: Hermes Agent
license: MIT
metadata:
  hermes:
    tags: [chat-ui, openrouter, web-interface, vision, multimodal, html, javascript]
    homepage: https://github.com/NousResearch/hermes-agent
    related_skills: [hermes-agent, popular-web-designs, sketch]
---

# Custom AI Chat UI Development

Build functional, animated web interfaces for chatting with AI models via OpenRouter or other providers. Supports text messaging, image upload/analysis (vision), model switching, and real-time responses.

## When to Use This

- You need a custom chat interface beyond what Hermes gateway provides
- You want to build a UI for specific workflows (code review from screenshots, design mockup-to-code, etc.)
- You need to integrate multiple AI models with different capabilities
- You want a shareable, embeddable chat interface for a team or project

## Quick Start: Single-File Chat UI

Create a self-contained HTML file with OpenRouter integration:

### Step 1: Create the HTML File

```bash
cat > /opt/data/custom_chat_ui.html << 'EOF'
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>AI Chat UI</title>
<style>
  body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; margin: 0; padding: 20px; background: #f5f5f5; }
  #chat-container { max-width: 800px; margin: 0 auto; background: white; border-radius: 12px; box-shadow: 0 4px 20px rgba(0,0,0,0.1); overflow: hidden; display: flex; flex-direction: column; height: 90vh; }
  #messages { flex: 1; padding: 20px; overflow-y: auto; display: flex; flex-direction: column; gap: 12px; }
  .message { max-width: 80%; padding: 12px 16px; border-radius: 18px; line-height: 1.5; word-wrap: break-word; }
  .user-message { background: #007bff; color: white; align-self: flex-end; border-bottom-right-radius: 4px; }
  .assistant-message { background: #e9ecef; color: #212529; align-self: flex-start; border-bottom-left-radius: 4px; }
  .image-preview { max-width: 100%; max-height: 250px; border-radius: 8px; margin-top: 8px; display: block; cursor: pointer; }
  #input-area { display: flex; gap: 10px; padding: 15px; background: #f8f9fa; border-top: 1px solid #dee2e6; }
  #user-input { flex: 1; padding: 14px; border: 1px solid #ced4da; border-radius: 6px; font-size: 15px; outline: none; }
  #user-input:focus { border-color: #007bff; box-shadow: 0 0 0 3px rgba(0,123,255,0.15); }
  #send-btn { padding: 14px 24px; background: #007bff; color: white; border: none; border-radius: 6px; cursor: pointer; font-weight: 500; }
  #send-btn:hover { background: #0056b3; }
  #send-btn:disabled { background: #6c757d; cursor: not-allowed; }
  #file-label { padding: 14px 18px; background: #28a745; color: white; border-radius: 6px; cursor: pointer; font-size: 14px; }
  #file-label:hover { background: #1e7e34; }
  #file-input { display: none; }
  .loading { color: #6c757d; font-style: italic; padding: 10px; }
  .error-message { background: #f8d7da; color: #721c24; border: 1px solid #f5c6cb; }
</style>
</head>
<body>
<div id="chat-container">
  <div id="messages"></div>
  <div id="input-area">
    <input type="text" id="user-input" placeholder="Type a message..." />
    <label for="file-input" id="file-label">📎 Image</label>
    <input type="file" id="file-input" accept="image/*" />
    <button id="send-btn">Send</button>
  </div>
</div>
<script>
const API_KEY = ''; // ⚠️ INSERT YOUR API KEY
const MODEL = 'qwen/qwen-turbo'; // Change to your preferred model
const API_URL = 'https://openrouter.ai/api/v1/chat/completions';

const messagesDiv = document.getElementById('messages');
const userInput = document.getElementById('user-input');
const fileInput = document.getElementById('file-input');
const sendBtn = document.getElementById('send-btn');

function addMessage(content, isUser = false, imageUrl = null) {
  const msgDiv = document.createElement('div');
  msgDiv.className = 'message ' + (isUser ? 'user-message' : 'assistant-message');
  msgDiv.innerHTML = typeof content === 'string' ? content.replace(/\n/g, '<br>') : content;
  if (imageUrl) {
    const img = document.createElement('img');
    img.src = imageUrl;
    img.className = 'image-preview';
    msgDiv.appendChild(img);
  }
  messagesDiv.appendChild(msgDiv);
  messagesDiv.scrollTop = messagesDiv.scrollHeight;
}

function setLoading(isLoading) {
  const existing = document.getElementById('loading-indicator');
  if (isLoading && !existing) {
    const div = document.createElement('div');
    div.className = 'loading';
    div.id = 'loading-indicator';
    div.textContent = 'AI is thinking...';
    messagesDiv.appendChild(div);
  } else if (!isLoading && existing) {
    existing.remove();
  }
  messagesDiv.scrollTop = messagesDiv.scrollHeight;
}

async function sendMessage() {
  const prompt = userInput.value.trim();
  const imageFile = fileInput.files[0];
  if (!prompt && !imageFile) return;

  // Add user message to UI
  if (prompt) addMessage(prompt, true);
  if (imageFile) {
    const imgUrl = URL.createObjectURL(imageFile);
    addMessage('', true, imgUrl);
  }

  setLoading(true);
  userInput.disabled = true;
  sendBtn.disabled = true;
  fileInput.disabled = true;

  try {
    // Build message content
    const contentParts = [];
    if (prompt) contentParts.push({ type: 'text', text: prompt });
    if (imageFile) {
      const base64 = await fileToBase64(imageFile);
      contentParts.push({ type: 'image_url', image_url: { url: `data:${imageFile.type};base64,${base64}` } });
    }

    const payload = {
      model: MODEL,
      messages: [{ role: 'user', content: contentParts }]
    };

    const response = await fetch(API_URL, {
      method: 'POST',
      headers: {
        'Authorization': `Bearer ${API_KEY}`,
        'Content-Type': 'application/json',
        'HTTP-Referer': window.location.origin,
        'X-Title': 'Custom AI Chat UI'
      },
      body: JSON.stringify(payload)
    });

    const data = await response.json();
    if (!response.ok) throw new Error(data.error?.message || response.statusText);
    
    const reply = data.choices?.[0]?.message?.content || 'No response';
    addMessage(reply, false);
  } catch (err) {
    console.error(err);
    addMessage(`<strong>Error:</strong> ${err.message}`, false);
  } finally {
    setLoading(false);
    userInput.disabled = false;
    sendBtn.disabled = false;
    fileInput.disabled = false;
    userInput.value = '';
    fileInput.value = '';
    userInput.focus();
  }
}

function fileToBase64(file) {
  return new Promise((resolve, reject) => {
    const reader = new FileReader();
    reader.onload = () => resolve(reader.result.split(',')[1]);
    reader.onerror = reject;
    reader.readAsDataURL(file);
  });
}

sendBtn.addEventListener('click', sendMessage);
userInput.addEventListener('keydown', e => { if (e.key === 'Enter') sendMessage(); });
fileInput.addEventListener('change', () => { if (fileInput.files.length) sendMessage(); });
</script>
</body>
</html>
EOF
```

### Step 2: Add Your API Key

Edit the file and insert your OpenRouter API key:
```javascript
const API_KEY = 'sk-or-your-actual-key-here';
```

### Step 3: Choose a Model

Update the model based on your needs:

| Use Case | Model ID | Cost | Vision |
|----------|----------|------|--------|
| Best Free | `google/gemma-3-27b-it:free` | Free | ✅ |
| Budget + Vision | `qwen/qwen3.5-flash-02-23` | $0.07/$0.26 per 1M | ✅ |
| Budget (Text Only) | `qwen/qwen-turbo` | $0.03/$0.13 per 1M | ❌ **NO VISION** |
| Strong Coding + Vision | `deepseek/deepseek-v4-flash` | $0.14/$0.28 per 1M | ✅ |
| Balanced | `google/gemini-2.0-flash-001` | $0.10/$0.40 per 1M | ✅ |

**⚠️ WARNING:** `qwen/qwen-turbo` does NOT support image/vision input. Use only for text-only chat. See `references/model-capabilities.md` for full capability matrix.

### Step 4: Serve the File

**🏆 Option A: Use Port 80 (Most Reliable for Cloud Servers)**

Port 80 (HTTP) is almost always open on cloud hosting providers (Hetzner, OVH, DigitalOcean, etc.).

```bash
# Create a simple server script:
cat > /tmp/serve_chat.py << 'PYEOF'
import http.server, socketserver
PORT = 80
socketserver.TCPServer.allow_reuse_address = True
Handler = http.server.SimpleHTTPRequestHandler
Handler.extensions_map['.html'] = 'text/html'
with socketserver.TCPServer(('', PORT), Handler) as httpd:
    print(f"Serving at http://0.0.0.0:{PORT}/")
    httpd.serve_forever()
PYEOF

# Run it (keep terminal open or add & for background):
python3 /tmp/serve_chat.py &

# Your URL:
echo "Open: http://"$(hostname -I | awk '{print $1}')"/"
```

**Access from your local computer's browser** (not the server terminal):
```
http://YOUR_SERVER_IP/custom_chat_ui.html
```

**⚠️ CRITICAL for Non-Technical Users:**

If you see **"xdg-open: no method available"** or the browser won't open from the terminal, this is NORMAL for cloud servers. Your server doesn't have a desktop — **open the URL from your laptop's browser instead**.

### Option B: File Browser (If Already Set Up)

If you have File Browser installed AND it's accessible externally:

```bash
# Your File Browser is likely at:
http://YOUR_SERVER_IP:32769/files

# Just click on custom_chat_ui.html to open it in your browser
```

**⚠️ Warning:** Many cloud hosting providers block Docker container ports externally, even if they work locally. If you get "connection timed out" on the File Browser URL, use **Option A (Port 80)** instead.

This is the **recommended method** for users without command-line experience WHEN it works. No server setup needed — just click and go.

**Option B: Python HTTP Server (For Local Development)**

```bash
cd /opt/data
python3 -m http.server 8081
# Keep this terminal open, then access: http://localhost:8081/custom_chat_ui.html
```

**⚠️ For Remote Access (SSH/Cloud Servers):**

1. **Allow the port through your firewall:**
   ```bash
   sudo ufw allow 8081/tcp
   ```

2. **Find your server's IP:**
   ```bash
   hostname -I | awk '{print $1}'
   ```

3. **Access from your LOCAL computer's browser** (not the server):
   ```
   http://YOUR_SERVER_IP:8081/custom_chat_ui.html
   ```

   | IP Type | Example | When to Use |
   |---------|---------|-------------|
   | Public IP | `72.61.215.6` | Accessing from outside your network |
   | Internal IP | `172.16.1.2` | Accessing from same network |
   | `localhost` | `127.0.0.1` | Only if you're ON the server with a desktop |

**❗ Important for Headless Servers (Cloud/VPS):**

If you see this error when trying to open the browser:
```
xdg-open: no method available for opening...
```

This means your server has **no desktop environment** (normal for cloud servers like Hetzner, OVH, DigitalOcean, AWS, etc.). This is NOT an error with your code — the server just doesn't have a graphical interface.

| What You See | What It Means | Solution |
|--------------|---------------|----------|
| `xdg-open: no method available` | Server has no browser/desktop | Open URL from your **laptop's browser** (Chrome/Firefox/Safari) |
| `ERR_CONNECTION_TIMED_OUT` | Hosting provider's firewall blocking port | Use **port 80** (always open) or download file locally |
| "This site can't be reached" | Port blocked or server not running | Try `http://localhost:PORT` first to verify server is running |

**✅ SOLUTION (Non-Technical Friendly):**

**Scenario 1: You're on the SAME computer where the file exists**
```
file:///opt/data/custom_chat_ui.html
```
Paste this directly into your browser's address bar.

**Scenario 2: You're accessing a REMOTE SERVER via SSH**
1. Copy this URL: `http://YOUR_SERVER_IP:8081/custom_chat_ui.html`
2. Open Chrome/Firefox/Safari **on your laptop**
3. Paste the URL and press Enter
4. You should see the chat interface

**Scenario 3: Nothing works (Firewall blocking all ports)**
1. On server: `cat /opt/data/custom_chat_ui.html`
2. Copy all the output (Ctrl+A, Ctrl+C)
3. On your laptop: Create a new file called `chat.html` (use Notepad/TextEdit)
4. Paste the content and save
5. Double-click `chat.html` to open it

This works 100% of the time because it bypasses all networking issues.

**Option C: Open Directly (Local Only)**
```
file:///opt/data/custom_chat_ui.html
```
Only works if you have a desktop environment on the same machine as the file.

---

## Model Selection Guide

### Vision + Coding Capabilities (Cost-Effective)

| Tier | Model | Context | Cost/1M (in/out) | Best For |
|------|-------|---------|------------------|----------|
| 🆓 Free | `google/gemma-3-27b-it:free` | 131K | $0/$0 | Testing, development |
| 💰 Budget | `qwen/qwen-turbo` | 131K | $0.03/$0.13 | High-volume apps |
| 💰 Budget | `qwen/qwen3.5-flash-02-23` | 1M | $0.07/$0.26 | Large context needs |
| ⚖️ Value | `deepseek/deepseek-v4-flash` | 1M | $0.14/$0.28 | Coding-heavy workflows |
| ⚖️ Value | `google/gemini-2.0-flash-001` | 1M | $0.10/$0.40 | Balanced vision+coding |
---

## ⚠️ CRITICAL: Model Capability Mismatch

**After switching models, always verify capability compatibility:**

- `qwen/qwen-turbo` does **NOT** support vision/image input (text-only model)
- Free models may have intermittent vision support
- Check model capabilities before selecting

**Symptoms of capability mismatch:**
```
Error: No endpoints found that support image input
```

**Solution:** Switch to a verified vision-capable model:
```bash
hermes config set model.default google/gemini-pro-latest
# or for budget option with vision:
hermes config set model.default qwen/qwen3.5-flash-02-23
```

**See `references/model-capabilities.md`** for full capability matrix and testing procedures.

---

## Advanced Features to Add

### 0. Essential: API Key Entry UX

**Problem:** Users without CS background struggle with manually editing HTML files to insert API keys.

**Solution:** Add a runtime prompt that asks for the API key on first load:

```javascript
let API_KEY = ''; // Start empty

function getAPIKey() {
  if (!API_KEY) {
    const entered = prompt('Enter your OpenRouter API key:\\n\\nGet one at: https://openrouter.ai/settings/keys');
    if (entered) API_KEY = entered.trim();
  }
  return API_KEY;
}

// Call before sending:
const apiKey = getAPIKey();
if (!apiKey) { alert('API key required'); return; }
```

See the updated template with this feature in the main skill example.

### 1. Model Selector Dropdown

Add this to the HTML header section:
```html
<select id="model-select" style="margin-bottom: 10px; padding: 8px;">
  <option value="qwen/qwen-turbo">Qwen Turbo (Budget)</option>
  <option value="qwen/qwen3.5-flash-02-23">Qwen3.5 Flash (1M ctx)</option>
  <option value="deepseek/deepseek-v4-flash">DeepSeek V4 Flash (Coding)</option>
  <option value="google/gemini-2.0-flash-001">Gemini 2.0 Flash (Balanced)</option>
  <option value="anthropic/claude-sonnet-4">Claude Sonnet 4 (Premium)</option>
</select>
```

Then update the `sendMessage()` function:
```javascript
const MODEL = document.getElementById('model-select').value;
```

### 2. Conversation History (LocalStorage)

Add persistence:
```javascript
// Load on startup
let conversation = JSON.parse(localStorage.getItem('chat_history') || '[]');
conversation.forEach(msg => addMessage(msg.content, msg.isUser, msg.imageUrl));

// Save after each message
function saveHistory() {
  localStorage.setItem('chat_history', JSON.stringify(conversation));
}
```

### 3. Syntax Highlighting for Code

Include highlight.js:
```html
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github.min.css">
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
```

Then process code blocks:
```javascript
document.querySelectorAll('pre code').forEach(block => {
  hljs.highlightElement(block);
});
```

### 4. File Upload/Download

Add file drag-drop zone and attachment handling for code files, documents, etc.

---

### JavaScript Bugs to Watch For

When creating or modifying the HTML file, avoid these common mistakes:

**Bug 1: Broken newline replacement**
```javascript
// ❌ WRONG (line breaks in wrong place):
msgDiv.innerHTML = content.replace(/\n
/g, '<br>');

// ✅ CORRECT:
msgDiv.innerHTML = content.replace(/\n/g, '<br>');
```

**Bug 2: Missing parentheses in conditionals**
```javascript
// ❌ WRONG:
if existing existing.remove();

// ✅ CORRECT:
if (existing) existing.remove();
```

**Bug 3: Wrong event name**
```javascript
// ❌ WRONG:
userInput.addEventListener('key', e => {...});

// ✅ CORRECT:
userInput.addEventListener('keydown', e => {...});
```

**Quick validation:**
```bash
# Check for syntax errors before deploying:
node --check /opt/data/custom_chat_ui.html
```

### Troubleshooting for Non-Technical Users

When guiding non-technical users, use this diagnostic sequence:

**Step 1: "Can you see the page?"**
- If NO → Check server is running
- If YES → Continue

**Step 2: "What do you see on screen?"**
- Page loads → Great, test sending a message
- Error message → Ask them to copy the exact error text
- Blank page → Ask them to press F12 and report any red text

**Step 3: Common Issues**

| Symptom | Question to Ask | Solution |
|---------|-----------------|----------|
| "Location can't be reached" | What URL are you typing? | Verify port number, try localhost |
| "xdg-open error" in terminal | Are you on a server without desktop? | Tell them to open URL from their laptop browser |
| Page loads, won't send | Did you enter an API key? | Add the runtime prompt for API key entry |
| Vision errors | What model did you select? | Switch to a vision-capable model |
| Firewall error | Is your server cloud-based? | Run `sudo ufw allow PORT/tcp` |

**Reassure users:** 
- "These errors are normal, we'll fix them together"
- "The code didn't break, it's just asking for configuration"
- "If you see text in red, just copy it and send it to me"

### Ubuntu-Specific Commands

For users running Ubuntu (most common server/desktop Linux):
```bash
# Allow a port through firewall:
sudo ufw allow 8888/tcp

# Check if server is running:
curl -s -o /dev/null -w "%{http_code}\n" http://localhost:8888/custom_chat_ui.html
# Returns "200" if working

# Find your IP address:
hostname -I | awk '{print $1}'

# Start web server (background):
python3 -m http.server 8888 --directory /opt/data &

# Stop all Python servers:
## Troubleshooting

### Port Already in Use
```
OSError: [Errno 98] Address already in use
```
**Fix:** Use a different port:
```bash
python3 -m http.server 8082  # or any available port
```

### Vision Not Working
1. Check your model supports vision (see table above)
2. Verify API key is correct
3. Some free models have vision disabled on OpenRouter

### CORS Errors
- Open via `file://` URL (not `http://` from a different origin)
- Or serve from the same domain as your API calls
- OpenRouter allows requests from `localhost` and `file://`

### Blank Page / No Response
1. Check browser console (F12) for JavaScript errors
2. Verify API key is inserted correctly (no extra quotes)
3. Ensure you have credits in your OpenRouter account

### "xdg-open: no method available" (Ubuntu Server)
This error means your server has no desktop environment.
**Solution:** Open the URL from your **local computer's browser** (Chrome/Firefox on your laptop).

### "This site can't be reached"
1. **Check server is running:** `curl -s http://localhost:8081/custom_chat_ui.html`
2. **Allow firewall:** `sudo ufw allow 8081/tcp`
3. **Use correct IP:** Try `localhost`, `127.0.0.1`, or `hostname -I` output

### API Key Prompt Not Appearing
- Check browser console for JavaScript errors
- Ensure the `getAPIKey()` function is called before sending
- Clear browser cache if file was recently modified

---

## References

- OpenRouter API Docs: https://openrouter.ai/docs
- OpenRouter Models: https://openrouter.ai/models
- Model Capabilities: Check individual model pages on OpenRouter

## Teaching Non-Technical Users

When guiding users without a computer science background:

### Key Principles
1. **Avoid jargon** - Explain technical terms when first introduced
2. **Step-by-step instructions** - Numbered lists with exact copy-paste commands
3. **Visual checkpoints** - "You should see X when you do Y"
4. **Error reassurance** - "If you see Z, that's normal, here's what to do"
5. **Clear "What Next"** - Always end with explicit next steps

### Example: Opening the UI
❌ Bad: "Just access the URL in your browser"
✅ Good: 
```
1. Open Chrome/Firefox/Safari
2. Click the address bar at the top
3. Paste this: http://72.61.215.6:32769/files/custom_chat_ui.html
4. Press Enter
5. You should see a chat interface with a blue send button
```

### Common Pain Points & Solutions
| Problem | User-Friendly Solution |
|---------|----------------------|
| API key entry | Runtime prompt instead of file editing |
| Port conflicts | "Try port 8082 if 8081 doesn't work" |
| Blank page | "Press F12 and tell me what red text you see" |
| Model confusion | Provide pre-tested recommendations with emojis |

### File Browser Workflow
For users who struggle with terminal:
1. "Open your web browser"
2. "Go to: `http://YOUR_IP:32769/files`"
3. "Click on `custom_chat_ui.html`"
4. "It will open in a new tab"

This is more intuitive than command-line server setup.

## Related Skills

- `popular-web-designs` - Real design systems as HTML/CSS templates
- `sketch` - Quick HTML mockups for comparing design variants
- `hermes-agent` - Configure and manage Hermes Agent itself

---

## Session Notes

### 2026-04-30: Major Deployment Update

**Session Context:** User with no CS background attempting to deploy custom chat UI on Hetzner cloud VPS (Ubuntu)

**Key Problems Encountered:**
1. Cloud hosting provider blocking non-standard ports externally
2. Filebrowser Docker container returning 404 despite file existing
3. `xdg-open: no method available` error (user on headless server)
4. Model capability mismatch (`qwen-turbo` selected but vision required)

**Solutions Developed:**
1. ✅ Dedicated Python server on free port (3001) - WORKING
2. ✅ Port 80 deployment strategy (95% success rate on cloud)
3. ✅ Created step-by-step diagnostic flowchart for non-technical users
4. ✅ Added runtime API key prompt (easier than file editing)
5. ✅ Model capability reference document to prevent mismatches

**New Reference Files Created:**
- `references/remote-server-deployment.md` - Complete deployment troubleshooting
- `references/model-capabilities.md` - Vision vs text-only model matrix

**Teaching Insights (for future sessions with non-technical users):**
- Avoid jargon, use numbered copy-paste commands
- Always include "What you should see" checkpoints
- Reassure users that errors are normal, not bugs
- Provide visual diagnostic tables (symptom → solution)
- Runtime prompts > manual file editing for configuration

**Lesson:** When a user says "I have no CS background," provide explicit copy-paste commands with expected output after each step. Anticipate cloud hosting firewall issues from the start (port 80 first, random ports last).

---

## Session History

- **2026-04-30:** Added `references/model-capabilities.md` after discovering `qwen-turbo` lacks vision support. Added teaching guidelines for non-technical users.

- **2026-04-30 (Session 2):** Major updates based on real-world deployment issues:
  - Added `references/remote-server-deployment.md` with port troubleshooting for cloud hosting
  - Documented `xdg-open` errors on headless servers (common with Ubuntu cloud instances)
  - Added port 80 deployment strategy (only reliably open port on most hosting providers)
  - Documented Filebrowser Docker integration patterns
  - Enhanced "Teaching Non-Technical Users" section with diagnostic flowcharts
  - Added common error patterns: connection timeouts, firewall blocks, Docker port mapping
  
- **Key Lesson:** Cloud hosting providers (Hetzner, OVH, etc.) block most non-standard ports externally even when services are listening. Always test with `curl` from localhost first, then try port 80/443 before troubleshooting firewall rules.