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