Add OpenAI-compatible LLM API calls to vanilla HTML/JS browser applications. Covers fetch-based chat completions, URL path construction, parameter handling, settings persistence, and UI patterns for e
Scanned 10/6/2026
npx -y skills add Kairos-ai-agent/kairos-code --skill browser-llm-integration --agent claude-codeInstalls into .claude/skills of the current project.
Are you the author of Browser Llm Integration?
Add the live security badge to your README — it updates automatically with every re-scan.
[](https://www.skillsdirectory.com/skills/kairos-ai-agent-browser-llm-integration)More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.
---
name: "browser-llm-integration"
description: "Add OpenAI-compatible LLM API calls to vanilla HTML/JS browser applications. Covers fetch-based chat completions, URL path construction, parameter handling, settings persistence, and UI patterns for e"
priority: 0.5
imported-from: "agents"
source-path: "agents/skills/software-development/browser-llm-integration/SKILL.md"
---
# Browser LLM Integration
Integrate OpenAI-compatible LLM APIs into custom HTML/JS applications running in the browser.
## Quick Start
```js
// Minimal working call
var url = baseUrl.replace(/\/+$/,'') + '/v1/chat/completions';
fetch(url, {
method: 'POST',
headers: {'Authorization': 'Bearer '+apiKey, 'Content-Type': 'application/json'},
body: JSON.stringify({model: modelName, messages: [{role:'user', content:msg}]})
})
.then(r => r.json())
.then(data => {
var reply = data.choices[0].message.content;
});
```
## URL Path Construction
Users enter a "base URL" — handle multiple input formats:
```js
var base = llmConfig.baseUrl.replace(/\/+$/, '');
var url = base;
if (/\/chat\/completions$/i.test(base)) url = base;
else if (/\/v1$/i.test(base)) url = base + '/chat/completions';
else url = base + '/v1/chat/completions';
```
| User Input | Result |
|---|---|
| `https://api.openai.com` | `.../v1/chat/completions` |
| `https://api.deepseek.com/v1` | `.../v1/chat/completions` |
| `http://localhost:11434` | `.../v1/chat/completions` |
| `https://api.xxx.com/v1/chat/completions` | Used as-is |
## max_tokens Pitfall
Some models (e.g. DeepSeek `deepseek-v4-flash`) reject `max_tokens` entirely. Implement conditional inclusion + retry:
```js
var body = {model, messages, temperature};
var mt = parseInt(maxTokens);
if (!isNaN(mt) && mt > 0 && mt < 999999) body.max_tokens = mt;
// Retry on 400 with max_tokens error
fetch(url, {method:'POST', headers, body: JSON.stringify(body)})
.then(r => {
if (!r.ok) return r.text().then(txt => {
if (body.max_tokens && txt.indexOf('max_tokens') >= 0) {
delete body.max_tokens;
// retry without max_tokens
return fetch(url, {method:'POST', headers, body: JSON.stringify(body)});
}
throw new Error('HTTP ' + r.status + ' - ' + txt);
});
return r.json();
})
```
## Paste Event Interception
Canvas apps often intercept `paste` globally with `e.preventDefault()`. This breaks pasting into config modals' input fields. Fix:
```js
document.addEventListener('paste', function(e) {
var tag = document.activeElement && document.activeElement.tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA') return; // let native paste through
var el = document.activeElement;
while (el) { if (el.id === 'modalId') return; el = el.parentElement; } // modal check
e.preventDefault();
// ... canvas paste handling ...
});
```
## Settings Panel Pattern
- Store API config in a separate `localStorage` key (e.g. `ic_llm`) — NOT mixed with app state
- Settings modal: overlay with form fields for baseUrl, apiKey, model, systemPrompt, temperature, maxTokens
- On open: load from localStorage and populate fields
- On save: read fields, validate, write to localStorage, close modal
- Include LLM config in export/import (embed in exported JSON)
## Flexbox Node Layout
When building custom node UIs with a textarea + action footer:
```css
.node-body { flex: 1; display: flex; flex-direction: column; }
.node-body textarea { flex: 1; }
.node-foot { flex-shrink: 0; display: flex; align-items: center; }
.node-foot .status { flex: 1; } /* pushes button to right */
```
The wrapping container (`.ndb` or equivalent) must use `flex-direction: column` or wrap body+foot in a sub-container that does. Default is `row` which causes body and foot to appear side by side.
**Wrapper pattern (most reliable):**
```html
<div class="ndb"> <!-- parent: display:flex (default row) -->
<div class="node-wrap" style="flex:1;display:flex;flex-direction:column;overflow:hidden">
<div class="node-body" style="flex:1"><textarea ...></textarea></div>
<div class="node-foot" style="flex-shrink:0;display:flex;align-items:center">
<span class="status" style="flex:1">● 就绪</span>
<button>▶ 发送</button>
</div>
</div>
</div>
```
The `.node-wrap` inner div creates a column flow INSIDE the row-oriented parent, so the textarea fills vertical space and the footer sits at the bottom.
## Response Node Overwrite Pattern (Canvas/Node UIs)
When a user can click "send" multiple times on the same LLM node, avoid stacking duplicate response nodes. Instead, overwrite the existing one:
**Store `respId` on the LLM node object:**
```js
// First send: create node + store reference
var respId = id++;
var rn2 = {id: respId, x: llmNode.x + llmNode.w + 40, y: llmNode.y, ...};
N.push(rn2);
// ... render node ...
C.push({id: 'c_'+(id++), from: llmNode.id, to: respId});
llmNode.respId = respId; // ← critical: direct reference on source node
// Subsequent sends: check and overwrite
if (llmNode.respId) {
var existingNode = gi(llmNode.respId);
if (existingNode) {
existingNode.content = reply;
// Update DOM: textarea value and title
var el = document.getElementById('n' + existingNode.id);
if (el) {
var ta = el.querySelector('.ndb textarea');
if (ta) ta.value = reply;
}
sl(existingNode.id); rd(); sv2();
return; // done — no new node created
}
}
// If no existing node (deleted, or first time), create new one
```
**Persistence:** Custom properties like `respId` must be explicitly included in all serialization paths:
- `saveState()` (undo stack)
- `sv2()` (localStorage auto-save)
- `svf()` (HTML export/embed)
## Error Display
Show errors directly in the node UI rather than alerts:
```js
var st = document.getElementById('status_' + nid);
st.textContent = '错误: ' + err.message;
st.className = 'status err'; // red coloring via CSS
```
Include the actual request body in error messages for debugging:
```js
throw new Error('HTTP ' + status + ' - ' + responseText + ' (sent: ' + JSON.stringify(body).slice(0,200) + ')');
```
## File Upload in LLM Nodes
Support uploading `.txt`, `.md`, `.docx`, `.pdf` into the LLM node. Two UX patterns:
### Pattern A: File content into textarea (simple)
Put file content directly into the LLM node's textarea so the user can edit before sending.
### Pattern B: File as link (node apps, recommended for canvas UIs)
Store file content on the node object, show a filename bar, and **prepend to the message on send** — keeps the textarea clean.
**Node data storage:**
```js
// On the LLM node object:
n.fileName = file.name; // original filename
n.fileContent = extractedText; // parsed text content
```
**Display: show a file bar between textarea and footer:**
```html
<div class="file-bar">
<span>📄 filename.txt</span>
<button onclick="removeFile(nodeId)">✕</button>
</div>
```
Render conditionally based on `n.fileName`:
```js
var fileHtml = n.fileName
? '<div class="file-bar" id="fb_'+n.id+'"><span>📄 '+es(n.fileName)+'</span>'+
'<button onclick="rmLLMFile('+n.id+')">✕</button></div>'
: '';
```
**Prepend on send:**
```js
var sendMsg = msg; // user's textarea content
if (n.fileName && n.fileContent) {
sendMsg = '以下是文件「' + n.fileName + '」的内容:\n---\n'
+ n.fileContent + '\n---\n\n' + msg;
}
```
**Remove file function:**
```js
function rmLLMFile(nid) {
var n = gi(nid);
if (!n) return;
n.fileName = null;
n.fileContent = null;
rn(n); // re-render to hide file bar
}
```
**Persistence:** Include `fileName` and `fileContent` in all serialization paths (saveState, sv2, export).
### Hidden file input
```html
<input type="file" id="llm_file_input" accept=".txt,.md,.docx,.pdf" style="display:none"
onchange="handleLLMFile(this.files)">
```
### Trigger + handler
```js
var _llmUploadTarget = null;
function pickLLMFile(nid) {
_llmUploadTarget = nid;
var inp = document.getElementById('llm_file_input');
inp.value = ''; // allow re-selecting same file
inp.click();
}
```
Use a shared `done(content)` callback that stores on the node:
```js
function handleLLMFile(files) {
if (!files || !files[0] || !_llmUploadTarget) return;
var file = files[0], name = file.name.toLowerCase(), nid = _llmUploadTarget;
var n = gi(nid); if (!n) return;
var st = document.getElementById('status_' + nid);
if (st) { st.textContent = '读取中...'; st.className = 'status busy'; }
function done(content) {
n.fileName = file.name;
n.fileContent = content;
rn(n); rd(); sv2(); // re-render node to show file bar
if (st) { st.textContent = '就绪'; st.className = 'status'; }
tm('文件已载入: ' + file.name);
}
if (name.endsWith('.txt') || name.endsWith('.md')) {
var r = new FileReader();
r.onload = function(e) { done(e.target.result); };
r.readAsText(file);
} else if (name.endsWith('.docx')) {
loadScript('https://cdnjs.cloudflare.com/ajax/libs/mammoth/1.6.0/mammoth.browser.min.js', function() {
var r = new FileReader();
r.onload = function(e) {
mammoth.extractRawText({ arrayBuffer: r.result })
.then(function(res) { done(res.value); })
.catch(function() { /* show error */ });
};
r.readAsArrayBuffer(file);
}, function() { /* CDN load failed */ });
} else if (name.endsWith('.pdf')) {
loadScript('https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js', function() {
pdfjsLib.GlobalWorkerOptions.workerSrc =
'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js';
var r = new FileReader();
r.onload = function(e) {
pdfjsLib.getDocument({ data: r.result }).promise.then(function(pdf) {
var texts = [];
(function lp(i) {
if (i > pdf.numPages) { done(texts.join('\n\n')); return; }
pdf.getPage(i).then(function(p) { return p.getTextContent(); })
.then(function(tc) {
texts.push(tc.items.map(function(it) { return it.str; }).join(' '));
lp(i + 1);
});
})(1);
}).catch(function() { /* show error */ });
};
r.readAsArrayBuffer(file);
}, function() { /* CDN load failed */ });
}
}
```
### Dynamic script loader
```js
function loadScript(url, ok, fail) {
var s = document.createElement('script');
s.src = url;
s.onload = ok;
s.onerror = fail || function() {};
document.head.appendChild(s);
}
```
### Upload button styling
For canvas node UIs, match the upload button style to the send button:
```css
.nd-llm-upbtn {
padding: 8px 14px; background: #000; color: #fff; border: none;
border-radius: 6px; font-size: 13px; cursor: pointer; font-family: inherit;
flex-shrink: 0;
}
.nd-llm-upbtn:hover { background: #333; }
```
## Markdown Response Rendering
When LLM responses contain structured content (tables, code, formatting), render as HTML instead of a plain textarea. Create a dedicated node type (e.g. `llm-resp`) that displays formatted content.
### Node type registration in `rn()`
```js
} else if (n.type === 'llm-resp') {
b = '<div class="nd-md">' + mdToHtml(n.content || '') + '</div>';
}
```
### Markdown-to-HTML with Placeholder Protection
The key technique: extract tables (and code blocks) into placeholders FIRST, process text formatting (bold, italic, line breaks) on the remaining content, then restore the placeholders LAST. This prevents `<br>` and `<p>` tags from corrupting table HTML.
```js
function mdToHtml(text) {
// 1. HTML-escape
text = text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
// 2. Extract code blocks into placeholders
var codes = [];
text = text.replace(/```([\s\S]*?)```/g, function(m, c) {
var i = codes.length; codes.push(c); return '\x00CODE' + i + '\x00';
});
// 3. Extract tables into placeholders
var tables = [];
text = text.replace(/((?:^|\n)[ \t]*\|[ \t]*[^\n|]+[ \t]*\|[^\n]*(?:\n[ \t]*\|[ \t]*[^\n|]+[ \t]*\|[^\n]*)*)/g, function(m) {
var idx = tables.length;
var html = parseTable(m); // see below
tables.push(html);
return '\x00TABLE' + idx + '\x00';
});
// 4. Restore code blocks
for (var i = 0; i < codes.length; i++)
text = text.replace('\x00CODE' + i + '\x00', '<pre><code>' + codes[i] + '</code></pre>');
// 5. Inline formatting (safe — no tables to corrupt)
text = text.replace(/`([^`]+)`/g, '<code>$1</code>');
text = text.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
text = text.replace(/\*([^*]+)\*/g, '<em>$1</em>');
// 6. Line breaks
text = text.replace(/\n\n/g, '</p><p>');
text = text.replace(/\n/g, '<br>');
// 7. Restore tables LAST
for (var i = 0; i < tables.length; i++)
text = text.replace('\x00TABLE' + i + '\x00', tables[i]);
return '<p>' + text + '</p>';
}
```
### Table parser: handle multiple formats
```js
function parseTable(block) {
var rows = block.split('\n');
// Pre-scan: find separator line (---|---|---)
var sepRow = -1;
for (var ri = 0; ri < rows.length; ri++) {
var ln = cleanPipeLine(rows[ri]);
var cells = ln.split('|').map(t => t.trim()).filter(c => c !== '');
if (cells.length && cells.every(c => /^[-:\s]+$/.test(c))) { sepRow = ri; break; }
}
var html = '<table>';
for (var ri2 = 0; ri2 < rows.length; ri2++) {
if (ri2 === sepRow) continue;
var ln2 = cleanPipeLine(rows[ri2]);
var cells2 = ln2.split('|').map(t => t.trim()).filter(c => c !== '');
if (!cells2.length) continue;
var isHd = sepRow < 0 ? ri2 === 0 : ri2 < sepRow;
html += '<tr>';
for (var ci = 0; ci < cells2.length; ci++) {
var cc = cells2[ci]
.replace(/`([^`]+)`/g, '<code>$1</code>')
.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
.replace(/\*([^*]+)\*/g, '<em>$1</em>');
html += '<' + (isHd ? 'th' : 'td') + '>' + cc + '</' + (isHd ? 'th' : 'td') + '>';
}
html += '</tr>';
}
return html + '</table>';
}
function cleanPipeLine(line) {
var l = line.trim();
if (l.charAt(0) === '|') l = l.substring(1);
if (l.charAt(l.length - 1) === '|') l = l.substring(0, l.length - 1);
return l;
}
```
### Table CSS (Excel-like grid)
```css
.nd-md table { border-collapse: collapse; margin: 6px 0; width: 100%; font-size: 12px; }
.nd-md td, .nd-md th { border: 1px solid #999; padding: 4px 8px; text-align: left; white-space: nowrap; }
.nd-md th { background: #e8e8e8; font-weight: 600; border-color: #888; }
.nd-md tr:nth-child(even) { background: #f8f8f8; }
.nd-md code { background: #f0f0f0; padding: 1px 5px; border-radius: 3px; font-size: 12px; }
.nd-md pre { background: #f5f5f5; padding: 8px; border-radius: 4px; overflow-x: auto; }
```
### Table formats handled
| Input format | Support | Example |
|---|---|---|
| `\| H1 \| H2 \|` (standard) | ✅ | Standard markdown |
| ` \| H1 \| H2 \|` (leading spaces) | ✅ | Regex allows `[ \t]*` |
| `H1 \| H2` (no outer pipes) | ✅ | Cleaned by `cleanPipeLine` |
| `\|1\| \|2\|` (individual pipe-wrapped cells) | ✅ | `filter(c => c !== '')` removes empties |
| No `---|---` separator | ✅ | First row = `<th>`, rest = `<td>` |
| Multiple tables in one response | ✅ | Each extracted independently via placeholders |
### Response node overwrite for repeated sends
When re-sending from the same LLM node, overwrite the existing response node instead of stacking new ones. Store `respId` on the source node:
```js
// Create: store reference
n.respId = respId;
// Overwrite: use reference
if (n.respId) {
var existing = gi(n.respId);
if (existing) {
existing.content = reply;
rn(existing); // full re-render (handles both textarea and HTML types)
sl(existing.id); rd(); sv2();
}
}
```
For HTML response nodes (`llm-resp`), `rn()` must be used for overwrites — updating a textarea's `.value` won't work since the node uses a `.nd-md` div with `innerHTML`.
## Footer Layout Variants
### Right-aligned button (flex: 1 on status)
```css
.foot { display: flex; align-items: center; padding: 6px 10px; }
.status { flex: 1; } /* pushes button to right */
```
Result: `[● 就绪] [▶ 发送]`
### Centered layout (justify-content: center)
```css
.foot { display: flex; align-items: center; justify-content: center; gap: 6px; }
```
Result: ` [● 就绪] [📎] [▶ 发送] model-name `
The centered variant works well when adding extra elements (upload button, model label) alongside the send button.
## Model Name Label
After the send button, show the configured model name:
```html
<span class="model-label" id="model_${nodeId}">${llmConfig.model}</span>
```
```css
.model-label {
font-size: 11px; color: #aaa; max-width: 120px;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
```
Refresh all labels when settings are saved:
```js
function saveLLMSettings() {
// ... save to localStorage ...
for (var i = 0; i < N.length; i++) {
if (N[i].type === 'llm') {
var el = document.getElementById('model_' + N[i].id);
if (el) el.textContent = llmConfig.model;
}
}
}
```
## Status classes
- `.busy` (orange) — request in progress
- `.done` (green) — completed
- `.err` (red) — error
## "OpenAI-Compatible" ≠ Standard Format (Critical Pitfall)
When a user says "use OpenAI-compatible API" or "OpenAI protocol", DO NOT assume the standard `/v1/chat/completions` request shape. Providers labeled "OpenAI-compatible" vary significantly:
- **Request format**: Some use `messages[]`, others use `content[]` with `role` fields, others use flat `prompt` + `image_urls[]`
- **Image handling**: Some accept base64 data URIs directly; others require uploading to an asset library first and referencing by ID (`asset://xxx`)
- **Polling paths**: `/v1/video/generations/:id` vs `/v1/tasks/:id` vs `/v1/videos/generations/:id` — guessing wastes time
- **Response nesting**: `content.video_url` vs `data.video_url` vs `output.video_url` vs `url`
- **Status values**: `queued/running/succeeded/failed` vs `pending/processing/completed/error`
**Rule**: Always fetch and read the actual API docs before writing integration code. If the user provides docs, verify your implementation against them line by line. See `references/uptoken-media-api.md` for a worked example.
## Flask Backend Proxy Pattern (API Key Security)
For browser-based tools that call paid APIs, route through a Flask backend instead of calling from JS — keeps API keys server-side:
```
Browser → POST /api/generate → Flask backend → POST to external API
← poll /api/poll/:id ← GET /v1/.../:task_id
```
Server-side `config.json` stores `api_base`, `api_key`, `model`. Frontend reads non-sensitive config via `GET /api/config` (omits key, sends `has_key: bool`).
## Async Media Generation Pattern
Video/image generation APIs are async — submit task, poll for result:
1. **Upload**: POST asset to library → get `asset://id` URL
2. **Submit**: POST generation request with asset URL → get `task_id`
3. **Poll**: GET status every 5s → check `status` field → on `succeeded`, extract media URL
4. **Timeout**: Set max poll time (e.g. 2h for video generation)
Frontend shows spinner + elapsed time during poll. Log each poll cycle for debugging.
## Startup Ordering: Load Config Before Rendering
When using `loadLLMConfig()` to restore saved settings (model name, API key, etc.), call it **before** rendering any nodes. Otherwise node content (model labels, file bars) will render with stale defaults:
```js
loadLLMConfig(); // ← first: populate llmConfig from localStorage
// then: load canvas data and render nodes
```
If embedded HTML export data contains a `d.llm` config, apply it before `loadLLMConfig()`:
```js
if (d.llm) { for (var k in d.llm) llmConfig[k] = d.llm[k]; }
loadLLMConfig();
for (var i = 0; i < N.length; i++) rn(N[i]); // render with correct config
```
## The IIFE Rendering Trap (Critical)
In canvas/node UIs, `rn()` handles ALL node rendering. But when creating a new response node from an API callback, the code often uses an **IIFE** that duplicates `rn()`'s DOM construction:
```js
// In API callback:
var rn2 = {id: respId, type: 'llm-resp', ...};
N.push(rn2);
(function(rnd) {
var el = document.createElement('div');
el.innerHTML = '...<div class="ndb"><textarea>...</textarea></div>...';
w.appendChild(el);
})(rn2);
```
The IIFE's hardcoded innerHTML silently diverges from `rn()`. Every feature added to `rn()` (tables, file bars, model labels) breaks new-node creation.
**Fix:** Call `rn()` directly instead of duplicating logic:
```js
N.push(rn2);
rn(rn2); // ← single source of truth
sl(rn2.id); rd(); uh(); sv2();
```
Or, if IIFE is unavoidable, paste a comment block and duplicate the logic verbatim. Verify both paths after every `rn()` change.
## `.ndb overflow:hidden` Pitfall
The node body container (`.ndb`) often has `overflow: hidden` for drag-connect behavior. This clips child overflow, including wide tables and scrollbars.
**Fix:** Inner content containers should use `overflow: auto` for both axes, and tables should use `width: auto` (not `100%`) to expand to natural width:
```css
.nd-md { flex: 1; overflow: auto; }
.nd-md table { width: auto; }
```
## Simpler Inline Table Detection (Alternative)
For script/storyboard content with individual `|X|` wrapped cells, try a simpler line-by-line scan directly in `rn()`:
```js
// In rn(), for llm-resp type:
var lines = content.split('\n'), pipeCount = 0;
for (var i = 0; i < lines.length; i++)
if (lines[i].trim().indexOf('|') >= 0) pipeCount++;
if (pipeCount >= 2) {
var html = '<table>', isHeader = false;
for (var ri = 0; ri < lines.length; ri++) {
var rw = lines[ri].trim();
if (rw.indexOf('|') < 0) continue;
if (rw.charAt(0) === '|') rw = rw.substring(1);
if (rw.charAt(rw.length - 1) === '|') rw = rw.substring(0, rw.length - 1);
var cells = rw.split('|').map(s => s.trim()).filter(s => s !== '');
if (!cells.length) continue;
if (cells.every(s => /^[-:\s]+$/.test(s))) continue;
html += '<tr>';
for (var cj = 0; cj < cells.length; cj++)
html += '<' + (isHeader ? 'td' : 'th') + '>' + cells[cj] + '</' + (isHeader ? 'td' : 'th') + '>';
html += '</tr>';
isHeader = true;
}
b = html + '</table>';
} else {
b = '<p>' + es(content).replace(/\n/g, '<br>') + '</p>';
}
```
Trade-off: no inline bold/italic inside cells. Use the placeholder approach when formatting inside cells is needed.
Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.
No comments yet. Be the first to comment!