From a84d33599b8de115b527ca8e92095f030486012d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E9=B9=8F=E5=AE=87?= Date: Thu, 28 May 2026 16:09:53 +0800 Subject: [PATCH] =?UTF-8?q?ai=E6=A8=A1=E5=9D=97=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- yuthon_ai_agent/__manifest__.py | 1 + yuthon_ai_agent/controllers/__init__.py | 1 + yuthon_ai_agent/controllers/report.py | 104 +++++++ yuthon_ai_agent/models/__init__.py | 2 + yuthon_ai_agent/models/ai_agent.py | 83 +++++- yuthon_ai_agent/models/ai_report.py | 115 ++++++++ yuthon_ai_agent/models/ai_report_template.py | 43 +++ yuthon_ai_agent/security/ir.model.access.csv | 4 + .../static/src/html/report_template.html | 270 ++++++++++++++++++ yuthon_ai_agent/views/ai_report_views.xml | 171 +++++++++++ yuthon_wechat/views/wecom_ai_views.xml | 4 +- 11 files changed, 792 insertions(+), 6 deletions(-) create mode 100644 yuthon_ai_agent/controllers/report.py create mode 100644 yuthon_ai_agent/models/ai_report.py create mode 100644 yuthon_ai_agent/models/ai_report_template.py create mode 100644 yuthon_ai_agent/static/src/html/report_template.html create mode 100644 yuthon_ai_agent/views/ai_report_views.xml diff --git a/yuthon_ai_agent/__manifest__.py b/yuthon_ai_agent/__manifest__.py index ca91668c4..9f14a510a 100644 --- a/yuthon_ai_agent/__manifest__.py +++ b/yuthon_ai_agent/__manifest__.py @@ -22,6 +22,7 @@ 'views/ai_provider_views.xml', 'views/ai_rule_views.xml', 'views/ai_manual_views.xml', + 'views/ai_report_views.xml', 'views/ai_user_profile_views.xml', 'views/ai_token_usage_views.xml', 'views/ai_conversation_views.xml', diff --git a/yuthon_ai_agent/controllers/__init__.py b/yuthon_ai_agent/controllers/__init__.py index 757b12a1f..f662cef4c 100644 --- a/yuthon_ai_agent/controllers/__init__.py +++ b/yuthon_ai_agent/controllers/__init__.py @@ -1,2 +1,3 @@ # -*- coding: utf-8 -*- from . import main +from . import report diff --git a/yuthon_ai_agent/controllers/report.py b/yuthon_ai_agent/controllers/report.py new file mode 100644 index 000000000..07dc142f3 --- /dev/null +++ b/yuthon_ai_agent/controllers/report.py @@ -0,0 +1,104 @@ +# -*- coding: utf-8 -*- +import json +import logging +import os + +from odoo import http, api, SUPERUSER_ID +from odoo.http import request, Response + +_logger = logging.getLogger(__name__) + +# H5 模板文件路径 +_H5_TEMPLATE_PATH = os.path.join( + os.path.dirname(os.path.dirname(os.path.abspath(__file__))), + 'static', 'src', 'html', 'report_template.html' +) + + +class AiReportController(http.Controller): + """AI 动态报表公开控制器""" + + def _get_report_by_token(self, token): + """根据 token 查找并验证报表""" + report = request.env['ai.report'].sudo().search([ + ('access_token', '=', token), + ], limit=1) + if not report: + return None, '报表不存在或已被删除' + if not report.is_valid(): + return None, '报表已过期或已停用' + return report, None + + @http.route('/ai/report/', type='http', auth='none', + methods=['GET'], csrf=False) + def report_page(self, token, **kw): + """渲染 H5 报表页面""" + report, error = self._get_report_by_token(token) + if error: + return Response( + self._render_error_page(error), + content_type='text/html; charset=utf-8' + ) + + # 读取 H5 模板并注入报表信息 + try: + with open(_H5_TEMPLATE_PATH, 'r', encoding='utf-8') as f: + html_template = f.read() + except FileNotFoundError: + return Response('模板文件未找到', content_type='text/plain') + + # 替换模板中的占位变量 + html_content = html_template.replace('{{REPORT_NAME}}', report.name or '') + html_content = html_content.replace('{{DATA_URL}}', f'/ai/report/{token}/data') + + return Response(html_content, content_type='text/html; charset=utf-8') + + @http.route('/ai/report//data', type='http', auth='none', + methods=['GET'], csrf=False) + def report_data(self, token, **kw): + """返回报表数据(JSON API)""" + report, error = self._get_report_by_token(token) + if error: + return Response( + json.dumps({'error': error}, ensure_ascii=False), + content_type='application/json; charset=utf-8' + ) + + data = report.fetch_data() + return Response( + json.dumps(data, ensure_ascii=False, default=str), + content_type='application/json; charset=utf-8' + ) + + @staticmethod + def _render_error_page(message): + """渲染简单的错误页面""" + return f''' + + + + + 报表错误 + + + +
+
⚠
+
{message}
+
+ +''' diff --git a/yuthon_ai_agent/models/__init__.py b/yuthon_ai_agent/models/__init__.py index fa12f9835..4026111d6 100644 --- a/yuthon_ai_agent/models/__init__.py +++ b/yuthon_ai_agent/models/__init__.py @@ -4,3 +4,5 @@ from . import ai_user_profile from . import ai_token_usage from . import ai_rule from . import ai_manual +from . import ai_report +from . import ai_report_template diff --git a/yuthon_ai_agent/models/ai_agent.py b/yuthon_ai_agent/models/ai_agent.py index 48b55861a..7ca7690ab 100644 --- a/yuthon_ai_agent/models/ai_agent.py +++ b/yuthon_ai_agent/models/ai_agent.py @@ -245,6 +245,29 @@ class AiConversation(models.Model): }, }, }, + { + 'type': 'function', + 'function': { + 'name': 'generate_report', + 'description': ( + '当查询结果数据较多(超过5条)或用户要求查看列表/报表时,' + '生成一个动态报表H5页面链接。用户点击链接可在手机上查看整齐的表格数据。' + '注意:必须先调用 odoo_fields_get 确认字段名称,再调用此工具。' + ), + 'parameters': { + 'type': 'object', + 'properties': { + 'model': {'type': 'string', 'description': '模型技术名称,如 sale.order'}, + 'domain': {'type': 'array', 'description': "查询条件 domain,如 [['state','=','sale']]", 'items': {}}, + 'fields': {'type': 'array', 'items': {'type': 'string'}, 'description': '要显示的字段列表'}, + 'field_labels': {'type': 'object', 'description': '字段中文标签,如 {"name":"订单号","amount_total":"金额"}'}, + 'name': {'type': 'string', 'description': '报表名称,如"本月销售订单"'}, + 'order': {'type': 'string', 'description': '排序,如 "create_date desc"'}, + }, + 'required': ['model', 'fields', 'name'], + }, + }, + }, ] if self.provider_id.allow_write: tools += [ @@ -325,6 +348,10 @@ class AiConversation(models.Model): }) return {'success': True, 'message': f'已记住:{content}'} + # generate_report 工具单独处理 + if name == 'generate_report': + return self._execute_generate_report(args) + model_name = args.get('model') if not model_name or model_name not in self.env: return {'error': f'未知模型:{model_name}'} @@ -397,6 +424,43 @@ class AiConversation(models.Model): return skill_rule.execute_skill(args) return None + def _execute_generate_report(self, args): + """执行 generate_report 工具:创建动态报表并返回 URL""" + import json as _json + + model_name = args.get('model') + if not model_name or model_name not in self.env: + return {'error': f'未知模型:{model_name}'} + + field_list = args.get('fields') or [] + if not field_list: + return {'error': '必须指定显示字段'} + + report_name = args.get('name') or '查询报表' + domain = args.get('domain') or [] + field_labels = args.get('field_labels') or {} + order = args.get('order') or 'id desc' + + try: + report = self.env['ai.report'].sudo().create({ + 'name': report_name, + 'model_name': model_name, + 'domain': _json.dumps(domain, ensure_ascii=False), + 'field_list': _json.dumps(field_list, ensure_ascii=False), + 'field_labels': _json.dumps(field_labels, ensure_ascii=False) if field_labels else '', + 'order': order, + 'create_uid': self.env.uid, + }) + url = report.get_report_url() + return { + 'success': True, + 'url': url, + 'message': f'已生成报表链接,用户可点击查看: {url}', + } + except Exception as e: + _logger.exception('generate_report failed: %s', e) + return {'error': f'生成报表失败: {e}'} + def _call_chat_completion(self, provider, messages, tools=None): """封装一次 HTTP 调用,返回 (assistant_message_dict, error_str)。 同时记录 Token 使用量。 @@ -527,10 +591,14 @@ class AiConversation(models.Model): '不要凭空编造结果。不确定字段名时先调用 odoo_fields_get。\n' '所有回答使用中文,简洁清晰。\n' '【格式要求】回复不要使用 Markdown 语法(禁止使用 | 表格、** 加粗、# 标题等)。\n' - '列表数据请用编号+冒号格式,每条一行,字段用空格分隔,例如:\n' - '1. 张三 研发部 2025-03-01\n' - '2. 李四 财务部 2025-03-15\n' - '多字段时用“ / ”分隔,保持简洁可读。\n' + '少量数据(≤5条)直接用编号列表展示,如:\n' + '1. 张三 / 研发部 / 2025-03-01\n' + '2. 李四 / 财务部 / 2025-03-15\n' + '【报表链接】当查询结果超过5条,或用户要求“列表/报表/明细”时,' + '必须调用 generate_report 工具生成报表链接。' + '回复时简要说明查询结果并附上链接,如:\n' + '查询到 XX 条记录,请点击查看详细数据:\n' + 'URL链接\n' '查询结果中的时间已经转换为北京时间(UTC+8),直接展示即可,无需再做时区转换。\n\n' '【重要】你具有记忆能力。当对话中发现用户的新偏好、工作内容、习惯等值得长期记住的信息时,' '主动调用 save_memory 工具保存。下次对话你会自动获取这些记忆。\n' @@ -561,6 +629,13 @@ class AiConversation(models.Model): system_msg += manual_content + '\n' system_msg += '当用户询问如何操作某个功能时,优先参考以上手册内容进行指导。\n' + # —— 注入报表模板 —— + templates_info = self.env['ai.report.template'].get_templates_for_ai() + if templates_info: + system_msg += '\n--- 可用报表模板(调用 generate_report 时可参考)---\n' + system_msg += templates_info + '\n' + system_msg += '当用户查询相关数据时,优先使用对应模板的字段和条件生成报表。\n' + if user_context: system_msg += f'\n--- 当前用户信息 ---\n{user_context}\n' messages = [{'role': 'system', 'content': system_msg}] diff --git a/yuthon_ai_agent/models/ai_report.py b/yuthon_ai_agent/models/ai_report.py new file mode 100644 index 000000000..2b42212c4 --- /dev/null +++ b/yuthon_ai_agent/models/ai_report.py @@ -0,0 +1,115 @@ +# -*- coding: utf-8 -*- +import json +import logging +import uuid +from datetime import timedelta + +from odoo import models, fields, api, SUPERUSER_ID + +_logger = logging.getLogger(__name__) + + +class AiReport(models.Model): + _name = 'ai.report' + _description = '动态报表' + _order = 'create_date desc' + + name = fields.Char('报表名称', required=True) + model_name = fields.Char('数据模型', required=True, + help='Odoo 模型技术名称,如 sale.order') + domain = fields.Text('查询条件', default='[]', + help='JSON 格式的 Odoo domain') + field_list = fields.Text('显示字段', required=True, + help='JSON 数组,如 ["name","date_order","partner_id"]') + field_labels = fields.Text('字段标签', + help='JSON 对象,如 {"name":"订单号","date_order":"下单日期"}') + order = fields.Char('排序', default='id desc') + limit = fields.Integer('最大条数', default=200) + access_token = fields.Char('访问令牌', index=True, copy=False) + active = fields.Boolean(default=True) + expire_date = fields.Datetime('过期时间') + template_id = fields.Many2one('ai.report.template', string='使用模板') + + @api.model_create_multi + def create(self, vals_list): + for vals in vals_list: + if not vals.get('access_token'): + vals['access_token'] = self._generate_token() + if not vals.get('expire_date'): + # 默认 7 天后过期 + vals['expire_date'] = fields.Datetime.now() + timedelta(days=7) + return super().create(vals_list) + + @staticmethod + def _generate_token(): + """生成唯一访问令牌""" + return uuid.uuid4().hex + + def get_report_url(self): + """获取报表的完整公开 URL""" + self.ensure_one() + base_url = self.env['ir.config_parameter'].sudo().get_param('web.base.url', '') + return f"{base_url}/ai/report/{self.access_token}" + + def fetch_data(self): + """执行查询并返回数据(使用 SUPERUSER 权限)""" + self.ensure_one() + try: + model_name = self.model_name + if model_name not in self.env: + return {'error': f'模型 {model_name} 不存在'} + + domain = json.loads(self.domain or '[]') + field_list = json.loads(self.field_list or '[]') + order = self.order or 'id desc' + limit = self.limit or 200 + + Model = self.env[model_name].sudo() + records = Model.search_read(domain, field_list, limit=limit, order=order) + + # 获取字段标签 + labels = {} + if self.field_labels: + labels = json.loads(self.field_labels) + else: + # 从模型中获取字段 string + fields_info = Model.fields_get(field_list, attributes=['string', 'type']) + for fname in field_list: + if fname in fields_info: + labels[fname] = fields_info[fname].get('string', fname) + + # 处理 Many2one 字段(转为 display_name) + fields_info = Model.fields_get(field_list, attributes=['type']) + for record in records: + for fname in field_list: + val = record.get(fname) + ftype = fields_info.get(fname, {}).get('type', '') + if ftype == 'many2one' and isinstance(val, (list, tuple)): + # Many2one 返回 [id, name],取 name + record[fname] = val[1] if len(val) > 1 else str(val[0]) + elif val is False: + record[fname] = '' + elif ftype in ('date', 'datetime') and val: + # 保持字符串格式 + record[fname] = str(val) if not isinstance(val, str) else val + + return { + 'success': True, + 'name': self.name, + 'fields': field_list, + 'labels': labels, + 'records': records, + 'count': len(records), + } + except Exception as e: + _logger.exception('Report fetch_data error: %s', e) + return {'error': str(e)} + + def is_valid(self): + """检查报表是否有效(未过期、启用状态)""" + self.ensure_one() + if not self.active: + return False + if self.expire_date and self.expire_date < fields.Datetime.now(): + return False + return True diff --git a/yuthon_ai_agent/models/ai_report_template.py b/yuthon_ai_agent/models/ai_report_template.py new file mode 100644 index 000000000..0649d47d6 --- /dev/null +++ b/yuthon_ai_agent/models/ai_report_template.py @@ -0,0 +1,43 @@ +# -*- coding: utf-8 -*- +import json +import logging + +from odoo import models, fields, api + +_logger = logging.getLogger(__name__) + + +class AiReportTemplate(models.Model): + _name = 'ai.report.template' + _description = '报表模板' + _order = 'sequence, id' + + name = fields.Char('模板名称', required=True) + model_name = fields.Char('数据模型', required=True, + help='Odoo 模型技术名称,如 sale.order') + field_list = fields.Text('字段列表', + help='JSON 数组,如 ["name","date_order","partner_id","amount_total"]') + field_labels = fields.Text('字段标签', + help='JSON 对象,如 {"name":"订单号","amount_total":"金额"}') + default_domain = fields.Text('默认条件', default='[]', + help='JSON 格式的默认查询 domain') + default_order = fields.Char('默认排序', default='id desc') + description = fields.Text('使用说明', + help='告诉 AI 何时使用此模板,如"当用户查询销售订单列表时使用此模板"') + sequence = fields.Integer('排序', default=10) + active = fields.Boolean(default=True) + + @api.model + def get_templates_for_ai(self): + """获取所有启用模板的摘要,供 AI system_msg 注入""" + templates = self.sudo().search([('active', '=', True)]) + if not templates: + return '' + lines = [] + for t in templates: + fields_str = t.field_list or '[]' + lines.append( + f'- 模板"{t.name}":模型={t.model_name},字段={fields_str},' + f'说明={t.description or "无"}' + ) + return '\n'.join(lines) diff --git a/yuthon_ai_agent/security/ir.model.access.csv b/yuthon_ai_agent/security/ir.model.access.csv index 1094d1eaa..266f3c693 100644 --- a/yuthon_ai_agent/security/ir.model.access.csv +++ b/yuthon_ai_agent/security/ir.model.access.csv @@ -17,3 +17,7 @@ access_ai_rule_user,AI规则-用户只读,model_ai_rule,yuthon_ai_agent.group_ai access_ai_rule_admin,AI规则-管理员,model_ai_rule,yuthon_ai_agent.group_ai_agent_admin,1,1,1,1 access_ai_manual_user,操作手册-用户只读,model_ai_manual,yuthon_ai_agent.group_ai_agent_user,1,0,0,0 access_ai_manual_admin,操作手册-管理员,model_ai_manual,yuthon_ai_agent.group_ai_agent_admin,1,1,1,1 +access_ai_report_user,动态报表-用户,model_ai_report,yuthon_ai_agent.group_ai_agent_user,1,0,1,0 +access_ai_report_admin,动态报表-管理员,model_ai_report,yuthon_ai_agent.group_ai_agent_admin,1,1,1,1 +access_ai_report_template_user,报表模板-用户只读,model_ai_report_template,yuthon_ai_agent.group_ai_agent_user,1,0,0,0 +access_ai_report_template_admin,报表模板-管理员,model_ai_report_template,yuthon_ai_agent.group_ai_agent_admin,1,1,1,1 diff --git a/yuthon_ai_agent/static/src/html/report_template.html b/yuthon_ai_agent/static/src/html/report_template.html new file mode 100644 index 000000000..b51f9aa10 --- /dev/null +++ b/yuthon_ai_agent/static/src/html/report_template.html @@ -0,0 +1,270 @@ + + + + + + {{REPORT_NAME}} + + + +
+

{{REPORT_NAME}}

+
+ 加载中... + +
+
+ +
+ -- + +
+ +
← 左右滑动查看更多 →
+ +
+
+
+
正在加载数据...
+
+
+ + + + + + diff --git a/yuthon_ai_agent/views/ai_report_views.xml b/yuthon_ai_agent/views/ai_report_views.xml new file mode 100644 index 000000000..055b5319f --- /dev/null +++ b/yuthon_ai_agent/views/ai_report_views.xml @@ -0,0 +1,171 @@ + + + + + + + + ai.report.tree + ai.report + + + + + + + + + + + + + + + + + ai.report.form + ai.report + +
+ + + +
+

+ +

+
+ + + + + + + + + + + + + + + + + +
+
+
+
+ + + + ai.report.search + ai.report + + + + + + + + + + + + + + + + + + 动态报表 + ai.report + tree,form + + + + + + + + + + ai.report.template.tree + ai.report.template + + + + + + + + + + + + + + + + ai.report.template.form + ai.report.template + +
+ + + +
+

+ +

+
+ + + + + + + + + + + + + + + + + + +
+
+
+
+ + + + 报表模板 + ai.report.template + tree,form + + + + + +
diff --git a/yuthon_wechat/views/wecom_ai_views.xml b/yuthon_wechat/views/wecom_ai_views.xml index 208c28132..ec8819eca 100644 --- a/yuthon_wechat/views/wecom_ai_views.xml +++ b/yuthon_wechat/views/wecom_ai_views.xml @@ -117,10 +117,10 @@ tree,form - + groups="base.group_no_one"/> -->