This commit is contained in:
amb
2026-09-01 21:43:27 +08:00
parent e47235dfff
commit 2d42b0e73d
7 changed files with 2035 additions and 1171 deletions
+529 -210
View File
@@ -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> &gt; {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' &gt; <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} &gt; {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("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
# 标题
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