diff --git a/rag/__pycache__/init.cpython-310.pyc b/rag/__pycache__/init.cpython-310.pyc index 3d78310..b667d00 100644 Binary files a/rag/__pycache__/init.cpython-310.pyc and b/rag/__pycache__/init.cpython-310.pyc differ diff --git a/rag/init.py b/rag/init.py index 1bb93bd..28a7d8a 100644 --- a/rag/init.py +++ b/rag/init.py @@ -679,6 +679,12 @@ async def tag_create_handler(request, params_kw, *args, **kwargs): return json.dumps({"error": "kb_id and name required"}) userorgid = await env.get_userorgid() async with get_sor_context(env, 'rag') as sor: + existing = await sor.sqlExe( + "SELECT id, color FROM tags WHERE kb_id=${kb_id}$ AND name=${name}$ AND org_id=${org_id}$", + {"kb_id": kb_id, "name": name, "org_id": userorgid}) + if existing: + return json.dumps({"status": "SUCCEEDED", "tag_id": existing[0].id, "name": name, + "color": existing[0].color, "duplicate": True}, ensure_ascii=False) tag_id = uuid.uuid4().hex[:16] await sor.sqlExe( "INSERT INTO tags (id, kb_id, name, color, org_id, created_at) " @@ -865,6 +871,44 @@ async def tag_search_handler(request, params_kw, *args, **kwargs): return json.dumps({"error": str(e)}) +async def tag_sync_handler(request, params_kw, *args, **kwargs): + """全量同步媒体标签关联:删除不在选中列表的,插入新增的""" + env = request._run_ns + try: + kb_id = params_kw.get("kb_id", "") + media_type = params_kw.get("media_type", "") + media_id = params_kw.get("media_id", "") + tag_ids_str = params_kw.get("tag_ids", "") + if not all([kb_id, media_type, media_id]): + return json.dumps({"error": "kb_id, media_type, media_id required"}) + if media_type not in ("document", "face", "voice"): + return json.dumps({"error": "media_type must be document/face/voice"}) + wanted_ids = [t.strip() for t in tag_ids_str.split(",") if t.strip()] + async with get_sor_context(env, 'rag') as sor: + recs = await sor.sqlExe( + "SELECT id, tag_id FROM media_tags WHERE media_type=${type}$ AND media_id=${mid}$", + {"type": media_type, "mid": media_id}) + current = {r.tag_id: r.id for r in recs} + removed = 0 + for tid, mt_id in current.items(): + if tid not in wanted_ids: + await sor.sqlExe("DELETE FROM media_tags WHERE id=${id}$", {"id": mt_id}) + removed += 1 + added = 0 + for tid in wanted_ids: + if tid not in current: + mt_id = uuid.uuid4().hex[:16] + await sor.sqlExe( + "INSERT INTO media_tags (id, kb_id, media_type, media_id, tag_id, created_at) " + "VALUES (${id}$, ${kb_id}$, ${type}$, ${mid}$, ${tid}$, NOW())", + {"id": mt_id, "kb_id": kb_id, "type": media_type, "mid": media_id, "tid": tid}) + added += 1 + return json.dumps({"status": "SUCCEEDED", "added": added, "removed": removed}) + except Exception as e: + exception(f"tag_sync: {e}, {format_exc()}") + return json.dumps({"error": str(e)}) + + def init_rag_module(): env = ServerEnv() rf = RegisterFunction() @@ -884,3 +928,4 @@ def init_rag_module(): rf.register("tag_unassign", tag_unassign_handler) rf.register("tag_media_tags", tag_media_tags_handler) rf.register("tag_search", tag_search_handler) + rf.register("tag_sync", tag_sync_handler) diff --git a/wwwroot/knowledge_bases_list/media_cards.dspy b/wwwroot/knowledge_bases_list/media_cards.dspy index c75c4e1..ef054d6 100644 --- a/wwwroot/knowledge_bases_list/media_cards.dspy +++ b/wwwroot/knowledge_bases_list/media_cards.dspy @@ -32,6 +32,20 @@ try: "metadata": r.metadata or '{}', "created_at": str(r.created_at)[:16] if r.created_at else '' }) + + # 查出入介质标签(从标签表中取出来),按 doc_id 编组 + doc_ids = [r["id"] for r in rows] + doc_tags = {} + if doc_ids: + mt_recs = await sor.sqlExe( + "SELECT mt.media_id, t.name, t.color FROM media_tags mt " + "JOIN tags t ON mt.tag_id=t.id " + "WHERE mt.media_type='document' AND mt.media_id IN (${ids}$)", + {"ids": doc_ids}) + for mt in mt_recs: + doc_tags.setdefault(mt.media_id, []).append({"name": mt.name, "color": mt.color or '#3b82f6'}) + for r in rows: + r["media_tags"] = doc_tags.get(r["id"], []) except Exception: return {"widgettype": "Text", "options": {"text": "加载失败", "cfontsize": 14, "color": "#e74c3c", "halign": "center", "marginTop": "40px"}} @@ -49,14 +63,7 @@ for f in rows: doc_id = f["id"] fname = f["file_name"] media_url = safe_url(f["file_path"]) - meta = {} - try: - meta = json.loads(f["metadata"]) - except Exception: - meta = {} - tags = meta.get('tags', []) - if not isinstance(tags, list): - tags = [] + tags = f.get("media_tags", []) if kind == 'voice': media_widget = {"widgettype": "Html", "options": {"html": "", "padding": "8px 12px"}} @@ -66,7 +73,9 @@ for f in rows: if tags: tag_widgets = [] for t in tags: - tag_widgets.append({"widgettype": "Text", "options": {"text": "#" + str(t), "cfontsize": 10, "color": "#1565c0", "bgcolor": "#e3f2fd", "padding": "1px 8px", "borderRadius": "8px", "margin": "2px 4px 2px 0"}}) + tag_widgets.append({"widgettype": "Text", "options": {"text": "#" + str(t["name"]), "cfontsize": 10, + "color": "#1565c0", "bgcolor": "#e3f2fd", + "padding": "1px 8px", "borderRadius": "8px", "margin": "2px 4px 2px 0"}}) tag_row = {"widgettype": "HBox", "options": {"wrap": True, "padding": "0 12px", "alignItems": "center"}, "subwidgets": tag_widgets} else: tag_row = {"widgettype": "Text", "options": {"text": "暂无标签", "cfontsize": 10, "color": "#bbb", "padding": "0 12px"}} diff --git a/wwwroot/knowledge_bases_list/tag_form.dspy b/wwwroot/knowledge_bases_list/tag_form.dspy index 6e966b3..4ac722e 100644 --- a/wwwroot/knowledge_bases_list/tag_form.dspy +++ b/wwwroot/knowledge_bases_list/tag_form.dspy @@ -4,53 +4,82 @@ ns = params_kw.copy() doc_id = ns.get('doc_id', '') kb_id = ns.get('kb_id', '') kind = ns.get('kind', '') +media_type = 'document' db = DBPools() dbname = get_module_dbname('rag') fname = '' -cur_tags = [] +tag_names_by_id = {} # map tag_id -> {"name":..., "color":...} +cur_tag_ids = [] # ids already on this document +all_tags = [] # all tags for kb: list of (id, name, color) + try: async with db.sqlorContext(dbname) as sor: recs = await sor.sqlExe( - "SELECT file_name, metadata FROM documents WHERE id=${id}$ AND kb_id=${kb_id}$", + "SELECT file_name FROM documents WHERE id=${id}$ AND kb_id=${kb_id}$", {"id": doc_id, "kb_id": kb_id}) if recs: fname = recs[0].file_name or '' - try: - meta = json.loads(recs[0].metadata or '{}') - except Exception: - meta = {} - cur_tags = meta.get('tags', []) - if not isinstance(cur_tags, list): - cur_tags = [] + + # 有哪些标签 + tag_recs = await sor.sqlExe( + "SELECT id, name, color FROM tags WHERE kb_id=${kb_id}$ ORDER BY created_at", + {"kb_id": kb_id}) + for t in tag_recs: + all_tags.append((t.id, t.name, t.color or '#3b82f6')) + tag_names_by_id[t.id] = {"name": t.name, "color": t.color or '#3b82f6'} + + # 当前已关联的 + mt_recs = await sor.sqlExe( + "SELECT tag_id FROM media_tags WHERE media_type=${type}$ AND media_id=${mid}$", + {"type": media_type, "mid": doc_id}) + cur_tag_ids = [r.tag_id for r in mt_recs] except Exception: pass -subwidgets = [] -if not doc_id: - subwidgets.append({"widgettype": "Text", "options": {"text": "缺少 doc_id 参数", "cfontsize": 13, "color": "#e74c3c", "padding": "20px"}}) -else: - subwidgets.append({"widgettype": "Text", "options": {"text": "📄 " + (fname or doc_id), "cfontsize": 13, "fontWeight": "bold", "color": "#333", "padding": "0 0 8px 0"}}) - if cur_tags: - tws = [] - for t in cur_tags: - tws.append({"widgettype": "Text", "options": {"text": "#" + str(t), "cfontsize": 10, "color": "#1565c0", "bgcolor": "#e3f2fd", "padding": "1px 8px", "borderRadius": "8px", "margin": "2px 6px 2px 0"}}) - subwidgets.append({"widgettype": "HBox", "options": {"wrap": True, "alignItems": "center", "marginBottom": "8px"}, "subwidgets": tws}) - else: - subwidgets.append({"widgettype": "Text", "options": {"text": "暂无标签", "cfontsize": 11, "color": "#aaa", "marginBottom": "8px"}}) - subwidgets.append({ - "widgettype": "Form", - "id": "tag_input_form", - "options": { - "cols": 1, - "fields": [ - {"name": "tag", "label": "标签名", "uitype": "str", "required": True} - ], - "buttons": [{"name": "submit", "label": "✓ 添加标签", "icon": "save"}] - } +# 构建 checkbox 行 +tag_rows = [] +for i, (tid, tname, tcolor) in enumerate(all_tags): + checked = tid in cur_tag_ids + tag_rows.append({ + "widgettype": "HBox", + "id": "tagrow_" + tid, + "options": {"data-sel": "1" if checked else "0", "margin": "3px 0", "padding": "4px 8px", + "borderRadius": "6px", "alignItems": "center", + "cursor": "pointer", "width": "100%", + "bgcolor": "#e8f0fe" if checked else "#f8f9fa"}, + "subwidgets": [ + {"widgettype": "Text", "id": "tagchk_" + tid, + "options": {"text": "\u2611" if checked else "\u2610", + "cfontsize": 14, "cwidth": 3, "halign": "center", + "color": "#1565c0" if checked else "#999", + "padding": "0 6px 0 0"}}, + {"widgettype": "Text", + "options": {"text": "#" + tname, "cfontsize": 12, + "color": "#1565c0", "bgcolor": "#e3f2fd", + "padding": "2px 10px", "borderRadius": "10px", + "margin": "0 4px 0 0"}} + ], + "binds": [{ + "wid": "self", "event": "click", + "actiontype": "script", + "script": ( + "var el=this.dom_element;" + "var on=el.getAttribute('data-sel')==='1';el.setAttribute('data-sel',on?'0':'1');" + "var c=el.querySelector('[id^=tagchk_]');if(c)c.textContent=on?'\u2610':'\u2611';" + "el.style.backgroundColor=on?'#f8f9fa':'#e8f0fe';" + "var tc=c?c.nextElementSibling:null;if(tc)tc.style.color=on?'#999':'#1565c0';" + ) + }] }) +if not tag_rows: + tag_rows.append({"widgettype": "Text", + "options": {"text": "该知识库暂无标签,请在下方创建", "cfontsize": 12, + "color": "#aaa", "halign": "center", "padding": "12px"}}) + +# 父面板刷新,js refresh_js = '' if kind: refresh_js = ("var mp=document.querySelector('#media_card_panel');" @@ -58,25 +87,98 @@ if kind: + kb_id + "&kind=" + kind + "').then(function(r){return r.json()}).then(function(d){" "var mw=mp.bricks_widget;mw.clear_widgets();bricks.widgetBuild(d,mw).then(function(nw){if(nw)mw.add_widget(nw)})}).catch(function(){})};") -tag_script = ("var d=event&&event.params?event.params:{};if(!d||!d.tag){alert('请输入标签名');return};" - "var u='/rag/knowledge_bases_list/add_tag.dspy?doc_id=" + doc_id + "&kb_id=" + kb_id + "'+'&tag='+encodeURIComponent(d.tag);" - "fetch(u).then(function(r){return r.text()}).then(function(t){var d2={};try{d2=JSON.parse(t)}catch(e){};" - "if(d2&&d2.status==='SUCCEEDED'){" + refresh_js + - "var pw=null;var w=this;while(w){if(w instanceof bricks.PopupWindow||w instanceof bricks.Popup){pw=w;break};w=w.parent};" - "if(pw){pw.dismiss();pw.destroy()}else{alert('标签已添加:'+d2.added)}}" - "else{alert('添加失败:'+((d2&&d2.error)||t))}}.bind(this)).catch(function(){alert('网络错误')})") +close_popup_js = ( + "var pw=null;var w=this;" + "while(w){if(w instanceof bricks.PopupWindow||w instanceof bricks.Popup){pw=w;break};w=w.parent};" + "if(pw){pw.dismiss();pw.destroy()};" +) + +# 关闭弹窗的快捷函数 +close_js = "function closeP(w){var pw=null;while(w){if(w instanceof bricks.PopupWindow||w instanceof bricks.Popup){pw=w;break};w=w.parent};if(pw){pw.dismiss();pw.destroy()}}" + +# + 按钮脚本:创建新标签 +add_tag_js = ("var inp=document.querySelector('#new_tag_input');" + "var name=(inp.value||'').trim();" + "if(!name){alert('请输入新标签名');return;};" + "fetch('/api/tag/create?kb_id=" + kb_id + "&name='+encodeURIComponent(name)).then(function(r){return r.json()}).then(function(d){" + "if(d&&d.status==='SUCCEEDED'){" + "if(d.duplicate){alert('标签「'+d.name+'」已存在');}" + "var tid=(d.tag_id||'');" + "var tname=(d.name||name);" + "var tcolor=(d.color||'#3b82f6');" + "var panel=document.querySelector('#tag_list_panel');" + "if(panel&&tid){" + "var exist=panel.querySelector('#tagrow_'+tid);" + "if(exist){exist.setAttribute('data-sel','1');exist.style.backgroundColor='#e8f0fe';" + "var ec=exist.querySelector('[id^=tagchk_]');if(ec){ec.textContent='\u2611';ec.style.color='#1565c0';}}" + "else{" + "var row=document.createElement('div');" + "row.setAttribute('data-sel','1');row.id='tagrow_'+tid;" + "row.style.cssText='display:flex;align-items:center;margin:3px 0;padding:4px 8px;border-radius:6px;width:100%;cursor:pointer;background:#e8f0fe';" + "row.onclick=function(){var el=this;var on=el.getAttribute('data-sel')==='1';el.setAttribute('data-sel',on?'0':'1');var c=el.querySelector('[id^=tagchk_]');if(c)c.textContent=on?'\u2610':'\u2611';el.style.backgroundColor=on?'#f8f9fa':'#e8f0fe';var tc=c?c.nextElementSibling:null;if(tc)tc.style.color=on?'#999':'#1565c0';};" + "var chk=document.createElement('span');chk.id='tagchk_'+tid;chk.style.cssText='font-size:14px;width:3em;text-align:center;color:#1565c0;padding-right:6px';chk.textContent='\u2611';" + "var lbl=document.createElement('span');lbl.style.cssText='font-size:12px;color:#1565c0;background:#e3f2fd;padding:2px 10px;border-radius:10px';lbl.textContent='#'+tname;" + "row.appendChild(chk);row.appendChild(lbl);panel.appendChild(row);}}" + "inp.value='';}" + "else{alert('创建失败:'+(d&&d.error||'未知错误'));}" + "}).catch(function(){alert('网络错误')});") + +# 提交按钮脚本 +submit_js = ("var rows=document.querySelectorAll('#tag_list_panel [data-sel]');" + "var ids=[];rows.forEach(function(r){if(r.getAttribute('data-sel')==='1'){ids.push(r.id.replace('tagrow_',''))}});" + "if(ids.length===0){alert('请至少勾选一个标签');return};" + "fetch('/api/tag/sync?kb_id=" + kb_id + "&media_type=document&media_id=" + doc_id + "&tag_ids='+encodeURIComponent(ids.join(',')))" + ".then(function(r){return r.json()}).then(function(d){" + "if(d&&d.status==='SUCCEEDED'){" + refresh_js + "alert('已保存:新增 '+d.added+' 个,移除 '+d.removed+' 个');" + close_popup_js + "}" + "else{alert('保存失败:'+(d&&d.error||'未知错误'));}" + "}).catch(function(){alert('网络错误')});") + +# 组合弹窗内容 +subwidgets = [] +subwidgets.append({"widgettype": "Text", "options": {"text": "\U0001f4c4 " + (fname or doc_id), + "cfontsize": 13, "fontWeight": "bold", "color": "#333", "padding": "0 0 4px 0"}}) +subwidgets.append({"widgettype": "Text", "options": {"text": "勾选要添加的标签(可多选):", + "cfontsize": 11, "color": "#666", "padding": "0 0 4px 0"}}) + +# 标签列表面板 +tag_list_box = {"widgettype": "VBox", "id": "tag_list_panel", + "options": {"css": "filler", "overflow": "auto", "padding": "4px", + "border": "1px solid #e0e0e0", "borderRadius": "8px", + "bgcolor": "#fcfcfc"}, + "subwidgets": tag_rows} +subwidgets.append(tag_list_box) + +# 新标签输入行 +new_tag_row = {"widgettype": "HBox", "options": {"alignItems": "center", "margin": "10px 0", "spacing": "6px"}, + "subwidgets": [ + {"widgettype": "Html", "options": { + "html": "", + "width": "100%"}}, + {"widgettype": "Button", "id": "btn_add_tag", + "options": {"label": "\uff0b", "cfontsize": 14, "fontWeight": "bold", + "bgcolor": "#e3f2fd", "color": "#1565c0", + "padding": "5px 14px", "borderRadius": "6px"}, + "binds": [{"wid": "self", "event": "click", + "actiontype": "script", "script": add_tag_js}]} + ]} +subwidgets.append(new_tag_row) + +# 提交按钮 +subwidgets.append({"widgettype": "HBox", "options": {"alignItems": "center"}, + "subwidgets": [ + {"widgettype": "Text", "options": {"text": "", "css": "filler"}}, + {"widgettype": "Button", "id": "btn_submit", + "options": {"label": "\u2705 \u4fdd\u5b58\u6240\u9009\u6807\u7b7e", + "cfontsize": 13, "fontWeight": "bold", + "bgcolor": "#1565c0", "color": "#fff", + "padding": "7px 20px", "borderRadius": "6px"}, + "binds": [{"wid": "self", "event": "click", + "actiontype": "script", "script": submit_js}]} + ]}) result = { "widgettype": "VBox", - "options": {"width": "100%", "padding": "16px", "spacing": "6px"}, - "subwidgets": subwidgets, - "binds": [ - { - "wid": "tag_input_form", - "event": "submit", - "actiontype": "script", - "script": tag_script - } - ] + "options": {"width": "100%", "padding": "12px", "spacing": "4px"}, + "subwidgets": subwidgets } return result