feat(tags): multi-select tag list + dedup create + tag_sync endpoint
- tag_form.dspy: rewrite as multi-select checkbox list (clickable rows with toggle) + input field for new tag with + button (calls /api/tag/create with dedup) + submit button calls /api/tag/sync to sync media_tags + dynamically add new tag row to list after creation - media_cards.dspy: read tags from media_tags table instead of metadata - init.py: tag_create_handler adds dedup (check existing by kb_id+name) - init.py: new tag_sync_handler (full sync: delete old + insert selected) - config.json: add /api/tag/sync route
This commit is contained in:
parent
371afae84a
commit
1dd59f7caa
Binary file not shown.
45
rag/init.py
45
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)
|
||||
|
||||
@ -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": "<audio controls preload=\"metadata\" style=\"width:100%\" src=\"" + media_url + "\"></audio>", "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"}}
|
||||
|
||||
@ -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": "<input id='new_tag_input' type='text' placeholder='\u8f93\u5165\u65b0\u6807\u7b7e\u540d' style='flex:1;padding:6px 10px;border:1px solid #ccc;border-radius:6px;font-size:13px;outline:none'>",
|
||||
"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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user