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:
zhayujie
2026-06-25 11:02:18 +08:00
parent 9ea0017778
commit 0c8cb974e2
7 changed files with 380 additions and 240 deletions

View File

@@ -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

View File

@@ -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',

View 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",

View 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

View File

@@ -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. "

View File

@@ -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:

View File

@@ -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);