164 lines
7.4 KiB
Plaintext
164 lines
7.4 KiB
Plaintext
# 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——页面加载时还没切项目 → 按钮写死报错脚本,之后切了项目仍报错。
|
||
#
|
||
# 端形态(2026-09-08 用户要求:产线上方的按钮收缩为一个菜单项,给会话留最大空间):
|
||
# 桌面:一排 Button(原样)
|
||
# 手机(jsoncall 自动带 _is_mobile=1):单个「⋯ 功能」Button → 点击弹
|
||
# MobileSheet 竖向菜单,项点击走同一 open_url(popup:true,agent_menu_open
|
||
# 返回 PopupWindow 自开,与桌面链路完全一致)
|
||
|
||
_is_mobile = str((params_kw or {}).get('_is_mobile', '') or '') == '1'
|
||
|
||
actions = [] # [{label, url}] 统一收集,最后按端形态渲染
|
||
|
||
# 通用会话(pipeline_id=_generic):只给"工作空间"入口,锁用户专属目录
|
||
if pipeline_id == '_generic':
|
||
actions.append({
|
||
"label": "🗂 工作空间",
|
||
"url": entire_url("/pipeline-sdlc/api/workspace_popup.dspy")
|
||
+ "?pipeline_id=_generic",
|
||
})
|
||
else:
|
||
# 固定「📁 项目」入口(2026-09-11 用户要求,所有专业产线通用):
|
||
# 点击弹出「新建项目 / 切换项目」;新建输项目名后关闭、会话进入新项目;
|
||
# 切换列出本产线全部项目、选择后关闭、会话进入所选项目。
|
||
_proj_q = []
|
||
if session_id:
|
||
_proj_q.append("session_id=" + session_id)
|
||
if pipeline_id:
|
||
_proj_q.append("pipeline_id=" + pipeline_id)
|
||
_proj_url = (entire_url("/pipeline_core/api/agent_project_popup.dspy") + "?op=menu"
|
||
+ (("&" + "&".join(_proj_q)) if _proj_q else ""))
|
||
actions.append({"label": "📁 项目", "url": _proj_url})
|
||
|
||
# 固定「工作空间」入口(通用能力,所有 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)
|
||
actions.append({"label": "🗂 工作空间", "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
|
||
actions.append({"label": label, "url": open_url})
|
||
|
||
if _is_mobile:
|
||
# 手机端:收缩为单个「⋯ 功能」按钮 → MobileSheet 竖向菜单。
|
||
# items 内联进脚本(JSON 字面量);popup:true → 点击 widgetBuild urlwidget,
|
||
# 服务端返回 PopupWindow 自开(agent_menu_open / workspace_popup 同链路)。
|
||
_items = [{"name": "act_%d" % i, "label": a["label"], "url": a["url"], "popup": True}
|
||
for i, a in enumerate(actions)]
|
||
_script = (
|
||
"var its=" + _json.dumps(_items, ensure_ascii=False) + ";"
|
||
"var sh=new bricks.MobileSheet({title:'功能',items:its});sh.open();"
|
||
)
|
||
return _json.dumps({
|
||
"widgettype": "HBox",
|
||
"options": {"width": "100%", "padding": "0 12px 6px 12px", "gap": "8px", "alignItems": "center"},
|
||
"subwidgets": [{
|
||
"widgettype": "Button",
|
||
"options": {"label": "⋯ 功能", "css": "small", "id": "menu_btn_mobile_more"},
|
||
"binds": [{
|
||
"wid": "self", "event": "click", "actiontype": "script",
|
||
"target": "self", "script": _script,
|
||
}],
|
||
}],
|
||
}, ensure_ascii=False)
|
||
|
||
buttons = []
|
||
for i, a in enumerate(actions):
|
||
buttons.append({
|
||
"widgettype": "Button",
|
||
"options": {"label": a["label"], "css": "small", "id": "menu_btn_%d" % i},
|
||
"binds": [{
|
||
"wid": "self", "event": "click", "actiontype": "urlwidget",
|
||
"target": "self", "options": {"url": a["url"]},
|
||
}],
|
||
})
|
||
|
||
return _json.dumps({
|
||
"widgettype": "HBox",
|
||
"options": {"width": "100%", "padding": "0 24px 8px 24px", "gap": "8px", "alignItems": "center"},
|
||
"subwidgets": buttons,
|
||
}, ensure_ascii=False)
|