394 lines
16 KiB
Python
394 lines
16 KiB
Python
# -*- 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)}
|