图片识别

This commit is contained in:
李鹏宇
2026-06-12 15:55:11 +08:00
parent 451c499ed2
commit 994318909d
7 changed files with 463 additions and 40 deletions
+37 -4
View File
@@ -1,8 +1,11 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
import json import json
import logging
from odoo import http from odoo import http
from odoo.http import request from odoo.http import request
_logger = logging.getLogger(__name__)
from odoo.tools import format_datetime from odoo.tools import format_datetime
@@ -27,10 +30,21 @@ class AiChatController(http.Controller):
reply = conv.send_message(message, attachment_ids=attachment_ids) reply = conv.send_message(message, attachment_ids=attachment_ids)
if isinstance(reply, dict) and 'error' in reply: if isinstance(reply, dict) and 'error' in reply:
return 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:] last_msg = conv.message_ids.filtered(lambda m: m.role == 'assistant')[-1:]
return { return {
'content': reply, 'content': reply,
'message_id': last_msg.id if last_msg else False, 'message_id': last_msg.id if last_msg else False,
'attachments': user_attachments,
} }
@http.route('/ai/chat/history', type='json', auth='user') @http.route('/ai/chat/history', type='json', auth='user')
@@ -51,11 +65,20 @@ class AiChatController(http.Controller):
time_str = local_dt.strftime('%H:%M') time_str = local_dt.strftime('%H:%M')
else: else:
time_str = '' time_str = ''
# 附件信息
msg_attachments = []
for att in msg.attachment_ids:
msg_attachments.append({
'id': att.id,
'name': att.name,
'mimetype': att.mimetype,
})
messages.append({ messages.append({
'id': msg.id, 'id': msg.id,
'role': msg.role, 'role': msg.role,
'content': msg.content, 'content': msg.content,
'date': time_str, 'date': time_str,
'attachments': msg_attachments,
}) })
return {'messages': messages} return {'messages': messages}
@@ -223,17 +246,26 @@ class AiChatController(http.Controller):
# OCR 提取文字 # OCR 提取文字
ocr_text = '' ocr_text = ''
ocr_error = ''
if provider and provider.ocr_enabled: if provider and provider.ocr_enabled:
from ..models.ocr_provider import get_ocr_instance from ..models.ocr_provider import get_ocr_instance
ocr = get_ocr_instance(provider) 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) result = ocr.smart_recognize(raw_bytes, mimetype)
if result.get('success'): if result.get('success'):
ocr_text = result.get('text') or '' 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: else:
# OCR 失败不阻塞上传,前端仍可发送(只是没有文字提取) ocr_error = result.get('error', 'OCR 调用失败')
pass _logger.error('[AI-OCR] %s', ocr_error)
return request.make_json_response({ return request.make_json_response({
'success': True, 'success': True,
@@ -241,4 +273,5 @@ class AiChatController(http.Controller):
'filename': filename, 'filename': filename,
'mimetype': mimetype, 'mimetype': mimetype,
'ocr_text': ocr_text, 'ocr_text': ocr_text,
'ocr_error': ocr_error,
}) })
+3 -2
View File
@@ -787,11 +787,12 @@ class AiConversation(models.Model):
# 构建最终发送给 AI 的用户消息内容 # 构建最终发送给 AI 的用户消息内容
full_content = ocr_result + content if ocr_result else content full_content = ocr_result + content if ocr_result else content
# 1. 保存用户消息 # 1. 保存用户消息(content 存原始用户文本,ocr_content 存 OCR 结果,
# full_content 带 OCR 的版本发给 AI,前端通过 attachments 字段渲染附件预览)
msg_vals = { msg_vals = {
'conversation_id': self.id, 'conversation_id': self.id,
'role': 'user', 'role': 'user',
'content': full_content, 'content': content, # 存干净的用户原文,不含 OCR 前缀
} }
if msg_attachment_ids: if msg_attachment_ids:
msg_vals['attachment_ids'] = msg_attachment_ids msg_vals['attachment_ids'] = msg_attachment_ids
+133
View File
@@ -113,6 +113,139 @@
background: #f8d7da; 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 { @keyframes fadeIn {
from { opacity: 0; transform: translateY(8px); } from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); } to { opacity: 1; transform: translateY(0); }
@@ -315,3 +315,135 @@
color: #ef4444; color: #ef4444;
background: #fef2f2; 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;
}
+36 -14
View File
@@ -24,16 +24,39 @@ export class AiChatAction extends Component {
} }
} }
// 渲染消息内容:转义HTML + URL转可点击链接 // 渲染消息内容:转义HTML + URL转精美链接卡片
renderMsgContent(text) { renderMsgContent(text) {
if (!text) return ''; if (!text) return '';
const div = document.createElement('div'); const div = document.createElement('div');
div.textContent = text; div.textContent = text;
const safe = div.innerHTML; const safe = div.innerHTML;
return safe.replace( // 先移除附件标记行
/(https?:\/\/[^\s<]+)/g, let display = safe
'<a href="$1" target="_blank" rel="noopener noreferrer" style="color:#4FC3F7;text-decoration:underline;word-break:break-all">$1</a>' .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 `<a href="${url}" target="_blank" rel="noopener noreferrer"
class="o_ai_link_card" title="${url}">
<span class="o_ai_link_icon"><i class="fa fa-external-link"></i></span>
<span class="o_ai_link_domain">${this._escapeHtml(domain)}</span>
<span class="o_ai_link_url">${this._escapeHtml(displayUrl)}</span>
</a>`;
}
); );
return display.replace(/\n/g, '<br>');
}
_escapeHtml(str) {
const d = document.createElement('div');
d.textContent = str;
return d.innerHTML;
} }
async _loadHistory() { async _loadHistory() {
@@ -112,12 +135,16 @@ export class AiChatAction extends Component {
} }
if (data && data.success) { if (data && data.success) {
if (data.ocr_error) {
console.warn("[AI] OCR:", data.ocr_error);
}
this.state.attachments = [ this.state.attachments = [
...this.state.attachments, ...this.state.attachments,
{ {
attachment_id: data.attachment_id, attachment_id: data.attachment_id,
filename: data.filename, filename: data.filename,
mimetype: data.mimetype, mimetype: data.mimetype,
ocr_error: data.ocr_error || "",
}, },
]; ];
} else { } else {
@@ -141,26 +168,21 @@ export class AiChatAction extends Component {
if (!text && !hasAttachments) return; if (!text && !hasAttachments) return;
if (!this.resId) return; if (!this.resId) return;
// 收集附件 ID
const attIds = this.state.attachments.map(a => a.attachment_id); const attIds = this.state.attachments.map(a => a.attachment_id);
const snapAttachments = [...this.state.attachments]; // 快照用于渲染
// 构建显示文本
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.inputText = "";
this.state.attachments = []; this.state.attachments = [];
this.state.loading = true; this.state.loading = true;
// 用户消息(干净文本 + 附件列表,不拼接 [附件:...])
this.state.messages = [ this.state.messages = [
...this.state.messages, ...this.state.messages,
{ role: "user", content: displayText, date: "刚刚" }, { role: "user", content: text || '', date: "刚刚", attachments: snapAttachments },
]; ];
try { try {
const params = { conversation_id: this.resId, message: displayText }; const params = { conversation_id: this.resId, message: text || '' };
if (attIds.length > 0) { if (attIds.length > 0) {
params.attachment_ids = attIds; params.attachment_ids = attIds;
} }
+93 -19
View File
@@ -112,11 +112,15 @@ async function uploadFile(file) {
} }
if (data && data.success) { if (data && data.success) {
if (data.ocr_error) {
console.warn("[AI] OCR:", data.ocr_error);
}
pendingAttachments.push({ pendingAttachments.push({
attachment_id: data.attachment_id, attachment_id: data.attachment_id,
filename: data.filename, filename: data.filename,
mimetype: data.mimetype, mimetype: data.mimetype,
ocr_text: data.ocr_text || "", ocr_text: data.ocr_text || "",
ocr_error: data.ocr_error || "",
}); });
renderAttachmentPreview(); renderAttachmentPreview();
} else { } else {
@@ -175,10 +179,18 @@ function renderAttachmentPreview() {
container.innerHTML = pendingAttachments.map((att, idx) => { container.innerHTML = pendingAttachments.map((att, idx) => {
const isImage = att.mimetype.startsWith("image/"); const isImage = att.mimetype.startsWith("image/");
const icon = isImage ? "fa-file-image-o" : "fa-file-pdf-o"; 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 ` return `
<div class="o_ai_attach_item" title="${escapeHtml(att.filename)}"> <div class="o_ai_attach_item${extraClass}" title="${title}">
<i class="fa ${icon} o_ai_attach_icon"></i> <i class="fa ${icon} o_ai_attach_icon"></i>
<span class="o_ai_attach_name">${escapeHtml(att.filename)}</span> <span class="o_ai_attach_name">${escapeHtml(att.filename)}</span>
${att.ocr_text ? '<span class="o_ai_attach_status" style="color:#22c55e;font-size:11px;" title="已识别">✅</span>' : ''}
${att.ocr_error ? '<span class="o_ai_attach_status" style="color:#f59e0b;font-size:11px;" title="' + escapeHtml(att.ocr_error) + '">⚠️</span>' : ''}
<button class="o_ai_attach_remove" data-idx="${idx}" title="移除"> <button class="o_ai_attach_remove" data-idx="${idx}" title="移除">
<i class="fa fa-times"></i> <i class="fa fa-times"></i>
</button> </button>
@@ -205,10 +217,74 @@ function escapeHtml(text) {
function renderContent(text) { function renderContent(text) {
const safe = escapeHtml(text || ''); const safe = escapeHtml(text || '');
return safe.replace( // 先移除附件标记行(这些会在消息气泡外单独渲染)
/(https?:\/\/[^\s<]+)/g, let display = safe
'<a href="$1" target="_blank" rel="noopener noreferrer" style="color:#4FC3F7;text-decoration:underline;word-break:break-all">$1</a>' .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 `<a href="${url}" target="_blank" rel="noopener noreferrer"
class="o_ai_link_card"
title="${url}">
<span class="o_ai_link_icon"><i class="fa fa-external-link"></i></span>
<span class="o_ai_link_domain">${escapeHtml(domain)}</span>
<span class="o_ai_link_url">${escapeHtml(displayUrl)}</span>
</a>`;
}
); );
// 换行转 <br>
display = display.replace(/\n/g, '<br>');
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 `
<div class="o_ai_msg ${isUser ? 'o_ai_msg_user' : 'o_ai_msg_ai'}">
<div class="o_ai_msg_bubble">${contentHtml}</div>
${attHtml}
<div class="o_ai_msg_time">${m.date || ''}</div>
</div>
`;
}
// 渲染消息中的附件(缩略图/文件卡片)
function renderMsgAttachments(attachments) {
return `<div class="o_ai_msg_attachments">${attachments.map(att => {
const isImage = att.mimetype && att.mimetype.startsWith('image/');
if (isImage) {
return `<a href="/web/content/documents.document/${att.id}/raw?download=1"
target="_blank" class="o_ai_msg_img_wrap" title="${escapeHtml(att.name)}">
<img src="/web/image/documents.document/${att.id}/raw?width=200&height=200"
alt="${escapeHtml(att.name)}" class="o_ai_msg_thumb" loading="lazy"/>
<span class="o_ai_msg_img_name">${escapeHtml(att.name)}</span>
</a>`;
}
return `<a href="/web/content/documents.document/${att.id}/raw?download=1"
target="_blank" class="o_ai_msg_file_card" title="下载 ${escapeHtml(att.name)}">
<span class="o_ai_msg_file_icon"><i class="fa fa-file-pdf-o"></i></span>
<span class="o_ai_msg_file_name">${escapeHtml(att.name)}</span>
<span class="o_ai_msg_file_dl"><i class="fa fa-download"></i></span>
</a>`;
}).join('')}</div>`;
} }
function renderChat() { function renderChat() {
@@ -245,12 +321,7 @@ function renderChat() {
<div class="o_ai_conv_loading"><i class="fa fa-spinner fa-spin"></i> 加载中...</div> <div class="o_ai_conv_loading"><i class="fa fa-spinner fa-spin"></i> 加载中...</div>
</div> </div>
<div id="yuthon_chat_msgs" class="o_ai_messages" style="display:${showConvList ? 'none' : 'flex'}"> <div id="yuthon_chat_msgs" class="o_ai_messages" style="display:${showConvList ? 'none' : 'flex'}">
${msgs.map(m => ` ${msgs.map(m => renderMessageHtml(m, true)).join('')}
<div class="o_ai_msg ${m.role === 'user' ? 'o_ai_msg_user' : 'o_ai_msg_ai'}">
<div class="o_ai_msg_bubble">${m.role === 'assistant' ? renderContent(m.content) : escapeHtml(m.content)}</div>
<div class="o_ai_msg_time">${m.date || ''}</div>
</div>
`).join('')}
</div> </div>
<div id="yuthon_attach_preview" class="o_ai_attach_preview" style="display:${showConvList ? 'none' : 'flex'}"> <div id="yuthon_attach_preview" class="o_ai_attach_preview" style="display:${showConvList ? 'none' : 'flex'}">
</div> </div>
@@ -362,18 +433,21 @@ async function sendMsg() {
// 收集附件 ID 列表 // 收集附件 ID 列表
const attIds = pendingAttachments.map(a => a.attachment_id); const attIds = pendingAttachments.map(a => a.attachment_id);
// 构建显示文本(用户消息气泡) // 构建附件渲染 HTML
let displayText = text || ""; const attachHtml = hasAttachments
if (hasAttachments) { ? renderMsgAttachments(pendingAttachments.map(a => ({
const fileNames = pendingAttachments.map(a => a.filename).join("、"); id: a.attachment_id,
displayText = (text ? "[附件: " + fileNames + "]\n" + text : "[附件: " + fileNames + "]"); name: a.filename,
} mimetype: a.mimetype,
})))
: '';
const msgsEl = document.getElementById("yuthon_chat_msgs"); const msgsEl = document.getElementById("yuthon_chat_msgs");
if (msgsEl) { if (msgsEl) {
msgsEl.innerHTML += ` msgsEl.innerHTML += `
<div class="o_ai_msg o_ai_msg_user"> <div class="o_ai_msg o_ai_msg_user">
<div class="o_ai_msg_bubble">${escapeHtml(displayText)}</div> <div class="o_ai_msg_bubble">${escapeHtml(text || '')}</div>
${attachHtml}
<div class="o_ai_msg_time">刚刚</div> <div class="o_ai_msg_time">刚刚</div>
</div> </div>
<div id="yuthon_loading" class="o_ai_loading"> <div id="yuthon_loading" class="o_ai_loading">
@@ -394,8 +468,8 @@ async function sendMsg() {
} }
}, 5000); }, 5000);
// 构建请求参数 // 构建请求参数(发送原始文本,不拼接附件信息)
const params = { conversation_id: id, message: displayText }; const params = { conversation_id: id, message: text || '' };
if (attIds.length > 0) { if (attIds.length > 0) {
params.attachment_ids = attIds; params.attachment_ids = attIds;
} }
@@ -16,6 +16,30 @@
<t t-foreach="state.messages" t-as="msg" t-key="msg_index"> <t t-foreach="state.messages" t-as="msg" t-key="msg_index">
<div t-att-class="'ai-chat-msg ' + msg.role"> <div t-att-class="'ai-chat-msg ' + msg.role">
<div class="ai-chat-bubble" t-raw="renderMsgContent(msg.content)"/> <div class="ai-chat-bubble" t-raw="renderMsgContent(msg.content)"/>
<!-- 附件缩略图 -->
<div t-if="msg.attachments and msg.attachments.length > 0"
t-att-class="'ai-chat-msg-attachments ' + (msg.role === 'user' ? 'justify-content-end' : '')">
<t t-foreach="msg.attachments" t-as="att" t-key="att.id">
<t t-if="att.mimetype and att.mimetype.startsWith('image/')">
<a t-att-href="'/web/content/documents.document/' + att.id + '/raw?download=1'"
target="_blank" class="ai-chat-msg-img-wrap"
t-att-title="att.name">
<img t-att-src="'/web/image/documents.document/' + att.id + '/raw?width=200&amp;height=200'"
t-att-alt="att.name" class="ai-chat-msg-thumb"/>
<span class="ai-chat-msg-img-name" t-esc="att.name"/>
</a>
</t>
<t t-else="">
<a t-att-href="'/web/content/documents.document/' + att.id + '/raw?download=1'"
target="_blank" class="ai-chat-msg-file-card"
t-att-title="att.name">
<span class="ai-chat-msg-file-icon"><i class="fa fa-file-pdf-o"/></span>
<span class="ai-chat-msg-file-name" t-esc="att.name"/>
<span class="ai-chat-msg-file-dl"><i class="fa fa-download"/></span>
</a>
</t>
</t>
</div>
<div class="ai-chat-meta" t-if="msg.date" t-esc="msg.date"/> <div class="ai-chat-meta" t-if="msg.date" t-esc="msg.date"/>
</div> </div>
</t> </t>
@@ -27,7 +51,8 @@
<!-- 附件预览 --> <!-- 附件预览 -->
<div t-if="state.attachments.length > 0" class="ai-chat-attach-preview p-2 border-top"> <div t-if="state.attachments.length > 0" class="ai-chat-attach-preview p-2 border-top">
<t t-foreach="state.attachments" t-as="att" t-key="att_index"> <t t-foreach="state.attachments" t-as="att" t-key="att_index">
<span class="ai-chat-attach-item"> <span class="ai-chat-attach-item"
t-att-title="att.ocr_error ? ('OCR: ' + att.ocr_error) : (att.filename)">
<t t-if="att.mimetype.startsWith('image/')"> <t t-if="att.mimetype.startsWith('image/')">
<i class="fa fa-file-image-o me-1"/> <i class="fa fa-file-image-o me-1"/>
</t> </t>
@@ -35,6 +60,9 @@
<i class="fa fa-file-pdf-o me-1"/> <i class="fa fa-file-pdf-o me-1"/>
</t> </t>
<t t-esc="att.filename"/> <t t-esc="att.filename"/>
<t t-if="att.ocr_error">
<span class="text-warning ms-1" t-att-title="att.ocr_error" style="font-size:11px;">⚠️</span>
</t>
<button class="ai-chat-attach-remove" t-on-click="() => removeAttachment(att_index)"> <button class="ai-chat-attach-remove" t-on-click="() => removeAttachment(att_index)">
<i class="fa fa-times"/> <i class="fa fa-times"/>
</button> </button>