feat(hermes): P1-3 lazy_deps 预处理 - 加 IM 渠道不再「首启 Gateway 卡 30 秒后崩」

Hermes 内核 tools/lazy_deps.py 维护了一个 allowlist:每个 feature(如 platform.telegram /
tts.elevenlabs / search.exa)对应一组 PyPI 包。原本只有 Gateway 启动 platform 模块时
才会调 ensure() 装包,导致首次启动卡 30 秒甚至超时崩溃。

本 PR 把 lazy_deps 暴露给 ClawPanel UI,让用户能主动预装。

## 后端三个新 Tauri 命令
- hermes_lazy_deps_features() — 列所有 LAZY_DEPS allowlist feature(17 个)
- hermes_lazy_deps_status(features) — 批量查每个 feature 是否已装好
- hermes_lazy_deps_ensure(feature) — 主动调内核 tools.lazy_deps.ensure 装

实现方式:
- 找到 ~/.hermes-venv 的 python 路径(unix: bin/python,windows: Scripts/python.exe)
- 用 tokio::process::Command spawn `python -c "<embedded script>"` 跑临时 Python 脚本
- 脚本走 from tools.lazy_deps import ensure / feature_missing / LAZY_DEPS
- 把结果以 JSON dump 给 stdout,Rust 端解析最后一行
- enhanced_path() 注入 PATH 兼容 macOS Tauri 启动后 PATH 不全
- serde_json::to_string 把字符串和列表序列化为 Python 合法字面量(防注入)

已注册到 lib.rs,前端 wrapper 在 tauri-api.js(features 走 10min 缓存)。

## 前端
- 新页面 src/engines/hermes/pages/lazy-deps.js
- 分类 grid(消息渠道 / TTS / STT / 搜索 / 模型商 / 记忆 / 图像 / 其他),每类有 emoji
- 卡片式:feature 名(友好显示)+ specs 元信息 + 状态徽章(已装✓ / 未装warn / 未知)+ 装/重装按钮
- 装/重装按钮 await ensure,期间「安装中…」disabled,完成后刷新整张表
- 失败走 humanizeError 统一提示
- 17 个 feature 都有友好显示名 i18n(platform.telegram → Telegram,platform.dingtalk → 钉钉 等)
- 完整 11 语言 i18n(hermesLazyDeps 模块),其它语言 fallback 到 en

## sidebar
- Hermes 引擎「管理」section 新增「可选依赖管理」入口
- 路由 /h/lazy-deps

## CSS
- 加 .lazy-deps-grid / .lazy-deps-card / .lazy-deps-badge.{ok,warn,unknown}
- 复用现有 .empty-state / .empty-compact 风格

## Web 模式
- dev-api.js 加三个同名 handler:
  - features 返回内置常见 platform 列表
  - status 全标 unknown(无法 spawn python)
  - ensure 直接 reject,提示走桌面端

## 累计变动
- 2 新文件(lazy-deps.js page + hermesLazyDeps.js i18n)
- 7 修改(dev-api / hermes.rs / lib.rs / hermes/index.js / tauri-api.js / locales/index.js / components.css)
- 11 语言 × ~17 个新 i18n 键
- cargo check ✓ + npm build ✓
This commit is contained in:
晴天
2026-05-14 04:18:33 +08:00
parent c4bf769eab
commit b852ebb6ee
9 changed files with 564 additions and 1 deletions

View File

