feat(memory): hot-swap embedding provider on rebuild-index

Switching embedding provider in the web console no longer requires a
restart and no longer drops the running conversation
This commit is contained in:
zhayujie
2026-05-20 21:32:53 +08:00
parent c181e500bc
commit fff7326209
3 changed files with 156 additions and 76 deletions

View File

@@ -50,15 +50,20 @@ const I18N = {
models_unavailable: '不可用',
models_set_via_env: '通过环境变量启用',
models_dim_label: '维度',
models_warn_rebuild: '向量配置已更新,建议在聊天框输入 /memory rebuild-index 重建索引',
models_save_success: '已保存',
models_save_failed: '保存失败',
models_cleared: '已清除',
models_clear_failed: '清除失败',
models_embedding_change_title: '更改向量模型',
models_embedding_change_msg: '切换向量模型后,已有索引将失效,需要重建。是否继续?',
models_embedding_saved_title: '向量模型已更新',
models_embedding_saved_msg: '请在聊天框输入 /memory rebuild-index 重建索引。',
models_embedding_saved_ok: '去执行',
models_clear_confirm_title: '清除厂商凭据',
models_clear_confirm_msg: '确认清除该厂商的 API Key 与 Base URL 吗?相关能力将不再可用。',
cancel: '取消',
save: '保存',
ok: '确定',
knowledge_title: '知识库', knowledge_desc: '浏览和探索你的知识库',
knowledge_tab_docs: '文档', knowledge_tab_graph: '图谱',
knowledge_loading: '加载知识库中...', knowledge_loading_desc: '知识页面将显示在这里',
@@ -193,15 +198,20 @@ const I18N = {
models_unavailable: 'unavailable',
models_set_via_env: 'enable via environment variable',
models_dim_label: 'dim',
models_warn_rebuild: 'Embedding settings changed — run /memory rebuild-index in chat to rebuild',
models_save_success: 'Saved',
models_save_failed: 'Save failed',
models_cleared: 'Cleared',
models_clear_failed: 'Clear failed',
models_embedding_change_title: 'Change embedding model',
models_embedding_change_msg: 'Switching the embedding model invalidates the existing index — a rebuild will be needed. Continue?',
models_embedding_saved_title: 'Embedding model updated',
models_embedding_saved_msg: 'Send /memory rebuild-index in the chat to rebuild the index.',
models_embedding_saved_ok: 'Go',
models_clear_confirm_title: 'Clear vendor credentials',
models_clear_confirm_msg: 'Remove this vendor\'s API Key and Base URL? Capabilities relying on it will stop working.',
cancel: 'Cancel',
save: 'Save',
ok: 'OK',
knowledge_title: 'Knowledge', knowledge_desc: 'Browse and explore your knowledge base',
knowledge_tab_docs: 'Documents', knowledge_tab_graph: 'Graph',
knowledge_loading: 'Loading knowledge base...', knowledge_loading_desc: 'Knowledge pages will be displayed here',
@@ -3213,12 +3223,14 @@ function closeMemoryViewer() {
// =====================================================================
// Custom Confirm Dialog
// =====================================================================
function showConfirmDialog({ title, message, okText, cancelText, onConfirm }) {
function showConfirmDialog({ title, message, okText, cancelText, onConfirm, hideCancel }) {
const overlay = document.getElementById('confirm-dialog-overlay');
document.getElementById('confirm-dialog-title').textContent = title || '';
document.getElementById('confirm-dialog-message').textContent = message || '';
document.getElementById('confirm-dialog-ok').textContent = okText || 'OK';
document.getElementById('confirm-dialog-cancel').textContent = cancelText || t('channels_cancel');
const cancelBtn = document.getElementById('confirm-dialog-cancel');
cancelBtn.textContent = cancelText || t('channels_cancel');
cancelBtn.classList.toggle('hidden', !!hideCancel);
function cleanup() {
overlay.classList.add('hidden');
@@ -3231,7 +3243,6 @@ function showConfirmDialog({ title, message, okText, cancelText, onConfirm }) {
function onOverlayClick(e) { if (e.target === overlay) cleanup(); }
const okBtn = document.getElementById('confirm-dialog-ok');
const cancelBtn = document.getElementById('confirm-dialog-cancel');
okBtn.addEventListener('click', onOk);
cancelBtn.addEventListener('click', onCancel);
overlay.addEventListener('click', onOverlayClick);
@@ -3555,11 +3566,16 @@ function renderCapabilityBody(def, cap, body) {
// For auto-capable capabilities, an "auto" strategy means the user has
// not pinned a vendor; we honor that by selecting the empty-string
// sentinel rather than the resolved fallback provider name.
// `suggested_provider` is a UI-only preselect for embedding when nothing
// is pinned yet — purely cosmetic, not persisted until the user saves.
const initialProviderValue = pendingProvider
? pendingProvider
: ((cap.strategy === 'auto' && capabilitySupportsAuto(def.id))
? ''
: (cap.current_provider || (ddOpts[0] && ddOpts[0].value) || ''));
: (cap.current_provider
|| cap.suggested_provider
|| (ddOpts[0] && ddOpts[0].value)
|| ''));
initDropdown(
provDd,
ddOpts,
@@ -3680,7 +3696,9 @@ function buildCapabilityProviderOptions(def, cap) {
}
function capabilitySupportsAuto(capId) {
return capId === 'image' || capId === 'vision' || capId === 'embedding';
// Embedding is intentionally NOT here: runtime only auto-falls back to
// OpenAI/LinkAI, so dressing it up as "auto" hides reality from users.
return capId === 'image' || capId === 'vision';
}
// After initDropdown renders the capability provider menu, decorate each
@@ -3882,21 +3900,64 @@ function saveCapability(capId) {
const isAuto = provider === '' && capabilitySupportsAuto(capId);
const model = isAuto ? '' : getCapabilityModelValue(def);
// Embedding changes invalidate any pre-existing vector index because
// dimensions / vendor differ. Gate the save behind a confirm, and on
// success surface a dedicated info dialog telling the user how to
// rebuild — both via the in-app custom dialog, not the native alert.
if (capId === 'embedding') {
const cap = modelsState.capabilities[capId] || {};
const before = (cap.current_provider || '').trim();
const after = (provider || '').trim();
if (before !== after) {
showConfirmDialog({
title: t('models_embedding_change_title'),
message: t('models_embedding_change_msg'),
okText: t('save'),
cancelText: t('cancel'),
onConfirm: () => _persistCapability(capId, provider, model, () => {
showConfirmDialog({
title: t('models_embedding_saved_title'),
message: t('models_embedding_saved_msg'),
okText: t('models_embedding_saved_ok'),
hideCancel: true,
onConfirm: () => {
navigateTo('chat');
// Defer focus + value set: navigateTo may
// re-render the chat panel; setting value before
// the input is mounted would be lost.
setTimeout(() => {
const input = document.getElementById('chat-input');
if (!input) return;
input.value = '/memory rebuild-index';
input.focus();
// Trigger any input listeners (autosize, send-button enable, etc.)
input.dispatchEvent(new Event('input', { bubbles: true }));
}, 60);
},
});
}),
});
return;
}
}
_persistCapability(capId, provider, model);
}
function _persistCapability(capId, provider, model, onAfterSuccess) {
fetch('/api/models', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'set_capability', capability: capId, provider_id: provider, model: model }),
}).then(r => r.json()).then(data => {
if (data.status === 'success') {
// Show "Saved" first; defer the view refresh so the user can
// see the confirmation (loadModelsView rebuilds the card and
// would otherwise wipe the status span instantly).
// Show "Saved" first, then refresh — loadModelsView would
// otherwise rebuild the card and wipe the status span before
// the user can register the confirmation.
showStatus(`cap-${capId}-status`, 'models_save_success', false);
setTimeout(() => {
if (data.warn_rebuild_index) alert(t('models_warn_rebuild'));
// Re-pull state so derived fields (e.g. image follows main) refresh.
loadModelsView({ preserveScroll: true });
}, 900);
if (onAfterSuccess) onAfterSuccess();
}, 400);
} else {
showStatus(`cap-${capId}-status`, 'models_save_failed', true);
}
@@ -4064,26 +4125,16 @@ function saveVendorModal() {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
}).then(r => r.json()).then(data => {
btn.disabled = false;
if (data.status === 'success') {
// Show inline confirmation in the modal footer, then close after
// a short delay so the user can see the "Saved" feedback.
showStatus('vendor-modal-status', 'models_save_success', false);
setTimeout(() => {
btn.disabled = false;
closeVendorModal();
const onSaved = vendorModalState.onSaved;
// If a caller provided an onSaved hook, it owns the refresh
// strategy (e.g. preserveScroll when launched from inside a
// capability dropdown). Otherwise fall back to a plain reload
// so the standalone "add vendor" entry still updates the grid.
if (onSaved) {
try { onSaved(providerId); } catch (e) { /* noop */ }
} else {
loadModelsView();
}
}, 700);
closeVendorModal();
const onSaved = vendorModalState.onSaved;
if (onSaved) {
try { onSaved(providerId); } catch (e) { /* noop */ }
} else {
loadModelsView();
}
} else {
btn.disabled = false;
showStatus('vendor-modal-status', 'models_save_failed', true);
}
}).catch(() => {
@@ -4107,11 +4158,8 @@ function clearVendorModal() {
body: JSON.stringify({ action: 'delete_provider', provider_id: providerId }),
}).then(r => r.json()).then(data => {
if (data.status === 'success') {
showStatus('vendor-modal-status', 'models_cleared', false);
setTimeout(() => {
closeVendorModal();
loadModelsView();
}, 700);
closeVendorModal();
loadModelsView();
} else {
showStatus('vendor-modal-status', 'models_clear_failed', true);
}

View File

@@ -1234,7 +1234,7 @@ class ModelsHandler:
# ids drawn from ConfigHandler.PROVIDER_MODELS where applicable.
_ASR_PROVIDERS = ["openai", "linkai", "baidu", "ali", "xunfei", "azure", "google"]
_TTS_PROVIDERS = ["openai", "linkai", "minimax", "baidu", "ali", "xunfei", "azure", "google", "elevenlabs", "edge", "pytts"]
_EMBEDDING_PROVIDERS = ["openai", "linkai", "dashscope", "doubao", "zhipu"]
_EMBEDDING_PROVIDERS = ["openai", "dashscope", "doubao", "zhipu", "linkai"]
# Capability-scoped model catalogs. The chat dropdown can reuse the
# provider's generic model list, but vision and image generation are
@@ -1522,29 +1522,24 @@ class ModelsHandler:
@classmethod
def _embedding_capability(cls, local_config: dict) -> dict:
# Embedding is "pick or empty" — runtime's legacy openai/linkai
# fallback is a safety net, not a UX-visible auto mode.
# `suggested_provider` is a UI-only hint (NOT persisted) that
# preselects the dropdown to whichever configured vendor we'd
# recommend, so users don't have to expand the menu to find it.
explicit = (local_config.get("embedding_provider") or "").strip().lower()
# When unset, the legacy auto path in agent_initializer.py picks
# openai -> linkai. We surface "auto" + an estimate of what it'd
# actually use, but don't probe the runtime here.
suggested = ""
if not explicit:
if cls._is_real_key(local_config.get("open_ai_api_key", "")):
effective = "openai"
elif cls._is_real_key(local_config.get("linkai_api_key", "")):
effective = "linkai"
else:
effective = ""
return {
"editable": True,
"strategy": "auto",
"current_provider": effective,
"current_model": local_config.get("embedding_model", "") or "",
"current_dim": int(local_config.get("embedding_dimensions") or 0) or None,
"providers": cls._EMBEDDING_PROVIDERS,
}
for pid in cls._EMBEDDING_PROVIDERS:
meta = ConfigHandler.PROVIDER_MODELS.get(pid) or {}
key_field = meta.get("api_key_field")
if key_field and cls._is_real_key(local_config.get(key_field, "")):
suggested = pid
break
return {
"editable": True,
"strategy": "specified",
"current_provider": explicit,
"suggested_provider": suggested,
"current_model": local_config.get("embedding_model", "") or "",
"current_dim": int(local_config.get("embedding_dimensions") or 0) or None,
"providers": cls._EMBEDDING_PROVIDERS,
@@ -1931,15 +1926,10 @@ class ModelsHandler:
file_cfg["embedding_model"] = ""
self._write_file_config(file_cfg)
logger.info(f"[ModelsHandler] embedding updated: provider={provider_id!r} model={model!r}")
# Embedding switches don't go through Bridge bots; the agent's
# MemoryManager rebuilds its provider on next process restart, but
# the index dim may now mismatch — frontend should warn the user.
return json.dumps({
"status": "success",
"provider": provider_id,
"model": model,
"warn_rebuild_index": True,
})
# The agent's MemoryManager picks the new provider on next process
# restart; the index dim may now mismatch so a rebuild is needed.
# The frontend surfaces this via a confirm + post-save dialog.
return json.dumps({"status": "success", "provider": provider_id, "model": model})
@staticmethod
def _reset_bridge() -> None:

View File

@@ -1056,6 +1056,38 @@ class CowCliPlugin(Plugin):
logger.warning(f"[CowCli] /memory dream sync failed: {e}")
return f"❌ 记忆蒸馏失败: {e}"
@staticmethod
def _resolve_active_embedding():
"""
Resolve (provider_label, model, dim) from the LATEST config, not the
possibly-stale provider instance cached on a running agent. Used by
/memory status and rebuild-index hints so they reflect what a rebuild
will actually run as after the user changes embedding_provider.
Returns (label, model, dim) where any field may be None when unknown.
"""
from agent.memory.embedding import EMBEDDING_VENDORS
from config import conf
provider_key = (conf().get("embedding_provider") or "").strip().lower()
cfg_model = (conf().get("embedding_model") or "").strip()
try:
cfg_dim = int(conf().get("embedding_dimensions") or 0)
except (TypeError, ValueError):
cfg_dim = 0
if not provider_key:
# Legacy auto path: openai -> linkai, both default to text-embedding-3-small (1536).
if (conf().get("open_ai_api_key") or "").strip():
return "openai (legacy)", "text-embedding-3-small", 1536
if (conf().get("linkai_api_key") or "").strip():
return "linkai (legacy)", "text-embedding-3-small", 1536
return "(legacy)", None, None
meta = EMBEDDING_VENDORS.get(provider_key) or {}
model = cfg_model or meta.get("default_model")
dim = cfg_dim if cfg_dim > 0 else meta.get("default_dimensions")
return provider_key, model, dim
def _memory_status(self) -> str:
"""Show current memory index status."""
from agent.memory.embedding import detect_index_dim
@@ -1078,15 +1110,14 @@ class CowCliPlugin(Plugin):
lines.append(f" Chunks : {chunks} (embedded: {embedded})")
lines.append("")
# Active provider (from running config + provider instance).
# Resolve from the latest config so users see what /memory rebuild-index
# will actually run as — not what the cached agent was initialized with.
cfg_provider, cfg_model, cfg_dim = self._resolve_active_embedding()
provider_obj = memory_manager.embedding_provider
cfg_provider = (conf().get("embedding_provider") or "").strip().lower() or "(legacy)"
if provider_obj is not None:
cfg_model = getattr(provider_obj, "model", "?")
cfg_dim = getattr(provider_obj, "_dimensions", None) or "?"
if cfg_model:
lines.append(f" Provider : {cfg_provider}")
lines.append(f" Model : {cfg_model}")
lines.append(f" Dim : {cfg_dim}")
lines.append(f" Dim : {cfg_dim if cfg_dim else '?'}")
else:
lines.append(" Provider : (未初始化, keyword-only)")
@@ -1105,7 +1136,6 @@ class CowCliPlugin(Plugin):
)
index_dim = detect_index_dim(memory_manager.storage)
cfg_dim = getattr(provider_obj, "_dimensions", None)
if index_dim is not None and cfg_dim and index_dim != cfg_dim:
warnings.append(
f" ⚠️ 索引中存量向量为 {index_dim} 维,与当前配置 {cfg_dim} 维不一致;"
@@ -1129,15 +1159,27 @@ class CowCliPlugin(Plugin):
)
memory_manager = agent.memory_manager
if memory_manager.embedding_provider is None:
# Rebuild against the LATEST config: build a fresh provider from
# config.json and swap it onto memory_manager. The agent's
# conversation_history and other state are untouched.
try:
from bridge.agent_initializer import AgentInitializer
fresh_provider = AgentInitializer(bridge=None, agent_bridge=None) \
._init_embedding_provider(memory_manager.config, session_id=session_id)
except Exception as e:
logger.exception("[CowCli] /memory rebuild-index: build provider failed")
return f"⚠️ 无法根据当前配置构造 embedding provider: {e}"
if fresh_provider is None:
return (
"⚠️ 当前没有可用的 embedding provider。\n"
"请检查 config.json 中的 embedding 相关配置 (provider / api key)。"
)
memory_manager.embedding_provider = fresh_provider
provider_obj = memory_manager.embedding_provider
model_label = getattr(provider_obj, "model", "?")
dim_label = getattr(provider_obj, "dimensions", "?")
model_label = getattr(fresh_provider, "model", "?")
dim_label = getattr(fresh_provider, "dimensions", "?")
# SaaS (e_context is None): run synchronously, return final result
if e_context is None:
@@ -1168,7 +1210,7 @@ class CowCliPlugin(Plugin):
threading.Thread(target=_run, daemon=True).start()
return (
f"🔧 索引重建已启动 (model={model_label}, dim={dim_label})\n\n"
f"清空现有 chunks 并重新 embed 所有记忆文件,完成后会通知你。"
f"重新向量化所有记忆和知识文件,完成后会通知你。"
)
@staticmethod