feat(web): kb 下载入口(原件+解析 Markdown)+ bump 0.59.3
- kb.js:已入库条目行「⬇ 下载原件」、单篇页头「下载原件/下载 Markdown」、待入库列表逐项下载;复用 media.js downloadFile 走现成 /v1/files/download(.kb 在 user_root 下,safe_join 天然放行),零后端改动 - dev.html:新按钮三行 CSS(hover 淡入同删除按钮、待入库行间距) - 弃选:kb 专属下载端点(与 files 下载重复,公测期白养对外契约) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
8751f71f5a
commit
0cd5b07124
|
|
@ -5,6 +5,10 @@
|
||||||
> 所以不是每个版本号都有条目。条目格式 `## <版本> — <日期>`,新条目加在最上面。
|
> 所以不是每个版本号都有条目。条目格式 `## <版本> — <日期>`,新条目加在最上面。
|
||||||
> 工程口径的完整记录见 `PROGRESS.md` / git log。
|
> 工程口径的完整记录见 `PROGRESS.md` / git log。
|
||||||
|
|
||||||
|
## 0.59.3 — 2026-07-23
|
||||||
|
|
||||||
|
- 知识库支持下载文件:已入库的每篇文档可下载原始文件(⬇ 按钮),打开单篇正文后还可下载整理好的 Markdown 文字版;等待入库的文件也能随时取回。
|
||||||
|
|
||||||
## 0.59.2 — 2026-07-23
|
## 0.59.2 — 2026-07-23
|
||||||
|
|
||||||
- 手机端也能发文件了:对话输入框下新增「📎 附件」按钮,点开可拍照、从相册选照片 / 视频、或选取文件,上传后随消息一起发给助手;电脑端点击直接打开文件选择器(原有的粘贴 / 拖拽方式照旧)。
|
- 手机端也能发文件了:对话输入框下新增「📎 附件」按钮,点开可拍照、从相册选照片 / 视频、或选取文件,上传后随消息一起发给助手;电脑端点击直接打开文件选择器(原有的粘贴 / 拖拽方式照旧)。
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@
|
||||||
|
|
||||||
### 2026-07
|
### 2026-07
|
||||||
|
|
||||||
|
- **07-23 / 0.59.3 / 知识库下载入口(原件 + 解析 Markdown,纯前端)**:kb 面板此前只能在线看不能取回。关键发现:`.kb/` 就在 user_root 下且 `/v1/files/download` 的 safe_join 只挡越界不挡 dotfile → 零后端改动,`kb.js` 复用 `media.js::downloadFile`(Bearer + blob)拼 `.kb/<库>/{sources,docs}/…` 路径即可。入口三处:已入库条目行「⬇ 下载原件」(agent 手写无原件的条目不显示)、单篇查看页头「下载原件 / 下载 Markdown」(doc→source 映射渲染详情时记 `_docSources`)、待入库列表逐项「下载」。弃选方案:新增 `/v1/kb/*/sources` 专属下载端点(与 files 下载全量重复,公测期白养一个对外契约)。改动仅 kb.js + dev.html 三行 CSS。
|
||||||
- **07-23 / 0.59.2 / 移动端对话附件入口(📎 拍照 / 照片库 / 选文件)**:chat 输入区此前仅粘贴 / 拖拽两条附件路(移动端双双不可用,手机没有任何传文件入口)。加 📎 按钮:触屏(`pointer: coarse`)弹 showMenu 三项菜单——拍照(`capture="environment"` 直起后置相机)/ 照片库(`accept="image/*,video/*"`)/ 选取文件(无 accept);桌面端点击直开系统文件选择器(拖拽 / 粘贴照旧)。三个隐藏 input 的 change 统一走既有 `uploadAttachFiles()`(chip 托盘 / 发送注入行零改动);change 后清 value 允许连选同一文件;微信 / 企业微信只读镜像随 `applyChannelComposerLock` 禁用按钮 + 点击侧 `_composerLocked()` 双保险。纯前端(dev.html / chat.js),无后端改动。裸 file input 方案(靠 iOS 原生三选单)因 Android 行为不确定被弃,三项菜单跨平台确定。
|
- **07-23 / 0.59.2 / 移动端对话附件入口(📎 拍照 / 照片库 / 选文件)**:chat 输入区此前仅粘贴 / 拖拽两条附件路(移动端双双不可用,手机没有任何传文件入口)。加 📎 按钮:触屏(`pointer: coarse`)弹 showMenu 三项菜单——拍照(`capture="environment"` 直起后置相机)/ 照片库(`accept="image/*,video/*"`)/ 选取文件(无 accept);桌面端点击直开系统文件选择器(拖拽 / 粘贴照旧)。三个隐藏 input 的 change 统一走既有 `uploadAttachFiles()`(chip 托盘 / 发送注入行零改动);change 后清 value 允许连选同一文件;微信 / 企业微信只读镜像随 `applyChannelComposerLock` 禁用按钮 + 点击侧 `_composerLocked()` 双保险。纯前端(dev.html / chat.js),无后端改动。裸 file input 方案(靠 iOS 原生三选单)因 Android 行为不确定被弃,三项菜单跨平台确定。
|
||||||
- **07-23 / 0.59.1 / 架构审查落地:15 commit 内部重构大扫除(对外行为零变化)**:全库审查(web/核心循环/工具渲染/数据层四路并行探查)后按优先级逐项执行。①**web 拆分**——app.py 3972→197 行:73 路由迁 11 个 `web/routers/*`(register 范式同 admin.py),lifespan 后台协程按域析出(`background`/`scheduler_runner`/`wechat_runner`),schemas/model_gate/runs/userfiles/common 各归位;②**计费收口**——units JSONB 读侧唯一出口 `core/storage/usage_report.py`(web 层清零 cast),写侧拆 `telemetry.py`(四类 cost=0 留痕与真计费分家,kind 常量成 loop↔toolfail 契约单一事实源);③**loop 拆分**——传输健壮性层(畸形/吐空检测、留痕、非流式降级重试)析出 `core/llm_transport.py`(loop 1158→812),`_execute_tool_call` 148 行拆 4 个正交方法,测试打桩缝(`_collect_stream_once`/`_nonstream_once`)原样保留;④**工具注册声明式化**——`core/tool_registry.py` (组名,gate,factory) 表收敛四种 env-gate 写法(§7.5 #7 获代码强制),32 工具挂载逐名比对不变;⑤**媒体工具去重**——五工具同构(配额闸/落盘命名/记账兜底/超时重试/递归找 URL)收 `tools/media_common.py`,净减 122 行,banner/轮询等刻意差异不动;⑥**渲染统一**——块收集器(fence 闭合/表格/blockquote/段落并合)与 brief inline 切分下沉 `rendering/common`,**golden 基线**(四 profile 渲富 fixture、document.xml 逐字节对账,`tests/golden/`)零 diff 验收,分派状态机(in_refs 等)留 profile;⑦**测试 286→351**——scheduler 服务层、usage 口径、media 原语、wecom 加解密(自造密文覆盖失败路径)、路由两套(无 DB 面:11 router 鉴权门 19 端点 + kb/skills/memory/files FS;DB 面:tasks CRUD 全链 + files 顶层目录 DB-aware 四分支 + upload);测试库基建 = docker PG@5433 + `ZCBOT_TEST_DB_URL` 显式门控(RUN.md 一键命令)。**修 2 个真 bug**:restore 恢复真软删过的任务必 500(server-side onupdate 列 flush 后 expired → DetachedInstanceError,幂等路径掩盖多时,路由测试抓出);DB 测试曾沿 .env 连到生产隧道(127.0.0.1:6012)——插入的到点 job 被生产 green 调度守护真跑,门控收紧为只认 ZCBOT_TEST_DB_URL + connect_timeout=5 兜挂库,产物已全量清理。附带:scripts 50→29(已结案诊断/backfill 归档 `scripts/archive/`)、`tools/output.py`(输出处理自 base.py 析出)、export_docx 复用 rendering.common 字体助手(rendering 保持无 core 依赖的沙箱可用性,修正审查"迁 rendering"建议)。
|
- **07-23 / 0.59.1 / 架构审查落地:15 commit 内部重构大扫除(对外行为零变化)**:全库审查(web/核心循环/工具渲染/数据层四路并行探查)后按优先级逐项执行。①**web 拆分**——app.py 3972→197 行:73 路由迁 11 个 `web/routers/*`(register 范式同 admin.py),lifespan 后台协程按域析出(`background`/`scheduler_runner`/`wechat_runner`),schemas/model_gate/runs/userfiles/common 各归位;②**计费收口**——units JSONB 读侧唯一出口 `core/storage/usage_report.py`(web 层清零 cast),写侧拆 `telemetry.py`(四类 cost=0 留痕与真计费分家,kind 常量成 loop↔toolfail 契约单一事实源);③**loop 拆分**——传输健壮性层(畸形/吐空检测、留痕、非流式降级重试)析出 `core/llm_transport.py`(loop 1158→812),`_execute_tool_call` 148 行拆 4 个正交方法,测试打桩缝(`_collect_stream_once`/`_nonstream_once`)原样保留;④**工具注册声明式化**——`core/tool_registry.py` (组名,gate,factory) 表收敛四种 env-gate 写法(§7.5 #7 获代码强制),32 工具挂载逐名比对不变;⑤**媒体工具去重**——五工具同构(配额闸/落盘命名/记账兜底/超时重试/递归找 URL)收 `tools/media_common.py`,净减 122 行,banner/轮询等刻意差异不动;⑥**渲染统一**——块收集器(fence 闭合/表格/blockquote/段落并合)与 brief inline 切分下沉 `rendering/common`,**golden 基线**(四 profile 渲富 fixture、document.xml 逐字节对账,`tests/golden/`)零 diff 验收,分派状态机(in_refs 等)留 profile;⑦**测试 286→351**——scheduler 服务层、usage 口径、media 原语、wecom 加解密(自造密文覆盖失败路径)、路由两套(无 DB 面:11 router 鉴权门 19 端点 + kb/skills/memory/files FS;DB 面:tasks CRUD 全链 + files 顶层目录 DB-aware 四分支 + upload);测试库基建 = docker PG@5433 + `ZCBOT_TEST_DB_URL` 显式门控(RUN.md 一键命令)。**修 2 个真 bug**:restore 恢复真软删过的任务必 500(server-side onupdate 列 flush 后 expired → DetachedInstanceError,幂等路径掩盖多时,路由测试抓出);DB 测试曾沿 .env 连到生产隧道(127.0.0.1:6012)——插入的到点 job 被生产 green 调度守护真跑,门控收紧为只认 ZCBOT_TEST_DB_URL + connect_timeout=5 兜挂库,产物已全量清理。附带:scripts 50→29(已结案诊断/backfill 归档 `scripts/archive/`)、`tools/output.py`(输出处理自 base.py 析出)、export_docx 复用 rendering.common 字体助手(rendering 保持无 core 依赖的沙箱可用性,修正审查"迁 rendering"建议)。
|
||||||
- **07-22 / 0.59.0 / 个人知识库(纯文件 .kb/ 机制,照记忆范式)**:方案 07-22 对齐后落地(DESIGN §3.8)。**core**:`core/kb.py`(状态/视图层:库名与文件名校验、INDEX 单行格式 `- [标题](docs/x.md)|来源 sources/x.pdf|摘要:…|关键词:…` 的 parse/format、"已入库判据 = INDEX 有条目"派生 pending、删单篇连带原件与 INDEX 行、`kb_block` 注入)+ `core/kb_ingest.py`(入库管线:markitdown Python API → 扫描件 PDF 文本近零走方舟 OCR 兜底(§8.13 同通道)→ flash 单次 chat 写标题/摘要/关键词(失败降级不阻塞)→ 追加 INDEX;编排 create_task+to_thread+per-(user,库) 锁,进度内存态供轮询)。**记账**:0022 迁移放宽 `usage_events.task_id` 可 NULL(kb 入库无 task 上下文),溯源 = kind="kb_ingest"/"vision" + units `{"kb","source"}`(record_chat/vision_usage 签名同步放宽)。**API**:`/v1/kb*` 8 端点(列/建/删库、详情带入库进度、上传即入库、手动 ingest、看/删单篇),不设 HTTP 检索端点。**agent 侧零新工具**:agent_builder 在 memory_block 后注 `kb_block`(有库才注,docker/host 路径换算同 .memory);documents skill「何时不用」补自建资料路由行。**前端**:`kb.js` 两栏 modal(建/删库、上传 XHR、入库进度 3s 轮询、看/删单篇),rail 底部四按钮改图标+小字两行布局(用户 07-22 定)。冒烟:core 层 28 项全过(真实 markitdown + flash 摘要),TestClient API 15 项全过(usage 行实测 task_id=NULL + units 溯源),受影响存量测试(usage_accounting / system_prompt_paths)绿。
|
- **07-22 / 0.59.0 / 个人知识库(纯文件 .kb/ 机制,照记忆范式)**:方案 07-22 对齐后落地(DESIGN §3.8)。**core**:`core/kb.py`(状态/视图层:库名与文件名校验、INDEX 单行格式 `- [标题](docs/x.md)|来源 sources/x.pdf|摘要:…|关键词:…` 的 parse/format、"已入库判据 = INDEX 有条目"派生 pending、删单篇连带原件与 INDEX 行、`kb_block` 注入)+ `core/kb_ingest.py`(入库管线:markitdown Python API → 扫描件 PDF 文本近零走方舟 OCR 兜底(§8.13 同通道)→ flash 单次 chat 写标题/摘要/关键词(失败降级不阻塞)→ 追加 INDEX;编排 create_task+to_thread+per-(user,库) 锁,进度内存态供轮询)。**记账**:0022 迁移放宽 `usage_events.task_id` 可 NULL(kb 入库无 task 上下文),溯源 = kind="kb_ingest"/"vision" + units `{"kb","source"}`(record_chat/vision_usage 签名同步放宽)。**API**:`/v1/kb*` 8 端点(列/建/删库、详情带入库进度、上传即入库、手动 ingest、看/删单篇),不设 HTTP 检索端点。**agent 侧零新工具**:agent_builder 在 memory_block 后注 `kb_block`(有库才注,docker/host 路径换算同 .memory);documents skill「何时不用」补自建资料路由行。**前端**:`kb.js` 两栏 modal(建/删库、上传 XHR、入库进度 3s 轮询、看/删单篇),rail 底部四按钮改图标+小字两行布局(用户 07-22 定)。冒烟:core 层 28 项全过(真实 markitdown + flash 摘要),TestClient API 15 项全过(usage 行实测 task_id=NULL + units 溯源),受影响存量测试(usage_accounting / system_prompt_paths)绿。
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
# zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。
|
# zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。
|
||||||
# 改版本只动这一行。
|
# 改版本只动这一行。
|
||||||
__version__ = "0.59.2"
|
__version__ = "0.59.3"
|
||||||
|
|
|
||||||
|
|
@ -509,8 +509,9 @@
|
||||||
.kb-entry { display: flex; align-items: flex-start; gap: 6px; padding: 6px 0; border-bottom: 1px dashed var(--border); }
|
.kb-entry { display: flex; align-items: flex-start; gap: 6px; padding: 6px 0; border-bottom: 1px dashed var(--border); }
|
||||||
.kb-entry-main { flex: 1; min-width: 0; cursor: pointer; }
|
.kb-entry-main { flex: 1; min-width: 0; cursor: pointer; }
|
||||||
.kb-entry-main:hover .sk-name { text-decoration: underline; }
|
.kb-entry-main:hover .sk-name { text-decoration: underline; }
|
||||||
.kb-entry .kb-doc-del { flex-shrink: 0; opacity: .55; }
|
.kb-entry .kb-doc-del, .kb-entry .kb-src-dl { flex-shrink: 0; opacity: .55; }
|
||||||
.kb-entry:hover .kb-doc-del { opacity: 1; }
|
.kb-entry:hover .kb-doc-del, .kb-entry:hover .kb-src-dl { opacity: 1; }
|
||||||
|
.kb-pending .kb-src-dl { margin-left: 6px; }
|
||||||
@media (max-width: 760px) {
|
@media (max-width: 760px) {
|
||||||
#kb-modal .card { width: 96vw; height: 88vh; max-height: 88vh; }
|
#kb-modal .card { width: 96vw; height: 88vh; max-height: 88vh; }
|
||||||
#kb-cols { flex-direction: column; }
|
#kb-cols { flex-direction: column; }
|
||||||
|
|
|
||||||
|
|
@ -8,11 +8,16 @@ import { state } from "./state.js";
|
||||||
import { escapeHtml } from "./format.js";
|
import { escapeHtml } from "./format.js";
|
||||||
import { renderMd, highlightIn } from "./markdown.js";
|
import { renderMd, highlightIn } from "./markdown.js";
|
||||||
import { dialogConfirm, dialogPrompt, message } from "./dialog.js";
|
import { dialogConfirm, dialogPrompt, message } from "./dialog.js";
|
||||||
|
import { downloadFile } from "./media.js";
|
||||||
|
|
||||||
const PLACEHOLDER = '<div class="sk-empty">← 选一个库查看,或「+ 新建库」</div>';
|
const PLACEHOLDER = '<div class="sk-empty">← 选一个库查看,或「+ 新建库」</div>';
|
||||||
const POLL_MS = 3000;
|
const POLL_MS = 3000;
|
||||||
let _current = null; // 当前选中库名
|
let _current = null; // 当前选中库名
|
||||||
let _pollTimer = null;
|
let _pollTimer = null;
|
||||||
|
let _docSources = {}; // doc 文件名 → source 原件名(renderDetail 时填,单篇页「下载原件」用)
|
||||||
|
|
||||||
|
// .kb 就在 user_root 下,下载直接复用 /v1/files/download(media.js downloadFile)
|
||||||
|
const kbRel = (sub) => ".kb/" + _current + "/" + sub;
|
||||||
|
|
||||||
function openKbModal() {
|
function openKbModal() {
|
||||||
$("kb-modal").classList.add("show");
|
$("kb-modal").classList.add("show");
|
||||||
|
|
@ -115,20 +120,28 @@ function renderDetail(d) {
|
||||||
}
|
}
|
||||||
if (pending.length) {
|
if (pending.length) {
|
||||||
html += `<div class="sk-group-title" style="margin-top:10px;">待入库 (${pending.length})</div>`;
|
html += `<div class="sk-group-title" style="margin-top:10px;">待入库 (${pending.length})</div>`;
|
||||||
html += pending.map((n) => `<div class="kb-pending">${escapeHtml(n)}</div>`).join("");
|
html += pending.map((n) => `<div class="kb-pending">${escapeHtml(n)}
|
||||||
|
<button class="small kb-src-dl" data-src="${escapeHtml(n)}" title="下载原件">下载</button></div>`).join("");
|
||||||
if (!ing.running) {
|
if (!ing.running) {
|
||||||
html += `<div style="padding:6px 0;"><button id="kb-reingest" class="small">重试入库</button></div>`;
|
html += `<div style="padding:6px 0;"><button id="kb-reingest" class="small">重试入库</button></div>`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
html += '<div class="sk-group-title" style="margin-top:10px;">已入库</div>';
|
html += '<div class="sk-group-title" style="margin-top:10px;">已入库</div>';
|
||||||
|
_docSources = {};
|
||||||
html += entries.length
|
html += entries.length
|
||||||
? entries.map((e) => `<div class="kb-entry">
|
? entries.map((e) => {
|
||||||
<div class="kb-entry-main" data-doc="${escapeHtml(e.doc.replace(/^docs\//, ""))}">
|
const doc = e.doc.replace(/^docs\//, "");
|
||||||
|
const src = (e.source || "").replace(/^sources\//, "");
|
||||||
|
if (src) _docSources[doc] = src;
|
||||||
|
return `<div class="kb-entry">
|
||||||
|
<div class="kb-entry-main" data-doc="${escapeHtml(doc)}">
|
||||||
<div class="sk-name">${escapeHtml(e.title)}</div>
|
<div class="sk-name">${escapeHtml(e.title)}</div>
|
||||||
<div class="sk-desc">${escapeHtml(e.summary)}${e.keywords ? " | " + escapeHtml(e.keywords) : ""}</div>
|
<div class="sk-desc">${escapeHtml(e.summary)}${e.keywords ? " | " + escapeHtml(e.keywords) : ""}</div>
|
||||||
</div>
|
</div>
|
||||||
<button class="small danger kb-doc-del" data-doc="${escapeHtml(e.doc.replace(/^docs\//, ""))}" title="删除该篇(含原件)">✕</button>
|
${src ? `<button class="small kb-src-dl" data-src="${escapeHtml(src)}" title="下载原件">⬇</button>` : ""}
|
||||||
</div>`).join("")
|
<button class="small danger kb-doc-del" data-doc="${escapeHtml(doc)}" title="删除该篇(含原件)">✕</button>
|
||||||
|
</div>`;
|
||||||
|
}).join("")
|
||||||
: '<div class="muted" style="padding:4px 0;font-size:12px;">空库。点「上传文件」把资料传进来,自动转 markdown 并写摘要。</div>';
|
: '<div class="muted" style="padding:4px 0;font-size:12px;">空库。点「上传文件」把资料传进来,自动转 markdown 并写摘要。</div>';
|
||||||
$("kb-detail").innerHTML = html;
|
$("kb-detail").innerHTML = html;
|
||||||
}
|
}
|
||||||
|
|
@ -144,9 +157,13 @@ async function showDoc(filename) {
|
||||||
detail.innerHTML = `<div class="err" style="padding:8px;">加载失败: ${escapeHtml(e.message)}</div>`;
|
detail.innerHTML = `<div class="err" style="padding:8px;">加载失败: ${escapeHtml(e.message)}</div>`;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const src = _docSources[filename];
|
||||||
detail.innerHTML =
|
detail.innerHTML =
|
||||||
`<div class="sk-d-head"><button id="kb-back" class="small">← 返回</button>
|
`<div class="sk-d-head"><button id="kb-back" class="small">← 返回</button>
|
||||||
<span class="sk-d-name">${escapeHtml(filename)}</span></div>` +
|
<span class="sk-d-name">${escapeHtml(filename)}</span>
|
||||||
|
<span class="spacer"></span>
|
||||||
|
${src ? `<button id="kb-doc-src-dl" class="small" data-src="${escapeHtml(src)}">下载原件</button>` : ""}
|
||||||
|
<button id="kb-doc-md-dl" class="small" data-doc="${escapeHtml(filename)}">下载 Markdown</button></div>` +
|
||||||
`<div class="sk-detail-md">${renderMd(data.content)}</div>`;
|
`<div class="sk-detail-md">${renderMd(data.content)}</div>`;
|
||||||
highlightIn(detail);
|
highlightIn(detail);
|
||||||
}
|
}
|
||||||
|
|
@ -195,6 +212,10 @@ $("kb-upload-input").addEventListener("change", () => {
|
||||||
$("kb-detail").addEventListener("click", async (e) => {
|
$("kb-detail").addEventListener("click", async (e) => {
|
||||||
if (e.target.id === "kb-upload-btn") { $("kb-upload-input").click(); return; }
|
if (e.target.id === "kb-upload-btn") { $("kb-upload-input").click(); return; }
|
||||||
if (e.target.id === "kb-back") { showKb(_current, null); return; }
|
if (e.target.id === "kb-back") { showKb(_current, null); return; }
|
||||||
|
if (e.target.id === "kb-doc-md-dl") { downloadFile(kbRel("docs/" + e.target.getAttribute("data-doc"))); return; }
|
||||||
|
if (e.target.id === "kb-doc-src-dl") { downloadFile(kbRel("sources/" + e.target.getAttribute("data-src"))); return; }
|
||||||
|
const srcDl = e.target.closest(".kb-src-dl");
|
||||||
|
if (srcDl) { downloadFile(kbRel("sources/" + srcDl.getAttribute("data-src"))); return; }
|
||||||
if (e.target.id === "kb-reingest") {
|
if (e.target.id === "kb-reingest") {
|
||||||
try { await api("POST", "/v1/kb/" + encodeURIComponent(_current) + "/ingest"); } catch (err) {
|
try { await api("POST", "/v1/kb/" + encodeURIComponent(_current) + "/ingest"); } catch (err) {
|
||||||
message("触发失败: " + err.message, "error"); return;
|
message("触发失败: " + err.message, "error"); return;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue