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 `
-