菜单跳转
This commit is contained in:
@@ -245,7 +245,10 @@ class AiConversation(models.Model):
|
||||
'description': (
|
||||
'引导用户跳转到 Odoo 指定页面(菜单/列表/表单/看板)。\n'
|
||||
'当用户说"打开XX页面"/"帮我打开XX"/"跳转到XX"/"去XX"/"查看XX列表"时调用。\n'
|
||||
'调用后聊天框会显示一个可点击的操作按钮,用户点击即可跳转。'
|
||||
'调用后聊天框会显示一个可点击的操作按钮,用户点击即可跳转。\n'
|
||||
'【精确跳转】如果用户说出了菜单层级(如"会议日程下的日程管理"),'
|
||||
'请使用 menu_path 参数,格式为"父菜单/子菜单";'
|
||||
'如果只是模糊说"日程管理",请使用 menu_name 参数。'
|
||||
),
|
||||
'parameters': {
|
||||
'type': 'object',
|
||||
@@ -272,6 +275,18 @@ class AiConversation(models.Model):
|
||||
'description': '可选,列表视图的筛选条件,如 [["state","=","draft"]]',
|
||||
'items': {},
|
||||
},
|
||||
'menu_path': {
|
||||
'type': 'string',
|
||||
'description': '【精确跳转】菜单层级路径,如"会议日程/日程管理"、"销售/销售订单/报价单"',
|
||||
},
|
||||
'menu_name': {
|
||||
'type': 'string',
|
||||
'description': '【模糊匹配】目标菜单名称,如"日程管理"、"销售订单"',
|
||||
},
|
||||
'menu_xmlid': {
|
||||
'type': 'string',
|
||||
'description': '目标菜单的完整 XML ID(如 meeting_schedule.menu_schedule)'
|
||||
},
|
||||
},
|
||||
'required': ['action_type'],
|
||||
},
|
||||
@@ -534,6 +549,85 @@ class AiConversation(models.Model):
|
||||
return rule.execute_skill(args)
|
||||
return None
|
||||
|
||||
def _get_menu_action_by_path_or_name(self, menu_path=None, menu_name=None, menu_xmlid=None):
|
||||
"""根据菜单路径/名称/xmlid 查找对应的 ir.ui.menu 及其 action,返回 (action_dict, menu_path_str)。
|
||||
|
||||
匹配优先级:
|
||||
1. menu_xmlid 精确匹配
|
||||
2. menu_path 按层级匹配,如 "会议日程/日程管理"
|
||||
3. menu_name 模糊匹配
|
||||
"""
|
||||
Menu = self.env['ir.ui.menu'].sudo()
|
||||
menu = None
|
||||
path_str = menu_name or menu_path or ''
|
||||
|
||||
# 1) 按 xmlid 精确匹配
|
||||
if menu_xmlid:
|
||||
try:
|
||||
menu = self.env.ref(menu_xmlid, raise_if_not_found=False)
|
||||
if menu and menu._name == 'ir.ui.menu':
|
||||
path_parts = []
|
||||
m = menu
|
||||
while m:
|
||||
path_parts.append(m.name)
|
||||
m = m.parent_id
|
||||
path_str = ' > '.join(reversed(path_parts))
|
||||
except Exception as e:
|
||||
_logger.warning('[AI-NAV] 解析 menu_xmlid %s 失败: %s', menu_xmlid, e)
|
||||
menu = None
|
||||
|
||||
# 2) 按 menu_path 层级匹配
|
||||
if not menu and menu_path:
|
||||
parts = [p.strip() for p in menu_path.replace('>', '/').split('/') if p.strip()]
|
||||
if parts:
|
||||
target_name = parts[-1]
|
||||
candidates = Menu.search([('name', 'ilike', target_name)])
|
||||
for cand in candidates:
|
||||
cand_parts = []
|
||||
m = cand
|
||||
while m:
|
||||
cand_parts.append(m.name)
|
||||
m = m.parent_id
|
||||
full_path = ' > '.join(reversed(cand_parts))
|
||||
# 所有给定层级名称都出现在完整路径里即可
|
||||
if all(p in full_path for p in parts):
|
||||
menu = cand
|
||||
path_str = full_path
|
||||
break
|
||||
|
||||
# 3) 按 menu_name 模糊匹配
|
||||
if not menu and menu_name:
|
||||
# 优先匹配叶子菜单,再按 sequence/id 排序取第一条
|
||||
menu = Menu.search([('name', 'ilike', menu_name)], limit=1, order='sequence,id')
|
||||
if menu:
|
||||
cand_parts = []
|
||||
m = menu
|
||||
while m:
|
||||
cand_parts.append(m.name)
|
||||
m = m.parent_id
|
||||
path_str = ' > '.join(reversed(cand_parts))
|
||||
|
||||
if not menu or not menu.action:
|
||||
return None, path_str
|
||||
|
||||
# 读取 action 字典并清理前端不需要的字段,只保留 doAction 必需字段
|
||||
action_ref = menu.action
|
||||
raw_action = action_ref.sudo().read()[0]
|
||||
keep_keys = {
|
||||
'name', 'type', 'target', 'res_model', 'res_id', 'view_mode', 'views',
|
||||
'domain', 'context', 'search_view_id', 'limit', 'binding_type',
|
||||
}
|
||||
action = {k: raw_action[k] for k in keep_keys if k in raw_action and raw_action[k] not in (None, False, [])}
|
||||
# 处理 context 字符串
|
||||
if 'context' in action and isinstance(action['context'], str):
|
||||
try:
|
||||
from ast import literal_eval
|
||||
action['context'] = literal_eval(action['context'])
|
||||
except Exception:
|
||||
action['context'] = {}
|
||||
action['type'] = action_ref._name
|
||||
return action, path_str
|
||||
|
||||
def _execute_navigate_to(self, args):
|
||||
"""构建 Odoo action 字典,供前端调用 doAction 跳转。
|
||||
返回 {'__action__': True, 'action': {...}, 'message': '...'},
|
||||
@@ -543,49 +637,62 @@ class AiConversation(models.Model):
|
||||
res_id = args.get('res_id')
|
||||
name = args.get('name', '')
|
||||
domain = args.get('domain') or []
|
||||
menu_path = args.get('menu_path', '')
|
||||
menu_name = args.get('menu_name', '')
|
||||
menu_xmlid = args.get('menu_xmlid', '')
|
||||
|
||||
action = {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': name or (model and f'{model} 列表'),
|
||||
'target': 'current',
|
||||
}
|
||||
action = None
|
||||
menu_path_str = ''
|
||||
|
||||
if action_type == 'menu':
|
||||
# 菜单跳转:直接用菜单的 xmlid 或 action id
|
||||
menu_xmlid = args.get('menu_xmlid', '')
|
||||
if menu_xmlid:
|
||||
action = {
|
||||
'type': 'ir.actions.client',
|
||||
'tag': 'menu',
|
||||
'params': {'menu_xmlid': menu_xmlid},
|
||||
}
|
||||
# 优先按菜单路径/名称/xmlid 精确解析
|
||||
if menu_path or menu_name or menu_xmlid:
|
||||
action, menu_path_str = self._get_menu_action_by_path_or_name(
|
||||
menu_path=menu_path, menu_name=menu_name, menu_xmlid=menu_xmlid
|
||||
)
|
||||
if not action:
|
||||
# 如果是按菜单路径没找到,不要直接失败,降级到按 model 匹配
|
||||
_logger.warning(
|
||||
'[AI-NAV] 未找到菜单 path=%s name=%s xmlid=%s,降级到 model=%s',
|
||||
menu_path, menu_name, menu_xmlid, model
|
||||
)
|
||||
|
||||
# 按 model 构建通用 action(兜底)
|
||||
if not action:
|
||||
action = {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': name or (model and f'{model} 列表'),
|
||||
'target': 'current',
|
||||
}
|
||||
if action_type == 'menu':
|
||||
# menu 类型但没提供 menu_xmlid 且没有按路径匹配到
|
||||
if not model:
|
||||
return {'error': 'menu 类型需要提供 menu_xmlid/menu_path,或提供 model 作为兜底'}
|
||||
action['res_model'] = model
|
||||
action['view_mode'] = 'list,kanban,form'
|
||||
if domain:
|
||||
action['domain'] = domain
|
||||
elif action_type == 'kanban':
|
||||
if not model:
|
||||
return {'error': 'kanban 类型需要提供 model 参数'}
|
||||
action['res_model'] = model
|
||||
action['view_mode'] = 'kanban,list,form'
|
||||
if domain:
|
||||
action['domain'] = domain
|
||||
elif action_type == 'form':
|
||||
if not model or not res_id:
|
||||
return {'error': 'form 类型需要提供 model 和 res_id 参数'}
|
||||
action['res_model'] = model
|
||||
action['res_id'] = res_id
|
||||
action['view_mode'] = 'form'
|
||||
action['views'] = [(False, 'form')]
|
||||
else:
|
||||
return {'error': 'menu 类型需要提供 menu_xmlid 参数'}
|
||||
|
||||
elif action_type == 'kanban':
|
||||
if not model:
|
||||
return {'error': 'kanban 类型需要提供 model 参数'}
|
||||
action['res_model'] = model
|
||||
action['view_mode'] = 'kanban,list,form'
|
||||
if domain:
|
||||
action['domain'] = domain
|
||||
|
||||
elif action_type == 'form':
|
||||
if not model or not res_id:
|
||||
return {'error': 'form 类型需要提供 model 和 res_id 参数'}
|
||||
action['res_model'] = model
|
||||
action['res_id'] = res_id
|
||||
action['view_mode'] = 'form'
|
||||
action['views'] = [(False, 'form')]
|
||||
|
||||
else:
|
||||
# list(默认)
|
||||
if not model:
|
||||
return {'error': 'list 类型需要提供 model 参数'}
|
||||
action['res_model'] = model
|
||||
action['view_mode'] = 'list,kanban,form'
|
||||
if domain:
|
||||
action['domain'] = domain
|
||||
# list(默认)
|
||||
if not model:
|
||||
return {'error': 'list 类型需要提供 model 参数'}
|
||||
action['res_model'] = model
|
||||
action['view_mode'] = 'list,kanban,form'
|
||||
if domain:
|
||||
action['domain'] = domain
|
||||
|
||||
# 获取模型的显示名称,用于默认 name
|
||||
if not action.get('name') and model and model in self.env:
|
||||
@@ -594,7 +701,11 @@ class AiConversation(models.Model):
|
||||
if model_desc:
|
||||
action['name'] = model_desc.name
|
||||
|
||||
msg = name or f'已为您打开{action.get("name", model)}'
|
||||
# 按钮文案:优先真实菜单名(action.name),不用 LLM 传的 name(LLM 可能自己编一个)
|
||||
btn_label = action.get('name') or name or '打开页面'
|
||||
msg = btn_label
|
||||
if menu_path_str and menu_path_str not in btn_label:
|
||||
msg = f'{btn_label}\n菜单路径:{menu_path_str}'
|
||||
return {
|
||||
'__action__': True,
|
||||
'action': action,
|
||||
@@ -983,7 +1094,10 @@ class AiConversation(models.Model):
|
||||
'主动调用 save_memory 工具保存。下次对话你会自动获取这些记忆。\n'
|
||||
'称呼用户时使用其设定的称呼,不要说"您"、"亲"等通用称谓。\n'
|
||||
'【页面跳转】当用户要求"打开XX页面"/"跳转到XX"/"帮我看XX列表"/"打开XX的详情"时,'
|
||||
'调用 navigate_to 工具。调用后对话窗口会显示操作按钮,用户可点击跳转。\n'
|
||||
'调用 navigate_to 工具。调用前:如果用户说出了菜单层级,请使用 menu_path(如"会议日程/日程管理");'
|
||||
'如果只说名称,请使用 menu_name。调用后回复里请列出完整菜单路径,如:'
|
||||
'「菜单路径:会议日程 > 日程管理」。'
|
||||
'对话窗口会显示操作按钮,用户点击即可跳转。\n'
|
||||
)
|
||||
|
||||
# —— 注入 AI 规则 ——
|
||||
|
||||
@@ -61,18 +61,27 @@
|
||||
|
||||
/* AI 导航按钮(菜单跳转) */
|
||||
.ai-chat-action-wrap {
|
||||
margin-top: 8px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
.ai-chat-action-btn {
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
padding: 5px 14px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
padding: 8px 18px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.ai-chat-action-btn:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 2px 8px rgba(0,123,255,0.3);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.ai-chat-action-btn i {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.ai-chat-input-area input {
|
||||
flex: 1;
|
||||
|
||||
@@ -196,14 +196,16 @@
|
||||
.o_ai_action_btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 7px 16px;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 9px 18px;
|
||||
background: #7c3aed;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
line-height: 1.4;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
@@ -212,6 +214,9 @@
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(124,58,237,0.35);
|
||||
}
|
||||
.o_ai_action_btn i {
|
||||
font-size: 13px;
|
||||
}
|
||||
.o_ai_loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
Reference in New Issue
Block a user