图片识别
This commit is contained in:
@@ -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,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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&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>
|
||||||
|
|||||||
Reference in New Issue
Block a user