mirror of
https://github.com/zhayujie/chatgpt-on-wechat.git
synced 2026-06-02 00:57:41 +08:00
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:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user