图片识别

This commit is contained in:
李鹏宇
2026-06-12 16:17:22 +08:00
parent aade7168ba
commit 24858cb587
6 changed files with 97 additions and 44 deletions
+16 -1
View File
@@ -767,12 +767,14 @@ class AiConversation(models.Model):
# —— 附件 OCR 预处理 ——
ocr_result = ''
attachment_note = ''
msg_attachment_ids = []
if attachment_ids:
attachments = self.env['documents.document'].sudo().browse(attachment_ids).exists()
if attachments:
msg_attachment_ids = [(4, a.id) for a in attachments]
ocr_parts = []
no_text_names = [] # OCR 无文字的附件
for att in attachments:
att_text = att.ai_ocr_content or ''
if att_text:
@@ -781,11 +783,24 @@ class AiConversation(models.Model):
f'{att_text}\n'
f'--- 附件内容结束 ---'
)
else:
no_text_names.append(att.name)
if ocr_parts:
ocr_result = '\n\n'.join(ocr_parts) + '\n\n'
# 有附件但 OCR 无文字 → 告知 AI 它是文本模型,无法“看”图片
if no_text_names:
file_list = '、'.join(no_text_names)
attachment_note = (
f'[系统提示:用户上传了附件({file_list}),'
f'但 OCR 未能从中提取到文字。'
f'你是一个纯文本模型,不具备图像识别/视觉能力,无法查看图片内容。'
f'请告知用户你只能识别图片中的印刷文字(通过 OCR),'
f'无法辨认物体、人物、场景、颜色等图像信息。'
f'如果用户问的是图片里有什么物体/人物/场景,请友好说明这一限制。]\n\n'
)
# 构建最终发送给 AI 的用户消息内容
full_content = ocr_result + content if ocr_result else content
full_content = attachment_note + ocr_result + content if (ocr_result or attachment_note) else content
# 1. 保存用户消息(content 存原始用户文本,ocr_content 存 OCR 结果,
# full_content 带 OCR 的版本发给 AI,前端通过 attachments 字段渲染附件预览)
+7 -4
View File
@@ -119,13 +119,16 @@
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
margin: 2px 0;
border-radius: 8px;
padding: 5px 12px;
margin: 4px 2px;
border-radius: 10px;
text-decoration: none !important;
transition: all 0.2s;
transition: all 0.2s cubic-bezier(0.16, 1, 0.3, 1);
max-width: 100%;
overflow: hidden;
white-space: normal; /* 覆盖父级 pre-wrap,卡片内部正常换行 */
word-break: normal; /* 不打断卡片内部文字 */
vertical-align: middle; /* 与周围文字对齐 */
}
.ai-chat-msg.assistant .o_ai_link_card {
background: #f0f4ff;
+15 -4
View File
@@ -300,6 +300,14 @@
white-space: nowrap;
color: #374151;
}
.o_ai_attach_status {
font-size: 10px;
flex-shrink: 0;
white-space: nowrap;
}
.o_ai_attach_ok { color: #22c55e; font-weight: 600; }
.o_ai_attach_info { color: #3b82f6; } /* 无文字 — 非错误,仅提示 */
.o_ai_attach_warn { color: #f59e0b; font-weight: 600; } /* 真正的 OCR 故障 */
.o_ai_attach_remove {
background: none;
border: none;
@@ -321,16 +329,19 @@
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
margin: 2px 0;
border-radius: 8px;
padding: 5px 12px;
margin: 4px 2px;
border-radius: 10px;
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;
transition: all 0.2s cubic-bezier(0.16, 1, 0.3, 1);
max-width: 100%;
overflow: hidden;
white-space: normal; /* 覆盖父级 pre-wrap,卡片内部正常换行 */
word-break: normal; /* 不打断卡片内部文字 */
vertical-align: middle; /* 与周围文字对齐 */
}
.o_ai_msg_ai .o_ai_link_card {
background: #f0f4ff;
+12 -10
View File
@@ -35,19 +35,20 @@ export class AiChatAction extends Component {
.replace(/【附件:[^】]+】[\s\S]*?--- 附件内容结束 ---/g, '')
.replace(/\[附件[::]\s*[^\]]+\]\n?/g, '')
.trim() || safe;
// URL → 精美卡片
// URL → 精美卡片(还原 escapeHtml 把 & 变回 &,保证 href 正确)
display = display.replace(
/(https?:\/\/[^\s<>"']+)/g,
/(https?:\/\/[^\s<>"'\u4e00-\u9fff\u3000-\u303f\uff00-\uffef]+)/g,
(url) => {
const rawUrl = url.replace(/&amp;/g, '&');
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>`;
try { domain = new URL(rawUrl).hostname; } catch (_) { domain = rawUrl.substring(0, 50); }
const displayUrl = rawUrl.length > 60 ? rawUrl.substring(0, 57) + '...' : rawUrl;
return `<a
class="o_ai_link_card"
href="${rawUrl}"
target="_blank"
rel="noopener noreferrer"
title="${rawUrl}"><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>');
@@ -144,6 +145,7 @@ export class AiChatAction extends Component {
attachment_id: data.attachment_id,
filename: data.filename,
mimetype: data.mimetype,
ocr_text: data.ocr_text || "",
ocr_error: data.ocr_error || "",
},
];
+35 -22
View File
@@ -179,18 +179,27 @@ 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" : "");
// 三种状态:有文字 ✅ / 无文字 📷(OCR 正常但图里没字) / 错误 ⚠️
let statusHtml = '';
let extraClass = '';
let title = escapeHtml(att.filename);
if (att.ocr_text) {
statusHtml = '<span class="o_ai_attach_status o_ai_attach_ok" title="已识别到文字">✅</span>';
extraClass = ' o_ai_attach_ok';
title = att.filename + ' ✅ ' + (att.ocr_text.length || 0) + ' 字符已识别';
} else if (att.ocr_error) {
const isNoText = att.ocr_error.includes('未识别到文字') || att.ocr_error.includes('无文字');
statusHtml = isNoText
? '<span class="o_ai_attach_status o_ai_attach_info" title="图片中无印刷文字,AI 为纯文本模型,无法理解图像内容">📷 无文字</span>'
: '<span class="o_ai_attach_status o_ai_attach_warn" title="' + escapeHtml(att.ocr_error) + '">⚠️ 失败</span>';
extraClass = isNoText ? ' o_ai_attach_info' : ' o_ai_attach_warn';
title = att.filename + ' — ' + att.ocr_error;
}
return `
<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>' : ''}
${statusHtml}
<button class="o_ai_attach_remove" data-idx="${idx}" title="移除">
<i class="fa fa-times"></i>
</button>
@@ -215,37 +224,41 @@ function escapeHtml(text) {
return div.innerHTML;
}
/**
* 将消息文本渲染为安全的 HTML:转义 → URL 转精美链接卡片 → 换行转 <br>
* 注意:不论内容是否被附件标记 strip 成空串,都走完整的 URL 卡片流程
*/
function renderContent(text) {
const safe = escapeHtml(text || '');
// 先移除附件标记行(这些会在消息气泡外单独渲染)
let display = safe
.replace(/【附件:[^】]+】[\s\S]*?--- 附件内容结束 ---/g, '')
.replace(/\[附件[::]\s*[^\]]+\]\n?/g, '')
.trim();
// URL 渲染为精美卡片
.trim() || safe; // 若 strip 后为空,回退到 safe 继续处理 URL
// URL 渲染为精美卡片(先还原 escapeHtml 把 &amp; 变回 &,保证 href 正确)
display = display.replace(
/(https?:\/\/[^\s<>"']+)/g,
/(https?:\/\/[^\s<>"'\u4e00-\u9fff\u3000-\u303f\uff00-\uffef]+)/g,
(url) => {
// 尝试提取域名作为显示文本
// 还原 HTML 实体(escapeHtml 会把 & 变成 &amp;,URL 参数中的 & 需要还原)
const rawUrl = url.replace(/&amp;/g, '&');
let domain = '';
try {
domain = new URL(url).hostname;
domain = new URL(rawUrl).hostname;
} catch (_) {
domain = url.substring(0, 50);
domain = rawUrl.substring(0, 50);
}
const displayUrl = url.length > 60 ? url.substring(0, 57) + '...' : url;
return `<a href="${url}" target="_blank" rel="noopener noreferrer"
const displayUrl = rawUrl.length > 60 ? rawUrl.substring(0, 57) + '...' : rawUrl;
return `<a
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>`;
href="${rawUrl}"
target="_blank"
rel="noopener noreferrer"
title="${rawUrl}"><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;
return display;
}
// 渲染一条消息(含附件预览)
@@ -52,7 +52,7 @@
<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"
t-att-title="att.ocr_error ? ('OCR: ' + att.ocr_error) : (att.filename)">
t-att-title="att.ocr_error ? (att.filename + ' — ' + att.ocr_error) : (att.ocr_text ? (att.filename + ' ✅ 已识别') : att.filename)">
<t t-if="att.mimetype.startsWith('image/')">
<i class="fa fa-file-image-o me-1"/>
</t>
@@ -60,8 +60,17 @@
<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 t-if="att.ocr_text">
<span class="text-success ms-1" t-att-title="'已识别 ' + att.ocr_text.length + ' 字符'" style="font-size:10px; font-weight:600;">✅</span>
</t>
<!-- 📷 无文字(正常,OCR 找到了 0 个文字) -->
<t t-elif="att.ocr_error and (att.ocr_error.indexOf('未识别到文字') >= 0 or att.ocr_error.indexOf('无文字') >= 0)">
<span class="text-primary ms-1" t-att-title="att.ocr_error" style="font-size:10px;">📷 无文字</span>
</t>
<!-- ⚠️ OCR 真正出错 -->
<t t-elif="att.ocr_error">
<span class="text-warning ms-1" t-att-title="att.ocr_error" style="font-size:10px;">⚠️ 失败</span>
</t>
<button class="ai-chat-attach-remove" t-on-click="() => removeAttachment(att_index)">
<i class="fa fa-times"/>