图片识别
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import json
|
||||
import base64
|
||||
|
||||
from odoo import http
|
||||
from odoo.http import request
|
||||
@@ -16,15 +17,15 @@ class AiChatController(http.Controller):
|
||||
return None
|
||||
|
||||
@http.route('/ai/chat/send', type='json', auth='user', methods=['POST'], csrf=False)
|
||||
def chat_send(self, conversation_id, message):
|
||||
"""发送消息"""
|
||||
def chat_send(self, conversation_id, message, attachment_ids=None):
|
||||
"""发送消息(支持附件 OCR)"""
|
||||
err = self._check_ai_access()
|
||||
if err:
|
||||
return err
|
||||
conv = request.env['ai.conversation'].browse(conversation_id)
|
||||
if not conv.exists():
|
||||
return {'error': '对话不存在'}
|
||||
reply = conv.send_message(message)
|
||||
reply = conv.send_message(message, attachment_ids=attachment_ids)
|
||||
if isinstance(reply, dict) and 'error' in reply:
|
||||
return reply
|
||||
last_msg = conv.message_ids.filtered(lambda m: m.role == 'assistant')[-1:]
|
||||
@@ -161,3 +162,79 @@ class AiChatController(http.Controller):
|
||||
'style_preference': profile.style_preference or 'concise',
|
||||
'memory_count': profile.memory_count,
|
||||
}
|
||||
|
||||
@http.route('/ai/attachment/upload', type='http', auth='user', methods=['POST'], csrf=False)
|
||||
def attachment_upload(self, **kwargs):
|
||||
"""上传聊天附件(图片/PDF),调 OCR 提取文字并返回结果。
|
||||
|
||||
请求:multipart/form-data,字段名 'file'
|
||||
返回:JSON { success, attachment_id, filename, mimetype, ocr_text, error }
|
||||
"""
|
||||
err = self._check_ai_access()
|
||||
if err:
|
||||
return request.make_json_response(err)
|
||||
|
||||
uploaded = request.httprequest.files.get('file')
|
||||
if not uploaded:
|
||||
return request.make_json_response({'success': False, 'error': '未收到文件'})
|
||||
|
||||
# 读取文件内容
|
||||
raw_bytes = uploaded.read()
|
||||
filename = uploaded.filename or 'untitled'
|
||||
mimetype = uploaded.content_type or 'application/octet-stream'
|
||||
|
||||
# 限制文件类型
|
||||
allowed = {'image/png', 'image/jpeg', 'image/jpg', 'image/bmp',
|
||||
'image/gif', 'application/pdf'}
|
||||
if mimetype not in allowed:
|
||||
return request.make_json_response({
|
||||
'success': False,
|
||||
'error': f'不支持的文件格式:{mimetype},仅支持 PNG/JPG/BMP/GIF/PDF'
|
||||
})
|
||||
|
||||
# 限制文件大小 (10MB)
|
||||
max_size = 10 * 1024 * 1024
|
||||
if len(raw_bytes) > max_size:
|
||||
return request.make_json_response({
|
||||
'success': False,
|
||||
'error': f'文件过大({len(raw_bytes) / 1024 / 1024:.1f}MB),上限 10MB'
|
||||
})
|
||||
|
||||
# 获取当前对话的 Provider OCR 配置
|
||||
conv_id = kwargs.get('conversation_id')
|
||||
provider = None
|
||||
if conv_id:
|
||||
conv = request.env['ai.conversation'].sudo().browse(int(conv_id))
|
||||
if conv.exists():
|
||||
provider = conv.provider_id
|
||||
|
||||
# 创建 ir.attachment
|
||||
attachment = request.env['ir.attachment'].sudo().create({
|
||||
'name': filename,
|
||||
'datas': base64.b64encode(raw_bytes).decode('utf-8'),
|
||||
'mimetype': mimetype,
|
||||
'res_model': 'ai.message',
|
||||
'res_id': 0,
|
||||
})
|
||||
|
||||
# OCR 提取文字
|
||||
ocr_text = ''
|
||||
if provider and provider.ocr_enabled:
|
||||
from ..models.ocr_provider import get_ocr_instance
|
||||
ocr = get_ocr_instance(provider)
|
||||
if ocr:
|
||||
result = ocr.smart_recognize(raw_bytes, mimetype)
|
||||
if result.get('success'):
|
||||
ocr_text = result.get('text') or ''
|
||||
attachment.sudo().write({'ai_ocr_content': ocr_text})
|
||||
else:
|
||||
# OCR 失败不阻塞上传,前端仍可发送(只是没有文字提取)
|
||||
pass
|
||||
|
||||
return request.make_json_response({
|
||||
'success': True,
|
||||
'attachment_id': attachment.id,
|
||||
'filename': filename,
|
||||
'mimetype': mimetype,
|
||||
'ocr_text': ocr_text,
|
||||
})
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from . import ai_agent
|
||||
from . import ai_attachment
|
||||
from . import ai_user_profile
|
||||
from . import ai_token_usage
|
||||
from . import ai_rule
|
||||
@@ -7,3 +8,4 @@ from . import ai_manual
|
||||
from . import ai_report
|
||||
from . import ai_report_template
|
||||
from . import ai_report_template_config
|
||||
from . import ocr_provider
|
||||
|
||||
@@ -63,6 +63,26 @@ class AiProvider(models.Model):
|
||||
help='允许 AI 调用 create/write/unlink 工具。建议仅在受信任环境开启。'
|
||||
)
|
||||
|
||||
# OCR 配置
|
||||
ocr_enabled = fields.Boolean(
|
||||
string='启用 OCR',
|
||||
default=False,
|
||||
help='开启后聊天支持上传图片/PDF,自动调用腾讯云 OCR 提取文字后再发给 AI'
|
||||
)
|
||||
ocr_secret_id = fields.Char(
|
||||
string='OCR SecretId',
|
||||
help='腾讯云 API 密钥 SecretId,在 https://console.cloud.tencent.com/cam/capi 获取'
|
||||
)
|
||||
ocr_secret_key = fields.Char(
|
||||
string='OCR SecretKey',
|
||||
help='腾讯云 API 密钥 SecretKey'
|
||||
)
|
||||
ocr_region = fields.Char(
|
||||
string='OCR 地域',
|
||||
default='ap-guangzhou',
|
||||
help='腾讯云 OCR 服务地域,如 ap-guangzhou, ap-shanghai, ap-beijing'
|
||||
)
|
||||
|
||||
# 规则配置
|
||||
rule_ids = fields.Many2many(
|
||||
'ai.rule', 'ai_rule_provider_rel', 'provider_id', 'rule_id',
|
||||
@@ -729,8 +749,13 @@ class AiConversation(models.Model):
|
||||
)
|
||||
return content
|
||||
|
||||
def send_message(self, content):
|
||||
"""发送用户消息并获取 AI 回复(支持工具调用)。"""
|
||||
def send_message(self, content, attachment_ids=None):
|
||||
"""发送用户消息并获取 AI 回复(支持工具调用)。
|
||||
|
||||
:param content: 用户输入的文本
|
||||
:param attachment_ids: 可选,附件 ID 列表。附件会先经 OCR 提取文字,
|
||||
拼入消息首部再发送给 AI。
|
||||
"""
|
||||
self.ensure_one()
|
||||
# 权限校验
|
||||
if not self.env.user.has_group('yuthon_ai_agent.group_ai_agent_user'):
|
||||
@@ -740,12 +765,38 @@ class AiConversation(models.Model):
|
||||
if not provider or not provider.active:
|
||||
return {'error': 'AI 服务未配置或已停用。'}
|
||||
|
||||
# —— 附件 OCR 预处理 ——
|
||||
ocr_result = ''
|
||||
msg_attachment_ids = []
|
||||
if attachment_ids:
|
||||
attachments = self.env['ir.attachment'].sudo().browse(attachment_ids).exists()
|
||||
if attachments:
|
||||
msg_attachment_ids = [(4, a.id) for a in attachments]
|
||||
ocr_parts = []
|
||||
for att in attachments:
|
||||
att_text = att.ai_ocr_content or ''
|
||||
if att_text:
|
||||
ocr_parts.append(
|
||||
f'【附件:{att.name}】\n'
|
||||
f'{att_text}\n'
|
||||
f'--- 附件内容结束 ---'
|
||||
)
|
||||
if ocr_parts:
|
||||
ocr_result = '\n\n'.join(ocr_parts) + '\n\n'
|
||||
|
||||
# 构建最终发送给 AI 的用户消息内容
|
||||
full_content = ocr_result + content if ocr_result else content
|
||||
|
||||
# 1. 保存用户消息
|
||||
self.env['ai.message'].create({
|
||||
msg_vals = {
|
||||
'conversation_id': self.id,
|
||||
'role': 'user',
|
||||
'content': content,
|
||||
})
|
||||
'content': full_content,
|
||||
}
|
||||
if msg_attachment_ids:
|
||||
msg_vals['attachment_ids'] = msg_attachment_ids
|
||||
msg_vals['ocr_content'] = ocr_result
|
||||
self.env['ai.message'].create(msg_vals)
|
||||
|
||||
# 2. 构建发送给模型的 messages
|
||||
# 获取用户画像
|
||||
@@ -913,4 +964,12 @@ class AiMessage(models.Model):
|
||||
('system', '系统'),
|
||||
], string='角色', required=True)
|
||||
content = fields.Text(string='内容', required=True)
|
||||
attachment_ids = fields.Many2many(
|
||||
'ir.attachment', 'ai_message_attachment_rel',
|
||||
'message_id', 'attachment_id', string='附件'
|
||||
)
|
||||
ocr_content = fields.Text(
|
||||
string='OCR 识别结果',
|
||||
help='附件图片/PDF 经 OCR 提取的文本内容'
|
||||
)
|
||||
create_uid = fields.Many2one('res.users', string='发送者', default=lambda self: self.env.uid)
|
||||
|
||||
@@ -74,6 +74,45 @@
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* 附件上传按钮 */
|
||||
.ai-chat-attach-btn {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* 附件预览区 */
|
||||
.ai-chat-attach-preview {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
background: #f8f9fa;
|
||||
}
|
||||
.ai-chat-attach-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 3px 10px;
|
||||
background: #fff;
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
color: #495057;
|
||||
}
|
||||
.ai-chat-attach-remove {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #adb5bd;
|
||||
cursor: pointer;
|
||||
padding: 0 2px;
|
||||
font-size: 10px;
|
||||
line-height: 1;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.ai-chat-attach-remove:hover {
|
||||
color: #dc3545;
|
||||
background: #f8d7da;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(8px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
|
||||
@@ -246,3 +246,72 @@
|
||||
background: #d1d5db;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* 附件上传按钮 */
|
||||
.o_ai_attach_btn {
|
||||
background: none;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 8px;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
color: #6b7280;
|
||||
transition: all 0.2s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.o_ai_attach_btn:hover {
|
||||
background: #f3f4f6;
|
||||
border-color: #7c3aed;
|
||||
color: #7c3aed;
|
||||
}
|
||||
|
||||
/* 附件预览区 */
|
||||
.o_ai_attach_preview {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
padding: 6px 12px;
|
||||
background: #f8f7f4;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.o_ai_attach_item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 10px;
|
||||
background: #fff;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
max-width: 200px;
|
||||
}
|
||||
.o_ai_attach_icon {
|
||||
color: #7c3aed;
|
||||
font-size: 13px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.o_ai_attach_name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: #374151;
|
||||
}
|
||||
.o_ai_attach_remove {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #9ca3af;
|
||||
cursor: pointer;
|
||||
padding: 0 2px;
|
||||
font-size: 10px;
|
||||
border-radius: 3px;
|
||||
flex-shrink: 0;
|
||||
line-height: 1;
|
||||
}
|
||||
.o_ai_attach_remove:hover {
|
||||
color: #ef4444;
|
||||
background: #fef2f2;
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ export class AiChatAction extends Component {
|
||||
inputText: "",
|
||||
loading: false,
|
||||
title: "AI 对话",
|
||||
attachments: [], // {attachment_id, filename, mimetype}
|
||||
});
|
||||
const params = this.props.action?.params || this.props.action?.context || {};
|
||||
this.resId = params.res_id || params.default_res_id || 0;
|
||||
@@ -46,24 +47,108 @@ export class AiChatAction extends Component {
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 文件上传 ----
|
||||
|
||||
triggerFileInput() {
|
||||
const el = document.getElementById("ai_chat_file_input");
|
||||
if (el) el.click();
|
||||
}
|
||||
|
||||
onFileSelected(ev) {
|
||||
const file = ev.target.files[0];
|
||||
if (!file) return;
|
||||
const allowed = ["image/png", "image/jpeg", "image/jpg", "image/bmp", "image/gif", "application/pdf"];
|
||||
if (!allowed.includes(file.type)) {
|
||||
alert("仅支持 PNG、JPG、BMP、GIF、PDF 文件");
|
||||
ev.target.value = "";
|
||||
return;
|
||||
}
|
||||
if (file.size > 10 * 1024 * 1024) {
|
||||
alert("文件不能超过 10MB");
|
||||
ev.target.value = "";
|
||||
return;
|
||||
}
|
||||
this._uploadFile(file);
|
||||
ev.target.value = "";
|
||||
}
|
||||
|
||||
onPaste(ev) {
|
||||
const items = (ev.clipboardData || ev.originalEvent.clipboardData).items;
|
||||
for (const item of items) {
|
||||
if (item.kind === "file" && item.type.startsWith("image/")) {
|
||||
ev.preventDefault();
|
||||
this._uploadFile(item.getAsFile());
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async _uploadFile(file) {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
if (this.resId) {
|
||||
formData.append("conversation_id", this.resId);
|
||||
}
|
||||
try {
|
||||
const resp = await fetch("/ai/attachment/upload", {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (data.success) {
|
||||
this.state.attachments = [
|
||||
...this.state.attachments,
|
||||
{
|
||||
attachment_id: data.attachment_id,
|
||||
filename: data.filename,
|
||||
mimetype: data.mimetype,
|
||||
},
|
||||
];
|
||||
} else {
|
||||
alert(data.error || "上传失败");
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("[AI] Upload failed:", e);
|
||||
alert("上传失败,请检查网络");
|
||||
}
|
||||
}
|
||||
|
||||
removeAttachment(idx) {
|
||||
this.state.attachments = this.state.attachments.filter((_, i) => i !== idx);
|
||||
}
|
||||
|
||||
// ---- 发送消息 ----
|
||||
|
||||
async sendMessage() {
|
||||
const text = this.state.inputText.trim();
|
||||
if (!text) return;
|
||||
const hasAttachments = this.state.attachments.length > 0;
|
||||
if (!text && !hasAttachments) return;
|
||||
if (!this.resId) return;
|
||||
|
||||
this.state.inputText = "";
|
||||
// 收集附件 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 + "]";
|
||||
}
|
||||
|
||||
this.state.inputText = "";
|
||||
this.state.attachments = [];
|
||||
this.state.loading = true;
|
||||
this.state.messages = [
|
||||
...this.state.messages,
|
||||
{ role: "user", content: text, date: "刚刚" },
|
||||
{ role: "user", content: displayText, date: "刚刚" },
|
||||
];
|
||||
|
||||
try {
|
||||
const res = await this.rpc("/ai/chat/send", {
|
||||
conversation_id: this.resId,
|
||||
message: text,
|
||||
});
|
||||
const params = { conversation_id: this.resId, message: displayText };
|
||||
if (attIds.length > 0) {
|
||||
params.attachment_ids = attIds;
|
||||
}
|
||||
const res = await this.rpc("/ai/chat/send", params);
|
||||
if (res.error) {
|
||||
this.state.messages = [
|
||||
...this.state.messages,
|
||||
@@ -95,6 +180,7 @@ export class AiChatAction extends Component {
|
||||
if (!confirm("确定清空所有消息?")) return;
|
||||
this.rpc("/ai/chat/clear", { conversation_id: this.resId });
|
||||
this.state.messages = [];
|
||||
this.state.attachments = [];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,8 +7,8 @@ import { onMounted } from "@odoo/owl";
|
||||
|
||||
/**
|
||||
* AI 浮动聊天窗口 — 右下角弹出式聊天面板
|
||||
* 支持对话切换、新建对话
|
||||
* Odoo 17 版本 — 使用 jsonrpc
|
||||
* 支持对话切换、新建对话、附件上传(图片/PDF → OCR 识别)
|
||||
* Odoo 17 版本 — 使用 jsonrpc + fetch (文件上传)
|
||||
*/
|
||||
|
||||
let convId = null;
|
||||
@@ -17,6 +17,7 @@ let chatEl = null;
|
||||
let isVisible = false;
|
||||
let isExpanded = false;
|
||||
let showConvList = false;
|
||||
let pendingAttachments = []; // {attachment_id, filename, mimetype} 待发送附件
|
||||
|
||||
function rpcCall(url, params) {
|
||||
return jsonrpc(url, params || {});
|
||||
@@ -64,6 +65,7 @@ async function createNewConv() {
|
||||
convId = res.id;
|
||||
convName = res.name || "新对话";
|
||||
showConvList = false;
|
||||
pendingAttachments = [];
|
||||
renderChat();
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -75,9 +77,110 @@ async function switchConv(id, name) {
|
||||
convId = id;
|
||||
convName = name || "对话";
|
||||
showConvList = false;
|
||||
pendingAttachments = [];
|
||||
renderChat();
|
||||
}
|
||||
|
||||
// ---- 文件上传 ----
|
||||
|
||||
async function uploadFile(file) {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
if (convId) {
|
||||
formData.append("conversation_id", convId);
|
||||
}
|
||||
try {
|
||||
const resp = await fetch("/ai/attachment/upload", {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (data.success) {
|
||||
pendingAttachments.push({
|
||||
attachment_id: data.attachment_id,
|
||||
filename: data.filename,
|
||||
mimetype: data.mimetype,
|
||||
ocr_text: data.ocr_text || "",
|
||||
});
|
||||
renderAttachmentPreview();
|
||||
} else {
|
||||
alert(data.error || "上传失败");
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("[AI] Upload failed:", e);
|
||||
alert("上传失败,请检查网络");
|
||||
}
|
||||
}
|
||||
|
||||
function triggerFileInput() {
|
||||
const input = document.getElementById("yuthon_file_input");
|
||||
if (input) input.click();
|
||||
}
|
||||
|
||||
function onFileSelected(e) {
|
||||
const file = e.target.files[0];
|
||||
if (!file) return;
|
||||
const allowed = ["image/png", "image/jpeg", "image/jpg", "image/bmp", "image/gif", "application/pdf"];
|
||||
if (!allowed.includes(file.type)) {
|
||||
alert("仅支持 PNG、JPG、BMP、GIF、PDF 文件");
|
||||
e.target.value = "";
|
||||
return;
|
||||
}
|
||||
if (file.size > 10 * 1024 * 1024) {
|
||||
alert("文件不能超过 10MB");
|
||||
e.target.value = "";
|
||||
return;
|
||||
}
|
||||
uploadFile(file);
|
||||
e.target.value = "";
|
||||
}
|
||||
|
||||
function onChatPaste(e) {
|
||||
const items = (e.clipboardData || e.originalEvent.clipboardData).items;
|
||||
for (const item of items) {
|
||||
if (item.kind === "file" && item.type.startsWith("image/")) {
|
||||
e.preventDefault();
|
||||
const file = item.getAsFile();
|
||||
uploadFile(file);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function renderAttachmentPreview() {
|
||||
const container = document.getElementById("yuthon_attach_preview");
|
||||
if (!container) return;
|
||||
if (pendingAttachments.length === 0) {
|
||||
container.innerHTML = "";
|
||||
container.style.display = "none";
|
||||
return;
|
||||
}
|
||||
container.style.display = "flex";
|
||||
container.innerHTML = pendingAttachments.map((att, idx) => {
|
||||
const isImage = att.mimetype.startsWith("image/");
|
||||
const icon = isImage ? "fa-file-image-o" : "fa-file-pdf-o";
|
||||
return `
|
||||
<div class="o_ai_attach_item" title="${escapeHtml(att.filename)}">
|
||||
<i class="fa ${icon} o_ai_attach_icon"></i>
|
||||
<span class="o_ai_attach_name">${escapeHtml(att.filename)}</span>
|
||||
<button class="o_ai_attach_remove" data-idx="${idx}" title="移除">
|
||||
<i class="fa fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
}).join("");
|
||||
container.querySelectorAll(".o_ai_attach_remove").forEach(btn => {
|
||||
btn.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
const idx = parseInt(btn.dataset.idx);
|
||||
pendingAttachments.splice(idx, 1);
|
||||
renderAttachmentPreview();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ---- 渲染 ----
|
||||
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement("div");
|
||||
div.textContent = text;
|
||||
@@ -133,9 +236,17 @@ function renderChat() {
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
<div id="yuthon_attach_preview" class="o_ai_attach_preview" style="display:${showConvList ? 'none' : 'flex'}">
|
||||
</div>
|
||||
<div class="o_ai_input_area" style="display:${showConvList ? 'none' : 'flex'}">
|
||||
<button id="yuthon_attach_btn" class="o_ai_attach_btn" title="上传图片或PDF">
|
||||
<i class="fa fa-paperclip"></i>
|
||||
</button>
|
||||
<input type="file" id="yuthon_file_input"
|
||||
accept="image/png,image/jpeg,image/jpg,image/bmp,image/gif,application/pdf"
|
||||
style="display:none"/>
|
||||
<textarea id="yuthon_chat_input" class="o_ai_input"
|
||||
placeholder="输入消息..." rows="1"></textarea>
|
||||
placeholder="输入消息... 支持粘贴图片" rows="1"></textarea>
|
||||
<button id="yuthon_chat_send" class="o_ai_send_btn">
|
||||
<i class="fa fa-paper-plane"></i>
|
||||
</button>
|
||||
@@ -150,9 +261,19 @@ function renderChat() {
|
||||
document.getElementById("yuthon_chat_send")?.addEventListener("click", sendMsg);
|
||||
document.getElementById("yuthon_conv_switch")?.addEventListener("click", toggleConvList);
|
||||
document.getElementById("yuthon_conv_new")?.addEventListener("click", createNewConv);
|
||||
document.getElementById("yuthon_chat_input")?.addEventListener("keydown", e => {
|
||||
if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); sendMsg(); }
|
||||
});
|
||||
document.getElementById("yuthon_attach_btn")?.addEventListener("click", triggerFileInput);
|
||||
document.getElementById("yuthon_file_input")?.addEventListener("change", onFileSelected);
|
||||
|
||||
const inputEl = document.getElementById("yuthon_chat_input");
|
||||
if (inputEl) {
|
||||
inputEl.addEventListener("keydown", e => {
|
||||
if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); sendMsg(); }
|
||||
});
|
||||
inputEl.addEventListener("paste", onChatPaste);
|
||||
}
|
||||
|
||||
// 恢复附件预览
|
||||
renderAttachmentPreview();
|
||||
|
||||
// 滚动到底部
|
||||
const msgsEl = document.getElementById("yuthon_chat_msgs");
|
||||
@@ -197,6 +318,7 @@ async function loadConvListUI() {
|
||||
if (delId === convId) {
|
||||
convId = null;
|
||||
convName = "";
|
||||
pendingAttachments = [];
|
||||
}
|
||||
loadConvListUI();
|
||||
} catch (err) {
|
||||
@@ -214,17 +336,28 @@ function toggleConvList() {
|
||||
async function sendMsg() {
|
||||
const input = document.getElementById("yuthon_chat_input");
|
||||
const text = input?.value.trim();
|
||||
if (!text) return;
|
||||
const hasAttachments = pendingAttachments.length > 0;
|
||||
if (!text && !hasAttachments) return;
|
||||
input.value = "";
|
||||
|
||||
const id = await ensureConv();
|
||||
if (!id) return;
|
||||
|
||||
// 收集附件 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 + "]");
|
||||
}
|
||||
|
||||
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(text)}</div>
|
||||
<div class="o_ai_msg_bubble">${escapeHtml(displayText)}</div>
|
||||
<div class="o_ai_msg_time">刚刚</div>
|
||||
</div>
|
||||
<div id="yuthon_loading" class="o_ai_loading">
|
||||
@@ -234,6 +367,10 @@ async function sendMsg() {
|
||||
msgsEl.scrollTop = msgsEl.scrollHeight;
|
||||
}
|
||||
|
||||
// 清空附件列表
|
||||
pendingAttachments = [];
|
||||
renderAttachmentPreview();
|
||||
|
||||
const loadingTimer = setTimeout(() => {
|
||||
const el = document.getElementById("yuthon_loading");
|
||||
if (el) {
|
||||
@@ -241,11 +378,14 @@ async function sendMsg() {
|
||||
}
|
||||
}, 5000);
|
||||
|
||||
// 构建请求参数
|
||||
const params = { conversation_id: id, message: displayText };
|
||||
if (attIds.length > 0) {
|
||||
params.attachment_ids = attIds;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await rpcCall("/ai/chat/send", {
|
||||
conversation_id: id,
|
||||
message: text,
|
||||
});
|
||||
const res = await rpcCall("/ai/chat/send", params);
|
||||
clearTimeout(loadingTimer);
|
||||
const content = res.error || res.content || "无响应";
|
||||
document.getElementById("yuthon_loading")?.remove();
|
||||
|
||||
@@ -24,12 +24,39 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 附件预览 -->
|
||||
<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 t-if="att.mimetype.startsWith('image/')">
|
||||
<i class="fa fa-file-image-o me-1"/>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<i class="fa fa-file-pdf-o me-1"/>
|
||||
</t>
|
||||
<t t-esc="att.filename"/>
|
||||
<button class="ai-chat-attach-remove" t-on-click="() => removeAttachment(att_index)">
|
||||
<i class="fa fa-times"/>
|
||||
</button>
|
||||
</span>
|
||||
</t>
|
||||
</div>
|
||||
|
||||
<!-- 输入区 -->
|
||||
<div class="ai-chat-input-area p-2 border-top d-flex gap-2 align-items-center">
|
||||
<button class="btn btn-outline-secondary btn-sm ai-chat-attach-btn"
|
||||
t-on-click="triggerFileInput" title="上传图片或PDF(支持 Ctrl+V 粘贴图片)">
|
||||
<i class="fa fa-paperclip"/>
|
||||
</button>
|
||||
<input type="file" id="ai_chat_file_input"
|
||||
accept="image/png,image/jpeg,image/jpg,image/bmp,image/gif,application/pdf"
|
||||
t-on-change="onFileSelected"
|
||||
style="display:none"/>
|
||||
<input type="text" class="form-control"
|
||||
placeholder="输入消息,按 Enter 发送..."
|
||||
placeholder="输入消息,按 Enter 发送... 支持粘贴图片"
|
||||
t-model="state.inputText"
|
||||
t-on-keydown="onKeydown"
|
||||
t-on-paste="onPaste"
|
||||
t-att-disabled="state.loading"/>
|
||||
<button class="btn btn-primary"
|
||||
t-on-click="sendMessage"
|
||||
|
||||
@@ -117,6 +117,26 @@
|
||||
绑定的规则将根据用户组自动生效:回复指令 → 注入 AI 系统提示词;限制规则 → 禁止回答特定话题;技能接口 → 注册为 AI 可调用工具。
|
||||
</p>
|
||||
</page>
|
||||
<page string="OCR 配置" name="ocr">
|
||||
<group string="腾讯云 OCR(附件图片/PDF 文字识别)">
|
||||
<group>
|
||||
<field name="ocr_enabled"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="ocr_region"/>
|
||||
</group>
|
||||
</group>
|
||||
<group string="API 密钥" invisible="not ocr_enabled">
|
||||
<field name="ocr_secret_id"
|
||||
placeholder="在 https://console.cloud.tencent.com/cam/capi 获取"/>
|
||||
<field name="ocr_secret_key" password="True"
|
||||
placeholder="腾讯云 SecretKey"/>
|
||||
</group>
|
||||
<p class="text-muted" invisible="not ocr_enabled">
|
||||
密钥在 <a href="https://console.cloud.tencent.com/cam/capi" target="_blank">腾讯云 API 密钥管理</a> 获取。
|
||||
每月免费 1000 次调用,超出按量计费(约 0.15 元/次)。
|
||||
</p>
|
||||
</page>
|
||||
</notebook>
|
||||
</sheet>
|
||||
</form>
|
||||
|
||||
@@ -223,23 +223,7 @@
|
||||
</field>
|
||||
</page>
|
||||
|
||||
<page string="图表" name="charts">
|
||||
<field name="chart_ids" nolabel="1">
|
||||
<tree string="图表配置" editable="bottom">
|
||||
<field name="model_id" column_invisible="1"/>
|
||||
<field name="title" placeholder="图表标题"/>
|
||||
<field name="chart_type"/>
|
||||
<field name="label_field_id"
|
||||
placeholder="分组字段..."
|
||||
options="{'no_create': True}"/>
|
||||
<field name="value_field_id"
|
||||
placeholder="数值字段(可空)..."
|
||||
options="{'no_create': True}"/>
|
||||
<field name="calc_method"/>
|
||||
<field name="sequence" widget="handle"/>
|
||||
</tree>
|
||||
</field>
|
||||
</page>
|
||||
<!-- 图表 tab 已隐藏:前端H5报表自带快速图表控件,无需后端配置 -->
|
||||
|
||||
<page string="AI 参数" name="params" invisible="1">
|
||||
<field name="param_def_ids" nolabel="1">
|
||||
|
||||
Reference in New Issue
Block a user