diff --git a/yuthon_ai_agent/__manifest__.py b/yuthon_ai_agent/__manifest__.py index ea6d7d31b..aad4678b5 100644 --- a/yuthon_ai_agent/__manifest__.py +++ b/yuthon_ai_agent/__manifest__.py @@ -26,6 +26,7 @@ 'views/ai_user_profile_views.xml', 'views/ai_token_usage_views.xml', 'views/ai_conversation_views.xml', + 'views/ai_import_record_views.xml', 'data/demo_provider.xml', 'data/demo_rule.xml', 'data/demo_manual.xml', diff --git a/yuthon_ai_agent/controllers/main.py b/yuthon_ai_agent/controllers/main.py index da3e9d421..4bc97a817 100644 --- a/yuthon_ai_agent/controllers/main.py +++ b/yuthon_ai_agent/controllers/main.py @@ -187,10 +187,11 @@ class AiChatController(http.Controller): @http.route('/ai/attachment/upload', type='http', auth='user', methods=['POST'], csrf=False) def attachment_upload(self, **kwargs): - """上传聊天附件(图片/PDF),调 OCR 提取文字并返回结果。 + """上传聊天附件(图片/PDF/Excel),调 OCR 或 Excel 解析器提取文字并返回结果。 请求:multipart/form-data,字段名 'file' - 返回:JSON { success, attachment_id, filename, mimetype, ocr_text, error } + 可能携带 conversation_id 用于关联对话 + 返回:JSON { success, attachment_id, filename, mimetype, ocr_text, error, meta } """ err = self._check_ai_access() if err: @@ -205,30 +206,50 @@ class AiChatController(http.Controller): filename = uploaded.filename or 'untitled' mimetype = uploaded.content_type or 'application/octet-stream' - # 限制文件类型 - allowed = {'image/png', 'image/jpeg', 'image/jpg', 'image/bmp', - 'image/gif', 'application/pdf'} - if mimetype not in allowed: + # 文件类型分类 + 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' + 'error': f'不支持的文件格式({mimetype}),支持:PNG/JPG/BMP/GIF/PDF/Excel/CSV' }) - # 限制文件大小 (10MB) - max_size = 10 * 1024 * 1024 + # 限制文件大小 + 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'文件过大({len(raw_bytes) / 1024 / 1024:.1f}MB),上限 10MB' + 'error': f'文件过大({size_mb:.1f}MB),上限 {max_size // 1024 // 1024}MB' }) - # 获取当前对话的 Provider OCR 配置 + # 获取当前对话的 Provider 配置 conv_id = kwargs.get('conversation_id') provider = None + conversation = None if conv_id: - conv = request.env['ai.conversation'].sudo().browse(int(conv_id)) - if conv.exists(): - provider = conv.provider_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() @@ -236,7 +257,7 @@ class AiChatController(http.Controller): if not folder: folder = Folder.create({'name': 'AI 聊天附件'}) - # 创建 documents.document(raw 字段接受原始二进制,内部通过 attachment_id 存储) + # 创建 documents.document document = request.env['documents.document'].sudo().create({ 'name': filename, 'mimetype': mimetype, @@ -244,45 +265,79 @@ class AiChatController(http.Controller): 'folder_id': folder.id, }) - # OCR 提取文字 ocr_text = '' ocr_error = '' - ocr_diag = {} # 诊断信息 - 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 实例创建失败' - _logger.error('[AI-OCR] get_ocr_instance 失败: %s', ocr_error) - ocr_diag['init_error'] = 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_diag['init_ok'] = True - # 直接用通用文字识别(不先走票据识别,避免不必要的 API 调用) - _logger.info('[AI-OCR] 开始识别 %s (%d bytes, %s)', filename, len(raw_bytes), mimetype) - 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}) - _logger.info('[AI-OCR] ✅ 识别成功,%d 字符: %s', len(ocr_text), ocr_text[:200]) - else: - ocr_error = '图片中未识别到文字' - _logger.info('[AI-OCR] ⚠️ 识别完成但无文字 — 图片中可能确实没有印刷文字') + 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_error = result.get('error', 'OCR 调用失败') - ocr_diag['error'] = ocr_error - _logger.error('[AI-OCR] ❌ %s', ocr_error) - else: - ocr_diag['ocr_enabled'] = False - if not provider: - ocr_diag['reason'] = '未找到 AI 服务商(conversation_id 可能无效)' + 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['reason'] = 'OCR 未启用' + ocr_diag['ocr_enabled'] = False + ocr_diag['reason'] = 'OCR 未启用' if provider else '未找到 AI 服务商' return request.make_json_response({ 'success': True, @@ -291,5 +346,6 @@ class AiChatController(http.Controller): 'mimetype': mimetype, 'ocr_text': ocr_text, 'ocr_error': ocr_error, - '_ocr_diag': ocr_diag, # 前端 console 可查看 + 'file_type': file_type_code, + '_ocr_diag': ocr_diag, }) diff --git a/yuthon_ai_agent/models/__init__.py b/yuthon_ai_agent/models/__init__.py index 7ad89d0e4..319878262 100644 --- a/yuthon_ai_agent/models/__init__.py +++ b/yuthon_ai_agent/models/__init__.py @@ -9,3 +9,4 @@ from . import ai_report from . import ai_report_template from . import ai_report_template_config from . import ocr_provider +from . import ai_import_record diff --git a/yuthon_ai_agent/security/ir.model.access.csv b/yuthon_ai_agent/security/ir.model.access.csv index 9ba8e3f2c..639cfe499 100644 --- a/yuthon_ai_agent/security/ir.model.access.csv +++ b/yuthon_ai_agent/security/ir.model.access.csv @@ -31,3 +31,7 @@ access_ai_report_template_domain_line_user,查询条件行-用户只读,model_ai access_ai_report_template_domain_line_admin,查询条件行-管理员,model_ai_report_template_domain_line,yuthon_ai_agent.group_ai_agent_admin,1,1,1,1 access_ai_report_template_chart_user,图表配置-用户只读,model_ai_report_template_chart,yuthon_ai_agent.group_ai_agent_user,1,0,0,0 access_ai_report_template_chart_admin,图表配置-管理员,model_ai_report_template_chart,yuthon_ai_agent.group_ai_agent_admin,1,1,1,1 +access_ai_import_record_user,附件导入记录-用户,model_ai_import_record,yuthon_ai_agent.group_ai_agent_user,1,0,1,0 +access_ai_import_record_admin,附件导入记录-管理员,model_ai_import_record,yuthon_ai_agent.group_ai_agent_admin,1,1,1,1 +access_ai_import_rule_admin,导入规则-管理员,model_ai_import_rule,yuthon_ai_agent.group_ai_agent_admin,1,1,1,1 +access_ai_import_rule_user,导入规则-用户只读,model_ai_import_rule,yuthon_ai_agent.group_ai_agent_user,1,0,0,0 diff --git a/yuthon_ai_agent/static/src/js/ai_chat_action.js b/yuthon_ai_agent/static/src/js/ai_chat_action.js index 6e629916c..dcbc10550 100644 --- a/yuthon_ai_agent/static/src/js/ai_chat_action.js +++ b/yuthon_ai_agent/static/src/js/ai_chat_action.js @@ -83,14 +83,21 @@ export class AiChatAction extends Component { onFileSelected(ev) { const file = ev.target.files[0]; if (!file) return; - const allowed = ["image/png", "image/jpeg", "image/jpg", "image/bmp", "image/gif", "application/pdf"]; - if (!allowed.includes(file.type)) { - alert("仅支持 PNG、JPG、BMP、GIF、PDF 文件"); + const IMG_TYPES = ["image/png", "image/jpeg", "image/jpg", "image/bmp", "image/gif"]; + const EXCEL_TYPES = ["application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + "application/vnd.ms-excel", "text/csv"]; + const ALLOWED = IMG_TYPES.concat(["application/pdf"], EXCEL_TYPES); + const isExcelByName = file.name && /\\.(xlsx|xls|csv)$/i.test(file.name); + if (!ALLOWED.includes(file.type) && !isExcelByName) { + alert("仅支持 PNG、JPG、BMP、GIF、PDF、Excel(.xlsx/.xls/.csv)文件"); ev.target.value = ""; return; } - if (file.size > 10 * 1024 * 1024) { - alert("文件不能超过 10MB"); + const isExcel = EXCEL_TYPES.includes(file.type) || isExcelByName; + const maxSize = isExcel ? 20 * 1024 * 1024 : 10 * 1024 * 1024; + const sizeLabel = isExcel ? "20MB" : "10MB"; + if (file.size > maxSize) { + alert("文件不能超过 " + sizeLabel); ev.target.value = ""; return; } diff --git a/yuthon_ai_agent/static/src/js/ai_floating.js b/yuthon_ai_agent/static/src/js/ai_floating.js index ac3130207..c1328d1ef 100644 --- a/yuthon_ai_agent/static/src/js/ai_floating.js +++ b/yuthon_ai_agent/static/src/js/ai_floating.js @@ -145,14 +145,21 @@ function triggerFileInput() { function onFileSelected(e) { const file = e.target.files[0]; if (!file) return; - const allowed = ["image/png", "image/jpeg", "image/jpg", "image/bmp", "image/gif", "application/pdf"]; - if (!allowed.includes(file.type)) { - alert("仅支持 PNG、JPG、BMP、GIF、PDF 文件"); + const IMG_TYPES = ["image/png", "image/jpeg", "image/jpg", "image/bmp", "image/gif"]; + const EXCEL_TYPES = ["application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + "application/vnd.ms-excel", "text/csv"]; + const ALLOWED = IMG_TYPES.concat(["application/pdf"], EXCEL_TYPES); + const isExcelByName = file.name && /\\.(xlsx|xls|csv)$/i.test(file.name); + if (!ALLOWED.includes(file.type) && !isExcelByName) { + alert("仅支持 PNG、JPG、BMP、GIF、PDF、Excel(.xlsx/.xls/.csv)文件"); e.target.value = ""; return; } - if (file.size > 10 * 1024 * 1024) { - alert("文件不能超过 10MB"); + const isExcel = EXCEL_TYPES.includes(file.type) || isExcelByName; + const maxSize = isExcel ? 20 * 1024 * 1024 : 10 * 1024 * 1024; + const sizeLabel = isExcel ? "20MB" : "10MB"; + if (file.size > maxSize) { + alert("文件不能超过 " + sizeLabel); e.target.value = ""; return; } @@ -345,11 +352,11 @@ function renderChat() {
- diff --git a/yuthon_ai_agent/static/src/xml/ai_chat_template.xml b/yuthon_ai_agent/static/src/xml/ai_chat_template.xml index 331b86013..9edd7edb4 100644 --- a/yuthon_ai_agent/static/src/xml/ai_chat_template.xml +++ b/yuthon_ai_agent/static/src/xml/ai_chat_template.xml @@ -82,11 +82,11 @@