241 lines
9.2 KiB
Python
241 lines
9.2 KiB
Python
# -*- coding: utf-8 -*-
|
||
import json
|
||
import base64
|
||
|
||
from odoo import http
|
||
from odoo.http import request
|
||
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
|
||
last_msg = conv.message_ids.filtered(lambda m: m.role == 'assistant')[-1:]
|
||
return {
|
||
'content': reply,
|
||
'message_id': last_msg.id if last_msg else False,
|
||
}
|
||
|
||
@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 = ''
|
||
messages.append({
|
||
'id': msg.id,
|
||
'role': msg.role,
|
||
'content': msg.content,
|
||
'date': time_str,
|
||
})
|
||
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/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),调 OCR 提取文字并返回结果。
|
||
|
||
请求:multipart/form-data,字段名 'file'
|
||
返回:JSON { success, attachment_id, filename, mimetype, ocr_text, error }
|
||
"""
|
||
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'
|
||
|
||
# 限制文件类型
|
||
allowed = {'image/png', 'image/jpeg', 'image/jpg', 'image/bmp',
|
||
'image/gif', 'application/pdf'}
|
||
if mimetype not in allowed:
|
||
return request.make_json_response({
|
||
'success': False,
|
||
'error': f'不支持的文件格式:{mimetype},仅支持 PNG/JPG/BMP/GIF/PDF'
|
||
})
|
||
|
||
# 限制文件大小 (10MB)
|
||
max_size = 10 * 1024 * 1024
|
||
if len(raw_bytes) > max_size:
|
||
return request.make_json_response({
|
||
'success': False,
|
||
'error': f'文件过大({len(raw_bytes) / 1024 / 1024:.1f}MB),上限 10MB'
|
||
})
|
||
|
||
# 获取当前对话的 Provider OCR 配置
|
||
conv_id = kwargs.get('conversation_id')
|
||
provider = None
|
||
if conv_id:
|
||
conv = request.env['ai.conversation'].sudo().browse(int(conv_id))
|
||
if conv.exists():
|
||
provider = conv.provider_id
|
||
|
||
# 创建 ir.attachment
|
||
attachment = request.env['ir.attachment'].sudo().create({
|
||
'name': filename,
|
||
'datas': base64.b64encode(raw_bytes).decode('utf-8'),
|
||
'mimetype': mimetype,
|
||
'res_model': 'ai.message',
|
||
'res_id': 0,
|
||
})
|
||
|
||
# OCR 提取文字
|
||
ocr_text = ''
|
||
if provider and provider.ocr_enabled:
|
||
from ..models.ocr_provider import get_ocr_instance
|
||
ocr = get_ocr_instance(provider)
|
||
if ocr:
|
||
result = ocr.smart_recognize(raw_bytes, mimetype)
|
||
if result.get('success'):
|
||
ocr_text = result.get('text') or ''
|
||
attachment.sudo().write({'ai_ocr_content': ocr_text})
|
||
else:
|
||
# OCR 失败不阻塞上传,前端仍可发送(只是没有文字提取)
|
||
pass
|
||
|
||
return request.make_json_response({
|
||
'success': True,
|
||
'attachment_id': attachment.id,
|
||
'filename': filename,
|
||
'mimetype': mimetype,
|
||
'ocr_text': ocr_text,
|
||
})
|