5119 lines
204 KiB
Python
5119 lines
204 KiB
Python
"""cmzs LLMWiki BFF backed by an independently deployed WeKnora."""
|
||
|
||
from __future__ import annotations
|
||
|
||
import asyncio
|
||
import hashlib
|
||
import json
|
||
import logging
|
||
import mimetypes
|
||
import re
|
||
import time
|
||
from collections.abc import AsyncIterator
|
||
from contextlib import asynccontextmanager
|
||
from dataclasses import replace
|
||
from datetime import UTC, datetime
|
||
from http.cookiejar import CookieJar, DefaultCookiePolicy
|
||
from io import BytesIO
|
||
from types import SimpleNamespace
|
||
from typing import Any, Literal
|
||
from urllib.parse import quote, unquote, urlencode, urlsplit
|
||
from uuid import uuid4
|
||
|
||
import httpx
|
||
from fastapi import APIRouter, BackgroundTasks, File, HTTPException, Query, Request, UploadFile
|
||
from fastapi.responses import JSONResponse, RedirectResponse, Response, StreamingResponse
|
||
from langchain_core.messages import HumanMessage
|
||
from pydantic import BaseModel, Field
|
||
|
||
from app.gateway.csrf_middleware import is_secure_request
|
||
from app.gateway.deps import get_agent_store, get_checkpointer, get_optional_user_from_request, get_thread_store
|
||
from app.gateway.llmwiki_deposit import (
|
||
CONVERSATION_DEPOSIT_KB_DESCRIPTION,
|
||
CONVERSATION_DEPOSIT_KB_NAME,
|
||
CONVERSATION_DEPOSIT_OWNER_USER_ID,
|
||
ConversationTurnDeposit,
|
||
deposit_conversation_turn,
|
||
ensure_conversation_deposit_mapping,
|
||
)
|
||
from app.gateway.llmwiki_rag import build_llmwiki_rag_context
|
||
from app.gateway.perf_logging import finish_trace, trace_request, trace_stage
|
||
from app.gateway.weknora_embed import (
|
||
WEKNORA_EMBED_API_PREFIX as _WEKNORA_EMBED_API_PREFIX,
|
||
)
|
||
from app.gateway.weknora_embed import (
|
||
WEKNORA_EMBED_COOKIE as _WEKNORA_EMBED_COOKIE,
|
||
)
|
||
from app.gateway.weknora_embed import (
|
||
WEKNORA_EMBED_TTL_SECONDS as _WEKNORA_EMBED_TTL_SECONDS,
|
||
)
|
||
from app.gateway.weknora_embed import (
|
||
WeKnoraEmbedContext,
|
||
)
|
||
from app.gateway.weknora_embed import (
|
||
sign_weknora_embed_payload as _sign_embed_payload,
|
||
)
|
||
from app.gateway.weknora_embed import (
|
||
verify_weknora_embed_cookie as _verify_embed_cookie,
|
||
)
|
||
from app.gateway.weknora_embed import (
|
||
verify_weknora_signed_payload as _verify_signed_payload,
|
||
)
|
||
from app.gateway.weknora_static import StaticAsset, StaticAssetCache, prepare_static_asset
|
||
from app.gateway.wiki_directory_constraints import (
|
||
directory_roots,
|
||
ensure_directory_paths,
|
||
normalize_directory_paths,
|
||
paths_from_parent_directories,
|
||
resolve_directory_paths,
|
||
)
|
||
from app.gateway.wiki_generation_config import (
|
||
WEKNORA_CUSTOM_INSTRUCTION_MAX,
|
||
WIKI_CONFIG_SCHEMA_VERSION,
|
||
WIKI_FOCUS_INSTRUCTION_MAX,
|
||
compile_wiki_instructions,
|
||
normalize_focus_instructions,
|
||
)
|
||
from deerflow.assistant_knowledge.package_io import looks_like_knowledge_package
|
||
from deerflow.config.agents_config import load_agent_config
|
||
from deerflow.config.llmwiki_config import LlmWikiRuntimeOverride, normalize_service_url
|
||
from deerflow.config.system_settings import load_system_settings, save_system_settings
|
||
from deerflow.integrations.weknora.client import WeKnoraClient, WeKnoraError
|
||
from deerflow.integrations.weknora.knowledge_profile import (
|
||
DEFAULT_RETRIEVAL_SETTINGS,
|
||
are_test_questions_customized,
|
||
resolved_retrieval_settings,
|
||
resolved_test_questions,
|
||
)
|
||
from deerflow.integrations.weknora.local_index.retrieval import is_fully_vectorized
|
||
from deerflow.integrations.weknora.local_index.search import WikiIndexError
|
||
from deerflow.integrations.weknora.runtime import build_weknora_client, get_resolved_llmwiki_runtime
|
||
from deerflow.persistence.llmwiki import LlmWikiStore
|
||
|
||
logger = logging.getLogger(__name__)
|
||
KNOWLEDGE_RESEARCH_DIRECTORY = "深度研究"
|
||
|
||
|
||
@asynccontextmanager
|
||
async def _proxy_lifespan(app: Any) -> AsyncIterator[None]:
|
||
# One pool per Gateway/event loop: the embedded app loads many assets and
|
||
# API requests together, which must not each establish a new connection.
|
||
async with httpx.AsyncClient(
|
||
timeout=httpx.Timeout(60.0),
|
||
follow_redirects=False,
|
||
trust_env=False,
|
||
# Only explicit per-request service headers authenticate upstream.
|
||
# Retain connections, never upstream cookies across DeerFlow users.
|
||
cookies=CookieJar(policy=DefaultCookiePolicy(allowed_domains=[])),
|
||
) as client:
|
||
app.state.llmwiki_proxy_http_client = client
|
||
app.state.llmwiki_static_assets = StaticAssetCache()
|
||
try:
|
||
yield
|
||
finally:
|
||
app.state.llmwiki_proxy_http_client = None
|
||
app.state.llmwiki_static_assets = None
|
||
|
||
|
||
@asynccontextmanager
|
||
async def _proxy_http_client(request: Request) -> AsyncIterator[httpx.AsyncClient]:
|
||
app = getattr(request, "scope", {}).get("app")
|
||
client = getattr(getattr(app, "state", None), "llmwiki_proxy_http_client", None)
|
||
if client is not None:
|
||
yield client
|
||
else:
|
||
# Standalone router callers without the Gateway lifespan still work.
|
||
async with httpx.AsyncClient(timeout=httpx.Timeout(60.0), follow_redirects=False, trust_env=False) as client:
|
||
yield client
|
||
|
||
|
||
router = APIRouter(tags=["llmwiki"], lifespan=_proxy_lifespan)
|
||
proxy_router = APIRouter(tags=["llmwiki-proxy"])
|
||
|
||
_MAX_UPLOAD_BYTES = 100 * 1024 * 1024
|
||
_KNOWLEDGE_PACKAGE_UPLOAD_DETAIL = (
|
||
"检测到知识库 JSONL 数据包。为避免大模型把数据包当普通文档重新识别,"
|
||
"请使用“导入 / 导出数据包”中的“上传并导入”;本次文件未送入模型。"
|
||
)
|
||
_WIKI_FILE_ACCESS_TTL_SECONDS = 24 * 60 * 60
|
||
_PROTECTED_WIKI_FILE_PATH = re.compile(
|
||
r"^(?:storage://[0-9A-Za-z_-]+/)?(?:resource|local|minio|cos|tos|s3|oss|ks3|obs)://",
|
||
re.IGNORECASE,
|
||
)
|
||
_WEKNORA_ADMIN_SESSION_CACHE: dict[str, dict[str, Any]] = {}
|
||
_WEKNORA_ADMIN_SESSION_TASKS: dict[str, asyncio.Task[dict[str, Any]]] = {}
|
||
_WEKNORA_PUBLIC_PREFIX = "/deerflow"
|
||
_WEKNORA_PUBLIC_ROOT_PATHS = (
|
||
"/platform",
|
||
"/assets",
|
||
"/css",
|
||
"/fonts",
|
||
"/img",
|
||
"/images",
|
||
"/js",
|
||
"/locales",
|
||
"/media",
|
||
"/static",
|
||
"/tdesign-icons",
|
||
"/api/v1",
|
||
"/favicon.ico",
|
||
"/favicon.svg",
|
||
"/config.js",
|
||
"/runtime-config.js",
|
||
"/logo.svg",
|
||
"/manifest.webmanifest",
|
||
)
|
||
_WEKNORA_PUBLIC_RELATIVE_ROOT_PATHS = (
|
||
"assets",
|
||
"css",
|
||
"fonts",
|
||
"img",
|
||
"images",
|
||
"js",
|
||
"locales",
|
||
"media",
|
||
"static",
|
||
"tdesign-icons",
|
||
)
|
||
_WEKNORA_PUBLIC_RELATIVE_FILE_PATHS = (
|
||
"config.js",
|
||
"runtime-config.js",
|
||
"favicon.ico",
|
||
"favicon.svg",
|
||
"logo.svg",
|
||
"manifest.webmanifest",
|
||
)
|
||
_WEKNORA_STATIC_FILE_SUFFIXES = (
|
||
".avif",
|
||
".css",
|
||
".eot",
|
||
".gif",
|
||
".ico",
|
||
".jpeg",
|
||
".jpg",
|
||
".js",
|
||
".json",
|
||
".map",
|
||
".mjs",
|
||
".otf",
|
||
".png",
|
||
".svg",
|
||
".ttf",
|
||
".txt",
|
||
".wasm",
|
||
".webmanifest",
|
||
".webp",
|
||
".woff",
|
||
".woff2",
|
||
)
|
||
_WEKNORA_STATIC_ROOT_PREFIXES = (
|
||
"/assets/",
|
||
"/css/",
|
||
"/fonts/",
|
||
"/img/",
|
||
"/images/",
|
||
"/js/",
|
||
"/locales/",
|
||
"/media/",
|
||
"/static/",
|
||
"/tdesign-icons/",
|
||
)
|
||
_WEKNORA_ROOT_ASSET_FILES = (
|
||
"/config.js",
|
||
"/runtime-config.js",
|
||
"/favicon.ico",
|
||
"/favicon.svg",
|
||
"/logo.svg",
|
||
"/manifest.webmanifest",
|
||
)
|
||
_HOP_BY_HOP_RESPONSE_HEADERS = {
|
||
"connection",
|
||
"content-encoding",
|
||
"content-length",
|
||
"keep-alive",
|
||
"proxy-authenticate",
|
||
"proxy-authorization",
|
||
"te",
|
||
"trailer",
|
||
"transfer-encoding",
|
||
"upgrade",
|
||
"x-frame-options",
|
||
"content-security-policy",
|
||
}
|
||
|
||
|
||
class LlmWikiSettingsUpdate(BaseModel):
|
||
override_enabled: bool = True
|
||
api_base_url: str = ""
|
||
web_base_url: str = ""
|
||
|
||
|
||
class LlmWikiConnectionTest(BaseModel):
|
||
api_base_url: str | None = None
|
||
|
||
|
||
class WikiGenerationInstructions(BaseModel):
|
||
"""User-owned guidance for WeKnora's stable Wiki ingest prompts."""
|
||
|
||
content_instructions: str | None = Field(
|
||
default=None,
|
||
max_length=WEKNORA_CUSTOM_INSTRUCTION_MAX,
|
||
)
|
||
extraction_instructions: str | None = Field(
|
||
default=None,
|
||
max_length=WEKNORA_CUSTOM_INSTRUCTION_MAX,
|
||
)
|
||
# ``directory_paths`` is generation guidance only. WeKnora may adapt it to
|
||
# the source material; DeerFlow must not pre-create, move, or prune folders
|
||
# to make the generated Wiki match this reference tree. The older
|
||
# ``parent_directories`` input remains for API compatibility.
|
||
parent_directories: list[str] | None = Field(default=None, max_length=100)
|
||
directory_paths: list[list[str]] | None = Field(default=None, max_length=100)
|
||
focus_instructions: str | None = Field(
|
||
default=None,
|
||
max_length=WIKI_FOCUS_INSTRUCTION_MAX,
|
||
)
|
||
|
||
|
||
class KnowledgeBaseCreate(BaseModel):
|
||
name: str = Field(..., min_length=1, max_length=255)
|
||
description: str = Field(default="", max_length=2048)
|
||
type: Literal["document", "faq"] = "document"
|
||
wiki_enabled: bool = True
|
||
embedding_model_id: str | None = Field(default=None, min_length=1, max_length=255)
|
||
wiki_config: WikiGenerationInstructions | None = None
|
||
|
||
|
||
class KnowledgeRetrievalSettingsUpdate(BaseModel):
|
||
entity_expansion_enabled: bool = True
|
||
max_related_terms: int = Field(default=6, ge=0, le=12)
|
||
statistics_enabled: bool = True
|
||
|
||
|
||
class KnowledgeBaseUpdate(BaseModel):
|
||
name: str | None = Field(default=None, min_length=1, max_length=255)
|
||
description: str | None = Field(default=None, max_length=2048)
|
||
test_questions: list[str] | None = Field(default=None, max_length=12)
|
||
retrieval_settings: KnowledgeRetrievalSettingsUpdate | None = None
|
||
wiki_config: WikiGenerationInstructions | None = None
|
||
|
||
|
||
class SearchRequest(BaseModel):
|
||
query: str = Field(..., min_length=1, max_length=4000)
|
||
scope: Literal["personal", "public", "all"] = "all"
|
||
knowledge_base_ids: list[str] = Field(default_factory=list, max_length=100)
|
||
top_k: int = Field(default=8, ge=1, le=50)
|
||
include_drafts: bool = False
|
||
|
||
|
||
class RetrievalDebugRequest(BaseModel):
|
||
"""Run the exact pre-answer knowledge retrieval path without calling an LLM."""
|
||
|
||
query: str = Field(..., min_length=1, max_length=4000)
|
||
knowledge_base_ids: list[str] = Field(default_factory=list, max_length=100)
|
||
assistant_knowledge_base_ids: list[str] = Field(default_factory=list, max_length=100)
|
||
|
||
|
||
class KnowledgeUrlImport(BaseModel):
|
||
url: str = Field(..., min_length=1, max_length=4096)
|
||
|
||
|
||
class ManualKnowledgeCreate(BaseModel):
|
||
title: str = Field(..., min_length=1, max_length=255)
|
||
content: str = Field(..., min_length=1, max_length=2_000_000)
|
||
|
||
|
||
class WikiPageCreate(BaseModel):
|
||
slug: str = Field(..., min_length=1, max_length=512)
|
||
title: str = Field(..., min_length=1, max_length=255)
|
||
content: str = Field(..., min_length=1, max_length=2_000_000)
|
||
summary: str | None = Field(default=None, max_length=200_000)
|
||
page_type: str | None = Field(default="article", min_length=1, max_length=64)
|
||
status: str | None = Field(default="published", min_length=1, max_length=64)
|
||
aliases: list[str] | None = Field(default=None, max_length=100)
|
||
parent_slug: str | None = Field(default=None, max_length=512)
|
||
category_path: list[str] | None = Field(default=None, max_length=50)
|
||
folder_id: str | None = Field(default=None, max_length=128)
|
||
wiki_path: str | None = Field(default=None, max_length=1024)
|
||
|
||
|
||
class WikiPageUpdate(BaseModel):
|
||
title: str | None = Field(default=None, min_length=1, max_length=255)
|
||
content: str | None = Field(default=None, min_length=1, max_length=2_000_000)
|
||
summary: str | None = Field(default=None, max_length=200_000)
|
||
page_type: str | None = Field(default=None, min_length=1, max_length=64)
|
||
status: str | None = Field(default=None, min_length=1, max_length=64)
|
||
aliases: list[str] | None = Field(default=None, max_length=100)
|
||
parent_slug: str | None = Field(default=None, max_length=512)
|
||
category_path: list[str] | None = Field(default=None, max_length=50)
|
||
folder_id: str | None = Field(default=None, max_length=128)
|
||
wiki_path: str | None = Field(default=None, max_length=1024)
|
||
|
||
|
||
class KnowledgeResearchSessionCreate(BaseModel):
|
||
question: str = Field(..., min_length=1, max_length=20_000)
|
||
|
||
|
||
class KnowledgeResearchSessionUpdate(BaseModel):
|
||
title: str | None = Field(default=None, min_length=1, max_length=255)
|
||
draft_markdown: str | None = Field(default=None, max_length=2_000_000)
|
||
|
||
|
||
class KnowledgeResearchPublicationSave(BaseModel):
|
||
title: str = Field(..., min_length=1, max_length=255)
|
||
expected_saved_hash: str | None = Field(default=None, min_length=64, max_length=64)
|
||
|
||
|
||
class ConversationDepositCreate(BaseModel):
|
||
thread_id: str = Field(..., min_length=1, max_length=128)
|
||
question: str = Field(default="", max_length=200_000)
|
||
answer: str = Field(default="", max_length=2_000_000)
|
||
human_message_id: str | None = Field(default=None, max_length=128)
|
||
assistant_message_id: str = Field(..., min_length=1, max_length=128)
|
||
assistant_id: str | None = Field(default=None, max_length=128)
|
||
knowledge_base_ids: list[str] = Field(default_factory=list, max_length=100)
|
||
|
||
|
||
def _store(request: Request) -> LlmWikiStore:
|
||
value = getattr(request.app.state, "llmwiki_store", None)
|
||
if value is None:
|
||
raise HTTPException(status_code=503, detail="LLMWiki metadata store is unavailable")
|
||
return value
|
||
|
||
|
||
def _schedule_local_wiki_sync(
|
||
request: Request,
|
||
mapping: dict[str, Any],
|
||
*,
|
||
constraints_changed: bool = False,
|
||
) -> None:
|
||
service = getattr(request.app.state, "llmwiki_sync_service", None)
|
||
config = getattr(request.app.state, "llmwiki_effective_config", None)
|
||
if config is None:
|
||
config = request.app.state.config.llmwiki.local_wiki_index
|
||
local_sync_enabled = bool(
|
||
service is not None
|
||
and config.enabled
|
||
and config.auto_sync
|
||
and mapping.get("wiki_index_enabled", True)
|
||
)
|
||
# Directory settings are prompt guidance only. Post-generation work is
|
||
# reserved for the independently configured strict focus filter.
|
||
constrained = bool(
|
||
str(mapping.get("wiki_focus_instructions") or "").strip()
|
||
)
|
||
if local_sync_enabled or constrained or constraints_changed:
|
||
from app.gateway.llmwiki_index_scheduler import schedule_local_wiki_mutation_sync
|
||
|
||
# WeKnora parses documents and regenerates Wiki pages asynchronously.
|
||
# The settle coordinator applies strict focus filtering and refreshes
|
||
# the optional local vector mirror; directory references need neither.
|
||
schedule_local_wiki_mutation_sync(
|
||
request.app,
|
||
mapping,
|
||
client=_client_or_503() if constrained else None,
|
||
sync_local=local_sync_enabled,
|
||
)
|
||
|
||
|
||
async def _actor(request: Request) -> tuple[str, bool]:
|
||
user = await get_optional_user_from_request(request)
|
||
if user is None:
|
||
return "default", True
|
||
return str(user.id), getattr(user, "system_role", None) == "admin"
|
||
|
||
|
||
async def _require_admin(request: Request) -> tuple[str, bool]:
|
||
actor = await _actor(request)
|
||
if not actor[1]:
|
||
raise HTTPException(status_code=403, detail="Only administrators can configure WeKnora")
|
||
return actor
|
||
|
||
|
||
def _runtime_or_legacy() -> dict[str, Any]:
|
||
runtime = get_resolved_llmwiki_runtime()
|
||
return {
|
||
"provider": runtime.provider,
|
||
"api_base_url": runtime.api_base_url,
|
||
"web_base_url": runtime.web_base_url,
|
||
"override_enabled": runtime.override_enabled,
|
||
}
|
||
|
||
|
||
def _client_or_503() -> WeKnoraClient:
|
||
runtime = get_resolved_llmwiki_runtime()
|
||
if not runtime.weknora_enabled:
|
||
raise HTTPException(status_code=409, detail="WeKnora mode is not enabled")
|
||
try:
|
||
return build_weknora_client(runtime)
|
||
except RuntimeError as exc:
|
||
raise HTTPException(status_code=503, detail=str(exc)) from None
|
||
|
||
|
||
def _raise_upstream(exc: WeKnoraError) -> None:
|
||
status = 422 if exc.status_code == 400 else exc.status_code if exc.status_code in {404, 502, 504} else 502
|
||
raise HTTPException(status_code=status, detail=str(exc)) from None
|
||
|
||
|
||
def _embedding_model_view(source: dict[str, Any]) -> dict[str, Any] | None:
|
||
"""Return the small, credential-free model shape needed by the create dialog."""
|
||
|
||
if str(source.get("type") or "").strip().lower() != "embedding":
|
||
return None
|
||
model_id = str(source.get("id") or "").strip()
|
||
if not model_id:
|
||
return None
|
||
status = str(source.get("status") or "active").strip().lower() or "active"
|
||
explicitly_available = source.get("available")
|
||
available = (
|
||
bool(explicitly_available)
|
||
if isinstance(explicitly_available, bool)
|
||
else status in {"active", "available", "enabled", "ready"}
|
||
)
|
||
parameters = source.get("parameters") if isinstance(source.get("parameters"), dict) else {}
|
||
|
||
def safe_text(*keys: str) -> str:
|
||
for key in keys:
|
||
value = source.get(key)
|
||
if isinstance(value, (str, int, float)) and str(value).strip():
|
||
return str(value).strip()
|
||
return ""
|
||
|
||
nested_provider = parameters.get("provider")
|
||
provider = safe_text("provider", "provider_type")
|
||
if not provider and isinstance(nested_provider, str):
|
||
provider = nested_provider.strip()
|
||
if not provider:
|
||
provider = safe_text("source", "platform")
|
||
nested_model = parameters.get("model") or parameters.get("model_name")
|
||
model_name = safe_text("model_name", "model")
|
||
if not model_name and isinstance(nested_model, str):
|
||
model_name = nested_model.strip()
|
||
|
||
return {
|
||
"id": model_id,
|
||
"name": safe_text("display_name", "name", "model_name", "model") or model_name or model_id,
|
||
"model_name": model_name,
|
||
"provider": provider,
|
||
"status": status,
|
||
"is_default": source.get("is_default") is True,
|
||
"available": available,
|
||
}
|
||
|
||
|
||
async def _embedding_models(client: WeKnoraClient) -> list[dict[str, Any]]:
|
||
return [view for item in await client.list_models() if (view := _embedding_model_view(item)) is not None]
|
||
|
||
|
||
def _select_embedding_model(models: list[dict[str, Any]], requested_id: str | None) -> str:
|
||
available = [item for item in models if item["available"]]
|
||
if requested_id:
|
||
selected = next((item for item in models if item["id"] == requested_id), None)
|
||
if selected is None:
|
||
raise WeKnoraError("The selected WeKnora embedding model does not exist", status_code=400)
|
||
if not selected["available"]:
|
||
raise WeKnoraError("The selected WeKnora embedding model is unavailable", status_code=400)
|
||
return requested_id
|
||
selected = next((item for item in available if item["is_default"]), None) or next(iter(available), None)
|
||
if selected is None:
|
||
raise WeKnoraError(
|
||
"WeKnora has no available embedding model; configure one in WeKnora before creating a knowledge base",
|
||
status_code=400,
|
||
)
|
||
return str(selected["id"])
|
||
|
||
|
||
def _select_knowledge_qa_model(models: list[dict[str, Any]]) -> str:
|
||
available = [
|
||
item
|
||
for item in models
|
||
if str(item.get("type") or "").strip().lower() == "knowledgeqa"
|
||
and str(item.get("id") or "").strip()
|
||
and str(item.get("status") or "active").strip().lower() == "active"
|
||
]
|
||
available.sort(
|
||
key=lambda item: (
|
||
0 if item.get("is_default") is True else 1,
|
||
str(item.get("id") or "").strip(),
|
||
)
|
||
)
|
||
if not available:
|
||
raise WeKnoraError(
|
||
"WeKnora has no active KnowledgeQA model; configure one before creating a Wiki",
|
||
status_code=400,
|
||
)
|
||
return str(available[0]["id"]).strip()
|
||
|
||
|
||
def _json_response(payload: Any, *, status_code: int = 200) -> Response:
|
||
return Response(
|
||
content=json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode("utf-8"),
|
||
status_code=status_code,
|
||
media_type="application/json",
|
||
)
|
||
|
||
|
||
def _runtime_for_iframe_proxy(request: Request | None = None):
|
||
source_config = None
|
||
if request is not None:
|
||
source_config = getattr(request.app.state, "llmwiki_source_config", None)
|
||
runtime = get_resolved_llmwiki_runtime(source_config)
|
||
if not runtime.weknora_enabled:
|
||
raise HTTPException(status_code=409, detail="WeKnora mode is not enabled")
|
||
if not runtime.web_base_url:
|
||
raise HTTPException(status_code=422, detail="WeKnora web_base_url is required for iframe embedding")
|
||
if not runtime.admin_email or not runtime.admin_password:
|
||
raise HTTPException(
|
||
status_code=422,
|
||
detail="Configure llmwiki.weknora.admin_email and admin_password for WeKnora iframe SSO",
|
||
)
|
||
return runtime
|
||
|
||
|
||
def _admin_session_cache_key(runtime: Any) -> str:
|
||
digest = hashlib.sha256(runtime.admin_password.encode("utf-8")).hexdigest()[:12]
|
||
return f"{runtime.api_base_url}|{runtime.admin_email}|{digest}"
|
||
|
||
|
||
def _extract_weknora_token(payload: dict[str, Any]) -> tuple[str, str, dict[str, Any] | None, dict[str, Any] | None]:
|
||
source = payload.get("data") if isinstance(payload.get("data"), dict) else payload
|
||
token = str(source.get("token") or source.get("access_token") or payload.get("token") or payload.get("access_token") or "")
|
||
refresh_token = str(source.get("refresh_token") or source.get("refreshToken") or payload.get("refresh_token") or payload.get("refreshToken") or "")
|
||
user = source.get("user") if isinstance(source.get("user"), dict) else payload.get("user")
|
||
tenant = source.get("tenant") if isinstance(source.get("tenant"), dict) else source.get("active_tenant") if isinstance(source.get("active_tenant"), dict) else payload.get("tenant")
|
||
return token, refresh_token, user if isinstance(user, dict) else None, tenant if isinstance(tenant, dict) else None
|
||
|
||
|
||
async def _get_weknora_admin_session(runtime: Any, *, force_refresh: bool = False) -> dict[str, Any]:
|
||
key = _admin_session_cache_key(runtime)
|
||
cached = _WEKNORA_ADMIN_SESSION_CACHE.get(key)
|
||
now = time.time()
|
||
if cached and not force_refresh and float(cached.get("expires_at") or 0) > now + 60:
|
||
return cached
|
||
|
||
pending = _WEKNORA_ADMIN_SESSION_TASKS.get(key)
|
||
if pending is None:
|
||
pending = asyncio.create_task(_login_weknora_admin_session(runtime))
|
||
_WEKNORA_ADMIN_SESSION_TASKS[key] = pending
|
||
|
||
def clear_pending(task: asyncio.Task[dict[str, Any]]) -> None:
|
||
if _WEKNORA_ADMIN_SESSION_TASKS.get(key) is task:
|
||
_WEKNORA_ADMIN_SESSION_TASKS.pop(key, None)
|
||
if not task.cancelled():
|
||
task.exception() # Also retrieve failures if every caller disconnected.
|
||
|
||
pending.add_done_callback(clear_pending)
|
||
# A navigation cancelling one request must not cancel everybody's login.
|
||
return await asyncio.shield(pending)
|
||
|
||
|
||
async def _login_weknora_admin_session(runtime: Any) -> dict[str, Any]:
|
||
key = _admin_session_cache_key(runtime)
|
||
now = time.time()
|
||
try:
|
||
async with httpx.AsyncClient(timeout=httpx.Timeout(20.0), follow_redirects=False, trust_env=False) as client:
|
||
response = await client.post(
|
||
f"{runtime.api_base_url}/api/v1/auth/login",
|
||
json={"email": runtime.admin_email, "password": runtime.admin_password},
|
||
headers={"Accept": "application/json"},
|
||
)
|
||
except httpx.TimeoutException as exc:
|
||
logger.warning("WeKnora admin login timed out: base_url=%s", runtime.api_base_url)
|
||
raise HTTPException(status_code=504, detail="WeKnora admin login timed out") from exc
|
||
except httpx.HTTPError as exc:
|
||
logger.warning("WeKnora admin login unavailable: base_url=%s error=%r", runtime.api_base_url, exc)
|
||
raise HTTPException(status_code=502, detail="WeKnora admin login is unavailable") from exc
|
||
|
||
if response.status_code >= 400:
|
||
raise HTTPException(status_code=502, detail=f"WeKnora admin login failed ({response.status_code})")
|
||
try:
|
||
payload = response.json()
|
||
except ValueError as exc:
|
||
raise HTTPException(status_code=502, detail="WeKnora admin login returned invalid JSON") from exc
|
||
if isinstance(payload, dict) and payload.get("success") is False:
|
||
raise HTTPException(status_code=502, detail=str(payload.get("message") or "WeKnora admin login failed"))
|
||
token, refresh_token, user, tenant = _extract_weknora_token(payload if isinstance(payload, dict) else {})
|
||
if not token:
|
||
raise HTTPException(status_code=502, detail="WeKnora admin login did not return a token")
|
||
|
||
session = {
|
||
"token": token,
|
||
"refresh_token": refresh_token,
|
||
"user": user or {},
|
||
"tenant": tenant or {},
|
||
# WeKnora tokens do not consistently expose expiry in the login body.
|
||
# Relogin periodically and retry once on upstream 401.
|
||
"expires_at": now + 25 * 60,
|
||
}
|
||
_WEKNORA_ADMIN_SESSION_CACHE[key] = session
|
||
return session
|
||
|
||
|
||
def _tenant_id_from_session(session: dict[str, Any]) -> str:
|
||
tenant = session.get("tenant") if isinstance(session.get("tenant"), dict) else {}
|
||
user = session.get("user") if isinstance(session.get("user"), dict) else {}
|
||
return str(tenant.get("id") or user.get("tenant_id") or "")
|
||
|
||
|
||
async def _read_embed_context(request: Request) -> WeKnoraEmbedContext:
|
||
ctx = _verify_embed_cookie(request.cookies.get(_WEKNORA_EMBED_COOKIE))
|
||
if not ctx.mapping_id or not ctx.weknora_id or not ctx.user_id:
|
||
raise HTTPException(status_code=401, detail="WeKnora iframe session is invalid")
|
||
store = _store(request)
|
||
readable = await store.get_authorized(ctx.mapping_id, ctx.user_id, write=False, is_admin=ctx.is_admin)
|
||
if readable is None or str(readable.get("weknora_id") or "") != ctx.weknora_id:
|
||
raise HTTPException(status_code=403, detail="WeKnora iframe session is no longer authorized")
|
||
writable = None
|
||
if not _is_conversation_deposit_mapping(readable):
|
||
writable = await store.get_authorized(ctx.mapping_id, ctx.user_id, write=True, is_admin=ctx.is_admin)
|
||
return replace(
|
||
ctx,
|
||
can_write=writable is not None,
|
||
is_conversation_deposit=_is_conversation_deposit_mapping(readable),
|
||
allowed_weknora_ids=_embed_allowed_weknora_ids(ctx),
|
||
)
|
||
|
||
|
||
def _embed_allowed_weknora_ids(ctx: WeKnoraEmbedContext) -> tuple[str, ...]:
|
||
"""Return the signed knowledge-base allowlist, always retaining the active base."""
|
||
|
||
return tuple(dict.fromkeys(value for value in (ctx.weknora_id, *ctx.allowed_weknora_ids) if value))
|
||
|
||
|
||
def _embed_context_payload(ctx: WeKnoraEmbedContext) -> dict[str, Any]:
|
||
# Keep this payload bounded: it is signed directly into the browser cookie.
|
||
# The visible knowledge-base list is hydrated only for the embedded page or
|
||
# its list API call, after the cookie has been verified.
|
||
return {
|
||
"mapping_id": ctx.mapping_id,
|
||
"weknora_id": ctx.weknora_id,
|
||
"user_id": ctx.user_id,
|
||
"is_admin": ctx.is_admin,
|
||
"can_write": ctx.can_write,
|
||
"is_conversation_deposit": ctx.is_conversation_deposit,
|
||
"exp": ctx.exp,
|
||
}
|
||
|
||
|
||
async def _with_visible_embed_knowledge_bases(
|
||
request: Request,
|
||
ctx: WeKnoraEmbedContext,
|
||
) -> WeKnoraEmbedContext:
|
||
"""Hydrate the iframe-only picker allowlist without putting it in a cookie."""
|
||
|
||
visible_rows = await _store(request).list_visible(
|
||
ctx.user_id,
|
||
scope="all",
|
||
# Mirror the regular picker. An administrator must not receive another
|
||
# user's private bases merely because the iframe uses an admin token.
|
||
is_admin=False,
|
||
)
|
||
return replace(
|
||
ctx,
|
||
allowed_weknora_ids=tuple(
|
||
dict.fromkeys(
|
||
value
|
||
for value in (
|
||
ctx.weknora_id,
|
||
*(str(item.get("weknora_id") or "") for item in visible_rows),
|
||
)
|
||
if value
|
||
)
|
||
),
|
||
)
|
||
|
||
|
||
def _embed_directory_time(value: Any) -> str | None:
|
||
if isinstance(value, datetime):
|
||
return value.isoformat()
|
||
if isinstance(value, str) and value.strip():
|
||
return value
|
||
return None
|
||
|
||
|
||
async def _local_embed_knowledge_base_directory(
|
||
request: Request,
|
||
ctx: WeKnoraEmbedContext,
|
||
) -> Response:
|
||
"""Serve the iframe picker from DeerFlow mappings, never WeKnora's full directory."""
|
||
|
||
rows = await _store(request).list_visible(
|
||
ctx.user_id,
|
||
scope="all",
|
||
# Match the normal picker: the WeKnora service-admin token must not
|
||
# make an administrator's private libraries discoverable to others.
|
||
is_admin=False,
|
||
)
|
||
items = [
|
||
{
|
||
"id": str(row.get("weknora_id") or ""),
|
||
"name": str(row.get("name") or ""),
|
||
"description": str(row.get("description") or ""),
|
||
"type": str(row.get("kb_type") or "document"),
|
||
"status": "active",
|
||
"created_at": _embed_directory_time(row.get("created_at")),
|
||
"updated_at": _embed_directory_time(row.get("updated_at")),
|
||
}
|
||
for row in rows
|
||
if str(row.get("weknora_id") or "")
|
||
]
|
||
response = _json_response(
|
||
{"success": True, "data": items, "total": len(items)},
|
||
)
|
||
response.headers["Cache-Control"] = "no-store"
|
||
return response
|
||
|
||
|
||
def _weknora_id_from_platform_path(path: str) -> str | None:
|
||
prefix = "/platform/knowledge-bases/"
|
||
if not path.startswith(prefix):
|
||
return None
|
||
raw_id = path.removeprefix(prefix)
|
||
if not raw_id or "/" in raw_id:
|
||
return None
|
||
return unquote(raw_id)
|
||
|
||
|
||
async def _switch_embed_context(
|
||
request: Request,
|
||
ctx: WeKnoraEmbedContext,
|
||
weknora_id: str,
|
||
) -> WeKnoraEmbedContext:
|
||
"""Resolve a native-UI switch to the exact cmzs mapping.
|
||
|
||
The embedded WeKnora UI uses a service-admin token, so the target must be
|
||
checked against the current cmzs visibility rules on every switch. This is
|
||
deliberately dynamic rather than a signed ID list in the browser cookie.
|
||
"""
|
||
|
||
store = _store(request)
|
||
mapped = await store.get_by_weknora_id(weknora_id)
|
||
if mapped is None:
|
||
raise HTTPException(status_code=403, detail="Knowledge base is not managed by cmzs")
|
||
readable = await store.get_authorized(
|
||
str(mapped["id"]),
|
||
ctx.user_id,
|
||
write=False,
|
||
is_admin=ctx.is_admin,
|
||
)
|
||
if readable is None or str(readable.get("weknora_id") or "") != weknora_id:
|
||
raise HTTPException(status_code=403, detail="Knowledge base is no longer authorized")
|
||
writable = None
|
||
if not _is_conversation_deposit_mapping(readable):
|
||
writable = await store.get_authorized(
|
||
str(readable["id"]),
|
||
ctx.user_id,
|
||
write=True,
|
||
is_admin=ctx.is_admin,
|
||
)
|
||
return replace(
|
||
ctx,
|
||
mapping_id=str(readable["id"]),
|
||
weknora_id=weknora_id,
|
||
can_write=writable is not None,
|
||
is_conversation_deposit=_is_conversation_deposit_mapping(readable),
|
||
)
|
||
|
||
|
||
def _encode_upstream_path(path: str) -> str:
|
||
if not path.startswith("/"):
|
||
path = f"/{path}"
|
||
return "/".join(quote(part, safe="") for part in path.split("/"))
|
||
|
||
|
||
def _safe_response_headers(headers: httpx.Headers) -> dict[str, str]:
|
||
result: dict[str, str] = {}
|
||
for key, value in headers.items():
|
||
if key.lower() in _HOP_BY_HOP_RESPONSE_HEADERS:
|
||
continue
|
||
if key.lower() == "set-cookie":
|
||
continue
|
||
result[key] = value
|
||
return result
|
||
|
||
|
||
def _should_follow_weknora_binary_redirect(path: str) -> bool:
|
||
"""Keep protected preview/image bytes inside the DeerFlow proxy."""
|
||
|
||
segments = _path_segments(path)
|
||
return (len(segments) == 5 and segments[:3] == ["api", "v1", "knowledge"] and segments[4] == "preview") or (len(segments) == 5 and segments[:3] == ["api", "v1", "knowledge-bases"] and segments[4] == "files")
|
||
|
||
|
||
def _is_weknora_content_mutation(method: str, path: str) -> bool:
|
||
if method.upper() not in {"POST", "PUT", "PATCH", "DELETE"}:
|
||
return False
|
||
segments = _path_segments(path)
|
||
if len(segments) < 3 or segments[:2] != ["api", "v1"]:
|
||
return False
|
||
return segments[2] in {"knowledge-bases", "knowledgebase", "knowledge", "chunks"}
|
||
|
||
|
||
async def _schedule_proxy_local_wiki_sync(
|
||
request: Request,
|
||
ctx: WeKnoraEmbedContext,
|
||
) -> None:
|
||
"""Bridge successful native WeKnora UI writes back to the local mirror."""
|
||
|
||
try:
|
||
mapping = await _store(request).get_authorized(
|
||
ctx.mapping_id,
|
||
ctx.user_id,
|
||
write=False,
|
||
is_admin=ctx.is_admin,
|
||
)
|
||
if mapping is not None and str(mapping.get("weknora_id") or "") == ctx.weknora_id:
|
||
_schedule_local_wiki_sync(request, mapping)
|
||
except Exception:
|
||
# The upstream mutation already succeeded. A scheduling failure must
|
||
# not turn that durable write into a false client-side failure; the
|
||
# periodic scanner remains the fallback.
|
||
logger.warning(
|
||
"Could not schedule local Wiki sync after proxied mutation mapping_id=%s",
|
||
ctx.mapping_id,
|
||
exc_info=True,
|
||
)
|
||
|
||
|
||
def _forward_request_headers(request: Request) -> dict[str, str]:
|
||
blocked = {
|
||
"host",
|
||
"connection",
|
||
"content-length",
|
||
"cookie",
|
||
"authorization",
|
||
"x-forwarded-host",
|
||
"x-forwarded-for",
|
||
"x-forwarded-proto",
|
||
}
|
||
headers = {key: value for key, value in request.headers.items() if key.lower() not in blocked}
|
||
headers.setdefault("Accept", "*/*")
|
||
return headers
|
||
|
||
|
||
def _path_segments(path: str) -> list[str]:
|
||
return [segment for segment in path.strip("/").split("/") if segment]
|
||
|
||
|
||
def _json_from_body(body: bytes, content_type: str | None) -> Any:
|
||
if not body or "application/json" not in (content_type or "").lower():
|
||
return None
|
||
try:
|
||
return json.loads(body)
|
||
except ValueError:
|
||
return None
|
||
|
||
|
||
def _body_kb_id(body: Any) -> str:
|
||
return str(body.get("kb_id") or body.get("knowledge_base_id") or body.get("source_kb_id") or "") if isinstance(body, dict) else ""
|
||
|
||
|
||
def _request_kb_id(request: Request, body: Any) -> str:
|
||
return _body_kb_id(body) or str(request.query_params.get("kb_id") or request.query_params.get("knowledge_base_id") or request.query_params.get("source_kb_id") or "")
|
||
|
||
|
||
async def _admin_json(runtime: Any, path: str, *, token: str, params: dict[str, Any] | None = None) -> dict[str, Any]:
|
||
try:
|
||
async with httpx.AsyncClient(timeout=httpx.Timeout(20.0), follow_redirects=False, trust_env=False) as client:
|
||
response = await client.get(
|
||
f"{runtime.api_base_url}{_encode_upstream_path(path)}",
|
||
params=params,
|
||
headers={"Accept": "application/json", "Authorization": f"Bearer {token}"},
|
||
)
|
||
except httpx.HTTPError as exc:
|
||
logger.warning("WeKnora verification request failed: base_url=%s path=%s error=%r", runtime.api_base_url, path, exc)
|
||
raise HTTPException(status_code=502, detail="WeKnora verification request failed") from exc
|
||
if response.status_code == 404:
|
||
raise HTTPException(status_code=404, detail="WeKnora resource was not found")
|
||
if response.status_code >= 400:
|
||
raise HTTPException(status_code=502, detail=f"WeKnora verification failed ({response.status_code})")
|
||
try:
|
||
payload = response.json()
|
||
except ValueError as exc:
|
||
raise HTTPException(status_code=502, detail="WeKnora verification returned invalid JSON") from exc
|
||
return payload if isinstance(payload, dict) else {}
|
||
|
||
|
||
def _payload_data(payload: dict[str, Any]) -> Any:
|
||
return payload.get("data", payload)
|
||
|
||
|
||
async def _verify_proxy_document_belongs_to_kb(runtime: Any, ctx: WeKnoraEmbedContext, document_id: str, token: str) -> None:
|
||
payload = await _admin_json(runtime, f"/api/v1/knowledge/{document_id}", token=token)
|
||
data = _payload_data(payload)
|
||
if not isinstance(data, dict) or str(data.get("knowledge_base_id") or "") != ctx.weknora_id:
|
||
raise HTTPException(status_code=403, detail="Document is outside the authorized WeKnora knowledge base")
|
||
|
||
|
||
async def _verify_proxy_chunk_belongs_to_kb(runtime: Any, ctx: WeKnoraEmbedContext, chunk_id: str, token: str) -> None:
|
||
payload = await _admin_json(runtime, f"/api/v1/chunks/by-id/{chunk_id}", token=token)
|
||
data = _payload_data(payload)
|
||
if not isinstance(data, dict) or str(data.get("knowledge_base_id") or "") != ctx.weknora_id:
|
||
raise HTTPException(status_code=403, detail="Chunk is outside the authorized WeKnora knowledge base")
|
||
|
||
|
||
async def _authorize_weknora_api_proxy(
|
||
request: Request,
|
||
ctx: WeKnoraEmbedContext,
|
||
runtime: Any,
|
||
path: str,
|
||
body: bytes,
|
||
token: str,
|
||
) -> None:
|
||
method = request.method.upper()
|
||
if method == "OPTIONS":
|
||
return
|
||
read_method = method in {"GET", "HEAD"}
|
||
write_method = method in {"POST", "PUT", "PATCH", "DELETE"}
|
||
if write_method and not ctx.can_write:
|
||
raise HTTPException(status_code=403, detail="Current cmzs user cannot write this knowledge base")
|
||
|
||
segments = _path_segments(path)
|
||
if len(segments) < 2 or segments[0] != "api" or segments[1] != "v1":
|
||
raise HTTPException(status_code=403, detail="Unsupported WeKnora API path")
|
||
tail = segments[2:]
|
||
json_body = _json_from_body(body, request.headers.get("content-type"))
|
||
|
||
if tail[:1] == ["auth"]:
|
||
if len(tail) >= 2 and tail[1] in {"me", "tenant", "validate", "refresh"}:
|
||
return
|
||
raise HTTPException(status_code=403, detail="Only WeKnora session introspection is allowed")
|
||
|
||
if read_method and tail == ["me", "invitations", "pending-count"]:
|
||
return
|
||
|
||
if read_method and tail == ["tenants", "kv", "retrieval-config"]:
|
||
return
|
||
|
||
if tail[:1] == ["knowledge-bases"]:
|
||
if len(tail) == 1:
|
||
if method == "GET":
|
||
return
|
||
raise HTTPException(status_code=403, detail="Creating or deleting other WeKnora knowledge bases is not allowed")
|
||
if tail[1] != ctx.weknora_id:
|
||
raise HTTPException(status_code=403, detail="Only the current WeKnora knowledge base is allowed")
|
||
if (
|
||
method == "POST"
|
||
and tail[2:] == ["knowledge", "file"]
|
||
and looks_like_knowledge_package(body)
|
||
):
|
||
raise HTTPException(status_code=422, detail=_KNOWLEDGE_PACKAGE_UPLOAD_DETAIL)
|
||
if read_method or (write_method and ctx.can_write):
|
||
return
|
||
|
||
if tail[:1] == ["knowledgebase"]:
|
||
if len(tail) < 2 or tail[1] != ctx.weknora_id:
|
||
raise HTTPException(status_code=403, detail="Only the current WeKnora wiki is allowed")
|
||
if read_method or (write_method and ctx.can_write):
|
||
return
|
||
|
||
if tail[:1] == ["knowledge"]:
|
||
if len(tail) == 1:
|
||
raise HTTPException(status_code=403, detail="Global WeKnora knowledge collection is not allowed")
|
||
action = tail[1]
|
||
if read_method and action == "batch":
|
||
ids = [item.strip() for raw in request.query_params.getlist("ids") for item in raw.split(",") if item.strip()]
|
||
if not ids:
|
||
raise HTTPException(status_code=403, detail="Batch operation must include document ids")
|
||
for document_id in ids:
|
||
await _verify_proxy_document_belongs_to_kb(runtime, ctx, document_id, token)
|
||
return
|
||
if action in {"batch", "batch-delete", "batch-reparse", "folder", "move", "tags"}:
|
||
if _request_kb_id(request, json_body) != ctx.weknora_id:
|
||
raise HTTPException(status_code=403, detail="Batch operation must be scoped to the current knowledge base")
|
||
return
|
||
if action in {"search"}:
|
||
raise HTTPException(status_code=403, detail="Global WeKnora search is disabled in embedded detail mode")
|
||
document_id = tail[2] if action == "manual" and len(tail) >= 3 else action
|
||
await _verify_proxy_document_belongs_to_kb(runtime, ctx, document_id, token)
|
||
return
|
||
|
||
if tail[:1] == ["chunks"]:
|
||
if len(tail) < 2:
|
||
raise HTTPException(status_code=403, detail="Chunk path is incomplete")
|
||
if tail[1] == "by-id":
|
||
if len(tail) < 3:
|
||
raise HTTPException(status_code=403, detail="Chunk path is incomplete")
|
||
await _verify_proxy_chunk_belongs_to_kb(runtime, ctx, tail[2], token)
|
||
return
|
||
await _verify_proxy_document_belongs_to_kb(runtime, ctx, tail[1], token)
|
||
return
|
||
|
||
if tail[:1] == ["knowledge-search"]:
|
||
ids = json_body.get("knowledge_base_ids") if isinstance(json_body, dict) else None
|
||
if ids == [ctx.weknora_id] or ids == (ctx.weknora_id,):
|
||
return
|
||
raise HTTPException(status_code=403, detail="Search must be scoped to the current WeKnora knowledge base")
|
||
|
||
# A few read-only metadata endpoints are required by the original detail
|
||
# page to render upload controls and current-tenant state. They do not
|
||
# expose other knowledge-base contents.
|
||
if (
|
||
read_method
|
||
and tail
|
||
and tail[0]
|
||
in {
|
||
"models",
|
||
"system",
|
||
"storage-backends",
|
||
"vector-stores",
|
||
"web-search-providers",
|
||
"mcp-services",
|
||
}
|
||
):
|
||
return
|
||
|
||
raise HTTPException(status_code=403, detail="This WeKnora endpoint is blocked in embedded detail mode")
|
||
|
||
|
||
def _filter_knowledge_base_list_response(response: httpx.Response, ctx: WeKnoraEmbedContext) -> bytes | None:
|
||
content_type = response.headers.get("content-type", "")
|
||
if "application/json" not in content_type.lower():
|
||
return None
|
||
try:
|
||
payload = response.json()
|
||
except ValueError:
|
||
return None
|
||
if not isinstance(payload, dict):
|
||
return None
|
||
|
||
allowed_weknora_ids = set(_embed_allowed_weknora_ids(ctx))
|
||
|
||
def keep_allowed(value: Any) -> Any:
|
||
if isinstance(value, list):
|
||
return [item for item in value if isinstance(item, dict) and str(item.get("id") or "") in allowed_weknora_ids]
|
||
if isinstance(value, dict):
|
||
result = dict(value)
|
||
for key in ("items", "list", "knowledge_bases", "knowledge", "records", "data"):
|
||
if isinstance(result.get(key), list):
|
||
result[key] = keep_allowed(result[key])
|
||
result["total"] = len(result[key])
|
||
return result
|
||
return value
|
||
|
||
if isinstance(payload.get("data"), list):
|
||
payload["data"] = keep_allowed(payload["data"])
|
||
payload["total"] = len(payload["data"])
|
||
elif isinstance(payload.get("data"), dict):
|
||
payload["data"] = keep_allowed(payload["data"])
|
||
else:
|
||
for key in ("items", "list", "knowledge_bases", "records"):
|
||
if isinstance(payload.get(key), list):
|
||
payload[key] = keep_allowed(payload[key])
|
||
payload["total"] = len(payload[key])
|
||
return json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
|
||
|
||
|
||
def _escape_script_json(value: Any) -> str:
|
||
return json.dumps(value, ensure_ascii=False, separators=(",", ":")).replace("</", "<\\/")
|
||
|
||
|
||
def _normalize_public_prefix(value: str | None) -> str:
|
||
raw = (value or "").strip()
|
||
if not raw:
|
||
return ""
|
||
if "://" in raw:
|
||
try:
|
||
raw = urlsplit(raw).path
|
||
except ValueError:
|
||
return ""
|
||
if not raw.startswith("/"):
|
||
raw = f"/{raw}"
|
||
if (
|
||
raw.startswith("//")
|
||
or any(marker in raw for marker in ("\\", "?", "#"))
|
||
or any(part in {".", ".."} for part in raw.split("/"))
|
||
or any(ord(char) <= 32 for char in raw)
|
||
):
|
||
return ""
|
||
raw = raw.rstrip("/")
|
||
return "" if raw == "/" else raw
|
||
|
||
|
||
def _request_public_prefix(request: Request) -> str:
|
||
# The public backend prefix belongs to the reverse proxy, not to the
|
||
# Gateway route itself. Offline deployments use /deerflow-api by default
|
||
# and pass it explicitly, while direct/local deployments retain the
|
||
# historical /deerflow fallback.
|
||
forwarded = _normalize_public_prefix(request.headers.get("x-forwarded-prefix"))
|
||
scope = getattr(request, "scope", None)
|
||
root_path = _normalize_public_prefix(
|
||
str(scope.get("root_path") or "") if isinstance(scope, dict) else ""
|
||
)
|
||
return forwarded or root_path or _WEKNORA_PUBLIC_PREFIX
|
||
|
||
|
||
def _with_public_prefix(prefix: str, path: str) -> str:
|
||
if not path.startswith("/"):
|
||
path = f"/{path}"
|
||
return f"{prefix}{path}" if prefix else path
|
||
|
||
|
||
def _without_public_prefix(prefix: str, path: str) -> str:
|
||
if prefix and path.startswith(f"{prefix}/"):
|
||
return path[len(prefix) :]
|
||
if prefix and path == prefix:
|
||
return "/"
|
||
return path
|
||
|
||
|
||
def _rewrite_weknora_html_public_paths(
|
||
html: str,
|
||
public_prefix: str,
|
||
*,
|
||
rewrite_relative: bool = True,
|
||
) -> str:
|
||
if not public_prefix:
|
||
return html
|
||
for path in _WEKNORA_PUBLIC_ROOT_PATHS:
|
||
if path not in html and path.replace("/", "\\/") not in html:
|
||
continue
|
||
prefixed = f"{public_prefix}{path}"
|
||
html = re.sub(
|
||
rf"(?P<prefix>[\"'`(=:\s]){re.escape(path)}(?=[/?#\"'`),;\]\}}\s>]|$)",
|
||
rf"\g<prefix>{prefixed}",
|
||
html,
|
||
)
|
||
escaped_path = rf"\\/{re.escape(path.lstrip('/'))}"
|
||
escaped_prefixed = f"\\/{prefixed.lstrip('/')}"
|
||
html = re.sub(
|
||
rf"(?<!\\/{re.escape(public_prefix.lstrip('/'))}){escaped_path}(?=[\\/?#\"'`),;\]\}}\s>]|$)",
|
||
escaped_prefixed,
|
||
html,
|
||
)
|
||
if rewrite_relative:
|
||
for path in _WEKNORA_PUBLIC_RELATIVE_ROOT_PATHS:
|
||
prefixed = f"{public_prefix}/{path}"
|
||
html = re.sub(
|
||
rf"(?P<prefix>[\"'`(=:\s])(?:\./)?{re.escape(path)}(?=/)",
|
||
rf"\g<prefix>{prefixed}",
|
||
html,
|
||
)
|
||
for path in _WEKNORA_PUBLIC_RELATIVE_FILE_PATHS:
|
||
prefixed = f"{public_prefix}/{path}"
|
||
html = re.sub(
|
||
rf"(?P<prefix>[\"'`(=:\s])(?:\./)?{re.escape(path)}(?=[?#\"'`),;\]\}}\s>]|$)",
|
||
rf"\g<prefix>{prefixed}",
|
||
html,
|
||
)
|
||
return html
|
||
|
||
|
||
def _rewrite_weknora_script_public_paths(text: str, public_prefix: str) -> str:
|
||
if not public_prefix:
|
||
return text
|
||
for path in _WEKNORA_PUBLIC_ROOT_PATHS:
|
||
if path not in text:
|
||
continue
|
||
prefixed = f"{public_prefix}{path}"
|
||
text = re.sub(
|
||
rf"(?P<quote>[\"'`]){re.escape(path)}(?=[/?#\"'`),;\]\}}\s]|$)",
|
||
rf"\g<quote>{prefixed}",
|
||
text,
|
||
)
|
||
return _rewrite_weknora_vite_dependency_map(text, public_prefix)
|
||
|
||
|
||
def _should_rewrite_weknora_static_response(content_type: str, upstream_path: str) -> bool:
|
||
lower_type = content_type.lower()
|
||
if any(
|
||
marker in lower_type
|
||
for marker in (
|
||
"javascript",
|
||
"text/css",
|
||
"application/json",
|
||
"manifest",
|
||
"image/svg+xml",
|
||
)
|
||
):
|
||
return True
|
||
lower_path = upstream_path.lower()
|
||
return lower_path.endswith((".js", ".mjs", ".css", ".json", ".webmanifest", ".svg"))
|
||
|
||
|
||
def _rewrite_weknora_vite_dependency_map(text: str, public_prefix: str) -> str:
|
||
if not public_prefix or "__vite__mapDeps" not in text:
|
||
return text
|
||
marker = "m.f||(m.f=["
|
||
start = text.find(marker)
|
||
if start < 0:
|
||
return text
|
||
end = text.find("]))", start + len(marker))
|
||
if end < 0:
|
||
return text
|
||
prefix = public_prefix.strip("/")
|
||
dependency_map = text[start:end]
|
||
dependency_map = re.sub(
|
||
r'(?P<quote>["\'])assets/',
|
||
rf"\g<quote>{prefix}/assets/",
|
||
dependency_map,
|
||
)
|
||
return f"{text[:start]}{dependency_map}{text[end:]}"
|
||
|
||
|
||
def _weknora_static_fallback_upstream_path(request: Request) -> str | None:
|
||
path = _without_public_prefix(
|
||
_request_public_prefix(request),
|
||
str(request.scope.get("path") or request.url.path),
|
||
)
|
||
if not path.startswith("/"):
|
||
path = f"/{path}"
|
||
lower_path = path.lower()
|
||
if lower_path.startswith("/api/"):
|
||
return None
|
||
if lower_path.startswith(_WEKNORA_STATIC_ROOT_PREFIXES):
|
||
return path
|
||
for root_prefix in _WEKNORA_STATIC_ROOT_PREFIXES:
|
||
index = lower_path.find(root_prefix)
|
||
if index > 0:
|
||
return path[index:]
|
||
for asset_file in _WEKNORA_ROOT_ASSET_FILES:
|
||
if lower_path.endswith(asset_file):
|
||
return asset_file
|
||
if lower_path.endswith(_WEKNORA_STATIC_FILE_SUFFIXES):
|
||
return path
|
||
return None
|
||
|
||
|
||
def _is_weknora_static_asset_path(path: str) -> bool:
|
||
if any(part in {".", ".."} for part in path.split("/")) or "\\" in path or "\x00" in path:
|
||
return False
|
||
lower = path.lower()
|
||
return lower in _WEKNORA_ROOT_ASSET_FILES or (lower.startswith(_WEKNORA_STATIC_ROOT_PREFIXES) and lower.endswith(_WEKNORA_STATIC_FILE_SUFFIXES))
|
||
|
||
|
||
def _prepare_weknora_static_asset(content: bytes, content_type: str, path: str, public_prefix: str, *, cacheable: bool) -> StaticAsset:
|
||
# All full-bundle regex passes, hashing and gzip work run off the event loop.
|
||
if _should_rewrite_weknora_static_response(content_type, path):
|
||
text = content.decode("utf-8", errors="replace")
|
||
if "javascript" in content_type.lower() or path.lower().endswith((".js", ".mjs")):
|
||
text = _rewrite_weknora_script_public_paths(text, public_prefix)
|
||
else:
|
||
text = _rewrite_weknora_html_public_paths(text, public_prefix, rewrite_relative=False)
|
||
content = text.encode("utf-8")
|
||
fingerprint = re.search(r"[-.]([A-Za-z0-9_-]{8,32})\.[A-Za-z0-9]+$", path)
|
||
versioned = path.startswith("/assets/") and fingerprint is not None and re.search(r"[0-9A-Z]", fingerprint[1]) is not None
|
||
if not cacheable:
|
||
control, ttl = "private, no-store", 0.0
|
||
elif path.lower() in {"/config.js", "/runtime-config.js"}:
|
||
control, ttl = "private, no-cache", 0.0
|
||
elif versioned:
|
||
control, ttl = "private, max-age=31536000, immutable", 3600.0
|
||
else:
|
||
control, ttl = "private, max-age=300", 300.0
|
||
return prepare_static_asset(content, content_type=content_type, cache_control=control, ttl=ttl)
|
||
|
||
|
||
async def _proxy_weknora_static_asset(request: Request, upstream_path: str) -> Response:
|
||
"""Serve only the public app bundle; HTML and all data APIs use full ACLs."""
|
||
if not _is_weknora_static_asset_path(upstream_path):
|
||
raise HTTPException(status_code=404, detail="WeKnora static resource not found")
|
||
ctx = _verify_embed_cookie(request.cookies.get(_WEKNORA_EMBED_COOKIE))
|
||
if not ctx.mapping_id or not ctx.weknora_id or not ctx.user_id:
|
||
raise HTTPException(status_code=401, detail="WeKnora iframe session is invalid")
|
||
runtime = _runtime_for_iframe_proxy(request)
|
||
public_prefix = _request_public_prefix(request)
|
||
cache = getattr(request.app.state, "llmwiki_static_assets", None)
|
||
if cache is None:
|
||
cache = StaticAssetCache()
|
||
request.app.state.llmwiki_static_assets = cache
|
||
key = ("static-rewrite-v1", runtime.web_base_url, public_prefix, upstream_path, str(request.query_params))
|
||
|
||
async def load() -> StaticAsset:
|
||
try:
|
||
async with _proxy_http_client(request) as client:
|
||
# No identity, cookies, Range or conditional validators are sent
|
||
# upstream. ETags describe our rewritten/gzipped representation.
|
||
response = await client.request(
|
||
"GET",
|
||
f"{runtime.web_base_url.rstrip('/')}{_encode_upstream_path(upstream_path)}",
|
||
params=request.query_params.multi_items(),
|
||
headers={"Accept": "*/*", "Accept-Encoding": "gzip, deflate"},
|
||
follow_redirects=False,
|
||
)
|
||
except httpx.TimeoutException as exc:
|
||
raise HTTPException(status_code=504, detail="WeKnora static resource request timed out") from exc
|
||
except httpx.HTTPError as exc:
|
||
raise HTTPException(status_code=502, detail="WeKnora static resource request failed") from exc
|
||
content_type = response.headers.get("content-type", "").lower()
|
||
allowed_types = (
|
||
"application/javascript",
|
||
"application/x-javascript",
|
||
"text/javascript",
|
||
"text/css",
|
||
"application/json",
|
||
"text/json",
|
||
"application/manifest+json",
|
||
"image/",
|
||
"font/",
|
||
"application/font",
|
||
"application/x-font",
|
||
"application/vnd.ms-fontobject",
|
||
"application/octet-stream",
|
||
"application/wasm",
|
||
"text/plain",
|
||
)
|
||
is_html = response.content.lstrip()[:32].lower().startswith((b"<!doctype html", b"<html"))
|
||
if response.status_code != 200 or not content_type.startswith(allowed_types) or is_html:
|
||
# A web-server SPA fallback is HTML, never an asset or an injected
|
||
# account session. Do not cache/serve it under a script/CSS URL.
|
||
raise HTTPException(status_code=404 if response.status_code in {200, 404} else 502, detail="WeKnora static resource is unavailable")
|
||
upstream_control = response.headers.get("cache-control", "").lower()
|
||
requires_revalidation = "no-cache" in upstream_control or re.search(r"(?:^|,)\s*(?:s-maxage|max-age)\s*=\s*\"?0\"?\s*(?:,|$)", upstream_control) is not None
|
||
cacheable = (
|
||
"set-cookie" not in response.headers
|
||
and "private" not in upstream_control
|
||
and "no-store" not in upstream_control
|
||
and not requires_revalidation
|
||
and all(item.strip().lower() in {"", "accept-encoding"} for item in response.headers.get("vary", "").split(","))
|
||
)
|
||
return await asyncio.to_thread(_prepare_weknora_static_asset, response.content, content_type, upstream_path, public_prefix, cacheable=cacheable)
|
||
|
||
request_control = request.headers.get("cache-control", "").lower()
|
||
refresh = "no-cache" in request_control or "max-age=0" in request_control or "no-cache" in request.headers.get("pragma", "").lower()
|
||
return (await cache.get(key, load, refresh=refresh)).response(request)
|
||
|
||
|
||
def _rewrite_weknora_location_header(location: str, public_prefix: str, target_base_url: str) -> str:
|
||
if not location or not public_prefix:
|
||
return location
|
||
try:
|
||
current = urlsplit(location)
|
||
upstream = urlsplit(target_base_url)
|
||
except ValueError:
|
||
return location
|
||
|
||
def proxied_path(path: str) -> str:
|
||
upstream_base_path = upstream.path.rstrip("/")
|
||
if upstream_base_path and (path == upstream_base_path or path.startswith(f"{upstream_base_path}/")):
|
||
path = path[len(upstream_base_path) :] or "/"
|
||
return _without_public_prefix(public_prefix, path)
|
||
|
||
suffix = ""
|
||
if current.query:
|
||
suffix += f"?{current.query}"
|
||
if current.fragment:
|
||
suffix += f"#{current.fragment}"
|
||
|
||
if not current.scheme and location.startswith("/"):
|
||
path = proxied_path(current.path or "/")
|
||
return f"{public_prefix}{path}{suffix}"
|
||
|
||
if current.scheme and current.netloc and current.netloc == upstream.netloc:
|
||
path = proxied_path(current.path or "/")
|
||
return f"{public_prefix}{path}{suffix}"
|
||
return location
|
||
|
||
|
||
def _weknora_embed_injection(
|
||
*,
|
||
allowed_path: str,
|
||
tab: str,
|
||
session: dict[str, Any],
|
||
ctx: WeKnoraEmbedContext,
|
||
public_prefix: str = "",
|
||
) -> str:
|
||
tenant_id = _tenant_id_from_session(session)
|
||
user = session.get("user") if isinstance(session.get("user"), dict) else {}
|
||
tenant = session.get("tenant") if isinstance(session.get("tenant"), dict) else {}
|
||
payload = {
|
||
"allowedPath": allowed_path,
|
||
"tab": tab,
|
||
"canWrite": ctx.can_write,
|
||
"token": session.get("token") or "",
|
||
"refreshToken": session.get("refresh_token") or "",
|
||
"tenantId": tenant_id,
|
||
"user": user,
|
||
"tenant": tenant,
|
||
"weknoraId": ctx.weknora_id,
|
||
"allowedWeKnoraIds": list(_embed_allowed_weknora_ids(ctx)),
|
||
"publicPrefix": public_prefix,
|
||
"apiPrefix": _with_public_prefix(public_prefix, _WEKNORA_EMBED_API_PREFIX),
|
||
}
|
||
data = _escape_script_json(payload)
|
||
return f"""
|
||
<style id="deerflow-weknora-embed-style">
|
||
html.deerflow-weknora-embed,
|
||
html.deerflow-weknora-embed body,
|
||
html.deerflow-weknora-embed #app {{
|
||
width: 100%;
|
||
height: 100%;
|
||
margin: 0;
|
||
overflow: hidden;
|
||
background: var(--td-bg-color-page) !important;
|
||
color: var(--td-text-color-primary) !important;
|
||
}}
|
||
html.deerflow-weknora-embed body {{
|
||
opacity: 1 !important;
|
||
}}
|
||
html.deerflow-weknora-embed .main {{
|
||
min-width: 0 !important;
|
||
background: var(--td-bg-color-page) !important;
|
||
}}
|
||
html.deerflow-weknora-embed .platform-route-outlet,
|
||
html.deerflow-weknora-embed .knowledge-layout {{
|
||
width: 100% !important;
|
||
min-width: 0 !important;
|
||
background: var(--td-bg-color-page) !important;
|
||
}}
|
||
html.deerflow-weknora-embed [data-deerflow-weknora-chrome="hidden"],
|
||
html.deerflow-weknora-embed #app > .main > .aside_box,
|
||
html.deerflow-weknora-embed #app > aside,
|
||
html.deerflow-weknora-embed #app > .sidebar,
|
||
html.deerflow-weknora-embed #app > .side-bar,
|
||
html.deerflow-weknora-embed #app > .side-menu,
|
||
html.deerflow-weknora-embed #app > .t-layout__sider,
|
||
html.deerflow-weknora-embed #app > .t-layout-sider,
|
||
html.deerflow-weknora-embed #app > .layout-sider,
|
||
html.deerflow-weknora-embed #app > .layout-sidebar,
|
||
html.deerflow-weknora-embed #app > .platform-sidebar,
|
||
html.deerflow-weknora-embed #app > .platform-sider,
|
||
html.deerflow-weknora-embed #app > .app-sidebar,
|
||
html.deerflow-weknora-embed #app > .app-sider,
|
||
html.deerflow-weknora-embed #app > .t-layout > aside,
|
||
html.deerflow-weknora-embed #app > .t-layout > .t-layout__sider,
|
||
html.deerflow-weknora-embed #app > .t-layout > .t-layout-sider,
|
||
html.deerflow-weknora-embed #app > .layout > aside,
|
||
html.deerflow-weknora-embed #app > .layout > .layout-sider,
|
||
html.deerflow-weknora-embed #app > .layout > .layout-sidebar {{
|
||
display: none !important;
|
||
visibility: hidden !important;
|
||
pointer-events: none !important;
|
||
}}
|
||
html.deerflow-weknora-embed [data-deerflow-weknora-content="true"],
|
||
html.deerflow-weknora-embed #app,
|
||
html.deerflow-weknora-embed .t-layout,
|
||
html.deerflow-weknora-embed .t-layout__content,
|
||
html.deerflow-weknora-embed .layout-content,
|
||
html.deerflow-weknora-embed .platform-content {{
|
||
margin-left: 0 !important;
|
||
padding-left: 0 !important;
|
||
left: 0 !important;
|
||
width: 100% !important;
|
||
max-width: none !important;
|
||
}}
|
||
html.deerflow-weknora-embed .document-header {{
|
||
padding-left: 32px !important;
|
||
padding-right: 32px !important;
|
||
background: var(--td-bg-color-page) !important;
|
||
}}
|
||
html.deerflow-weknora-embed .breadcrumb-link:first-child {{
|
||
pointer-events: none !important;
|
||
cursor: default !important;
|
||
}}
|
||
html.deerflow-weknora-embed .breadcrumb-link:first-child {{
|
||
color: var(--td-text-color-primary) !important;
|
||
}}
|
||
html.deerflow-weknora-embed[data-deerflow-can-write="false"] .kb-settings-button,
|
||
html.deerflow-weknora-embed[data-deerflow-can-write="false"] .document-upload-input,
|
||
html.deerflow-weknora-embed[data-deerflow-can-write="false"] .kb-upload-source-dropdown,
|
||
html.deerflow-weknora-embed[data-deerflow-can-write="false"] .t-button--theme-primary {{
|
||
display: none !important;
|
||
}}
|
||
html.deerflow-weknora-embed .t-guide,
|
||
html.deerflow-weknora-embed .t-guide__wrapper,
|
||
html.deerflow-weknora-embed .t-guide__reference,
|
||
html.deerflow-weknora-embed .t-popup[data-popper-placement][class*="guide"],
|
||
html.deerflow-weknora-embed [data-guide-highlight],
|
||
html.deerflow-weknora-embed .contextual-guide,
|
||
html.deerflow-weknora-embed .guide__backdrop,
|
||
html.deerflow-weknora-embed [class^="guide__"],
|
||
html.deerflow-weknora-embed [class*=" guide__"] {{
|
||
display: none !important;
|
||
}}
|
||
html.deerflow-source-detail-open [data-deerflow-source-detail-hidden="true"] {{
|
||
display: none !important;
|
||
}}
|
||
html.deerflow-source-detail-open [data-deerflow-source-detail-panel="true"] {{
|
||
position: fixed !important;
|
||
inset: 0 !important;
|
||
width: 100vw !important;
|
||
height: 100vh !important;
|
||
max-width: none !important;
|
||
max-height: none !important;
|
||
margin: 0 !important;
|
||
transform: none !important;
|
||
z-index: 2147483000 !important;
|
||
background: var(--td-bg-color-page, #fff) !important;
|
||
box-shadow: none !important;
|
||
border-radius: 0 !important;
|
||
}}
|
||
html.deerflow-source-detail-open [data-deerflow-source-detail-panel="true"] [class*="drawer"],
|
||
html.deerflow-source-detail-open [data-deerflow-source-detail-panel="true"] [class*="Drawer"],
|
||
html.deerflow-source-detail-open [data-deerflow-source-detail-panel="true"] [class*="dialog"],
|
||
html.deerflow-source-detail-open [data-deerflow-source-detail-panel="true"] [class*="Dialog"] {{
|
||
max-width: none !important;
|
||
}}
|
||
</style>
|
||
<script id="deerflow-weknora-embed-guard">
|
||
(() => {{
|
||
const cfg = {data};
|
||
const root = document.documentElement;
|
||
root.classList.add("deerflow-weknora-embed");
|
||
root.dataset.deerflowCanWrite = cfg.canWrite ? "true" : "false";
|
||
|
||
function safeSet(key, value) {{
|
||
try {{
|
||
if (value === undefined || value === null || value === "") return;
|
||
localStorage.setItem(key, typeof value === "string" ? value : JSON.stringify(value));
|
||
}} catch (_) {{}}
|
||
}}
|
||
function safeSessionSet(key, value) {{
|
||
try {{
|
||
if (value === undefined || value === null || value === "") return;
|
||
sessionStorage.setItem(key, typeof value === "string" ? value : JSON.stringify(value));
|
||
}} catch (_) {{}}
|
||
}}
|
||
safeSet("weknora_token", cfg.token);
|
||
safeSet("weknora_refresh_token", cfg.refreshToken);
|
||
safeSet("weknora_selected_tenant_id", cfg.tenantId);
|
||
safeSet("weknora_user", cfg.user);
|
||
safeSet("weknora_tenant", cfg.tenant);
|
||
[
|
||
"weknora:contextual-guide-agent-create:v1",
|
||
"weknora:contextual-guide-agent-list:v1",
|
||
"weknora:contextual-guide-chat:v1",
|
||
"weknora:contextual-guide-kb-create:v3",
|
||
"weknora:contextual-guide-kb-detail:v1",
|
||
"weknora:contextual-guide-kb-list:v2",
|
||
"weknora:contextual-guide-tenant-models:v1",
|
||
"weknora:new-user-guide-done:v1",
|
||
"weknora:onboarding:workspace:v1",
|
||
"weknora:onboarding:completed"
|
||
].forEach((key) => {{
|
||
safeSet(key, "1");
|
||
safeSessionSet(key, "1");
|
||
}});
|
||
|
||
function rewriteApiUrl(value) {{
|
||
try {{
|
||
const protectedUrl = protectedImageApiUrl(value);
|
||
if (protectedUrl) return protectedUrl;
|
||
const next = new URL(value, location.origin);
|
||
if (next.origin !== location.origin) return value;
|
||
const publicPrefix = cfg.publicPrefix || "";
|
||
const apiPath = "/api/v1/";
|
||
const publicApiPath = publicPrefix + apiPath;
|
||
let upstreamPath = "";
|
||
if (next.pathname.startsWith(publicApiPath)) {{
|
||
upstreamPath = next.pathname.slice(publicPrefix.length);
|
||
}} else if (next.pathname.startsWith(apiPath)) {{
|
||
upstreamPath = next.pathname;
|
||
}} else {{
|
||
return value;
|
||
}}
|
||
next.pathname = cfg.apiPrefix + upstreamPath;
|
||
return next.href;
|
||
}} catch (_) {{
|
||
return value;
|
||
}}
|
||
}}
|
||
function protectedImageApiUrl(value) {{
|
||
const raw = String(value || "").trim();
|
||
if (!/^(?:storage:\\/\\/[0-9A-Za-z_-]+\\/)?(?:resource|local|minio|cos|tos|s3|oss|ks3|obs):\\/\\//i.test(raw)) {{
|
||
return "";
|
||
}}
|
||
const endpoint =
|
||
cfg.apiPrefix +
|
||
"/api/v1/knowledge-bases/" +
|
||
encodeURIComponent(cfg.weknoraId) +
|
||
"/files?file_path=" +
|
||
encodeURIComponent(raw);
|
||
return new URL(endpoint, location.origin).href;
|
||
}}
|
||
const nativeFetch = window.fetch.bind(window);
|
||
window.fetch = (input, init) => {{
|
||
if (input instanceof Request) {{
|
||
return nativeFetch(new Request(rewriteApiUrl(input.url), input), init);
|
||
}}
|
||
return nativeFetch(rewriteApiUrl(input), init);
|
||
}};
|
||
const nativeXhrOpen = XMLHttpRequest.prototype.open;
|
||
XMLHttpRequest.prototype.open = function(method, url, ...rest) {{
|
||
return nativeXhrOpen.call(this, method, rewriteApiUrl(url), ...rest);
|
||
}};
|
||
|
||
// WeKnora renders protected Markdown images as transparent placeholders and
|
||
// hydrates them asynchronously. In an embedded page that hydration can run
|
||
// before its own API client has picked up our proxy prefix, leaving a 1x1
|
||
// GIF forever. Resolve the same protected source through the scoped proxy
|
||
// as a fallback. The backend validates the scheme and current KB again.
|
||
function hydrateProtectedImage(image) {{
|
||
const protectedSrc = String(image?.getAttribute?.("data-protected-src") || "").trim();
|
||
const proxyUrl = protectedImageApiUrl(protectedSrc);
|
||
if (!proxyUrl) return;
|
||
if (
|
||
image.dataset.deerflowProtectedSource === protectedSrc &&
|
||
String(image.getAttribute("src") || "") === proxyUrl
|
||
) return;
|
||
// Keep the real HTTP proxy URL on the DOM node. WeKnora's preview/lightbox
|
||
// reuses the thumbnail's src when the user clicks it; a short-lived blob URL
|
||
// may render once and then become invalid before the preview opens.
|
||
image.dataset.deerflowProtectedSource = protectedSrc;
|
||
image.removeAttribute("data-img-loading");
|
||
image.removeAttribute("data-wiki-image-error");
|
||
image.src = proxyUrl;
|
||
}}
|
||
function hydrateProtectedImages(scope = document) {{
|
||
if (scope?.matches?.("img[data-protected-src]")) hydrateProtectedImage(scope);
|
||
scope?.querySelectorAll?.("img[data-protected-src]").forEach(hydrateProtectedImage);
|
||
}}
|
||
const protectedImageObserver = new MutationObserver((mutations) => {{
|
||
mutations.forEach((mutation) => {{
|
||
if (mutation.type === "attributes") hydrateProtectedImage(mutation.target);
|
||
mutation.addedNodes?.forEach((node) => {{
|
||
if (node?.nodeType === Node.ELEMENT_NODE) hydrateProtectedImages(node);
|
||
}});
|
||
}});
|
||
}});
|
||
protectedImageObserver.observe(document.documentElement, {{
|
||
childList: true,
|
||
subtree: true,
|
||
attributes: true,
|
||
attributeFilter: ["data-protected-src", "src"]
|
||
}});
|
||
hydrateProtectedImages();
|
||
|
||
function tone(color, alpha) {{
|
||
return `color-mix(in srgb, ${{color}} ${{Math.round(alpha * 100)}}%, transparent)`;
|
||
}}
|
||
let themeObserver = null;
|
||
function applyTheme(message) {{
|
||
if (!message || message.type !== "deerflow-weknora-theme") return;
|
||
window.__deerflowWeKnoraTheme = message;
|
||
const vars = message.vars || {{}};
|
||
const dark = message.theme === "dark";
|
||
const mode = dark ? "dark" : "light";
|
||
if (root.classList.contains("dark") !== dark) root.classList.toggle("dark", dark);
|
||
for (const name of ["theme-mode", "data-theme-mode", "data-theme"]) {{
|
||
if (root.getAttribute(name) !== mode) root.setAttribute(name, mode);
|
||
}}
|
||
const pairs = {{
|
||
"--td-brand-color": vars.primary,
|
||
"--td-brand-color-hover": vars.primary,
|
||
"--td-brand-color-active": vars.primary,
|
||
"--td-brand-color-light": tone(vars.primary || "#0062d9", dark ? 0.22 : 0.12),
|
||
"--td-brand-color-focus": tone(vars.primary || "#0062d9", 0.22),
|
||
"--td-success-color": vars.primary,
|
||
"--td-success-color-light": tone(vars.primary || "#0062d9", dark ? 0.20 : 0.10),
|
||
"--td-bg-color-page": vars.background,
|
||
"--td-bg-color-container": vars.card,
|
||
"--td-bg-color-secondarycontainer": vars.muted,
|
||
"--td-bg-color-component": vars.muted,
|
||
"--td-bg-color-component-hover": tone(vars.primary || "#0062d9", dark ? 0.20 : 0.08),
|
||
"--td-bg-color-container-hover": tone(vars.primary || "#0062d9", dark ? 0.18 : 0.06),
|
||
"--td-text-color-primary": vars.foreground,
|
||
"--td-text-color-secondary": vars.mutedForeground,
|
||
"--td-text-color-placeholder": vars.mutedForeground,
|
||
"--td-text-color-disabled": tone(vars.mutedForeground || "#8a8f98", 0.65),
|
||
"--td-component-stroke": vars.border,
|
||
"--td-component-border": vars.border,
|
||
"--td-error-color": vars.destructive,
|
||
"--td-font-family": "var(--app-font-family, Inter, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif)"
|
||
}};
|
||
for (const [key, value] of Object.entries(pairs)) {{
|
||
if (value && root.style.getPropertyValue(key) !== value) root.style.setProperty(key, value);
|
||
}}
|
||
if (root.style.colorScheme !== mode) root.style.colorScheme = mode;
|
||
try {{
|
||
for (const key of ["theme-mode", "tdesign-starter-theme-mode", "weknora-theme-mode"]) {{
|
||
if (localStorage.getItem(key) !== mode) localStorage.setItem(key, mode);
|
||
}}
|
||
}} catch (_) {{}}
|
||
// Ignore our own root writes, but keep watching for WeKnora overwriting
|
||
// them later. Content mutations must never schedule another theme write.
|
||
themeObserver?.takeRecords();
|
||
}}
|
||
window.addEventListener("message", (event) => applyTheme(event.data));
|
||
const urlTheme = new URLSearchParams(location.search).get("theme");
|
||
if (urlTheme === "dark" || urlTheme === "light") {{
|
||
applyTheme({{
|
||
type: "deerflow-weknora-theme",
|
||
theme: urlTheme,
|
||
vars: {{
|
||
primary: urlTheme === "dark" ? "#2b81ff" : "#0062d9",
|
||
background: urlTheme === "dark" ? "#0e172b" : "#ffffff",
|
||
foreground: urlTheme === "dark" ? "#eff2f5" : "#202a3a",
|
||
card: urlTheme === "dark" ? "#1a2539" : "#ffffff",
|
||
muted: urlTheme === "dark" ? "#232e40" : "#f3f5f8",
|
||
mutedForeground: urlTheme === "dark" ? "#8e9aa4" : "#7f7f7f",
|
||
border: urlTheme === "dark" ? "rgba(255,255,255,0.12)" : "#dbdbdb",
|
||
destructive: "#d54941"
|
||
}}
|
||
}});
|
||
}}
|
||
|
||
const launchParams = new URLSearchParams(location.search);
|
||
const requestedWikiSlug = launchParams.get("wikiSlug") || launchParams.get("slug") || "";
|
||
const requestedDocumentId =
|
||
launchParams.get("documentId") ||
|
||
launchParams.get("knowledgeId") ||
|
||
launchParams.get("knowledge_id") ||
|
||
"";
|
||
const sourceDetailMode = Boolean(requestedDocumentId || requestedWikiSlug);
|
||
if (sourceDetailMode) {{
|
||
root.classList.add("deerflow-source-detail-request");
|
||
}}
|
||
let requestedWikiOpened = false;
|
||
let requestedDocumentOpened = false;
|
||
let sourceDetailAttempts = 0;
|
||
const maxSourceDetailAttempts = 18;
|
||
function escapeAttr(value) {{
|
||
if (window.CSS && typeof window.CSS.escape === "function") return window.CSS.escape(value);
|
||
return String(value).replace(/["\\\\]/g, "\\\\$&");
|
||
}}
|
||
function openRequestedWikiSlug() {{
|
||
if (!requestedWikiSlug || requestedWikiOpened || root.classList.contains("deerflow-source-detail-open")) return;
|
||
const slug = escapeAttr(requestedWikiSlug);
|
||
const link =
|
||
document.querySelector(`[data-slug="${{slug}}"]`) ||
|
||
document.querySelector(`a[href*="${{encodeURIComponent(requestedWikiSlug)}}"]`) ||
|
||
document.querySelector(`a[href*="${{requestedWikiSlug.replace(/"/g, "%22")}}"]`);
|
||
if (!link || typeof link.click !== "function") return;
|
||
requestedWikiOpened = true;
|
||
link.click();
|
||
}}
|
||
function openRequestedDocumentId() {{
|
||
if (!requestedDocumentId || requestedDocumentOpened || root.classList.contains("deerflow-source-detail-open")) return;
|
||
const id = escapeAttr(requestedDocumentId);
|
||
const encoded = encodeURIComponent(requestedDocumentId);
|
||
const candidates = [
|
||
`[data-id="${{id}}"]`,
|
||
`[data-row-key="${{id}}"]`,
|
||
`[data-knowledge-id="${{id}}"]`,
|
||
`[data-document-id="${{id}}"]`,
|
||
`a[href*="${{encoded}}"]`,
|
||
`button[href*="${{encoded}}"]`
|
||
];
|
||
let node = null;
|
||
for (const selector of candidates) {{
|
||
node = document.querySelector(selector);
|
||
if (node) break;
|
||
}}
|
||
if (!node) {{
|
||
node = Array.from(document.querySelectorAll("[data-id],[data-row-key],[data-knowledge-id],[data-document-id]"))
|
||
.find((item) => {{
|
||
const values = [
|
||
item.getAttribute("data-id"),
|
||
item.getAttribute("data-row-key"),
|
||
item.getAttribute("data-knowledge-id"),
|
||
item.getAttribute("data-document-id")
|
||
];
|
||
return values.includes(requestedDocumentId);
|
||
}}) || null;
|
||
}}
|
||
if (!node) return;
|
||
const clickable = node.closest("button,a,[role='button'],.t-card,.document-card,.knowledge-card,.file-card,.t-list-item,.t-table__row") || node;
|
||
if (!clickable || typeof clickable.click !== "function") return;
|
||
requestedDocumentOpened = true;
|
||
clickable.click();
|
||
}}
|
||
function sourceDetailCandidateScore(node) {{
|
||
if (!node || node === document.body || node === document.documentElement) return 0;
|
||
const rect = node.getBoundingClientRect();
|
||
if (rect.width < 360 || rect.height < 260) return 0;
|
||
const text = (node.textContent || "").slice(0, 1200);
|
||
const className = String(node.className || "");
|
||
let score = 0;
|
||
if (/基本信息|上传时间|创建时间|摘要|文档内容|查看分块|预览|引用内容|Wiki 原文|全文/.test(text)) score += 4;
|
||
if (/drawer|Drawer|dialog|Dialog|modal|Modal|detail|Detail|preview|Preview/.test(className)) score += 3;
|
||
if (rect.right > window.innerWidth * 0.65 && rect.width >= window.innerWidth * 0.38) score += 2;
|
||
if (getComputedStyle(node).position === "fixed") score += 2;
|
||
return score;
|
||
}}
|
||
function findSourceDetailPanel() {{
|
||
const selectors = [
|
||
".t-drawer",
|
||
".t-drawer__content",
|
||
".t-dialog",
|
||
".t-dialog__ctx",
|
||
".document-detail",
|
||
".knowledge-detail",
|
||
".document-preview",
|
||
"[class*='Drawer']",
|
||
"[class*='drawer']",
|
||
"[class*='Dialog']",
|
||
"[class*='dialog']",
|
||
"[class*='Detail']",
|
||
"[class*='detail']",
|
||
"[class*='Preview']",
|
||
"[class*='preview']"
|
||
].join(",");
|
||
let best = null;
|
||
let bestScore = 0;
|
||
document.querySelectorAll(selectors).forEach((node) => {{
|
||
const score = sourceDetailCandidateScore(node);
|
||
if (score > bestScore) {{
|
||
best = node;
|
||
bestScore = score;
|
||
}}
|
||
}});
|
||
return bestScore >= 5 ? best : null;
|
||
}}
|
||
function isolateSourceDetailPanel() {{
|
||
if (!sourceDetailMode) return;
|
||
if (root.classList.contains("deerflow-source-detail-open")) return;
|
||
const panel = findSourceDetailPanel();
|
||
if (!panel) return;
|
||
panel.dataset.deerflowSourceDetailPanel = "true";
|
||
document.querySelectorAll("[data-deerflow-source-detail-hidden='true']").forEach((node) => {{
|
||
if (node.contains(panel) || panel.contains(node)) return;
|
||
delete node.dataset.deerflowSourceDetailHidden;
|
||
}});
|
||
Array.from(document.body.children).forEach((child) => {{
|
||
if (child === panel || child.contains(panel)) return;
|
||
child.dataset.deerflowSourceDetailHidden = "true";
|
||
}});
|
||
root.classList.add("deerflow-source-detail-open");
|
||
}}
|
||
function tickSourceDetail() {{
|
||
if (!sourceDetailMode || root.classList.contains("deerflow-source-detail-open")) return true;
|
||
sourceDetailAttempts += 1;
|
||
openRequestedDocumentId();
|
||
openRequestedWikiSlug();
|
||
isolateSourceDetailPanel();
|
||
if (root.classList.contains("deerflow-source-detail-open")) return true;
|
||
if (sourceDetailAttempts >= maxSourceDetailAttempts) {{
|
||
root.classList.add("deerflow-source-detail-open");
|
||
return true;
|
||
}}
|
||
return false;
|
||
}}
|
||
|
||
const allowedPath = cfg.allowedPath;
|
||
const allowedWeKnoraIds = new Set(
|
||
[cfg.weknoraId, ...(Array.isArray(cfg.allowedWeKnoraIds) ? cfg.allowedWeKnoraIds : [])]
|
||
.filter(Boolean)
|
||
);
|
||
const knowledgeBasePathPrefix = (cfg.publicPrefix || "") + "/platform/knowledge-bases/";
|
||
function isAllowedKnowledgeBasePath(pathname) {{
|
||
if (!pathname.startsWith(knowledgeBasePathPrefix)) return false;
|
||
const encodedId = pathname.slice(knowledgeBasePathPrefix.length);
|
||
if (!encodedId || encodedId.includes("/")) return false;
|
||
try {{ return allowedWeKnoraIds.has(decodeURIComponent(encodedId)); }} catch (_) {{ return false; }}
|
||
}}
|
||
function allowedUrl(url) {{
|
||
try {{
|
||
const next = new URL(url, location.origin);
|
||
if (next.origin !== location.origin) return null;
|
||
if (next.pathname === allowedPath || isAllowedKnowledgeBasePath(next.pathname)) {{
|
||
return next.pathname + next.search + next.hash;
|
||
}}
|
||
const query = new URLSearchParams(location.search);
|
||
if (cfg.tab && !query.has("tab")) query.set("tab", cfg.tab);
|
||
return allowedPath + (query.toString() ? "?" + query.toString() : "") + location.hash;
|
||
}} catch (_) {{
|
||
return allowedPath + location.search + location.hash;
|
||
}}
|
||
}}
|
||
function enforceLocation() {{
|
||
if (location.pathname !== allowedPath && !isAllowedKnowledgeBasePath(location.pathname)) {{
|
||
history.replaceState(history.state, "", allowedPath + location.search + location.hash);
|
||
}}
|
||
}}
|
||
function reloadForKnowledgeBaseSwitch(target, replace) {{
|
||
try {{
|
||
const next = new URL(target, location.origin);
|
||
if (next.pathname === location.pathname || !isAllowedKnowledgeBasePath(next.pathname)) return false;
|
||
if (replace) location.replace(next.href);
|
||
else location.assign(next.href);
|
||
return true;
|
||
}} catch (_) {{
|
||
return false;
|
||
}}
|
||
}}
|
||
function scopeNodes(scope, selector) {{
|
||
return [
|
||
...(scope?.matches?.(selector) ? [scope] : []),
|
||
...Array.from(scope?.querySelectorAll?.(selector) || [])
|
||
];
|
||
}}
|
||
const guideSelector = ".t-guide,.t-guide__wrapper,.t-guide__reference,.contextual-guide,.guide__backdrop,[class^='guide__'],[class*=' guide__'],[data-guide-highlight]";
|
||
function purgeGuides(scope = document) {{
|
||
scopeNodes(scope, guideSelector).forEach((node) => {{
|
||
try {{ node.remove(); }} catch (_) {{}}
|
||
}});
|
||
}}
|
||
let themeRefreshTimer = 0;
|
||
function refreshThemeSoon() {{
|
||
if (!window.__deerflowWeKnoraTheme || themeRefreshTimer) return;
|
||
themeRefreshTimer = window.setTimeout(() => {{
|
||
themeRefreshTimer = 0;
|
||
applyTheme(window.__deerflowWeKnoraTheme);
|
||
}}, 80);
|
||
}}
|
||
themeObserver = new MutationObserver(refreshThemeSoon);
|
||
themeObserver.observe(root, {{ attributes: true, attributeFilter: ["class", "style", "data-theme", "data-theme-mode", "theme-mode"] }});
|
||
const shellSidebarSelector = [
|
||
".main > .aside_box",
|
||
"aside",
|
||
".sidebar",
|
||
".side-bar",
|
||
".side-menu",
|
||
".nav-sidebar",
|
||
".navigation-sidebar",
|
||
".menu-sidebar",
|
||
".t-layout__sider",
|
||
".t-layout-sider",
|
||
".layout-sider",
|
||
".layout-sidebar",
|
||
".platform-sidebar",
|
||
".platform-sider",
|
||
".app-sidebar",
|
||
".app-sider",
|
||
"[class*='Sidebar']",
|
||
"[class*='sidebar']",
|
||
"[class*='SideBar']",
|
||
"[class*='sideBar']",
|
||
"[class*='Sider']",
|
||
"[class*='sider']"
|
||
].join(",");
|
||
function isInsideKnowledgeDetail(node) {{
|
||
return Boolean(
|
||
node.closest(
|
||
".knowledge-layout,.document-layout,.document-detail,.wiki-layout,.wiki-page,.graph-layout,.graph-page,[class*='knowledge-detail'],[class*='wiki-detail']"
|
||
)
|
||
);
|
||
}}
|
||
function isLikelyOuterSidebar(node) {{
|
||
if (!node || node === document.body || node === document.documentElement) return false;
|
||
if (isInsideKnowledgeDetail(node)) return false;
|
||
const rect = node.getBoundingClientRect();
|
||
if (!rect.width || !rect.height) return false;
|
||
const style = getComputedStyle(node);
|
||
const className = String(node.className || "").toLowerCase();
|
||
const namedLikeShell =
|
||
/sidebar|side-bar|side_menu|side-menu|sider|layout-sider|platform-sider|platform-sidebar|app-sider|app-sidebar|nav-sidebar|menu-sidebar/.test(
|
||
className
|
||
);
|
||
const nearLeft = rect.left <= 4;
|
||
const sidebarWidth = rect.width >= 40 && rect.width <= Math.min(360, Math.max(120, window.innerWidth * 0.42));
|
||
const tallEnough = rect.height >= Math.max(240, window.innerHeight * 0.45);
|
||
const topShellChild =
|
||
node.parentElement === document.body ||
|
||
node.parentElement === document.getElementById("app") ||
|
||
node.parentElement?.parentElement === document.getElementById("app") ||
|
||
node.closest("#app") === node.parentElement?.closest("#app");
|
||
const positionedShell = style.position === "fixed" || style.position === "sticky";
|
||
const text = (node.textContent || "").slice(0, 400);
|
||
const containsAppNav = /知识库|工作台|空间|租户|模型|智能体|设置|Knowledge|Workspace|Agent|Model|Tenant/.test(text);
|
||
return namedLikeShell && nearLeft && sidebarWidth && tallEnough && (topShellChild || positionedShell || containsAppNav);
|
||
}}
|
||
const shellContentSelector = "#app,.t-layout,.t-layout__content,.layout-content,.platform-content,.platform-route-outlet,.main";
|
||
function hideWeKnoraShell(scope = document) {{
|
||
try {{
|
||
scopeNodes(scope, shellSidebarSelector).forEach((node) => {{
|
||
if (isLikelyOuterSidebar(node)) {{
|
||
if (node.dataset.deerflowWeknoraChrome !== "hidden") node.dataset.deerflowWeknoraChrome = "hidden";
|
||
if (node.getAttribute("aria-hidden") !== "true") node.setAttribute("aria-hidden", "true");
|
||
}}
|
||
}});
|
||
scopeNodes(scope, shellContentSelector).forEach((node) => {{
|
||
if (!isInsideKnowledgeDetail(node) && node.dataset.deerflowWeknoraContent !== "true") {{
|
||
node.dataset.deerflowWeknoraContent = "true";
|
||
}}
|
||
}});
|
||
}} catch (_) {{}}
|
||
}}
|
||
const rawPushState = history.pushState.bind(history);
|
||
const rawReplaceState = history.replaceState.bind(history);
|
||
history.pushState = (state, title, url) => {{
|
||
const target = url ? allowedUrl(url) : location.href;
|
||
if (url && target && reloadForKnowledgeBaseSwitch(target, false)) return;
|
||
return rawPushState(state, title, target);
|
||
}};
|
||
history.replaceState = (state, title, url) => {{
|
||
const target = url ? allowedUrl(url) : location.href;
|
||
if (url && target && reloadForKnowledgeBaseSwitch(target, true)) return;
|
||
return rawReplaceState(state, title, target);
|
||
}};
|
||
window.addEventListener("popstate", enforceLocation);
|
||
document.addEventListener("click", (event) => {{
|
||
const anchor = event.target && event.target.closest ? event.target.closest("a[href]") : null;
|
||
if (!anchor) return;
|
||
const target = allowedUrl(anchor.href);
|
||
if (target && new URL(anchor.href, location.origin).pathname !== allowedPath) {{
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
if (reloadForKnowledgeBaseSwitch(target, false)) return;
|
||
history.replaceState(history.state, "", target);
|
||
}}
|
||
}}, true);
|
||
let ready = false;
|
||
let readyFallbackTimer = 0;
|
||
let sourceDetailTimer = 0;
|
||
function hasDetailShell() {{
|
||
return Boolean(document.querySelector(".knowledge-layout") || document.querySelector(".document-header"));
|
||
}}
|
||
function markReady(force = false) {{
|
||
if (ready) return;
|
||
if (!force && (!isAllowedKnowledgeBasePath(location.pathname) && location.pathname !== allowedPath || !hasDetailShell())) return;
|
||
ready = true;
|
||
root.classList.add("deerflow-weknora-ready");
|
||
}}
|
||
function finishSourceDetail() {{
|
||
if (sourceDetailTimer) clearInterval(sourceDetailTimer);
|
||
sourceDetailTimer = 0;
|
||
}}
|
||
function refreshUi(scopes) {{
|
||
enforceLocation();
|
||
for (const scope of scopes) {{
|
||
purgeGuides(scope);
|
||
hideWeKnoraShell(scope);
|
||
}}
|
||
if (sourceDetailMode && tickSourceDetail()) finishSourceDetail();
|
||
markReady(false);
|
||
if (ready && (!sourceDetailMode || root.classList.contains("deerflow-source-detail-open"))) {{
|
||
clearTimeout(readyFallbackTimer);
|
||
readyFallbackTimer = 0;
|
||
}}
|
||
}}
|
||
let observerTimer = 0;
|
||
const pendingScopes = new Set();
|
||
function scheduleRefresh(scope) {{
|
||
for (const pending of pendingScopes) {{
|
||
if (pending === scope || pending.contains?.(scope)) return;
|
||
if (scope.contains?.(pending)) pendingScopes.delete(pending);
|
||
}}
|
||
pendingScopes.add(scope);
|
||
if (observerTimer) return;
|
||
observerTimer = window.setTimeout(() => {{
|
||
observerTimer = 0;
|
||
const scopes = Array.from(pendingScopes);
|
||
pendingScopes.clear();
|
||
refreshUi(scopes);
|
||
}}, 120);
|
||
}}
|
||
const readyObserver = new MutationObserver((mutations) => {{
|
||
for (const mutation of mutations) {{
|
||
if (mutation.type === "childList") {{
|
||
for (const node of mutation.addedNodes) {{
|
||
if (node.nodeType === Node.ELEMENT_NODE) scheduleRefresh(node);
|
||
}}
|
||
}} else if (mutation.target !== root && mutation.target.matches?.(
|
||
shellSidebarSelector + "," + shellContentSelector + "," + guideSelector + ",.knowledge-layout,.document-header"
|
||
)) {{
|
||
scheduleRefresh(mutation.target);
|
||
}}
|
||
}}
|
||
}});
|
||
// Root theme repair is observed separately. Scanning only inserted branches
|
||
// avoids re-walking the whole application on every animation/style update.
|
||
readyObserver.observe(root, {{ childList: true, subtree: true, attributes: true, attributeFilter: ["class"] }});
|
||
readyFallbackTimer = setTimeout(() => {{
|
||
readyFallbackTimer = 0;
|
||
markReady(true);
|
||
if (sourceDetailMode && !root.classList.contains("deerflow-source-detail-open")) {{
|
||
root.classList.add("deerflow-source-detail-open");
|
||
}}
|
||
finishSourceDetail();
|
||
}}, 9000);
|
||
// Only source deep links need bounded retries while their drawer mounts.
|
||
// Ordinary embedded pages have no recurring DOM-maintenance timer.
|
||
if (sourceDetailMode) {{
|
||
sourceDetailTimer = setInterval(() => refreshUi([]), 500);
|
||
}}
|
||
refreshUi([document]);
|
||
if (document.readyState === "complete") {{
|
||
scheduleRefresh(document);
|
||
}} else {{
|
||
window.addEventListener("load", () => scheduleRefresh(document), {{ once: true }});
|
||
}}
|
||
window.addEventListener("resize", () => scheduleRefresh(document));
|
||
}})();
|
||
</script>
|
||
"""
|
||
|
||
|
||
def _inject_weknora_embed_html(
|
||
html: bytes,
|
||
*,
|
||
allowed_path: str,
|
||
tab: str,
|
||
session: dict[str, Any],
|
||
ctx: WeKnoraEmbedContext,
|
||
public_prefix: str = "",
|
||
) -> bytes:
|
||
text = html.decode("utf-8", errors="replace")
|
||
text = _rewrite_weknora_html_public_paths(text, public_prefix)
|
||
injection = _weknora_embed_injection(
|
||
allowed_path=allowed_path,
|
||
tab=tab,
|
||
session=session,
|
||
ctx=ctx,
|
||
public_prefix=public_prefix,
|
||
)
|
||
if "</head>" in text:
|
||
text = text.replace("</head>", f"{injection}</head>", 1)
|
||
else:
|
||
text = f"{injection}{text}"
|
||
return text.encode("utf-8")
|
||
|
||
|
||
async def _proxy_weknora_request(
|
||
request: Request,
|
||
*,
|
||
target_base_url: str,
|
||
upstream_path: str,
|
||
ctx: WeKnoraEmbedContext,
|
||
is_api: bool,
|
||
tab: str = "wiki",
|
||
) -> Response:
|
||
runtime = _runtime_for_iframe_proxy(request)
|
||
session = await _get_weknora_admin_session(runtime)
|
||
token = str(session.get("token") or "")
|
||
body = await request.body()
|
||
if is_api:
|
||
await _authorize_weknora_api_proxy(request, ctx, runtime, upstream_path, body, token)
|
||
|
||
headers = _forward_request_headers(request)
|
||
if is_api:
|
||
headers["Authorization"] = f"Bearer {token}"
|
||
tenant_id = _tenant_id_from_session(session)
|
||
if tenant_id:
|
||
headers["X-Tenant-ID"] = tenant_id
|
||
|
||
url = f"{target_base_url.rstrip('/')}{_encode_upstream_path(upstream_path)}"
|
||
follow_binary_redirects = is_api and _should_follow_weknora_binary_redirect(upstream_path)
|
||
try:
|
||
async with _proxy_http_client(request) as client:
|
||
response = await client.request(
|
||
request.method,
|
||
url,
|
||
params=request.query_params.multi_items(),
|
||
headers=headers,
|
||
content=body,
|
||
follow_redirects=follow_binary_redirects,
|
||
)
|
||
if is_api and response.status_code in {401, 403}:
|
||
session = await _get_weknora_admin_session(runtime, force_refresh=True)
|
||
headers["Authorization"] = f"Bearer {session.get('token')}"
|
||
tenant_id = _tenant_id_from_session(session)
|
||
if tenant_id:
|
||
headers["X-Tenant-ID"] = tenant_id
|
||
async with _proxy_http_client(request) as client:
|
||
response = await client.request(
|
||
request.method,
|
||
url,
|
||
params=request.query_params.multi_items(),
|
||
headers=headers,
|
||
content=body,
|
||
follow_redirects=follow_binary_redirects,
|
||
)
|
||
except httpx.TimeoutException as exc:
|
||
raise HTTPException(status_code=504, detail="WeKnora proxy request timed out") from exc
|
||
except httpx.HTTPError as exc:
|
||
logger.warning("WeKnora proxy request failed: url=%s error=%r", url, exc)
|
||
raise HTTPException(status_code=502, detail="WeKnora proxy request failed") from exc
|
||
|
||
content = response.content
|
||
content_type = response.headers.get("content-type", "")
|
||
public_prefix = _request_public_prefix(request)
|
||
if is_api and request.method.upper() == "GET" and upstream_path.rstrip("/") == "/api/v1/knowledge-bases":
|
||
content = _filter_knowledge_base_list_response(response, ctx) or content
|
||
if is_api and ctx.is_conversation_deposit and "application/json" in content_type.lower():
|
||
try:
|
||
payload = json.loads(content)
|
||
except (TypeError, ValueError, json.JSONDecodeError):
|
||
pass
|
||
else:
|
||
content = json.dumps(
|
||
_sanitize_conversation_deposit_value(payload),
|
||
ensure_ascii=False,
|
||
separators=(",", ":"),
|
||
).encode("utf-8")
|
||
if not is_api and "text/html" in content_type.lower():
|
||
allowed_path = _with_public_prefix(
|
||
public_prefix,
|
||
f"/platform/knowledge-bases/{quote(ctx.weknora_id, safe='')}",
|
||
)
|
||
content = _inject_weknora_embed_html(
|
||
content,
|
||
allowed_path=allowed_path,
|
||
tab=tab,
|
||
session=session,
|
||
ctx=ctx,
|
||
public_prefix=public_prefix,
|
||
)
|
||
content_type = "text/html; charset=utf-8"
|
||
elif not is_api and _should_rewrite_weknora_static_response(content_type, upstream_path):
|
||
text = content.decode("utf-8", errors="replace")
|
||
lower_type = content_type.lower()
|
||
if "javascript" in lower_type or upstream_path.lower().endswith((".js", ".mjs")):
|
||
text = _rewrite_weknora_script_public_paths(text, public_prefix)
|
||
else:
|
||
text = _rewrite_weknora_html_public_paths(
|
||
text,
|
||
public_prefix,
|
||
rewrite_relative=False,
|
||
)
|
||
content = text.encode("utf-8")
|
||
|
||
headers_out = _safe_response_headers(response.headers)
|
||
if "location" in headers_out:
|
||
headers_out["location"] = _rewrite_weknora_location_header(
|
||
headers_out["location"],
|
||
public_prefix,
|
||
target_base_url,
|
||
)
|
||
if "Location" in headers_out:
|
||
headers_out["Location"] = _rewrite_weknora_location_header(
|
||
headers_out["Location"],
|
||
public_prefix,
|
||
target_base_url,
|
||
)
|
||
if not is_api:
|
||
headers_out.pop("etag", None)
|
||
headers_out.pop("ETag", None)
|
||
headers_out["Cache-Control"] = "no-store" if is_api or "text/html" in content_type.lower() else "private, max-age=3600"
|
||
if (
|
||
is_api
|
||
and response.status_code < 400
|
||
and _is_weknora_content_mutation(request.method, upstream_path)
|
||
):
|
||
await _schedule_proxy_local_wiki_sync(request, ctx)
|
||
return Response(
|
||
content=content,
|
||
status_code=response.status_code,
|
||
media_type=content_type.split(";", 1)[0] if content_type else None,
|
||
headers=headers_out,
|
||
)
|
||
|
||
|
||
def _is_conversation_deposit_mapping(row: dict[str, Any]) -> bool:
|
||
return str(row.get("owner_user_id") or "") == CONVERSATION_DEPOSIT_OWNER_USER_ID and str(row.get("name") or "").strip() == CONVERSATION_DEPOSIT_KB_NAME
|
||
|
||
|
||
_LEGACY_BRAND_PATTERN = re.compile(r"deer[\s_-]*flow", re.IGNORECASE)
|
||
|
||
|
||
def _sanitize_conversation_deposit_value(value: Any) -> Any:
|
||
if isinstance(value, str):
|
||
return _LEGACY_BRAND_PATTERN.sub("cmzs", value)
|
||
if isinstance(value, dict):
|
||
return {key: _sanitize_conversation_deposit_value(item) for key, item in value.items()}
|
||
if isinstance(value, list):
|
||
return [_sanitize_conversation_deposit_value(item) for item in value]
|
||
if isinstance(value, tuple):
|
||
return tuple(_sanitize_conversation_deposit_value(item) for item in value)
|
||
return value
|
||
|
||
|
||
async def _sync_conversation_deposit_view(
|
||
store: LlmWikiStore,
|
||
client: WeKnoraClient,
|
||
row: dict[str, Any],
|
||
remote: dict[str, Any] | None,
|
||
) -> tuple[dict[str, Any], dict[str, Any] | None]:
|
||
if not _is_conversation_deposit_mapping(row):
|
||
return row, remote
|
||
|
||
current_remote = remote
|
||
if remote is not None and str(remote.get("description") or "") != CONVERSATION_DEPOSIT_KB_DESCRIPTION:
|
||
try:
|
||
updated_remote = await client.update_knowledge_base(
|
||
str(row["weknora_id"]),
|
||
{"description": CONVERSATION_DEPOSIT_KB_DESCRIPTION},
|
||
)
|
||
current_remote = {
|
||
**remote,
|
||
**updated_remote,
|
||
"description": CONVERSATION_DEPOSIT_KB_DESCRIPTION,
|
||
}
|
||
except WeKnoraError:
|
||
logger.warning("Failed to synchronize the cmzs conversation-deposit description", exc_info=True)
|
||
|
||
current_row = row
|
||
if str(row.get("description") or "") != CONVERSATION_DEPOSIT_KB_DESCRIPTION:
|
||
updated_row = await store.update_mapping(
|
||
str(row["id"]),
|
||
description=CONVERSATION_DEPOSIT_KB_DESCRIPTION,
|
||
)
|
||
current_row = updated_row or row
|
||
return current_row, current_remote
|
||
|
||
|
||
def _persisted_directory_paths(row: dict[str, Any]) -> list[list[str]]:
|
||
value = row.get("wiki_directory_paths")
|
||
if value is not None:
|
||
return normalize_directory_paths(value)
|
||
return paths_from_parent_directories(row.get("wiki_parent_directories"))
|
||
|
||
|
||
def _raw_wiki_text(
|
||
row: dict[str, Any],
|
||
remote_wiki_config: dict[str, Any],
|
||
persisted_key: str,
|
||
remote_key: str,
|
||
) -> str:
|
||
persisted = row.get(persisted_key)
|
||
if persisted is not None:
|
||
return str(persisted)
|
||
return str(remote_wiki_config.get(remote_key) or "").strip()
|
||
|
||
|
||
def _mapping_view(row: dict[str, Any], remote: dict[str, Any] | None, *, actor_user_id: str, is_admin: bool) -> dict[str, Any]:
|
||
source = remote or {}
|
||
is_conversation_deposit = _is_conversation_deposit_mapping(row)
|
||
is_owner = row.get("owner_user_id") == actor_user_id
|
||
description = CONVERSATION_DEPOSIT_KB_DESCRIPTION if is_conversation_deposit else source.get("description") if source.get("description") is not None else row.get("description") or ""
|
||
display_name = str(source.get("name") or row.get("name") or "")
|
||
profile_row = {**row, "name": display_name}
|
||
remote_wiki_config = (
|
||
dict(source.get("wiki_config"))
|
||
if isinstance(source.get("wiki_config"), dict)
|
||
else {}
|
||
)
|
||
directory_paths = _persisted_directory_paths(row)
|
||
parent_directories = directory_roots(directory_paths)
|
||
raw_content = _raw_wiki_text(
|
||
row,
|
||
remote_wiki_config,
|
||
"wiki_content_instructions_raw",
|
||
"content_instructions",
|
||
)
|
||
raw_extraction = _raw_wiki_text(
|
||
row,
|
||
remote_wiki_config,
|
||
"wiki_extraction_instructions_raw",
|
||
"extraction_instructions",
|
||
)
|
||
focus = str(row.get("wiki_focus_instructions") or "")
|
||
try:
|
||
compiled = compile_wiki_instructions(
|
||
content_instructions=raw_content,
|
||
extraction_instructions=raw_extraction,
|
||
focus_instructions=focus,
|
||
directory_paths=directory_paths,
|
||
)
|
||
except ValueError:
|
||
# Legacy rows may contain upstream values above today's supported
|
||
# limit. They remain readable while the next edit receives validation.
|
||
compiled = None
|
||
|
||
managed = row.get("wiki_generation_managed")
|
||
if managed is None:
|
||
strategy = source.get("indexing_strategy")
|
||
if isinstance(strategy, dict) and isinstance(
|
||
strategy.get("wiki_enabled"),
|
||
bool,
|
||
):
|
||
managed = bool(strategy["wiki_enabled"])
|
||
revision = row.get("wiki_config_revision")
|
||
apply_status = row.get("wiki_config_apply_status")
|
||
if not apply_status:
|
||
apply_status = (
|
||
"active"
|
||
if managed is True
|
||
else "not_applicable"
|
||
if managed is False
|
||
else "error"
|
||
)
|
||
wiki_config = dict(remote_wiki_config)
|
||
wiki_config.update({
|
||
"content_instructions": raw_content,
|
||
"extraction_instructions": raw_extraction,
|
||
"parent_directories": parent_directories,
|
||
"directory_paths": directory_paths,
|
||
"focus_instructions": focus,
|
||
"wiki_config_schema_version": row.get("wiki_config_schema_version"),
|
||
"wiki_config_revision": revision,
|
||
"applied_revision": revision if apply_status == "active" else None,
|
||
"applied_result_id": None,
|
||
"apply_status": apply_status,
|
||
"apply_error": row.get("wiki_config_apply_error"),
|
||
"directory_prompt_coverage": (
|
||
compiled.directory_prompt_coverage if compiled else "none"
|
||
),
|
||
"directory_prompt_covered_nodes": (
|
||
compiled.directory_prompt_covered_nodes if compiled else 0
|
||
),
|
||
"directory_prompt_total_nodes": (
|
||
compiled.directory_prompt_total_nodes
|
||
if compiled
|
||
else len(directory_paths)
|
||
),
|
||
})
|
||
return {
|
||
"id": row["id"],
|
||
"remote_status": "cached" if source.get("_local_cache_only") else "available" if remote is not None else "missing",
|
||
"name": display_name,
|
||
"description": description,
|
||
"test_questions": resolved_test_questions(profile_row),
|
||
"test_questions_customized": are_test_questions_customized(profile_row),
|
||
"retrieval_settings": resolved_retrieval_settings(row),
|
||
"type": source.get("type") or row.get("kb_type") or "document",
|
||
"publication_status": "published" if is_conversation_deposit else row.get("publication_status") or "private",
|
||
"wiki_generation_managed": managed,
|
||
"wiki_index_enabled": row.get("wiki_index_enabled", True),
|
||
"external_search_enabled": False if is_conversation_deposit else row.get("external_search_enabled", False),
|
||
"external_search_updated_at": row.get("external_search_updated_at"),
|
||
"owner_user_id": row.get("owner_user_id") if is_admin else None,
|
||
"is_owner": is_owner,
|
||
"can_write": not is_conversation_deposit and (is_admin or is_owner),
|
||
"is_conversation_deposit": is_conversation_deposit,
|
||
"knowledge_count": int(source.get("knowledge_count") or 0),
|
||
"chunk_count": int(source.get("chunk_count") or 0),
|
||
"processing_count": int(source.get("processing_count") or 0),
|
||
"share_count": int(source.get("share_count") or 0),
|
||
"is_processing": bool(source.get("is_processing") or int(source.get("processing_count") or 0) > 0),
|
||
"is_temporary": bool(source.get("is_temporary", False)),
|
||
"embedding_model_id": source.get("embedding_model_id"),
|
||
"vector_store_id": source.get("vector_store_id"),
|
||
"vector_store_name": source.get("vector_store_name"),
|
||
"vector_store_source": source.get("vector_store_source"),
|
||
"vector_store_engine_type": source.get("vector_store_engine_type"),
|
||
"vector_store_status": source.get("vector_store_status"),
|
||
"chunking_config": source.get("chunking_config") if isinstance(source.get("chunking_config"), dict) else {},
|
||
"image_processing_config": (source.get("image_processing_config") if isinstance(source.get("image_processing_config"), dict) else {}),
|
||
"indexing_strategy": source.get("indexing_strategy") if isinstance(source.get("indexing_strategy"), dict) else {},
|
||
"capabilities": source.get("capabilities") if isinstance(source.get("capabilities"), dict) else {},
|
||
"extract_config": source.get("extract_config") if isinstance(source.get("extract_config"), dict) else {},
|
||
"faq_config": source.get("faq_config") if isinstance(source.get("faq_config"), dict) else {},
|
||
"wiki_config": wiki_config,
|
||
"question_generation_config": (source.get("question_generation_config") if isinstance(source.get("question_generation_config"), dict) else {}),
|
||
"auto_tag_config": source.get("auto_tag_config") if isinstance(source.get("auto_tag_config"), dict) else {},
|
||
"vlm_config": source.get("vlm_config") if isinstance(source.get("vlm_config"), dict) else {},
|
||
"asr_config": source.get("asr_config") if isinstance(source.get("asr_config"), dict) else {},
|
||
"summary_model_id": source.get("summary_model_id"),
|
||
"storage_provider": (source.get("storage_provider_config", {}).get("provider") if isinstance(source.get("storage_provider_config"), dict) else None),
|
||
"created_at": row.get("created_at"),
|
||
"updated_at": row.get("updated_at"),
|
||
"published_at": row.get("published_at"),
|
||
"remote_created_at": source.get("created_at"),
|
||
"remote_updated_at": source.get("updated_at"),
|
||
}
|
||
|
||
|
||
async def _authorized_mapping(
|
||
request: Request,
|
||
mapping_id: str,
|
||
*,
|
||
write: bool,
|
||
) -> tuple[dict[str, Any], str, bool]:
|
||
user_id, is_admin = await _actor(request)
|
||
row = await _store(request).get_authorized(mapping_id, user_id, write=write, is_admin=is_admin)
|
||
if row is None:
|
||
raise HTTPException(status_code=404, detail="Knowledge base not found")
|
||
if write and _is_conversation_deposit_mapping(row):
|
||
raise HTTPException(status_code=403, detail="Conversation deposit is managed by cmzs")
|
||
return row, user_id, is_admin
|
||
|
||
|
||
async def _public_mapping(request: Request, mapping_id: str) -> dict[str, Any]:
|
||
"""Resolve a world-readable knowledge base without inheriting actor/admin state."""
|
||
|
||
row = await _store(request).get_authorized(
|
||
mapping_id,
|
||
"__public_wiki__",
|
||
write=False,
|
||
is_admin=False,
|
||
)
|
||
if row is None or row.get("publication_status") != "published" or _is_conversation_deposit_mapping(row):
|
||
raise HTTPException(status_code=404, detail="Published knowledge base not found")
|
||
return row
|
||
|
||
|
||
def _validate_protected_wiki_file_path(file_path: str) -> str:
|
||
value = file_path.strip()
|
||
if not value or "\x00" in value or not _PROTECTED_WIKI_FILE_PATH.match(value):
|
||
raise HTTPException(status_code=422, detail="Unsupported Wiki image path")
|
||
return value
|
||
|
||
|
||
async def _wiki_file_response(row: dict[str, Any], file_path: str, *, public: bool) -> Response:
|
||
safe_file_path = _validate_protected_wiki_file_path(file_path)
|
||
try:
|
||
content, media_type = await _client_or_503().get_knowledge_base_file(
|
||
str(row["weknora_id"]),
|
||
safe_file_path,
|
||
)
|
||
except WeKnoraError as exc:
|
||
_raise_upstream(exc)
|
||
filename = unquote(safe_file_path.rstrip("/").rsplit("/", 1)[-1]).replace("\r", "").replace("\n", "") or "wiki-file"
|
||
if "." not in filename:
|
||
filename += mimetypes.guess_extension(str(media_type).split(";", 1)[0].strip()) or ""
|
||
return Response(
|
||
content=content,
|
||
media_type=media_type,
|
||
headers={
|
||
"Cache-Control": "public, max-age=300" if public else "private, max-age=3600",
|
||
"Content-Disposition": f"inline; filename*=UTF-8''{quote(filename, safe='')}",
|
||
"X-Content-Type-Options": "nosniff",
|
||
},
|
||
)
|
||
|
||
|
||
async def _verify_document(client: WeKnoraClient, row: dict[str, Any], document_id: str) -> dict[str, Any]:
|
||
document = await client.get_document(document_id)
|
||
if str(document.get("knowledge_base_id") or "") != str(row["weknora_id"]):
|
||
raise HTTPException(status_code=404, detail="Document not found")
|
||
return document
|
||
|
||
|
||
def _document_view(document: dict[str, Any]) -> dict[str, Any]:
|
||
"""Expose useful document state without leaking WeKnora storage paths or hashes."""
|
||
|
||
fields = (
|
||
"id",
|
||
"type",
|
||
"title",
|
||
"description",
|
||
"source",
|
||
"channel",
|
||
"parse_status",
|
||
"pending_subtasks_count",
|
||
"summary_status",
|
||
"enable_status",
|
||
"embedding_model_id",
|
||
"file_name",
|
||
"file_type",
|
||
"file_size",
|
||
"storage_size",
|
||
"created_at",
|
||
"updated_at",
|
||
"processed_at",
|
||
"error_message",
|
||
)
|
||
return {key: document.get(key) for key in fields}
|
||
|
||
|
||
def _related_chunk_ids(value: Any) -> list[str]:
|
||
if not isinstance(value, list):
|
||
return []
|
||
result: list[str] = []
|
||
for item in value:
|
||
candidate = item.get("id") if isinstance(item, dict) else item
|
||
if candidate and str(candidate) not in result:
|
||
result.append(str(candidate))
|
||
return result
|
||
|
||
|
||
def _chunk_view(chunk: dict[str, Any]) -> dict[str, Any]:
|
||
return {
|
||
"id": str(chunk.get("id") or ""),
|
||
"content": str(chunk.get("content") or ""),
|
||
"chunk_index": int(chunk.get("chunk_index") or 0),
|
||
"is_enabled": bool(chunk.get("is_enabled")),
|
||
"status": chunk.get("status"),
|
||
"start_at": int(chunk.get("start_at") or 0),
|
||
"end_at": int(chunk.get("end_at") or 0),
|
||
"chunk_type": str(chunk.get("chunk_type") or "text"),
|
||
"parent_chunk_id": str(chunk.get("parent_chunk_id") or ""),
|
||
"relation_chunk_ids": _related_chunk_ids(chunk.get("relation_chunks")),
|
||
"indirect_relation_chunk_ids": _related_chunk_ids(chunk.get("indirect_relation_chunks")),
|
||
"metadata": chunk.get("metadata") if isinstance(chunk.get("metadata"), dict) else {},
|
||
"created_at": chunk.get("created_at"),
|
||
"updated_at": chunk.get("updated_at"),
|
||
}
|
||
|
||
|
||
def _string_tokens(value: Any) -> list[str]:
|
||
tokens: list[str] = []
|
||
if isinstance(value, str):
|
||
raw = value.strip()
|
||
if raw:
|
||
tokens.append(raw)
|
||
for separator in ("#", ":", "/", "\\"):
|
||
if separator in raw:
|
||
tokens.extend(part.strip() for part in raw.split(separator) if part.strip())
|
||
elif isinstance(value, dict):
|
||
for key in ("id", "chunk_id", "knowledge_id", "document_id", "source_id", "ref_id", "slug"):
|
||
tokens.extend(_string_tokens(value.get(key)))
|
||
return list(dict.fromkeys(tokens))
|
||
|
||
|
||
def _source_ref_tokens(value: Any) -> set[str]:
|
||
refs: set[str] = set()
|
||
if isinstance(value, list):
|
||
for item in value:
|
||
refs.update(_string_tokens(item))
|
||
else:
|
||
refs.update(_string_tokens(value))
|
||
return refs
|
||
|
||
|
||
def _candidate_chunk_ref_tokens(chunk: dict[str, Any], chunk_id: str) -> set[str]:
|
||
refs = {chunk_id, str(chunk.get("id") or "")}
|
||
metadata = chunk.get("metadata") if isinstance(chunk.get("metadata"), dict) else {}
|
||
for source in (chunk, metadata):
|
||
for key in (
|
||
"knowledge_id",
|
||
"document_id",
|
||
"source_id",
|
||
"source_document_id",
|
||
"knowledge_uuid",
|
||
"file_id",
|
||
):
|
||
refs.update(_string_tokens(source.get(key)))
|
||
return {item for item in refs if item}
|
||
|
||
|
||
def _candidate_wiki_slugs(chunk: dict[str, Any]) -> list[str]:
|
||
metadata = chunk.get("metadata") if isinstance(chunk.get("metadata"), dict) else {}
|
||
candidates: list[str] = []
|
||
for source in (chunk, metadata):
|
||
for key in ("wiki_page_slug", "page_slug", "wiki_slug", "llmwiki_slug", "slug"):
|
||
candidates.extend(_string_tokens(source.get(key)))
|
||
nested_wiki = metadata.get("wiki")
|
||
if isinstance(nested_wiki, dict):
|
||
for key in ("slug", "page_slug"):
|
||
candidates.extend(_string_tokens(nested_wiki.get(key)))
|
||
return list(dict.fromkeys(candidates))
|
||
|
||
|
||
def _wiki_page_view(page: dict[str, Any]) -> dict[str, Any] | None:
|
||
slug = str(page.get("slug") or "").strip()
|
||
title = str(page.get("title") or slug or "").strip()
|
||
content = str(page.get("content") or "").strip()
|
||
summary = str(page.get("summary") or "").strip()
|
||
if not slug and not title and not content and not summary:
|
||
return None
|
||
return {
|
||
"page_metadata": page.get("page_metadata") if isinstance(page.get("page_metadata"), dict) else {},
|
||
"id": str(page.get("id") or ""),
|
||
"slug": slug,
|
||
"title": title,
|
||
"page_type": str(page.get("page_type") or "page"),
|
||
"status": str(page.get("status") or ""),
|
||
"summary": summary,
|
||
"content": content,
|
||
"aliases": list(page.get("aliases") or []) if isinstance(page.get("aliases"), list) else [],
|
||
"parent_slug": str(page.get("parent_slug") or page.get("parentSlug") or ""),
|
||
"category_path": list(page.get("category_path") or []) if isinstance(page.get("category_path"), list) else [],
|
||
"folder_id": str(page.get("folder_id") or page.get("folderId") or ""),
|
||
"wiki_path": str(page.get("wiki_path") or page.get("wikiPath") or ""),
|
||
"depth": int(page.get("depth") or 0),
|
||
"version": int(page.get("version") or 0),
|
||
"created_at": str(page.get("created_at") or ""),
|
||
"updated_at": str(page.get("updated_at") or ""),
|
||
"source_refs": list(page.get("source_refs") or []) if isinstance(page.get("source_refs"), list) else [],
|
||
"in_links": list(page.get("in_links") or []) if isinstance(page.get("in_links"), list) else [],
|
||
"out_links": list(page.get("out_links") or []) if isinstance(page.get("out_links"), list) else [],
|
||
}
|
||
|
||
|
||
async def _all_wiki_pages(client: WeKnoraClient, remote_id: str) -> list[dict[str, Any]]:
|
||
"""Read every Wiki page and its processed Markdown, never raw documents."""
|
||
|
||
summaries: dict[str, dict[str, Any]] = {}
|
||
page_number = 1
|
||
while True:
|
||
listing = await client.list_wiki_pages(remote_id, page=page_number, page_size=500)
|
||
rows = [item for item in listing.get("pages", []) if isinstance(item, dict)]
|
||
for item in rows:
|
||
slug = str(item.get("slug") or item.get("wiki_slug") or item.get("path") or "").strip("/")
|
||
if slug:
|
||
summaries[slug] = item
|
||
total_pages = max(1, int(listing.get("total_pages") or 1))
|
||
if page_number >= total_pages or not rows:
|
||
break
|
||
page_number += 1
|
||
|
||
pages: list[dict[str, Any]] = []
|
||
entries = list(summaries.items())
|
||
for start in range(0, len(entries), 20):
|
||
batch = entries[start : start + 20]
|
||
details = await asyncio.gather(*(client.get_wiki_page(remote_id, slug) for slug, _ in batch))
|
||
pages.extend({**summary, **detail, "slug": slug} for (slug, summary), detail in zip(batch, details, strict=True))
|
||
return pages
|
||
|
||
|
||
def _wiki_directory_parts(page: dict[str, Any], by_slug: dict[str, dict[str, Any]]) -> list[str]:
|
||
parent_slug = str(page.get("parent_slug") or "").strip("/")
|
||
if parent_slug:
|
||
ancestors: list[str] = []
|
||
visited: set[str] = set()
|
||
current = by_slug.get(parent_slug)
|
||
while current is not None:
|
||
slug = str(current.get("slug") or "").strip("/")
|
||
if not slug or slug in visited:
|
||
break
|
||
visited.add(slug)
|
||
ancestors.insert(0, str(current.get("title") or slug.rsplit("/", 1)[-1]))
|
||
current_parent = str(current.get("parent_slug") or "").strip("/")
|
||
current = by_slug.get(current_parent) if current_parent else None
|
||
if ancestors:
|
||
return ancestors
|
||
|
||
category_path = [str(item).strip() for item in page.get("category_path") or [] if str(item).strip()]
|
||
if category_path:
|
||
return category_path
|
||
wiki_path = [item.strip() for item in str(page.get("wiki_path") or "").split("/") if item.strip()]
|
||
if len(wiki_path) > 2:
|
||
return wiki_path[1:-1]
|
||
slug_parts = [item for item in str(page.get("slug") or "").split("/") if item]
|
||
return slug_parts[:-1]
|
||
|
||
|
||
def _safe_excel_text(value: Any) -> str:
|
||
text = str(value or "")
|
||
return f"'{text}" if text.startswith(("=", "+", "-", "@")) else text
|
||
|
||
|
||
def _build_wiki_excel(knowledge_base_name: str, raw_pages: list[dict[str, Any]]) -> BytesIO:
|
||
from openpyxl import Workbook
|
||
from openpyxl.styles import Alignment, Font, PatternFill
|
||
|
||
pages = [view for raw in raw_pages if (view := _wiki_page_view(raw)) is not None]
|
||
by_slug = {page["slug"]: page for page in pages if page["slug"]}
|
||
directories = [_wiki_directory_parts(page, by_slug) for page in pages]
|
||
max_depth = max((len(parts) for parts in directories), default=0)
|
||
max_content_parts = max((max(1, (len(str(page.get("content") or "")) + 31_999) // 32_000) for page in pages), default=1)
|
||
columns = [
|
||
"知识库",
|
||
"标题",
|
||
"Slug",
|
||
"目录完整路径",
|
||
"所在目录",
|
||
"父级目录完整路径",
|
||
*(f"第{level}级目录" for level in range(1, max_depth + 1)),
|
||
"摘要",
|
||
*("正文" if index == 1 else f"正文(续{index - 1})" for index in range(1, max_content_parts + 1)),
|
||
"页面类型",
|
||
"状态",
|
||
"别名",
|
||
"版本",
|
||
"创建时间",
|
||
"更新时间",
|
||
]
|
||
workbook = Workbook()
|
||
sheet = workbook.active
|
||
sheet.title = "Wiki 全量导出"
|
||
sheet.append(columns)
|
||
for cell in sheet[1]:
|
||
cell.font = Font(bold=True, color="FFFFFF")
|
||
cell.fill = PatternFill("solid", fgColor="1F4E78")
|
||
cell.alignment = Alignment(horizontal="center", vertical="center")
|
||
for page, directory in zip(pages, directories, strict=True):
|
||
content = str(page.get("content") or "")
|
||
content_parts = [content[index : index + 32_000] for index in range(0, len(content), 32_000)] or [""]
|
||
content_parts.extend([""] * (max_content_parts - len(content_parts)))
|
||
row = [
|
||
knowledge_base_name,
|
||
page.get("title"),
|
||
page.get("slug"),
|
||
" / ".join(directory),
|
||
directory[-1] if directory else "根目录",
|
||
" / ".join(directory[:-1]),
|
||
*directory,
|
||
*([""] * (max_depth - len(directory))),
|
||
page.get("summary"),
|
||
*content_parts,
|
||
page.get("page_type"),
|
||
page.get("status"),
|
||
"、".join(str(item) for item in page.get("aliases") or []),
|
||
page.get("version"),
|
||
page.get("created_at"),
|
||
page.get("updated_at"),
|
||
]
|
||
sheet.append([_safe_excel_text(value) for value in row])
|
||
sheet.freeze_panes = "A2"
|
||
sheet.auto_filter.ref = sheet.dimensions
|
||
for column in sheet.columns:
|
||
letter = column[0].column_letter
|
||
heading = str(column[0].value or "")
|
||
sheet.column_dimensions[letter].width = 60 if heading.startswith("正文") else 32 if heading in {"摘要", "目录完整路径", "父级目录完整路径"} else 20
|
||
for cell in column[1:]:
|
||
cell.alignment = Alignment(vertical="top", wrap_text=True)
|
||
output = BytesIO()
|
||
workbook.save(output)
|
||
output.seek(0)
|
||
return output
|
||
|
||
|
||
def _weknora_embed_target_path(weknora_id: str, tab: str) -> str:
|
||
target_query = urlencode({"tab": tab}) if tab != "documents" else ""
|
||
target = f"/platform/knowledge-bases/{quote(weknora_id, safe='')}"
|
||
if target_query:
|
||
target = f"{target}?{target_query}"
|
||
return target
|
||
|
||
|
||
def _set_weknora_embed_cookie(request: Request, response: Response, payload: dict[str, Any]) -> None:
|
||
secure = is_secure_request(request)
|
||
signed = _sign_embed_payload(payload)
|
||
for path in ("/", _request_public_prefix(request)):
|
||
response.set_cookie(
|
||
_WEKNORA_EMBED_COOKIE,
|
||
signed,
|
||
max_age=_WEKNORA_EMBED_TTL_SECONDS,
|
||
httponly=True,
|
||
secure=secure,
|
||
samesite="none" if secure else "lax",
|
||
path=path,
|
||
)
|
||
response.headers["Cache-Control"] = "no-store"
|
||
|
||
|
||
async def _find_wiki_page_for_chunk(
|
||
client: WeKnoraClient,
|
||
*,
|
||
knowledge_base_id: str,
|
||
chunk_id: str,
|
||
chunks: list[dict[str, Any]],
|
||
) -> dict[str, Any] | None:
|
||
focus = next((chunk for chunk in chunks if str(chunk.get("id") or "") == chunk_id), None)
|
||
if focus is None:
|
||
return None
|
||
|
||
for slug in _candidate_wiki_slugs(focus):
|
||
try:
|
||
page = await client.get_wiki_page(knowledge_base_id, slug)
|
||
except WeKnoraError as exc:
|
||
if exc.status_code != 404:
|
||
logger.debug("Failed to read WeKnora wiki page slug=%s", slug, exc_info=True)
|
||
continue
|
||
view = _wiki_page_view(page)
|
||
if view and (view.get("content") or view.get("summary")):
|
||
return view
|
||
|
||
return None
|
||
|
||
|
||
async def _prepare_weknora_embed_session(
|
||
request: Request,
|
||
mapping_id: str,
|
||
) -> tuple[str, dict[str, Any]]:
|
||
_runtime_for_iframe_proxy(request)
|
||
row, user_id, is_admin = await _authorized_mapping(request, mapping_id, write=False)
|
||
writable = None
|
||
if not _is_conversation_deposit_mapping(row):
|
||
writable = await _store(request).get_authorized(mapping_id, user_id, write=True, is_admin=is_admin)
|
||
weknora_id = str(row["weknora_id"])
|
||
ctx = WeKnoraEmbedContext(
|
||
mapping_id=mapping_id,
|
||
weknora_id=weknora_id,
|
||
user_id=user_id,
|
||
is_admin=is_admin,
|
||
can_write=writable is not None,
|
||
is_conversation_deposit=_is_conversation_deposit_mapping(row),
|
||
exp=int(time.time()) + _WEKNORA_EMBED_TTL_SECONDS,
|
||
)
|
||
return weknora_id, _embed_context_payload(ctx)
|
||
|
||
|
||
@router.post("/api/llmwiki/knowledge-bases/{mapping_id}/weknora/session")
|
||
async def create_weknora_detail_session(
|
||
request: Request,
|
||
mapping_id: str,
|
||
tab: Literal["documents", "wiki", "graph"] = Query(default="wiki"),
|
||
) -> JSONResponse:
|
||
"""Create a signed iframe session using the normal DeerFlow API auth path."""
|
||
|
||
weknora_id, payload = await _prepare_weknora_embed_session(request, mapping_id)
|
||
target = _with_public_prefix(
|
||
_request_public_prefix(request),
|
||
_weknora_embed_target_path(weknora_id, tab),
|
||
)
|
||
response = JSONResponse(
|
||
{
|
||
"frame_path": target,
|
||
"expires_in": _WEKNORA_EMBED_TTL_SECONDS,
|
||
}
|
||
)
|
||
_set_weknora_embed_cookie(request, response, payload)
|
||
return response
|
||
|
||
|
||
@router.get("/api/llmwiki/knowledge-bases/{mapping_id}/weknora/frame")
|
||
async def open_weknora_detail_frame(
|
||
request: Request,
|
||
mapping_id: str,
|
||
tab: Literal["documents", "wiki", "graph"] = Query(default="wiki"),
|
||
) -> RedirectResponse:
|
||
"""Start a short-lived, DeerFlow-authorized WeKnora detail iframe session."""
|
||
|
||
weknora_id, payload = await _prepare_weknora_embed_session(request, mapping_id)
|
||
target = _with_public_prefix(
|
||
_request_public_prefix(request),
|
||
_weknora_embed_target_path(weknora_id, tab),
|
||
)
|
||
response = RedirectResponse(target, status_code=302)
|
||
_set_weknora_embed_cookie(request, response, payload)
|
||
return response
|
||
|
||
|
||
@proxy_router.api_route("/platform", methods=["GET", "HEAD"])
|
||
@proxy_router.api_route("/platform/{proxied_path:path}", methods=["GET", "HEAD"])
|
||
async def proxy_weknora_platform_page(request: Request, proxied_path: str = "") -> Response:
|
||
static_upstream_path = _weknora_static_fallback_upstream_path(request)
|
||
if static_upstream_path is not None and _is_weknora_static_asset_path(static_upstream_path):
|
||
return await _proxy_weknora_static_asset(request, static_upstream_path)
|
||
ctx = await _read_embed_context(request)
|
||
requested = f"/platform/{proxied_path}".rstrip("/")
|
||
allowed_path = f"/platform/knowledge-bases/{quote(ctx.weknora_id, safe='')}"
|
||
if requested != allowed_path:
|
||
requested_weknora_id = _weknora_id_from_platform_path(requested)
|
||
if requested_weknora_id is not None:
|
||
selected_ctx = await _switch_embed_context(request, ctx, requested_weknora_id)
|
||
selected_ctx = await _with_visible_embed_knowledge_bases(request, selected_ctx)
|
||
response = await _proxy_weknora_request(
|
||
request,
|
||
target_base_url=_runtime_for_iframe_proxy(request).web_base_url,
|
||
upstream_path=f"/platform/knowledge-bases/{quote(requested_weknora_id, safe='')}",
|
||
ctx=selected_ctx,
|
||
is_api=False,
|
||
tab=request.query_params.get("tab") or "wiki",
|
||
)
|
||
_set_weknora_embed_cookie(request, response, _embed_context_payload(selected_ctx))
|
||
return response
|
||
static_upstream_path = _weknora_static_fallback_upstream_path(request)
|
||
if static_upstream_path is not None:
|
||
runtime = _runtime_for_iframe_proxy(request)
|
||
return await _proxy_weknora_request(
|
||
request,
|
||
target_base_url=runtime.web_base_url,
|
||
upstream_path=static_upstream_path,
|
||
ctx=ctx,
|
||
is_api=False,
|
||
)
|
||
target_query = urlencode({"tab": request.query_params.get("tab") or "wiki"})
|
||
public_target = _with_public_prefix(_request_public_prefix(request), allowed_path)
|
||
return RedirectResponse(f"{public_target}?{target_query}", status_code=302)
|
||
runtime = _runtime_for_iframe_proxy(request)
|
||
ctx = await _with_visible_embed_knowledge_bases(request, ctx)
|
||
return await _proxy_weknora_request(
|
||
request,
|
||
target_base_url=runtime.web_base_url,
|
||
upstream_path=allowed_path,
|
||
ctx=ctx,
|
||
is_api=False,
|
||
tab=request.query_params.get("tab") or "wiki",
|
||
)
|
||
|
||
|
||
@proxy_router.api_route("/assets/{proxied_path:path}", methods=["GET", "HEAD"])
|
||
async def proxy_weknora_assets(request: Request, proxied_path: str) -> Response:
|
||
return await _proxy_weknora_static_asset(request, f"/assets/{proxied_path}")
|
||
|
||
|
||
@proxy_router.api_route("/locales/{proxied_path:path}", methods=["GET", "HEAD"])
|
||
async def proxy_weknora_locales(request: Request, proxied_path: str) -> Response:
|
||
return await _proxy_weknora_static_asset(request, f"/locales/{proxied_path}")
|
||
|
||
|
||
@proxy_router.api_route("/favicon.ico", methods=["GET", "HEAD"])
|
||
@proxy_router.api_route("/favicon.svg", methods=["GET", "HEAD"])
|
||
@proxy_router.api_route("/config.js", methods=["GET", "HEAD"])
|
||
@proxy_router.api_route("/runtime-config.js", methods=["GET", "HEAD"])
|
||
@proxy_router.api_route("/logo.svg", methods=["GET", "HEAD"])
|
||
@proxy_router.api_route("/manifest.webmanifest", methods=["GET", "HEAD"])
|
||
async def proxy_weknora_root_asset(request: Request) -> Response:
|
||
return await _proxy_weknora_static_asset(
|
||
request,
|
||
_without_public_prefix(
|
||
_request_public_prefix(request),
|
||
str(request.scope.get("path") or request.url.path),
|
||
),
|
||
)
|
||
|
||
|
||
@proxy_router.api_route("/tdesign-icons/{proxied_path:path}", methods=["GET", "HEAD"])
|
||
async def proxy_weknora_tdesign_icons(request: Request, proxied_path: str) -> Response:
|
||
return await _proxy_weknora_static_asset(request, f"/tdesign-icons/{proxied_path}")
|
||
|
||
|
||
@proxy_router.api_route(
|
||
"/api/v1/{proxied_path:path}",
|
||
methods=["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
|
||
)
|
||
async def proxy_weknora_api(request: Request, proxied_path: str) -> Response:
|
||
if request.method.upper() == "OPTIONS":
|
||
return Response(status_code=204)
|
||
ctx = await _read_embed_context(request)
|
||
if request.method.upper() in {"GET", "HEAD"} and proxied_path.rstrip("/") == "knowledge-bases":
|
||
return await _local_embed_knowledge_base_directory(request, ctx)
|
||
runtime = _runtime_for_iframe_proxy(request)
|
||
return await _proxy_weknora_request(
|
||
request,
|
||
target_base_url=runtime.api_base_url,
|
||
upstream_path=f"/api/v1/{proxied_path}",
|
||
ctx=ctx,
|
||
is_api=True,
|
||
)
|
||
|
||
|
||
@router.api_route(
|
||
"/api/llmwiki/weknora-embed/api/v1/{proxied_path:path}",
|
||
methods=["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
|
||
)
|
||
@router.api_route(
|
||
"/deerflow/api/llmwiki/weknora-embed/api/v1/{proxied_path:path}",
|
||
methods=["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
|
||
)
|
||
async def proxy_weknora_embed_api(request: Request, proxied_path: str) -> Response:
|
||
"""Proxy iframe API calls under a collision-free cmzs namespace."""
|
||
|
||
if request.method.upper() == "OPTIONS":
|
||
return Response(status_code=204)
|
||
ctx = await _read_embed_context(request)
|
||
if request.method.upper() in {"GET", "HEAD"} and proxied_path.rstrip("/") == "knowledge-bases":
|
||
return await _local_embed_knowledge_base_directory(request, ctx)
|
||
runtime = _runtime_for_iframe_proxy(request)
|
||
return await _proxy_weknora_request(
|
||
request,
|
||
target_base_url=runtime.api_base_url,
|
||
upstream_path=f"/api/v1/{proxied_path}",
|
||
ctx=ctx,
|
||
is_api=True,
|
||
)
|
||
|
||
|
||
@proxy_router.api_route("/{proxied_path:path}", methods=["GET", "HEAD"])
|
||
async def proxy_weknora_static_fallback(request: Request, proxied_path: str) -> Response:
|
||
upstream_path = _weknora_static_fallback_upstream_path(request)
|
||
if upstream_path is None:
|
||
raise HTTPException(status_code=404, detail="WeKnora proxy path not found")
|
||
if _is_weknora_static_asset_path(upstream_path):
|
||
return await _proxy_weknora_static_asset(request, upstream_path)
|
||
ctx = await _read_embed_context(request)
|
||
runtime = _runtime_for_iframe_proxy(request)
|
||
return await _proxy_weknora_request(
|
||
request,
|
||
target_base_url=runtime.web_base_url,
|
||
upstream_path=upstream_path,
|
||
ctx=ctx,
|
||
is_api=False,
|
||
)
|
||
|
||
|
||
@router.get("/api/llmwiki/runtime")
|
||
async def get_llmwiki_runtime() -> dict[str, Any]:
|
||
return _runtime_or_legacy()
|
||
|
||
|
||
@router.get("/api/system-settings/llmwiki")
|
||
async def get_llmwiki_settings(request: Request) -> dict[str, Any]:
|
||
await _require_admin(request)
|
||
from deerflow.config import get_app_config
|
||
|
||
app_config = get_app_config()
|
||
runtime = get_resolved_llmwiki_runtime(app_config)
|
||
return {
|
||
**_runtime_or_legacy(),
|
||
"file_api_base_url": app_config.llmwiki.weknora.api_base_url,
|
||
"file_web_base_url": app_config.llmwiki.weknora.web_base_url,
|
||
"effective_provider": runtime.provider,
|
||
}
|
||
|
||
|
||
@router.put("/api/system-settings/llmwiki")
|
||
async def update_llmwiki_settings(request: Request, body: LlmWikiSettingsUpdate) -> dict[str, Any]:
|
||
await _require_admin(request)
|
||
try:
|
||
override = LlmWikiRuntimeOverride(
|
||
enabled=body.override_enabled,
|
||
api_base_url=body.api_base_url,
|
||
web_base_url=body.web_base_url,
|
||
)
|
||
except ValueError as exc:
|
||
raise HTTPException(status_code=422, detail=str(exc)) from None
|
||
current = load_system_settings()
|
||
save_system_settings(current.model_copy(update={"llmwiki": override}))
|
||
from app.gateway.llmwiki_index_runtime import rebuild_local_wiki_index_runtime
|
||
|
||
await rebuild_local_wiki_index_runtime(request.app, force=True)
|
||
return _runtime_or_legacy()
|
||
|
||
|
||
@router.delete("/api/system-settings/llmwiki/override")
|
||
async def reset_llmwiki_settings(request: Request) -> dict[str, Any]:
|
||
await _require_admin(request)
|
||
current = load_system_settings()
|
||
save_system_settings(current.model_copy(update={"llmwiki": LlmWikiRuntimeOverride()}))
|
||
from app.gateway.llmwiki_index_runtime import rebuild_local_wiki_index_runtime
|
||
|
||
await rebuild_local_wiki_index_runtime(request.app, force=True)
|
||
return _runtime_or_legacy()
|
||
|
||
|
||
@router.post("/api/system-settings/llmwiki/test")
|
||
async def test_llmwiki_connection(request: Request, body: LlmWikiConnectionTest) -> dict[str, Any]:
|
||
await _require_admin(request)
|
||
runtime = get_resolved_llmwiki_runtime()
|
||
if body.api_base_url is not None:
|
||
try:
|
||
candidate = normalize_service_url(body.api_base_url)
|
||
except ValueError as exc:
|
||
raise HTTPException(status_code=422, detail=str(exc)) from None
|
||
runtime = replace(runtime, provider="weknora" if candidate else "legacy", api_base_url=candidate)
|
||
if not runtime.weknora_enabled:
|
||
raise HTTPException(status_code=422, detail="Enter a WeKnora API address first")
|
||
try:
|
||
health = await build_weknora_client(runtime).health()
|
||
except RuntimeError as exc:
|
||
raise HTTPException(status_code=503, detail=str(exc)) from None
|
||
except WeKnoraError as exc:
|
||
_raise_upstream(exc)
|
||
return {"success": True, "provider": "weknora", "health": health}
|
||
|
||
|
||
_PUBLIC_WIKI_SUMMARY_CONCURRENCY = 8
|
||
_PUBLIC_WIKI_PAGE_SIZE = 500
|
||
_PUBLIC_WIKI_PAGE_LIMIT = 100
|
||
_GENERATED_PUBLIC_WIKI_INDEX_SLUG = "__index__"
|
||
|
||
|
||
def _is_public_wiki_index_page(page: dict[str, Any]) -> bool:
|
||
page_type = str(page.get("page_type") or "").strip().lower()
|
||
title = str(page.get("title") or "").strip().lower()
|
||
slug_name = str(page.get("slug") or "").strip("/").rsplit("/", 1)[-1].lower()
|
||
return page_type == "index" or title in {"索引", "index"} or slug_name in {"index", "home", "readme"}
|
||
|
||
|
||
def _public_wiki_default_page(pages: list[dict[str, Any]]) -> dict[str, Any] | None:
|
||
return (
|
||
next((page for page in pages if _is_public_wiki_index_page(page)), None)
|
||
or next((page for page in pages if str(page.get("page_type") or "").lower() == "overview"), None)
|
||
or next(
|
||
(page for page in pages if not str(page.get("parent_slug") or "").strip() and int(page.get("depth") or 0) == 0),
|
||
None,
|
||
)
|
||
or (pages[0] if pages else None)
|
||
)
|
||
|
||
|
||
def _count_public_wiki_values(pages: list[dict[str, Any]], key: str, fallback: str) -> dict[str, int]:
|
||
counts: dict[str, int] = {}
|
||
for page in pages:
|
||
value = str(page.get(key) or fallback).strip().lower() or fallback
|
||
counts[value] = counts.get(value, 0) + 1
|
||
return counts
|
||
|
||
|
||
def _public_wiki_time_bound(pages: list[dict[str, Any]], key: str, *, latest: bool) -> str | None:
|
||
values = [str(page.get(key) or "").strip() for page in pages]
|
||
values = [value for value in values if value]
|
||
if not values:
|
||
return None
|
||
return max(values) if latest else min(values)
|
||
|
||
|
||
async def _all_public_wiki_page_views(
|
||
client: WeKnoraClient,
|
||
remote_id: str,
|
||
) -> tuple[list[dict[str, Any]], int, bool]:
|
||
pages_by_slug: dict[str, dict[str, Any]] = {}
|
||
remote_total = 0
|
||
complete = False
|
||
for page_number in range(1, _PUBLIC_WIKI_PAGE_LIMIT + 1):
|
||
result = await client.list_wiki_pages(
|
||
remote_id,
|
||
page=page_number,
|
||
page_size=_PUBLIC_WIKI_PAGE_SIZE,
|
||
query="",
|
||
)
|
||
raw_pages = result.get("pages") if isinstance(result, dict) else []
|
||
page_items = raw_pages if isinstance(raw_pages, list) else []
|
||
for page_item in page_items:
|
||
if not isinstance(page_item, dict):
|
||
continue
|
||
page = _wiki_page_view(page_item)
|
||
if page is None:
|
||
continue
|
||
slug = str(page.get("slug") or "").strip()
|
||
pages_by_slug[slug or f"__page_{len(pages_by_slug)}"] = page
|
||
reported_total = int(result.get("total") or 0)
|
||
remote_total = max(remote_total, reported_total, len(pages_by_slug))
|
||
total_pages = max(1, int(result.get("total_pages") or 1))
|
||
if not page_items or page_number >= total_pages or (reported_total > 0 and len(pages_by_slug) >= remote_total):
|
||
complete = True
|
||
break
|
||
return list(pages_by_slug.values()), max(remote_total, len(pages_by_slug)), complete
|
||
|
||
|
||
def _public_wiki_summary(
|
||
mapping_id: str,
|
||
pages: list[dict[str, Any]],
|
||
*,
|
||
total: int,
|
||
complete: bool,
|
||
) -> dict[str, Any]:
|
||
explicit_index = next((page for page in pages if _is_public_wiki_index_page(page)), None)
|
||
default_page = _public_wiki_default_page(pages)
|
||
if explicit_index is not None:
|
||
index_mode = "explicit"
|
||
index_slug: str | None = str(explicit_index.get("slug") or "") or None
|
||
elif pages:
|
||
index_mode = "generated"
|
||
index_slug = _GENERATED_PUBLIC_WIKI_INDEX_SLUG
|
||
else:
|
||
index_mode = "none"
|
||
index_slug = None
|
||
default_slug = index_slug or (str(default_page.get("slug") or "") if default_page else None)
|
||
route = f"/embed/knowledge/{quote(mapping_id, safe='')}"
|
||
if default_slug:
|
||
route = f"{route}?{urlencode({'wikiSlug': default_slug})}"
|
||
return {
|
||
"status": "ready" if pages else "empty",
|
||
"page_count": total,
|
||
"loaded_page_count": len(pages),
|
||
"stats_complete": complete,
|
||
"page_type_counts": _count_public_wiki_values(pages, "page_type", "page"),
|
||
"page_status_counts": _count_public_wiki_values(pages, "status", "unknown"),
|
||
"index_mode": index_mode,
|
||
"has_explicit_index": explicit_index is not None,
|
||
"index_slug": index_slug,
|
||
"default_slug": default_slug,
|
||
"created_at": _public_wiki_time_bound(pages, "created_at", latest=False),
|
||
"updated_at": _public_wiki_time_bound(pages, "updated_at", latest=True),
|
||
"embed_route": route,
|
||
}
|
||
|
||
|
||
def _unavailable_public_wiki_summary(mapping_id: str) -> dict[str, Any]:
|
||
return {
|
||
"status": "unavailable",
|
||
"page_count": 0,
|
||
"loaded_page_count": 0,
|
||
"stats_complete": False,
|
||
"page_type_counts": {},
|
||
"page_status_counts": {},
|
||
"index_mode": "unknown",
|
||
"has_explicit_index": False,
|
||
"index_slug": None,
|
||
"default_slug": None,
|
||
"created_at": None,
|
||
"updated_at": None,
|
||
"embed_route": f"/embed/knowledge/{quote(mapping_id, safe='')}",
|
||
}
|
||
|
||
|
||
def _public_local_index_summary(
|
||
request: Request,
|
||
mapping: dict[str, Any],
|
||
state: dict[str, Any] | None,
|
||
) -> dict[str, Any]:
|
||
app_state = request.app.state
|
||
local_config = getattr(app_state, "llmwiki_effective_config", None)
|
||
if local_config is None:
|
||
local_config = getattr(
|
||
getattr(getattr(app_state, "config", None), "llmwiki", None),
|
||
"local_wiki_index",
|
||
None,
|
||
)
|
||
enabled = bool(getattr(local_config, "enabled", False))
|
||
current = dict(state or {})
|
||
state_name = str(current.get("state") or "not_synced")
|
||
if state_name == "idle" and int(current.get("index_revision") or 0) == 0 and not current.get("last_completed_at") and not current.get("last_success_at"):
|
||
state_name = "not_synced"
|
||
embedding = getattr(app_state, "llmwiki_embedding", None)
|
||
fingerprint = embedding.fingerprint if embedding is not None else None
|
||
fully_vectorized = bool(mapping.get("wiki_index_enabled", True)) and is_fully_vectorized(
|
||
current,
|
||
fingerprint,
|
||
)
|
||
return {
|
||
"enabled": enabled,
|
||
"wiki_index_enabled": bool(mapping.get("wiki_index_enabled", True)),
|
||
"state": state_name,
|
||
"fully_vectorized": fully_vectorized,
|
||
"retrieval_mode": "vector" if fully_vectorized else "wiki_api",
|
||
"remote_page_count": int(current.get("remote_page_count") or 0),
|
||
"local_page_count": int(current.get("local_page_count") or 0),
|
||
"ready_page_count": int(current.get("ready_page_count") or 0),
|
||
"failed_page_count": int(current.get("failed_page_count") or 0),
|
||
"vector_count": int(current.get("vector_count") or 0),
|
||
"last_started_at": current.get("last_started_at"),
|
||
"last_completed_at": current.get("last_completed_at"),
|
||
"last_success_at": current.get("last_success_at"),
|
||
"updated_at": current.get("updated_at"),
|
||
}
|
||
|
||
|
||
@router.get("/api/public/llmwiki/knowledge-bases")
|
||
async def list_public_knowledge_bases(request: Request) -> dict[str, Any]:
|
||
"""List live, published Wiki libraries with safe metadata and index health."""
|
||
|
||
rows = [
|
||
row
|
||
for row in await _store(request).list_visible(
|
||
"__public_wiki__",
|
||
scope="public",
|
||
is_admin=False,
|
||
)
|
||
if row.get("publication_status") == "published" and not _is_conversation_deposit_mapping(row)
|
||
]
|
||
client = _client_or_503()
|
||
try:
|
||
remote_items = await client.list_knowledge_bases()
|
||
except WeKnoraError as exc:
|
||
_raise_upstream(exc)
|
||
remote_by_id = {str(item.get("id")): item for item in remote_items if isinstance(item, dict) and item.get("id")}
|
||
# A successful upstream list is authoritative: stale DeerFlow mappings are
|
||
# omitted so discovery never advertises a Wiki that its detail route cannot open.
|
||
rows = [row for row in rows if str(row.get("weknora_id") or "") in remote_by_id]
|
||
mapping_ids = [str(row["id"]) for row in rows]
|
||
index_states: dict[str, dict[str, Any]] = {}
|
||
index_store = getattr(request.app.state, "llmwiki_index_store", None)
|
||
if index_store is not None and mapping_ids:
|
||
try:
|
||
index_states = {str(state.get("knowledge_base_mapping_id") or ""): state for state in await index_store.list_index_status(mapping_ids) if isinstance(state, dict)}
|
||
except Exception:
|
||
logger.warning("Failed to read public Wiki local-index summaries", exc_info=True)
|
||
|
||
semaphore = asyncio.Semaphore(_PUBLIC_WIKI_SUMMARY_CONCURRENCY)
|
||
|
||
async def build_summary(row: dict[str, Any]) -> dict[str, Any]:
|
||
mapping_id = str(row["id"])
|
||
remote_id = str(row["weknora_id"])
|
||
remote = remote_by_id[remote_id]
|
||
async with semaphore:
|
||
try:
|
||
remote = {**remote, **await client.get_knowledge_base(remote_id)}
|
||
except WeKnoraError as exc:
|
||
if exc.status_code == 404:
|
||
return {}
|
||
logger.warning(
|
||
"Failed to load public Wiki detail metadata: mapping_id=%s",
|
||
mapping_id,
|
||
exc_info=True,
|
||
)
|
||
try:
|
||
pages, page_total, complete = await _all_public_wiki_page_views(client, remote_id)
|
||
wiki = _public_wiki_summary(
|
||
mapping_id,
|
||
pages,
|
||
total=page_total,
|
||
complete=complete,
|
||
)
|
||
except WeKnoraError as exc:
|
||
logger.warning(
|
||
"Failed to load public Wiki page summary: mapping_id=%s",
|
||
mapping_id,
|
||
exc_info=True,
|
||
)
|
||
wiki = (
|
||
_public_wiki_summary(mapping_id, [], total=0, complete=True)
|
||
if exc.status_code == 404
|
||
else _unavailable_public_wiki_summary(mapping_id)
|
||
)
|
||
return {
|
||
**_mapping_view(
|
||
row,
|
||
remote,
|
||
actor_user_id="__public_wiki__",
|
||
is_admin=False,
|
||
),
|
||
"wiki": wiki,
|
||
"local_index": _public_local_index_summary(
|
||
request,
|
||
row,
|
||
index_states.get(mapping_id),
|
||
),
|
||
}
|
||
|
||
knowledge_bases = [summary for summary in await asyncio.gather(*(build_summary(row) for row in rows)) if summary]
|
||
return {"knowledge_bases": knowledge_bases, "total": len(knowledge_bases)}
|
||
|
||
|
||
@router.get("/api/public/llmwiki/knowledge-bases/{mapping_id}")
|
||
async def get_public_knowledge_base(request: Request, mapping_id: str) -> dict[str, Any]:
|
||
"""Return the safe public metadata used by the chrome-free Wiki reader."""
|
||
|
||
row = await _public_mapping(request, mapping_id)
|
||
try:
|
||
remote = await _client_or_503().get_knowledge_base(str(row["weknora_id"]))
|
||
except WeKnoraError as exc:
|
||
_raise_upstream(exc)
|
||
return _mapping_view(
|
||
row,
|
||
remote,
|
||
actor_user_id="__public_wiki__",
|
||
is_admin=False,
|
||
)
|
||
|
||
|
||
@router.get("/api/public/llmwiki/knowledge-bases/{mapping_id}/wiki/pages")
|
||
async def list_public_wiki_pages(
|
||
request: Request,
|
||
mapping_id: str,
|
||
page: int = Query(default=1, ge=1),
|
||
page_size: int = Query(default=50, ge=1, le=500),
|
||
query: str = Query(default="", max_length=255),
|
||
) -> dict[str, Any]:
|
||
"""List Wiki pages for a published knowledge base without requiring login."""
|
||
|
||
row = await _public_mapping(request, mapping_id)
|
||
try:
|
||
result = await _client_or_503().list_wiki_pages(
|
||
str(row["weknora_id"]),
|
||
page=page,
|
||
page_size=page_size,
|
||
query=query.strip(),
|
||
)
|
||
except WeKnoraError as exc:
|
||
_raise_upstream(exc)
|
||
raw_pages = result.get("pages") if isinstance(result, dict) else []
|
||
pages = [view for page_item in (raw_pages if isinstance(raw_pages, list) else []) if isinstance(page_item, dict) for view in [_wiki_page_view(page_item)] if view is not None]
|
||
return {**result, "pages": pages}
|
||
|
||
|
||
@router.get("/api/public/llmwiki/knowledge-bases/{mapping_id}/files")
|
||
async def get_public_wiki_file(
|
||
request: Request,
|
||
mapping_id: str,
|
||
file_path: str = Query(..., min_length=1, max_length=8192),
|
||
) -> Response:
|
||
"""Serve one protected image belonging to a published Wiki."""
|
||
|
||
row = await _public_mapping(request, mapping_id)
|
||
return await _wiki_file_response(row, file_path, public=True)
|
||
|
||
|
||
@router.get("/api/public/llmwiki/files/{token}")
|
||
async def get_signed_wiki_file(token: str) -> Response:
|
||
"""Serve a private Wiki image through a short-lived, signed HTTP URL."""
|
||
|
||
payload = _verify_signed_payload(token)
|
||
if payload.get("kind") != "wiki_file":
|
||
raise HTTPException(status_code=401, detail="Signed Wiki file token is invalid")
|
||
mapping_id = str(payload.get("mapping_id") or "")
|
||
weknora_id = str(payload.get("weknora_id") or "")
|
||
file_path = str(payload.get("file_path") or "")
|
||
if not mapping_id or not weknora_id or not file_path:
|
||
raise HTTPException(status_code=401, detail="Signed Wiki file token is invalid")
|
||
return await _wiki_file_response(
|
||
{"id": mapping_id, "weknora_id": weknora_id},
|
||
file_path,
|
||
public=False,
|
||
)
|
||
|
||
|
||
_PUBLIC_WIKI_INDEX_TYPES = ("summary", "entity", "concept", "synthesis", "comparison")
|
||
|
||
|
||
def _public_wiki_index_item(value: Any) -> dict[str, Any] | None:
|
||
if not isinstance(value, dict):
|
||
return None
|
||
slug = str(value.get("slug") or "").strip()
|
||
if not slug:
|
||
return None
|
||
return {
|
||
"slug": slug,
|
||
"title": str(value.get("title") or slug),
|
||
"summary": str(value.get("summary") or ""),
|
||
"category_path": ([str(item) for item in value.get("category_path", []) if str(item).strip()] if isinstance(value.get("category_path"), list) else []),
|
||
"wiki_path": str(value.get("wiki_path") or ""),
|
||
"depth": int(value.get("depth") or 0),
|
||
}
|
||
|
||
|
||
async def _all_public_wiki_index(client: WeKnoraClient, remote_id: str) -> dict[str, Any]:
|
||
intro = ""
|
||
version = 0
|
||
groups: list[dict[str, Any]] = []
|
||
for page_type in _PUBLIC_WIKI_INDEX_TYPES:
|
||
items: list[dict[str, Any]] = []
|
||
cursor = ""
|
||
seen_cursors: set[str] = set()
|
||
total = 0
|
||
for _page_number in range(100):
|
||
payload = await client.get_wiki_index(
|
||
remote_id,
|
||
types=[page_type],
|
||
limit=500,
|
||
cursor=cursor,
|
||
)
|
||
intro = intro or str(payload.get("intro") or "")
|
||
version = max(version, int(payload.get("version") or 0))
|
||
raw_groups = payload.get("groups") if isinstance(payload.get("groups"), list) else []
|
||
group = next(
|
||
(value for value in raw_groups if isinstance(value, dict) and value.get("type") == page_type),
|
||
{},
|
||
)
|
||
total = max(total, int(group.get("total") or 0))
|
||
raw_items = group.get("items") if isinstance(group.get("items"), list) else []
|
||
items.extend(item for value in raw_items if (item := _public_wiki_index_item(value)) is not None)
|
||
next_cursor = str(group.get("next_cursor") or "")
|
||
if not next_cursor or next_cursor in seen_cursors:
|
||
break
|
||
seen_cursors.add(next_cursor)
|
||
cursor = next_cursor
|
||
else:
|
||
logger.warning(
|
||
"Stopped public Wiki index pagination at safety limit: mapping_remote_id=%s page_type=%s",
|
||
remote_id,
|
||
page_type,
|
||
)
|
||
groups.append({"type": page_type, "total": total or len(items), "items": items})
|
||
return {"intro": intro, "version": version, "groups": groups}
|
||
|
||
|
||
@router.get("/api/public/llmwiki/knowledge-bases/{mapping_id}/wiki/index")
|
||
async def get_public_wiki_index(request: Request, mapping_id: str) -> dict[str, Any]:
|
||
"""Return WeKnora's ordered dynamic Wiki index for the public reader."""
|
||
|
||
row = await _public_mapping(request, mapping_id)
|
||
try:
|
||
return await _all_public_wiki_index(_client_or_503(), str(row["weknora_id"]))
|
||
except WeKnoraError as exc:
|
||
_raise_upstream(exc)
|
||
|
||
|
||
@router.get("/api/public/llmwiki/knowledge-bases/{mapping_id}/wiki/pages/{slug:path}")
|
||
async def get_public_wiki_page(
|
||
request: Request,
|
||
mapping_id: str,
|
||
slug: str,
|
||
) -> dict[str, Any]:
|
||
"""Read one Wiki article from a published knowledge base."""
|
||
|
||
row = await _public_mapping(request, mapping_id)
|
||
try:
|
||
page = await _client_or_503().get_wiki_page(str(row["weknora_id"]), slug)
|
||
except WeKnoraError as exc:
|
||
_raise_upstream(exc)
|
||
return _wiki_page_view(page) or page
|
||
|
||
|
||
@router.get("/api/llmwiki/knowledge-bases")
|
||
async def list_knowledge_bases(
|
||
request: Request,
|
||
scope: Literal["personal", "public", "all"] = Query(default="all"),
|
||
refresh: bool = False,
|
||
) -> dict[str, Any]:
|
||
trace = trace_request(request, "llmwiki_knowledge_bases")
|
||
try:
|
||
stage_started = time.perf_counter()
|
||
user_id, is_admin = await _actor(request)
|
||
trace_stage(trace, "actor_and_access", stage_started)
|
||
|
||
stage_started = time.perf_counter()
|
||
store = _store(request)
|
||
rows = await store.list_visible(user_id, scope=scope, is_admin=False)
|
||
trace_stage(trace, "local_knowledge_base_query_db", stage_started)
|
||
|
||
client: WeKnoraClient | None = None
|
||
remote_items: list[dict[str, Any]] = []
|
||
remote: dict[str, dict[str, Any]] = {}
|
||
if refresh:
|
||
stage_started = time.perf_counter()
|
||
client = _client_or_503()
|
||
try:
|
||
remote_items = await client.list_knowledge_bases(refresh=True)
|
||
remote = {str(item.get("id")): item for item in remote_items if item.get("id")}
|
||
except WeKnoraError as exc:
|
||
_raise_upstream(exc)
|
||
trace_stage(trace, "weknora_explicit_refresh_wait", stage_started)
|
||
|
||
deposit_remote = next(
|
||
(item for item in remote_items if str(item.get("name") or "").strip() == CONVERSATION_DEPOSIT_KB_NAME),
|
||
None,
|
||
)
|
||
if deposit_remote is not None:
|
||
previous_mapping = await store.get_by_weknora_id(str(deposit_remote.get("id") or ""))
|
||
deposit_mapping = await ensure_conversation_deposit_mapping(
|
||
store,
|
||
client,
|
||
remote_knowledge_bases=remote_items,
|
||
)
|
||
description_was_synchronized = previous_mapping is None or not _is_conversation_deposit_mapping(previous_mapping) or str(previous_mapping.get("description") or "") != CONVERSATION_DEPOSIT_KB_DESCRIPTION
|
||
if deposit_mapping is not None and description_was_synchronized:
|
||
remote_id = str(deposit_mapping["weknora_id"])
|
||
remote[remote_id] = {
|
||
**remote.get(remote_id, deposit_remote),
|
||
"description": CONVERSATION_DEPOSIT_KB_DESCRIPTION,
|
||
}
|
||
trace_stage(trace, "conversation_deposit_mapping_sync", stage_started)
|
||
|
||
stage_started = time.perf_counter()
|
||
knowledge_bases: list[dict[str, Any]] = []
|
||
for row in rows:
|
||
if refresh:
|
||
remote_item = remote.get(str(row["weknora_id"]))
|
||
# An explicit successful refresh is authoritative: do not show
|
||
# mappings whose remote knowledge base has been deleted.
|
||
if remote_item is None:
|
||
continue
|
||
assert client is not None
|
||
row, remote_item = await _sync_conversation_deposit_view(store, client, row, remote_item)
|
||
else:
|
||
# Normal page loads are served entirely from the local SQL
|
||
# mapping. A remote check happens only after an explicit refresh.
|
||
remote_item = {
|
||
"id": str(row.get("weknora_id") or ""),
|
||
"name": str(row.get("name") or ""),
|
||
"description": str(row.get("description") or ""),
|
||
"_local_cache_only": True,
|
||
}
|
||
knowledge_bases.append(_mapping_view(row, remote_item, actor_user_id=user_id, is_admin=is_admin))
|
||
trace_stage(trace, "map_visible_knowledge_bases", stage_started)
|
||
trace["details"].update({"scope": scope, "refresh": refresh, "remote_count": len(remote_items) if refresh else None, "local_count": len(rows), "result_count": len(knowledge_bases)})
|
||
return {"knowledge_bases": knowledge_bases}
|
||
finally:
|
||
finish_trace(trace)
|
||
|
||
|
||
@router.get("/api/llmwiki/embedding-models")
|
||
async def list_embedding_models(request: Request) -> dict[str, Any]:
|
||
"""List selectable WeKnora embedding models without exposing model secrets."""
|
||
|
||
await _actor(request)
|
||
try:
|
||
models = await _embedding_models(_client_or_503())
|
||
except WeKnoraError as exc:
|
||
_raise_upstream(exc)
|
||
available = [item for item in models if item["available"]]
|
||
default_model = next((item for item in available if item["is_default"]), None) or next(iter(available), None)
|
||
default_model_id = str(default_model["id"]) if default_model is not None else None
|
||
return {"models": models, "default_model_id": default_model_id}
|
||
|
||
|
||
@router.get("/api/llmwiki/agents/{agent_id}/knowledge-bases")
|
||
async def list_agent_knowledge_bases(request: Request, agent_id: str) -> dict[str, Any]:
|
||
"""Return the agent-bound bases still visible to the current DeerFlow user."""
|
||
|
||
trace = trace_request(request, "llmwiki_agent_knowledge_bases")
|
||
try:
|
||
stage_started = time.perf_counter()
|
||
user_id, is_admin = await _actor(request)
|
||
agent_store = get_agent_store(request)
|
||
agent = await (agent_store.get_any(agent_id) if is_admin else agent_store.get_visible(agent_id, user_id))
|
||
if agent is None:
|
||
raise HTTPException(status_code=404, detail="Agent not found")
|
||
trace_stage(trace, "actor_and_agent_access", stage_started)
|
||
|
||
stage_started = time.perf_counter()
|
||
try:
|
||
config = load_agent_config(agent_id)
|
||
except (FileNotFoundError, ValueError):
|
||
config = None
|
||
bound_ids = list(config.llmwiki_knowledge_base_ids or []) if config else []
|
||
trace_stage(trace, "load_agent_binding_config", stage_started)
|
||
|
||
stage_started = time.perf_counter()
|
||
store = _store(request)
|
||
rows: list[dict[str, Any]] = []
|
||
for mapping_id in bound_ids:
|
||
row = await store.get_authorized(
|
||
mapping_id,
|
||
user_id,
|
||
write=False,
|
||
is_admin=is_admin,
|
||
)
|
||
if row is not None:
|
||
rows.append(row)
|
||
trace_stage(trace, "local_knowledge_base_mapping_query", stage_started)
|
||
|
||
stage_started = time.perf_counter()
|
||
knowledge_bases = []
|
||
for row in rows:
|
||
remote_item = {
|
||
"id": str(row.get("weknora_id") or ""),
|
||
"name": str(row.get("name") or ""),
|
||
"description": str(row.get("description") or ""),
|
||
"_local_cache_only": True,
|
||
}
|
||
knowledge_bases.append(_mapping_view(row, remote_item, actor_user_id=user_id, is_admin=is_admin))
|
||
trace_stage(trace, "map_agent_knowledge_bases", stage_started)
|
||
trace["details"].update({"bound_count": len(bound_ids), "remote_count": None, "result_count": len(knowledge_bases)})
|
||
return {"knowledge_bases": knowledge_bases}
|
||
finally:
|
||
finish_trace(trace)
|
||
|
||
|
||
@router.post("/api/llmwiki/knowledge-bases", status_code=201)
|
||
async def create_knowledge_base(request: Request, body: KnowledgeBaseCreate) -> dict[str, Any]:
|
||
client = _client_or_503()
|
||
trace = trace_request(request, "llmwiki_knowledge_base_create")
|
||
stage_started = time.perf_counter()
|
||
user_id, is_admin = await _actor(request)
|
||
trace_stage(trace, "actor_and_access", stage_started)
|
||
if body.name.strip() == CONVERSATION_DEPOSIT_KB_NAME:
|
||
raise HTTPException(status_code=409, detail="对话沉淀是 cmzs 系统保留知识库")
|
||
wiki_config = body.wiki_config if body.wiki_enabled else None
|
||
directory_paths_were_set = bool(
|
||
wiki_config is not None
|
||
and "directory_paths" in wiki_config.model_fields_set
|
||
)
|
||
parent_directories_were_set = bool(
|
||
wiki_config is not None
|
||
and "parent_directories" in wiki_config.model_fields_set
|
||
)
|
||
try:
|
||
directory_paths = resolve_directory_paths(
|
||
wiki_config.directory_paths if wiki_config else None,
|
||
wiki_config.parent_directories if wiki_config else None,
|
||
directory_paths_set=directory_paths_were_set,
|
||
parent_directories_set=parent_directories_were_set,
|
||
)
|
||
parent_directories = directory_roots(directory_paths)
|
||
focus_instructions = normalize_focus_instructions(
|
||
wiki_config.focus_instructions if wiki_config else None
|
||
)
|
||
raw_content_instructions = (
|
||
str(wiki_config.content_instructions or "").strip()
|
||
if wiki_config
|
||
else ""
|
||
)
|
||
raw_extraction_instructions = (
|
||
str(wiki_config.extraction_instructions or "").strip()
|
||
if wiki_config
|
||
else ""
|
||
)
|
||
compiled = compile_wiki_instructions(
|
||
content_instructions=raw_content_instructions,
|
||
extraction_instructions=raw_extraction_instructions,
|
||
focus_instructions=focus_instructions,
|
||
directory_paths=directory_paths,
|
||
)
|
||
except ValueError as exc:
|
||
raise HTTPException(status_code=422, detail=str(exc)) from None
|
||
try:
|
||
stage_started = time.perf_counter()
|
||
upstream_models = await client.list_models()
|
||
embedding_model_id = _select_embedding_model(
|
||
[view for item in upstream_models if (view := _embedding_model_view(item)) is not None],
|
||
body.embedding_model_id,
|
||
)
|
||
knowledge_qa_model_id = (
|
||
_select_knowledge_qa_model(upstream_models) if body.wiki_enabled else None
|
||
)
|
||
trace_stage(trace, "weknora_model_catalog", stage_started)
|
||
stage_started = time.perf_counter()
|
||
remote = await client.create_knowledge_base(
|
||
name=body.name.strip(),
|
||
description=body.description,
|
||
kb_type=body.type,
|
||
wiki_enabled=body.wiki_enabled,
|
||
embedding_model_id=embedding_model_id,
|
||
knowledge_qa_model_id=knowledge_qa_model_id,
|
||
wiki_content_instructions=(
|
||
compiled.content_instructions if body.wiki_enabled else ""
|
||
),
|
||
wiki_extraction_instructions=(
|
||
compiled.extraction_instructions if body.wiki_enabled else ""
|
||
),
|
||
)
|
||
trace_stage(trace, "weknora_create", stage_started)
|
||
except WeKnoraError as exc:
|
||
finish_trace(trace)
|
||
_raise_upstream(exc)
|
||
try:
|
||
# Directory settings are references for WeKnora's generator. Creating
|
||
# folders here would turn the reference into an accidental template.
|
||
stage_started = time.perf_counter()
|
||
row = await _store(request).create_mapping(
|
||
weknora_id=str(remote["id"]),
|
||
owner_user_id=user_id,
|
||
name=str(remote.get("name") or body.name),
|
||
description=str(remote.get("description") or body.description),
|
||
kb_type=str(remote.get("type") or body.type),
|
||
retrieval_settings=dict(DEFAULT_RETRIEVAL_SETTINGS),
|
||
wiki_parent_directories=parent_directories or None,
|
||
wiki_directory_paths=(directory_paths if body.wiki_enabled else []),
|
||
wiki_focus_instructions=(focus_instructions if body.wiki_enabled else ""),
|
||
wiki_content_instructions_raw=(
|
||
raw_content_instructions if body.wiki_enabled else ""
|
||
),
|
||
wiki_extraction_instructions_raw=(
|
||
raw_extraction_instructions if body.wiki_enabled else ""
|
||
),
|
||
wiki_generation_managed=body.wiki_enabled,
|
||
wiki_config_schema_version=(
|
||
WIKI_CONFIG_SCHEMA_VERSION if body.wiki_enabled else None
|
||
),
|
||
wiki_config_revision=1 if body.wiki_enabled else None,
|
||
wiki_config_apply_status=(
|
||
"active" if body.wiki_enabled else "not_applicable"
|
||
),
|
||
wiki_config_apply_error=None,
|
||
)
|
||
trace_stage(trace, "local_mapping_create", stage_started)
|
||
except Exception as exc:
|
||
finish_trace(trace)
|
||
logger.exception(
|
||
"Failed to initialize or persist LLMWiki mapping; compensating remote create"
|
||
)
|
||
try:
|
||
await client.delete_knowledge_base(str(remote["id"]))
|
||
except Exception:
|
||
logger.exception("Failed to compensate orphan WeKnora knowledge base")
|
||
if isinstance(exc, WeKnoraError):
|
||
_raise_upstream(exc)
|
||
raise HTTPException(status_code=500, detail="Failed to register the knowledge base") from None
|
||
_schedule_local_wiki_sync(request, row)
|
||
finish_trace(trace)
|
||
return _mapping_view(row, remote, actor_user_id=user_id, is_admin=is_admin)
|
||
|
||
|
||
@router.get("/api/llmwiki/knowledge-bases/{mapping_id}")
|
||
async def get_knowledge_base(request: Request, mapping_id: str) -> dict[str, Any]:
|
||
row, user_id, is_admin = await _authorized_mapping(request, mapping_id, write=False)
|
||
client = _client_or_503()
|
||
try:
|
||
remote = await client.get_knowledge_base(str(row["weknora_id"]))
|
||
except WeKnoraError as exc:
|
||
_raise_upstream(exc)
|
||
row, remote = await _sync_conversation_deposit_view(_store(request), client, row, remote)
|
||
return _mapping_view(row, remote, actor_user_id=user_id, is_admin=is_admin)
|
||
|
||
|
||
@router.patch("/api/llmwiki/knowledge-bases/{mapping_id}")
|
||
async def update_knowledge_base(request: Request, mapping_id: str, body: KnowledgeBaseUpdate) -> dict[str, Any]:
|
||
row, user_id, is_admin = await _authorized_mapping(request, mapping_id, write=True)
|
||
changes = body.model_dump(exclude_none=True)
|
||
if "name" in changes:
|
||
changes["name"] = changes["name"].strip()
|
||
if not changes["name"]:
|
||
raise HTTPException(status_code=422, detail="知识库名称不能为空")
|
||
if changes["name"] == CONVERSATION_DEPOSIT_KB_NAME:
|
||
raise HTTPException(status_code=409, detail="对话沉淀是 cmzs 系统保留知识库")
|
||
questions_were_set = "test_questions" in body.model_fields_set
|
||
questions = None
|
||
if questions_were_set:
|
||
questions = list(dict.fromkeys(str(item).strip() for item in (body.test_questions or []) if str(item).strip()))
|
||
if any(len(item) > 500 for item in questions):
|
||
raise HTTPException(status_code=422, detail="测试问题不能超过 500 个字符")
|
||
retrieval_settings = None
|
||
if body.retrieval_settings is not None:
|
||
retrieval_settings = body.retrieval_settings.model_dump()
|
||
wiki_config_fields = (
|
||
set(body.wiki_config.model_fields_set)
|
||
if body.wiki_config is not None
|
||
else set()
|
||
)
|
||
directory_paths_were_set = "directory_paths" in wiki_config_fields
|
||
parent_directories_were_set = bool(
|
||
"parent_directories" in wiki_config_fields
|
||
)
|
||
directory_config_were_set = (
|
||
directory_paths_were_set or parent_directories_were_set
|
||
)
|
||
client = _client_or_503()
|
||
remote_before: dict[str, Any] | None = None
|
||
if wiki_config_fields:
|
||
try:
|
||
remote_before = await client.get_knowledge_base(str(row["weknora_id"]))
|
||
except WeKnoraError as exc:
|
||
_raise_upstream(exc)
|
||
strategy = remote_before.get("indexing_strategy")
|
||
remote_wiki_enabled = (
|
||
strategy.get("wiki_enabled")
|
||
if isinstance(strategy, dict)
|
||
else None
|
||
)
|
||
if row.get("wiki_generation_managed") is False or remote_wiki_enabled is False:
|
||
raise HTTPException(
|
||
status_code=409,
|
||
detail="当前知识库未启用 Wiki 生成,不能保存 Wiki 生成配置",
|
||
)
|
||
|
||
current_paths = _persisted_directory_paths(row)
|
||
directory_paths = current_paths
|
||
parent_directories = directory_roots(current_paths)
|
||
current_remote_wiki = (
|
||
dict(remote_before.get("wiki_config"))
|
||
if isinstance(remote_before, dict)
|
||
and isinstance(remote_before.get("wiki_config"), dict)
|
||
else {}
|
||
)
|
||
current_content = _raw_wiki_text(
|
||
row,
|
||
current_remote_wiki,
|
||
"wiki_content_instructions_raw",
|
||
"content_instructions",
|
||
)
|
||
current_extraction = _raw_wiki_text(
|
||
row,
|
||
current_remote_wiki,
|
||
"wiki_extraction_instructions_raw",
|
||
"extraction_instructions",
|
||
)
|
||
current_focus = str(row.get("wiki_focus_instructions") or "")
|
||
raw_content = current_content
|
||
raw_extraction = current_extraction
|
||
focus_instructions = current_focus
|
||
compiled = None
|
||
config_changed = False
|
||
if wiki_config_fields:
|
||
try:
|
||
if directory_config_were_set:
|
||
directory_paths = resolve_directory_paths(
|
||
body.wiki_config.directory_paths if body.wiki_config else None,
|
||
body.wiki_config.parent_directories if body.wiki_config else None,
|
||
directory_paths_set=directory_paths_were_set,
|
||
parent_directories_set=parent_directories_were_set,
|
||
)
|
||
parent_directories = directory_roots(directory_paths)
|
||
if "focus_instructions" in wiki_config_fields:
|
||
focus_instructions = normalize_focus_instructions(
|
||
body.wiki_config.focus_instructions if body.wiki_config else None
|
||
)
|
||
if "content_instructions" in wiki_config_fields:
|
||
raw_content = str(
|
||
body.wiki_config.content_instructions or ""
|
||
if body.wiki_config
|
||
else ""
|
||
).strip()
|
||
if "extraction_instructions" in wiki_config_fields:
|
||
raw_extraction = str(
|
||
body.wiki_config.extraction_instructions or ""
|
||
if body.wiki_config
|
||
else ""
|
||
).strip()
|
||
compiled = compile_wiki_instructions(
|
||
content_instructions=raw_content,
|
||
extraction_instructions=raw_extraction,
|
||
focus_instructions=focus_instructions,
|
||
directory_paths=directory_paths,
|
||
)
|
||
except ValueError as exc:
|
||
raise HTTPException(status_code=422, detail=str(exc)) from None
|
||
config_changed = any(
|
||
(
|
||
directory_paths != current_paths,
|
||
focus_instructions != current_focus,
|
||
raw_content != current_content,
|
||
raw_extraction != current_extraction,
|
||
)
|
||
)
|
||
|
||
remote_changes = {
|
||
key: value
|
||
for key, value in changes.items()
|
||
if key in {"name", "description"}
|
||
}
|
||
if config_changed and compiled is not None:
|
||
remote_wiki_config: dict[str, str] = {}
|
||
if (
|
||
directory_paths
|
||
or focus_instructions
|
||
or directory_config_were_set
|
||
or "focus_instructions" in wiki_config_fields
|
||
):
|
||
remote_wiki_config = {
|
||
"content_instructions": compiled.content_instructions,
|
||
"extraction_instructions": compiled.extraction_instructions,
|
||
}
|
||
else:
|
||
if "content_instructions" in wiki_config_fields:
|
||
remote_wiki_config["content_instructions"] = raw_content
|
||
if "extraction_instructions" in wiki_config_fields:
|
||
remote_wiki_config["extraction_instructions"] = raw_extraction
|
||
if remote_wiki_config:
|
||
remote_changes["wiki_config"] = remote_wiki_config
|
||
if (
|
||
not remote_changes
|
||
and not questions_were_set
|
||
and retrieval_settings is None
|
||
and not config_changed
|
||
):
|
||
return _mapping_view(
|
||
row,
|
||
remote_before,
|
||
actor_user_id=user_id,
|
||
is_admin=is_admin,
|
||
)
|
||
remote = remote_before
|
||
if remote_changes:
|
||
try:
|
||
remote = await client.update_knowledge_base(str(row["weknora_id"]), remote_changes)
|
||
except WeKnoraError as exc:
|
||
_raise_upstream(exc)
|
||
elif remote is None:
|
||
try:
|
||
remote = await client.get_knowledge_base(str(row["weknora_id"]))
|
||
except WeKnoraError as exc:
|
||
_raise_upstream(exc)
|
||
local_changes: dict[str, Any] = {
|
||
"name": remote_changes.get("name"),
|
||
"description": remote_changes.get("description"),
|
||
"test_questions": questions,
|
||
"test_questions_set": questions_were_set,
|
||
"retrieval_settings": retrieval_settings,
|
||
}
|
||
if config_changed:
|
||
local_changes["wiki_parent_directories"] = parent_directories
|
||
local_changes["wiki_directory_paths"] = directory_paths
|
||
local_changes["wiki_focus_instructions"] = focus_instructions
|
||
local_changes["wiki_content_instructions_raw"] = raw_content
|
||
local_changes["wiki_extraction_instructions_raw"] = raw_extraction
|
||
local_changes["wiki_generation_managed"] = True
|
||
local_changes["wiki_config_schema_version"] = WIKI_CONFIG_SCHEMA_VERSION
|
||
local_changes["wiki_config_revision"] = int(
|
||
row.get("wiki_config_revision") or 0
|
||
) + 1
|
||
local_changes["wiki_config_apply_status"] = "active"
|
||
local_changes["wiki_config_apply_error"] = None
|
||
try:
|
||
updated = await _store(request).update_mapping(mapping_id, **local_changes)
|
||
except Exception:
|
||
# A synchronous Wiki-config update reaches WeKnora before the local
|
||
# projection is committed. Restore the provider's prior effective
|
||
# instructions if that commit fails, so the database does not claim an
|
||
# unapplied revision and a later edit can retry from the same raw state.
|
||
if config_changed and remote_before is not None and remote_changes:
|
||
rollback_changes: dict[str, Any] = {}
|
||
if "name" in remote_changes:
|
||
rollback_changes["name"] = str(remote_before.get("name") or row.get("name") or "")
|
||
if "description" in remote_changes:
|
||
rollback_changes["description"] = str(
|
||
remote_before.get("description")
|
||
if remote_before.get("description") is not None
|
||
else row.get("description") or ""
|
||
)
|
||
if "wiki_config" in remote_changes:
|
||
previous_wiki_config = remote_before.get("wiki_config")
|
||
previous_wiki_config = (
|
||
dict(previous_wiki_config)
|
||
if isinstance(previous_wiki_config, dict)
|
||
else {}
|
||
)
|
||
rollback_changes["wiki_config"] = {
|
||
"content_instructions": str(
|
||
previous_wiki_config.get("content_instructions") or ""
|
||
),
|
||
"extraction_instructions": str(
|
||
previous_wiki_config.get("extraction_instructions") or ""
|
||
),
|
||
}
|
||
try:
|
||
await client.update_knowledge_base(
|
||
str(row["weknora_id"]),
|
||
rollback_changes,
|
||
)
|
||
except Exception:
|
||
logger.exception(
|
||
"Failed to roll back WeKnora after local Wiki-config persistence failed"
|
||
)
|
||
raise
|
||
effective_row = updated or row
|
||
focus_config_was_set = "focus_instructions" in wiki_config_fields
|
||
if focus_config_was_set and config_changed:
|
||
_schedule_local_wiki_sync(
|
||
request,
|
||
effective_row,
|
||
constraints_changed=True,
|
||
)
|
||
return _mapping_view(effective_row, remote, actor_user_id=user_id, is_admin=is_admin)
|
||
|
||
|
||
@router.delete("/api/llmwiki/knowledge-bases/{mapping_id}", status_code=204)
|
||
async def delete_knowledge_base(request: Request, mapping_id: str) -> None:
|
||
row, _, _ = await _authorized_mapping(request, mapping_id, write=True)
|
||
try:
|
||
await _client_or_503().delete_knowledge_base(str(row["weknora_id"]))
|
||
except WeKnoraError as exc:
|
||
# A 404 means the remote data is already gone. The owner/admin must still
|
||
# be able to remove DeerFlow's stale mapping; other upstream failures stay
|
||
# visible and never cause local-only deletion.
|
||
if exc.status_code != 404:
|
||
_raise_upstream(exc)
|
||
await _store(request).delete_mapping(mapping_id)
|
||
|
||
|
||
@router.get("/api/llmwiki/knowledge-bases/{mapping_id}/documents")
|
||
async def list_documents(
|
||
request: Request,
|
||
mapping_id: str,
|
||
page: int = Query(default=1, ge=1),
|
||
page_size: int = Query(default=100, ge=1, le=200),
|
||
) -> dict[str, Any]:
|
||
row, _, _ = await _authorized_mapping(request, mapping_id, write=False)
|
||
try:
|
||
result = await _client_or_503().list_documents(str(row["weknora_id"]), page=page, page_size=page_size)
|
||
except WeKnoraError as exc:
|
||
_raise_upstream(exc)
|
||
return {**result, "items": [_document_view(item) for item in result["items"]]}
|
||
|
||
|
||
@router.get("/api/llmwiki/knowledge-bases/{mapping_id}/documents/{document_id}")
|
||
async def get_document(request: Request, mapping_id: str, document_id: str) -> dict[str, Any]:
|
||
row, _, _ = await _authorized_mapping(request, mapping_id, write=False)
|
||
try:
|
||
document = await _verify_document(_client_or_503(), row, document_id)
|
||
except WeKnoraError as exc:
|
||
_raise_upstream(exc)
|
||
return _document_view(document)
|
||
|
||
|
||
@router.get("/api/llmwiki/knowledge-bases/{mapping_id}/documents/{document_id}/chunks")
|
||
async def list_document_chunks(
|
||
request: Request,
|
||
mapping_id: str,
|
||
document_id: str,
|
||
page: int = Query(default=1, ge=1),
|
||
page_size: int = Query(default=20, ge=1, le=100),
|
||
) -> dict[str, Any]:
|
||
row, _, _ = await _authorized_mapping(request, mapping_id, write=False)
|
||
client = _client_or_503()
|
||
try:
|
||
await _verify_document(client, row, document_id)
|
||
result = await client.list_chunks(document_id, page=page, page_size=page_size)
|
||
except WeKnoraError as exc:
|
||
_raise_upstream(exc)
|
||
return {**result, "items": [_chunk_view(item) for item in result["items"]]}
|
||
|
||
|
||
@router.get("/api/llmwiki/knowledge-bases/{mapping_id}/documents/{document_id}/preview")
|
||
async def preview_document(request: Request, mapping_id: str, document_id: str) -> Response:
|
||
row, _, _ = await _authorized_mapping(request, mapping_id, write=False)
|
||
client = _client_or_503()
|
||
try:
|
||
document = await _verify_document(client, row, document_id)
|
||
content, media_type = await client.get_document_preview(document_id)
|
||
except WeKnoraError as exc:
|
||
_raise_upstream(exc)
|
||
filename = str(document.get("file_name") or document.get("title") or "document")
|
||
return Response(
|
||
content=content,
|
||
media_type=media_type,
|
||
headers={
|
||
"Content-Disposition": f"inline; filename*=UTF-8''{quote(filename)}",
|
||
"Cache-Control": "private, max-age=60",
|
||
},
|
||
)
|
||
|
||
|
||
@router.get("/api/llmwiki/knowledge-bases/{mapping_id}/files")
|
||
async def get_wiki_file(
|
||
request: Request,
|
||
mapping_id: str,
|
||
file_path: str = Query(..., min_length=1, max_length=8192),
|
||
) -> Response:
|
||
"""Serve one protected Wiki image after normal knowledge-base authorization."""
|
||
|
||
row, _, _ = await _authorized_mapping(request, mapping_id, write=False)
|
||
return await _wiki_file_response(row, file_path, public=False)
|
||
|
||
|
||
@router.get("/api/llmwiki/knowledge-bases/{mapping_id}/files/access-url")
|
||
async def create_wiki_file_access(
|
||
request: Request,
|
||
mapping_id: str,
|
||
file_path: str = Query(..., min_length=1, max_length=8192),
|
||
) -> dict[str, Any]:
|
||
"""Issue a browser-usable HTTP capability after normal KB authorization."""
|
||
|
||
row, _, _ = await _authorized_mapping(request, mapping_id, write=False)
|
||
safe_file_path = _validate_protected_wiki_file_path(file_path)
|
||
token = _sign_embed_payload(
|
||
{
|
||
"kind": "wiki_file",
|
||
"mapping_id": mapping_id,
|
||
"weknora_id": str(row["weknora_id"]),
|
||
"file_path": safe_file_path,
|
||
"exp": int(time.time()) + _WIKI_FILE_ACCESS_TTL_SECONDS,
|
||
}
|
||
)
|
||
return {"token": token, "expires_in": _WIKI_FILE_ACCESS_TTL_SECONDS}
|
||
|
||
|
||
@router.get("/api/llmwiki/knowledge-bases/{mapping_id}/graph")
|
||
async def get_knowledge_base_graph(
|
||
request: Request,
|
||
mapping_id: str,
|
||
limit: int = Query(default=200, ge=1, le=500),
|
||
) -> dict[str, Any]:
|
||
row, _, _ = await _authorized_mapping(request, mapping_id, write=False)
|
||
client = _client_or_503()
|
||
try:
|
||
remote = await client.get_knowledge_base(str(row["weknora_id"]))
|
||
strategy = remote.get("indexing_strategy") if isinstance(remote.get("indexing_strategy"), dict) else {}
|
||
if not strategy.get("wiki_enabled"):
|
||
return {
|
||
"nodes": [],
|
||
"edges": [],
|
||
"meta": {"mode": "disabled", "total": 0, "returned": 0, "truncated": False},
|
||
}
|
||
graph = await client.get_wiki_graph(str(row["weknora_id"]), limit=limit)
|
||
except WeKnoraError as exc:
|
||
_raise_upstream(exc)
|
||
nodes = [
|
||
{
|
||
"id": str(node.get("slug") or ""),
|
||
"title": str(node.get("title") or node.get("slug") or ""),
|
||
"type": str(node.get("page_type") or "page"),
|
||
"link_count": int(node.get("link_count") or 0),
|
||
}
|
||
for node in graph.get("nodes", [])
|
||
if isinstance(node, dict) and node.get("slug")
|
||
]
|
||
edges = [{"source": str(edge.get("source") or ""), "target": str(edge.get("target") or "")} for edge in graph.get("edges", []) if isinstance(edge, dict) and edge.get("source") and edge.get("target")]
|
||
meta = graph.get("meta") if isinstance(graph.get("meta"), dict) else {}
|
||
return {"nodes": nodes, "edges": edges, "meta": meta}
|
||
|
||
|
||
def _knowledge_research_store(request: Request):
|
||
store = getattr(request.app.state, "knowledge_research_store", None)
|
||
if store is None:
|
||
raise HTTPException(status_code=503, detail="知识库研究存储尚未就绪")
|
||
return store
|
||
|
||
|
||
async def _knowledge_research_can_write(
|
||
request: Request,
|
||
mapping: dict[str, Any],
|
||
user_id: str,
|
||
is_admin: bool,
|
||
) -> bool:
|
||
if _is_conversation_deposit_mapping(mapping):
|
||
return False
|
||
writable = await _store(request).get_authorized(
|
||
str(mapping["id"]),
|
||
user_id,
|
||
write=True,
|
||
is_admin=is_admin,
|
||
)
|
||
return writable is not None
|
||
|
||
|
||
def _knowledge_research_publication_view(
|
||
row: dict[str, Any],
|
||
*,
|
||
user_id: str,
|
||
can_write: bool,
|
||
include_content: bool = False,
|
||
) -> dict[str, Any] | None:
|
||
if not row.get("saved_hash"):
|
||
return None
|
||
owned = row.get("user_id") == user_id
|
||
result: dict[str, Any] = {
|
||
"id": row["publication_id"],
|
||
"title": row.get("saved_title") or row.get("title") or "",
|
||
"wiki_slug": row.get("wiki_slug") or "",
|
||
"saved_hash": row["saved_hash"],
|
||
"saved_at": row.get("saved_at"),
|
||
"updated_at": row.get("saved_at") or row.get("updated_at"),
|
||
"owned_by_current_user": owned,
|
||
"can_continue": owned,
|
||
"can_write": owned and can_write,
|
||
}
|
||
if owned:
|
||
# Only the creator receives a bridge from the public book to the
|
||
# private research session. The thread id remains session-only.
|
||
result["session_id"] = row["id"]
|
||
if include_content:
|
||
result["content_markdown"] = row.get("saved_markdown") or ""
|
||
return result
|
||
|
||
|
||
def _knowledge_research_session_view(
|
||
row: dict[str, Any],
|
||
*,
|
||
user_id: str,
|
||
can_write: bool,
|
||
) -> dict[str, Any]:
|
||
return {
|
||
"id": row["id"],
|
||
"thread_id": row["thread_id"],
|
||
"title": row.get("title") or "",
|
||
"draft_markdown": row.get("draft_markdown") or "",
|
||
"created_at": row.get("created_at"),
|
||
"updated_at": row.get("updated_at"),
|
||
"can_write": can_write,
|
||
"publication": _knowledge_research_publication_view(
|
||
row,
|
||
user_id=user_id,
|
||
can_write=can_write,
|
||
),
|
||
}
|
||
|
||
|
||
def _knowledge_research_default_title(question: str) -> str:
|
||
for line in question.splitlines():
|
||
normalized = " ".join(line.split())
|
||
if normalized:
|
||
return normalized[:255]
|
||
raise HTTPException(status_code=422, detail="问题不能为空")
|
||
|
||
|
||
@router.get("/api/llmwiki/knowledge-bases/{mapping_id}/research/sessions")
|
||
async def list_knowledge_research_sessions(
|
||
request: Request,
|
||
mapping_id: str,
|
||
) -> dict[str, Any]:
|
||
mapping, user_id, is_admin = await _authorized_mapping(
|
||
request,
|
||
mapping_id,
|
||
write=False,
|
||
)
|
||
can_write = await _knowledge_research_can_write(
|
||
request,
|
||
mapping,
|
||
user_id,
|
||
is_admin,
|
||
)
|
||
store = _knowledge_research_store(request)
|
||
sessions, publications = await asyncio.gather(
|
||
store.list_owned(mapping_id, user_id),
|
||
store.list_published(mapping_id),
|
||
)
|
||
return {
|
||
"sessions": [
|
||
_knowledge_research_session_view(
|
||
row,
|
||
user_id=user_id,
|
||
can_write=can_write,
|
||
)
|
||
for row in sessions
|
||
],
|
||
"publications": [
|
||
view
|
||
for row in publications
|
||
if (
|
||
view := _knowledge_research_publication_view(
|
||
row,
|
||
user_id=user_id,
|
||
can_write=can_write,
|
||
)
|
||
)
|
||
is not None
|
||
],
|
||
}
|
||
|
||
|
||
@router.post("/api/llmwiki/knowledge-bases/{mapping_id}/research/sessions", status_code=201)
|
||
async def create_knowledge_research_session(
|
||
request: Request,
|
||
mapping_id: str,
|
||
body: KnowledgeResearchSessionCreate,
|
||
) -> dict[str, Any]:
|
||
mapping, user_id, is_admin = await _authorized_mapping(
|
||
request,
|
||
mapping_id,
|
||
write=False,
|
||
)
|
||
can_write = await _knowledge_research_can_write(
|
||
request,
|
||
mapping,
|
||
user_id,
|
||
is_admin,
|
||
)
|
||
question = body.question.strip()
|
||
title = _knowledge_research_default_title(question)
|
||
store = _knowledge_research_store(request)
|
||
session_id = str(uuid4())
|
||
publication_id = str(uuid4())
|
||
thread_id = str(uuid4())
|
||
session = await store.create(
|
||
session_id=session_id,
|
||
publication_id=publication_id,
|
||
thread_id=thread_id,
|
||
knowledge_base_id=mapping_id,
|
||
user_id=user_id,
|
||
title=title,
|
||
)
|
||
metadata = {
|
||
"system": True,
|
||
"thread_type": "knowledge_research",
|
||
"knowledge_research_session_id": session_id,
|
||
"knowledge_research_knowledge_base_id": mapping_id,
|
||
"llmwiki_knowledge_base_ids": [mapping_id],
|
||
"assistant_knowledge_base_ids": [],
|
||
"knowledge_retrieval_mode": "classic",
|
||
"knowledge_space_strict": True,
|
||
"rag_mode_enabled": True,
|
||
"rag_mode": True,
|
||
}
|
||
try:
|
||
from app.gateway.routers.threads import ThreadCreateRequest, create_thread
|
||
|
||
await create_thread(
|
||
ThreadCreateRequest(
|
||
thread_id=thread_id,
|
||
assistant_id=None,
|
||
metadata=metadata,
|
||
initialize_checkpoint=True,
|
||
),
|
||
request,
|
||
)
|
||
await get_thread_store(request).update_display_name(
|
||
thread_id,
|
||
title,
|
||
user_id=user_id,
|
||
)
|
||
except Exception:
|
||
# Thread creation writes metadata before its initial checkpoint. Undo
|
||
# every side on failure so no half-bound private chat remains.
|
||
await store.delete(session_id)
|
||
try:
|
||
checkpointer = get_checkpointer(request)
|
||
if hasattr(checkpointer, "adelete_thread"):
|
||
await checkpointer.adelete_thread(thread_id)
|
||
except Exception:
|
||
logger.debug("Could not remove failed research checkpoint", exc_info=True)
|
||
try:
|
||
await get_thread_store(request).delete(thread_id, user_id=user_id)
|
||
except Exception:
|
||
logger.debug("Could not remove failed research thread metadata", exc_info=True)
|
||
raise
|
||
return _knowledge_research_session_view(
|
||
session,
|
||
user_id=user_id,
|
||
can_write=can_write,
|
||
)
|
||
|
||
|
||
async def _owned_knowledge_research_session(
|
||
request: Request,
|
||
mapping_id: str,
|
||
session_id: str,
|
||
) -> tuple[dict[str, Any], dict[str, Any], str, bool, bool]:
|
||
mapping, user_id, is_admin = await _authorized_mapping(
|
||
request,
|
||
mapping_id,
|
||
write=False,
|
||
)
|
||
session = await _knowledge_research_store(request).get_owned(
|
||
session_id,
|
||
user_id,
|
||
)
|
||
if session is None or session["knowledge_base_id"] != mapping_id:
|
||
raise HTTPException(status_code=404, detail="研究会话不存在或无权访问")
|
||
can_write = await _knowledge_research_can_write(
|
||
request,
|
||
mapping,
|
||
user_id,
|
||
is_admin,
|
||
)
|
||
return session, mapping, user_id, is_admin, can_write
|
||
|
||
|
||
@router.get(
|
||
"/api/llmwiki/knowledge-bases/{mapping_id}/research/sessions/{session_id}"
|
||
)
|
||
async def get_knowledge_research_session(
|
||
request: Request,
|
||
mapping_id: str,
|
||
session_id: str,
|
||
) -> dict[str, Any]:
|
||
session, _, user_id, _, can_write = await _owned_knowledge_research_session(
|
||
request,
|
||
mapping_id,
|
||
session_id,
|
||
)
|
||
return _knowledge_research_session_view(
|
||
session,
|
||
user_id=user_id,
|
||
can_write=can_write,
|
||
)
|
||
|
||
|
||
@router.patch(
|
||
"/api/llmwiki/knowledge-bases/{mapping_id}/research/sessions/{session_id}"
|
||
)
|
||
async def update_knowledge_research_session(
|
||
request: Request,
|
||
mapping_id: str,
|
||
session_id: str,
|
||
body: KnowledgeResearchSessionUpdate,
|
||
) -> dict[str, Any]:
|
||
current, _, user_id, _, can_write = await _owned_knowledge_research_session(
|
||
request,
|
||
mapping_id,
|
||
session_id,
|
||
)
|
||
fields = body.model_fields_set
|
||
if not fields:
|
||
return _knowledge_research_session_view(
|
||
current,
|
||
user_id=user_id,
|
||
can_write=can_write,
|
||
)
|
||
title = body.title.strip() if "title" in fields and body.title is not None else None
|
||
if "title" in fields and not title:
|
||
raise HTTPException(status_code=422, detail="标题不能为空")
|
||
updated = await _knowledge_research_store(request).update_owned(
|
||
session_id,
|
||
user_id,
|
||
title=title,
|
||
draft_markdown=(
|
||
body.draft_markdown if "draft_markdown" in fields else None
|
||
),
|
||
)
|
||
if updated is None:
|
||
raise HTTPException(status_code=404, detail="研究会话不存在或无权访问")
|
||
if title is not None:
|
||
try:
|
||
await get_thread_store(request).update_display_name(
|
||
updated["thread_id"],
|
||
title,
|
||
user_id=user_id,
|
||
)
|
||
except Exception:
|
||
logger.debug("Could not update research thread title", exc_info=True)
|
||
return _knowledge_research_session_view(
|
||
updated,
|
||
user_id=user_id,
|
||
can_write=can_write,
|
||
)
|
||
|
||
|
||
async def _upsert_knowledge_research_wiki_page(
|
||
*,
|
||
client: WeKnoraClient,
|
||
knowledge_base_id: str,
|
||
slug: str,
|
||
payload: dict[str, Any],
|
||
previously_saved: bool,
|
||
) -> dict[str, Any]:
|
||
if previously_saved:
|
||
try:
|
||
return await client.update_wiki_page(
|
||
knowledge_base_id,
|
||
slug,
|
||
payload,
|
||
)
|
||
except WeKnoraError as exc:
|
||
if exc.status_code != 404:
|
||
raise
|
||
return await client.create_wiki_page(
|
||
knowledge_base_id,
|
||
{"slug": slug, **payload},
|
||
)
|
||
try:
|
||
return await client.create_wiki_page(
|
||
knowledge_base_id,
|
||
{"slug": slug, **payload},
|
||
)
|
||
except WeKnoraError as create_error:
|
||
# A prior request may have committed upstream before its local DB write
|
||
# failed. Probe the stable slug and turn the retry into an update.
|
||
if create_error.status_code not in {400, 409}:
|
||
raise
|
||
try:
|
||
await client.get_wiki_page(knowledge_base_id, slug)
|
||
except WeKnoraError:
|
||
raise create_error
|
||
return await client.update_wiki_page(knowledge_base_id, slug, payload)
|
||
|
||
|
||
@router.put(
|
||
"/api/llmwiki/knowledge-bases/{mapping_id}/research/sessions/{session_id}/publication"
|
||
)
|
||
async def save_knowledge_research_publication(
|
||
request: Request,
|
||
mapping_id: str,
|
||
session_id: str,
|
||
body: KnowledgeResearchPublicationSave,
|
||
) -> dict[str, Any]:
|
||
# Write authorization and private-session ownership are independent: even
|
||
# an administrator may not publish another user's private conversation.
|
||
mapping, user_id, _ = await _authorized_mapping(request, mapping_id, write=True)
|
||
if _is_conversation_deposit_mapping(mapping):
|
||
raise HTTPException(
|
||
status_code=403,
|
||
detail="Conversation deposit is managed by cmzs",
|
||
)
|
||
title = body.title.strip()
|
||
if not title:
|
||
raise HTTPException(status_code=422, detail="标题不能为空")
|
||
store = _knowledge_research_store(request)
|
||
client = _client_or_503()
|
||
remote_id = str(mapping["weknora_id"])
|
||
saved: dict[str, Any] | None = None
|
||
async with store.locked_publication(session_id, user_id) as publication:
|
||
if publication is None:
|
||
raise HTTPException(status_code=404, detail="研究会话不存在或无权访问")
|
||
session = publication.snapshot()
|
||
if session["knowledge_base_id"] != mapping_id:
|
||
raise HTTPException(status_code=404, detail="研究会话不存在或无权访问")
|
||
markdown = str(session.get("draft_markdown") or "")
|
||
if not markdown.strip():
|
||
raise HTTPException(status_code=422, detail="请先生成或填写 Markdown 正文")
|
||
digest = hashlib.sha256(
|
||
json.dumps(
|
||
{"title": title, "markdown": markdown},
|
||
ensure_ascii=False,
|
||
sort_keys=True,
|
||
separators=(",", ":"),
|
||
).encode("utf-8")
|
||
).hexdigest()
|
||
current_saved_hash = session.get("saved_hash")
|
||
if current_saved_hash == digest:
|
||
# A prior identical request may have committed both upstream and
|
||
# locally while its HTTP response was lost. Treat that retry as
|
||
# a no-op even if it carries the pre-save expected hash.
|
||
return _knowledge_research_session_view(
|
||
session,
|
||
user_id=user_id,
|
||
can_write=True,
|
||
)
|
||
if current_saved_hash and body.expected_saved_hash is None:
|
||
raise HTTPException(
|
||
status_code=409,
|
||
detail={
|
||
"code": "KNOWLEDGE_RESEARCH_EXPECTED_HASH_REQUIRED",
|
||
"message": "覆盖已保存文档时必须携带当前版本,请刷新后再保存。",
|
||
"saved_hash": current_saved_hash,
|
||
},
|
||
)
|
||
if (
|
||
body.expected_saved_hash is not None
|
||
and body.expected_saved_hash != current_saved_hash
|
||
):
|
||
raise HTTPException(
|
||
status_code=409,
|
||
detail={
|
||
"code": "KNOWLEDGE_RESEARCH_PUBLICATION_CHANGED",
|
||
"message": "文档已被更新,请刷新后再保存。",
|
||
"saved_hash": current_saved_hash,
|
||
},
|
||
)
|
||
|
||
slug = str(
|
||
session.get("wiki_slug")
|
||
or f"knowledge-research/{session['publication_id']}"
|
||
)
|
||
try:
|
||
folder_ids = await ensure_directory_paths(
|
||
client,
|
||
remote_id,
|
||
[[KNOWLEDGE_RESEARCH_DIRECTORY]],
|
||
)
|
||
folder_id = folder_ids.get(KNOWLEDGE_RESEARCH_DIRECTORY)
|
||
if not folder_id:
|
||
raise WeKnoraError("未能创建深度研究 Wiki 目录", status_code=502)
|
||
page = await _upsert_knowledge_research_wiki_page(
|
||
client=client,
|
||
knowledge_base_id=remote_id,
|
||
slug=slug,
|
||
previously_saved=bool(current_saved_hash),
|
||
payload={
|
||
"title": title,
|
||
"content": markdown,
|
||
"summary": "知识库问答沉淀",
|
||
"page_type": "summary",
|
||
"status": "published",
|
||
"category_path": [KNOWLEDGE_RESEARCH_DIRECTORY],
|
||
"folder_id": folder_id,
|
||
},
|
||
)
|
||
except WeKnoraError as exc:
|
||
if exc.status_code == 409:
|
||
raise HTTPException(status_code=409, detail=str(exc)) from None
|
||
_raise_upstream(exc)
|
||
|
||
saved = publication.save(
|
||
title=title,
|
||
markdown=markdown,
|
||
saved_hash=digest,
|
||
wiki_slug=slug,
|
||
wiki_page_id=str(page.get("id") or page.get("page_id") or "")
|
||
or None,
|
||
wiki_folder_id=folder_id,
|
||
)
|
||
assert saved is not None
|
||
_schedule_local_wiki_sync(request, mapping)
|
||
return _knowledge_research_session_view(
|
||
saved,
|
||
user_id=user_id,
|
||
can_write=True,
|
||
)
|
||
|
||
|
||
@router.get(
|
||
"/api/llmwiki/knowledge-bases/{mapping_id}/research/publications/{publication_id}"
|
||
)
|
||
async def get_knowledge_research_publication(
|
||
request: Request,
|
||
mapping_id: str,
|
||
publication_id: str,
|
||
) -> dict[str, Any]:
|
||
mapping, user_id, is_admin = await _authorized_mapping(
|
||
request,
|
||
mapping_id,
|
||
write=False,
|
||
)
|
||
publication = await _knowledge_research_store(request).get_publication(
|
||
publication_id
|
||
)
|
||
if (
|
||
publication is None
|
||
or publication["knowledge_base_id"] != mapping_id
|
||
):
|
||
raise HTTPException(status_code=404, detail="研究文档不存在")
|
||
can_write = await _knowledge_research_can_write(
|
||
request,
|
||
mapping,
|
||
user_id,
|
||
is_admin,
|
||
)
|
||
view = _knowledge_research_publication_view(
|
||
publication,
|
||
user_id=user_id,
|
||
can_write=can_write,
|
||
include_content=True,
|
||
)
|
||
if view is None:
|
||
raise HTTPException(status_code=404, detail="研究文档不存在")
|
||
return view
|
||
|
||
|
||
@router.get("/api/llmwiki/knowledge-bases/{mapping_id}/wiki/pages")
|
||
async def list_wiki_pages(
|
||
request: Request,
|
||
mapping_id: str,
|
||
page: int = Query(default=1, ge=1),
|
||
page_size: int = Query(default=50, ge=1, le=500),
|
||
query: str = Query(default="", max_length=255),
|
||
) -> dict[str, Any]:
|
||
row, _, _ = await _authorized_mapping(request, mapping_id, write=False)
|
||
try:
|
||
result = await _client_or_503().list_wiki_pages(
|
||
str(row["weknora_id"]),
|
||
page=page,
|
||
page_size=page_size,
|
||
query=query.strip(),
|
||
)
|
||
raw_pages = result.get("pages") if isinstance(result, dict) else []
|
||
pages = [view for page_item in (raw_pages if isinstance(raw_pages, list) else []) if isinstance(page_item, dict) for view in [_wiki_page_view(page_item)] if view is not None]
|
||
return {**result, "pages": pages}
|
||
except WeKnoraError as exc:
|
||
_raise_upstream(exc)
|
||
|
||
|
||
@router.get("/api/llmwiki/knowledge-bases/{mapping_id}/wiki/export.xlsx")
|
||
async def export_wiki_pages_excel(request: Request, mapping_id: str) -> StreamingResponse:
|
||
"""Export every processed Wiki page with its current and parent directories."""
|
||
|
||
row, _, _ = await _authorized_mapping(request, mapping_id, write=False)
|
||
client = _client_or_503()
|
||
try:
|
||
pages = await _all_wiki_pages(client, str(row["weknora_id"]))
|
||
except WeKnoraError as exc:
|
||
_raise_upstream(exc)
|
||
try:
|
||
output = await asyncio.to_thread(_build_wiki_excel, str(row.get("name") or "LLMWiki"), pages)
|
||
except ImportError as exc:
|
||
raise HTTPException(status_code=500, detail=f"openpyxl is not installed: {exc}") from exc
|
||
timestamp = datetime.now(UTC).strftime("%Y%m%d-%H%M%S")
|
||
safe_name = re.sub(r"[\\/:*?\"<>|\r\n]+", "_", str(row.get("name") or "LLMWiki")).strip(" ._") or "LLMWiki"
|
||
filename = f"{safe_name}-Wiki-{timestamp}.xlsx"
|
||
return StreamingResponse(
|
||
output,
|
||
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||
headers={"Content-Disposition": f"attachment; filename=\"wiki-export.xlsx\"; filename*=UTF-8''{quote(filename)}"},
|
||
)
|
||
|
||
|
||
@router.post("/api/llmwiki/knowledge-bases/{mapping_id}/wiki/pages")
|
||
async def create_wiki_page(request: Request, mapping_id: str, body: WikiPageCreate) -> dict[str, Any]:
|
||
row, _, _ = await _authorized_mapping(request, mapping_id, write=True)
|
||
payload = body.model_dump(exclude_none=True)
|
||
try:
|
||
page = await _client_or_503().create_wiki_page(str(row["weknora_id"]), payload)
|
||
_schedule_local_wiki_sync(request, row)
|
||
return _wiki_page_view(page) or page
|
||
except WeKnoraError as exc:
|
||
_raise_upstream(exc)
|
||
|
||
|
||
@router.get("/api/llmwiki/knowledge-bases/{mapping_id}/wiki/pages/{slug:path}")
|
||
async def get_wiki_page(request: Request, mapping_id: str, slug: str) -> dict[str, Any]:
|
||
row, _, _ = await _authorized_mapping(request, mapping_id, write=False)
|
||
try:
|
||
page = await _client_or_503().get_wiki_page(str(row["weknora_id"]), slug)
|
||
return _wiki_page_view(page) or page
|
||
except WeKnoraError as exc:
|
||
_raise_upstream(exc)
|
||
|
||
|
||
@router.patch("/api/llmwiki/knowledge-bases/{mapping_id}/wiki/pages/{slug:path}")
|
||
async def update_wiki_page(request: Request, mapping_id: str, slug: str, body: WikiPageUpdate) -> dict[str, Any]:
|
||
row, _, _ = await _authorized_mapping(request, mapping_id, write=True)
|
||
changes = body.model_dump(exclude_none=True)
|
||
if not changes:
|
||
try:
|
||
page = await _client_or_503().get_wiki_page(str(row["weknora_id"]), slug)
|
||
return _wiki_page_view(page) or page
|
||
except WeKnoraError as exc:
|
||
_raise_upstream(exc)
|
||
try:
|
||
page = await _client_or_503().update_wiki_page(str(row["weknora_id"]), slug, changes)
|
||
_schedule_local_wiki_sync(request, row)
|
||
return _wiki_page_view(page) or page
|
||
except WeKnoraError as exc:
|
||
_raise_upstream(exc)
|
||
|
||
|
||
@router.delete("/api/llmwiki/knowledge-bases/{mapping_id}/wiki/pages/{slug:path}", status_code=204)
|
||
async def delete_wiki_page(request: Request, mapping_id: str, slug: str) -> None:
|
||
row, _, _ = await _authorized_mapping(request, mapping_id, write=True)
|
||
try:
|
||
await _client_or_503().delete_wiki_page(str(row["weknora_id"]), slug)
|
||
_schedule_local_wiki_sync(request, row)
|
||
except WeKnoraError as exc:
|
||
_raise_upstream(exc)
|
||
|
||
|
||
@router.post("/api/llmwiki/knowledge-bases/{mapping_id}/documents", status_code=201)
|
||
async def upload_document(request: Request, mapping_id: str, file: UploadFile = File(...)) -> dict[str, Any]:
|
||
row, _, _ = await _authorized_mapping(request, mapping_id, write=True)
|
||
content = await file.read(_MAX_UPLOAD_BYTES + 1)
|
||
if len(content) > _MAX_UPLOAD_BYTES:
|
||
raise HTTPException(status_code=413, detail="File exceeds the 100 MB cmzs upload limit")
|
||
if not file.filename:
|
||
raise HTTPException(status_code=422, detail="File name is required")
|
||
if looks_like_knowledge_package(content):
|
||
raise HTTPException(status_code=422, detail=_KNOWLEDGE_PACKAGE_UPLOAD_DETAIL)
|
||
try:
|
||
result = await _client_or_503().upload_document(
|
||
str(row["weknora_id"]),
|
||
filename=file.filename,
|
||
content=content,
|
||
content_type=file.content_type or "application/octet-stream",
|
||
)
|
||
_schedule_local_wiki_sync(request, row)
|
||
return result
|
||
except WeKnoraError as exc:
|
||
_raise_upstream(exc)
|
||
|
||
|
||
@router.post("/api/llmwiki/knowledge-bases/{mapping_id}/documents/url", status_code=201)
|
||
async def import_document_url(request: Request, mapping_id: str, body: KnowledgeUrlImport) -> dict[str, Any]:
|
||
row, _, _ = await _authorized_mapping(request, mapping_id, write=True)
|
||
url = body.url.strip()
|
||
if not url.lower().startswith(("http://", "https://")):
|
||
raise HTTPException(status_code=422, detail="URL must start with http:// or https://")
|
||
try:
|
||
result = await _client_or_503().import_document_url(str(row["weknora_id"]), url=url)
|
||
_schedule_local_wiki_sync(request, row)
|
||
return result
|
||
except WeKnoraError as exc:
|
||
_raise_upstream(exc)
|
||
|
||
|
||
@router.post("/api/llmwiki/knowledge-bases/{mapping_id}/documents/manual", status_code=201)
|
||
async def create_manual_document(request: Request, mapping_id: str, body: ManualKnowledgeCreate) -> dict[str, Any]:
|
||
row, _, _ = await _authorized_mapping(request, mapping_id, write=True)
|
||
try:
|
||
result = await _client_or_503().create_manual_document(
|
||
str(row["weknora_id"]),
|
||
title=body.title.strip(),
|
||
content=body.content,
|
||
)
|
||
_schedule_local_wiki_sync(request, row)
|
||
return result
|
||
except WeKnoraError as exc:
|
||
_raise_upstream(exc)
|
||
|
||
|
||
@router.delete("/api/llmwiki/knowledge-bases/{mapping_id}/documents/{document_id}", status_code=204)
|
||
async def delete_document(request: Request, mapping_id: str, document_id: str) -> None:
|
||
row, _, _ = await _authorized_mapping(request, mapping_id, write=True)
|
||
client = _client_or_503()
|
||
try:
|
||
await _verify_document(client, row, document_id)
|
||
await client.delete_document(document_id)
|
||
_schedule_local_wiki_sync(request, row)
|
||
except WeKnoraError as exc:
|
||
_raise_upstream(exc)
|
||
|
||
|
||
@router.post("/api/llmwiki/knowledge-bases/{mapping_id}/documents/{document_id}/reprocess")
|
||
async def reprocess_document(request: Request, mapping_id: str, document_id: str) -> dict[str, Any]:
|
||
row, _, _ = await _authorized_mapping(request, mapping_id, write=True)
|
||
client = _client_or_503()
|
||
try:
|
||
await _verify_document(client, row, document_id)
|
||
result = await client.reprocess_document(document_id)
|
||
_schedule_local_wiki_sync(request, row)
|
||
return result
|
||
except WeKnoraError as exc:
|
||
_raise_upstream(exc)
|
||
|
||
|
||
@router.post("/api/llmwiki/search")
|
||
async def search_llmwiki(request: Request, body: SearchRequest) -> dict[str, Any]:
|
||
user_id, is_admin = await _actor(request)
|
||
store = _store(request)
|
||
if body.knowledge_base_ids:
|
||
rows: list[dict[str, Any]] = []
|
||
for mapping_id in dict.fromkeys(body.knowledge_base_ids):
|
||
row = await store.get_authorized(mapping_id, user_id, write=False, is_admin=is_admin)
|
||
if row is None:
|
||
raise HTTPException(status_code=404, detail="Knowledge base not found")
|
||
rows.append(row)
|
||
else:
|
||
rows = await store.list_visible(user_id, scope=body.scope, is_admin=False)
|
||
service = getattr(request.app.state, "llmwiki_retrieval_service", None)
|
||
if service is None:
|
||
raise HTTPException(status_code=503, detail={"code": "WIKI_RETRIEVAL_UNAVAILABLE"})
|
||
include_drafts = body.include_drafts and (is_admin or all(str(row.get("owner_user_id")) == user_id for row in rows))
|
||
try:
|
||
result = await service.search(body.query, rows, top_k_pages=body.top_k, include_drafts=include_drafts)
|
||
except WikiIndexError as exc:
|
||
raise HTTPException(status_code=exc.status_code, detail={"code": exc.code, "message": str(exc)}) from None
|
||
mapping_by_id = {str(row["id"]): row for row in rows}
|
||
result["results"] = [_sanitize_conversation_deposit_value(item) if _is_conversation_deposit_mapping(mapping_by_id.get(str(item.get("knowledge_base_id") or ""), {})) else item for item in result.get("results") or []]
|
||
return result
|
||
|
||
|
||
@router.post("/api/llmwiki/retrieval-debug")
|
||
async def debug_llmwiki_retrieval(request: Request, body: RetrievalDebugRequest) -> dict[str, Any]:
|
||
"""Preview the same bounded evidence that a normal Q&A turn would receive.
|
||
|
||
The endpoint deliberately calls :func:`build_llmwiki_rag_context` instead
|
||
of the vector-only API. Its response therefore exposes the real
|
||
vector/keyword/Wiki-page/original-chunk mode, thresholds, warnings and
|
||
exact prompt-bounded evidence without starting a model run.
|
||
"""
|
||
|
||
await _require_admin(request)
|
||
clean_query = body.query.strip()
|
||
if not clean_query:
|
||
raise HTTPException(status_code=422, detail="检索问题不能为空")
|
||
ordinary_ids = list(dict.fromkeys(item.strip() for item in body.knowledge_base_ids if item.strip()))
|
||
assistant_ids = list(
|
||
dict.fromkeys(item.strip() for item in body.assistant_knowledge_base_ids if item.strip())
|
||
)
|
||
if not ordinary_ids and not assistant_ids:
|
||
raise HTTPException(status_code=422, detail="请至少选择一个知识空间")
|
||
synthetic_body = SimpleNamespace(
|
||
assistant_id=None,
|
||
metadata=None,
|
||
context={
|
||
"llmwiki_knowledge_base_ids": ordinary_ids,
|
||
"assistant_knowledge_base_ids": assistant_ids,
|
||
},
|
||
)
|
||
rag = await build_llmwiki_rag_context(
|
||
request=request,
|
||
body=synthetic_body,
|
||
graph_input={"messages": [HumanMessage(content=clean_query)]},
|
||
)
|
||
trace = dict(rag.trace)
|
||
trace["trigger"] = "debug"
|
||
return trace
|
||
|
||
|
||
@router.post("/api/llmwiki/conversation-deposits/turn", status_code=202)
|
||
async def create_conversation_deposit(
|
||
request: Request,
|
||
background_tasks: BackgroundTasks,
|
||
body: ConversationDepositCreate,
|
||
) -> dict[str, Any]:
|
||
runtime = get_resolved_llmwiki_runtime(getattr(request.app.state, "config", None))
|
||
if not runtime.weknora_enabled:
|
||
return {"queued": False, "provider": runtime.provider}
|
||
user_id, _ = await _actor(request)
|
||
if not await get_thread_store(request).check_access(body.thread_id, user_id, require_existing=True):
|
||
raise HTTPException(status_code=404, detail="Thread not found")
|
||
app = request.app
|
||
turn = ConversationTurnDeposit(
|
||
thread_id=body.thread_id,
|
||
question=body.question,
|
||
answer=body.answer,
|
||
human_message_id=body.human_message_id,
|
||
assistant_message_id=body.assistant_message_id,
|
||
assistant_id=body.assistant_id,
|
||
knowledge_base_ids=list(dict.fromkeys(item.strip() for item in body.knowledge_base_ids if item.strip())),
|
||
created_by_user_id=user_id,
|
||
)
|
||
background_tasks.add_task(deposit_conversation_turn, app, turn)
|
||
return {"queued": True, "provider": "weknora"}
|
||
|
||
|
||
@router.post("/api/llmwiki/knowledge-bases/{mapping_id}/publish")
|
||
async def request_publish(request: Request, mapping_id: str) -> dict[str, Any]:
|
||
user_id, is_admin = await _actor(request)
|
||
existing = await _store(request).get_authorized(
|
||
mapping_id,
|
||
user_id,
|
||
write=True,
|
||
is_admin=is_admin,
|
||
)
|
||
if existing is None:
|
||
raise HTTPException(status_code=404, detail="Knowledge base not found")
|
||
if _is_conversation_deposit_mapping(existing) and existing.get("publication_status") == "published":
|
||
return {"id": existing["id"], "publication_status": "published"}
|
||
try:
|
||
await _client_or_503().get_knowledge_base(str(existing["weknora_id"]))
|
||
except WeKnoraError as exc:
|
||
_raise_upstream(exc)
|
||
row = await _store(request).request_publish(mapping_id, user_id, is_admin=is_admin)
|
||
if row is None:
|
||
raise HTTPException(status_code=404, detail="Knowledge base not found")
|
||
return {"id": row["id"], "publication_status": row["publication_status"]}
|
||
|
||
|
||
@router.post("/api/llmwiki/knowledge-bases/{mapping_id}/unpublish")
|
||
async def unpublish(request: Request, mapping_id: str) -> dict[str, Any]:
|
||
user_id, is_admin = await _actor(request)
|
||
existing = await _store(request).get_authorized(mapping_id, user_id, write=True, is_admin=is_admin)
|
||
if existing is None:
|
||
raise HTTPException(status_code=404, detail="Knowledge base not found")
|
||
if _is_conversation_deposit_mapping(existing):
|
||
raise HTTPException(status_code=403, detail="Conversation deposit is always public")
|
||
row = await _store(request).unpublish(mapping_id, user_id, is_admin=is_admin)
|
||
if row is None:
|
||
raise HTTPException(status_code=404, detail="Knowledge base not found")
|
||
return {"id": row["id"], "publication_status": row["publication_status"]}
|
||
|
||
|
||
@router.get("/api/llmwiki/sources/resolve")
|
||
async def resolve_source_knowledge_base(
|
||
request: Request,
|
||
knowledge_base_id: str = Query(..., min_length=1, max_length=128),
|
||
) -> dict[str, str]:
|
||
"""Resolve a skill's provider id to an authorized DeerFlow knowledge base."""
|
||
user_id, is_admin = await _actor(request)
|
||
store = _store(request)
|
||
row = await store.get_authorized(knowledge_base_id, user_id, write=False, is_admin=is_admin)
|
||
if row is None:
|
||
# Retrieval MCPs return WeKnora ids; the original-file APIs take mapping
|
||
# ids. Check authorization again after lookup, before exposing that id.
|
||
candidate = await store.get_by_weknora_id(knowledge_base_id)
|
||
if candidate is not None:
|
||
row = await store.get_authorized(str(candidate["id"]), user_id, write=False, is_admin=is_admin)
|
||
if row is None:
|
||
raise HTTPException(status_code=404, detail="Knowledge base not found")
|
||
return {"knowledge_base_id": str(row["id"])}
|
||
|
||
|
||
@router.get("/api/llmwiki/sources/context")
|
||
async def get_source_context(
|
||
request: Request,
|
||
knowledge_base_id: str = Query(..., min_length=1, max_length=128),
|
||
chunk_id: str = Query(..., min_length=1, max_length=128),
|
||
) -> dict[str, Any]:
|
||
row, _, _ = await _authorized_mapping(request, knowledge_base_id, write=False)
|
||
client = _client_or_503()
|
||
try:
|
||
chunks = await client.get_chunk_context(
|
||
chunk_id,
|
||
expected_knowledge_base_id=str(row["weknora_id"]),
|
||
radius=2,
|
||
)
|
||
except WeKnoraError as exc:
|
||
_raise_upstream(exc)
|
||
wiki_page = await _find_wiki_page_for_chunk(
|
||
client,
|
||
knowledge_base_id=str(row["weknora_id"]),
|
||
chunk_id=chunk_id,
|
||
chunks=chunks,
|
||
)
|
||
sanitize_deposit = _is_conversation_deposit_mapping(row)
|
||
if sanitize_deposit and wiki_page is not None:
|
||
wiki_page = _sanitize_conversation_deposit_value(wiki_page)
|
||
focus_chunk = next((chunk for chunk in chunks if str(chunk.get("id") or "") == chunk_id), {})
|
||
document_id = str(focus_chunk.get("knowledge_id") or focus_chunk.get("document_id") or focus_chunk.get("knowledgeId") or "")
|
||
document_title = str(focus_chunk.get("knowledge_title") or focus_chunk.get("title") or focus_chunk.get("file_name") or focus_chunk.get("filename") or "")
|
||
normalized_chunks = [
|
||
{
|
||
"id": str(chunk.get("id") or ""),
|
||
"chunk_index": int(chunk.get("chunk_index") or 0),
|
||
"content": str(_sanitize_conversation_deposit_value(chunk.get("content") or "") if sanitize_deposit else chunk.get("content") or ""),
|
||
"is_focus": str(chunk.get("id") or "") == chunk_id,
|
||
}
|
||
for chunk in chunks
|
||
if chunk.get("id")
|
||
]
|
||
return {
|
||
"success": True,
|
||
"data": {
|
||
"knowledge_base_id": row["id"],
|
||
"knowledge_base_name": row.get("name") or "",
|
||
"document_id": _sanitize_conversation_deposit_value(document_id) if sanitize_deposit else document_id,
|
||
"document_title": _sanitize_conversation_deposit_value(document_title) if sanitize_deposit else document_title,
|
||
"focus_chunk_id": chunk_id,
|
||
"display_mode": "wiki" if wiki_page is not None else "chunks",
|
||
"wiki_page": wiki_page,
|
||
"chunks": normalized_chunks,
|
||
},
|
||
}
|