# -*- coding: utf-8 -*- import json import logging from odoo import http from odoo.http import request _logger = logging.getLogger(__name__) from odoo.tools import format_datetime class AiChatController(http.Controller): """AI 聊天 API""" def _check_ai_access(self): """检查用户是否有 AI Agent 使用权限""" if not request.env.user.has_group('yuthon_ai_agent.group_ai_agent_user'): return {'error': '您没有使用 AI 助手的权限,请联系管理员开通。'} return None @http.route('/ai/chat/send', type='json', auth='user', methods=['POST'], csrf=False) def chat_send(self, conversation_id, message, attachment_ids=None): """发送消息(支持附件 OCR)""" err = self._check_ai_access() if err: return err conv = request.env['ai.conversation'].browse(conversation_id) if not conv.exists(): return {'error': '对话不存在'} reply = conv.send_message(message, attachment_ids=attachment_ids) if isinstance(reply, dict) and 'error' in reply: return reply # 提取 assistant 回复内容(支持 dict 和纯文本两种返回格式) content = reply.get('content') if isinstance(reply, dict) else reply action = reply.get('action') if isinstance(reply, dict) else None # 获取刚创建的用户消息(附件信息) user_msg = conv.message_ids.filtered(lambda m: m.role == 'user')[-1:] user_attachments = [] if user_msg: for att in user_msg.attachment_ids: user_attachments.append({ 'id': att.id, 'name': att.name, 'mimetype': att.mimetype, }) last_msg = conv.message_ids.filtered(lambda m: m.role == 'assistant')[-1:] result = { 'content': content, 'message_id': last_msg.id if last_msg else False, 'attachments': user_attachments, } if action: result['action'] = action return result @http.route('/ai/chat/history', type='json', auth='user') def chat_history(self, conversation_id): """获取对话历史""" conv = request.env['ai.conversation'].browse(conversation_id) if not conv.exists(): return {'error': '对话不存在'} user_tz = request.env.user.tz or 'Asia/Shanghai' from pytz import timezone, utc local_tz = timezone(user_tz) messages = [] for msg in conv.message_ids: if msg.create_date: utc_dt = msg.create_date.replace(tzinfo=utc) local_dt = utc_dt.astimezone(local_tz) time_str = local_dt.strftime('%H:%M') else: time_str = '' # 附件信息 msg_attachments = [] for att in msg.attachment_ids: msg_attachments.append({ 'id': att.id, 'name': att.name, 'mimetype': att.mimetype, }) messages.append({ 'id': msg.id, 'role': msg.role, 'content': msg.content, 'date': time_str, 'attachments': msg_attachments, 'action': msg.action if msg.action else None, }) return {'messages': messages} @http.route('/ai/chat/clear', type='json', auth='user', methods=['POST'], csrf=False) def chat_clear(self, conversation_id): """清空对话""" conv = request.env['ai.conversation'].browse(conversation_id) if not conv.exists(): return {'error': '对话不存在'} conv.message_ids.unlink() return {'success': True} @http.route('/ai/conversation/create', type='json', auth='user', methods=['POST'], csrf=False) def conversation_create(self): """创建新对话并返回ID""" provider = request.env['ai.provider'].search([('active', '=', True)], limit=1) if not provider: return {'error': '请先在服务商配置中设置 AI 服务'} conv = request.env['ai.conversation'].create({ 'name': '新对话', 'provider_id': provider.id, }) return {'id': conv.id} @http.route('/ai/conversation/get_or_create_personal', type='json', auth='user') def get_or_create_personal(self): """获取或创建当前用户的个人对话(浮动聊天用)""" err = self._check_ai_access() if err: return err conv = request.env['ai.conversation'].search([ ('create_uid', '=', request.env.uid), ], limit=1, order='create_date desc') if not conv: provider = request.env['ai.provider'].search([('active', '=', True)], limit=1) if not provider: return {'error': '请先在服务商配置中设置 AI 服务'} conv = request.env['ai.conversation'].create({ 'name': '个人对话', 'provider_id': provider.id, }) return {'id': conv.id, 'name': conv.name} @http.route('/ai/conversation/list', type='json', auth='user') def conversation_list(self): """获取当前用户的所有对话列表""" err = self._check_ai_access() if err: return err convs = request.env['ai.conversation'].search([ ('create_uid', '=', request.env.uid), ], order='create_date desc', limit=50) return { 'conversations': [{ 'id': c.id, 'name': c.name, 'message_count': c.message_count, } 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): """新建对话""" err = self._check_ai_access() if err: return err provider = request.env['ai.provider'].search([('active', '=', True)], limit=1) if not provider: return {'error': '请先在服务商配置中设置 AI 服务'} conv = request.env['ai.conversation'].create({ 'name': '新对话', 'provider_id': provider.id, }) return {'id': conv.id, 'name': conv.name} @http.route('/ai/conversation/delete', type='json', auth='user', methods=['POST'], csrf=False) def conversation_delete(self, conversation_id): """删除对话""" err = self._check_ai_access() if err: return err conv = request.env['ai.conversation'].browse(conversation_id) if not conv.exists(): return {'error': '对话不存在'} if conv.create_uid.id != request.env.uid: return {'error': '只能删除自己的对话'} conv.unlink() return {'success': True} @http.route('/ai/profile/get', type='json', auth='user') def get_profile(self): """获取当前用户的 AI 画像(含记忆摘要)""" err = self._check_ai_access() if err: return err profile = request.env['ai.user.profile'].get_or_create() return { 'id': profile.id, 'nickname': profile.nickname or '', 'role': profile.role or '', 'department': profile.department or '', 'style_preference': profile.style_preference or 'concise', 'memory_count': profile.memory_count, } @http.route('/ai/attachment/upload', type='http', auth='user', methods=['POST'], csrf=False) def attachment_upload(self, **kwargs): """上传聊天附件(图片/PDF/Excel),调 OCR 或 Excel 解析器提取文字并返回结果。 请求:multipart/form-data,字段名 'file' 可能携带 conversation_id 用于关联对话 返回:JSON { success, attachment_id, filename, mimetype, ocr_text, error, meta } """ err = self._check_ai_access() if err: return request.make_json_response(err) uploaded = request.httprequest.files.get('file') if not uploaded: return request.make_json_response({'success': False, 'error': '未收到文件'}) # 读取文件内容 raw_bytes = uploaded.read() filename = uploaded.filename or 'untitled' mimetype = uploaded.content_type or 'application/octet-stream' # 文件类型分类 IMG_MIMES = {'image/png', 'image/jpeg', 'image/jpg', 'image/bmp', 'image/gif'} PDF_MIMES = {'application/pdf'} EXCEL_MIMES = { 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', # .xlsx 'application/vnd.ms-excel', # .xls 'text/csv', # .csv 'application/vnd.ms-excel.sheet.macroEnabled.12', # .xlsm } is_image = mimetype in IMG_MIMES is_pdf = mimetype in PDF_MIMES is_excel = mimetype in EXCEL_MIMES # 对于 Excel,也接受通过文件扩展名判断(企微/浏览器可能给错 MIME) if not is_excel and not is_image and not is_pdf: lower_name = filename.lower() if lower_name.endswith(('.xlsx', '.xls', '.csv', '.xlsm')): is_excel = True mimetype = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' if not (is_image or is_pdf or is_excel): return request.make_json_response({ 'success': False, 'error': f'不支持的文件格式({mimetype}),支持:PNG/JPG/BMP/GIF/PDF/Excel/CSV' }) # 限制文件大小 max_size = 20 * 1024 * 1024 if is_excel else 10 * 1024 * 1024 if len(raw_bytes) > max_size: size_mb = len(raw_bytes) / 1024 / 1024 return request.make_json_response({ 'success': False, 'error': f'文件过大({size_mb:.1f}MB),上限 {max_size // 1024 // 1024}MB' }) # 获取当前对话的 Provider 配置 conv_id = kwargs.get('conversation_id') provider = None conversation = None if conv_id: conversation = request.env['ai.conversation'].sudo().browse(int(conv_id)) if conversation.exists(): provider = conversation.provider_id # 查找或创建 AI 聊天附件专用文件夹 Folder = request.env['documents.folder'].sudo() folder = Folder.search([('name', '=', 'AI 聊天附件')], limit=1) if not folder: folder = Folder.create({'name': 'AI 聊天附件'}) # 创建 documents.document document = request.env['documents.document'].sudo().create({ 'name': filename, 'mimetype': mimetype, 'raw': raw_bytes, 'folder_id': folder.id, }) ocr_text = '' ocr_error = '' ocr_diag = {} file_type_code = 'image' # —— Excel 解析 —— if is_excel: file_type_code = 'excel' ocr_diag['parser'] = 'excel_parser' # 创建导入记录 import_record = request.env['ai.import.record'].sudo().create({ 'document_id': document.id, 'filename': filename, 'file_type': 'excel', 'user_id': request.env.uid, 'ocr_status': 'pending', }) if conversation: import_record.write({'conversation_id': conversation.id}) from ..utils.excel_parser import parse_excel_to_markdown, MAX_ROWS ocr_diag['max_rows'] = MAX_ROWS markdown_text, meta = parse_excel_to_markdown(raw_bytes, filename) ocr_diag['meta'] = meta if meta.get('error'): ocr_error = meta['error'] import_record.write({'ocr_status': 'failed', 'ocr_error': ocr_error}) else: ocr_text = markdown_text document.sudo().write({'ai_ocr_content': ocr_text}) ocr_status = 'success' if not meta.get('truncated') else 'success' import_record.write({ 'ocr_status': ocr_status, 'ocr_content': ocr_text, 'ocr_error': '' }) # 查找匹配的导入规则 rule = request.env['ai.import.rule'].sudo().find_matching_rule( filename, 'excel', mimetype ) if rule: import_record.write({'import_rule_id': rule.id}) ocr_diag['matched_rule'] = rule.name _logger.info('[Excel-Upload] 匹配导入规则: %s', rule.name) # —— 图片/PDF OCR —— elif is_image or is_pdf: file_type_code = 'image' if is_image else 'pdf' if provider and provider.ocr_enabled: from ..models.ocr_provider import get_ocr_instance ocr, init_err = get_ocr_instance(provider) ocr_diag['provider'] = provider.name ocr_diag['ocr_enabled'] = True if ocr is None: ocr_error = init_err or 'OCR 实例创建失败' ocr_diag['init_error'] = ocr_error else: ocr_diag['init_ok'] = True result = ocr.recognize_general(raw_bytes, mimetype) ocr_diag['method'] = 'GeneralBasicOCR' ocr_diag['success'] = result.get('success') if result.get('success'): ocr_text = result.get('text') or '' ocr_diag['text_len'] = len(ocr_text) if ocr_text: document.sudo().write({'ai_ocr_content': ocr_text}) else: ocr_error = '图片中未识别到文字' else: ocr_error = result.get('error', 'OCR 调用失败') else: ocr_diag['ocr_enabled'] = False ocr_diag['reason'] = 'OCR 未启用' if provider else '未找到 AI 服务商' return request.make_json_response({ 'success': True, 'attachment_id': document.id, 'filename': filename, 'mimetype': mimetype, 'ocr_text': ocr_text, 'ocr_error': ocr_error, 'file_type': file_type_code, '_ocr_diag': ocr_diag, }) @http.route('/ai/rule/test_guide', type='json', auth='user', methods=['POST']) def test_active_guide(self, rule_id): """测试主动感知规则:创建引导消息,返回 conversation_id 供前端打开聊天""" err = self._check_ai_access() if err: return err rule = request.env['ai.rule'].browse(rule_id) if not rule.exists(): return {'error': '规则不存在'} try: conv_id = rule._do_test_guide() return {'success': True, 'conversation_id': conv_id} except Exception as e: _logger.exception('Test guide failed') return {'error': str(e)}