fix: 修复会话鉴权「登录后 token 无效」并补齐 NAS 部署流程
- deps.py: _create_redis_client 增加同步 ping 校验,Redis 不可达时正确降级为内存模式 - main.py: lifespan 复用 deps 的 UserStore 单例,避免 admin 与 API 请求实例不一致 - 前端: 强制改密弹窗(must_change_password 用户)、兼容新旧登录返回格式、markPasswordChanged - 新增 NAS SSH 部署脚本与批处理测试;gitignore 前端构建产物
This commit is contained in:
+12
-2
@@ -8,6 +8,7 @@ UserStore/SessionStore 为模块级懒加载单例:Redis 客户端创建失败
|
||||
import structlog
|
||||
from fastapi import Depends, Header
|
||||
from redis import asyncio as redis_async
|
||||
from redis import Redis as RedisSync
|
||||
|
||||
from app.api.response import ApiError
|
||||
from app.config import settings
|
||||
@@ -22,11 +23,20 @@ _session_store: SessionStore | None = None
|
||||
|
||||
|
||||
def _create_redis_client() -> redis_async.Redis | None:
|
||||
"""创建 redis.asyncio 客户端(decode_responses=True);失败返回 None 走内存降级"""
|
||||
"""创建 redis.asyncio 客户端(decode_responses=True);连接失败返回 None 走内存降级
|
||||
|
||||
注意:redis.asyncio.from_url() 仅构造客户端对象,不会实际连接 Redis,
|
||||
因此需要用同步客户端 ping 一次确认连接可用,否则后续操作才会报错,
|
||||
导致 UserStore/SessionStore 无法降级为内存模式。
|
||||
"""
|
||||
try:
|
||||
# 同步 ping 确认 Redis 可达(from_url 不会实际连接)
|
||||
sync_client = RedisSync.from_url(settings.redis_url, decode_responses=True)
|
||||
sync_client.ping()
|
||||
sync_client.close()
|
||||
return redis_async.from_url(settings.redis_url, decode_responses=True)
|
||||
except Exception:
|
||||
logger.warning("Redis 客户端创建失败,认证存储降级为内存模式", exc_info=True)
|
||||
logger.warning("Redis 连接失败,认证存储降级为内存模式", exc_info=True)
|
||||
return None
|
||||
|
||||
|
||||
|
||||
@@ -183,6 +183,149 @@ async def upload_document(
|
||||
)
|
||||
|
||||
|
||||
async def _process_single_upload(file: UploadFile) -> dict[str, Any]:
|
||||
"""处理单个上传文件:校验 → 提取文本 → 落盘 → 提交入库
|
||||
|
||||
供批量上传复用:返回成功 {"filename", "task_id"} 或失败 {"filename", "error"}。
|
||||
title/source 使用文件名默认值,不接受用户传入的额外表单字段。
|
||||
"""
|
||||
original_filename = file.filename or "unnamed"
|
||||
ext = Path(original_filename).suffix.lower()
|
||||
|
||||
# 1. 扩展名校验
|
||||
allowed = _allowed_extensions()
|
||||
if ext not in allowed:
|
||||
return {"filename": original_filename, "error": f"不支持的文件类型: {ext or '(无扩展名)'}"}
|
||||
|
||||
# 2. 读取字节并校验大小
|
||||
try:
|
||||
content = await file.read()
|
||||
except Exception as exc:
|
||||
return {"filename": original_filename, "error": f"文件读取失败: {exc}"}
|
||||
max_bytes = settings.upload_max_size_mb * 1024 * 1024
|
||||
if len(content) > max_bytes:
|
||||
return {"filename": original_filename, "error": f"文件超过大小上限: {settings.upload_max_size_mb}MB"}
|
||||
|
||||
# 3. 提取文本
|
||||
try:
|
||||
text = parse_file(original_filename, content)
|
||||
except ValueError as exc:
|
||||
return {"filename": original_filename, "error": str(exc)}
|
||||
if not text.strip():
|
||||
return {"filename": original_filename, "error": "无法从文件提取文本"}
|
||||
|
||||
# 4. 落盘(按 YYYY/MM 日期分片;失败仅 warning,不阻塞入库)
|
||||
doc_id = uuid.uuid4().hex
|
||||
metadata_dict: dict[str, str] = {}
|
||||
try:
|
||||
upload_dir = Path(settings.upload_dir).resolve()
|
||||
shard_subdir = datetime.now(UTC).strftime("%Y/%m")
|
||||
target_dir = upload_dir / shard_subdir
|
||||
target_dir.mkdir(parents=True, exist_ok=True)
|
||||
target = target_dir / f"{doc_id}_{original_filename}"
|
||||
target.write_bytes(content)
|
||||
metadata_dict.update(
|
||||
{
|
||||
"raw_file_path": str(target),
|
||||
"original_filename": original_filename,
|
||||
"original_size_bytes": str(len(content)),
|
||||
}
|
||||
)
|
||||
logger.info(
|
||||
"上传文件已落盘", doc_id=doc_id, saved_path=str(target), size=len(content)
|
||||
)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"上传文件落盘失败,仅做文本入库",
|
||||
doc_id=doc_id,
|
||||
filename=original_filename,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
# 5. 默认 title / source
|
||||
title = Path(original_filename).stem
|
||||
source = f"file:{original_filename}"
|
||||
|
||||
# 6. 提交入库流水线
|
||||
doc_input = DocumentInput(text=text, title=title, source=source, metadata=metadata_dict)
|
||||
task_id = await _get_task_manager().submit(doc_input)
|
||||
return {"filename": original_filename, "task_id": task_id}
|
||||
|
||||
|
||||
@router.post("/documents/upload-batch")
|
||||
async def upload_batch(
|
||||
files: list[UploadFile] = File(...),
|
||||
user: UserRecord = Depends(get_current_user),
|
||||
) -> JSONResponse:
|
||||
"""批量文件上传入库:逐文件复用单文件逻辑,收集成功与失败结果
|
||||
|
||||
返回 202 + {tasks: [{filename, task_id}], failed: [{filename, error}]};
|
||||
空文件列表返回 1001。
|
||||
"""
|
||||
if not files:
|
||||
raise ApiError(1001, "未提供任何文件")
|
||||
tasks: list[dict[str, Any]] = []
|
||||
failed: list[dict[str, Any]] = []
|
||||
for file in files:
|
||||
result = await _process_single_upload(file)
|
||||
if "task_id" in result:
|
||||
tasks.append({"filename": result["filename"], "task_id": result["task_id"]})
|
||||
else:
|
||||
failed.append({"filename": result["filename"], "error": result["error"]})
|
||||
return JSONResponse(status_code=202, content=ok({"tasks": tasks, "failed": failed}))
|
||||
|
||||
|
||||
@router.get("/documents/tasks")
|
||||
async def list_ingest_tasks(
|
||||
limit: int = Query(default=20, ge=1, le=100),
|
||||
user: UserRecord = Depends(get_current_user),
|
||||
) -> dict[str, Any]:
|
||||
"""列出近期入库任务(合并内存与 Redis 镜像,按 updated_at 降序)"""
|
||||
tasks = await _get_task_manager().list_tasks(limit=limit)
|
||||
return ok({"items": tasks, "total": len(tasks)})
|
||||
|
||||
|
||||
@router.post("/documents/{doc_id}/reingest")
|
||||
async def reingest_document(
|
||||
doc_id: str, user: UserRecord = Depends(get_current_user)
|
||||
) -> JSONResponse:
|
||||
"""重新入库:读原始文件 → 删旧数据 → 提交新入库任务
|
||||
|
||||
仅对有原始文件落盘记录的文档可重新入库;重新入库会生成新 doc_id。
|
||||
"""
|
||||
meta = await _get_qdrant().get_l1_metadata(doc_id)
|
||||
if meta is None:
|
||||
raise ApiError(1004, "文档不存在")
|
||||
raw_file_path = meta.get("raw_file_path", "")
|
||||
if not raw_file_path:
|
||||
raise ApiError(1001, "该文档无原始文件,无法重新入库")
|
||||
path = Path(raw_file_path)
|
||||
if not path.is_file():
|
||||
raise ApiError(1004, "文件不存在")
|
||||
|
||||
# 读原文件并重新提取文本
|
||||
try:
|
||||
text = parse_file(meta.get("original_filename", path.name), path.read_bytes())
|
||||
except ValueError as exc:
|
||||
raise ApiError(1001, str(exc)) from exc
|
||||
if not text.strip():
|
||||
raise ApiError(1001, "无法从文件提取文本")
|
||||
|
||||
# 删旧数据(四层集合按 doc_id 清除,幂等)
|
||||
await _get_qdrant().delete_by_doc_id(doc_id)
|
||||
|
||||
# 构造新文档输入(保留原 metadata,title/source 从 meta 取或文件名回退)
|
||||
original_filename = meta.get("original_filename", path.name)
|
||||
title = meta.get("title") or Path(original_filename).stem
|
||||
source = meta.get("source") or f"file:{original_filename}"
|
||||
doc_input = DocumentInput(text=text, title=title, source=source, metadata=dict(meta))
|
||||
|
||||
task_id = await _get_task_manager().submit(doc_input)
|
||||
return JSONResponse(
|
||||
status_code=202, content=ok({"task_id": task_id, "status": "pending"})
|
||||
)
|
||||
|
||||
|
||||
@router.get("/documents/tasks/{task_id}")
|
||||
async def get_ingest_task(task_id: str) -> dict[str, Any]:
|
||||
"""查询入库任务状态:含 task_id/status/created_at/updated_at,done 附 result,failed 附 error"""
|
||||
|
||||
@@ -11,7 +11,8 @@ from fastapi import APIRouter, Depends
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.api.response import ApiError, ok
|
||||
from app.core.auth import AuthUser, get_current_user, require_admin
|
||||
from app.api.deps import get_current_user, require_admin
|
||||
from app.core.users import UserRecord
|
||||
from app.core.dedup import invalidate_dedup_strategy_cache
|
||||
from app.core.file_parser import (
|
||||
list_docx_plugins,
|
||||
@@ -46,7 +47,7 @@ class SettingsUpdateRequest(BaseModel):
|
||||
|
||||
|
||||
@router.get("/settings")
|
||||
async def get_settings(user: AuthUser = Depends(get_current_user)) -> dict[str, Any]:
|
||||
async def get_settings(user: UserRecord = Depends(get_current_user)) -> dict[str, Any]:
|
||||
"""返回当前 RuntimeSettings(任何登录用户可读)"""
|
||||
cfg = get_runtime_settings()
|
||||
return ok(cfg.model_dump(mode="json"))
|
||||
@@ -55,7 +56,7 @@ async def get_settings(user: AuthUser = Depends(get_current_user)) -> dict[str,
|
||||
@router.put("/settings")
|
||||
async def update_settings(
|
||||
body: SettingsUpdateRequest,
|
||||
user: AuthUser = Depends(require_admin),
|
||||
user: UserRecord = Depends(require_admin),
|
||||
) -> dict[str, Any]:
|
||||
"""部分更新 RuntimeSettings(仅 admin)
|
||||
|
||||
@@ -82,7 +83,7 @@ async def update_settings(
|
||||
|
||||
@router.get("/settings/schema")
|
||||
async def get_settings_schema(
|
||||
user: AuthUser = Depends(get_current_user),
|
||||
user: UserRecord = Depends(get_current_user),
|
||||
) -> dict[str, Any]:
|
||||
"""返回可选插件与策略列表(前端 Settings 页渲染选项用)"""
|
||||
return ok(
|
||||
@@ -98,7 +99,7 @@ async def get_settings_schema(
|
||||
|
||||
@router.post("/settings/reset")
|
||||
async def reset_settings(
|
||||
user: AuthUser = Depends(require_admin),
|
||||
user: UserRecord = Depends(require_admin),
|
||||
) -> dict[str, Any]:
|
||||
"""重置 RuntimeSettings 为默认值(仅 admin),同时清缓存"""
|
||||
try:
|
||||
|
||||
@@ -20,7 +20,7 @@ from typing import Any
|
||||
import structlog
|
||||
|
||||
from app.config import Settings
|
||||
from app.core.dedup import DEDUP_KEY_PREFIX, get_dedup_strategy
|
||||
from app.core.dedup import get_dedup_strategy
|
||||
from app.core.ingestion import Ingester, IngestionError
|
||||
from app.models.document import DocumentInput
|
||||
from app.services.redis import RedisCache
|
||||
@@ -79,6 +79,7 @@ class IngestTaskManager:
|
||||
"""
|
||||
task_id = uuid.uuid4().hex
|
||||
now = _utc_now_iso()
|
||||
filename = self._extract_filename(doc)
|
||||
dedup = get_dedup_strategy(self._redis)
|
||||
|
||||
# 1. 去重命中:直接置 done,复用旧结果,不调 _run
|
||||
@@ -93,6 +94,7 @@ class IngestTaskManager:
|
||||
"updated_at": now,
|
||||
"result": result_dict,
|
||||
"error": None,
|
||||
"filename": filename,
|
||||
}
|
||||
self._schedule_mirror(task_id)
|
||||
logger.info(
|
||||
@@ -110,6 +112,7 @@ class IngestTaskManager:
|
||||
"updated_at": now,
|
||||
"result": None,
|
||||
"error": None,
|
||||
"filename": filename,
|
||||
}
|
||||
self._schedule_mirror(task_id)
|
||||
background = asyncio.create_task(self._run(task_id, doc, dedup))
|
||||
@@ -135,6 +138,72 @@ class IngestTaskManager:
|
||||
)
|
||||
return None
|
||||
|
||||
async def list_tasks(self, limit: int = 20) -> list[dict[str, Any]]:
|
||||
"""合并内存注册表与 Redis 镜像,去重,按 updated_at 降序,截断 limit
|
||||
|
||||
每项提取 {task_id, status, filename, created_at, updated_at, doc_id}:
|
||||
doc_id 从 done 任务的 result.document_id 提取,其余状态为 None。
|
||||
"""
|
||||
records: dict[str, dict[str, Any]] = {}
|
||||
# 1. 内存注册表(主)
|
||||
for task_id, record in self._tasks.items():
|
||||
records[task_id] = record
|
||||
# 2. Redis 镜像补充内存中没有的(内存未命中的任务,如重启后只存在 Redis 的历史记录)
|
||||
for task_id, record in await self._scan_redis_tasks():
|
||||
records.setdefault(task_id, record)
|
||||
# 3. 按 updated_at 降序,截断 limit
|
||||
sorted_records = sorted(
|
||||
records.values(),
|
||||
key=lambda r: r.get("updated_at") or "",
|
||||
reverse=True,
|
||||
)[:limit]
|
||||
# 4. 提取展示字段
|
||||
return [self._to_list_item(r) for r in sorted_records]
|
||||
|
||||
async def _scan_redis_tasks(self) -> list[tuple[str, dict[str, Any]]]:
|
||||
"""扫描 Redis 中 ingest_task:* 键,返回 (task_id, record) 列表
|
||||
|
||||
RedisCache 未暴露公开扫描接口,借道底层 _get_client().scan_iter;
|
||||
任何异常降级为空列表,不影响 list_tasks 主流程。
|
||||
"""
|
||||
if self._redis is None:
|
||||
return []
|
||||
get_client = getattr(self._redis, "_get_client", None)
|
||||
if not callable(get_client):
|
||||
return []
|
||||
try:
|
||||
client = get_client()
|
||||
tasks: list[tuple[str, dict[str, Any]]] = []
|
||||
async for key in client.scan_iter(match=f"{REDIS_KEY_PREFIX}*"):
|
||||
key_str = key if isinstance(key, str) else key.decode("utf-8", "replace")
|
||||
task_id = key_str[len(REDIS_KEY_PREFIX) :]
|
||||
record = await self._redis.get_json(key_str)
|
||||
if isinstance(record, dict):
|
||||
tasks.append((task_id, record))
|
||||
return tasks
|
||||
except Exception:
|
||||
logger.warning("扫描 Redis 任务键失败", exc_info=True)
|
||||
return []
|
||||
|
||||
@staticmethod
|
||||
def _extract_filename(doc: DocumentInput) -> str | None:
|
||||
"""从文档输入提取展示用文件名:优先 metadata.original_filename,其次 title"""
|
||||
return doc.metadata.get("original_filename") or doc.title or None
|
||||
|
||||
@staticmethod
|
||||
def _to_list_item(record: dict[str, Any]) -> dict[str, Any]:
|
||||
"""从完整任务记录提取列表展示字段"""
|
||||
result = record.get("result")
|
||||
doc_id = result.get("document_id") if isinstance(result, dict) else None
|
||||
return {
|
||||
"task_id": record.get("task_id"),
|
||||
"status": record.get("status"),
|
||||
"filename": record.get("filename"),
|
||||
"created_at": record.get("created_at"),
|
||||
"updated_at": record.get("updated_at"),
|
||||
"doc_id": doc_id,
|
||||
}
|
||||
|
||||
async def wait_done(self, task_id: str, timeout: float = 30.0) -> dict[str, Any]:
|
||||
"""轮询内存注册表直到任务进入终态(done/failed)或超时
|
||||
|
||||
|
||||
+9
-10
@@ -6,7 +6,6 @@ import structlog
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
from fastapi.responses import FileResponse, JSONResponse, RedirectResponse
|
||||
from redis import asyncio as redis_async
|
||||
|
||||
from app.api.response import ApiError, error
|
||||
from app.api.v1.auth import router as auth_router
|
||||
@@ -15,7 +14,7 @@ from app.api.v1.knowledge import router as knowledge_router
|
||||
from app.api.v1.search import router as search_router
|
||||
from app.api.v1.settings import router as settings_router
|
||||
from app.config import settings
|
||||
from app.core.users import UserStore, bootstrap_admin
|
||||
from app.core.users import bootstrap_admin
|
||||
from app.services.qdrant import QdrantService
|
||||
|
||||
logger = structlog.get_logger()
|
||||
@@ -33,13 +32,13 @@ async def lifespan(_: FastAPI) -> AsyncIterator[None]:
|
||||
except Exception:
|
||||
logger.error("Qdrant 集合初始化失败,跳过初始化继续启动")
|
||||
try:
|
||||
# Redis 客户端创建失败(如 URL 非法)时传 None,UserStore 降级为内存模式
|
||||
redis_client = redis_async.from_url(settings.redis_url, decode_responses=True)
|
||||
except Exception:
|
||||
redis_client = None
|
||||
try:
|
||||
# 复用 deps 的 UserStore 单例,确保 lifespan 创建的 admin 与 API 请求用的是同一实例
|
||||
# _get_user_store 内部会检测 Redis 可达性,不可达时降级为内存模式
|
||||
from app.api.deps import _get_user_store
|
||||
|
||||
user_store = _get_user_store()
|
||||
# 空库引导默认管理员;明文密码由 bootstrap_admin 内部 warning 打印一次
|
||||
await bootstrap_admin(UserStore(redis_client), logger)
|
||||
await bootstrap_admin(user_store, logger)
|
||||
except Exception:
|
||||
logger.warning("默认管理员初始化失败,跳过", exc_info=True)
|
||||
try:
|
||||
@@ -150,8 +149,8 @@ async def admin_spa(rest: str):
|
||||
_AGENT_SKILL_ZIP = _STATIC_DIR / "agent-skill" / "QMDSearch-Agent-Skill.zip"
|
||||
|
||||
|
||||
@app.get("/agent-skill", include_in_schema=False)
|
||||
async def agent_skill_download() -> FileResponse | JSONResponse:
|
||||
@app.get("/agent-skill", include_in_schema=False, response_model=None)
|
||||
async def agent_skill_download():
|
||||
"""下载 AI Agent Skill 压缩包(qmdsearch-agent skill,含 SKILL.md 与示例)"""
|
||||
if not _AGENT_SKILL_ZIP.is_file():
|
||||
return JSONResponse(
|
||||
|
||||
+221
-25
@@ -259,6 +259,7 @@
|
||||
<button type="button" data-target="section-overview" class="active">概览</button>
|
||||
<button type="button" data-target="section-docs">文档管理</button>
|
||||
<button type="button" data-target="section-ingest">文档入库</button>
|
||||
<button type="button" data-target="section-progress" id="nav-progress">入库进度</button>
|
||||
<button type="button" data-target="section-search">检索测试台</button>
|
||||
<button type="button" data-target="section-categories">类目列表</button>
|
||||
<button type="button" data-target="section-api-guide">API 指南</button>
|
||||
@@ -340,8 +341,8 @@
|
||||
<h3 style="font-size:14px; margin-top:24px;">或上传文件</h3>
|
||||
<form id="upload-form">
|
||||
<div class="field">
|
||||
<label for="upload-file">选择文件(支持 .txt/.md/.html/.htm/.pdf/.docx)</label>
|
||||
<input type="file" id="upload-file" name="file" accept=".txt,.md,.html,.htm,.pdf,.docx" required>
|
||||
<label for="upload-file">选择文件(支持多选,.txt/.md/.html/.htm/.pdf/.docx)</label>
|
||||
<input type="file" id="upload-file" name="file" accept=".txt,.md,.html,.htm,.pdf,.docx" multiple required>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="upload-title">标题(可选,默认取文件名)</label>
|
||||
@@ -356,6 +357,21 @@
|
||||
<div class="result-box hidden" id="ingest-result"></div>
|
||||
</section>
|
||||
|
||||
<section id="section-progress" class="hidden">
|
||||
<h2>入库进度</h2>
|
||||
<div class="error-bar hidden" id="error-progress"></div>
|
||||
<div class="toolbar">
|
||||
<button type="button" class="action" id="btn-refresh-progress">刷新</button>
|
||||
<span class="muted" id="progress-stats"></span>
|
||||
</div>
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>文件名</th><th>状态</th><th>创建时间</th><th>更新时间</th><th>操作</th></tr>
|
||||
</thead>
|
||||
<tbody id="progress-tbody"></tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<section id="section-search" class="hidden">
|
||||
<h2>检索测试台</h2>
|
||||
<div class="error-bar hidden" id="error-search"></div>
|
||||
@@ -505,6 +521,7 @@ function showLogin() {
|
||||
document.getElementById("password-overlay").classList.add("hidden");
|
||||
document.getElementById("user-area").classList.add("hidden");
|
||||
loadedOnce = {};
|
||||
stopProgressPolling();
|
||||
unmountUsersSection();
|
||||
}
|
||||
|
||||
@@ -858,6 +875,8 @@ function truncate(text, maxLen) {
|
||||
var loadedOnce = {};
|
||||
|
||||
function activateSection(targetId) {
|
||||
/* 切走前停止进度区块轮询(切到其他区块即暂停) */
|
||||
stopProgressPolling();
|
||||
var sections = document.querySelectorAll("main section");
|
||||
sections.forEach(function (sec) {
|
||||
sec.classList.toggle("hidden", sec.id !== targetId);
|
||||
@@ -875,6 +894,8 @@ function activateSection(targetId) {
|
||||
if (targetId === "section-api-guide") { renderApiGuide(); }
|
||||
if (targetId === "section-profile") { loadProfile(); }
|
||||
}
|
||||
/* 切到入库进度区块时恢复轮询 */
|
||||
if (targetId === "section-progress") { startProgressPolling(); }
|
||||
}
|
||||
|
||||
document.getElementById("nav").addEventListener("click", function (event) {
|
||||
@@ -1158,12 +1179,17 @@ function restoreIngestButton() {
|
||||
}
|
||||
|
||||
function makeStatusBadge(status) {
|
||||
var badge = statusBadgeEl(status);
|
||||
badge.id = "ingest-status-badge";
|
||||
return badge;
|
||||
}
|
||||
|
||||
/* 状态徽章构造:复用 INGEST_STATUS_TEXT 中文映射与 status-* 配色,无 id 可重复使用 */
|
||||
function statusBadgeEl(status) {
|
||||
var cls = "status-running";
|
||||
if (status === "done") { cls = "status-done"; }
|
||||
if (status === "failed") { cls = "status-failed"; }
|
||||
var badge = el("span", INGEST_STATUS_TEXT[status] || status, "status-badge " + cls);
|
||||
badge.id = "ingest-status-badge";
|
||||
return badge;
|
||||
return el("span", INGEST_STATUS_TEXT[status] || status, "status-badge " + cls);
|
||||
}
|
||||
|
||||
function updateIngestStatus(status) {
|
||||
@@ -1290,26 +1316,178 @@ document.getElementById("upload-form").addEventListener("submit", function (even
|
||||
var submitBtn = document.getElementById("btn-upload-submit");
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.textContent = "上传中…";
|
||||
var formData = new FormData();
|
||||
formData.append("file", fileInput.files[0]);
|
||||
var title = document.getElementById("upload-title").value;
|
||||
var source = document.getElementById("upload-source").value;
|
||||
if (title) { formData.append("title", title); }
|
||||
if (source) { formData.append("source", source); }
|
||||
api("/api/v1/documents/upload", {
|
||||
method: "POST",
|
||||
body: formData
|
||||
}).then(function (data) {
|
||||
renderIngestHeader(data.task_id, data.status || "pending");
|
||||
startIngestPolling(data.task_id);
|
||||
}).catch(function (err) {
|
||||
showError("error-ingest", err);
|
||||
}).finally(function () {
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.textContent = "上传入库";
|
||||
});
|
||||
if (fileInput.files.length > 1) {
|
||||
/* 批量上传:FormData 用 files 字段逐文件 append,走 upload-batch 端点 */
|
||||
var batchForm = new FormData();
|
||||
for (var i = 0; i < fileInput.files.length; i++) {
|
||||
batchForm.append("files", fileInput.files[i]);
|
||||
}
|
||||
api("/api/v1/documents/upload-batch", {
|
||||
method: "POST",
|
||||
body: batchForm
|
||||
}).then(function (data) {
|
||||
renderBatchUploadResult(data);
|
||||
/* 自动切到入库进度区块查看进度 */
|
||||
activateSection("section-progress");
|
||||
}).catch(function (err) {
|
||||
showError("error-ingest", err);
|
||||
}).finally(function () {
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.textContent = "上传入库";
|
||||
});
|
||||
} else {
|
||||
/* 单文件上传:保持原逻辑,走 upload 端点 */
|
||||
var formData = new FormData();
|
||||
formData.append("file", fileInput.files[0]);
|
||||
if (title) { formData.append("title", title); }
|
||||
if (source) { formData.append("source", source); }
|
||||
api("/api/v1/documents/upload", {
|
||||
method: "POST",
|
||||
body: formData
|
||||
}).then(function (data) {
|
||||
renderIngestHeader(data.task_id, data.status || "pending");
|
||||
startIngestPolling(data.task_id);
|
||||
}).catch(function (err) {
|
||||
showError("error-ingest", err);
|
||||
}).finally(function () {
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.textContent = "上传入库";
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/* 批量上传结果展示:成功 N 个任务 + 失败 N 个,纯 textContent 防 XSS */
|
||||
function renderBatchUploadResult(data) {
|
||||
var box = document.getElementById("ingest-result");
|
||||
clearChildren(box);
|
||||
box.classList.remove("hidden");
|
||||
var tasks = data.tasks || [];
|
||||
var failed = data.failed || [];
|
||||
var headRow = el("div", null, "kv");
|
||||
headRow.appendChild(el("span", "批量上传完成:成功 " + tasks.length + " 个任务,失败 " + failed.length + " 个"));
|
||||
box.appendChild(headRow);
|
||||
if (tasks.length > 0) {
|
||||
var tasksRow = el("div", null, "kv");
|
||||
tasksRow.appendChild(el("span", "已提交任务:", "k"));
|
||||
var tasksList = tasks.map(function (t) {
|
||||
return (t.filename || "") + "(" + (t.task_id || "") + ")";
|
||||
}).join(",");
|
||||
tasksRow.appendChild(el("span", tasksList));
|
||||
box.appendChild(tasksRow);
|
||||
}
|
||||
if (failed.length > 0) {
|
||||
var failedRow = el("div", null, "kv");
|
||||
failedRow.appendChild(el("span", "失败文件:", "k"));
|
||||
var failedList = failed.map(function (f) {
|
||||
return (f.filename || "") + "(" + (f.error || "") + ")";
|
||||
}).join(",");
|
||||
failedRow.appendChild(el("span", failedList));
|
||||
box.appendChild(failedRow);
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- 3.5 入库进度区块(2s 轮询,区块可见时轮询、切走暂停) ---------- */
|
||||
|
||||
var PROGRESS_POLL_INTERVAL_MS = 2000;
|
||||
var progressPollTimer = null;
|
||||
|
||||
function stopProgressPolling() {
|
||||
if (progressPollTimer !== null) {
|
||||
clearInterval(progressPollTimer);
|
||||
progressPollTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function startProgressPolling() {
|
||||
stopProgressPolling();
|
||||
loadProgress();
|
||||
progressPollTimer = setInterval(loadProgress, PROGRESS_POLL_INTERVAL_MS);
|
||||
}
|
||||
|
||||
/* GET /documents/tasks 拉取任务列表,渲染表格与状态统计 */
|
||||
function loadProgress() {
|
||||
hideError("error-progress");
|
||||
api("/api/v1/documents/tasks?limit=50").then(function (data) {
|
||||
renderProgress(data.items || [], data.total || 0);
|
||||
}).catch(function (err) { showError("error-progress", err); });
|
||||
}
|
||||
|
||||
function renderProgress(items, total) {
|
||||
var tbody = document.getElementById("progress-tbody");
|
||||
clearChildren(tbody);
|
||||
var running = 0, done = 0, failed = 0;
|
||||
items.forEach(function (it) {
|
||||
if (it.status === "done") { done += 1; }
|
||||
else if (it.status === "failed") { failed += 1; }
|
||||
else { running += 1; }
|
||||
tbody.appendChild(buildProgressRow(it));
|
||||
});
|
||||
document.getElementById("progress-stats").textContent =
|
||||
"总数 " + total + " · 进行中 " + running + " · 完成 " + done + " · 失败 " + failed;
|
||||
if (items.length === 0) {
|
||||
var tr = el("tr");
|
||||
var td = el("td", "暂无入库任务", "muted");
|
||||
td.setAttribute("colspan", "5");
|
||||
tr.appendChild(td);
|
||||
tbody.appendChild(tr);
|
||||
}
|
||||
}
|
||||
|
||||
/* 单行渲染:文件名 / 状态徽章 / 创建时间 / 更新时间 / 操作(done 查看文档+重新入库,failed 重试) */
|
||||
function buildProgressRow(item) {
|
||||
var tr = el("tr");
|
||||
tr.appendChild(el("td", item.filename || "(未知文件)"));
|
||||
|
||||
var statusTd = el("td");
|
||||
statusTd.appendChild(statusBadgeEl(item.status));
|
||||
tr.appendChild(statusTd);
|
||||
|
||||
tr.appendChild(el("td", item.created_at || ""));
|
||||
tr.appendChild(el("td", item.updated_at || ""));
|
||||
|
||||
var opsTd = el("td");
|
||||
if (item.status === "done" && item.doc_id) {
|
||||
var viewBtn = el("button", "查看文档", "action");
|
||||
viewBtn.type = "button";
|
||||
viewBtn.addEventListener("click", function () {
|
||||
activateSection("section-docs");
|
||||
loadDocDetail(item.doc_id);
|
||||
});
|
||||
var reingestBtn = el("button", "重新入库", "action");
|
||||
reingestBtn.type = "button";
|
||||
reingestBtn.addEventListener("click", function () {
|
||||
reingestDocument(item.doc_id, item.filename);
|
||||
});
|
||||
opsTd.appendChild(viewBtn);
|
||||
opsTd.appendChild(reingestBtn);
|
||||
} else if (item.status === "failed" && item.doc_id) {
|
||||
var retryBtn = el("button", "重试", "action");
|
||||
retryBtn.type = "button";
|
||||
retryBtn.addEventListener("click", function () {
|
||||
reingestDocument(item.doc_id, item.filename);
|
||||
});
|
||||
opsTd.appendChild(retryBtn);
|
||||
}
|
||||
tr.appendChild(opsTd);
|
||||
return tr;
|
||||
}
|
||||
|
||||
/* 重新入库:POST /documents/{doc_id}/reingest,成功后刷新进度表格 */
|
||||
function reingestDocument(docId, filename) {
|
||||
hideError("error-progress");
|
||||
api("/api/v1/documents/" + encodeURIComponent(docId) + "/reingest", {
|
||||
method: "POST"
|
||||
}).then(function (data) {
|
||||
var stats = document.getElementById("progress-stats");
|
||||
stats.textContent = "已提交「" + (filename || docId) + "」重新入库:task_id=" + (data.task_id || "");
|
||||
loadProgress();
|
||||
}).catch(function (err) { showError("error-progress", err); });
|
||||
}
|
||||
|
||||
document.getElementById("btn-refresh-progress").addEventListener("click", loadProgress);
|
||||
|
||||
/* ---------- 4. 检索测试台 ---------- */
|
||||
|
||||
document.getElementById("search-form").addEventListener("submit", function (event) {
|
||||
@@ -1768,7 +1946,15 @@ var API_GUIDE = [
|
||||
{ name: "title", desc: "可选标题,默认取文件名去扩展", def: "" },
|
||||
{ name: "source", desc: "可选来源标识,默认 file:原文件名", def: "" }
|
||||
],
|
||||
body: null, upload: true },
|
||||
body: null, upload: true, uploadField: "file", uploadMultiple: false },
|
||||
{ method: "POST", path: "/api/v1/documents/upload-batch", desc: "批量文件上传入库(multipart files 字段,逐文件复用单文件逻辑,202 异步)", auth: "bearer",
|
||||
query: [], pathParams: [], form: [],
|
||||
body: null, upload: true, uploadField: "files", uploadMultiple: true },
|
||||
{ method: "GET", path: "/api/v1/documents/tasks", desc: "入库任务列表(合并内存与 Redis 镜像,按 updated_at 降序)", auth: "bearer",
|
||||
query: [
|
||||
{ name: "limit", desc: "返回条数(1-100)", def: "20" }
|
||||
],
|
||||
pathParams: [], form: [], body: null, upload: false },
|
||||
{ method: "GET", path: "/api/v1/documents/tasks/{task_id}", desc: "入库任务状态查询(done 附 result,failed 附 error)", auth: "none",
|
||||
query: [],
|
||||
pathParams: [{ name: "task_id", desc: "入库任务 ID", def: "" }],
|
||||
@@ -1787,6 +1973,10 @@ var API_GUIDE = [
|
||||
query: [],
|
||||
pathParams: [{ name: "doc_id", desc: "文档 ID", def: "" }],
|
||||
form: [], body: null, upload: false },
|
||||
{ method: "POST", path: "/api/v1/documents/{doc_id}/reingest", desc: "重新入库(读原文件→删旧数据→提交新任务,生成新 doc_id)", auth: "bearer",
|
||||
query: [],
|
||||
pathParams: [{ name: "doc_id", desc: "文档 ID", def: "" }],
|
||||
form: [], body: null, upload: false },
|
||||
{ method: "GET", path: "/api/v1/knowledge/categories", desc: "知识分类类目集", auth: "none",
|
||||
query: [], pathParams: [], form: [], body: null, upload: false },
|
||||
{ method: "GET", path: "/api/v1/knowledge/stats", desc: "统计(四层点数 + 类目分布 + uncategorized 数)", auth: "none",
|
||||
@@ -1841,7 +2031,8 @@ function buildCurl(ep) {
|
||||
parts.push('-H "Authorization: Bearer <TOKEN>"');
|
||||
}
|
||||
if (ep.upload) {
|
||||
parts.push('-F "file=@/path/to/file.md"');
|
||||
var fieldName = ep.uploadField || "file";
|
||||
parts.push('-F "' + fieldName + '=@/path/to/file.md"');
|
||||
} else if (ep.body) {
|
||||
parts.push('-H "Content-Type: application/json"');
|
||||
parts.push("-d '" + JSON.stringify(ep.body) + "'");
|
||||
@@ -1964,7 +2155,11 @@ function sendApiTry(idx, panel) {
|
||||
}
|
||||
/* FormData 由浏览器自动生成 multipart 边界,不设置 Content-Type 头 */
|
||||
var formData = new FormData();
|
||||
formData.append("file", fileInput.files[0]);
|
||||
/* uploadField 默认 file;批量上传用 files 字段逐文件 append */
|
||||
var upField = ep.uploadField || "file";
|
||||
for (var fi = 0; fi < fileInput.files.length; fi++) {
|
||||
formData.append(upField, fileInput.files[fi]);
|
||||
}
|
||||
var formInputs = panel.querySelectorAll(".try-form-input");
|
||||
for (var k = 0; k < formInputs.length; k++) {
|
||||
var fVal = formInputs[k].value.trim();
|
||||
@@ -2044,11 +2239,12 @@ function renderApiItem(ep, idx) {
|
||||
if (ep.upload) {
|
||||
renderTryInputs(panel, "form", ep.form);
|
||||
var fileField = el("div", null, "field");
|
||||
fileField.appendChild(el("label", "文件(.txt/.md/.html/.htm/.pdf/.docx)"));
|
||||
fileField.appendChild(el("label", "文件(.txt/.md/.html/.htm/.pdf/.docx" + (ep.uploadMultiple ? ",可多选" : "") + ")"));
|
||||
var fileInput = el("input");
|
||||
fileInput.type = "file";
|
||||
fileInput.className = "try-file";
|
||||
fileInput.accept = ".txt,.md,.html,.htm,.pdf,.docx";
|
||||
if (ep.uploadMultiple) { fileInput.setAttribute("multiple", ""); }
|
||||
fileField.appendChild(fileInput);
|
||||
panel.appendChild(fileField);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user