fix: restore embedded wiki images and optimize vector sync
This commit is contained in:
1 parent
30943159bf
commit
0c5e2e8cb0
23 files changed
+609
-136
No files matched your search
@@ -998,7 +998,7 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
||||
local_wiki_index = app.state.config.llmwiki.local_wiki_index
|
||||
if (
|
||||
local_wiki_index.enabled
|
||||
and local_wiki_index.sync_enabled
|
||||
and local_wiki_index.auto_sync
|
||||
and getattr(app.state, "llmwiki_sync_service", None) is not None
|
||||
):
|
||||
from app.gateway.llmwiki_index_scheduler import local_wiki_index_scheduler_loop
|
||||
|
||||
@@ -321,13 +321,16 @@ async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]:
|
||||
from deerflow.integrations.weknora.local_index.runtime import register_local_wiki_index_runtime
|
||||
from deerflow.persistence.llmwiki_index import make_llmwiki_index_store
|
||||
|
||||
app.state.llmwiki_index_store = make_llmwiki_index_store(sf)
|
||||
local_index_config = config.llmwiki.local_wiki_index
|
||||
app.state.llmwiki_index_store = make_llmwiki_index_store(
|
||||
sf,
|
||||
write_batch_size=local_index_config.database_batch_size,
|
||||
)
|
||||
app.state.llmwiki_embedding = None
|
||||
app.state.llmwiki_vector_cache = None
|
||||
app.state.llmwiki_vector_search = None
|
||||
app.state.llmwiki_sync_service = None
|
||||
app.state.llmwiki_retrieval_service = None
|
||||
local_index_config = config.llmwiki.local_wiki_index
|
||||
from deerflow.integrations.weknora.runtime import build_weknora_client, get_resolved_llmwiki_runtime
|
||||
|
||||
resolved_llmwiki = get_resolved_llmwiki_runtime(config)
|
||||
|
||||
@@ -48,6 +48,10 @@ async def _sync_once(app: FastAPI) -> None:
|
||||
|
||||
async def local_wiki_index_scheduler_loop(app: FastAPI) -> None:
|
||||
interval = app.state.config.llmwiki.local_wiki_index.sync_interval_seconds
|
||||
logger.info(
|
||||
"Local Wiki auto-sync scheduler started; first scan delayed by %s seconds",
|
||||
interval,
|
||||
)
|
||||
while True:
|
||||
await _sync_once(app)
|
||||
await asyncio.sleep(interval)
|
||||
await _sync_once(app)
|
||||
@@ -61,6 +61,10 @@ router = APIRouter(tags=["llmwiki"])
|
||||
proxy_router = APIRouter(tags=["llmwiki-proxy"])
|
||||
|
||||
_MAX_UPLOAD_BYTES = 100 * 1024 * 1024
|
||||
_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_PUBLIC_PREFIX = "/deerflow"
|
||||
_WEKNORA_PUBLIC_ROOT_PATHS = (
|
||||
@@ -242,7 +246,7 @@ def _store(request: Request) -> LlmWikiStore:
|
||||
def _schedule_local_wiki_sync(request: Request, mapping: dict[str, Any]) -> None:
|
||||
service = getattr(request.app.state, "llmwiki_sync_service", None)
|
||||
config = request.app.state.config.llmwiki.local_wiki_index
|
||||
if service is not None and config.enabled and mapping.get("wiki_index_enabled", True):
|
||||
if service is not None and config.enabled and config.auto_sync and mapping.get("wiki_index_enabled", True):
|
||||
from app.gateway.llmwiki_index_scheduler import spawn_local_wiki_index_job
|
||||
|
||||
spawn_local_wiki_index_job(request.app, service.sync_mapping(mapping, force=True))
|
||||
@@ -1085,6 +1089,8 @@ html.deerflow-source-detail-open [data-deerflow-source-detail-panel="true"] [cla
|
||||
|
||||
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 || "";
|
||||
@@ -1104,6 +1110,19 @@ html.deerflow-source-detail-open [data-deerflow-source-detail-panel="true"] [cla
|
||||
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) {{
|
||||
@@ -1116,6 +1135,67 @@ html.deerflow-source-detail-open [data-deerflow-source-detail-panel="true"] [cla
|
||||
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") || "").startsWith("blob:")
|
||||
) return;
|
||||
const attempts = Number(image.dataset.deerflowProtectedAttempts || "0");
|
||||
if (attempts >= 3 || image.dataset.deerflowProtectedLoading === "1") return;
|
||||
image.dataset.deerflowProtectedLoading = "1";
|
||||
image.dataset.deerflowProtectedAttempts = String(attempts + 1);
|
||||
nativeFetch(proxyUrl, {{ credentials: "same-origin", headers: {{ Accept: "image/*" }} }})
|
||||
.then((response) => {{
|
||||
if (!response.ok) throw new Error(`protected image request failed (${{response.status}})`);
|
||||
return response.blob();
|
||||
}})
|
||||
.then((blob) => {{
|
||||
const objectUrl = URL.createObjectURL(blob);
|
||||
image.dataset.deerflowProtectedSource = protectedSrc;
|
||||
image.removeAttribute("data-img-loading");
|
||||
image.removeAttribute("data-wiki-image-error");
|
||||
image.src = objectUrl;
|
||||
image.addEventListener("load", () => URL.revokeObjectURL(objectUrl), {{ once: true }});
|
||||
}})
|
||||
.catch(() => {{
|
||||
image.dataset.wikiImageError = "true";
|
||||
window.setTimeout(() => {{
|
||||
delete image.dataset.deerflowProtectedLoading;
|
||||
hydrateProtectedImage(image);
|
||||
}}, 1200 * (attempts + 1));
|
||||
}})
|
||||
.finally(() => {{
|
||||
delete image.dataset.deerflowProtectedLoading;
|
||||
}});
|
||||
}}
|
||||
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"]
|
||||
}});
|
||||
hydrateProtectedImages();
|
||||
|
||||
function tone(color, alpha) {{
|
||||
return `color-mix(in srgb, ${{color}} ${{Math.round(alpha * 100)}}%, transparent)`;
|
||||
}}
|
||||
@@ -1519,6 +1599,7 @@ html.deerflow-source-detail-open [data-deerflow-source-detail-panel="true"] [cla
|
||||
enforceLocation();
|
||||
purgeGuides();
|
||||
hideWeKnoraShell();
|
||||
hydrateProtectedImages();
|
||||
if (tickSourceDetail() && sourceDetailMode) {{
|
||||
clearInterval(maintenanceTimer);
|
||||
try {{ readyObserver.disconnect(); }} catch (_) {{}}
|
||||
@@ -1820,6 +1901,32 @@ async def _public_mapping(request: Request, mapping_id: str) -> dict[str, Any]:
|
||||
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:
|
||||
try:
|
||||
content, media_type = await _client_or_503().get_knowledge_base_file(
|
||||
str(row["weknora_id"]),
|
||||
_validate_protected_wiki_file_path(file_path),
|
||||
)
|
||||
except WeKnoraError as exc:
|
||||
_raise_upstream(exc)
|
||||
return Response(
|
||||
content=content,
|
||||
media_type=media_type,
|
||||
headers={
|
||||
"Cache-Control": "public, max-age=300" if public else "private, max-age=60",
|
||||
"Content-Disposition": "inline",
|
||||
"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"]):
|
||||
@@ -2510,6 +2617,18 @@ async def list_public_wiki_pages(
|
||||
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)
|
||||
|
||||
|
||||
_PUBLIC_WIKI_INDEX_TYPES = ("summary", "entity", "concept", "synthesis", "comparison")
|
||||
|
||||
|
||||
@@ -2820,6 +2939,18 @@ async def preview_document(request: Request, mapping_id: str, document_id: str)
|
||||
)
|
||||
|
||||
|
||||
@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}/graph")
|
||||
async def get_knowledge_base_graph(
|
||||
request: Request,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
@@ -14,6 +15,7 @@ from deerflow.integrations.weknora.local_index.retrieval import is_fully_vectori
|
||||
from deerflow.integrations.weknora.local_index.search import WikiIndexError
|
||||
|
||||
router = APIRouter(prefix="/api/llmwiki", tags=["llmwiki-index"])
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class WikiVectorSearchRequest(BaseModel):
|
||||
@@ -181,6 +183,7 @@ async def _run_sync(app, mapping_ids: list[str], force: bool) -> None:
|
||||
try:
|
||||
await service.sync_mapping(mapping, force=force)
|
||||
except Exception:
|
||||
logger.exception("Manual Wiki vectorization failed mapping_id=%s", mapping.get("id"))
|
||||
continue
|
||||
|
||||
|
||||
@@ -191,6 +194,7 @@ async def _run_one_sync(app, mapping: dict[str, Any], *, force: bool) -> None:
|
||||
try:
|
||||
await service.sync_mapping(mapping, force=force)
|
||||
except Exception:
|
||||
logger.exception("Manual Wiki vectorization failed mapping_id=%s", mapping.get("id"))
|
||||
return
|
||||
|
||||
|
||||
|
||||
Reference in new issue
Block a user