Files
gzth/yuthon_ai_agent/controllers/main.py
T
2026-07-02 11:10:20 +08:00

394 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- 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)}