diff --git a/yuthon_ai_agent/controllers/main.py b/yuthon_ai_agent/controllers/main.py index 9df771761..237717d71 100644 --- a/yuthon_ai_agent/controllers/main.py +++ b/yuthon_ai_agent/controllers/main.py @@ -1,8 +1,11 @@ # -*- 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 @@ -27,10 +30,21 @@ class AiChatController(http.Controller): reply = conv.send_message(message, attachment_ids=attachment_ids) if isinstance(reply, dict) and 'error' in reply: return reply + # 获取刚创建的用户消息(附件信息) + 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:] return { 'content': reply, 'message_id': last_msg.id if last_msg else False, + 'attachments': user_attachments, } @http.route('/ai/chat/history', type='json', auth='user') @@ -51,11 +65,20 @@ class AiChatController(http.Controller): 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, }) return {'messages': messages} @@ -223,17 +246,26 @@ class AiChatController(http.Controller): # OCR 提取文字 ocr_text = '' + ocr_error = '' if provider and provider.ocr_enabled: from ..models.ocr_provider import get_ocr_instance ocr = get_ocr_instance(provider) - if ocr: + if ocr is None: + ocr_error = 'OCR 实例创建失败,请检查 SecretId/SecretKey 是否完整' + _logger.error('[AI-OCR] get_ocr_instance 返回 None') + else: result = ocr.smart_recognize(raw_bytes, mimetype) if result.get('success'): ocr_text = result.get('text') or '' - document.sudo().write({'ai_ocr_content': ocr_text}) + if ocr_text: + document.sudo().write({'ai_ocr_content': ocr_text}) + _logger.info('[AI-OCR] 识别成功,%d 字符', len(ocr_text)) + else: + ocr_error = '图片中未识别到文字' + _logger.info('[AI-OCR] 识别完成但无文字') else: - # OCR 失败不阻塞上传,前端仍可发送(只是没有文字提取) - pass + ocr_error = result.get('error', 'OCR 调用失败') + _logger.error('[AI-OCR] %s', ocr_error) return request.make_json_response({ 'success': True, @@ -241,4 +273,5 @@ class AiChatController(http.Controller): 'filename': filename, 'mimetype': mimetype, 'ocr_text': ocr_text, + 'ocr_error': ocr_error, }) diff --git a/yuthon_ai_agent/models/ai_agent.py b/yuthon_ai_agent/models/ai_agent.py index f1030a74f..f30a35e1a 100644 --- a/yuthon_ai_agent/models/ai_agent.py +++ b/yuthon_ai_agent/models/ai_agent.py @@ -787,11 +787,12 @@ class AiConversation(models.Model): # 构建最终发送给 AI 的用户消息内容 full_content = ocr_result + content if ocr_result else content - # 1. 保存用户消息 + # 1. 保存用户消息(content 存原始用户文本,ocr_content 存 OCR 结果, + # full_content 带 OCR 的版本发给 AI,前端通过 attachments 字段渲染附件预览) msg_vals = { 'conversation_id': self.id, 'role': 'user', - 'content': full_content, + 'content': content, # 存干净的用户原文,不含 OCR 前缀 } if msg_attachment_ids: msg_vals['attachment_ids'] = msg_attachment_ids diff --git a/yuthon_ai_agent/static/src/css/ai_chat.css b/yuthon_ai_agent/static/src/css/ai_chat.css index cd3ef91df..8a8767046 100644 --- a/yuthon_ai_agent/static/src/css/ai_chat.css +++ b/yuthon_ai_agent/static/src/css/ai_chat.css @@ -113,6 +113,139 @@ background: #f8d7da; } +/* === URL 链接卡片 === */ +.ai-chat-msg .o_ai_link_card, +.ai-chat-msg-user .o_ai_link_card { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 4px 10px; + margin: 2px 0; + border-radius: 8px; + text-decoration: none !important; + transition: all 0.2s; + max-width: 100%; + overflow: hidden; +} +.ai-chat-msg.assistant .o_ai_link_card { + background: #f0f4ff; + border: 1px solid #d0d8f0; + color: #1d4ed8 !important; +} +.ai-chat-msg.assistant .o_ai_link_card:hover { + background: #e0e8ff; + border-color: #3b82f6; +} +.ai-chat-msg.user .o_ai_link_card { + background: rgba(255,255,255,0.15); + border: 1px solid rgba(255,255,255,0.25); + color: inherit !important; +} +.ai-chat-msg.user .o_ai_link_card:hover { + background: rgba(255,255,255,0.25); +} +.o_ai_link_icon { + flex-shrink: 0; + font-size: 11px; + opacity: 0.7; +} +.o_ai_link_domain { + font-weight: 600; + font-size: 12px; + white-space: nowrap; +} +.o_ai_link_url { + font-size: 11px; + opacity: 0.7; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* === 消息中附件缩略图 === */ +.ai-chat-msg-attachments { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-top: 6px; + padding: 0 4px; +} +.ai-chat-msg-attachments.justify-content-end { + justify-content: flex-end; +} +.ai-chat-msg-img-wrap { + display: flex; + flex-direction: column; + align-items: center; + width: 140px; + border-radius: 10px; + overflow: hidden; + background: #fff; + border: 1px solid #dee2e6; + text-decoration: none !important; + transition: all 0.2s; + box-shadow: 0 1px 3px rgba(0,0,0,0.06); +} +.ai-chat-msg-img-wrap:hover { + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(0,0,0,0.12); + border-color: #007bff; +} +.ai-chat-msg-thumb { + width: 140px; + height: 140px; + object-fit: cover; + display: block; +} +.ai-chat-msg-img-name { + padding: 4px 8px; + font-size: 11px; + color: #6c757d; + text-align: center; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 100%; + box-sizing: border-box; +} +.ai-chat-msg-file-card { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 6px 12px; + border-radius: 8px; + background: #fff; + border: 1px solid #dee2e6; + text-decoration: none !important; + color: #495057 !important; + font-size: 12px; + transition: all 0.2s; + max-width: 220px; +} +.ai-chat-msg-file-card:hover { + border-color: #007bff; + background: #e8f0fe; +} +.ai-chat-msg-file-icon { + color: #dc3545; + font-size: 16px; + flex-shrink: 0; +} +.ai-chat-msg-file-name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + flex: 1; +} +.ai-chat-msg-file-dl { + color: #adb5bd; + font-size: 12px; + flex-shrink: 0; +} +.ai-chat-msg-file-card:hover .ai-chat-msg-file-dl { + color: #007bff; +} + @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 edf98a742..fb3019cec 100644 --- a/yuthon_ai_agent/static/src/css/ai_floating.css +++ b/yuthon_ai_agent/static/src/css/ai_floating.css @@ -315,3 +315,135 @@ color: #ef4444; background: #fef2f2; } + +/* === URL 链接卡片 === */ +.o_ai_link_card { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 4px 10px; + margin: 2px 0; + border-radius: 8px; + background: rgba(255,255,255,0.12); + border: 1px solid rgba(255,255,255,0.2); + text-decoration: none !important; + color: inherit !important; + transition: all 0.2s; + max-width: 100%; + overflow: hidden; +} +.o_ai_msg_ai .o_ai_link_card { + background: #f0f4ff; + border: 1px solid #d0d8f0; + color: #1d4ed8 !important; +} +.o_ai_msg_ai .o_ai_link_card:hover { + background: #e0e8ff; + border-color: #3b82f6; +} +.o_ai_msg_user .o_ai_link_card:hover { + background: rgba(255,255,255,0.22); + border-color: rgba(255,255,255,0.4); +} +.o_ai_link_icon { + flex-shrink: 0; + font-size: 11px; + opacity: 0.7; +} +.o_ai_link_domain { + font-weight: 600; + font-size: 12px; + white-space: nowrap; +} +.o_ai_link_url { + font-size: 11px; + opacity: 0.7; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* === 消息中的附件缩略图 === */ +.o_ai_msg_attachments { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-top: 6px; + padding: 0 4px; +} +.o_ai_msg_user .o_ai_msg_attachments { + justify-content: flex-end; +} +.o_ai_msg_img_wrap { + display: flex; + flex-direction: column; + align-items: center; + width: 140px; + border-radius: 10px; + overflow: hidden; + background: #fff; + border: 1px solid #e5e7eb; + text-decoration: none !important; + transition: all 0.2s; + box-shadow: 0 1px 3px rgba(0,0,0,0.06); +} +.o_ai_msg_img_wrap:hover { + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(0,0,0,0.12); + border-color: #7c3aed; +} +.o_ai_msg_thumb { + width: 140px; + height: 140px; + object-fit: cover; + display: block; +} +.o_ai_msg_img_name { + padding: 4px 8px; + font-size: 11px; + color: #6b7280; + text-align: center; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 100%; + box-sizing: border-box; +} +/* 文件卡片 */ +.o_ai_msg_file_card { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 6px 12px; + border-radius: 8px; + background: #fff; + border: 1px solid #e5e7eb; + text-decoration: none !important; + color: #374151 !important; + font-size: 12px; + transition: all 0.2s; + max-width: 220px; +} +.o_ai_msg_file_card:hover { + border-color: #7c3aed; + background: #f5f3ff; +} +.o_ai_msg_file_icon { + color: #ef4444; + font-size: 16px; + flex-shrink: 0; +} +.o_ai_msg_file_name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + flex: 1; +} +.o_ai_msg_file_dl { + color: #9ca3af; + font-size: 12px; + flex-shrink: 0; +} +.o_ai_msg_file_card:hover .o_ai_msg_file_dl { + color: #7c3aed; +} 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 3fc860609..73e139497 100644 --- a/yuthon_ai_agent/static/src/js/ai_chat_action.js +++ b/yuthon_ai_agent/static/src/js/ai_chat_action.js @@ -24,16 +24,39 @@ export class AiChatAction extends Component { } } - // 渲染消息内容:转义HTML + URL转可点击链接 + // 渲染消息内容:转义HTML + URL转精美链接卡片 renderMsgContent(text) { if (!text) return ''; const div = document.createElement('div'); div.textContent = text; const safe = div.innerHTML; - return safe.replace( - /(https?:\/\/[^\s<]+)/g, - '$1' + // 先移除附件标记行 + let display = safe + .replace(/【附件:[^】]+】[\s\S]*?--- 附件内容结束 ---/g, '') + .replace(/\[附件[::]\s*[^\]]+\]\n?/g, '') + .trim() || safe; + // URL → 精美卡片 + display = display.replace( + /(https?:\/\/[^\s<>"']+)/g, + (url) => { + let domain = ''; + try { domain = new URL(url).hostname; } catch (_) { domain = url.substring(0, 50); } + const displayUrl = url.length > 60 ? url.substring(0, 57) + '...' : url; + return ` + + ${this._escapeHtml(domain)} + ${this._escapeHtml(displayUrl)} + `; + } ); + return display.replace(/\n/g, '
'); + } + + _escapeHtml(str) { + const d = document.createElement('div'); + d.textContent = str; + return d.innerHTML; } async _loadHistory() { @@ -112,12 +135,16 @@ export class AiChatAction extends Component { } if (data && data.success) { + if (data.ocr_error) { + console.warn("[AI] OCR:", data.ocr_error); + } this.state.attachments = [ ...this.state.attachments, { attachment_id: data.attachment_id, filename: data.filename, mimetype: data.mimetype, + ocr_error: data.ocr_error || "", }, ]; } else { @@ -141,26 +168,21 @@ export class AiChatAction extends Component { if (!text && !hasAttachments) return; if (!this.resId) return; - // 收集附件 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 + "]"; - } + const snapAttachments = [...this.state.attachments]; // 快照用于渲染 this.state.inputText = ""; this.state.attachments = []; this.state.loading = true; + + // 用户消息(干净文本 + 附件列表,不拼接 [附件:...]) this.state.messages = [ ...this.state.messages, - { role: "user", content: displayText, date: "刚刚" }, + { role: "user", content: text || '', date: "刚刚", attachments: snapAttachments }, ]; try { - const params = { conversation_id: this.resId, message: displayText }; + const params = { conversation_id: this.resId, message: text || '' }; if (attIds.length > 0) { params.attachment_ids = attIds; } diff --git a/yuthon_ai_agent/static/src/js/ai_floating.js b/yuthon_ai_agent/static/src/js/ai_floating.js index d5ab1c833..5c671dc47 100644 --- a/yuthon_ai_agent/static/src/js/ai_floating.js +++ b/yuthon_ai_agent/static/src/js/ai_floating.js @@ -112,11 +112,15 @@ async function uploadFile(file) { } if (data && data.success) { + if (data.ocr_error) { + console.warn("[AI] OCR:", data.ocr_error); + } pendingAttachments.push({ attachment_id: data.attachment_id, filename: data.filename, mimetype: data.mimetype, ocr_text: data.ocr_text || "", + ocr_error: data.ocr_error || "", }); renderAttachmentPreview(); } else { @@ -175,10 +179,18 @@ function renderAttachmentPreview() { container.innerHTML = pendingAttachments.map((att, idx) => { const isImage = att.mimetype.startsWith("image/"); const icon = isImage ? "fa-file-image-o" : "fa-file-pdf-o"; + const title = att.ocr_error + ? `${escapeHtml(att.filename)} ⚠ OCR: ${escapeHtml(att.ocr_error)}` + : att.ocr_text + ? `${escapeHtml(att.filename)} ✅ 已识别` + : escapeHtml(att.filename); + const extraClass = att.ocr_text ? " o_ai_attach_ok" : (att.ocr_error ? " o_ai_attach_warn" : ""); return ` -
+
${escapeHtml(att.filename)} + ${att.ocr_text ? '✅' : ''} + ${att.ocr_error ? '⚠️' : ''} @@ -205,10 +217,74 @@ function escapeHtml(text) { function renderContent(text) { const safe = escapeHtml(text || ''); - return safe.replace( - /(https?:\/\/[^\s<]+)/g, - '$1' + // 先移除附件标记行(这些会在消息气泡外单独渲染) + let display = safe + .replace(/【附件:[^】]+】[\s\S]*?--- 附件内容结束 ---/g, '') + .replace(/\[附件[::]\s*[^\]]+\]\n?/g, '') + .trim(); + // URL 渲染为精美卡片 + display = display.replace( + /(https?:\/\/[^\s<>"']+)/g, + (url) => { + // 尝试提取域名作为显示文本 + let domain = ''; + try { + domain = new URL(url).hostname; + } catch (_) { + domain = url.substring(0, 50); + } + const displayUrl = url.length > 60 ? url.substring(0, 57) + '...' : url; + return ` + + ${escapeHtml(domain)} + ${escapeHtml(displayUrl)} + `; + } ); + // 换行转
+ display = display.replace(/\n/g, '
'); + return display || safe; +} + +// 渲染一条消息(含附件预览) +function renderMessageHtml(m, showAttachments) { + const isUser = m.role === 'user'; + const contentHtml = isUser + ? renderContent(m.content) + : renderContent(m.content); + const attHtml = (showAttachments && m.attachments && m.attachments.length) + ? renderMsgAttachments(m.attachments) + : ''; + return ` +
+
${contentHtml}
+ ${attHtml} +
${m.date || ''}
+
+ `; +} + +// 渲染消息中的附件(缩略图/文件卡片) +function renderMsgAttachments(attachments) { + return `
${attachments.map(att => { + const isImage = att.mimetype && att.mimetype.startsWith('image/'); + if (isImage) { + return ` + ${escapeHtml(att.name)} + ${escapeHtml(att.name)} + `; + } + return ` + + ${escapeHtml(att.name)} + + `; + }).join('')}
`; } function renderChat() { @@ -245,12 +321,7 @@ function renderChat() {
加载中...
- ${msgs.map(m => ` -
-
${m.role === 'assistant' ? renderContent(m.content) : escapeHtml(m.content)}
-
${m.date || ''}
-
- `).join('')} + ${msgs.map(m => renderMessageHtml(m, true)).join('')}
@@ -362,18 +433,21 @@ async function sendMsg() { // 收集附件 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 + "]"); - } + // 构建附件渲染 HTML + const attachHtml = hasAttachments + ? renderMsgAttachments(pendingAttachments.map(a => ({ + id: a.attachment_id, + name: a.filename, + mimetype: a.mimetype, + }))) + : ''; const msgsEl = document.getElementById("yuthon_chat_msgs"); if (msgsEl) { msgsEl.innerHTML += `
-
${escapeHtml(displayText)}
+
${escapeHtml(text || '')}
+ ${attachHtml}
刚刚
@@ -394,8 +468,8 @@ async function sendMsg() { } }, 5000); - // 构建请求参数 - const params = { conversation_id: id, message: displayText }; + // 构建请求参数(发送原始文本,不拼接附件信息) + const params = { conversation_id: id, message: text || '' }; if (attIds.length > 0) { params.attachment_ids = attIds; } 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 07d18e1d3..450051068 100644 --- a/yuthon_ai_agent/static/src/xml/ai_chat_template.xml +++ b/yuthon_ai_agent/static/src/xml/ai_chat_template.xml @@ -16,6 +16,30 @@
+ +
+ + + + + + + + + + + + + + + +
@@ -27,7 +51,8 @@
- + @@ -35,6 +60,9 @@ + + ⚠️ +