diff --git a/channel/web/static/js/console.js b/channel/web/static/js/console.js index 0e8a24aa..89769efd 100644 --- a/channel/web/static/js/console.js +++ b/channel/web/static/js/console.js @@ -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); } diff --git a/channel/web/web_channel.py b/channel/web/web_channel.py index e58c8751..5e4630fe 100644 --- a/channel/web/web_channel.py +++ b/channel/web/web_channel.py @@ -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: diff --git a/plugins/cow_cli/cow_cli.py b/plugins/cow_cli/cow_cli.py index 3ecbfaec..6c0deb4a 100644 --- a/plugins/cow_cli/cow_cli.py +++ b/plugins/cow_cli/cow_cli.py @@ -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