@@ -86,6 +86,7 @@ export default {
{ route: '/h/skills', label: t('sidebar.skills'), icon: 'skills' },
{ route: '/h/memory', label: t('sidebar.memory'), icon: 'memory' },
{ route: '/h/cron', label: t('sidebar.cron'), icon: 'clock' },
{ route: '/h/lazy-deps', label: t('hermesLazyDeps.title'), icon: 'package' },
{ route: '/h/extensions', label: t('sidebar.extensions'), icon: 'package' },
]
}, {
@@ -112,6 +113,7 @@ export default {
{ path: '/h/memory', loader: () => import('./pages/memory.js') },
{ path: '/h/cron', loader: () => import('./pages/cron.js') },
{ path: '/h/extensions', loader: () => import('./pages/extensions.js') },
{ path: '/h/lazy-deps', loader: () => import('./pages/lazy-deps.js') },
{ path: '/h/services', loader: () => import('./pages/services.js') },
{ path: '/h/config', loader: () => import('./pages/config.js') },
{ path: '/h/channels', loader: () => import('./pages/channels.js') },

View File

@@ -0,0 +1,200 @@
/**
* Hermes lazy_deps 依赖管理P1-3
*
* 列出 Hermes 内核 LAZY_DEPS allowlist 的所有 featureplatform.* / tts.* / stt.* /
* search.* / provider.* / memory.* / image.*),显示安装状态,提供「装」按钮。
*
* 解决「用户配好渠道首次启动 Gateway 卡 30 秒后崩」的常见 bug ——
* 让用户能在「启动 Gateway 之前」主动预装。
*/
import { t } from '../../../lib/i18n.js'
import { api } from '../../../lib/tauri-api.js'
import { toast } from '../../../components/toast.js'
import { humanizeError } from '../../../lib/humanize-error.js'
// feature 分类配置(决定分组顺序 + 图标 + 文案)
const CATEGORIES = [
{ prefix: 'platform.', emoji: '💬', titleKey: 'hermesLazyDeps.catPlatform' },
{ prefix: 'tts.', emoji: '🔊', titleKey: 'hermesLazyDeps.catTts' },
{ prefix: 'stt.', emoji: '🎙️', titleKey: 'hermesLazyDeps.catStt' },
{ prefix: 'search.', emoji: '🔍', titleKey: 'hermesLazyDeps.catSearch' },
{ prefix: 'provider.', emoji: '🧠', titleKey: 'hermesLazyDeps.catProvider' },
{ prefix: 'memory.', emoji: '🗂️', titleKey: 'hermesLazyDeps.catMemory' },
{ prefix: 'image.', emoji: '🎨', titleKey: 'hermesLazyDeps.catImage' },
]
const DESC_OVERRIDE_KEY = 'hermesLazyDeps.descOverride' // i18n.key 下的 feature → 描述
// 把 feature 按分类分组
function groupByCategory(features) {
const groups = CATEGORIES.map(c => ({ ...c, items: [] }))
const other = { prefix: '', emoji: '🧩', titleKey: 'hermesLazyDeps.catOther', items: [] }
for (const f of features) {
const cat = groups.find(g => f.feature.startsWith(g.prefix))
if (cat) cat.items.push(f)
else other.items.push(f)
}
return [...groups.filter(g => g.items.length > 0), ...(other.items.length ? [other] : [])]
}
export async function render() {
const page = document.createElement('div')
page.className = 'page'
page.dataset.engine = 'hermes'
page.innerHTML = `
<div class="page-header">
<div>
<h1 class="page-title">${t('hermesLazyDeps.title')}</h1>
<p class="page-desc">${t('hermesLazyDeps.desc')}</p>
</div>
<div class="config-actions">
<button class="btn btn-secondary btn-sm" id="btn-refresh">${t('hermesLazyDeps.refresh')}</button>
</div>
</div>
<div id="lazy-deps-content">
<div style="padding:32px;text-align:center;color:var(--text-tertiary)">
${t('common.loading')}
</div>
</div>
`
loadAndRender(page)
page.querySelector('#btn-refresh').onclick = () => loadAndRender(page)
return page
}
async function loadAndRender(page) {
const content = page.querySelector('#lazy-deps-content')
content.innerHTML = `<div style="padding:32px;text-align:center;color:var(--text-tertiary)">${t('common.loading')}…</div>`
let featuresResp
try {
featuresResp = await api.hermesLazyDepsFeatures()
} catch (e) {
content.innerHTML = `<div style="color:var(--error);padding:20px">${escapeHtml(t('hermesLazyDeps.loadFailed'))}: ${escapeHtml(String(e))}</div>`
return
}
if (!featuresResp?.ok) {
content.innerHTML = `<div style="color:var(--error);padding:20px">${escapeHtml(t('hermesLazyDeps.loadFailed'))}: ${escapeHtml(featuresResp?.error || 'unknown')}</div>`
return
}
const features = featuresResp.features || []
if (!features.length) {
content.innerHTML = `<div class="empty-state empty-compact">
<div class="empty-icon">📦</div>
<div class="empty-title">${escapeHtml(t('hermesLazyDeps.emptyTitle'))}</div>
</div>`
return
}
// 批量查状态
let status = {}
try {
const statusResp = await api.hermesLazyDepsStatus(features.map(f => f.feature))
status = statusResp?.ok ? (statusResp.status || {}) : {}
} catch (e) {
// 状态查询失败也允许渲染(按未知处理)
console.warn('lazy_deps status failed:', e)
}
const groups = groupByCategory(features)
content.innerHTML = groups.map(g => renderGroup(g, status)).join('')
// 绑定每个 feature 的「装」按钮
content.querySelectorAll('button[data-feature]').forEach(btn => {
btn.onclick = () => onEnsureClick(page, btn.dataset.feature, btn)
})
}
function renderGroup(group, status) {
const items = group.items.map(f => renderItem(f, status[f.feature])).join('')
return `
<div class="config-section">
<div class="config-section-title">
<span style="font-size:18px;line-height:1">${group.emoji}</span>
${escapeHtml(t(group.titleKey))}
</div>
<div class="lazy-deps-grid">
${items}
</div>
</div>
`
}
function renderItem(f, st) {
const satisfied = st && st.satisfied
const known = st ? st.known : true
const missing = st?.missing || []
const specsTitle = (f.specs || []).join('\n')
const featureLabel = featureDisplayName(f.feature)
const stateBadge = satisfied
? `<span class="lazy-deps-badge ok">✓ ${escapeHtml(t('hermesLazyDeps.installed'))}</span>`
: (known
? `<span class="lazy-deps-badge warn">${escapeHtml(t('hermesLazyDeps.notInstalled'))}</span>`
: `<span class="lazy-deps-badge unknown">?</span>`)
const installBtn = satisfied
? `<button class="btn btn-sm btn-secondary" data-feature="${escapeAttr(f.feature)}" data-action="reinstall">${escapeHtml(t('hermesLazyDeps.reinstall'))}</button>`
: `<button class="btn btn-sm btn-primary" data-feature="${escapeAttr(f.feature)}" data-action="install">${escapeHtml(t('hermesLazyDeps.install'))}</button>`
const missingHint = !satisfied && missing.length
? `<div class="lazy-deps-missing" title="${escapeAttr(missing.join('\n'))}">${escapeHtml(t('hermesLazyDeps.missingCount', { n: missing.length }))}</div>`
: ''
return `
<div class="lazy-deps-card">
<div class="lazy-deps-card-head">
<div class="lazy-deps-card-title" title="${escapeAttr(f.feature)}">${escapeHtml(featureLabel)}</div>
${stateBadge}
</div>
<div class="lazy-deps-card-meta" title="${escapeAttr(specsTitle)}">${escapeHtml((f.specs || []).join(', '))}</div>
${missingHint}
<div class="lazy-deps-card-actions">
${installBtn}
</div>
</div>
`
}
// 映射 feature → 友好显示名(兼容 i18n 缺词时 fallback 到原名)
function featureDisplayName(feature) {
const friendly = t('hermesLazyDeps.featureName.' + feature)
// i18n 没翻译时 t() 返回 key 本身,做 fallback
if (friendly && !friendly.endsWith('.' + feature)) return friendly
return feature
}
async function onEnsureClick(page, feature, btn) {
const origText = btn.textContent
btn.disabled = true
btn.textContent = t('hermesLazyDeps.installing') + '…'
try {
const resp = await api.hermesLazyDepsEnsure(feature)
if (resp?.ok) {
const installed = resp.installed || []
if (resp.alreadySatisfied) {
toast(t('hermesLazyDeps.alreadyInstalled', { feature }), 'success')
} else {
toast({
message: t('hermesLazyDeps.installSuccess', { feature }),
hint: installed.length
? t('hermesLazyDeps.installedSpecs', { specs: installed.join(', ') })
: '',
}, 'success')
}
} else {
toast(humanizeError(resp?.error || 'unknown', t('hermesLazyDeps.installFailed', { feature })), 'error')
}
} catch (e) {
toast(humanizeError(e, t('hermesLazyDeps.installFailed', { feature })), 'error')
} finally {
btn.disabled = false
btn.textContent = origText
// 装完刷新整张页面状态
setTimeout(() => loadAndRender(page), 600)
}
}
function escapeHtml(s) {
return String(s ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
}
function escapeAttr(s) { return escapeHtml(s) }

View File

@@ -477,6 +477,9 @@ export const api = {
hermesAgentRunStream: (input, sessionId, conversationHistory, instructions, onEvent, options) => webStreamInvoke('hermes_agent_run_stream', { input, sessionId: sessionId || null, conversationHistory: conversationHistory || null, instructions: instructions || null }, onEvent, options),
hermesReadConfig: () => invoke('hermes_read_config'),
hermesReadConfigFull: () => invoke('hermes_read_config_full'),
hermesLazyDepsFeatures: () => cachedInvoke('hermes_lazy_deps_features', {}, 600000),
hermesLazyDepsStatus: (features) => invoke('hermes_lazy_deps_status', { features }),
hermesLazyDepsEnsure: (feature) => invoke('hermes_lazy_deps_ensure', { feature }),
hermesFetchModels: (baseUrl, apiKey, apiType, provider) => invoke('hermes_fetch_models', { baseUrl, apiKey, apiType: apiType || null, provider: provider || null }),
hermesUpdateModel: (model, provider) => invoke('hermes_update_model', { model, provider: provider || null }),
hermesListProviders: () => cachedInvoke('hermes_list_providers', {}, 600000),

View File

@@ -38,13 +38,14 @@ import engine from './modules/engine.js'
import ciaoBug from './modules/ciaoBug.js'
import cliConflict from './modules/cliConflict.js'
import glossary from './modules/glossary.js'
import hermesLazyDeps from './modules/hermesLazyDeps.js'
const MODULES = {
common, sidebar, instance, dashboard, services, settings,
models, agents, agentDetail, gateway, security, communication, channels,
memory, dreaming, cron, usage, skills, chat, chatDebug, setup, about,
ext, logs, assistant, toast, modal, engagement, diagnose, routeMap, extensions,
engine, ciaoBug, cliConflict, glossary,
engine, ciaoBug, cliConflict, glossary, hermesLazyDeps,
}
/** 构建所有语言字典 { 'zh-CN': { common: {...}, sidebar: {...}, ... }, ... } */

View File

@@ -0,0 +1,99 @@
/**
* Hermes lazy_deps 依赖管理 i18nP1-3
*
* 11 语言全覆盖(其它语言 fallback 到 en
*/
import { _ } from '../helper.js'
export default {
title: _(
'可选依赖管理',
'Optional Dependencies',
'可選相依套件',
'オプション依存関係',
'선택적 의존성',
'Phụ thuộc tùy chọn',
'Dependencias opcionales',
'Dependências opcionais',
'Дополнительные зависимости',
'Dépendances optionnelles',
'Optionale Abhängigkeiten'
),
desc: _(
'渠道Telegram / Discord 等、TTS / STT、搜索引擎等需要单独的 PyPI 包。点「装」一次性预装好,避免启动 Gateway 时卡住。',
'Channels (Telegram / Discord, etc.), TTS / STT, search backends require additional PyPI packages. Click "Install" to pre-install them once and avoid getting stuck when starting the Gateway.',
'頻道Telegram / Discord 等、TTS / STT、搜尋引擎等需要單獨的 PyPI 套件。點「安裝」一次性預裝好,避免啟動 Gateway 時卡住。',
'チャンネルTelegram / Discord 等、TTS / STT、検索バックエンドには個別の PyPI パッケージが必要です。「インストール」をクリックして事前にインストールし、Gateway 起動時の停滞を回避しましょう。',
'채널(Telegram / Discord 등), TTS / STT, 검색 백엔드는 별도의 PyPI 패키지가 필요합니다. "설치"를 눌러 미리 설치하여 Gateway 시작 시 멈추는 것을 방지하세요.',
'Các kênh (Telegram / Discord, v.v.), TTS / STT, công cụ tìm kiếm cần các gói PyPI riêng. Nhấn "Cài đặt" để cài trước, tránh kẹt khi khởi động Gateway.',
'Los canales (Telegram / Discord, etc.), TTS / STT y motores de búsqueda requieren paquetes PyPI adicionales. Haz clic en "Instalar" para preinstalarlos y evitar que el Gateway se atasque al iniciar.',
'Os canais (Telegram / Discord, etc.), TTS / STT e backends de busca exigem pacotes PyPI extras. Clique em "Instalar" para pré-instalar e evitar travamentos ao iniciar o Gateway.',
'Каналы (Telegram / Discord и др.), TTS / STT, поисковые бэкенды требуют отдельных PyPI-пакетов. Нажмите «Установить», чтобы установить заранее и избежать зависания при запуске Gateway.',
'Les canaux (Telegram / Discord, etc.), TTS / STT et moteurs de recherche nécessitent des paquets PyPI supplémentaires. Cliquez sur « Installer » pour les pré-installer et éviter le blocage au démarrage du Gateway.',
'Kanäle (Telegram / Discord usw.), TTS / STT und Suchbackends benötigen zusätzliche PyPI-Pakete. Klicken Sie auf „Installieren", um sie vorab zu installieren und Hänger beim Start des Gateways zu vermeiden.'
),
refresh: _('刷新状态', 'Refresh', '重新整理', '更新', '새로고침', 'Làm mới', 'Actualizar', 'Atualizar', 'Обновить', 'Actualiser', 'Aktualisieren'),
loadFailed: _('加载失败', 'Failed to load', '載入失敗', '読み込み失敗', '불러오기 실패', 'Tải thất bại', 'Error al cargar', 'Falha ao carregar', 'Не удалось загрузить', 'Échec du chargement', 'Laden fehlgeschlagen'),
emptyTitle: _('暂无可装的依赖', 'No optional dependencies', '暫無可裝的相依套件', 'オプション依存関係なし', '선택적 의존성이 없습니다', 'Không có phụ thuộc tùy chọn', 'No hay dependencias opcionales', 'Nenhuma dependência opcional', 'Нет дополнительных зависимостей', 'Aucune dépendance optionnelle', 'Keine optionalen Abhängigkeiten'),
installed: _('已装', 'Installed', '已安裝', 'インストール済み', '설치됨', 'Đã cài', 'Instalado', 'Instalado', 'Установлено', 'Installé', 'Installiert'),
notInstalled: _('未装', 'Not installed', '未安裝', '未インストール', '미설치', 'Chưa cài', 'No instalado', 'Não instalado', 'Не установлено', 'Non installé', 'Nicht installiert'),
install: _('一键安装', 'Install', '一鍵安裝', 'インストール', '설치', 'Cài đặt', 'Instalar', 'Instalar', 'Установить', 'Installer', 'Installieren'),
reinstall: _('重新安装', 'Reinstall', '重新安裝', '再インストール', '재설치', 'Cài lại', 'Reinstalar', 'Reinstalar', 'Переустановить', 'Réinstaller', 'Neu installieren'),
installing: _('安装中', 'Installing', '安裝中', 'インストール中', '설치 중', 'Đang cài', 'Instalando', 'Instalando', 'Установка', 'Installation', 'Installation'),
installSuccess: _(
'已成功安装 {feature}', 'Successfully installed {feature}', '已成功安裝 {feature}', '{feature} のインストールに成功しました',
'{feature} 설치 성공', 'Đã cài đặt {feature} thành công', 'Instalado correctamente: {feature}', '{feature} instalado com sucesso',
'{feature} успешно установлено', '{feature} installé avec succès', '{feature} erfolgreich installiert'
),
installFailed: _(
'{feature} 安装失败', 'Failed to install {feature}', '{feature} 安裝失敗', '{feature} のインストールに失敗',
'{feature} 설치 실패', 'Cài đặt {feature} thất bại', 'Error al instalar {feature}', 'Falha ao instalar {feature}',
'Не удалось установить {feature}', 'Échec de l\'installation de {feature}', '{feature} Installation fehlgeschlagen'
),
alreadyInstalled: _(
'{feature} 已经装好了', '{feature} is already installed', '{feature} 已經裝好了', '{feature} は既にインストール済みです',
'{feature}는 이미 설치되어 있습니다', '{feature} đã được cài', '{feature} ya está instalado', '{feature} já está instalado',
'{feature} уже установлено', '{feature} est déjà installé', '{feature} ist bereits installiert'
),
installedSpecs: _(
'已装包:{specs}', 'Installed: {specs}', '已裝套件:{specs}', 'インストール済み: {specs}',
'설치됨: {specs}', 'Đã cài: {specs}', 'Instalados: {specs}', 'Instalados: {specs}',
'Установлено: {specs}', 'Installé : {specs}', 'Installiert: {specs}'
),
missingCount: _(
'缺 {n} 个包', 'Missing {n} package(s)', '缺 {n} 個套件', '{n} 個のパッケージが不足',
'{n}개 패키지 누락', 'Thiếu {n} gói', 'Faltan {n} paquete(s)', 'Faltam {n} pacote(s)',
'Не хватает {n} пакетов', 'Manque {n} paquet(s)', 'Fehlen {n} Pakete'
),
catPlatform: _('消息渠道', 'Messaging Channels', '訊息頻道', 'メッセージチャネル', '메시지 채널', 'Kênh nhắn tin', 'Canales de mensajería', 'Canais de mensagens', 'Каналы обмена сообщениями', 'Canaux de messagerie', 'Nachrichtenkanäle'),
catTts: _('语音合成 (TTS)', 'Text-to-Speech (TTS)', '語音合成 (TTS)', '音声合成 (TTS)', '음성 합성 (TTS)', 'Tổng hợp giọng nói (TTS)', 'Síntesis de voz (TTS)', 'Síntese de voz (TTS)', 'Синтез речи (TTS)', 'Synthèse vocale (TTS)', 'Sprachsynthese (TTS)'),
catStt: _('语音识别 (STT)', 'Speech-to-Text (STT)', '語音辨識 (STT)', '音声認識 (STT)', '음성 인식 (STT)', 'Nhận dạng giọng nói (STT)', 'Reconocimiento de voz (STT)', 'Reconhecimento de voz (STT)', 'Распознавание речи (STT)', 'Reconnaissance vocale (STT)', 'Spracherkennung (STT)'),
catSearch: _('搜索引擎', 'Search Engines', '搜尋引擎', '検索エンジン', '검색 엔진', 'Công cụ tìm kiếm', 'Motores de búsqueda', 'Motores de busca', 'Поисковые движки', 'Moteurs de recherche', 'Suchmaschinen'),
catProvider: _('模型提供商', 'Model Providers', '模型提供商', 'モデルプロバイダー', '모델 제공자', 'Nhà cung cấp mô hình', 'Proveedores de modelos', 'Provedores de modelos', 'Поставщики моделей', 'Fournisseurs de modèles', 'Modellanbieter'),
catMemory: _('长期记忆', 'Long-term Memory', '長期記憶', '長期記憶', '장기 기억', 'Bộ nhớ dài hạn', 'Memoria a largo plazo', 'Memória de longo prazo', 'Долгосрочная память', 'Mémoire à long terme', 'Langzeitgedächtnis'),
catImage: _('图像生成', 'Image Generation', '影像生成', '画像生成', '이미지 생성', 'Tạo hình ảnh', 'Generación de imágenes', 'Geração de imagens', 'Генерация изображений', 'Génération d\'images', 'Bildgenerierung'),
catOther: _('其他', 'Other', '其他', 'その他', '기타', 'Khác', 'Otro', 'Outro', 'Прочее', 'Autre', 'Andere'),
// 友好显示名(小白看到的不是 platform.telegram 而是「Telegram」
featureName: {
'platform.telegram': _('Telegram', 'Telegram'),
'platform.discord': _('Discord', 'Discord'),
'platform.slack': _('Slack', 'Slack'),
'platform.matrix': _('Matrix', 'Matrix'),
'platform.dingtalk': _('钉钉', 'DingTalk', '釘釘'),
'platform.feishu': _('飞书', 'Feishu (Lark)', '飛書'),
'tts.edge': _('Edge TTS微软', 'Edge TTS (Microsoft)', 'Edge TTS微軟'),
'tts.elevenlabs': _('ElevenLabs', 'ElevenLabs'),
'stt.faster_whisper': _('Faster Whisper本地', 'Faster Whisper (local)', 'Faster Whisper本地'),
'search.exa': _('Exa Search', 'Exa Search'),
'search.firecrawl': _('Firecrawl', 'Firecrawl'),
'search.parallel': _('Parallel.ai Web', 'Parallel.ai Web'),
'provider.anthropic': _('Anthropic原生 SDK', 'Anthropic (native SDK)', 'Anthropic原生 SDK'),
'provider.bedrock': _('AWS Bedrock', 'AWS Bedrock'),
'memory.honcho': _('Honcho 记忆', 'Honcho Memory', 'Honcho 記憶'),
'memory.hindsight': _('Hindsight 记忆', 'Hindsight Memory', 'Hindsight 記憶'),
'image.fal': _('FAL 图像', 'FAL Image', 'FAL 影像'),
},
}

View File

@@ -473,6 +473,91 @@ mark {
margin-bottom: var(--space-md);
}
/* P1-3: lazy_deps 依赖管理页 */
.lazy-deps-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: var(--space-md);
margin-top: var(--space-md);
}
.lazy-deps-card {
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
padding: var(--space-md);
display: flex;
flex-direction: column;
gap: 8px;
transition: border-color 0.15s, transform 0.15s;
}
.lazy-deps-card:hover {
border-color: var(--primary);
}
.lazy-deps-card-head {
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
}
.lazy-deps-card-title {
font-weight: 500;
color: var(--text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.lazy-deps-card-meta {
font-size: var(--font-size-xs);
color: var(--text-tertiary);
font-family: var(--font-mono, monospace);
word-break: break-all;
line-height: 1.5;
max-height: 3em;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.lazy-deps-missing {
font-size: var(--font-size-xs);
color: var(--warning, #f59e0b);
}
.lazy-deps-card-actions {
margin-top: auto;
display: flex;
justify-content: flex-end;
}
.lazy-deps-badge {
font-size: 11px;
padding: 2px 8px;
border-radius: 999px;
white-space: nowrap;
}
.lazy-deps-badge.ok {
background: rgba(34, 197, 94, 0.15);
color: var(--success, #22c55e);
}
.lazy-deps-badge.warn {
background: rgba(245, 158, 11, 0.15);
color: var(--warning, #f59e0b);
}
.lazy-deps-badge.unknown {
background: var(--bg-tertiary);
color: var(--text-tertiary);
}
/* ── 新手引导卡片 ── */
.onboarding-card {
background: linear-gradient(135deg, rgba(99, 102, 241, 0.08), rgba(168, 85, 247, 0.05));