diff --git a/yuthon_ai_agent/models/ai_agent.py b/yuthon_ai_agent/models/ai_agent.py index 4e94a77db..b66fbc396 100644 --- a/yuthon_ai_agent/models/ai_agent.py +++ b/yuthon_ai_agent/models/ai_agent.py @@ -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 规则 —— diff --git a/yuthon_ai_agent/static/src/css/ai_chat.css b/yuthon_ai_agent/static/src/css/ai_chat.css index e03abf859..21a15c3b6 100644 --- a/yuthon_ai_agent/static/src/css/ai_chat.css +++ b/yuthon_ai_agent/static/src/css/ai_chat.css @@ -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; diff --git a/yuthon_ai_agent/static/src/css/ai_floating.css b/yuthon_ai_agent/static/src/css/ai_floating.css index 06dbfce67..8f2c96b92 100644 --- a/yuthon_ai_agent/static/src/css/ai_floating.css +++ b/yuthon_ai_agent/static/src/css/ai_floating.css @@ -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;