mirror of
https://github.com/zhayujie/chatgpt-on-wechat.git
synced 2026-07-17 11:07:11 +08:00
feat(knowledge): auto-maintain index.md, improve import UX, fix embedding provider
- Auto-rebuild knowledge/index.md from the real directory tree on create/import so it never drifts or loses documents (no longer relies on the agent hand-writing it). - Auto-open the created/imported document in the tree after success. - Add create_document status message, shorten EN action buttons, and localize the "insert template" content. - Show filename for protected system files (index.md/log.md) in the tree instead of their H1 heading. - Reuse a shared embedding-provider factory so knowledge index sync also gets vectors instead of degrading to keyword-only search.
This commit is contained in:
@@ -17,6 +17,7 @@ import shutil
|
|||||||
import threading
|
import threading
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional, Iterable
|
from typing import Optional, Iterable
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
from common.log import logger
|
from common.log import logger
|
||||||
from config import conf
|
from config import conf
|
||||||
@@ -79,7 +80,14 @@ class KnowledgeService:
|
|||||||
|
|
||||||
def _manager(self):
|
def _manager(self):
|
||||||
if self._memory_manager is None:
|
if self._memory_manager is None:
|
||||||
self._memory_manager = MemoryManager(MemoryConfig(workspace_root=self.workspace_root))
|
# Reuse the shared embedding provider selection so knowledge index
|
||||||
|
# sync gets vectors too, instead of degrading to keyword-only.
|
||||||
|
from agent.memory.embedding import create_default_embedding_provider
|
||||||
|
embedding_provider = create_default_embedding_provider()
|
||||||
|
self._memory_manager = MemoryManager(
|
||||||
|
MemoryConfig(workspace_root=self.workspace_root),
|
||||||
|
embedding_provider=embedding_provider,
|
||||||
|
)
|
||||||
return self._memory_manager
|
return self._memory_manager
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -114,6 +122,84 @@ class KnowledgeService:
|
|||||||
manager.mark_dirty()
|
manager.mark_dirty()
|
||||||
self._run_sync(manager.sync())
|
self._run_sync(manager.sync())
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _extract_title(md_path: Path, fallback: str) -> str:
|
||||||
|
"""Read a markdown file's H1 title, falling back to the file stem."""
|
||||||
|
try:
|
||||||
|
with open(md_path, "r", encoding="utf-8") as f:
|
||||||
|
for _ in range(20):
|
||||||
|
line = f.readline()
|
||||||
|
if not line:
|
||||||
|
break
|
||||||
|
stripped = line.strip()
|
||||||
|
if stripped.startswith("# "):
|
||||||
|
return stripped[2:].strip() or fallback
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return fallback
|
||||||
|
|
||||||
|
def rebuild_index_md(self) -> bool:
|
||||||
|
"""Regenerate knowledge/index.md from the actual directory tree.
|
||||||
|
|
||||||
|
Keeps the index in sync with real files so it never drifts or loses
|
||||||
|
documents. Returns True when the file was (re)written.
|
||||||
|
"""
|
||||||
|
root = Path(self.knowledge_dir)
|
||||||
|
if not root.is_dir():
|
||||||
|
return False
|
||||||
|
|
||||||
|
def collect(dir_path: Path) -> list:
|
||||||
|
# Return sorted (rel_path, title) tuples for *.md under dir_path,
|
||||||
|
# excluding protected files at the knowledge root and dot files.
|
||||||
|
entries = []
|
||||||
|
for md in sorted(dir_path.rglob("*.md")):
|
||||||
|
rel = md.relative_to(root).as_posix()
|
||||||
|
if any(part.startswith(".") for part in md.relative_to(root).parts):
|
||||||
|
continue
|
||||||
|
if rel in self.PROTECTED_FILES:
|
||||||
|
continue
|
||||||
|
entries.append((rel, self._extract_title(md, md.stem)))
|
||||||
|
return entries
|
||||||
|
|
||||||
|
all_entries = collect(root)
|
||||||
|
|
||||||
|
def link(rel: str) -> str:
|
||||||
|
# Encode each path segment so spaces / special chars stay valid in
|
||||||
|
# markdown links, while keeping the slashes between segments.
|
||||||
|
encoded = "/".join(quote(part) for part in rel.split("/"))
|
||||||
|
return f"./{encoded}"
|
||||||
|
|
||||||
|
lines = ["# 知识库目录", ""]
|
||||||
|
# Root-level documents first (no category dir).
|
||||||
|
root_docs = [(rel, title) for rel, title in all_entries if "/" not in rel]
|
||||||
|
for rel, title in root_docs:
|
||||||
|
lines.append(f"- [{title}]({link(rel)})")
|
||||||
|
if root_docs:
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
# Group remaining documents by their top-level category.
|
||||||
|
categories = {}
|
||||||
|
for rel, title in all_entries:
|
||||||
|
if "/" not in rel:
|
||||||
|
continue
|
||||||
|
category = rel.split("/", 1)[0]
|
||||||
|
categories.setdefault(category, []).append((rel, title))
|
||||||
|
|
||||||
|
for category in sorted(categories.keys()):
|
||||||
|
lines.append(f"## {category}")
|
||||||
|
for rel, title in categories[category]:
|
||||||
|
lines.append(f"- [{title}]({link(rel)})")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
content = "\n".join(lines).rstrip() + "\n"
|
||||||
|
index_path = root / "index.md"
|
||||||
|
try:
|
||||||
|
index_path.write_text(content, encoding="utf-8")
|
||||||
|
return True
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning(f"[KnowledgeService] Failed to rebuild index.md: {exc}")
|
||||||
|
return False
|
||||||
|
|
||||||
def _sanitize_document_name(self, filename: str) -> str:
|
def _sanitize_document_name(self, filename: str) -> str:
|
||||||
name = os.path.basename((filename or "").replace("\\", "/")).strip()
|
name = os.path.basename((filename or "").replace("\\", "/")).strip()
|
||||||
if not name:
|
if not name:
|
||||||
@@ -171,6 +257,8 @@ class KnowledgeService:
|
|||||||
old_paths = [rel_path] if full_path.exists() else []
|
old_paths = [rel_path] if full_path.exists() else []
|
||||||
full_path.parent.mkdir(parents=True, exist_ok=True)
|
full_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
full_path.write_text(content or "", encoding="utf-8")
|
full_path.write_text(content or "", encoding="utf-8")
|
||||||
|
# Keep index.md in sync before reindexing so it is indexed too.
|
||||||
|
self.rebuild_index_md()
|
||||||
self._sync_index(old_paths, force=True)
|
self._sync_index(old_paths, force=True)
|
||||||
return {"path": rel_path, "created": True, "overwritten": bool(old_paths)}
|
return {"path": rel_path, "created": True, "overwritten": bool(old_paths)}
|
||||||
|
|
||||||
@@ -218,6 +306,8 @@ class KnowledgeService:
|
|||||||
results.append({"filename": filename or "", "status": "failed", "reason": str(exc)})
|
results.append({"filename": filename or "", "status": "failed", "reason": str(exc)})
|
||||||
|
|
||||||
if imported:
|
if imported:
|
||||||
|
# Keep index.md in sync before reindexing so it is indexed too.
|
||||||
|
self.rebuild_index_md()
|
||||||
self._sync_index(old_paths, force=True)
|
self._sync_index(old_paths, force=True)
|
||||||
return {"results": results, "imported": imported, "skipped": skipped, "failed": failed}
|
return {"results": results, "imported": imported, "skipped": skipped, "failed": failed}
|
||||||
|
|
||||||
@@ -394,14 +484,18 @@ class KnowledgeService:
|
|||||||
if not is_root:
|
if not is_root:
|
||||||
stats["pages"] += 1
|
stats["pages"] += 1
|
||||||
stats["size"] += size
|
stats["size"] += size
|
||||||
title = name.replace(".md", "")
|
# Prefer the H1 heading as a readable title for normal docs.
|
||||||
try:
|
# System files (index.md / log.md) keep their filename so the
|
||||||
with open(full, "r", encoding="utf-8") as f:
|
# tree never hides what they actually are.
|
||||||
first_line = f.readline().strip()
|
title = name[:-3]
|
||||||
if first_line.startswith("# "):
|
if name not in self.PROTECTED_FILES:
|
||||||
title = first_line[2:].strip()
|
try:
|
||||||
except Exception:
|
with open(full, "r", encoding="utf-8") as f:
|
||||||
pass
|
first_line = f.readline().strip()
|
||||||
|
if first_line.startswith("# "):
|
||||||
|
title = first_line[2:].strip() or title
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
files.append({"name": name, "title": title, "size": size})
|
files.append({"name": name, "title": title, "size": size})
|
||||||
return files, children
|
return files, children
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ conversation history persistence (SQLite).
|
|||||||
|
|
||||||
from agent.memory.manager import MemoryManager
|
from agent.memory.manager import MemoryManager
|
||||||
from agent.memory.config import MemoryConfig, get_default_memory_config, set_global_memory_config
|
from agent.memory.config import MemoryConfig, get_default_memory_config, set_global_memory_config
|
||||||
from agent.memory.embedding import create_embedding_provider
|
from agent.memory.embedding import create_embedding_provider, create_default_embedding_provider
|
||||||
from agent.memory.conversation_store import ConversationStore, get_conversation_store
|
from agent.memory.conversation_store import ConversationStore, get_conversation_store
|
||||||
from agent.memory.summarizer import ensure_daily_memory_file
|
from agent.memory.summarizer import ensure_daily_memory_file
|
||||||
|
|
||||||
@@ -17,6 +17,7 @@ __all__ = [
|
|||||||
'get_default_memory_config',
|
'get_default_memory_config',
|
||||||
'set_global_memory_config',
|
'set_global_memory_config',
|
||||||
'create_embedding_provider',
|
'create_embedding_provider',
|
||||||
|
'create_default_embedding_provider',
|
||||||
'ConversationStore',
|
'ConversationStore',
|
||||||
'get_conversation_store',
|
'get_conversation_store',
|
||||||
'ensure_daily_memory_file',
|
'ensure_daily_memory_file',
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ from agent.memory.embedding.provider import (
|
|||||||
OpenAIEmbeddingProvider,
|
OpenAIEmbeddingProvider,
|
||||||
create_embedding_provider,
|
create_embedding_provider,
|
||||||
)
|
)
|
||||||
|
from agent.memory.embedding.factory import create_default_embedding_provider
|
||||||
from agent.memory.embedding.rebuild import (
|
from agent.memory.embedding.rebuild import (
|
||||||
RebuildResult,
|
RebuildResult,
|
||||||
clear_index,
|
clear_index,
|
||||||
@@ -33,6 +34,7 @@ __all__ = [
|
|||||||
"EmbeddingProvider",
|
"EmbeddingProvider",
|
||||||
"OpenAIEmbeddingProvider",
|
"OpenAIEmbeddingProvider",
|
||||||
"create_embedding_provider",
|
"create_embedding_provider",
|
||||||
|
"create_default_embedding_provider",
|
||||||
"RebuildResult",
|
"RebuildResult",
|
||||||
"clear_index",
|
"clear_index",
|
||||||
"rebuild_in_process",
|
"rebuild_in_process",
|
||||||
|
|||||||
209
agent/memory/embedding/factory.py
Normal file
209
agent/memory/embedding/factory.py
Normal file
@@ -0,0 +1,209 @@
|
|||||||
|
"""
|
||||||
|
Shared embedding provider factory.
|
||||||
|
|
||||||
|
Resolves the embedding provider purely from config.json, so every caller
|
||||||
|
(agent initialization, knowledge base sync, index rebuild, ...) selects the
|
||||||
|
same provider instead of silently degrading to keyword-only search.
|
||||||
|
|
||||||
|
Two paths:
|
||||||
|
A. Default (no `embedding_provider` in config.json):
|
||||||
|
Auto-init OpenAI -> LinkAI fallback.
|
||||||
|
B. Explicit (`embedding_provider` is set):
|
||||||
|
Initialize the requested vendor with unified dim (default per vendor).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from common.log import logger
|
||||||
|
|
||||||
|
# Track whether the embedding model log has been printed in this process,
|
||||||
|
# so we avoid spamming it once per session/caller.
|
||||||
|
_embedding_logged: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
def create_default_embedding_provider():
|
||||||
|
"""Build the embedding provider from config, or None for keyword-only mode."""
|
||||||
|
from config import conf
|
||||||
|
|
||||||
|
explicit_provider = (conf().get("embedding_provider") or "").strip().lower()
|
||||||
|
if not explicit_provider:
|
||||||
|
return _init_legacy_provider()
|
||||||
|
return _init_explicit_provider(explicit_provider)
|
||||||
|
|
||||||
|
|
||||||
|
def _init_legacy_provider():
|
||||||
|
"""Legacy auto-init path: OpenAI -> LinkAI."""
|
||||||
|
from agent.memory.embedding.provider import create_embedding_provider
|
||||||
|
from config import conf
|
||||||
|
|
||||||
|
embedding_provider = None
|
||||||
|
embedding_model = None
|
||||||
|
|
||||||
|
openai_api_key = conf().get("open_ai_api_key", "")
|
||||||
|
openai_api_base = conf().get("open_ai_api_base", "")
|
||||||
|
if openai_api_key and openai_api_key not in ["", "YOUR API KEY", "YOUR_API_KEY"]:
|
||||||
|
try:
|
||||||
|
model = "text-embedding-3-small"
|
||||||
|
embedding_provider = create_embedding_provider(
|
||||||
|
provider="openai",
|
||||||
|
model=model,
|
||||||
|
api_key=openai_api_key,
|
||||||
|
api_base=openai_api_base or "https://api.openai.com/v1",
|
||||||
|
)
|
||||||
|
embedding_model = f"openai/{model}"
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"[EmbeddingFactory] OpenAI embedding failed: {e}")
|
||||||
|
|
||||||
|
if embedding_provider is None:
|
||||||
|
linkai_api_key = conf().get("linkai_api_key", "") or os.environ.get("LINKAI_API_KEY", "")
|
||||||
|
linkai_api_base = conf().get("linkai_api_base", "https://api.link-ai.tech")
|
||||||
|
if linkai_api_key and linkai_api_key not in ["", "YOUR API KEY", "YOUR_API_KEY"]:
|
||||||
|
try:
|
||||||
|
model = "text-embedding-3-small"
|
||||||
|
embedding_provider = create_embedding_provider(
|
||||||
|
provider="linkai",
|
||||||
|
model=model,
|
||||||
|
api_key=linkai_api_key,
|
||||||
|
api_base=f"{linkai_api_base}/v1",
|
||||||
|
)
|
||||||
|
embedding_model = f"linkai/{model}"
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"[EmbeddingFactory] LinkAI embedding failed: {e}")
|
||||||
|
|
||||||
|
if embedding_provider is not None and embedding_model:
|
||||||
|
_log_provider_once(f"{embedding_model} (dim={embedding_provider.dimensions})")
|
||||||
|
|
||||||
|
return embedding_provider
|
||||||
|
|
||||||
|
|
||||||
|
def _init_explicit_provider(provider_key: str):
|
||||||
|
"""Explicit-provider path: build the configured vendor."""
|
||||||
|
from agent.memory.embedding.provider import EMBEDDING_VENDORS, create_embedding_provider
|
||||||
|
from config import conf
|
||||||
|
|
||||||
|
# Custom providers ("custom:<id>") resolve credentials from custom_providers.
|
||||||
|
resolved_provider_key = provider_key
|
||||||
|
if provider_key.startswith("custom:"):
|
||||||
|
resolved_provider_key = "custom"
|
||||||
|
|
||||||
|
meta = EMBEDDING_VENDORS.get(resolved_provider_key)
|
||||||
|
if meta is None:
|
||||||
|
logger.error(
|
||||||
|
f"[EmbeddingFactory] Unknown embedding_provider '{provider_key}'. "
|
||||||
|
f"Supported: {sorted(EMBEDDING_VENDORS.keys())}. "
|
||||||
|
f"Memory will run in keyword-only mode."
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
api_key = _resolve_api_key(provider_key)
|
||||||
|
api_base = _resolve_api_base(provider_key, meta["default_base_url"])
|
||||||
|
|
||||||
|
if not api_key:
|
||||||
|
logger.error(
|
||||||
|
f"[EmbeddingFactory] embedding_provider='{provider_key}' is set but its "
|
||||||
|
f"API key is missing. Memory will run in keyword-only mode."
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
model = (conf().get("embedding_model") or "").strip()
|
||||||
|
# Custom providers without a model fall back to the provider's default.
|
||||||
|
if not model and resolved_provider_key == "custom":
|
||||||
|
from models.custom_provider import parse_custom_bot_type, get_custom_providers, _find_provider_by_id
|
||||||
|
_, custom_id = parse_custom_bot_type(provider_key)
|
||||||
|
if custom_id:
|
||||||
|
entry = _find_provider_by_id(get_custom_providers(), custom_id)
|
||||||
|
if entry and entry.get("model"):
|
||||||
|
model = entry["model"]
|
||||||
|
if not model and resolved_provider_key != "custom":
|
||||||
|
model = meta["default_model"]
|
||||||
|
|
||||||
|
try:
|
||||||
|
cfg_dim = int(conf().get("embedding_dimensions") or 0)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
cfg_dim = 0
|
||||||
|
dim = cfg_dim if cfg_dim > 0 else meta["default_dimensions"]
|
||||||
|
|
||||||
|
try:
|
||||||
|
provider = create_embedding_provider(
|
||||||
|
provider=resolved_provider_key,
|
||||||
|
model=model,
|
||||||
|
api_key=api_key,
|
||||||
|
api_base=api_base,
|
||||||
|
dimensions=dim,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
f"[EmbeddingFactory] Failed to init embedding provider "
|
||||||
|
f"'{provider_key}/{model}': {e}"
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
_log_provider_once(f"{provider_key}/{model} (dim={provider.dimensions})")
|
||||||
|
return provider
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_api_key(provider_key: str) -> str:
|
||||||
|
"""Pick the API key for an explicit embedding provider from config."""
|
||||||
|
from config import conf
|
||||||
|
|
||||||
|
if provider_key.startswith("custom:"):
|
||||||
|
from models.custom_provider import parse_custom_bot_type, get_custom_providers, _find_provider_by_id
|
||||||
|
_, custom_id = parse_custom_bot_type(provider_key)
|
||||||
|
if custom_id:
|
||||||
|
entry = _find_provider_by_id(get_custom_providers(), custom_id)
|
||||||
|
if entry:
|
||||||
|
return entry.get("api_key", "")
|
||||||
|
return ""
|
||||||
|
|
||||||
|
key_map = {
|
||||||
|
"openai": "open_ai_api_key",
|
||||||
|
"linkai": "linkai_api_key",
|
||||||
|
"dashscope": "dashscope_api_key",
|
||||||
|
"doubao": "ark_api_key",
|
||||||
|
"zhipu": "zhipu_ai_api_key",
|
||||||
|
}
|
||||||
|
field = key_map.get(provider_key)
|
||||||
|
if not field:
|
||||||
|
return ""
|
||||||
|
value = conf().get(field, "") or ""
|
||||||
|
if value in ["", "YOUR API KEY", "YOUR_API_KEY"]:
|
||||||
|
return ""
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_api_base(provider_key: str, default_base: str) -> str:
|
||||||
|
"""Pick the API base for an explicit embedding provider from config."""
|
||||||
|
from config import conf
|
||||||
|
|
||||||
|
if provider_key.startswith("custom:"):
|
||||||
|
from models.custom_provider import parse_custom_bot_type, get_custom_providers, _find_provider_by_id
|
||||||
|
_, custom_id = parse_custom_bot_type(provider_key)
|
||||||
|
if custom_id:
|
||||||
|
entry = _find_provider_by_id(get_custom_providers(), custom_id)
|
||||||
|
if entry and entry.get("api_base"):
|
||||||
|
return entry["api_base"]
|
||||||
|
return default_base
|
||||||
|
|
||||||
|
base_map = {
|
||||||
|
"openai": "open_ai_api_base",
|
||||||
|
"linkai": "linkai_api_base",
|
||||||
|
"doubao": "ark_base_url",
|
||||||
|
"zhipu": "zhipu_ai_api_base",
|
||||||
|
}
|
||||||
|
field = base_map.get(provider_key)
|
||||||
|
if not field:
|
||||||
|
return default_base
|
||||||
|
value = (conf().get(field) or "").strip()
|
||||||
|
if not value:
|
||||||
|
return default_base
|
||||||
|
if provider_key == "linkai" and not value.rstrip("/").endswith("/v1"):
|
||||||
|
return f"{value.rstrip('/')}/v1"
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _log_provider_once(detail: str):
|
||||||
|
global _embedding_logged
|
||||||
|
if not _embedding_logged:
|
||||||
|
logger.info(f"[EmbeddingFactory] Embedding model in use: {detail}")
|
||||||
|
_embedding_logged = True
|
||||||
@@ -163,10 +163,9 @@ def main() -> int:
|
|||||||
logger.info(f"[RebuildIndex] Workspace: {workspace_root}")
|
logger.info(f"[RebuildIndex] Workspace: {workspace_root}")
|
||||||
logger.info(f"[RebuildIndex] Index db: {memory_config.get_db_path()}")
|
logger.info(f"[RebuildIndex] Index db: {memory_config.get_db_path()}")
|
||||||
|
|
||||||
from bridge.agent_initializer import AgentInitializer
|
from agent.memory.embedding import create_default_embedding_provider
|
||||||
|
|
||||||
initializer = AgentInitializer(bridge=None, agent_bridge=None)
|
embedding_provider = create_default_embedding_provider()
|
||||||
embedding_provider = initializer._init_embedding_provider(memory_config, session_id=None)
|
|
||||||
if embedding_provider is None:
|
if embedding_provider is None:
|
||||||
logger.error(
|
logger.error(
|
||||||
"[RebuildIndex] No embedding provider could be initialized. "
|
"[RebuildIndex] No embedding provider could be initialized. "
|
||||||
|
|||||||
@@ -17,10 +17,6 @@ from common.utils import expand_path
|
|||||||
# Module-level lock to serialize scheduler init across concurrent sessions
|
# Module-level lock to serialize scheduler init across concurrent sessions
|
||||||
_scheduler_init_lock = threading.Lock()
|
_scheduler_init_lock = threading.Lock()
|
||||||
|
|
||||||
# Track whether the embedding model log has been printed in this process,
|
|
||||||
# so we avoid spamming it once per session.
|
|
||||||
_embedding_logged: bool = False
|
|
||||||
|
|
||||||
|
|
||||||
class AgentInitializer:
|
class AgentInitializer:
|
||||||
"""
|
"""
|
||||||
@@ -306,224 +302,16 @@ class AgentInitializer:
|
|||||||
"""
|
"""
|
||||||
Initialize the embedding provider for memory.
|
Initialize the embedding provider for memory.
|
||||||
|
|
||||||
Two paths:
|
Delegates to the shared factory so agent init, knowledge sync and
|
||||||
|
index rebuild all select the same provider:
|
||||||
A. Default (no `embedding_provider` in config.json):
|
A. Default (no `embedding_provider` in config.json):
|
||||||
Auto-init OpenAI -> LinkAI fallback. Existing 1536-dim indices
|
Auto-init OpenAI -> LinkAI fallback.
|
||||||
keep working.
|
|
||||||
B. Explicit (`embedding_provider` is set):
|
B. Explicit (`embedding_provider` is set):
|
||||||
Initialize the requested vendor with unified dim (default 1024).
|
Initialize the requested vendor.
|
||||||
If the index was built with a different dim, vector search will
|
|
||||||
quietly return no results (cosine returns 0) and keyword search
|
|
||||||
takes over until the user runs /memory rebuild-index.
|
|
||||||
"""
|
"""
|
||||||
from agent.memory import create_embedding_provider
|
from agent.memory import create_default_embedding_provider
|
||||||
from config import conf
|
return create_default_embedding_provider()
|
||||||
|
|
||||||
explicit_provider = (conf().get("embedding_provider") or "").strip().lower()
|
|
||||||
|
|
||||||
if not explicit_provider:
|
|
||||||
return self._init_embedding_provider_legacy(session_id=session_id)
|
|
||||||
|
|
||||||
return self._init_embedding_provider_explicit(
|
|
||||||
memory_config, explicit_provider, session_id=session_id,
|
|
||||||
)
|
|
||||||
|
|
||||||
def _init_embedding_provider_legacy(self, session_id: Optional[str] = None):
|
|
||||||
"""Legacy auto-init path: OpenAI -> LinkAI. Preserved verbatim for compat."""
|
|
||||||
from agent.memory import create_embedding_provider
|
|
||||||
from config import conf
|
|
||||||
|
|
||||||
embedding_provider = None
|
|
||||||
embedding_model = None
|
|
||||||
|
|
||||||
openai_api_key = conf().get("open_ai_api_key", "")
|
|
||||||
openai_api_base = conf().get("open_ai_api_base", "")
|
|
||||||
if openai_api_key and openai_api_key not in ["", "YOUR API KEY", "YOUR_API_KEY"]:
|
|
||||||
try:
|
|
||||||
model = "text-embedding-3-small"
|
|
||||||
embedding_provider = create_embedding_provider(
|
|
||||||
provider="openai",
|
|
||||||
model=model,
|
|
||||||
api_key=openai_api_key,
|
|
||||||
api_base=openai_api_base or "https://api.openai.com/v1"
|
|
||||||
)
|
|
||||||
embedding_model = f"openai/{model}"
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"[AgentInitializer] OpenAI embedding failed: {e}")
|
|
||||||
|
|
||||||
if embedding_provider is None:
|
|
||||||
linkai_api_key = conf().get("linkai_api_key", "") or os.environ.get("LINKAI_API_KEY", "")
|
|
||||||
linkai_api_base = conf().get("linkai_api_base", "https://api.link-ai.tech")
|
|
||||||
if linkai_api_key and linkai_api_key not in ["", "YOUR API KEY", "YOUR_API_KEY"]:
|
|
||||||
try:
|
|
||||||
model = "text-embedding-3-small"
|
|
||||||
embedding_provider = create_embedding_provider(
|
|
||||||
provider="linkai",
|
|
||||||
model=model,
|
|
||||||
api_key=linkai_api_key,
|
|
||||||
api_base=f"{linkai_api_base}/v1"
|
|
||||||
)
|
|
||||||
embedding_model = f"linkai/{model}"
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"[AgentInitializer] LinkAI embedding failed: {e}")
|
|
||||||
|
|
||||||
if embedding_provider is not None and embedding_model:
|
|
||||||
global _embedding_logged
|
|
||||||
if not _embedding_logged:
|
|
||||||
logger.info(
|
|
||||||
f"[AgentInitializer] Embedding model in use: {embedding_model} "
|
|
||||||
f"(dim={embedding_provider.dimensions})"
|
|
||||||
)
|
|
||||||
_embedding_logged = True
|
|
||||||
|
|
||||||
return embedding_provider
|
|
||||||
|
|
||||||
def _init_embedding_provider_explicit(
|
|
||||||
self,
|
|
||||||
memory_config,
|
|
||||||
provider_key: str,
|
|
||||||
session_id: Optional[str] = None,
|
|
||||||
):
|
|
||||||
"""Explicit-provider path: build the configured vendor.
|
|
||||||
|
|
||||||
If the index was built with a different dim, vector search will
|
|
||||||
silently return no results (cosine returns 0 for mismatched dims)
|
|
||||||
and keyword search takes over. Users switch vendors by running
|
|
||||||
/memory rebuild-index — see docs.
|
|
||||||
"""
|
|
||||||
from agent.memory import create_embedding_provider
|
|
||||||
from agent.memory.embedding import EMBEDDING_VENDORS
|
|
||||||
from config import conf
|
|
||||||
|
|
||||||
# Custom providers ("custom:<id>") resolve credentials
|
|
||||||
# from the custom_providers list.
|
|
||||||
resolved_provider_key = provider_key
|
|
||||||
if provider_key.startswith("custom:"):
|
|
||||||
resolved_provider_key = "custom"
|
|
||||||
|
|
||||||
meta = EMBEDDING_VENDORS.get(resolved_provider_key)
|
|
||||||
if meta is None:
|
|
||||||
logger.error(
|
|
||||||
f"[AgentInitializer] Unknown embedding_provider '{provider_key}'. "
|
|
||||||
f"Supported: {sorted(EMBEDDING_VENDORS.keys())}. "
|
|
||||||
f"Memory will run in keyword-only mode."
|
|
||||||
)
|
|
||||||
return None
|
|
||||||
|
|
||||||
api_key = self._resolve_embedding_api_key(provider_key)
|
|
||||||
api_base = self._resolve_embedding_api_base(provider_key, meta["default_base_url"])
|
|
||||||
|
|
||||||
if not api_key:
|
|
||||||
logger.error(
|
|
||||||
f"[AgentInitializer] embedding_provider='{provider_key}' is set but its "
|
|
||||||
f"API key is missing. Memory will run in keyword-only mode."
|
|
||||||
)
|
|
||||||
return None
|
|
||||||
|
|
||||||
model = (conf().get("embedding_model") or "").strip()
|
|
||||||
# Custom providers without a model fall back to the provider's default.
|
|
||||||
if not model and resolved_provider_key == "custom":
|
|
||||||
from models.custom_provider import parse_custom_bot_type, get_custom_providers, _find_provider_by_id
|
|
||||||
_, custom_id = parse_custom_bot_type(provider_key)
|
|
||||||
if custom_id:
|
|
||||||
entry = _find_provider_by_id(get_custom_providers(), custom_id)
|
|
||||||
if entry and entry.get("model"):
|
|
||||||
model = entry["model"]
|
|
||||||
if not model and resolved_provider_key != "custom":
|
|
||||||
model = meta["default_model"]
|
|
||||||
try:
|
|
||||||
cfg_dim = int(conf().get("embedding_dimensions") or 0)
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
cfg_dim = 0
|
|
||||||
dim = cfg_dim if cfg_dim > 0 else meta["default_dimensions"]
|
|
||||||
|
|
||||||
try:
|
|
||||||
provider = create_embedding_provider(
|
|
||||||
provider=resolved_provider_key,
|
|
||||||
model=model,
|
|
||||||
api_key=api_key,
|
|
||||||
api_base=api_base,
|
|
||||||
dimensions=dim,
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(
|
|
||||||
f"[AgentInitializer] Failed to init embedding provider "
|
|
||||||
f"'{provider_key}/{model}': {e}"
|
|
||||||
)
|
|
||||||
return None
|
|
||||||
|
|
||||||
global _embedding_logged
|
|
||||||
if not _embedding_logged:
|
|
||||||
logger.info(
|
|
||||||
f"[AgentInitializer] Embedding model in use: "
|
|
||||||
f"{provider_key}/{model} (dim={provider.dimensions})"
|
|
||||||
)
|
|
||||||
_embedding_logged = True
|
|
||||||
return provider
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _resolve_embedding_api_key(provider_key: str) -> str:
|
|
||||||
"""Pick the API key for an explicit embedding provider from config."""
|
|
||||||
from config import conf
|
|
||||||
|
|
||||||
# Custom providers ("custom:<id>") resolve from the custom_providers list.
|
|
||||||
if provider_key.startswith("custom:"):
|
|
||||||
from models.custom_provider import parse_custom_bot_type, get_custom_providers, _find_provider_by_id
|
|
||||||
_, custom_id = parse_custom_bot_type(provider_key)
|
|
||||||
if custom_id:
|
|
||||||
providers = get_custom_providers()
|
|
||||||
entry = _find_provider_by_id(providers, custom_id)
|
|
||||||
if entry:
|
|
||||||
return entry.get("api_key", "")
|
|
||||||
return ""
|
|
||||||
|
|
||||||
key_map = {
|
|
||||||
"openai": "open_ai_api_key",
|
|
||||||
"linkai": "linkai_api_key",
|
|
||||||
"dashscope": "dashscope_api_key",
|
|
||||||
"doubao": "ark_api_key",
|
|
||||||
"zhipu": "zhipu_ai_api_key",
|
|
||||||
}
|
|
||||||
field = key_map.get(provider_key)
|
|
||||||
if not field:
|
|
||||||
return ""
|
|
||||||
value = conf().get(field, "") or ""
|
|
||||||
if value in ["", "YOUR API KEY", "YOUR_API_KEY"]:
|
|
||||||
return ""
|
|
||||||
return value
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _resolve_embedding_api_base(provider_key: str, default_base: str) -> str:
|
|
||||||
"""Pick the API base for an explicit embedding provider from config."""
|
|
||||||
from config import conf
|
|
||||||
|
|
||||||
# Custom providers ("custom:<id>") resolve from the custom_providers list.
|
|
||||||
if provider_key.startswith("custom:"):
|
|
||||||
from models.custom_provider import parse_custom_bot_type, get_custom_providers, _find_provider_by_id
|
|
||||||
_, custom_id = parse_custom_bot_type(provider_key)
|
|
||||||
if custom_id:
|
|
||||||
providers = get_custom_providers()
|
|
||||||
entry = _find_provider_by_id(providers, custom_id)
|
|
||||||
if entry and entry.get("api_base"):
|
|
||||||
return entry["api_base"]
|
|
||||||
return default_base
|
|
||||||
|
|
||||||
base_map = {
|
|
||||||
"openai": "open_ai_api_base",
|
|
||||||
"linkai": "linkai_api_base",
|
|
||||||
"doubao": "ark_base_url",
|
|
||||||
"zhipu": "zhipu_ai_api_base",
|
|
||||||
}
|
|
||||||
field = base_map.get(provider_key)
|
|
||||||
if not field:
|
|
||||||
return default_base
|
|
||||||
value = (conf().get(field) or "").strip()
|
|
||||||
if not value:
|
|
||||||
return default_base
|
|
||||||
if provider_key == "linkai" and not value.rstrip("/").endswith("/v1"):
|
|
||||||
return f"{value.rstrip('/')}/v1"
|
|
||||||
return value
|
|
||||||
|
|
||||||
def _sync_memory(self, memory_manager, session_id: Optional[str] = None):
|
def _sync_memory(self, memory_manager, session_id: Optional[str] = None):
|
||||||
"""Sync memory database"""
|
"""Sync memory database"""
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -336,9 +336,9 @@ const I18N = {
|
|||||||
knowledge_select_hint: 'Select a document to view', knowledge_empty_hint: 'No knowledge pages yet',
|
knowledge_select_hint: 'Select a document to view', knowledge_empty_hint: 'No knowledge pages yet',
|
||||||
knowledge_empty_guide: 'Send documents, links or topics to the agent in chat, and it will automatically organize them into your knowledge base.',
|
knowledge_empty_guide: 'Send documents, links or topics to the agent in chat, and it will automatically organize them into your knowledge base.',
|
||||||
knowledge_go_chat: 'Start a conversation',
|
knowledge_go_chat: 'Start a conversation',
|
||||||
knowledge_new_category: 'New category',
|
knowledge_new_category: 'Category',
|
||||||
knowledge_new_document: 'New document',
|
knowledge_new_document: 'Document',
|
||||||
knowledge_import_documents: 'Import documents',
|
knowledge_import_documents: 'Import',
|
||||||
welcome_subtitle: 'I can help you answer questions, manage your computer, create and execute skills, and keep growing through <br> long-term memory and a personal knowledge base.',
|
welcome_subtitle: 'I can help you answer questions, manage your computer, create and execute skills, and keep growing through <br> long-term memory and a personal knowledge base.',
|
||||||
example_sys_title: 'System', example_sys_text: 'Show me the files in the workspace',
|
example_sys_title: 'System', example_sys_text: 'Show me the files in the workspace',
|
||||||
example_task_title: 'Scheduler', example_task_text: 'Remind me to check the server in 5 minutes',
|
example_task_title: 'Scheduler', example_task_text: 'Remind me to check the server in 5 minutes',
|
||||||
@@ -7771,7 +7771,7 @@ const KNOWLEDGE_IMPORT_MAX_FILES = 100;
|
|||||||
const KNOWLEDGE_IMPORT_MAX_FILE_SIZE = 10 * 1024 * 1024;
|
const KNOWLEDGE_IMPORT_MAX_FILE_SIZE = 10 * 1024 * 1024;
|
||||||
const KNOWLEDGE_IMPORT_MAX_TOTAL_SIZE = 200 * 1024 * 1024;
|
const KNOWLEDGE_IMPORT_MAX_TOTAL_SIZE = 200 * 1024 * 1024;
|
||||||
|
|
||||||
function loadKnowledgeView() {
|
function loadKnowledgeView(targetPath) {
|
||||||
// Reset to docs tab
|
// Reset to docs tab
|
||||||
switchKnowledgeTab('docs');
|
switchKnowledgeTab('docs');
|
||||||
_knowledgeGraphLoaded = false;
|
_knowledgeGraphLoaded = false;
|
||||||
@@ -7808,6 +7808,15 @@ function loadKnowledgeView() {
|
|||||||
|
|
||||||
renderKnowledgeTree(tree, rootFiles);
|
renderKnowledgeTree(tree, rootFiles);
|
||||||
|
|
||||||
|
// Prefer opening the just created/imported file; ensure its group is
|
||||||
|
// expanded so the active item is visible in the tree.
|
||||||
|
const targetTitle = targetPath ? _findKnowledgeFileTitle(targetPath) : null;
|
||||||
|
if (targetTitle !== null) {
|
||||||
|
_expandKnowledgeGroupFor(targetPath);
|
||||||
|
openKnowledgeFile(targetPath, targetTitle);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Auto-select the first file (desktop only)
|
// Auto-select the first file (desktop only)
|
||||||
if (window.innerWidth >= 768) {
|
if (window.innerWidth >= 768) {
|
||||||
const firstFile = rootFiles.length > 0 ? rootFiles[0] : null;
|
const firstFile = rootFiles.length > 0 ? rootFiles[0] : null;
|
||||||
@@ -7825,6 +7834,36 @@ function loadKnowledgeView() {
|
|||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Find a file's display title by its relative path within the knowledge tree.
|
||||||
|
// Returns the title, or null when the path is not present.
|
||||||
|
function _findKnowledgeFileTitle(path) {
|
||||||
|
if (!path) return null;
|
||||||
|
const rootHit = (_knowledgeRootFiles || []).find(f => f.name === path);
|
||||||
|
if (rootHit) return rootHit.title || rootHit.name;
|
||||||
|
const walk = (groups, parentPath) => {
|
||||||
|
for (const group of groups || []) {
|
||||||
|
const groupPath = parentPath ? `${parentPath}/${group.dir}` : group.dir;
|
||||||
|
const hit = (group.files || []).find(f => `${groupPath}/${f.name}` === path);
|
||||||
|
if (hit) return hit.title || hit.name;
|
||||||
|
const childHit = walk(group.children, groupPath);
|
||||||
|
if (childHit !== null) return childHit;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
return walk(_knowledgeTreeData, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open every ancestor group of the given file path so it is visible.
|
||||||
|
function _expandKnowledgeGroupFor(path) {
|
||||||
|
if (!path || !path.includes('/')) return;
|
||||||
|
const target = document.querySelector(`.knowledge-tree-file[data-path="${CSS.escape(path)}"]`);
|
||||||
|
let node = target ? target.closest('.knowledge-tree-group') : null;
|
||||||
|
while (node) {
|
||||||
|
node.classList.add('open');
|
||||||
|
node = node.parentElement ? node.parentElement.closest('.knowledge-tree-group') : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function renderKnowledgeTree(tree, rootFilesOrFilter, filter) {
|
function renderKnowledgeTree(tree, rootFilesOrFilter, filter) {
|
||||||
const container = document.getElementById('knowledge-tree');
|
const container = document.getElementById('knowledge-tree');
|
||||||
container.innerHTML = '';
|
container.innerHTML = '';
|
||||||
@@ -7906,7 +7945,7 @@ function _knowledgeCategoryActions(path) {
|
|||||||
return `<span class="knowledge-actions">${_knowledgeActionButton('fa-pen', '重命名', `renameKnowledgeCategory(${value})`)}${_knowledgeActionButton('fa-trash', '删除', `deleteKnowledgeCategory(${value})`)}</span>`;
|
return `<span class="knowledge-actions">${_knowledgeActionButton('fa-pen', '重命名', `renameKnowledgeCategory(${value})`)}${_knowledgeActionButton('fa-trash', '删除', `deleteKnowledgeCategory(${value})`)}</span>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function dispatchKnowledgeAction(action, payload) {
|
async function dispatchKnowledgeAction(action, payload, openPathResolver) {
|
||||||
_setKnowledgeStatus(currentLang === 'zh' ? '处理中...' : 'Working...', false, true);
|
_setKnowledgeStatus(currentLang === 'zh' ? '处理中...' : 'Working...', false, true);
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/knowledge/action', {
|
const response = await fetch('/api/knowledge/action', {
|
||||||
@@ -7921,7 +7960,9 @@ async function dispatchKnowledgeAction(action, payload) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
_setKnowledgeStatus(_knowledgeResultMessage(action, result.payload), false);
|
_setKnowledgeStatus(_knowledgeResultMessage(action, result.payload), false);
|
||||||
loadKnowledgeView();
|
// Optionally auto-open the affected file after the tree refreshes.
|
||||||
|
const openPath = openPathResolver ? openPathResolver(result.payload) : null;
|
||||||
|
loadKnowledgeView(openPath || undefined);
|
||||||
return result.payload;
|
return result.payload;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
_setKnowledgeStatus(currentLang === 'zh' ? '请求失败,请稍后重试' : 'Request failed, please try again', true);
|
_setKnowledgeStatus(currentLang === 'zh' ? '请求失败,请稍后重试' : 'Request failed, please try again', true);
|
||||||
@@ -7941,6 +7982,7 @@ function _setKnowledgeStatus(message, isError, persistent) {
|
|||||||
function _knowledgeResultMessage(action, payload) {
|
function _knowledgeResultMessage(action, payload) {
|
||||||
if (currentLang !== 'zh') {
|
if (currentLang !== 'zh') {
|
||||||
return action === 'create_category' ? 'Category created' :
|
return action === 'create_category' ? 'Category created' :
|
||||||
|
action === 'create_document' ? 'Document created' :
|
||||||
action === 'rename_category' ? 'Category renamed' :
|
action === 'rename_category' ? 'Category renamed' :
|
||||||
action === 'delete_category' ? 'Category deleted' :
|
action === 'delete_category' ? 'Category deleted' :
|
||||||
action === 'import_documents' ? `${payload?.imported || 0} imported · ${payload?.skipped || 0} skipped · ${payload?.failed || 0} failed` :
|
action === 'import_documents' ? `${payload?.imported || 0} imported · ${payload?.skipped || 0} skipped · ${payload?.failed || 0} failed` :
|
||||||
@@ -7948,6 +7990,7 @@ function _knowledgeResultMessage(action, payload) {
|
|||||||
`${payload?.deleted || 0} document deleted`;
|
`${payload?.deleted || 0} document deleted`;
|
||||||
}
|
}
|
||||||
return action === 'create_category' ? '分类已创建' :
|
return action === 'create_category' ? '分类已创建' :
|
||||||
|
action === 'create_document' ? '文档已创建' :
|
||||||
action === 'rename_category' ? '分类已重命名' :
|
action === 'rename_category' ? '分类已重命名' :
|
||||||
action === 'delete_category' ? '分类已删除' :
|
action === 'delete_category' ? '分类已删除' :
|
||||||
action === 'import_documents' ? `导入 ${payload?.imported || 0} 个,跳过 ${payload?.skipped || 0} 个,失败 ${payload?.failed || 0} 个` :
|
action === 'import_documents' ? `导入 ${payload?.imported || 0} 个,跳过 ${payload?.skipped || 0} 个,失败 ${payload?.failed || 0} 个` :
|
||||||
@@ -8003,7 +8046,9 @@ function openKnowledgeDialog(options) {
|
|||||||
templateBtn.onclick = () => {
|
templateBtn.onclick = () => {
|
||||||
if (documentContent.value.trim()) return;
|
if (documentContent.value.trim()) return;
|
||||||
const title = (documentFilename.value || 'untitled').replace(/\.md$/i, '');
|
const title = (documentFilename.value || 'untitled').replace(/\.md$/i, '');
|
||||||
documentContent.value = `# ${title}\n\n## 摘要\n\n\n## 关键点\n\n- \n\n## 参考\n\n`;
|
documentContent.value = currentLang === 'zh'
|
||||||
|
? `# ${title}\n\n## 摘要\n\n\n## 关键点\n\n- \n\n## 参考\n\n`
|
||||||
|
: `# ${title}\n\n## Summary\n\n\n## Key points\n\n- \n\n## References\n\n`;
|
||||||
documentContent.focus();
|
documentContent.focus();
|
||||||
};
|
};
|
||||||
if (options.type === 'select') {
|
if (options.type === 'select') {
|
||||||
@@ -8101,7 +8146,7 @@ function openKnowledgeDocumentEditor(category) {
|
|||||||
path: `${category}/${safeName}`,
|
path: `${category}/${safeName}`,
|
||||||
content: value.content,
|
content: value.content,
|
||||||
overwrite: false,
|
overwrite: false,
|
||||||
});
|
}, payload => payload?.path || `${category}/${safeName}`);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -8160,7 +8205,9 @@ async function importKnowledgeDocuments(files, targetCategory) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
_setKnowledgeStatus(_knowledgeResultMessage('import_documents', result.payload), false);
|
_setKnowledgeStatus(_knowledgeResultMessage('import_documents', result.payload), false);
|
||||||
loadKnowledgeView();
|
// Auto-open the first successfully imported document.
|
||||||
|
const firstImported = (result.payload?.results || []).find(item => item.status === 'imported');
|
||||||
|
loadKnowledgeView(firstImported ? firstImported.path : undefined);
|
||||||
return result.payload;
|
return result.payload;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
_setKnowledgeStatus(currentLang === 'zh' ? '导入请求失败' : 'Import request failed', true);
|
_setKnowledgeStatus(currentLang === 'zh' ? '导入请求失败' : 'Import request failed', true);
|
||||||
|
|||||||
Reference in New Issue
Block a user