菜单跳转
This commit is contained in:
@@ -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': [
|
||||
|
||||
@@ -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):
|
||||
"""新建对话"""
|
||||
|
||||
@@ -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": {...}}',
|
||||
)
|
||||
|
||||
@@ -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='方法说明',
|
||||
)
|
||||
|
||||
|
||||
@@ -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>
|
||||
<!-- 附件缩略图 -->
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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': [],
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user