图片识别

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 -*-
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,
})
+3 -2
View File
@@ -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
+133
View File
@@ -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); }
@@ -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;
}
+36 -14
View File
@@ -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,
'<a href="$1" target="_blank" rel="noopener noreferrer" style="color:#4FC3F7;text-decoration:underline;word-break:break-all">$1</a>'
// 先移除附件标记行
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 `<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() {
@@ -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;
}
+93 -19
View File
@@ -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 `
<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>
<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="移除">
<i class="fa fa-times"></i>
</button>
@@ -205,10 +217,74 @@ function escapeHtml(text) {
function renderContent(text) {
const safe = escapeHtml(text || '');
return safe.replace(
/(https?:\/\/[^\s<]+)/g,
'<a href="$1" target="_blank" rel="noopener noreferrer" style="color:#4FC3F7;text-decoration:underline;word-break:break-all">$1</a>'
// 先移除附件标记行(这些会在消息气泡外单独渲染)
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 `<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() {
@@ -245,12 +321,7 @@ function renderChat() {
<div class="o_ai_conv_loading"><i class="fa fa-spinner fa-spin"></i> 加载中...</div>
</div>
<div id="yuthon_chat_msgs" class="o_ai_messages" style="display:${showConvList ? 'none' : 'flex'}">
${msgs.map(m => `
<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('')}
${msgs.map(m => renderMessageHtml(m, true)).join('')}
</div>
<div id="yuthon_attach_preview" class="o_ai_attach_preview" style="display:${showConvList ? 'none' : 'flex'}">
</div>
@@ -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 += `
<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>
<div id="yuthon_loading" class="o_ai_loading">
@@ -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;
}
@@ -16,6 +16,30 @@
<t t-foreach="state.messages" t-as="msg" t-key="msg_index">
<div t-att-class="'ai-chat-msg ' + msg.role">
<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>
</t>
@@ -27,7 +51,8 @@
<!-- 附件预览 -->
<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">
<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/')">
<i class="fa fa-file-image-o me-1"/>
</t>
@@ -35,6 +60,9 @@
<i class="fa fa-file-pdf-o me-1"/>
</t>
<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)">
<i class="fa fa-times"/>
</button>