hermes-web / index.html
aaxaxax's picture
feat: add NVIDIA NIM and Baidu CoBoom multi-provider support
7e57cf8
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>🦀 Hermès Agent</title>
<style>
:root {
--primary: #8b5cf6;
--secondary: #ec4899;
--bg: #fafafa;
--surface: #ffffff;
--text: #1f2937;
--border: #e5e7eb;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: var(--bg); color: var(--text); min-height: 100vh; display: flex; flex-direction: column; }
header { background: linear-gradient(135deg, var(--primary), var(--secondary)); color: white; padding: 16px 24px; display: flex; align-items: center; justify-content: space-between; }
header h1 { font-size: 20px; font-weight: 700; }
header .badge { background: rgba(255,255,255,0.2); padding: 4px 12px; border-radius: 20px; font-size: 12px; }
.container { max-width: 900px; margin: 0 auto; padding: 24px; width: 100%; flex: 1; display: flex; flex-direction: column; gap: 16px; }
.settings { background: var(--surface); border-radius: 12px; padding: 20px; box-shadow: 0 1px 3px rgba(0,0,0,0.08); }
.settings h3 { font-size: 13px; color: #9ca3af; margin-bottom: 14px; text-transform: uppercase; letter-spacing: 0.5px; }
.settings-grid { display: grid; grid-template-columns: 1fr 180px; gap: 12px; }
.settings-row { display: flex; gap: 12px; flex-wrap: wrap; }
.settings-row > * { flex: 1; min-width: 200px; }
@media (max-width: 640px) {
.settings-grid { grid-template-columns: 1fr; }
.settings-row > * { min-width: 100%; }
}
input, select, textarea {
width: 100%; padding: 10px 14px; border: 1px solid var(--border); border-radius: 8px; font-size: 14px; outline: none; transition: border-color 0.2s, box-shadow 0.2s; background: var(--surface);
}
input:focus, select:focus, textarea:focus { border-color: var(--primary); box-shadow: 0 0 0 3px rgba(139,92,246,0.1); }
select { cursor: pointer; }
.field-hint { font-size: 11px; color: #9ca3af; margin-top: 5px; }
.field-hint code { background: #f3f4f6; padding: 1px 5px; border-radius: 3px; font-size: 11px; }
.provider-tabs { display: flex; gap: 4px; margin-bottom: 14px; background: #f3f4f6; padding: 4px; border-radius: 10px; }
.provider-tab { flex: 1; padding: 8px 12px; text-align: center; border-radius: 7px; font-size: 13px; font-weight: 500; cursor: pointer; transition: all 0.2s; color: #6b7280; border: none; background: transparent; }
.provider-tab.active { background: var(--surface); color: var(--primary); box-shadow: 0 1px 3px rgba(0,0,0,0.1); }
.provider-tab:hover:not(.active) { color: var(--text); }
.api-key-section { margin-top: 12px; border-top: 1px solid var(--border); padding-top: 12px; }
.api-key-section label { display: block; font-size: 13px; font-weight: 500; margin-bottom: 6px; }
.chat-container { background: var(--surface); border-radius: 12px; box-shadow: 0 1px 3px rgba(0,0,0,0.08); flex: 1; display: flex; flex-direction: column; min-height: 480px; }
.chat-header { padding: 14px 20px; border-bottom: 1px solid var(--border); font-size: 13px; color: #6b7280; display: flex; align-items: center; gap: 8px; }
.chat-header .dot { width: 8px; height: 8px; border-radius: 50%; background: #10b981; }
.chat-messages { flex: 1; overflow-y: auto; padding: 20px; display: flex; flex-direction: column; gap: 14px; }
.msg { max-width: 82%; padding: 11px 15px; border-radius: 14px; line-height: 1.65; font-size: 14px; white-space: pre-wrap; word-break: break-word; }
.msg.user { align-self: flex-end; background: var(--primary); color: white; border-bottom-right-radius: 4px; }
.msg.assistant { align-self: flex-start; background: #f3f4f6; border-bottom-left-radius: 4px; }
.msg.error { background: #fef2f2; color: #dc2626; border: 1px solid #fecaca; }
.msg.system { align-self: center; background: transparent; color: #9ca3af; font-size: 13px; text-align: center; max-width: 100%; }
.msg .model-tag { display: inline-block; font-size: 10px; background: rgba(0,0,0,0.06); padding: 1px 6px; border-radius: 4px; margin-bottom: 6px; color: #9ca3af; }
.msg.user .model-tag { background: rgba(255,255,255,0.2); color: rgba(255,255,255,0.8); }
.chat-input-area { padding: 14px 20px; border-top: 1px solid var(--border); display: flex; gap: 10px; }
.chat-input-area textarea { resize: none; flex: 1; height: 48px; }
.send-btn { background: linear-gradient(135deg, var(--primary), var(--secondary)); color: white; border: none; padding: 0 24px; border-radius: 8px; cursor: pointer; font-weight: 600; font-size: 14px; transition: opacity 0.2s; white-space: nowrap; }
.send-btn:hover { opacity: 0.88; }
.send-btn:disabled { opacity: 0.5; cursor: not-allowed; }
.loading { display: none; align-items: center; gap: 8px; color: #6b7280; font-size: 13px; padding: 8px 0; }
.loading.show { display: flex; }
.spinner { width: 16px; height: 16px; border: 2px solid var(--border); border-top-color: var(--primary); border-radius: 50%; animation: spin 0.8s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
.examples { display: flex; flex-wrap: wrap; gap: 8px; }
.example-chip { background: #f3f4f6; border: 1px solid var(--border); padding: 6px 14px; border-radius: 20px; font-size: 13px; cursor: pointer; transition: all 0.15s; }
.example-chip:hover { background: var(--primary); color: white; border-color: var(--primary); }
.model-info { font-size: 12px; color: #6b7280; margin-top: 4px; }
.model-info .tag { display: inline-block; padding: 1px 6px; border-radius: 3px; font-size: 10px; font-weight: 600; margin-right: 6px; }
.tag.nvidia { background: #76b90020; color: #76b900; }
.tag.baidu { background: #2932e120; color: #2932e1; }
.tag.anthropic { background: #d4a57420; color: #c47a43; }
.info-bar { font-size: 12px; color: #9ca3af; text-align: center; }
</style>
</head>
<body>
<header>
<h1>🦀 Hermès Agent</h1>
<span class="badge">HF Free · 多模型</span>
</header>
<div class="container">
<div class="settings">
<h3>⚙️ 模型与 API 设置</h3>
<div class="provider-tabs">
<button class="provider-tab active" data-provider="anthropic" onclick="switchProvider('anthropic')">🤖 Anthropic</button>
<button class="provider-tab" data-provider="nvidia" onclick="switchProvider('nvidia')">🔋 NVIDIA NIM</button>
<button class="provider-tab" data-provider="baidu" onclick="switchProvider('baidu')">🌐 百度 CoBoom</button>
</div>
<div class="settings-row">
<div>
<label for="model">模型</label>
<select id="model" onchange="updateModelInfo()"></select>
<div class="model-info" id="modelInfo"></div>
</div>
</div>
<div class="api-key-section">
<label for="apiKey">API Key <span id="keyHint"></span></label>
<input type="password" id="apiKey" placeholder="输入 API Key..." oninput="saveSettings()">
<div class="field-hint" id="keyHelp"></div>
</div>
</div>
<div class="chat-container">
<div class="chat-header">
<span class="dot"></span>
<span id="statusText">选择模型并输入 API Key 后开始对话</span>
</div>
<div class="chat-messages" id="messages">
<div class="msg system">🦀 Hermès Agent · 多模型 AI 助手<br><span style="font-size:12px;opacity:0.7">支持 Anthropic / NVIDIA NIM / 百度 CoBoom</span></div>
</div>
<div class="loading" id="loading">
<div class="spinner"></div>
<span id="loadingText">Hermès 思考中...</span>
</div>
<div class="chat-input-area">
<textarea id="input" rows="1" placeholder="输入消息,按 Enter 发送..." onkeydown="handleKeydown(event)"></textarea>
<button class="send-btn" id="sendBtn" onclick="sendMessage()">发送</button>
</div>
</div>
<div style="background:var(--surface);border-radius:12px;padding:16px 20px;box-shadow:0 1px 3px rgba(0,0,0,0.08);">
<div style="font-size:12px;color:#9ca3af;margin-bottom:10px;text-transform:uppercase;letter-spacing:0.5px">💡 示例问题</div>
<div class="examples">
<span class="example-chip" onclick="askExample(this.textContent)">你好,你叫什么名字?</span>
<span class="example-chip" onclick="askExample(this.textContent)">帮我写一个 Python Hello World</span>
<span class="example-chip" onclick="askExample(this.textContent)">解释什么是大语言模型</span>
<span class="example-chip" onclick="askExample(this.textContent)">用 Python 实现快速排序</span>
<span class="example-chip" onclick="askExample(this.textContent)">解释什么是 REST API</span>
</div>
</div>
<div class="info-bar">
API Key 仅存储在浏览器本地 · 直连各大 API 提供商
</div>
</div>
<script>
// ============================================================================
// Model Configurations
// ============================================================================
const PROVIDERS = {
anthropic: {
name: 'Anthropic',
baseUrl: 'https://api.anthropic.com/v1/messages',
keyPlaceholder: 'sk-ant-...',
keyHelp: '访问 <a href="https://console.anthropic.com/" target="_blank" style="color:var(--primary)">console.anthropic.com</a> 创建免费 API Key',
models: [
{ id: 'claude-sonnet-4-7-2027', name: 'Claude Sonnet 4', size: '~120B', tag: 'anthropic', hint: '最新旗舰模型' },
{ id: 'claude-3-5-sonnet-2027-06-20', name: 'Claude 3.5 Sonnet', size: '~70B', tag: 'anthropic', hint: '平衡性能与速度' },
{ id: 'claude-3-5-haiku-2027-03-07', name: 'Claude 3.5 Haiku', size: '~20B', tag: 'anthropic', hint: '快速响应' },
]
},
nvidia: {
name: 'NVIDIA NIM',
baseUrl: 'https://integrate.api.nvidia.com/v1/chat/completions',
keyPlaceholder: 'nvapi-...',
keyHelp: '访问 <a href="https://build.nvidia.com/" target="_blank" style="color:var(--primary)">build.nvidia.com</a> 获取免费 API Key',
models: [
{ id: 'nvidia/llama-3.1-nemotron-70b-instruct', name: 'Nemotron 70B', size: '70B', tag: 'nvidia', hint: 'NVIDIA 优化 Llama' },
{ id: 'meta/llama-3.1-405b-instruct', name: 'Llama 3.1 405B', size: '405B', tag: 'nvidia', hint: '超大参数模型' },
{ id: 'meta/llama-3.1-70b-instruct', name: 'Llama 3.1 70B', size: '70B', tag: 'nvidia', hint: '高性能开源' },
{ id: 'mistralai/mixtral-8x22b-instruct-v0.1', name: 'Mixtral 8x22B', size: '8x22B', tag: 'nvidia', hint: 'MOE 架构' },
{ id: 'google/gemma-2-27b-it', name: 'Gemma 2 27B', size: '27B', tag: 'nvidia', hint: 'Google 开源' },
]
},
baidu: {
name: '百度 CoBoom',
baseUrl: '', // user needs to provide their own endpoint
keyPlaceholder: '输入 CoBoom API Key...',
keyHelp: '访问 <a href="https://cloud.baidu.com/" target="_blank" style="color:var(--primary)">cloud.baidu.com</a> 获取 CoBoom API Key',
models: [
{ id: 'ernie-4.5-turbo', name: 'ERNIE 4.5 Turbo', size: '?', tag: 'baidu', hint: '文心一言 4.5 Turbo' },
{ id: 'ernie-speed-pro', name: 'ERNIE Speed-Pro', size: '?', tag: 'baidu', hint: '高速响应版本' },
{ id: 'ernie-lite-pro', name: 'ERNIE Lite-Pro', size: '?', tag: 'baidu', hint: '轻量高性能' },
{ id: 'coboodle-code', name: 'CoBuddy 代码模型', size: '?', tag: 'baidu', hint: '百度代码专用模型' },
]
}
};
// ============================================================================
// State
// ============================================================================
let currentProvider = 'anthropic';
let messages = [];
const SYSTEM_PROMPT = `你是一个友好的 AI 助手,名叫 Hermès 🦀。
核心原则:
1. 简洁直接 - 结论先行,不需要废话
2. 行动导向 - 有想法就执行
3. 中文优先 - 用中文回答,除非用户用英文
你的专长:
- 信息查询与分析
- 代码编写与调试
- 写作与翻译
- 逻辑推理
遇到不确定的问题,诚实说明,不要编造。`;
// ============================================================================
// UI Functions
// ============================================================================
function switchProvider(provider) {
currentProvider = provider;
// Update tabs
document.querySelectorAll('.provider-tab').forEach(tab => {
tab.classList.toggle('active', tab.dataset.provider === provider);
});
// Update model dropdown
const select = document.getElementById('model');
const config = PROVIDERS[provider];
select.innerHTML = config.models.map(m =>
`<option value="${m.id}">${m.name} (${m.size})</option>`
).join('');
// Update key fields
document.getElementById('keyHint').textContent = `(${config.name})`;
document.getElementById('keyHelp').innerHTML = config.keyHelp;
updateModelInfo();
saveSettings();
}
function updateModelInfo() {
const modelId = document.getElementById('model').value;
const config = PROVIDERS[currentProvider];
const model = config.models.find(m => m.id === modelId);
if (model) {
document.getElementById('modelInfo').innerHTML =
`<span class="tag ${model.tag}">${model.tag.toUpperCase()}</span>${model.hint}`;
}
}
function saveSettings() {
localStorage.setItem('hermes_provider', currentProvider);
localStorage.setItem('hermes_apiKey_' + currentProvider, document.getElementById('apiKey').value);
localStorage.setItem('hermes_model_' + currentProvider, document.getElementById('model').value);
}
function loadSettings() {
const savedProvider = localStorage.getItem('hermes_provider') || 'anthropic';
currentProvider = savedProvider;
// Update tabs
document.querySelectorAll('.provider-tab').forEach(tab => {
tab.classList.toggle('active', tab.dataset.provider === savedProvider);
});
// Fill provider config
const config = PROVIDERS[savedProvider];
document.getElementById('keyHint').textContent = `(${config.name})`;
document.getElementById('keyHelp').innerHTML = config.keyHelp;
document.getElementById('model').innerHTML = config.models.map(m =>
`<option value="${m.id}">${m.name} (${m.size})</option>`
).join('');
// Restore saved values
const savedKey = localStorage.getItem('hermes_apiKey_' + savedProvider) || '';
const savedModel = localStorage.getItem('hermes_model_' + savedProvider) || config.models[0].id;
document.getElementById('apiKey').value = savedKey;
document.getElementById('model').value = savedModel;
updateModelInfo();
}
loadSettings();
// ============================================================================
// Chat Functions
// ============================================================================
function askExample(text) {
const apiKey = document.getElementById('apiKey').value.trim();
if (!apiKey) {
addMsg('system', '⚠️ 请先选择模型并输入对应的 API Key');
return;
}
document.getElementById('input').value = text;
sendMessage();
}
function handleKeydown(e) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
sendMessage();
}
}
function addMsg(role, content, modelTag) {
const container = document.getElementById('messages');
const div = document.createElement('div');
div.className = `msg ${role}`;
if (modelTag && role === 'assistant') {
div.innerHTML = `<div class="model-tag">${modelTag}</div>${escapeHtml(content)}`;
} else {
div.textContent = content;
}
container.appendChild(div);
container.scrollTop = container.scrollHeight;
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
function setLoading(on, text) {
document.getElementById('loading').className = on ? 'loading show' : 'loading';
if (text) document.getElementById('loadingText').textContent = text;
document.getElementById('sendBtn').disabled = on;
document.getElementById('input').disabled = on;
}
function setStatus(text) {
document.getElementById('statusText').textContent = text;
}
// ============================================================================
// API Calls
// ============================================================================
async function callAnthropic(apiKey, model, msgs) {
const resp = await fetch('https://api.anthropic.com/v1/messages', {
method: 'POST',
headers: {
'x-api-key': apiKey,
'anthropic-version': '2023-06-01',
'content-type': 'application/json',
},
body: JSON.stringify({
model: model,
max_tokens: 4096,
system: SYSTEM_PROMPT,
messages: msgs,
}),
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.error?.message || `API 错误 ${resp.status}`);
}
const data = await resp.json();
return data.content[0].text;
}
async function callNvidia(apiKey, model, msgs) {
// Convert messages to openai-compatible format
const openaiMsgs = [{ role: 'system', content: SYSTEM_PROMPT }];
for (const m of msgs) {
openaiMsgs.push({ role: m.role, content: m.content });
}
const resp = await fetch('https://integrate.api.nvidia.com/v1/chat/completions', {
method: 'POST',
headers: {
'Authorization': 'Bearer ' + apiKey,
'Content-Type': 'application/json',
},
body: JSON.stringify({
model: model,
messages: openaiMsgs,
max_tokens: 4096,
temperature: 0.7,
stream: false,
}),
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.error?.message || `API 错误 ${resp.status}`);
}
const data = await resp.json();
return data.choices[0].message.content;
}
async function callBaidu(apiKey, model, msgs) {
// Baidu ERNIE uses different API - show guidance for user to configure
const baseUrls = {
'ernie-4.5-turbo': 'https://qianfan.baidubce.com/v2/app/conversation/v2/chat',
'ernie-speed-pro': 'https://qianfan.baidubce.com/v2/app/conversation/v2/chat',
'ernie-lite-pro': 'https://qianfan.baidubce.com/v2/app/conversation/v2/chat',
};
// For CoBoom code model, use Wenxin API format
const systemMsg = { role: 'system', content: SYSTEM_PROMPT };
const openaiMsgs = [systemMsg, ...msgs.map(m => ({ role: m.role, content: m.content }))];
// Try standard ERNIE API endpoint
const endpoint = baseUrls[model] || 'https://qianfan.baidubce.com/v2/app/conversation/v2/chat';
const resp = await fetch(endpoint, {
method: 'POST',
headers: {
'Authorization': 'Bearer ' + apiKey,
'Content-Type': 'application/json',
},
body: JSON.stringify({
model: model,
messages: openaiMsgs,
max_tokens: 4096,
}),
});
if (!resp.ok) {
const text = await resp.text();
throw new Error(`百度 API 错误 ${resp.status}: ${text}`);
}
const data = await resp.json();
return data.result || JSON.stringify(data);
}
async function sendMessage() {
const input = document.getElementById('input');
const text = input.value.trim();
const apiKey = document.getElementById('apiKey').value.trim();
const model = document.getElementById('model').value;
if (!text) return;
if (!apiKey) {
addMsg('system', '⚠️ 请先输入 API Key');
return;
}
addMsg('user', text);
input.value = '';
setLoading(true, 'Hermès 思考中...');
setStatus(`正在调用 ${PROVIDERS[currentProvider].name}...`);
const userMsg = { role: 'user', content: text };
let reply;
let modelLabel = model;
try {
switch (currentProvider) {
case 'anthropic':
reply = await callAnthropic(apiKey, model, messages.concat(userMsg));
break;
case 'nvidia':
reply = await callNvidia(apiKey, model, messages.concat(userMsg));
break;
case 'baidu':
reply = await callBaidu(apiKey, model, messages.concat(userMsg));
break;
}
messages.push(userMsg, { role: 'assistant', content: reply });
const config = PROVIDERS[currentProvider];
const modelObj = config.models.find(m => m.id === model);
modelLabel = modelObj ? modelObj.name : model;
addMsg('assistant', reply, modelLabel);
setStatus(`已就绪 · ${modelLabel}`);
} catch (err) {
addMsg('error', `❌ ${err.message}`);
setStatus('请求失败');
} finally {
setLoading(false);
}
}
</script>
</body>
</html>