chore: release v0.14.0

集中发版:

新功能(10)
- 心甜Claw 引擎入口(第 3 个引擎模式)
- Hermes 22 个 Provider 注册表 + 安装/仪表盘动态加载
- Hermes .env 高级编辑(拒绝触碰托管 Provider 密钥)
- Hermes 会话与用量分析增强
- Hermes Dashboard 自动拉起 + Windows POSIX-only 兼容模态
- Hermes Skills 工具集面板
- 官网 Hermes Agent 黑金特色区 + 图文指南
- Boot Manifest 启动页(双语 + 错峰动画)
- 官网 Markdown 阅读器图片 lightbox
- Hermes Memory 概览卡

改进(9)
- Hermes 仪表盘/扩展页全面本地化
- 记忆编辑大尺寸模态
- 日志下载 Web/桌面分流
- 侧边栏导航补全
- 模型备选管理 UI(PR #232)
- 模型加载错误 UX 重做(错误卡 + 详情 + 重试)
- .page 布局 clamp + .page-narrow
- Memory 单列断点提早到 1100px
- Web 模式跳过前端热更新检查

修复(12)
- Gateway 启动 platforms.api_server.enabled 自修复(含 7 unit test)
- Memory 页 overview 卡穿模(旧 flex 列约束 → 自然块流)
- Skills 页 hero/toolsets 被压缩(flex-shrink:0)
- Web 模式 Skills ReferenceError(补 _readHermesDisabledSkills)
- 日志/记忆下载行为分流
- src/pages/models.js 5 处 typo
- 删除 56 行 .hm-memory-* 死代码 + line-clamp 标准属性
- Dependabot rustls-webpki / postcss / rand
This commit is contained in:
晴天
2026-04-25 23:47:22 +08:00
parent 8a314ff64e
commit 9ee99ead24
35 changed files with 2348 additions and 230 deletions

View File

@@ -10,6 +10,12 @@ import { API_TYPES, PROVIDER_PRESETS, QTCOOL, MODEL_PRESETS, fetchQtcoolModels }
import { t } from '../lib/i18n.js'
import { scheduleGatewayRestart, fireRestartNow, cancelPendingRestart, onRestartState } from '../lib/gateway-restart-queue.js'
// HTML 转义,防止错误信息中的特殊字符破坏页面或被注入
function escapeHtml(str) {
if (str == null) return ''
return String(str).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
}
export async function render() {
const page = document.createElement('div')
page.className = 'page'
@@ -88,8 +94,29 @@ async function loadConfig(page, state) {
renderDefaultBar(page, state)
renderProviders(page, state)
} catch (e) {
listEl.innerHTML = '<div style="color:var(--error);padding:20px">' + t('models.configLoadFailed') + ': ' + e + '</div>'
toast(t('models.configLoadFailed') + ': ' + e, 'error')
console.error('[models] loadConfig failed:', e)
const detail = escapeHtml(e?.stack || e?.message || String(e))
const shortMsg = escapeHtml(e?.message || String(e))
listEl.innerHTML = `
<div class="models-load-error" style="padding:36px 20px;text-align:center;max-width:560px;margin:0 auto">
<div style="display:inline-flex;align-items:center;justify-content:center;width:48px;height:48px;border-radius:50%;background:rgba(239,68,68,0.10);color:var(--error);margin-bottom:14px">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10"/>
<line x1="12" y1="8" x2="12" y2="12"/>
<line x1="12" y1="16" x2="12.01" y2="16"/>
</svg>
</div>
<div style="color:var(--text-primary);font-weight:600;font-size:15px;margin-bottom:6px">${t('models.configLoadFailed')}</div>
<div style="color:var(--text-secondary);font-size:13px;line-height:1.65;margin-bottom:18px">${t('models.configLoadFailedHint')}</div>
<details style="text-align:left;margin-bottom:18px">
<summary style="cursor:pointer;color:var(--text-tertiary);font-size:12px;padding:4px 0;user-select:none">${t('models.configLoadDetails')}</summary>
<pre style="margin-top:8px;padding:10px 12px;background:var(--bg-secondary);border:1px solid var(--border-primary);border-radius:6px;font-size:11px;color:var(--text-secondary);white-space:pre-wrap;word-break:break-all;max-height:220px;overflow:auto;text-align:left">${detail}</pre>
</details>
<button class="btn btn-primary btn-sm" id="models-retry-load">${t('models.retryRestart')}</button>
</div>
`
listEl.querySelector('#models-retry-load')?.addEventListener('click', () => loadConfig(page, state))
toast(`${t('models.configLoadFailed')}: ${shortMsg}`, 'error')
}
}
@@ -116,7 +143,7 @@ function collectAllModels(config) {
if (id) result.push({ provider: pk, modelId: id, full: `${pk}/${id}` })
}
}
return resul
return result
}
function getApiTypeLabel(apiType) {
@@ -1513,7 +1540,7 @@ async function handleBatchTest(section, state, providerKey) {
const start = Date.now()
try {
await api.testModel(provider.baseUrl, provider.apiKey || '', modelId, provider.api || 'openai-completions')
const elapsed = Date.now() - star
const elapsed = Date.now() - start
if (model && typeof model === 'object') {
model.latency = elapsed
model.lastTestAt = Date.now()
@@ -1522,7 +1549,7 @@ async function handleBatchTest(section, state, providerKey) {
}
ok++
} catch (e) {
const elapsed = Date.now() - star
const elapsed = Date.now() - start
if (model && typeof model === 'object') {
model.latency = null
model.lastTestAt = Date.now()
@@ -1554,7 +1581,7 @@ async function handleBatchTest(section, state, providerKey) {
newBtn.classList.add('btn-secondary')
}
const aborted = ctrl.abor
const aborted = ctrl.abort
autoSave(state)
if (aborted) {
toast(t('models.batchTestAborted', { ok, fail, skip: ids.length - ok - fail }), 'warning')
@@ -1678,13 +1705,13 @@ async function testModel(btn, state, providerKey, idx) {
const modelId = typeof model === 'string' ? model : model.id
btn.disabled = true
const origText = btn.textConten
const origText = btn.textContent
btn.textContent = t('models.testing')
const start = Date.now()
try {
const reply = await api.testModel(provider.baseUrl, provider.apiKey || '', modelId, provider.api || 'openai-completions')
const elapsed = Date.now() - star
const elapsed = Date.now() - start
// 记录到模型对象
if (typeof model === 'object') {
model.latency = elapsed
@@ -1707,7 +1734,7 @@ async function testModel(btn, state, providerKey, idx) {
toast(t('models.testOk', { model: modelId, time: (elapsed / 1000).toFixed(1), reply: reply.slice(0, 50) }), 'success')
}
} catch (e) {
const elapsed = Date.now() - star
const elapsed = Date.now() - start
if (typeof model === 'object') {
model.latency = null
model.lastTestAt = Date.now()
@@ -1717,7 +1744,7 @@ async function testModel(btn, state, providerKey, idx) {
toast(t('models.testFail', { model: modelId, time: (elapsed / 1000).toFixed(1), error: e }), 'error', { duration: 8000 })
} finally {
btn.disabled = false
btn.textContent = origTex
btn.textContent = origText
// 刷新卡片显示最新状态
const page = btn.closest('.page')
if (page) {