# agent_menus.dspy - 返回当前产线的功能菜单(AgentIO 上方菜单/按钮) # 由 core 通用 agent 的 index.ui 通过 urlwidget 加载,动态渲染产线固化的联机功能入口 import json as _json uid = await get_user() if not uid: uid = 'user-01' # 会话内唯一标识(web 多 tab;工作空间按钮按会话解析项目) session_id = (params_kw or {}).get('session_id', '') or '' # 产线默认能力(入口指定,如 bidding_general):无当前项目时用该产线菜单/能力 default_pipeline_id = (params_kw or {}).get('pipeline_id', '') or '' # 1. 解析当前产线:当前会话项目 → sd_projects.pipeline_id # 统一走 get_session_project_id(含悬空引用防护:指向已删除项目的记录自愈清理并回退全局)。 # 禁止在此重复实现 session→项目解析——多份实现语义漂移正是「会话有项目、入口说没有」的根因。 # 产线隔离:入口指定产线(如商机/投标)时,会话项目必须同产线才生效—— # 否则残留的跨产线项目(如开发产线的元景项目)会劫持本产线菜单/工作空间。 pipeline_id = '' pid = '' try: from pipeline_service.workspace import get_session_project_id dbname = get_module_dbname('pipeline_core') async with DBPools().sqlorContext(dbname) as sor: pid = await get_session_project_id(sor, uid, session_id) or '' if pid: precs = await sor.sqlExe( "SELECT pipeline_id FROM sd_projects WHERE id=${p}$", {"p": pid}) if precs: pipeline_id = getattr(precs[0], 'pipeline_id', '') or '' if default_pipeline_id and pipeline_id != default_pipeline_id: # 跨产线项目劫持:清空项目,用入口产线 pid = '' pipeline_id = default_pipeline_id except Exception: pass if not pipeline_id: if default_pipeline_id: pipeline_id = default_pipeline_id else: try: from pipeline_core import DEFAULT_ABILITY_ID pipeline_id = DEFAULT_ABILITY_ID except Exception: pipeline_id = 'sdlc_general' # 2. 读产线功能菜单 try: from pipeline_core.ability import get_ability_menus menus = get_ability_menus(pipeline_id) except Exception: menus = [] # 3. 构建菜单按钮(actiontype=urlwidget 打开通用弹窗 agent_menu_open,控制弹窗大小) # 注意:这里不预先 urlencode 查询参数。前端 bricks_fetch 的 url_parse 会把 url 里的 # 查询参数提取后再 encodeURIComponent 编码一次,若此处已 urlencode 会导致双重编码 # (title 的 emoji/中文会变成 %F0%9F... 明文显示)。 # # 「需要项目」判断在【点击时】由 agent_menu_open 服务端做(传 require_project/session_id/pipeline_id): # 渲染时固化判断是老 bug——页面加载时还没切项目 → 按钮写死报错脚本,之后切了项目仍报错。 buttons = [] # 固定「工作空间」按钮(通用能力,所有 agent 都有;依赖当前项目) # 带产线参数:入口产线与会话项目跨产线时视为无项目(产线隔离,弹窗端点自身在点击时判断) _ws_url = entire_url("/pipeline-sdlc/api/workspace_popup.dspy") _ws_q = [] if session_id: _ws_q.append("session_id=" + session_id) if pipeline_id: _ws_q.append("pipeline_id=" + pipeline_id) if _ws_q: _ws_url += "?" + "&".join(_ws_q) buttons.append({ "widgettype": "Button", "options": {"label": "🗂 工作空间", "css": "small", "id": "menu_btn_ws"}, "binds": [{ "wid": "self", "event": "click", "actiontype": "urlwidget", "target": "self", "options": {"url": _ws_url}, }], }) for i, m in enumerate(menus): label = str(m.get("label", "") or "") url = str(m.get("url", "") or "") if not url: continue require_project = bool(m.get("require_project", False)) width = str(m.get("width", "85%") or "85%") height = str(m.get("height", "80%") or "80%") open_url = (entire_url("/pipeline_core/api/agent_menu_open.dspy") + "?url=" + url + "&title=" + label + "&width=" + width + "&height=" + height + ("&require_project=1" if require_project else "")) if session_id: open_url += "&session_id=" + session_id if pipeline_id: open_url += "&pipeline_id=" + pipeline_id buttons.append({ "widgettype": "Button", "options": {"label": label, "css": "small", "id": "menu_btn_%d" % i}, "binds": [{ "wid": "self", "event": "click", "actiontype": "urlwidget", "target": "self", "options": {"url": open_url}, }], }) return _json.dumps({ "widgettype": "HBox", "options": {"width": "100%", "padding": "0 24px 8px 24px", "gap": "8px", "alignItems": "center"}, "subwidgets": buttons, }, ensure_ascii=False)