From 8f1f75c94758e10889a51b57d16aecb10d35dcf8 Mon Sep 17 00:00:00 2001 From: yumoqing Date: Wed, 5 Aug 2026 16:27:32 +0800 Subject: [PATCH] feat(kb): add rename and delete knowledge base - kb_list.dspy: add per-card rename (popup form) and delete (confirm+cascade) buttons - rename_kb_form.dspy: popup form with name + description fields - rename_kb.dspy: UPDATE knowledge_bases handler - delete_kb.dspy: cascade delete (docs, chunks, media_tags, tags, files) handler --- wwwroot/knowledge_bases_list/delete_kb.dspy | 76 +++++++++++++++++++ wwwroot/knowledge_bases_list/kb_list.dspy | 64 ++++++++++++---- wwwroot/knowledge_bases_list/rename_kb.dspy | 20 +++++ .../knowledge_bases_list/rename_kb_form.dspy | 51 +++++++++++++ 4 files changed, 198 insertions(+), 13 deletions(-) create mode 100644 wwwroot/knowledge_bases_list/delete_kb.dspy create mode 100644 wwwroot/knowledge_bases_list/rename_kb.dspy create mode 100644 wwwroot/knowledge_bases_list/rename_kb_form.dspy diff --git a/wwwroot/knowledge_bases_list/delete_kb.dspy b/wwwroot/knowledge_bases_list/delete_kb.dspy new file mode 100644 index 0000000..76808c5 --- /dev/null +++ b/wwwroot/knowledge_bases_list/delete_kb.dspy @@ -0,0 +1,76 @@ +ns = params_kw.copy() +kb_id = ns.get('kb_id', '') + +if not kb_id: + return json.dumps({"status": "error", "error": "kb_id required"}, ensure_ascii=False) + +env = request._run_ns +userorgid = await env.get_userorgid() +deleted = {"documents": 0, "chunks": 0, "tags": 0, "media_tags": 0, "files": 0} + +async with get_sor_context(env, 'rag') as sor: + # Check owner + kb = await sor.sqlExe( + "SELECT id, name FROM knowledge_bases WHERE id=${id}$ AND org_id=${org_id}$", + {"id": kb_id, "org_id": userorgid}) + if not kb: + return json.dumps({"status": "error", "error": "knowledge base not found or access denied"}, ensure_ascii=False) + + # Get all doc IDs and file paths for physical cleanup + docs = await sor.sqlExe( + "SELECT id, file_path FROM documents WHERE kb_id=${kb_id}$", + {"kb_id": kb_id}) + doc_ids = [d.id for d in docs] + + if doc_ids: + # Delete chunks + await sor.sqlExe( + "DELETE FROM document_chunks WHERE doc_id IN (${ids}$)", + {"ids": doc_ids}) + deleted["chunks"] = len(doc_ids) + + # Delete media_tags + mt = await sor.sqlExe( + "SELECT COUNT(*) AS cnt FROM media_tags WHERE kb_id=${kb_id}$", + {"kb_id": kb_id}) + await sor.sqlExe( + "DELETE FROM media_tags WHERE kb_id=${kb_id}$", + {"kb_id": kb_id}) + deleted["media_tags"] = mt[0].cnt if mt else 0 + + # Delete tags + t = await sor.sqlExe( + "SELECT COUNT(*) AS cnt FROM tags WHERE kb_id=${kb_id}$", + {"kb_id": kb_id}) + await sor.sqlExe( + "DELETE FROM tags WHERE kb_id=${kb_id}$", + {"kb_id": kb_id}) + deleted["tags"] = t[0].cnt if t else 0 + + # Delete documents + await sor.sqlExe( + "DELETE FROM documents WHERE kb_id=${kb_id}$", + {"kb_id": kb_id}) + deleted["documents"] = len(doc_ids) + + # Delete the KB itself + await sor.sqlExe( + "DELETE FROM knowledge_bases WHERE id=${id}$", + {"id": kb_id}) + deleted["kb_name"] = str(kb[0].name) + +# Delete physical files +for d in docs: + fp = d.file_path or '' + if fp and fp.startswith("/idfile/"): + try: + real_path = env.realpath(fp) + if os.path.exists(real_path): + os.remove(real_path) + deleted["files"] += 1 + except Exception: + pass + +return json.dumps({"status": "SUCCEEDED", "kb_name": str(kb[0].name), + "documents": deleted["documents"], "files_removed": deleted["files"], + "tags": deleted["tags"]}, ensure_ascii=False) diff --git a/wwwroot/knowledge_bases_list/kb_list.dspy b/wwwroot/knowledge_bases_list/kb_list.dspy index a34ad2d..9512a89 100644 --- a/wwwroot/knowledge_bases_list/kb_list.dspy +++ b/wwwroot/knowledge_bases_list/kb_list.dspy @@ -3,8 +3,7 @@ cards = [] async with get_sor_context(env, 'rag') as sor: recs = await sor.R("knowledge_bases", {}) for r in recs: - doc_recs = await sor.R("documents", {"kb_id": r.id}) - doc_count = len(doc_recs) + doc_count = r.doc_count or 0 total_size = r.total_size or 0 if total_size >= 1073741824: size_str = "%.1fGB" % (total_size / 1073741824) @@ -15,19 +14,58 @@ async with get_sor_context(env, 'rag') as sor: else: size_str = str(total_size) + "B" engine = r.embedding_engine or "CLIP" + idstr = str(r.id) + namestr = r.name or '' cards.append({ "widgettype": "VBox", - "options": {"cwidth": 16, "cheight": 12, "bgcolor": "#f0f7ff", "padding": "16px", "css": "card clickable", "border": "1px solid #d0e4f7"}, + "options": {"cwidth": 16, "cheight": 12, "bgcolor": "#f0f7ff", "padding": "12px", + "css": "card", "border": "1px solid #d0e4f7"}, "subwidgets": [ - {"widgettype": "Text", "options": {"text": "πŸ“š " + str(r.name), "cfontsize": 16, "fontWeight": "bold"}}, - {"widgettype": "Text", "options": {"text": str(engine) + " Β· 1024η»΄", "cfontsize": 12, "color": "#888"}}, - {"widgettype": "HBox", "options": {"spacing": "12px"}, "subwidgets": [ - {"widgettype": "Text", "options": {"text": "πŸ“„ " + str(doc_count) + "ζ–‡ζ‘£", "cfontsize": 12, "color": "#666"}}, - {"widgettype": "Text", "options": {"text": "πŸ’Ύ " + size_str, "cfontsize": 12, "color": "#666"}}]} - ], - "binds": [{"wid": "self", "event": "click", "actiontype": "urlwidget", "target": "app.rag_main_content", "mode": "replace", - "options": {"url": "/rag/knowledge_bases_list/detail.ui", "params": {"kb_id": str(r.id), "kb_name": str(r.name)}}}] + # Name row (clickable to enter KB) + {"widgettype": "Text", "options": {"text": "\U0001f4da " + namestr, + "cfontsize": 16, "fontWeight": "bold", "css": "clickable"}, + "binds": [{"wid": "self", "event": "click", "actiontype": "urlwidget", + "target": "app.rag_main_content", "mode": "replace", + "options": {"url": "/rag/knowledge_bases_list/detail.ui", + "params": {"kb_id": idstr, "kb_name": namestr}}}]}, + # Meta info + {"widgettype": "Text", "options": {"text": str(engine) + " \u00b7 1024\u7ef4", + "cfontsize": 12, "color": "#888", "padding": "2px 0 6px 0"}}, + {"widgettype": "HBox", "options": {"spacing": "12px", "padding": "0 0 8px 0"}, "subwidgets": [ + {"widgettype": "Text", "options": {"text": "\U0001f4c4 " + str(doc_count) + "\u6587\u6863", + "cfontsize": 12, "color": "#666"}}, + {"widgettype": "Text", "options": {"text": "\U0001f4be " + size_str, + "cfontsize": 12, "color": "#666"}} + ]}, + # Action buttons row + {"widgettype": "HBox", "options": {"justifyContent": "flex-end", "spacing": "6px", + "padding": "4px 0 0 0", "borderTop": "1px solid #e8f0fe"}, "subwidgets": [ + {"widgettype": "Text", "options": {"text": "\u270f\ufe0f \u91cd\u547d\u540d", + "cfontsize": 12, "color": "#4a90d9", "css": "clickable", + "padding": "2px 8px", "borderRadius": "4px", "bgcolor": "#e3f0fb"}, + "binds": [{"wid": "self", "event": "click", "actiontype": "urlwidget", + "target": "PopupWindow", + "popup_options": {"title": "\u91cd\u547d\u540d\u300c" + namestr + "\u300d", + "width": "30%", "height": "auto", + "movable": True, "resizable": True, "modal": True}, + "options": {"url": "/rag/knowledge_bases_list/rename_kb_form.dspy?kb_id=" + idstr}}]}, + {"widgettype": "Text", "options": {"text": "\U0001f5d1\ufe0f \u5220\u9664", + "cfontsize": 12, "color": "#e74c3c", "css": "clickable", + "padding": "2px 8px", "borderRadius": "4px", "bgcolor": "#fee"}, + "binds": [{"wid": "self", "event": "click", "actiontype": "script", + "script": "if(!confirm('\u786e\u8ba4\u5220\u9664\u77e5\u8bc6\u5e93\u300c" + namestr + "\u300d\uff1f\n\n\u8be5\u64cd\u4f5c\u4f1a\u5220\u9664\u77e5\u8bc6\u5e93\u5185\u6240\u6709\u6587\u4ef6\u3001\u6807\u7b7e\u548c\u6570\u636e\uff0c\u4e0d\u53ef\u6062\u590d\uff01'))return;\n" + "var u='/rag/knowledge_bases_list/delete_kb.dspy?kb_id=" + idstr + "';\n" + "fetch(u).then(function(r){return r.json()}).then(function(d){\n" + "if(d.status==='error'){alert('\u5220\u9664\u5931\u8d25\uff1a'+d.error);return;}\n" + "window.location.href='/rag/knowledge_bases_list/index.ui';\n" + "}).catch(function(){alert('\u7f51\u7edc\u9519\u8bef')});"}]} + ]} + ] }) + if not cards: - cards.append({"widgettype": "Text", "options": {"text": "ζš‚ζ— ηŸ₯θ―†εΊ“", "color": "#aaa", "cfontsize": 14, "halign": "center"}}) -return json.dumps({"widgettype": "HBox", "options": {"width": "100%", "spacing": "12px", "wrap": True}, "subwidgets": cards}, ensure_ascii=False, default=str) + cards.append({"widgettype": "Text", "options": { + "text": "\u6682\u65e0\u77e5\u8bc6\u5e93", "cfontsize": 14, "color": "#aaa", "halign": "center"}}) + +return json.dumps({"widgettype": "HBox", "options": {"width": "100%", "spacing": "12px", "wrap": True}, + "subwidgets": cards}, ensure_ascii=False, default=str) diff --git a/wwwroot/knowledge_bases_list/rename_kb.dspy b/wwwroot/knowledge_bases_list/rename_kb.dspy new file mode 100644 index 0000000..af6e036 --- /dev/null +++ b/wwwroot/knowledge_bases_list/rename_kb.dspy @@ -0,0 +1,20 @@ +ns = params_kw.copy() +kb_id = ns.get('kb_id', '') +name = ns.get('name', '').strip() +desc = ns.get('description', '') + +if not kb_id or not name: + return json.dumps({"status": "error", "error": "kb_id and name required"}, ensure_ascii=False) + +env = request._run_ns +userorgid = await env.get_userorgid() + +async with get_sor_context(env, 'rag') as sor: + await sor.sqlExe( + "UPDATE knowledge_bases SET name=${name}$, description=${desc}$, updated_at=NOW() " + "WHERE id=${id}$ AND org_id=${org_id}$", + {"name": name, "desc": desc, "id": kb_id, "org_id": userorgid}) + +# Reload KB list page +return {"widgettype": "urlwidget", + "options": {"url": entire_url('/rag/knowledge_bases_list/index.ui')}} diff --git a/wwwroot/knowledge_bases_list/rename_kb_form.dspy b/wwwroot/knowledge_bases_list/rename_kb_form.dspy new file mode 100644 index 0000000..c365521 --- /dev/null +++ b/wwwroot/knowledge_bases_list/rename_kb_form.dspy @@ -0,0 +1,51 @@ +ns = params_kw.copy() +kb_id = ns.get('kb_id', '') +name = ns.get('name', '') +desc = ns.get('description', '') + +if not kb_id: + return json.dumps({"status": "error", "error": "kb_id required"}, ensure_ascii=False) + +env = request._run_ns + +if not name: + async with get_sor_context(env, 'rag') as sor: + recs = await sor.sqlExe( + "SELECT name, description FROM knowledge_bases WHERE id=${id}$", + {"id": kb_id}) + if recs: + name = recs[0].name or '' + desc = recs[0].description or '' + +result = { + "widgettype": "VBox", + "options": {"padding": "16px", "spacing": "12px", "width": "100%"}, + "subwidgets": [ + {"widgettype": "Text", "options": {"text": "重命名ηŸ₯θ―†εΊ“", "cfontsize": 16, "fontWeight": "bold", + "color": "#333", "padding": "0 0 8px 0"}}, + { + "widgettype": "Form", + "id": "rename_form", + "options": { + "cols": 1, + "fields": [ + {"name": "name", "label": "名称", "uitype": "str", "required": True, + "defaultValue": name}, + {"name": "description", "label": "描述", "uitype": "text", + "defaultValue": desc}, + {"name": "kb_id", "uitype": "hide", "value": kb_id} + ], + "submit_label": "保存" + } + } + ], + "binds": [{ + "wid": "rename_form", + "event": "submit", + "actiontype": "urlwidget", + "target": "app.rag_main_content", + "mode": "replace", + "options": {"url": "{{entire_url('./rename_kb.dspy')}}"} + }] +} +return result