7
This commit is contained in:
+529
-210
@@ -1,5 +1,11 @@
|
||||
"""公共 AI 页面路由(/k/**)。
|
||||
|
||||
核心设计:
|
||||
- 主页 /k/{token} = 目录索引(TOC),显示所有目录及其文档数量
|
||||
- 目录页 /k/{token}/category/{path} = 该目录下的文档列表
|
||||
- 文档页 /k/{token}/doc/{doc_token} = 单个文档内容
|
||||
- AI 先看目录索引 → 根据用户描述选择目录 → 进入该目录看文档
|
||||
|
||||
规则:
|
||||
- 零 JS、零 Cookie、零登录、SSR 输出、标准 HTML
|
||||
- <meta name="robots" content="noindex,nofollow,noarchive">
|
||||
@@ -9,6 +15,7 @@
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
|
||||
from fastapi import APIRouter, Depends, Query, Request, Response
|
||||
from fastapi.responses import HTMLResponse, PlainTextResponse
|
||||
@@ -27,7 +34,7 @@ router = APIRouter(prefix="/k", tags=["public"])
|
||||
|
||||
|
||||
def _log_access(db: Session, kb_id: str, path: str, request: Request, doc_id: str | None = None, req_type: str | None = None) -> None:
|
||||
"""记录访问日志(best-effort,不阻塞响应)。"""
|
||||
"""记录访问日志(best-effort)。"""
|
||||
try:
|
||||
ua = request.headers.get("user-agent", "")
|
||||
AccessLogService(db).record(
|
||||
@@ -38,11 +45,10 @@ def _log_access(db: Session, kb_id: str, path: str, request: Request, doc_id: st
|
||||
request_type=req_type,
|
||||
)
|
||||
except Exception:
|
||||
pass # 日志失败不影响响应
|
||||
pass
|
||||
|
||||
|
||||
def _rate_limit(request: Request, token: str) -> None:
|
||||
"""限流检查。"""
|
||||
ip = request.client.host if request.client else "unknown"
|
||||
if not check_rate_limit(token_key=token[:16], ip_key=ip):
|
||||
raise RateLimitedError()
|
||||
@@ -56,7 +62,87 @@ def _referrer_meta() -> str:
|
||||
return '<meta name="referrer" content="no-referrer">'
|
||||
|
||||
|
||||
# --- 知识库入口(后缀路由必须先于无后缀路由注册)---
|
||||
def _get_category_by_path(db: Session, kb_id: str, path: str) -> DocumentCategory | None:
|
||||
"""根据路径查找分类。"""
|
||||
from sqlalchemy import select
|
||||
stmt = select(DocumentCategory).where(
|
||||
DocumentCategory.knowledge_base_id == kb_id,
|
||||
DocumentCategory.path == path,
|
||||
)
|
||||
return db.scalars(stmt).first()
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 目录索引(主页)- AI 从这里了解知识库结构
|
||||
# ============================================================
|
||||
|
||||
|
||||
@router.get("/{token}.json")
|
||||
def kb_index_json(
|
||||
token: str,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
) -> Response:
|
||||
"""知识库完整内容(JSON)- 按目录分组,包含所有文档内容。
|
||||
|
||||
AI 访问此接口获取知识库全部内容,按目录结构组织。
|
||||
"""
|
||||
_rate_limit(request, token)
|
||||
svc = KbPublicService(db)
|
||||
kb = svc.get_kb_by_token(token)
|
||||
_log_access(db, kb.id, f"/k/{token}.json", request, req_type="json")
|
||||
|
||||
category_tree = svc.get_category_tree(kb)
|
||||
|
||||
# 获取所有文档(按目录分组)
|
||||
docs, _ = svc.list_documents(kb)
|
||||
|
||||
# 按 category_id 分组
|
||||
doc_map: dict[str, list] = {}
|
||||
for doc in docs:
|
||||
cat_id = doc.category_id or "__uncategorized__"
|
||||
if cat_id not in doc_map:
|
||||
doc_map[cat_id] = []
|
||||
doc_map[cat_id].append({
|
||||
"title": doc.title or doc.original_filename,
|
||||
"file_type": doc.file_ext,
|
||||
"description": doc.description,
|
||||
"summary": doc.content_summary,
|
||||
"keywords": doc.keywords.split(",") if doc.keywords else [],
|
||||
"updated_at": doc.updated_at,
|
||||
})
|
||||
|
||||
# 递归构建目录 + 文档
|
||||
def build_tree_with_docs(nodes: list) -> list:
|
||||
result = []
|
||||
for node in nodes:
|
||||
cat_docs = doc_map.get(node["id"], [])
|
||||
node_data = {
|
||||
"name": node["name"],
|
||||
"path": node["path"],
|
||||
"is_folder": node["is_folder"],
|
||||
"documents": cat_docs,
|
||||
"children": build_tree_with_docs(node.get("children", [])),
|
||||
}
|
||||
result.append(node_data)
|
||||
return result
|
||||
|
||||
tree_with_docs = build_tree_with_docs(category_tree)
|
||||
|
||||
# 未分类文档
|
||||
uncategorized = doc_map.get("__uncategorized__", [])
|
||||
|
||||
data = {
|
||||
"name": kb.name,
|
||||
"description": kb.description,
|
||||
"categories": tree_with_docs,
|
||||
"uncategorized_documents": uncategorized,
|
||||
}
|
||||
|
||||
return Response(
|
||||
content=json.dumps(data, ensure_ascii=False, indent=2),
|
||||
media_type="application/json",
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{token}.md")
|
||||
@@ -65,33 +151,39 @@ def kb_index_markdown(
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
) -> PlainTextResponse:
|
||||
"""知识库首页(Markdown)。"""
|
||||
"""知识库目录索引(Markdown)。"""
|
||||
_rate_limit(request, token)
|
||||
svc = KbPublicService(db)
|
||||
kb = svc.get_kb_by_token(token)
|
||||
_log_access(db, kb.id, f"/k/{token}.md", request, req_type="md")
|
||||
docs, _ = svc.list_documents(kb)
|
||||
|
||||
category_tree = svc.get_category_tree(kb)
|
||||
|
||||
lines = [f"# {kb.name}", ""]
|
||||
if kb.description:
|
||||
lines.append(kb.description)
|
||||
lines.append("")
|
||||
|
||||
lines.append("## 文档列表")
|
||||
lines.append("## 目录结构")
|
||||
lines.append("")
|
||||
lines.append("请通过以下目录链接访问对应内容:")
|
||||
lines.append("")
|
||||
|
||||
for doc in docs:
|
||||
title = doc.title or doc.original_filename
|
||||
lines.append(f"### {title}")
|
||||
lines.append(f"- 类型:{doc.file_ext}")
|
||||
if doc.description:
|
||||
lines.append(f"- 描述:{doc.description}")
|
||||
if doc.keywords:
|
||||
lines.append(f"- 关键词:{doc.keywords}")
|
||||
if doc.content_summary:
|
||||
lines.append(f"- 摘要:{doc.content_summary}")
|
||||
lines.append(f"- 更新时间:{doc.updated_at}")
|
||||
lines.append("")
|
||||
def render_tree_md(nodes: list, level: int = 0):
|
||||
for node in nodes:
|
||||
prefix = " " * level
|
||||
icon = "📁" if node["is_folder"] else "📄"
|
||||
doc_count = f" ({node['doc_count']}篇)" if node["doc_count"] > 0 else ""
|
||||
url = f"/k/{token}/category{node['path']}"
|
||||
lines.append(f"{prefix}- {icon} [{node['name']}]({url}){doc_count}")
|
||||
if node.get("children"):
|
||||
render_tree_md(node["children"], level + 1)
|
||||
|
||||
render_tree_md(category_tree)
|
||||
|
||||
lines.append("")
|
||||
lines.append("---")
|
||||
lines.append(f"访问各目录链接查看具体文档。如需搜索:/k/{token}/search?q=关键词")
|
||||
|
||||
return PlainTextResponse(content="\n".join(lines), media_type="text/markdown")
|
||||
|
||||
@@ -102,48 +194,272 @@ def kb_index_text(
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
) -> PlainTextResponse:
|
||||
"""知识库首页(纯文本)。"""
|
||||
"""知识库目录索引(纯文本)。"""
|
||||
_rate_limit(request, token)
|
||||
svc = KbPublicService(db)
|
||||
kb = svc.get_kb_by_token(token)
|
||||
_log_access(db, kb.id, f"/k/{token}.txt", request, req_type="txt")
|
||||
docs, _ = svc.list_documents(kb)
|
||||
|
||||
category_tree = svc.get_category_tree(kb)
|
||||
|
||||
lines = [kb.name, "=" * len(kb.name), ""]
|
||||
if kb.description:
|
||||
lines.append(kb.description)
|
||||
lines.append("")
|
||||
|
||||
lines.append("文档列表:")
|
||||
lines.append("目录结构:")
|
||||
lines.append("")
|
||||
|
||||
for i, doc in enumerate(docs, 1):
|
||||
title = doc.title or doc.original_filename
|
||||
lines.append(f"{i}. {title}")
|
||||
if doc.description:
|
||||
lines.append(f" 描述:{doc.description}")
|
||||
if doc.keywords:
|
||||
lines.append(f" 关键词:{doc.keywords}")
|
||||
lines.append("")
|
||||
def render_tree_txt(nodes: list, level: int = 0):
|
||||
for node in nodes:
|
||||
prefix = " " * level
|
||||
icon = "[文件夹]" if node["is_folder"] else "[文档]"
|
||||
doc_count = f" ({node['doc_count']}篇)" if node["doc_count"] > 0 else ""
|
||||
url = f"/k/{token}/category{node['path']}"
|
||||
lines.append(f"{prefix}{icon} {node['name']}{doc_count}")
|
||||
lines.append(f"{prefix} → {url}")
|
||||
if node.get("children"):
|
||||
render_tree_txt(node["children"], level + 1)
|
||||
|
||||
render_tree_txt(category_tree)
|
||||
|
||||
return PlainTextResponse(content="\n".join(lines), media_type="text/plain")
|
||||
|
||||
|
||||
@router.get("/{token}.json")
|
||||
def kb_index_json(
|
||||
@router.get("/{token}")
|
||||
def kb_index_html(
|
||||
token: str,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
) -> Response:
|
||||
"""知识库首页(JSON)。"""
|
||||
) -> HTMLResponse:
|
||||
"""知识库完整内容(HTML)- 按目录分组显示所有文档。
|
||||
|
||||
AI 直接读取此页面即可获取全部内容,无需点击。
|
||||
人类用户可通过左侧目录快速跳转到对应章节。
|
||||
"""
|
||||
_rate_limit(request, token)
|
||||
svc = KbPublicService(db)
|
||||
kb = svc.get_kb_by_token(token)
|
||||
_log_access(db, kb.id, f"/k/{token}.json", request, req_type="json")
|
||||
_log_access(db, kb.id, f"/k/{token}", request, req_type="html")
|
||||
|
||||
category_tree = svc.get_category_tree(kb)
|
||||
docs, _ = svc.list_documents(kb)
|
||||
|
||||
# 按 category_id 分组
|
||||
doc_map: dict[str, list] = {}
|
||||
for doc in docs:
|
||||
cat_id = doc.category_id or "__uncategorized__"
|
||||
if cat_id not in doc_map:
|
||||
doc_map[cat_id] = []
|
||||
doc_map[cat_id].append(doc)
|
||||
|
||||
# 递归渲染目录 + 文档(按目录顺序)
|
||||
section_counter = [0]
|
||||
|
||||
def render_section(nodes: list, level: int = 0) -> tuple[str, str]:
|
||||
"""返回 (内容HTML, 目录HTML)"""
|
||||
content_html = ""
|
||||
toc_html = ""
|
||||
for node in nodes:
|
||||
section_counter[0] += 1
|
||||
section_id = f"section-{section_counter[0]}"
|
||||
icon = "📁" if node["is_folder"] else "📄"
|
||||
heading_tag = "h2" if level == 0 else "h3" if level == 1 else "h4"
|
||||
indent = " " * level
|
||||
|
||||
# 目录项
|
||||
doc_count = f" ({node['doc_count']}篇)" if node["doc_count"] > 0 else ""
|
||||
toc_html += f'{indent}<div class="toc-item" style="padding-left: {level * 16}px">'
|
||||
toc_html += f'<a href="#{section_id}">{icon} {node["name"]}</a>{doc_count}</div>\n'
|
||||
|
||||
# 内容标题
|
||||
content_html += f'<{heading_tag} id="{section_id}">{icon} {node["name"]}</{heading_tag}>\n'
|
||||
|
||||
# 该目录下的文档
|
||||
cat_docs = doc_map.get(node["id"], [])
|
||||
for doc in cat_docs:
|
||||
doc_token = decrypt_token(doc.doc_token_encrypted) if doc.doc_token_encrypted else ""
|
||||
doc_url = f"/k/{token}/doc/{doc_token}"
|
||||
summary = doc.content_summary or ""
|
||||
keywords = doc.keywords or ""
|
||||
status_badge = f' <span style="color:#e67e22;font-size:0.8em">[{doc.status}]</span>' if doc.status != "READY" else ""
|
||||
|
||||
content_html += f'<div class="doc-item">\n'
|
||||
content_html += f' <h4><a href="{doc_url}">{doc.title or doc.original_filename}</a>{status_badge}</h4>\n'
|
||||
content_html += f' <p class="meta">类型:{doc.file_ext} | 更新:{doc.updated_at}</p>\n'
|
||||
if summary:
|
||||
content_html += f' <p class="summary">{summary}</p>\n'
|
||||
if keywords:
|
||||
content_html += f' <p class="keywords">关键词:{keywords}</p>\n'
|
||||
content_html += f'</div>\n'
|
||||
|
||||
# 子目录
|
||||
if node.get("children"):
|
||||
child_content, child_toc = render_section(node["children"], level + 1)
|
||||
content_html += child_content
|
||||
toc_html += child_toc
|
||||
|
||||
return content_html, toc_html
|
||||
|
||||
all_content, all_toc = render_section(category_tree)
|
||||
|
||||
# 未分类文档
|
||||
uncategorized = doc_map.get("__uncategorized__", [])
|
||||
if uncategorized:
|
||||
all_content += '<h2 id="uncategorized">📄 未分类文档</h2>\n'
|
||||
for doc in uncategorized:
|
||||
doc_token = decrypt_token(doc.doc_token_encrypted) if doc.doc_token_encrypted else ""
|
||||
doc_url = f"/k/{token}/doc/{doc_token}"
|
||||
all_content += f'<div class="doc-item"><h4><a href="{doc_url}">{doc.title or doc.original_filename}</a></h4></div>\n'
|
||||
|
||||
html = f"""<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{kb.name}</title>
|
||||
{_robots_meta()}
|
||||
{_referrer_meta()}
|
||||
<style>
|
||||
:root {{ --font-size: 15px; }}
|
||||
* {{ box-sizing: border-box; margin: 0; padding: 0; }}
|
||||
body {{ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; font-size: var(--font-size); line-height: 1.8; color: #333; }}
|
||||
|
||||
/* 手机端:单列布局 */
|
||||
.container {{ display: flex; min-height: 100vh; }}
|
||||
.sidebar {{ width: 260px; background: #f8f9fa; border-right: 1px solid #e4e7ed; padding: 20px; overflow-y: auto; position: fixed; height: 100vh; }}
|
||||
.main {{ flex: 1; padding: 20px 30px; margin-left: 260px; max-width: 900px; }}
|
||||
|
||||
.sidebar h2 {{ font-size: 16px; margin-bottom: 15px; color: #333; }}
|
||||
.toc-item {{ padding: 6px 8px; border-radius: 4px; font-size: 14px; }}
|
||||
.toc-item:hover {{ background: #e8e8e8; }}
|
||||
.toc-item a {{ color: #333; text-decoration: none; }}
|
||||
.toc-item a:hover {{ color: #409eff; }}
|
||||
|
||||
h1 {{ font-size: 24px; color: #333; border-bottom: 2px solid #409eff; padding-bottom: 10px; margin-bottom: 20px; }}
|
||||
h2 {{ font-size: 20px; color: #333; margin: 30px 0 15px; padding-bottom: 8px; border-bottom: 1px solid #eee; }}
|
||||
h3 {{ font-size: 18px; color: #333; margin: 25px 0 10px; }}
|
||||
h4 {{ font-size: 16px; color: #333; margin: 15px 0 8px; }}
|
||||
|
||||
.doc-item {{ padding: 12px 0; border-bottom: 1px solid #f0f0f0; }}
|
||||
.doc-item a {{ color: #0066cc; text-decoration: none; }}
|
||||
.doc-item a:hover {{ text-decoration: underline; }}
|
||||
.meta {{ color: #888; font-size: 0.9em; margin: 4px 0; }}
|
||||
.summary {{ color: #555; font-size: 0.95em; margin: 6px 0; }}
|
||||
.keywords {{ color: #888; font-size: 0.85em; }}
|
||||
|
||||
.footer {{ margin-top: 40px; padding: 20px 0; border-top: 1px solid #eee; color: #999; font-size: 0.9em; }}
|
||||
|
||||
/* 字体大小控制 */
|
||||
.font-controls {{ position: fixed; bottom: 20px; right: 20px; background: #fff; border: 1px solid #ddd; border-radius: 8px; padding: 8px; display: flex; gap: 8px; box-shadow: 0 2px 8px rgba(0,0,0,0.1); z-index: 100; }}
|
||||
.font-controls button {{ width: 36px; height: 36px; border: 1px solid #ddd; border-radius: 4px; background: #fff; cursor: pointer; font-size: 16px; }}
|
||||
.font-controls button:hover {{ background: #f5f5f5; }}
|
||||
|
||||
/* 手机端适配 */
|
||||
@media (max-width: 768px) {{
|
||||
.sidebar {{ display: none; }}
|
||||
.main {{ margin-left: 0; padding: 15px; }}
|
||||
h1 {{ font-size: 20px; }}
|
||||
h2 {{ font-size: 18px; }}
|
||||
h3 {{ font-size: 16px; }}
|
||||
h4 {{ font-size: 15px; }}
|
||||
.font-controls {{ bottom: 10px; right: 10px; }}
|
||||
|
||||
/* 手机端显示目录按钮 */
|
||||
.mobile-toc {{ display: block; }}
|
||||
.sidebar.mobile-show {{ display: block; position: fixed; top: 0; left: 0; width: 80%; max-width: 300px; z-index: 200; box-shadow: 2px 0 10px rgba(0,0,0,0.2); }}
|
||||
.sidebar.mobile-show ~ .overlay {{ display: block; position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.5); z-index: 199; }}
|
||||
}}
|
||||
|
||||
@media (min-width: 769px) {{
|
||||
.mobile-toc {{ display: none; }}
|
||||
.overlay {{ display: none; }}
|
||||
}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<aside class="sidebar" id="sidebar">
|
||||
<h2>📚 目录</h2>
|
||||
{all_toc}
|
||||
<div style="margin-top: 20px; padding-top: 15px; border-top: 1px solid #ddd;">
|
||||
<a href="/k/{token}/search?q=" style="color: #0066cc; font-size: 14px;">🔍 搜索文档</a>
|
||||
</div>
|
||||
</aside>
|
||||
<div class="overlay" id="overlay" onclick="document.getElementById('sidebar').classList.remove('mobile-show');this.style.display='none'"></div>
|
||||
|
||||
<main class="main">
|
||||
<button class="mobile-toc" onclick="document.getElementById('sidebar').classList.add('mobile-show');document.getElementById('overlay').style.display='block'" style="margin-bottom: 15px; padding: 8px 16px; border: 1px solid #ddd; border-radius: 6px; background: #fff; cursor: pointer;">📚 目录</button>
|
||||
|
||||
<h1>{kb.name}</h1>
|
||||
{"<p style='color:#666;margin-bottom:20px'>" + kb.description + "</p>" if kb.description else ""}
|
||||
|
||||
{all_content}
|
||||
|
||||
<div class="footer">
|
||||
<p>本页为 AI 可读知识库页面,包含全部文档内容。</p>
|
||||
<p><a href="/k/{token}.json" style="color:#0066cc">JSON 格式</a> | <a href="/k/{token}.md" style="color:#0066cc">Markdown 格式</a> | <a href="/k/{token}.txt" style="color:#0066cc">纯文本格式</a></p>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- 字体大小控制 -->
|
||||
<div class="font-controls">
|
||||
<button onclick="changeFontSize(-1)" title="缩小字体">A-</button>
|
||||
<button onclick="changeFontSize(1)" title="放大字体">A+</button>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function changeFontSize(delta) {{
|
||||
const root = document.documentElement;
|
||||
const current = parseInt(getComputedStyle(root).getPropertyValue('--font-size'));
|
||||
const newSize = Math.max(12, Math.min(24, current + delta));
|
||||
root.style.setProperty('--font-size', newSize + 'px');
|
||||
localStorage.setItem('kb-font-size', newSize);
|
||||
}}
|
||||
// 恢复保存的字体大小
|
||||
const saved = localStorage.getItem('kb-font-size');
|
||||
if (saved) document.documentElement.style.setProperty('--font-size', saved + 'px');
|
||||
</script>
|
||||
</body>
|
||||
</html>"""
|
||||
return HTMLResponse(content=html)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 目录页面 - 显示某个目录下的文档
|
||||
# ============================================================
|
||||
|
||||
|
||||
@router.get("/{token}/category/{path:path}.json")
|
||||
def category_json(
|
||||
token: str,
|
||||
path: str,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
) -> Response:
|
||||
"""目录下的文档列表(JSON)。
|
||||
|
||||
path 格式:01公司层/公司基本信息(不含前后斜杠)
|
||||
"""
|
||||
_rate_limit(request, token)
|
||||
svc = KbPublicService(db)
|
||||
kb = svc.get_kb_by_token(token)
|
||||
|
||||
# 规范化路径
|
||||
category_path = f"/{path.strip('/')}/"
|
||||
cat = _get_category_by_path(db, kb.id, category_path)
|
||||
if cat is None:
|
||||
raise NotFoundError("目录不存在。")
|
||||
|
||||
_log_access(db, kb.id, f"/k/{token}/category/{path}.json", request, req_type="category_json")
|
||||
|
||||
# 获取该目录下的文档
|
||||
docs, total = svc.list_documents(kb, category_id=cat.id)
|
||||
|
||||
doc_list = []
|
||||
for doc in docs:
|
||||
doc_token = decrypt_token(doc.doc_token_encrypted) if doc.doc_token_encrypted else ""
|
||||
doc_list.append({
|
||||
"title": doc.title or doc.original_filename,
|
||||
"file_type": doc.file_ext,
|
||||
@@ -151,17 +467,15 @@ def kb_index_json(
|
||||
"summary": doc.content_summary,
|
||||
"keywords": doc.keywords.split(",") if doc.keywords else [],
|
||||
"updated_at": doc.updated_at,
|
||||
"url": f"/k/{token}/doc/{doc_token}",
|
||||
})
|
||||
|
||||
# 获取目录树
|
||||
category_tree = svc.get_category_tree(kb)
|
||||
|
||||
data = {
|
||||
"name": kb.name,
|
||||
"description": kb.description,
|
||||
"document_count": len(doc_list),
|
||||
"categories": category_tree,
|
||||
"category": cat.name,
|
||||
"category_path": cat.path,
|
||||
"document_count": total,
|
||||
"documents": doc_list,
|
||||
"back_to_index": f"/k/{token}.json",
|
||||
}
|
||||
|
||||
return Response(
|
||||
@@ -170,140 +484,176 @@ def kb_index_json(
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{token}")
|
||||
def kb_index_html(
|
||||
@router.get("/{token}/category/{path:path}.md")
|
||||
def category_markdown(
|
||||
token: str,
|
||||
path: str,
|
||||
request: Request,
|
||||
category: str = Query(None, description="按分类路径过滤,如 /01公司层/公司基本信息/"),
|
||||
page: int = Query(1, ge=1),
|
||||
db: Session = Depends(get_db),
|
||||
) -> HTMLResponse:
|
||||
"""知识库首页(HTML)。支持按目录过滤。"""
|
||||
) -> PlainTextResponse:
|
||||
"""目录下的文档列表(Markdown)。"""
|
||||
_rate_limit(request, token)
|
||||
svc = KbPublicService(db)
|
||||
kb = svc.get_kb_by_token(token)
|
||||
_log_access(db, kb.id, f"/k/{token}", request, req_type="html")
|
||||
|
||||
# 获取目录树
|
||||
category_tree = svc.get_category_tree(kb)
|
||||
category_path = f"/{path.strip('/')}/"
|
||||
cat = _get_category_by_path(db, kb.id, category_path)
|
||||
if cat is None:
|
||||
raise NotFoundError("目录不存在。")
|
||||
|
||||
# 按分类过滤
|
||||
category_id = None
|
||||
if category:
|
||||
# 根据路径查找分类 ID
|
||||
from sqlalchemy import select
|
||||
stmt = select(DocumentCategory).where(
|
||||
DocumentCategory.knowledge_base_id == kb.id,
|
||||
DocumentCategory.path == category,
|
||||
)
|
||||
cat = db.scalars(stmt).first()
|
||||
if cat:
|
||||
category_id = cat.id
|
||||
_log_access(db, kb.id, f"/k/{token}/category/{path}.md", request, req_type="category_md")
|
||||
|
||||
docs, total = svc.list_documents(kb, category_id=category_id, page=page, page_size=50)
|
||||
docs, _ = svc.list_documents(kb, category_id=cat.id)
|
||||
|
||||
# 渲染目录树侧边栏
|
||||
def render_tree(nodes: list, level: int = 0) -> str:
|
||||
html = ""
|
||||
for node in nodes:
|
||||
indent = " " * level
|
||||
is_active = category == node["path"]
|
||||
active_class = ' class="active"' if is_active else ""
|
||||
doc_count = f' <span class="count">({node["doc_count"]})</span>' if node["doc_count"] > 0 else ""
|
||||
lines = [f"# {cat.name}", ""]
|
||||
lines.append(f"知识库:{kb.name}")
|
||||
lines.append("")
|
||||
|
||||
if node["is_folder"]:
|
||||
html += f'{indent}<li{active_class}><a href="/k/{token}?category={node["path"]}">{node["name"]}</a>{doc_count}</li>\n'
|
||||
if node["children"]:
|
||||
html += f'{indent}<ul>\n{render_tree(node["children"], level + 1)}{indent}</ul>\n'
|
||||
else:
|
||||
html += f'{indent}<li{active_class}><a href="/k/{token}?category={node["path"]}">{node["name"]}</a>{doc_count}</li>\n'
|
||||
return html
|
||||
if docs:
|
||||
lines.append("## 文档列表")
|
||||
lines.append("")
|
||||
for doc in docs:
|
||||
title = doc.title or doc.original_filename
|
||||
lines.append(f"### {title}")
|
||||
if doc.description:
|
||||
lines.append(f"- 描述:{doc.description}")
|
||||
if doc.keywords:
|
||||
lines.append(f"- 关键词:{doc.keywords}")
|
||||
if doc.content_summary:
|
||||
lines.append(f"- 摘要:{doc.content_summary}")
|
||||
lines.append(f"- 更新:{doc.updated_at}")
|
||||
lines.append("")
|
||||
else:
|
||||
lines.append("暂无文档。")
|
||||
|
||||
tree_html = render_tree(category_tree)
|
||||
return PlainTextResponse(content="\n".join(lines), media_type="text/markdown")
|
||||
|
||||
|
||||
@router.get("/{token}/category/{path:path}.txt")
|
||||
def category_text(
|
||||
token: str,
|
||||
path: str,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
) -> PlainTextResponse:
|
||||
"""目录下的文档列表(纯文本)。"""
|
||||
_rate_limit(request, token)
|
||||
svc = KbPublicService(db)
|
||||
kb = svc.get_kb_by_token(token)
|
||||
|
||||
category_path = f"/{path.strip('/')}/"
|
||||
cat = _get_category_by_path(db, kb.id, category_path)
|
||||
if cat is None:
|
||||
raise NotFoundError("目录不存在。")
|
||||
|
||||
_log_access(db, kb.id, f"/k/{token}/category/{path}.txt", request, req_type="category_txt")
|
||||
|
||||
docs, _ = svc.list_documents(kb, category_id=cat.id)
|
||||
|
||||
lines = [cat.name, "=" * len(cat.name), ""]
|
||||
lines.append(f"知识库:{kb.name}")
|
||||
lines.append("")
|
||||
|
||||
if docs:
|
||||
for i, doc in enumerate(docs, 1):
|
||||
title = doc.title or doc.original_filename
|
||||
lines.append(f"{i}. {title}")
|
||||
if doc.description:
|
||||
lines.append(f" 描述:{doc.description}")
|
||||
if doc.keywords:
|
||||
lines.append(f" 关键词:{doc.keywords}")
|
||||
lines.append("")
|
||||
else:
|
||||
lines.append("暂无文档。")
|
||||
|
||||
return PlainTextResponse(content="\n".join(lines), media_type="text/plain")
|
||||
|
||||
|
||||
@router.get("/{token}/category/{path:path}")
|
||||
def category_html(
|
||||
token: str,
|
||||
path: str,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
) -> HTMLResponse:
|
||||
"""目录下的文档列表(HTML)。"""
|
||||
_rate_limit(request, token)
|
||||
svc = KbPublicService(db)
|
||||
kb = svc.get_kb_by_token(token)
|
||||
|
||||
category_path = f"/{path.strip('/')}/"
|
||||
cat = _get_category_by_path(db, kb.id, category_path)
|
||||
if cat is None:
|
||||
raise NotFoundError("目录不存在。")
|
||||
|
||||
_log_access(db, kb.id, f"/k/{token}/category/{path}", request, req_type="category_html")
|
||||
|
||||
docs, total = svc.list_documents(kb, category_id=cat.id)
|
||||
|
||||
doc_rows = ""
|
||||
for doc in docs:
|
||||
doc_url = f"/k/{token}/doc/{decrypt_token(doc.doc_token_encrypted) if doc.doc_token_encrypted else ''}"
|
||||
keywords_html = f'<span class="keywords">关键词:{doc.keywords}</span>' if doc.keywords else ""
|
||||
doc_token = decrypt_token(doc.doc_token_encrypted) if doc.doc_token_encrypted else ""
|
||||
doc_url = f"/k/{token}/doc/{doc_token}"
|
||||
keywords_html = f'<p class="keywords">关键词:{doc.keywords}</p>' if doc.keywords else ""
|
||||
summary_html = f'<p class="summary">{doc.content_summary or ""}</p>' if doc.content_summary else ""
|
||||
status_badge = ""
|
||||
if doc.status != "READY":
|
||||
status_badge = f' <span class="status-badge">[{doc.status}]</span>'
|
||||
|
||||
doc_rows += f"""
|
||||
<div class="doc-item">
|
||||
<h3><a href="{doc_url}">{doc.title or doc.original_filename}</a>{status_badge}</h3>
|
||||
<h3><a href="{doc_url}">{doc.title or doc.original_filename}</a></h3>
|
||||
<p class="meta">类型:{doc.file_ext} | 更新:{doc.updated_at}</p>
|
||||
{summary_html}
|
||||
{keywords_html}
|
||||
</div>
|
||||
"""
|
||||
|
||||
total_pages = (total + 49) // 50
|
||||
pagination = ""
|
||||
if total_pages > 1:
|
||||
pagination = f'<p class="pagination">第 {page} / {total_pages} 页</p>'
|
||||
|
||||
html = f"""<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>{kb.name}</title>
|
||||
<title>{cat.name} - {kb.name}</title>
|
||||
{_robots_meta()}
|
||||
{_referrer_meta()}
|
||||
<style>
|
||||
* {{ box-sizing: border-box; }}
|
||||
body {{ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; margin: 0; padding: 20px; line-height: 1.6; display: flex; gap: 30px; }}
|
||||
.sidebar {{ width: 280px; flex-shrink: 0; }}
|
||||
.main {{ flex: 1; max-width: 800px; }}
|
||||
h1 {{ color: #333; margin-top: 0; }}
|
||||
.tree {{ list-style: none; padding: 0; margin: 0; }}
|
||||
.tree ul {{ list-style: none; padding-left: 20px; margin: 0; }}
|
||||
.tree li {{ padding: 6px 10px; border-radius: 4px; }}
|
||||
.tree li:hover {{ background: #f5f7fa; }}
|
||||
.tree li.active {{ background: #ecf5ff; }}
|
||||
.tree a {{ color: #333; text-decoration: none; font-size: 0.95em; }}
|
||||
.tree a:hover {{ color: #409eff; }}
|
||||
.tree .count {{ color: #999; font-size: 0.85em; }}
|
||||
.doc-item {{ border-bottom: 1px solid #eee; padding: 15px 0; }}
|
||||
.doc-item h3 {{ margin: 0 0 5px 0; }}
|
||||
.doc-item a {{ color: #0066cc; text-decoration: none; }}
|
||||
body {{ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; max-width: 900px; margin: 0 auto; padding: 30px; line-height: 1.8; }}
|
||||
h1 {{ color: #333; }}
|
||||
.breadcrumb {{ color: #666; font-size: 14px; margin-bottom: 20px; }}
|
||||
.breadcrumb a {{ color: #0066cc; text-decoration: none; }}
|
||||
.breadcrumb a:hover {{ text-decoration: underline; }}
|
||||
.doc-item {{ border-bottom: 1px solid #eee; padding: 20px 0; }}
|
||||
.doc-item h3 {{ margin: 0 0 8px 0; }}
|
||||
.doc-item a {{ color: #0066cc; text-decoration: none; font-size: 16px; }}
|
||||
.doc-item a:hover {{ text-decoration: underline; }}
|
||||
.meta {{ color: #666; font-size: 0.9em; margin: 5px 0; }}
|
||||
.summary {{ color: #444; font-size: 0.95em; margin: 5px 0; }}
|
||||
.keywords {{ color: #888; font-size: 0.85em; }}
|
||||
.pagination {{ color: #666; font-size: 0.9em; text-align: center; }}
|
||||
.status-badge {{ color: #e67e22; font-size: 0.8em; font-weight: normal; }}
|
||||
.footer {{ margin-top: 30px; padding-top: 15px; border-top: 1px solid #eee; color: #999; font-size: 0.85em; }}
|
||||
.back-link {{ display: inline-block; margin-bottom: 15px; color: #0066cc; text-decoration: none; }}
|
||||
.back-link:hover {{ text-decoration: underline; }}
|
||||
.meta {{ color: #666; font-size: 14px; margin: 5px 0; }}
|
||||
.summary {{ color: #444; font-size: 14px; margin: 8px 0; }}
|
||||
.keywords {{ color: #888; font-size: 13px; }}
|
||||
.footer {{ margin-top: 40px; padding-top: 20px; border-top: 1px solid #eee; color: #999; font-size: 14px; }}
|
||||
.footer a {{ color: #0066cc; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="sidebar">
|
||||
<h2 style="margin-top: 0; font-size: 1.1em;">目录</h2>
|
||||
<ul class="tree">
|
||||
<li{" class='active'" if not category else ""}><a href="/k/{token}">全部文档</a> <span class="count">({total})</span></li>
|
||||
{tree_html}
|
||||
</ul>
|
||||
<div class="breadcrumb">
|
||||
<a href="/k/{token}">📚 {kb.name}</a> > {cat.name}
|
||||
</div>
|
||||
<div class="main">
|
||||
<h1>{kb.name}</h1>
|
||||
{"<p>" + kb.description + "</p>" if kb.description else ""}
|
||||
{"<a href='/k/" + token + "' class='back-link'>← 返回全部文档</a>" if category else ""}
|
||||
<h2>{"当前分类:" + category if category else "文档列表"}</h2>
|
||||
{doc_rows if doc_rows else "<p>暂无文档。</p>"}
|
||||
{pagination}
|
||||
<div class="footer">
|
||||
<p>This page is an AI-readable knowledge base index. Use the document links above to retrieve specific documents.</p>
|
||||
<p>本页为 AI 可读知识库目录,请通过上述文档链接获取具体内容。</p>
|
||||
</div>
|
||||
|
||||
<h1>{cat.name}</h1>
|
||||
<p style="color: #666;">共 {total} 篇文档</p>
|
||||
|
||||
{doc_rows if doc_rows else "<p style='color: #999;'>该目录暂无文档。</p>"}
|
||||
|
||||
<div class="footer">
|
||||
<p><a href="/k/{token}">← 返回目录索引</a></p>
|
||||
<p>This is an AI-readable document list. Click document links to view content.</p>
|
||||
<p>本页为 AI 可读文档列表。请点击文档链接查看具体内容。</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>"""
|
||||
return HTMLResponse(content=html)
|
||||
|
||||
|
||||
# --- 单文档访问 ---
|
||||
# ============================================================
|
||||
# 单文档访问
|
||||
# ============================================================
|
||||
|
||||
|
||||
@router.get("/{token}/doc/{doc_token}.md")
|
||||
@@ -313,14 +663,13 @@ def doc_page_markdown(
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
) -> PlainTextResponse:
|
||||
"""文档(Markdown)。"""
|
||||
"""文档内容(Markdown)。"""
|
||||
_rate_limit(request, token)
|
||||
svc = KbPublicService(db)
|
||||
kb = svc.get_kb_by_token(token)
|
||||
doc = svc.get_document_by_token(kb, doc_token)
|
||||
_log_access(db, kb.id, f"/k/{token}/doc/{doc_token}.md", request, doc_id=doc.id, req_type="doc_md")
|
||||
markdown_content = svc.get_document_content(doc)
|
||||
return PlainTextResponse(content=markdown_content, media_type="text/markdown")
|
||||
return PlainTextResponse(content=svc.get_document_content(doc), media_type="text/markdown")
|
||||
|
||||
|
||||
@router.get("/{token}/doc/{doc_token}.txt")
|
||||
@@ -330,15 +679,14 @@ def doc_page_text(
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
) -> PlainTextResponse:
|
||||
"""文档(纯文本)。"""
|
||||
"""文档内容(纯文本)。"""
|
||||
_rate_limit(request, token)
|
||||
svc = KbPublicService(db)
|
||||
kb = svc.get_kb_by_token(token)
|
||||
doc = svc.get_document_by_token(kb, doc_token)
|
||||
_log_access(db, kb.id, f"/k/{token}/doc/{doc_token}.txt", request, doc_id=doc.id, req_type="doc_txt")
|
||||
markdown_content = svc.get_document_content(doc)
|
||||
import re
|
||||
text = re.sub(r"[#*_`\[\]()>]", "", markdown_content)
|
||||
content = svc.get_document_content(doc)
|
||||
text = re.sub(r"[#*_`\[\]()>]", "", content)
|
||||
text = re.sub(r"\n{3,}", "\n\n", text)
|
||||
return PlainTextResponse(content=text.strip(), media_type="text/plain")
|
||||
|
||||
@@ -350,18 +698,29 @@ def doc_page_html(
|
||||
request: Request = None,
|
||||
db: Session = Depends(get_db),
|
||||
) -> HTMLResponse:
|
||||
"""文档页面(HTML)。"""
|
||||
"""文档内容(HTML)。"""
|
||||
_rate_limit(request, token)
|
||||
svc = KbPublicService(db)
|
||||
kb = svc.get_kb_by_token(token)
|
||||
doc = svc.get_document_by_token(kb, doc_token)
|
||||
_log_access(db, kb.id, f"/k/{token}/doc/{doc_token}", request, doc_id=doc.id, req_type="doc_html")
|
||||
markdown_content = svc.get_document_content(doc)
|
||||
|
||||
# Markdown → HTML(简单转换)
|
||||
html_content = _markdown_to_html(markdown_content)
|
||||
content = svc.get_document_content(doc)
|
||||
html_content = _markdown_to_html(content)
|
||||
|
||||
# 获取文档所属目录
|
||||
category_name = ""
|
||||
category_path = ""
|
||||
if doc.category_id:
|
||||
cat = db.get(DocumentCategory, doc.category_id)
|
||||
if cat:
|
||||
category_name = cat.name
|
||||
category_path = cat.path
|
||||
|
||||
keywords_html = f"<p><strong>关键词:</strong>{doc.keywords}</p>" if doc.keywords else ""
|
||||
breadcrumb = f'<a href="/k/{token}">📚 {kb.name}</a>'
|
||||
if category_name:
|
||||
breadcrumb += f' > <a href="/k/{token}/category{category_path}">{category_name}</a>'
|
||||
|
||||
html = f"""<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
@@ -371,9 +730,12 @@ def doc_page_html(
|
||||
{_robots_meta()}
|
||||
{_referrer_meta()}
|
||||
<style>
|
||||
body {{ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; max-width: 800px; margin: 0 auto; padding: 20px; line-height: 1.8; }}
|
||||
body {{ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; max-width: 900px; margin: 0 auto; padding: 30px; line-height: 1.8; }}
|
||||
h1 {{ color: #333; }}
|
||||
.meta {{ color: #666; font-size: 0.9em; margin-bottom: 20px; }}
|
||||
.breadcrumb {{ color: #666; font-size: 14px; margin-bottom: 20px; }}
|
||||
.breadcrumb a {{ color: #0066cc; text-decoration: none; }}
|
||||
.breadcrumb a:hover {{ text-decoration: underline; }}
|
||||
.meta {{ color: #666; font-size: 14px; margin-bottom: 20px; }}
|
||||
.content {{ margin-top: 20px; }}
|
||||
.content h1, .content h2, .content h3 {{ color: #333; }}
|
||||
.content pre {{ background: #f5f5f5; padding: 15px; border-radius: 5px; overflow-x: auto; }}
|
||||
@@ -381,11 +743,13 @@ def doc_page_html(
|
||||
.content table {{ border-collapse: collapse; width: 100%; }}
|
||||
.content th, .content td {{ border: 1px solid #ddd; padding: 8px; text-align: left; }}
|
||||
.content th {{ background: #f5f5f5; }}
|
||||
.back {{ margin-top: 30px; }}
|
||||
.back a {{ color: #0066cc; }}
|
||||
.footer {{ margin-top: 40px; padding-top: 20px; border-top: 1px solid #eee; color: #999; font-size: 14px; }}
|
||||
.footer a {{ color: #0066cc; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="breadcrumb">{breadcrumb} > {doc.title or doc.original_filename}</div>
|
||||
|
||||
<h1>{doc.title or doc.original_filename}</h1>
|
||||
<div class="meta">
|
||||
<p>类型:{doc.file_ext} | 更新:{doc.updated_at}</p>
|
||||
@@ -395,55 +759,17 @@ def doc_page_html(
|
||||
<div class="content">
|
||||
{html_content}
|
||||
</div>
|
||||
<div class="back">
|
||||
<a href="/k/{token}">← 返回知识库目录</a>
|
||||
<div class="footer">
|
||||
<p><a href="/k/{token}">← 返回目录索引</a>{" | <a href='/k/" + token + "/category" + category_path + "'>← 返回" + category_name + "</a>" if category_name else ""}</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>"""
|
||||
return HTMLResponse(content=html)
|
||||
|
||||
|
||||
@router.get("/{token}/doc/{doc_token}.md")
|
||||
def doc_page_markdown(
|
||||
token: str,
|
||||
doc_token: str,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
) -> PlainTextResponse:
|
||||
"""文档(Markdown)。"""
|
||||
_rate_limit(request, token)
|
||||
svc = KbPublicService(db)
|
||||
kb = svc.get_kb_by_token(token)
|
||||
doc = svc.get_document_by_token(kb, doc_token)
|
||||
_log_access(db, kb.id, f"/k/{token}/doc/{doc_token}.md", request, doc_id=doc.id, req_type="doc_md")
|
||||
markdown_content = svc.get_document_content(doc)
|
||||
return PlainTextResponse(content=markdown_content, media_type="text/markdown")
|
||||
|
||||
|
||||
@router.get("/{token}/doc/{doc_token}.txt")
|
||||
def doc_page_text(
|
||||
token: str,
|
||||
doc_token: str,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
) -> PlainTextResponse:
|
||||
"""文档(纯文本)。"""
|
||||
_rate_limit(request, token)
|
||||
svc = KbPublicService(db)
|
||||
kb = svc.get_kb_by_token(token)
|
||||
doc = svc.get_document_by_token(kb, doc_token)
|
||||
_log_access(db, kb.id, f"/k/{token}/doc/{doc_token}.txt", request, doc_id=doc.id, req_type="doc_txt")
|
||||
markdown_content = svc.get_document_content(doc)
|
||||
|
||||
# 去掉 Markdown 标记
|
||||
import re
|
||||
|
||||
text = re.sub(r"[#*_`\[\]()>]", "", markdown_content)
|
||||
text = re.sub(r"\n{3,}", "\n\n", text)
|
||||
return PlainTextResponse(content=text.strip(), media_type="text/plain")
|
||||
|
||||
|
||||
# --- 搜索 ---
|
||||
# ============================================================
|
||||
# 搜索
|
||||
# ============================================================
|
||||
|
||||
|
||||
@router.get("/{token}/search")
|
||||
@@ -464,9 +790,11 @@ def search_html(
|
||||
result_items = ""
|
||||
for item in results:
|
||||
doc_url = f"/k/{token}/doc/{item['url_hint'] or ''}"
|
||||
category_path = item.get('category_path', '')
|
||||
category_badge = f' <span style="color:#999;font-size:12px">[{category_path}]</span>' if category_path else ""
|
||||
result_items += f"""
|
||||
<div class="result-item">
|
||||
<h3><a href="{doc_url}">{item['title']}</a></h3>
|
||||
<h3><a href="{doc_url}">{item['title']}</a>{category_badge}</h3>
|
||||
<p class="meta">类型:{item['file_type']} | 更新:{item['updated_at']}</p>
|
||||
{"<p>" + (item.get('description') or '') + "</p>"}
|
||||
</div>
|
||||
@@ -480,17 +808,17 @@ def search_html(
|
||||
{_robots_meta()}
|
||||
{_referrer_meta()}
|
||||
<style>
|
||||
body {{ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; max-width: 800px; margin: 0 auto; padding: 20px; line-height: 1.6; }}
|
||||
body {{ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; max-width: 900px; margin: 0 auto; padding: 30px; line-height: 1.6; }}
|
||||
.result-item {{ border-bottom: 1px solid #eee; padding: 15px 0; }}
|
||||
.result-item a {{ color: #0066cc; text-decoration: none; }}
|
||||
.meta {{ color: #666; font-size: 0.9em; }}
|
||||
.result-item a {{ color: #0066cc; text-decoration: none; font-size: 16px; }}
|
||||
.meta {{ color: #666; font-size: 14px; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>搜索:{q}</h1>
|
||||
<p>共找到 {total} 个结果</p>
|
||||
{result_items if result_items else "<p>未找到相关文档。</p>"}
|
||||
<p><a href="/k/{token}">← 返回知识库目录</a></p>
|
||||
<p><a href="/k/{token}">← 返回目录索引</a></p>
|
||||
</body>
|
||||
</html>"""
|
||||
return HTMLResponse(content=html)
|
||||
@@ -521,31 +849,22 @@ def search_json(
|
||||
)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 工具函数
|
||||
# ============================================================
|
||||
|
||||
|
||||
def _markdown_to_html(markdown: str) -> str:
|
||||
"""简单 Markdown → HTML 转换(安全处理)。"""
|
||||
import re
|
||||
|
||||
# 转义 HTML 特殊字符
|
||||
html = markdown.replace("&", "&").replace("<", "<").replace(">", ">")
|
||||
|
||||
# 标题
|
||||
html = re.sub(r"^#### (.+)$", r"<h4>\1</h4>", html, flags=re.MULTILINE)
|
||||
html = re.sub(r"^### (.+)$", r"<h3>\1</h3>", html, flags=re.MULTILINE)
|
||||
html = re.sub(r"^## (.+)$", r"<h2>\1</h2>", html, flags=re.MULTILINE)
|
||||
html = re.sub(r"^# (.+)$", r"<h1>\1</h1>", html, flags=re.MULTILINE)
|
||||
|
||||
# 粗体/斜体
|
||||
html = re.sub(r"\*\*(.+?)\*\*", r"<strong>\1</strong>", html)
|
||||
html = re.sub(r"\*(.+?)\*", r"<em>\1</em>", html)
|
||||
|
||||
# 代码块
|
||||
html = re.sub(r"```[\s\S]*?```", lambda m: f"<pre><code>{m.group(0)[3:-3]}</code></pre>", html)
|
||||
|
||||
# 行内代码
|
||||
html = re.sub(r"`([^`]+)`", r"<code>\1</code>", html)
|
||||
|
||||
# 段落(双换行 → <p>)
|
||||
html = re.sub(r"\n\n+", "</p><p>", html)
|
||||
html = f"<p>{html}</p>"
|
||||
|
||||
return html
|
||||
|
||||
@@ -94,7 +94,10 @@ def test_kb_index_json() -> None:
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["name"] == "测试知识库"
|
||||
assert "documents" in data
|
||||
assert "categories" in data
|
||||
# 每个目录有 documents 字段
|
||||
if data["categories"]:
|
||||
assert "documents" in data["categories"][0]
|
||||
# 安全:不包含内部字段
|
||||
assert "user_id" not in data
|
||||
assert "token_hash" not in data
|
||||
|
||||
Reference in New Issue
Block a user