feat: improve WeKnora source preview and wiki management

This commit is contained in:
吉鑫升 committed 2026-08-17 15:53:19 +08:00
1 parent 94cf37a2ae
commit 3ab2c9b282
19 files changed
+2602 -303

No files matched your search

@@ -179,6 +179,33 @@ class ManualKnowledgeCreate(BaseModel):
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 ConversationDepositCreate(BaseModel):
thread_id: str = Field(..., min_length=1, max_length=128)
question: str = Field(default="", max_length=200_000)
@@ -291,15 +318,17 @@ async def _get_weknora_admin_session(runtime: Any, *, force_refresh: bool = Fals
return cached
try:
async with httpx.AsyncClient(timeout=httpx.Timeout(20.0), follow_redirects=False) as client:
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:
@@ -412,13 +441,14 @@ def _request_kb_id(request: Request, body: Any) -> str:
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) as client:
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")
@@ -502,6 +532,18 @@ async def _authorize_weknora_api_proxy(
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")
@@ -783,6 +825,7 @@ def _weknora_embed_injection(
"tenantId": tenant_id,
"user": user,
"tenant": tenant,
"weknoraId": ctx.weknora_id,
"publicPrefix": public_prefix,
"apiPrefix": _with_public_prefix(public_prefix, _WEKNORA_EMBED_API_PREFIX),
}
@@ -878,6 +921,29 @@ 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">
(() => {{
@@ -1019,6 +1085,143 @@ html.deerflow-weknora-embed [class*=" guide__"] {{
}});
}}
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;
function allowedUrl(url) {{
try {{
@@ -1160,14 +1363,20 @@ html.deerflow-weknora-embed [class*=" guide__"] {{
enforceLocation();
purgeGuides();
hideWeKnoraShell();
tickSourceDetail();
if (ready) return;
if (!force && (location.pathname !== allowedPath || !hasDetailShell())) return;
ready = true;
root.classList.add("deerflow-weknora-ready");
}}
let observerTimer = 0;
const readyObserver = new MutationObserver(() => {{
markReady(false);
refreshThemeSoon();
if (observerTimer) return;
observerTimer = window.setTimeout(() => {{
observerTimer = 0;
markReady(false);
refreshThemeSoon();
}}, 120);
}});
readyObserver.observe(document.documentElement, {{ childList: true, subtree: true, attributes: true, attributeFilter: ["class", "style", "data-theme", "theme-mode"] }});
if (document.readyState === "complete") {{
@@ -1175,12 +1384,23 @@ html.deerflow-weknora-embed [class*=" guide__"] {{
}} else {{
window.addEventListener("load", () => setTimeout(() => markReady(false), 180), {{ once: true }});
}}
setInterval(() => {{
const maintenanceTimer = setInterval(() => {{
enforceLocation();
purgeGuides();
hideWeKnoraShell();
if (tickSourceDetail() && sourceDetailMode) {{
clearInterval(maintenanceTimer);
try {{ readyObserver.disconnect(); }} catch (_) {{}}
}}
}}, 500);
setTimeout(() => markReady(true), 2200);
setTimeout(() => {{
markReady(true);
if (sourceDetailMode && !root.classList.contains("deerflow-source-detail-open")) {{
root.classList.add("deerflow-source-detail-open");
clearInterval(maintenanceTimer);
try {{ readyObserver.disconnect(); }} catch (_) {{}}
}}
}}, 9000);
}})();
</script>
"""
@@ -1236,7 +1456,7 @@ async def _proxy_weknora_request(
url = f"{target_base_url.rstrip('/')}{_encode_upstream_path(upstream_path)}"
try:
async with httpx.AsyncClient(timeout=httpx.Timeout(60.0), follow_redirects=False) as client:
async with httpx.AsyncClient(timeout=httpx.Timeout(60.0), follow_redirects=False, trust_env=False) as client:
response = await client.request(
request.method,
url,
@@ -1244,10 +1464,13 @@ async def _proxy_weknora_request(
headers=headers,
content=body,
)
if is_api and response.status_code == 401:
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')}"
async with httpx.AsyncClient(timeout=httpx.Timeout(60.0), follow_redirects=False) as client:
tenant_id = _tenant_id_from_session(session)
if tenant_id:
headers["X-Tenant-ID"] = tenant_id
async with httpx.AsyncClient(timeout=httpx.Timeout(60.0), follow_redirects=False, trust_env=False) as client:
response = await client.request(
request.method,
url,
@@ -1258,6 +1481,7 @@ async def _proxy_weknora_request(
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
@@ -1577,7 +1801,18 @@ def _wiki_page_view(page: dict[str, Any]) -> dict[str, Any] | None:
"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 [],
}
@@ -1627,31 +1862,6 @@ async def _find_wiki_page_for_chunk(
if view and (view.get("content") or view.get("summary")):
return view
candidate_refs = _candidate_chunk_ref_tokens(focus, chunk_id)
query = str(focus.get("knowledge_title") or focus.get("title") or focus.get("file_name") or "").strip()
try:
page_list = await client.list_wiki_pages(knowledge_base_id, page=1, page_size=100, query=query)
except WeKnoraError as exc:
if exc.status_code != 404:
logger.debug("Failed to list WeKnora wiki pages for source preview", exc_info=True)
return None
for page in page_list.get("pages", []):
if not isinstance(page, dict):
continue
source_refs = _source_ref_tokens(page.get("source_refs"))
if not source_refs or not candidate_refs.intersection(source_refs):
continue
slug = str(page.get("slug") or "").strip()
try:
full_page = await client.get_wiki_page(knowledge_base_id, slug) if slug else page
except WeKnoraError as exc:
if exc.status_code != 404:
logger.debug("Failed to hydrate WeKnora wiki page slug=%s", slug, exc_info=True)
full_page = page
view = _wiki_page_view(full_page)
if view and (view.get("content") or view.get("summary")):
return view
return None
@@ -2187,17 +2397,37 @@ 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=100),
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:
return await _client_or_503().list_wiki_pages(
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.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)
return _wiki_page_view(page) or page
except WeKnoraError as exc:
_raise_upstream(exc)
@@ -2206,7 +2436,34 @@ async def list_wiki_pages(
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:
return await _client_or_503().get_wiki_page(str(row["weknora_id"]), slug)
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)
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)
except WeKnoraError as exc:
_raise_upstream(exc)
@@ -2407,6 +2664,20 @@ async def get_source_context(
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 ""),
@@ -2426,6 +2697,8 @@ async def get_source_context(
"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,
@@ -110,6 +110,7 @@ class WeKnoraClient:
client = self._http_client or httpx.AsyncClient(
timeout=httpx.Timeout(self.timeout_seconds),
follow_redirects=False,
trust_env=False,
)
try:
response = await client.post(
@@ -164,7 +165,11 @@ class WeKnoraClient:
data: Mapping[str, Any] | None = None,
) -> Any:
owns_client = self._http_client is None
client = self._http_client or httpx.AsyncClient(timeout=httpx.Timeout(self.timeout_seconds), follow_redirects=False)
client = self._http_client or httpx.AsyncClient(
timeout=httpx.Timeout(self.timeout_seconds),
follow_redirects=False,
trust_env=False,
)
try:
for attempt in range(2):
response = await client.request(
@@ -392,6 +397,7 @@ class WeKnoraClient:
client = self._http_client or httpx.AsyncClient(
timeout=httpx.Timeout(self.timeout_seconds),
follow_redirects=True,
trust_env=False,
)
try:
for attempt in range(2):
@@ -486,6 +492,36 @@ class WeKnoraClient:
raise WeKnoraError("WeKnora returned an invalid Wiki page")
return dict(value)
async def create_wiki_page(self, knowledge_base_id: str, payload: Mapping[str, Any]) -> dict[str, Any]:
value = _data(
await self._request(
"POST",
f"/api/v1/knowledgebase/{quote(knowledge_base_id, safe='')}/wiki/pages",
json=dict(payload),
)
)
if not isinstance(value, dict):
raise WeKnoraError("WeKnora returned an invalid Wiki page")
return dict(value)
async def update_wiki_page(self, knowledge_base_id: str, slug: str, changes: Mapping[str, Any]) -> dict[str, Any]:
safe_slug = "/".join(quote(part, safe="") for part in slug.split("/"))
value = _data(
await self._request(
"PUT",
f"/api/v1/knowledgebase/{quote(knowledge_base_id, safe='')}/wiki/pages/{safe_slug}",
json=dict(changes),
)
)
return dict(value) if isinstance(value, dict) else {}
async def delete_wiki_page(self, knowledge_base_id: str, slug: str) -> None:
safe_slug = "/".join(quote(part, safe="") for part in slug.split("/"))
await self._request(
"DELETE",
f"/api/v1/knowledgebase/{quote(knowledge_base_id, safe='')}/wiki/pages/{safe_slug}",
)
async def search(self, query: str, knowledge_base_ids: Sequence[str]) -> list[dict[str, Any]]:
ids = [str(value) for value in knowledge_base_ids if str(value)]
if not ids:
@@ -133,6 +133,9 @@ async def llmwiki_search_tool(
{
"knowledge_base_id": mapping["id"],
"chunk_id": visible_result.get("chunk_id") or "",
"knowledge_id": visible_result.get("knowledge_id") or "",
"document_id": visible_result.get("knowledge_id") or "",
"wiki_slug": visible_result.get("wiki_slug") or visible_result.get("slug") or "",
}
)
citations.append(
@@ -551,6 +551,39 @@ async def test_weknora_client_imports_url_manual_content_and_lists_wiki_pages()
assert pages["pages"][0]["slug"] == "guide"
@pytest.mark.asyncio
async def test_weknora_client_creates_updates_and_deletes_wiki_pages() -> None:
seen: list[tuple[str, str]] = []
async def handler(request: httpx.Request) -> httpx.Response:
seen.append((request.method, request.url.path))
if request.method == "POST":
assert request.url.path == "/api/v1/knowledgebase/kb-one/wiki/pages"
assert json.loads(request.content) == {"slug": "folder/guide", "title": "Guide", "content": "# Guide"}
return httpx.Response(200, json={"success": True, "data": {"slug": "folder/guide", "title": "Guide"}})
assert request.url.path == "/api/v1/knowledgebase/kb-one/wiki/pages/folder/guide"
if request.method == "PUT":
assert json.loads(request.content) == {"title": "Guide", "content": "# Guide"}
return httpx.Response(200, json={"success": True, "data": {"slug": "folder/guide", "title": "Guide"}})
if request.method == "DELETE":
return httpx.Response(204)
return httpx.Response(405)
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http_client:
client = WeKnoraClient(base_url="http://weknora.local:8080", http_client=http_client)
created = await client.create_wiki_page("kb-one", {"slug": "folder/guide", "title": "Guide", "content": "# Guide"})
updated = await client.update_wiki_page("kb-one", "folder/guide", {"title": "Guide", "content": "# Guide"})
await client.delete_wiki_page("kb-one", "folder/guide")
assert created["slug"] == "folder/guide"
assert updated["slug"] == "folder/guide"
assert seen == [
("POST", "/api/v1/knowledgebase/kb-one/wiki/pages"),
("PUT", "/api/v1/knowledgebase/kb-one/wiki/pages/folder/guide"),
("DELETE", "/api/v1/knowledgebase/kb-one/wiki/pages/folder/guide"),
]
@pytest.mark.asyncio
async def test_weknora_client_reads_document_chunks_and_wiki_graph() -> None:
async def handler(request: httpx.Request) -> httpx.Response: