菜单跳转

This commit is contained in:
李鹏宇
2026-07-02 09:57:44 +08:00
parent 483c607665
commit 91449820b8
12 changed files with 255 additions and 35 deletions
+1 -1
View File
@@ -13,7 +13,7 @@
- AI 规则配置系统
""",
'author': 'pengyuthon',
'version': '17.0.2.2.2',
'version': '17.0.2.2.3',
'license': 'LGPL-3',
'depends': ['base', 'web', 'hr', 'documents', 'sh_backmate_theme_adv_enterprise'],
'data': [
+20
View File
@@ -85,6 +85,7 @@ class AiChatController(http.Controller):
'content': msg.content,
'date': time_str,
'attachments': msg_attachments,
'action': msg.action if msg.action else None,
})
return {'messages': messages}
@@ -146,6 +147,25 @@ class AiChatController(http.Controller):
} for c in convs]
}
@http.route('/ai/chat/execute-tool', type='json', auth='user', methods=['POST'], csrf=False)
def chat_execute_tool(self, conversation_id, tool_name, tool_params=None):
"""执行工具(主动感知按钮点击后调用)"""
err = self._check_ai_access()
if err:
return err
conv = request.env['ai.conversation'].browse(conversation_id)
if not conv.exists():
return {'error': '对话不存在'}
# 调用 AI Agent 的工具执行方法
try:
result = conv._execute_tool(tool_name, tool_params or {})
return result
except Exception as e:
_logger.exception('Execute tool %s failed: %s', tool_name, e)
return {'error': f'执行失败:{e}'}
@http.route('/ai/conversation/new', type='json', auth='user', methods=['POST'], csrf=False)
def conversation_new(self):
"""新建对话"""
+42 -16
View File
@@ -187,8 +187,11 @@ class AiConversation(models.Model):
# ---------------------------------------------------------
# Function Calling: 工具定义 & 执行
# ---------------------------------------------------------
def _get_tools_schema(self):
"""返回 OpenAI 兼容的工具列表(JSON Schema)。"""
def _get_tools_schema(self, allow_write=False):
"""返回 OpenAI 兼容的工具列表(JSON Schema)。
:param allow_write: 是否允许写入操作(由适用规则的 can_write 决定)
"""
tools = [
{
'type': 'function',
@@ -383,7 +386,7 @@ class AiConversation(models.Model):
},
},
]
if self.provider_id.allow_write:
if allow_write:
tools += [
{
'type': 'function',
@@ -434,9 +437,11 @@ class AiConversation(models.Model):
]
return tools
def _execute_tool(self, name, args):
def _execute_tool(self, name, args, allow_write=False):
"""执行单个工具调用,返回可 JSON 序列化的结果字典。
所有 ORM 操作都走当前用户权限(self.env),自动遵循 ir.rule / ir.model.access。
:param allow_write: 是否允许写入操作(由适用规则的 can_write 决定)
"""
try:
# navigate_to 工具 — 返回 action 标记,中断 tool_calls 循环
@@ -499,21 +504,24 @@ class AiConversation(models.Model):
return {'success': True, 'fields': fg}
if name == 'odoo_create':
if not self.provider_id.allow_write:
return {'error': '当前 Provider 未启用写入权限'}
if not allow_write:
return {'error': '当前匹配的规则未启用写入权限,AI 无法修改数据。'
'如需开启,请在【规则配置】中勾选对应规则的"允许修改数据"。'}
rec = Model.create(args.get('values') or {})
return {'success': True, 'id': rec.id, 'display_name': rec.display_name}
if name == 'odoo_write':
if not self.provider_id.allow_write:
return {'error': '当前 Provider 未启用写入权限'}
if not allow_write:
return {'error': '当前匹配的规则未启用写入权限,AI 无法修改数据。'
'如需开启,请在【规则配置】中勾选对应规则的"允许修改数据"。'}
ids = args.get('ids') or []
Model.browse(ids).write(args.get('values') or {})
return {'success': True, 'updated_ids': ids}
if name == 'odoo_unlink':
if not self.provider_id.allow_write:
return {'error': '当前 Provider 未启用写入权限'}
if not allow_write:
return {'error': '当前匹配的规则未启用写入权限,AI 无法修改数据。'
'如需开启,请在【规则配置】中勾选对应规则的"允许修改数据"。'}
ids = args.get('ids') or []
Model.browse(ids).unlink()
return {'success': True, 'deleted_ids': ids}
@@ -588,9 +596,19 @@ class AiConversation(models.Model):
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):
cand_parts.reverse() # 从根到叶子
full_path = ' > '.join(cand_parts)
# 精确匹配:每个给定层级必须完整匹配路径中的对应组件
if len(cand_parts) >= len(parts):
# 从叶子向上匹配给定层级的最后 N 个组件
match = True
for i, p in enumerate(parts):
# 匹配路径末尾的对应组件(从叶子向上数)
path_idx = len(cand_parts) - len(parts) + i
if path_idx < 0 or cand_parts[path_idx] != p:
match = False
break
if match:
menu = cand
path_str = full_path
break
@@ -989,6 +1007,10 @@ class AiConversation(models.Model):
if not provider or not provider.active:
return {'error': 'AI 服务未配置或已停用。'}
# 计算写入权限:适用规则中任一条勾选 can_write 即允许写入
applicable_rules = provider.get_applicable_rules()
allow_write = any(r.can_write for r in applicable_rules)
# —— 附件 OCR 预处理 ——
ocr_result = ''
attachment_note = ''
@@ -1098,10 +1120,10 @@ class AiConversation(models.Model):
'如果只说名称,请使用 menu_name。调用后回复里请列出完整菜单路径,如:'
'「菜单路径:会议日程 > 日程管理」。'
'对话窗口会显示操作按钮,用户点击即可跳转。\n'
f'【数据写入权限】{"允许创建/修改/删除 Odoo 数据。" if allow_write else "当前仅允许查询数据,不允许修改数据(创建/写入/删除操作已被禁用)。"}\n'
)
# —— 注入 AI 规则 ——
applicable_rules = provider.get_applicable_rules()
instruction_rules = applicable_rules.filtered(lambda r: r.rule_type == 'instruction')
restriction_rules = applicable_rules.filtered(lambda r: r.rule_type == 'restriction')
skill_rules = applicable_rules.filtered(lambda r: r.rule_type in ('skill', 'method') and r.skill_name)
@@ -1141,7 +1163,7 @@ class AiConversation(models.Model):
if msg.role in ('user', 'assistant') and msg.content:
messages.append({'role': msg.role, 'content': msg.content})
tools = self._get_tools_schema() if provider.enable_tools else None
tools = self._get_tools_schema(allow_write=allow_write) if provider.enable_tools else None
# 把技能规则注册为额外的工具
if skill_rules:
@@ -1182,7 +1204,7 @@ class AiConversation(models.Model):
# 先尝试技能接口工具,找不到再走内置工具
result = self._execute_skill_tool(fname, fargs)
if result is None:
result = self._execute_tool(fname, fargs)
result = self._execute_tool(fname, fargs, allow_write=allow_write)
# 检测 __action__ 标记(navigate_to 工具返回)
if isinstance(result, dict) and result.get('__action__'):
final_action = result.get('action')
@@ -1248,3 +1270,7 @@ class AiMessage(models.Model):
help='附件图片/PDF 经 OCR 提取的文本内容'
)
create_uid = fields.Many2one('res.users', string='发送者', default=lambda self: self.env.uid)
action = fields.Text(
string='操作配置',
help='JSON格式,存储按钮配置,如 {"type": "guide_button", "label": "确认", "action": {...}}',
)
+45 -1
View File
@@ -24,11 +24,13 @@ class AiRule(models.Model):
('restriction', '限制规则'),
('skill', '技能接口'),
('method', '调用模型方法'),
('active_guide', '主动感知'),
], string='规则类型', required=True, default='instruction',
help='回复指令:注入到 AI 系统提示词中,控制回复风格和内容。\n'
'限制规则:告诉 AI 哪些话题不能回答。\n'
'技能接口:对接外部 API,AI 可调用获取专业数据。\n'
'调用模型方法:直接调用 Odoo 模型方法(如撤回消息),无需 HTTP 接口。'
'调用模型方法:直接调用 Odoo 模型方法(如撤回消息),无需 HTTP 接口。\n'
'主动感知:在用户操作(如流程结束)时主动弹出引导消息。'
)
active = fields.Boolean(string='启用', default=True)
priority = fields.Integer(string='优先级', default=10, help='数字越小优先级越高')
@@ -83,6 +85,14 @@ class AiRule(models.Model):
string='接口超时(秒)', default=10,
)
# ---- 数据写入权限 ----
can_write = fields.Boolean(
string='允许修改数据',
default=False,
help='勾选后,匹配此规则时 AI 可以调用 create/write/unlink 工具修改 Odoo 数据。'
'只要有一条适用规则勾选了此项,AI 就拥有写入权限。',
)
# ---- 调用模型方法配置 ----
skill_model = fields.Char(
string='目标模型',
@@ -192,3 +202,37 @@ class AiRule(models.Model):
_logger.exception('Method rule %s failed: %s', self.skill_name, e)
return {'error': f'{type(e).__name__}: {e}'}
# ---- 主动感知配置 ----
guide_model = fields.Char(
string='触发模型',
help='监听哪个模型,如 yuthon.notice',
)
guide_field = fields.Char(
string='触发字段',
help='监听哪个字段变化,如 work_end',
)
guide_field_value = fields.Char(
string='触发值',
help='字段值等于什么时触发,如 True',
)
guide_message = fields.Text(
string='提醒内容',
help='主动弹出时显示的文字,支持变量:{record_name}, {user_name}',
)
guide_button_label = fields.Char(
string='按钮文字',
default='确认执行',
)
guide_button_action = fields.Text(
string='按钮动作 (JSON)',
help='按钮点击后执行的动作,JSON格式,如 {"type": "execute_tool", "tool": "notice_publish", "params": {"notice_id": "{record_id}"}}',
default='{}',
)
guide_method_name = fields.Char(
string='方法名称',
help='后台处理方法名,用于标识触发哪个逻辑',
)
guide_method_desc = fields.Text(
string='方法说明',
)
+2 -2
View File
@@ -69,7 +69,7 @@
justify-content: center;
gap: 8px;
border-radius: 8px;
font-size: 14px;
font-size: 15px;
font-weight: 500;
padding: 8px 18px;
line-height: 1.4;
@@ -80,7 +80,7 @@
transition: all 0.2s ease;
}
.ai-chat-action-btn i {
font-size: 13px;
font-size: 14px;
}
.ai-chat-input-area input {
@@ -199,13 +199,13 @@
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 6px 14px;
gap: 8px;
padding: 7px 16px;
background: #7c3aed;
color: #fff;
border: none;
border-radius: 8px;
font-size: 14px;
font-size: 15px;
font-weight: 500;
line-height: 1.4;
cursor: pointer;
@@ -217,7 +217,7 @@
box-shadow: 0 4px 12px rgba(124,58,237,0.35);
}
.o_ai_action_btn i {
font-size: 12px;
font-size: 14px;
}
.o_ai_loading {
display: flex;
@@ -15,6 +15,7 @@ export class AiChatAction extends Component {
messages: [],
inputText: "",
loading: false,
executingAction: false, // 防止重复点击 action 按钮
title: "AI 对话",
attachments: [], // {attachment_id, filename, mimetype}
});
@@ -241,10 +242,50 @@ export class AiChatAction extends Component {
this.state.attachments = [];
}
// 执行 AI 返回的导航 action(菜单跳转)
doNavAction(action) {
if (action) {
this.actionService.doAction(action);
// 执行 AI 返回的操作 action(菜单跳转/主动感知等)
async handleActionClick(actionObj) {
if (!actionObj || this.state.executingAction) return;
this.state.executingAction = true;
try {
const action = actionObj.action || actionObj;
if (action.type === 'navigate') {
// 菜单跳转
await this.actionService.doAction(action, { clearBreadcrumbs: true });
} else if (action.type === 'execute_tool') {
// 执行后台方法
const result = await this.rpc('/ai/chat/execute_tool', {
conversation_id: this.resId,
tool_name: action.tool_name,
tool_params: action.params || {},
});
// 在聊天框显示执行结果
this.state.messages = [
...this.state.messages,
{ role: 'assistant', content: `✅ 执行完成:${JSON.stringify(result)}`, date: '刚刚' },
];
} else if (action.type === 'guide_button') {
// 主动感知按钮(和 execute_tool 类似)
const result = await this.rpc('/ai/chat/execute-tool', {
conversation_id: this.resId,
tool_name: action.tool_name || action.action?.tool_name,
tool_params: action.params || action.action?.params || {},
});
this.state.messages = [
...this.state.messages,
{ role: 'assistant', content: `✅ 已处理完成`, date: '刚刚' },
];
}
} catch (e) {
this.state.messages = [
...this.state.messages,
{ role: 'assistant', content: `❌ 执行失败:${e.message}`, date: '刚刚' },
];
} finally {
this.state.executingAction = false;
}
}
}
@@ -16,11 +16,18 @@
<t t-foreach="state.messages" t-as="msg" t-key="msg_index">
<div t-att-class="'ai-chat-msg ' + msg.role">
<div class="ai-chat-bubble" t-raw="renderMsgContent(msg.content)"/>
<!-- AI 返回的导航按钮(菜单跳转) -->
<!-- AI 返回的操作按钮(菜单跳转/主动感知等) -->
<div t-if="msg.role === 'assistant' and msg.action" class="ai-chat-action-wrap">
<t t-if="typeof msg.action === 'string'">
<t t-set="actionObj" t-raw="JSON.parse(msg.action)"/>
</t>
<t t-else="">
<t t-set="actionObj" t-raw="msg.action"/>
</t>
<button class="btn btn-primary btn-sm ai-chat-action-btn"
t-on-click="() => doNavAction(msg.action)">
<i class="fa fa-arrow-right me-1"/> <t t-esc="msg.action.name || '打开页面'"/>
t-att-disabled="state.executingAction"
t-on-click="() => handleActionClick(actionObj)">
<i class="fa fa-arrow-right me-1"/> <t t-esc="actionObj.label || '执行操作'"/>
</button>
</div>
<!-- 附件缩略图 -->
+3
View File
@@ -45,6 +45,9 @@
<field name="rule_type"/>
<field name="priority"/>
<field name="active"/>
<field name="can_write" widget="boolean_toggle"
help="勾选后匹配此规则时 AI 可修改数据(create/write/unlink),"
"适用于需要 AI 辅助录入或修改数据的场景"/>
</group>
<group string="适用范围">
<field name="provider_ids" widget="many2many_tags"
+1 -1
View File
@@ -23,7 +23,7 @@
'web.assets_backend': [
'yuthon_base/static/src/views/**/*',
'yuthon_base/static/src/js/datetime_picker.js',
# 'yuthon_base/static/src/scss/base.css',
'yuthon_base/static/src/scss/base.css',
]
},
'demo': [],
+10
View File
@@ -1,3 +1,13 @@
.flex-column {
flex-direction: row !important;
}
/* 隐藏 form view 右上角 mail 相关按钮:消息、活动、关注者、主附件 */
.o_control_panel .o-mail-Chatter-topbar,
.o_control_panel .o_attachment_preview,
.o_control_panel .o_attachment_preview_button,
.o_form_view .o-mail-Chatter-topbar,
.o_form_view .o_attachment_preview,
.o_form_view .o_attachment_preview_button {
display: none !important;
}
+69
View File
@@ -256,6 +256,75 @@ class YuthonNotice(models.Model):
},
}
def write(self, vals):
"""重写write方法,检测work_end字段变化,触发AI主动感知"""
result = super().write(vals)
# 检测work_end从False变为True
if 'work_end' in vals and vals['work_end']:
for record in self:
if record.work_end: # 确保当前值确实是True
self._trigger_ai_guide(record)
return result
def _trigger_ai_guide(self, record):
"""触发AI主动感知:查找匹配的规则,推送消息到聊天框"""
# 查找匹配的规则
rules = self.env['ai.rule'].sudo().search([
('rule_type', '=', 'active_guide'),
('guide_model', '=', self._name),
('guide_field', '=', 'work_end'),
('guide_field_value', '=', 'True'),
('active', '=', True),
])
for rule in rules:
# 生成提醒内容(替换变量)
message = rule.guide_message or f'{record.name} 流程已结束'
message = message.replace('{record_name}', record.name or '')
message = message.replace('{user_name}', self.env.user.name or '')
message = message.replace('{record_id}', str(record.id))
# 解析按钮动作
try:
action_config = json.loads(rule.guide_button_action or '{}')
except json.JSONDecodeError:
action_config = {}
# 替换action_config中的变量
action_config_str = json.dumps(action_config, ensure_ascii=False)
action_config_str = action_config_str.replace('{record_id}', str(record.id))
action_config = json.loads(action_config_str)
# 查找或创建AI对话(用当前用户的默认对话)
conversation = self.env['ai.conversation'].sudo().search([
('create_uid', '=', self.env.user.id),
], limit=1, order='write_date desc')
if not conversation:
conversation = self.env['ai.conversation'].sudo().create({
'name': 'AI 对话',
})
# 创建AI消息(带action)
self.env['ai.message'].sudo().create({
'conversation_id': conversation.id,
'role': 'assistant',
'content': message,
'action': json.dumps({
'type': 'guide_button',
'label': rule.guide_button_label or '确认执行',
'action': action_config,
}, ensure_ascii=False),
})
# TODO: 通过bus.bus推送到前端,让聊天框自动显示新消息
# self.env['bus.bus'].sudo()._sendone(self.env.user.partner_id, 'ai_guide_notification', {
# 'conversation_id': conversation.id,
# 'message': message,
# })
def stop_notice(self):
self.write({
'notice_state': 'stop',