diff --git a/yuthon_ai_agent/controllers/main.py b/yuthon_ai_agent/controllers/main.py index 651094ff2..0f510dcda 100644 --- a/yuthon_ai_agent/controllers/main.py +++ b/yuthon_ai_agent/controllers/main.py @@ -1,5 +1,6 @@ # -*- coding: utf-8 -*- import json +import base64 from odoo import http from odoo.http import request @@ -16,15 +17,15 @@ class AiChatController(http.Controller): return None @http.route('/ai/chat/send', type='json', auth='user', methods=['POST'], csrf=False) - def chat_send(self, conversation_id, message): - """发送消息""" + 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) + 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:] @@ -161,3 +162,79 @@ class AiChatController(http.Controller): '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, + }) diff --git a/yuthon_ai_agent/models/__init__.py b/yuthon_ai_agent/models/__init__.py index a6fa32b41..7ad89d0e4 100644 --- a/yuthon_ai_agent/models/__init__.py +++ b/yuthon_ai_agent/models/__init__.py @@ -1,5 +1,6 @@ # -*- coding: utf-8 -*- from . import ai_agent +from . import ai_attachment from . import ai_user_profile from . import ai_token_usage from . import ai_rule @@ -7,3 +8,4 @@ from . import ai_manual from . import ai_report from . import ai_report_template from . import ai_report_template_config +from . import ocr_provider diff --git a/yuthon_ai_agent/models/ai_agent.py b/yuthon_ai_agent/models/ai_agent.py index 7115255c9..981efe8fd 100644 --- a/yuthon_ai_agent/models/ai_agent.py +++ b/yuthon_ai_agent/models/ai_agent.py @@ -63,6 +63,26 @@ class AiProvider(models.Model): help='允许 AI 调用 create/write/unlink 工具。建议仅在受信任环境开启。' ) + # OCR 配置 + ocr_enabled = fields.Boolean( + string='启用 OCR', + default=False, + help='开启后聊天支持上传图片/PDF,自动调用腾讯云 OCR 提取文字后再发给 AI' + ) + ocr_secret_id = fields.Char( + string='OCR SecretId', + help='腾讯云 API 密钥 SecretId,在 https://console.cloud.tencent.com/cam/capi 获取' + ) + ocr_secret_key = fields.Char( + string='OCR SecretKey', + help='腾讯云 API 密钥 SecretKey' + ) + ocr_region = fields.Char( + string='OCR 地域', + default='ap-guangzhou', + help='腾讯云 OCR 服务地域,如 ap-guangzhou, ap-shanghai, ap-beijing' + ) + # 规则配置 rule_ids = fields.Many2many( 'ai.rule', 'ai_rule_provider_rel', 'provider_id', 'rule_id', @@ -729,8 +749,13 @@ class AiConversation(models.Model): ) return content - def send_message(self, content): - """发送用户消息并获取 AI 回复(支持工具调用)。""" + def send_message(self, content, attachment_ids=None): + """发送用户消息并获取 AI 回复(支持工具调用)。 + + :param content: 用户输入的文本 + :param attachment_ids: 可选,附件 ID 列表。附件会先经 OCR 提取文字, + 拼入消息首部再发送给 AI。 + """ self.ensure_one() # 权限校验 if not self.env.user.has_group('yuthon_ai_agent.group_ai_agent_user'): @@ -740,12 +765,38 @@ class AiConversation(models.Model): if not provider or not provider.active: return {'error': 'AI 服务未配置或已停用。'} + # —— 附件 OCR 预处理 —— + ocr_result = '' + msg_attachment_ids = [] + if attachment_ids: + attachments = self.env['ir.attachment'].sudo().browse(attachment_ids).exists() + if attachments: + msg_attachment_ids = [(4, a.id) for a in attachments] + ocr_parts = [] + for att in attachments: + att_text = att.ai_ocr_content or '' + if att_text: + ocr_parts.append( + f'【附件:{att.name}】\n' + f'{att_text}\n' + f'--- 附件内容结束 ---' + ) + if ocr_parts: + ocr_result = '\n\n'.join(ocr_parts) + '\n\n' + + # 构建最终发送给 AI 的用户消息内容 + full_content = ocr_result + content if ocr_result else content + # 1. 保存用户消息 - self.env['ai.message'].create({ + msg_vals = { 'conversation_id': self.id, 'role': 'user', - 'content': content, - }) + 'content': full_content, + } + if msg_attachment_ids: + msg_vals['attachment_ids'] = msg_attachment_ids + msg_vals['ocr_content'] = ocr_result + self.env['ai.message'].create(msg_vals) # 2. 构建发送给模型的 messages # 获取用户画像 @@ -913,4 +964,12 @@ class AiMessage(models.Model): ('system', '系统'), ], string='角色', required=True) content = fields.Text(string='内容', required=True) + attachment_ids = fields.Many2many( + 'ir.attachment', 'ai_message_attachment_rel', + 'message_id', 'attachment_id', string='附件' + ) + ocr_content = fields.Text( + string='OCR 识别结果', + help='附件图片/PDF 经 OCR 提取的文本内容' + ) create_uid = fields.Many2one('res.users', string='发送者', default=lambda self: self.env.uid) diff --git a/yuthon_ai_agent/static/src/css/ai_chat.css b/yuthon_ai_agent/static/src/css/ai_chat.css index fc8d946da..cd3ef91df 100644 --- a/yuthon_ai_agent/static/src/css/ai_chat.css +++ b/yuthon_ai_agent/static/src/css/ai_chat.css @@ -74,6 +74,45 @@ font-size: 13px; } +/* 附件上传按钮 */ +.ai-chat-attach-btn { + flex-shrink: 0; +} + +/* 附件预览区 */ +.ai-chat-attach-preview { + flex-shrink: 0; + display: flex; + flex-wrap: wrap; + gap: 6px; + background: #f8f9fa; +} +.ai-chat-attach-item { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 3px 10px; + background: #fff; + border: 1px solid #dee2e6; + border-radius: 6px; + font-size: 12px; + color: #495057; +} +.ai-chat-attach-remove { + background: none; + border: none; + color: #adb5bd; + cursor: pointer; + padding: 0 2px; + font-size: 10px; + line-height: 1; + border-radius: 3px; +} +.ai-chat-attach-remove:hover { + color: #dc3545; + background: #f8d7da; +} + @keyframes fadeIn { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } diff --git a/yuthon_ai_agent/static/src/css/ai_floating.css b/yuthon_ai_agent/static/src/css/ai_floating.css index 4857c61cc..edf98a742 100644 --- a/yuthon_ai_agent/static/src/css/ai_floating.css +++ b/yuthon_ai_agent/static/src/css/ai_floating.css @@ -246,3 +246,72 @@ background: #d1d5db; cursor: not-allowed; } + +/* 附件上传按钮 */ +.o_ai_attach_btn { + background: none; + border: 1px solid #d1d5db; + border-radius: 8px; + width: 36px; + height: 36px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + color: #6b7280; + transition: all 0.2s; + flex-shrink: 0; +} +.o_ai_attach_btn:hover { + background: #f3f4f6; + border-color: #7c3aed; + color: #7c3aed; +} + +/* 附件预览区 */ +.o_ai_attach_preview { + display: flex; + flex-wrap: wrap; + gap: 6px; + padding: 6px 12px; + background: #f8f7f4; + border-top: 1px solid #e5e7eb; + flex-shrink: 0; +} +.o_ai_attach_item { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 4px 10px; + background: #fff; + border: 1px solid #e5e7eb; + border-radius: 6px; + font-size: 12px; + max-width: 200px; +} +.o_ai_attach_icon { + color: #7c3aed; + font-size: 13px; + flex-shrink: 0; +} +.o_ai_attach_name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: #374151; +} +.o_ai_attach_remove { + background: none; + border: none; + color: #9ca3af; + cursor: pointer; + padding: 0 2px; + font-size: 10px; + border-radius: 3px; + flex-shrink: 0; + line-height: 1; +} +.o_ai_attach_remove:hover { + color: #ef4444; + background: #fef2f2; +} 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 16744ee12..d96394795 100644 --- a/yuthon_ai_agent/static/src/js/ai_chat_action.js +++ b/yuthon_ai_agent/static/src/js/ai_chat_action.js @@ -15,6 +15,7 @@ export class AiChatAction extends Component { inputText: "", loading: false, title: "AI 对话", + attachments: [], // {attachment_id, filename, mimetype} }); const params = this.props.action?.params || this.props.action?.context || {}; this.resId = params.res_id || params.default_res_id || 0; @@ -46,24 +47,108 @@ export class AiChatAction extends Component { } } + // ---- 文件上传 ---- + + triggerFileInput() { + const el = document.getElementById("ai_chat_file_input"); + if (el) el.click(); + } + + 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 文件"); + ev.target.value = ""; + return; + } + if (file.size > 10 * 1024 * 1024) { + alert("文件不能超过 10MB"); + ev.target.value = ""; + return; + } + this._uploadFile(file); + ev.target.value = ""; + } + + onPaste(ev) { + const items = (ev.clipboardData || ev.originalEvent.clipboardData).items; + for (const item of items) { + if (item.kind === "file" && item.type.startsWith("image/")) { + ev.preventDefault(); + this._uploadFile(item.getAsFile()); + return; + } + } + } + + async _uploadFile(file) { + const formData = new FormData(); + formData.append("file", file); + if (this.resId) { + formData.append("conversation_id", this.resId); + } + try { + const resp = await fetch("/ai/attachment/upload", { + method: "POST", + body: formData, + }); + const data = await resp.json(); + if (data.success) { + this.state.attachments = [ + ...this.state.attachments, + { + attachment_id: data.attachment_id, + filename: data.filename, + mimetype: data.mimetype, + }, + ]; + } else { + alert(data.error || "上传失败"); + } + } catch (e) { + console.error("[AI] Upload failed:", e); + alert("上传失败,请检查网络"); + } + } + + removeAttachment(idx) { + this.state.attachments = this.state.attachments.filter((_, i) => i !== idx); + } + + // ---- 发送消息 ---- + async sendMessage() { const text = this.state.inputText.trim(); - if (!text) return; + const hasAttachments = this.state.attachments.length > 0; + if (!text && !hasAttachments) return; if (!this.resId) return; - this.state.inputText = ""; + // 收集附件 ID + const attIds = this.state.attachments.map(a => a.attachment_id); + // 构建显示文本 + let displayText = text || ""; + if (hasAttachments) { + const fileNames = this.state.attachments.map(a => a.filename).join("、"); + displayText = text ? "[附件: " + fileNames + "]\n" + text : "[附件: " + fileNames + "]"; + } + + this.state.inputText = ""; + this.state.attachments = []; this.state.loading = true; this.state.messages = [ ...this.state.messages, - { role: "user", content: text, date: "刚刚" }, + { role: "user", content: displayText, date: "刚刚" }, ]; try { - const res = await this.rpc("/ai/chat/send", { - conversation_id: this.resId, - message: text, - }); + const params = { conversation_id: this.resId, message: displayText }; + if (attIds.length > 0) { + params.attachment_ids = attIds; + } + const res = await this.rpc("/ai/chat/send", params); if (res.error) { this.state.messages = [ ...this.state.messages, @@ -95,6 +180,7 @@ export class AiChatAction extends Component { if (!confirm("确定清空所有消息?")) return; this.rpc("/ai/chat/clear", { conversation_id: this.resId }); this.state.messages = []; + this.state.attachments = []; } } diff --git a/yuthon_ai_agent/static/src/js/ai_floating.js b/yuthon_ai_agent/static/src/js/ai_floating.js index 102a83ab1..219163822 100644 --- a/yuthon_ai_agent/static/src/js/ai_floating.js +++ b/yuthon_ai_agent/static/src/js/ai_floating.js @@ -7,8 +7,8 @@ import { onMounted } from "@odoo/owl"; /** * AI 浮动聊天窗口 — 右下角弹出式聊天面板 - * 支持对话切换、新建对话 - * Odoo 17 版本 — 使用 jsonrpc + * 支持对话切换、新建对话、附件上传(图片/PDF → OCR 识别) + * Odoo 17 版本 — 使用 jsonrpc + fetch (文件上传) */ let convId = null; @@ -17,6 +17,7 @@ let chatEl = null; let isVisible = false; let isExpanded = false; let showConvList = false; +let pendingAttachments = []; // {attachment_id, filename, mimetype} 待发送附件 function rpcCall(url, params) { return jsonrpc(url, params || {}); @@ -64,6 +65,7 @@ async function createNewConv() { convId = res.id; convName = res.name || "新对话"; showConvList = false; + pendingAttachments = []; renderChat(); } } catch (e) { @@ -75,9 +77,110 @@ async function switchConv(id, name) { convId = id; convName = name || "对话"; showConvList = false; + pendingAttachments = []; renderChat(); } +// ---- 文件上传 ---- + +async function uploadFile(file) { + const formData = new FormData(); + formData.append("file", file); + if (convId) { + formData.append("conversation_id", convId); + } + try { + const resp = await fetch("/ai/attachment/upload", { + method: "POST", + body: formData, + }); + const data = await resp.json(); + if (data.success) { + pendingAttachments.push({ + attachment_id: data.attachment_id, + filename: data.filename, + mimetype: data.mimetype, + ocr_text: data.ocr_text || "", + }); + renderAttachmentPreview(); + } else { + alert(data.error || "上传失败"); + } + } catch (e) { + console.error("[AI] Upload failed:", e); + alert("上传失败,请检查网络"); + } +} + +function triggerFileInput() { + const input = document.getElementById("yuthon_file_input"); + if (input) input.click(); +} + +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 文件"); + e.target.value = ""; + return; + } + if (file.size > 10 * 1024 * 1024) { + alert("文件不能超过 10MB"); + e.target.value = ""; + return; + } + uploadFile(file); + e.target.value = ""; +} + +function onChatPaste(e) { + const items = (e.clipboardData || e.originalEvent.clipboardData).items; + for (const item of items) { + if (item.kind === "file" && item.type.startsWith("image/")) { + e.preventDefault(); + const file = item.getAsFile(); + uploadFile(file); + return; + } + } +} + +function renderAttachmentPreview() { + const container = document.getElementById("yuthon_attach_preview"); + if (!container) return; + if (pendingAttachments.length === 0) { + container.innerHTML = ""; + container.style.display = "none"; + return; + } + container.style.display = "flex"; + container.innerHTML = pendingAttachments.map((att, idx) => { + const isImage = att.mimetype.startsWith("image/"); + const icon = isImage ? "fa-file-image-o" : "fa-file-pdf-o"; + return ` +
+ + ${escapeHtml(att.filename)} + +
+ `; + }).join(""); + container.querySelectorAll(".o_ai_attach_remove").forEach(btn => { + btn.addEventListener("click", (e) => { + e.stopPropagation(); + const idx = parseInt(btn.dataset.idx); + pendingAttachments.splice(idx, 1); + renderAttachmentPreview(); + }); + }); +} + +// ---- 渲染 ---- + function escapeHtml(text) { const div = document.createElement("div"); div.textContent = text; @@ -133,9 +236,17 @@ function renderChat() { `).join('')} +
+
+ + + placeholder="输入消息... 支持粘贴图片" rows="1"> @@ -150,9 +261,19 @@ function renderChat() { document.getElementById("yuthon_chat_send")?.addEventListener("click", sendMsg); document.getElementById("yuthon_conv_switch")?.addEventListener("click", toggleConvList); document.getElementById("yuthon_conv_new")?.addEventListener("click", createNewConv); - document.getElementById("yuthon_chat_input")?.addEventListener("keydown", e => { - if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); sendMsg(); } - }); + document.getElementById("yuthon_attach_btn")?.addEventListener("click", triggerFileInput); + document.getElementById("yuthon_file_input")?.addEventListener("change", onFileSelected); + + const inputEl = document.getElementById("yuthon_chat_input"); + if (inputEl) { + inputEl.addEventListener("keydown", e => { + if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); sendMsg(); } + }); + inputEl.addEventListener("paste", onChatPaste); + } + + // 恢复附件预览 + renderAttachmentPreview(); // 滚动到底部 const msgsEl = document.getElementById("yuthon_chat_msgs"); @@ -197,6 +318,7 @@ async function loadConvListUI() { if (delId === convId) { convId = null; convName = ""; + pendingAttachments = []; } loadConvListUI(); } catch (err) { @@ -214,17 +336,28 @@ function toggleConvList() { async function sendMsg() { const input = document.getElementById("yuthon_chat_input"); const text = input?.value.trim(); - if (!text) return; + const hasAttachments = pendingAttachments.length > 0; + if (!text && !hasAttachments) return; input.value = ""; const id = await ensureConv(); if (!id) return; + // 收集附件 ID 列表 + const attIds = pendingAttachments.map(a => a.attachment_id); + + // 构建显示文本(用户消息气泡) + let displayText = text || ""; + if (hasAttachments) { + const fileNames = pendingAttachments.map(a => a.filename).join("、"); + displayText = (text ? "[附件: " + fileNames + "]\n" + text : "[附件: " + fileNames + "]"); + } + const msgsEl = document.getElementById("yuthon_chat_msgs"); if (msgsEl) { msgsEl.innerHTML += `
-
${escapeHtml(text)}
+
${escapeHtml(displayText)}
刚刚
@@ -234,6 +367,10 @@ async function sendMsg() { msgsEl.scrollTop = msgsEl.scrollHeight; } + // 清空附件列表 + pendingAttachments = []; + renderAttachmentPreview(); + const loadingTimer = setTimeout(() => { const el = document.getElementById("yuthon_loading"); if (el) { @@ -241,11 +378,14 @@ async function sendMsg() { } }, 5000); + // 构建请求参数 + const params = { conversation_id: id, message: displayText }; + if (attIds.length > 0) { + params.attachment_ids = attIds; + } + try { - const res = await rpcCall("/ai/chat/send", { - conversation_id: id, - message: text, - }); + const res = await rpcCall("/ai/chat/send", params); clearTimeout(loadingTimer); const content = res.error || res.content || "无响应"; document.getElementById("yuthon_loading")?.remove(); 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 db4cf47c6..07d18e1d3 100644 --- a/yuthon_ai_agent/static/src/xml/ai_chat_template.xml +++ b/yuthon_ai_agent/static/src/xml/ai_chat_template.xml @@ -24,12 +24,39 @@
+ +
+ + + + + + + + + + + + +
+
+ +