diff --git a/conf/config.json b/conf/config.json index aa2e773..dd3c96e 100644 --- a/conf/config.json +++ b/conf/config.json @@ -14,6 +14,8 @@ } }, "SAGE_RBAC_DB": "rag", + "session_max_time": 3000, + "session_issue_time": 2500, "session_redis": { "host": "127.0.0.1", "port": 6379, diff --git a/wwwroot/menu.ui b/wwwroot/menu.ui index 047357f..a2eefb4 100644 --- a/wwwroot/menu.ui +++ b/wwwroot/menu.ui @@ -14,6 +14,27 @@ "url": "{{entire_url('/knowledge_bases_list/index.ui')}}", "target": "app.rag_main_content" }, + { + "name": "tags", + "label": "标签管理", + "icon": "{{entire_url('/bricks/imgs/tag.png')}}", + "url": "{{entire_url('/tags_list/index.ui')}}", + "target": "app.rag_main_content" + }, + { + "name": "tag_assign", + "label": "标签分配", + "icon": "{{entire_url('/bricks/imgs/edit.svg')}}", + "url": "{{entire_url('/tag_assign/index.dspy')}}", + "target": "app.rag_main_content" + }, + { + "name": "tag_search", + "label": "标签检索", + "icon": "{{entire_url('/bricks/imgs/search.svg')}}", + "url": "{{entire_url('/tag_search/index.dspy')}}", + "target": "app.rag_main_content" + }, { "name": "engines", "label": "引擎配置", diff --git a/wwwroot/tag_assign/index.dspy b/wwwroot/tag_assign/index.dspy new file mode 100644 index 0000000..5e103a3 --- /dev/null +++ b/wwwroot/tag_assign/index.dspy @@ -0,0 +1,157 @@ +ns = params_kw.copy() +kb_id = ns.get('kb_id', '') +media_type = ns.get('media_type', 'document') +media_id = ns.get('media_id', '') +action = ns.get('action', '') +tag_id = ns.get('tag_id', '') + +db = DBPools() +dbname = get_module_dbname('rag') +import json +base_url = entire_url('/tag_assign/index.dspy') + +async with db.sqlorContext(dbname) as sor: + # Handle assign/unassign actions + if action and media_id and tag_id: + import uuid + try: + if action == 'assign': + 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": tag_id}) + elif action == 'unassign': + await sor.sqlExe( + "DELETE FROM media_tags WHERE media_type=${type}$ AND media_id=${mid}$ AND tag_id=${tid}$", + {"type": media_type, "mid": media_id, "tid": tag_id}) + except Exception: + pass + + # Get knowledge bases + kbs = await sor.R('knowledge_bases', {}) + + # Get media items + media_items = [] + if kb_id: + if media_type == 'document': + recs = await sor.sqlExe( + "SELECT id, file_name, file_type FROM documents WHERE kb_id=${kb_id}$ ORDER BY created_at DESC LIMIT 100", + {"kb_id": kb_id}) + media_items = [{"id": r.id, "name": r.file_name, "type": r.file_type} for r in recs] + elif media_type == 'face': + recs = await sor.sqlExe( + "SELECT id, name, description FROM entities WHERE kb_id=${kb_id}$ AND entity_type='person' AND face_embedding_id IS NOT NULL ORDER BY created_at DESC LIMIT 100", + {"kb_id": kb_id}) + media_items = [{"id": r.id, "name": r.name or '人脸#'+r.id[:8], "type": "face"} for r in recs] + elif media_type == 'voice': + recs = await sor.sqlExe( + "SELECT id, name, description FROM entities WHERE kb_id=${kb_id}$ AND entity_type='voice' AND voice_embedding_id IS NOT NULL ORDER BY created_at DESC LIMIT 100", + {"kb_id": kb_id}) + media_items = [{"id": r.id, "name": r.name or '声纹#'+r.id[:8], "type": "voice"} for r in recs] + + # Get current tags for selected media + current_tags = [] + if media_id: + recs = await sor.sqlExe( + "SELECT t.id, t.name, t.color FROM media_tags mt JOIN tags t ON mt.tag_id=t.id WHERE mt.media_type=${type}$ AND mt.media_id=${mid}$", + {"type": media_type, "mid": media_id}) + current_tags = [{"id": r.id, "name": r.name, "color": r.color} for r in recs] + + # Get all available tags + all_tags = [] + if kb_id: + tag_recs = await sor.R('tags', {'kb_id': kb_id}) + all_tags = [{"id": t.id, "name": t.name, "color": t.color} for t in tag_recs] + + current_tag_ids = {t['id'] for t in current_tags} + + # Build available tag buttons + tag_buttons = [] + for t in all_tags: + is_assigned = t['id'] in current_tag_ids + if is_assigned: + act = 'unassign' + lbl = '✓ ' + t['name'] + bg = t['color'] + clr = '#fff' + else: + act = 'assign' + lbl = '+ ' + t['name'] + bg = '#e5e7eb' + clr = '#666' + tag_url = base_url + '?kb_id=' + kb_id + '&media_type=' + media_type + '&media_id=' + media_id + '&action=' + act + '&tag_id=' + t['id'] + tag_buttons.append({ + "widgettype": "Button", + "options": {"label": lbl, "bgcolor": bg, "color": clr, "cfontsize": 12, "padding": "4px 12px", "marginRight": "6px", "marginBottom": "6px"}, + "binds": [{"wid": "self", "event": "click", "actiontype": "urlwidget", "target": "app.rag_main_content", "mode": "replace", "options": {"url": tag_url}}] + }) + + # Build media list buttons + media_buttons = [] + for m in media_items: + murl = base_url + '?kb_id=' + kb_id + '&media_type=' + media_type + '&media_id=' + m['id'] + is_current = m['id'] == media_id + media_buttons.append({ + "widgettype": "Button", + "options": { + "label": ('📄' if media_type=='document' else ('👤' if media_type=='face' else '🎤')) + ' ' + m['name'], + "bgcolor": "#dbeafe" if is_current else "#f9fafb", + "color": "#1e40af" if is_current else "#666", + "cfontsize": 12, + "padding": "6px 12px", + "marginBottom": "4px", + "halign": "left" + }, + "binds": [{"wid": "self", "event": "click", "actiontype": "urlwidget", "target": "app.rag_main_content", "mode": "replace", "options": {"url": murl}}] + }) + + # KB buttons + kb_buttons = [] + for k in kbs: + kb_buttons.append({ + "widgettype": "Button", + "options": {"label": k.name, "bgcolor": "#3b82f6" if k.id==kb_id else "#e5e7eb", "color": "#fff" if k.id==kb_id else "#666", "cfontsize": 12, "padding": "6px 14px", "marginRight": "6px", "marginBottom": "4px"}, + "binds": [{"wid": "self", "event": "click", "actiontype": "urlwidget", "target": "app.rag_main_content", "mode": "replace", + "options": {"url": base_url + '?kb_id=' + k.id + '&media_type=' + media_type}}]} + ) + + # Type tabs + type_tabs = [] + for mt, mt_label in [('document', '📄 文档'), ('face', '👤 人脸'), ('voice', '🎤 声纹')]: + type_tabs.append({ + "widgettype": "Button", + "options": {"label": mt_label, "bgcolor": "#3b82f6" if mt==media_type else "#e5e7eb", "color": "#fff" if mt==media_type else "#666", "cfontsize": 12, "padding": "6px 16px", "marginRight": "4px"}, + "binds": [{"wid": "self", "event": "click", "actiontype": "urlwidget", "target": "app.rag_main_content", "mode": "replace", + "options": {"url": base_url + '?kb_id=' + kb_id + '&media_type=' + mt}}]} + ) + +result = { + "widgettype": "VBox", + "options": {"cheight": 40, "width": "100%", "padding": "16px", "spacing": "12px"}, + "subwidgets": [ + {"widgettype": "Text", "options": {"text": "🏷️ 标签分配", "cfontsize": 20, "fontWeight": "bold", "color": "#333"}}, + {"widgettype": "Text", "options": {"text": "为文档、人脸、声纹设置标签", "cfontsize": 13, "color": "#888"}}, + # KB + {"widgettype": "Text", "options": {"text": "知识库:", "cfontsize": 13, "color": "#555", "marginTop": "8px"}}, + {"widgettype": "HBox", "options": {"spacing": "4px", "wrap": True}, "subwidgets": kb_buttons}, + # Media type + {"widgettype": "Text", "options": {"text": "媒体类型:", "cfontsize": 13, "color": "#555", "marginTop": "4px"}}, + {"widgettype": "HBox", "options": {"spacing": "4px"}, "subwidgets": type_tabs}, + # Media list + {"widgettype": "Text", "options": {"text": '选择' + ('文档' if media_type=='document' else ('人脸' if media_type=='face' else '声纹')) + ':', "cfontsize": 13, "color": "#555", "marginTop": "8px"}}, + {"widgettype": "VBox", "options": {"cheight": 12, "bgcolor": "#fafafa", "padding": "8px", "border": "1px solid #e0e0e0", "overflowY": "auto"}, "subwidgets": media_buttons}, + # Current tags + {"widgettype": "Text", "options": {"text": "当前标签:", "cfontsize": 13, "color": "#555", "marginTop": "8px"}}, + {"widgettype": "HBox", "options": {"spacing": "4px", "wrap": True}, "subwidgets": [ + {"widgettype": "Button", "options": {"label": t['name'], "bgcolor": t['color'], "color": "#fff", "cfontsize": 11, "padding": "3px 10px", "marginRight": "4px", "marginBottom": "4px"}, + "binds": [{"wid": "self", "event": "click", "actiontype": "urlwidget", "target": "app.rag_main_content", "mode": "replace", + "options": {"url": base_url + '?kb_id=' + kb_id + '&media_type=' + media_type + '&media_id=' + media_id + '&action=unassign&tag_id=' + t['id']}}]} + for t in current_tags] if current_tags else [{"widgettype": "Text", "options": {"text": "(无)", "color": "#aaa", "cfontsize": 12}}]}, + # Available tags + {"widgettype": "Text", "options": {"text": "可用标签 (点击切换):", "cfontsize": 13, "color": "#555", "marginTop": "4px"}}, + {"widgettype": "HBox", "options": {"spacing": "4px", "wrap": True}, "subwidgets": tag_buttons} if media_id else [ + {"widgettype": "Text", "options": {"text": "请先选择媒体文件", "color": "#aaa", "cfontsize": 12, "padding": "12px 0"}} + ] + ] +} +result diff --git a/wwwroot/tag_search/index.dspy b/wwwroot/tag_search/index.dspy new file mode 100644 index 0000000..f54120b --- /dev/null +++ b/wwwroot/tag_search/index.dspy @@ -0,0 +1,139 @@ +ns = params_kw.copy() +kb_id = ns.get('kb_id', '') +tag_ids_str = ns.get('tag_ids', '') +match_mode = ns.get('match_mode', 'any') +query = ns.get('query', '') +tag_ids = [t for t in tag_ids_str.split(',') if t] if tag_ids_str else [] + +db = DBPools() +dbname = get_module_dbname('rag') +base_url = entire_url('/tag_search/index.dspy') + +async with db.sqlorContext(dbname) as sor: + # Get knowledge bases + kbs = await sor.R('knowledge_bases', {}) + kb_options = [{"value": k.id, "text": k.name} for k in kbs] + + # Get all tags for selected kb + tags = [] + if kb_id: + tag_recs = await sor.R('tags', {'kb_id': kb_id}) + tags = [{"id": t.id, "name": t.name, "color": t.color} for t in tag_recs] + + # --- Build tag pills --- + tag_pills = [] + for t in tags: + is_selected = t['id'] in tag_ids + pill_bg = t['color'] if is_selected else '#e5e7eb' + pill_color = '#fff' if is_selected else '#666' + new_ids = [x for x in tag_ids if x != t['id']] + if not is_selected: + new_ids.append(t['id']) + new_tag_str = ','.join(new_ids) + url = base_url + '?kb_id=' + kb_id + '&tag_ids=' + new_tag_str + '&match_mode=' + match_mode + '&query=' + query + tag_pills.append({ + "widgettype": "Button", + "options": { + "label": ('✓ ' if is_selected else '') + t['name'], + "bgcolor": pill_bg, + "color": pill_color, + "cfontsize": 12, + "padding": "4px 12px", + "marginRight": "6px", + "marginBottom": "6px" + }, + "binds": [{ + "wid": "self", "event": "click", + "actiontype": "urlwidget", + "target": "app.rag_main_content", + "mode": "replace", + "options": {"url": url} + }] + }) + + # --- Build results --- + results_widgets = [] + if tag_ids and not query: + # Tag-only search via our API + from urllib.request import Request, urlopen + try: + api_url = 'http://localhost:9181/api/tag/search?kb_id=' + kb_id + '&tag_ids=' + tag_ids_str + '&match_mode=' + match_mode + req = Request(api_url) + data = json.loads(urlopen(req, timeout=5).read()) + res = data.get('results', []) + if isinstance(res, list): + for r in res: + icon = '📄' if r['type'] == 'document' else ('👤' if r['type'] == 'face' else '🎤') + results_widgets.append({ + "widgettype": "HBox", + "options": {"padding": "8px 12px", "bgcolor": "#f9fafb", "marginBottom": "4px", "alignItems": "center"}, + "subwidgets": [ + {"widgettype": "Text", "options": {"text": icon + ' ' + (r.get('name') or r.get('id','')), "cfontsize": 13, "color": "#333"}}, + {"widgettype": "Text", "options": {"text": r['type'] + ' · ' + (r.get('file_type','')), "cfontsize": 11, "color": "#888", "css": "filler"}} + ] + }) + # Show tags for this item + try: + tag_url = 'http://localhost:9181/api/tag/media_tags?media_type=' + ('document' if r['type'] in ('document','text','image','audio','video') else r['type']) + '&media_id=' + r['id'] + tag_data = json.loads(urlopen(Request(tag_url), timeout=3).read()) + item_tags = tag_data.get('tags', []) + for it in item_tags: + results_widgets[-1]['subwidgets'].append({ + "widgettype": "Text", + "options": {"text": it['name'], "cfontsize": 10, "color": "#fff", "bgcolor": it['color'], "padding": "1px 6px", "marginLeft": "4px"} + }) + except: + pass + else: + for key, items in res.items(): + if isinstance(items, list): + for r in items: + results_widgets.append({ + "widgettype": "Text", + "options": {"text": str(r.get('name') or r.get('id','')), "cfontsize": 13, "color": "#333", "padding": "4px 0"} + }) + except Exception as e: + results_widgets.append({"widgettype": "Text", "options": {"text": '搜索出错: ' + str(e), "color": "#e53e3e"}}) + + if not results_widgets and (tag_ids or query): + results_widgets.append({"widgettype": "Text", "options": {"text": '未找到匹配的媒体文件', "color": "#888", "cfontsize": 14, "halign": "center", "padding": "40px 0"}}) + + # --- Match mode toggle --- + any_url = base_url + '?kb_id=' + kb_id + '&tag_ids=' + tag_ids_str + '&match_mode=any&query=' + query + all_url = base_url + '?kb_id=' + kb_id + '&tag_ids=' + tag_ids_str + '&match_mode=all&query=' + query + +result = { + "widgettype": "VBox", + "options": {"cheight": 40, "width": "100%", "padding": "16px", "spacing": "12px"}, + "subwidgets": [ + {"widgettype": "Text", "options": {"text": "🔍 标签检索", "cfontsize": 20, "fontWeight": "bold", "color": "#333"}}, + {"widgettype": "Text", "options": {"text": "组合标签筛选 + 语义搜索", "cfontsize": 13, "color": "#888"}}, + # KB selector + {"widgettype": "Text", "options": {"text": "知识库:", "cfontsize": 13, "color": "#555", "marginTop": "8px"}}, + {"widgettype": "HBox", "options": {"spacing": "6px", "wrap": True}, "subwidgets": [ + {"widgettype": "Button", "options": {"label": k['text'], "bgcolor": "#3b82f6" if k['value']==kb_id else "#e5e7eb", "color": "#fff" if k['value']==kb_id else "#666", "cfontsize": 12, "padding": "6px 14px", "marginRight": "4px", "marginBottom": "4px"}, + "binds": [{"wid": "self", "event": "click", "actiontype": "urlwidget", "target": "app.rag_main_content", "mode": "replace", + "options": {"url": base_url + '?kb_id=' + k['value'] + '&match_mode=' + match_mode}}]} + for k in kb_options]}, + # Tags + {"widgettype": "Text", "options": {"text": '标签 (' + str(len(tag_ids)) + ' 已选):', "cfontsize": 13, "color": "#555", "marginTop": "8px"}}, + {"widgettype": "HBox", "options": {"spacing": "4px", "wrap": True}, "subwidgets": tag_pills}, + # Match mode + {"widgettype": "HBox", "options": {"spacing": "8px", "alignItems": "center", "marginTop": "4px"}, "subwidgets": [ + {"widgettype": "Text", "options": {"text": "匹配:", "cfontsize": 13, "color": "#555"}}, + {"widgettype": "Button", "options": {"label": "任一 (OR)", "bgcolor": "#3b82f6" if match_mode=='any' else "#e5e7eb", "color": "#fff" if match_mode=='any' else "#666", "cfontsize": 12, "padding": "4px 12px"}, + "binds": [{"wid": "self", "event": "click", "actiontype": "urlwidget", "target": "app.rag_main_content", "mode": "replace", "options": {"url": any_url}}]}, + {"widgettype": "Button", "options": {"label": "全部 (AND)", "bgcolor": "#3b82f6" if match_mode=='all' else "#e5e7eb", "color": "#fff" if match_mode=='all' else "#666", "cfontsize": 12, "padding": "4px 12px"}, + "binds": [{"wid": "self", "event": "click", "actiontype": "urlwidget", "target": "app.rag_main_content", "mode": "replace", "options": {"url": all_url}}]} + ]}, + # Query input + search button (using form or simple text) + {"widgettype": "Text", "options": {"text": "语义查询 (可选):", "cfontsize": 13, "color": "#555", "marginTop": "8px"}}, + {"widgettype": "HBox", "options": {"spacing": "8px", "alignItems": "center"}, "subwidgets": [ + {"widgettype": "Text", "options": {"text": query if query else '(输入查询关键词)', "cfontsize": 13, "color": "#333" if query else "#aaa", "bgcolor": "#f9fafb", "padding": "8px 12px", "css": "filler", "border": "1px solid #e0e0e0"}} + ]}, + # Results + {"widgettype": "Text", "options": {"text": '结果 (' + str(len(results_widgets)) + '):', "cfontsize": 14, "fontWeight": "bold", "color": "#333", "marginTop": "16px"}}, + {"widgettype": "VBox", "options": {"spacing": "4px"}, "subwidgets": results_widgets} + ] +} +result diff --git a/wwwroot/tags_list/add.dspy b/wwwroot/tags_list/add.dspy new file mode 100644 index 0000000..037b639 --- /dev/null +++ b/wwwroot/tags_list/add.dspy @@ -0,0 +1,19 @@ +ns = params_kw.copy() +db = DBPools() +dbname = get_module_dbname('rag') +import uuid + +name = ns.get('name', '').strip() +kb_id = ns.get('kb_id', '') +color = ns.get('color', '#3b82f6') + +if not name or not kb_id: + result = {"widgettype": "Message", "options": {"user_data": {"error": "name and kb_id required"}}} +else: + async with db.sqlorContext(dbname) as sor: + tag_id = uuid.uuid4().hex[:16] + await sor.sqlExe( + "INSERT INTO tags (id, kb_id, name, color, org_id, created_at) VALUES (${id}$, ${kb_id}$, ${name}$, ${color}$, '', NOW())", + {"id": tag_id, "kb_id": kb_id, "name": name, "color": color}) + result = {"widgettype": "Message", "options": {"user_data": {"id": tag_id, "name": name, "color": color, "kb_id": kb_id}}} + result diff --git a/wwwroot/tags_list/data.dspy b/wwwroot/tags_list/data.dspy new file mode 100644 index 0000000..a9d0d12 --- /dev/null +++ b/wwwroot/tags_list/data.dspy @@ -0,0 +1,18 @@ +ns = params_kw.copy() +db = DBPools() +dbname = get_module_dbname('rag') +userorgid = ns.get('org_id', '') +import json + +async with db.sqlorContext(dbname) as sor: + recs = await sor.R('tags', {}) + result = [] + for r in recs: + result.append({ + "id": r.id, + "name": r.name, + "color": r.color, + "kb_id": r.kb_id, + "created_at": str(r.created_at) if r.created_at else '' + }) + result diff --git a/wwwroot/tags_list/delete.dspy b/wwwroot/tags_list/delete.dspy new file mode 100644 index 0000000..96c7c08 --- /dev/null +++ b/wwwroot/tags_list/delete.dspy @@ -0,0 +1,14 @@ +ns = params_kw.copy() +db = DBPools() +dbname = get_module_dbname('rag') + +tag_id = ns.get('id', '') + +if not tag_id: + result = {"widgettype": "Message", "options": {"user_data": {"error": "id required"}}} +else: + async with db.sqlorContext(dbname) as sor: + await sor.sqlExe("DELETE FROM media_tags WHERE tag_id=${id}$", {"id": tag_id}) + await sor.sqlExe("DELETE FROM tags WHERE id=${id}$", {"id": tag_id}) + result = {"widgettype": "Message", "options": {"user_data": {"id": tag_id}}} + result diff --git a/wwwroot/tags_list/index.ui b/wwwroot/tags_list/index.ui new file mode 100644 index 0000000..5c44b79 --- /dev/null +++ b/wwwroot/tags_list/index.ui @@ -0,0 +1,43 @@ +{ + "widgettype": "VBox", + "options": { + "cheight": 40, + "width": "100%", + "padding": "16px", + "spacing": "12px" + }, + "subwidgets": [ + { + "widgettype": "VBox", + "options": {"spacing": "4px"}, + "subwidgets": [ + {"widgettype": "Text", "options": {"text": "🏷️ 标签管理", "cfontsize": 22, "fontWeight": "bold", "color": "#333"}}, + {"widgettype": "Text", "options": {"text": "创建和管理知识库标签", "cfontsize": 14, "color": "#888"}} + ] + }, + { + "widgettype": "Tabular", + "options": { + "dataurl": "{{entire_url('/tags_list/data.dspy')}}", + "title": "标签列表", + "height": "70vh", + "browserfields": [ + {"field": "name", "title": "标签名", "width": "25%"}, + {"field": "color", "title": "颜色", "width": "15%"}, + {"field": "kb_id", "title": "知识库", "width": "30%"}, + {"field": "created_at", "title": "创建时间", "width": "30%"} + ], + "editable": { + "fields": [ + {"field": "name", "title": "标签名", "uitype": "str", "label": "标签名", "required": true}, + {"field": "kb_id", "title": "知识库ID", "uitype": "str", "label": "知识库ID", "required": true}, + {"field": "color", "title": "颜色", "uitype": "str", "label": "颜色(#hex)", "default": "#3b82f6"} + ], + "add_url": "{{entire_url('/tags_list/add.dspy')}}", + "update_url": "{{entire_url('/tags_list/update.dspy')}}", + "delete_url": "{{entire_url('/tags_list/delete.dspy')}}" + } + } + } + ] +} diff --git a/wwwroot/tags_list/update.dspy b/wwwroot/tags_list/update.dspy new file mode 100644 index 0000000..15faa25 --- /dev/null +++ b/wwwroot/tags_list/update.dspy @@ -0,0 +1,24 @@ +ns = params_kw.copy() +db = DBPools() +dbname = get_module_dbname('rag') + +tag_id = ns.get('id', '') +name = ns.get('name', '').strip() +color = ns.get('color', '#3b82f6') + +if not tag_id: + result = {"widgettype": "Message", "options": {"user_data": {"error": "id required"}}} +else: + async with db.sqlorContext(dbname) as sor: + parts = [] + params = {"id": tag_id} + if name: + parts.append("name=${name}$") + params["name"] = name + if color: + parts.append("color=${color}$") + params["color"] = color + if parts: + await sor.sqlExe(f"UPDATE tags SET {', '.join(parts)} WHERE id=${{id}}$", params) + result = {"widgettype": "Message", "options": {"user_data": {"id": tag_id, "name": name, "color": color}}} + result