Files
gzth/yuthon_ai_agent/static/src/js/ai_floating.js
T
2026-07-01 20:21:44 +08:00

640 lines
25 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/** @odoo-module **/
import { jsonrpc } from "@web/core/network/rpc_service";
import { NavBar } from "@web/webclient/navbar/navbar";
import { patch } from "@web/core/utils/patch";
import { onMounted } from "@odoo/owl";
/**
* AI 浮动聊天窗口 — 右下角弹出式聊天面板
* 支持对话切换、新建对话、附件上传(图片/PDF → OCR 识别)
* Odoo 17 版本 — 使用 jsonrpc + fetch (文件上传)
*/
let convId = null;
let convName = "";
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 || {});
}
async function ensureConv() {
if (convId) return convId;
try {
const res = await rpcCall("/ai/conversation/get_or_create_personal", {});
if (res && res.id) {
convId = res.id;
convName = res.name || "对话";
}
} catch (e) {
console.error("[AI] Failed to get/create conversation:", e);
}
return convId;
}
async function loadHistory() {
const id = await ensureConv();
if (!id) return [];
try {
const data = await rpcCall("/ai/chat/history", { conversation_id: id });
if (data.error) return [];
return data.messages || [];
} catch (e) {
return [];
}
}
async function loadConversations() {
try {
const data = await rpcCall("/ai/conversation/list", {});
return data.conversations || [];
} catch (e) {
return [];
}
}
async function createNewConv() {
try {
const res = await rpcCall("/ai/conversation/new", {});
if (res && res.id) {
convId = res.id;
convName = res.name || "新对话";
showConvList = false;
pendingAttachments = [];
renderChat();
}
} catch (e) {
console.error("[AI] Failed to create conversation:", e);
}
}
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,
credentials: "same-origin",
});
// 先读 text,再尝试解析 JSON(避免 body stream already read)
const text = await resp.text();
let data;
try {
data = JSON.parse(text);
} catch (_) {
data = null;
}
if (!resp.ok) {
let detail = (data && data.error) || text.substring(0, 300);
console.error("[AI] Upload HTTP", resp.status, detail);
alert("上传失败 (HTTP " + resp.status + ")\n" + detail);
return;
}
if (data && data.success) {
// 诊断日志:查看 OCR 各环节状态
console.log("[AI] 上传成功:", data.filename, "| OCR文字:", (data.ocr_text || '(无)').substring(0, 100));
if (data._ocr_diag) {
console.log("[AI] OCR诊断:", JSON.stringify(data._ocr_diag));
}
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 {
alert((data && data.error) || "上传失败");
}
} catch (e) {
console.error("[AI] Upload failed:", e);
alert("上传失败,请检查网络\n" + e.message);
}
}
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 IMG_TYPES = ["image/png", "image/jpeg", "image/jpg", "image/bmp", "image/gif"];
const EXCEL_TYPES = ["application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"application/vnd.ms-excel", "text/csv"];
const ALLOWED = IMG_TYPES.concat(["application/pdf"], EXCEL_TYPES);
const isExcelByName = file.name && /\\.(xlsx|xls|csv)$/i.test(file.name);
if (!ALLOWED.includes(file.type) && !isExcelByName) {
alert("仅支持 PNG、JPG、BMP、GIF、PDF、Excel(.xlsx/.xls/.csv)文件");
e.target.value = "";
return;
}
const isExcel = EXCEL_TYPES.includes(file.type) || isExcelByName;
const maxSize = isExcel ? 20 * 1024 * 1024 : 10 * 1024 * 1024;
const sizeLabel = isExcel ? "20MB" : "10MB";
if (file.size > maxSize) {
alert("文件不能超过 " + sizeLabel);
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";
// 三种状态:有文字 ✅ / 无文字 📷(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>
${statusHtml}
<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;
return div.innerHTML;
}
/** HTML 属性安全转义(用于 data-action 的 JSON) */
function escapeAttr(text) {
return text.replace(/&/g, '&amp;').replace(/'/g, '&#39;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
/**
* 将消息文本渲染为安全的 HTML:转义 → URL 转精美链接卡片 → 换行转 <br>
* 注意:不论内容是否被附件标记 strip 成空串,都走完整的 URL 卡片流程
*/
function renderContent(text) {
const safe = escapeHtml(text || '');
// 先移除附件标记行(这些会在消息气泡外单独渲染)
let display = safe
.replace(/【附件:[^】]+】[\s\S]*?--- 附件内容结束 ---/g, '')
.replace(/\[附件[::]\s*[^\]]+\]\n?/g, '')
.trim() || safe; // 若 strip 后为空,回退到 safe 继续处理 URL
// 1) 先处理 Markdown 风格链接 [显示名](url) → 卡片使用自定义名称
display = display.replace(
/\[([^\]]+)\]\((https?:\/\/[^\s<>"')\u4e00-\u9fff\u3000-\u303f\uff00-\uffef]+)\)/g,
(_, label, url) => {
const rawUrl = url.replace(/&amp;/g, '&');
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">${escapeHtml(label)}</span></a>`;
}
);
// 2) 再处理裸 URL → 域名作为卡片名称
display = display.replace(
/(https?:\/\/[^\s<>"'\u4e00-\u9fff\u3000-\u303f\uff00-\uffef]+)/g,
(url) => {
const rawUrl = url.replace(/&amp;/g, '&');
let domain = '';
try {
domain = new URL(rawUrl).hostname;
} catch (_) {
domain = 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">${escapeHtml(domain)}</span></a>`;
}
);
// 换行转 <br>
display = display.replace(/\n/g, '<br>');
return display;
}
// 渲染一条消息(含附件预览)
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() {
if (!chatEl) return;
chatEl.innerHTML = "";
if (!isVisible) return;
const wrapper = document.createElement("div");
wrapper.className = "o_ai_floating_chat_wrapper" + (isExpanded ? " expanded" : "");
loadHistory().then(msgs => {
wrapper.innerHTML = `
<div class="o_ai_floating_chat">
<div class="o_ai_chat_header">
<div class="o_ai_header_left">
<button id="yuthon_conv_switch" class="o_ai_btn_icon o_ai_conv_switch" title="切换对话">
<i class="fa fa-list"></i>
</button>
<span class="o_ai_chat_title">${escapeHtml(convName || 'AI 助手')}</span>
</div>
<div class="o_ai_header_actions">
<button id="yuthon_conv_new" class="o_ai_btn_icon" title="新对话">
<i class="fa fa-plus"></i>
</button>
<button id="yuthon_chat_expand" class="o_ai_btn_icon" title="${isExpanded ? '缩小' : '展开'}">
<i class="fa ${isExpanded ? 'fa-compress' : 'fa-expand'}"></i>
</button>
<button id="yuthon_chat_close" class="o_ai_btn_icon" title="关闭">
<i class="fa fa-times"></i>
</button>
</div>
</div>
<div id="yuthon_conv_list" class="o_ai_conv_list" style="display:${showConvList ? 'block' : 'none'}">
<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 => renderMessageHtml(m, true)).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/Excel">
<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,.xlsx,.xls,.csv"
style="display:none"/>
<textarea id="yuthon_chat_input" class="o_ai_input"
placeholder="输入消息... 支持粘贴图片" rows="1"></textarea>
<button id="yuthon_chat_send" class="o_ai_send_btn">
<i class="fa fa-paper-plane"></i>
</button>
</div>
</div>
`;
chatEl.appendChild(wrapper);
// 绑定事件
document.getElementById("yuthon_chat_close")?.addEventListener("click", toggleChat);
document.getElementById("yuthon_chat_expand")?.addEventListener("click", toggleExpand);
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_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");
if (msgsEl) setTimeout(() => msgsEl.scrollTop = msgsEl.scrollHeight, 50);
if (showConvList) {
loadConvListUI();
}
});
}
async function loadConvListUI() {
const listEl = document.getElementById("yuthon_conv_list");
if (!listEl) return;
const convs = await loadConversations();
if (convs.length === 0) {
listEl.innerHTML = `<div class="o_ai_conv_empty">暂无对话,点击 + 新建</div>`;
return;
}
listEl.innerHTML = convs.map(c => `
<div class="o_ai_conv_item ${c.id === convId ? 'active' : ''}" data-id="${c.id}" data-name="${escapeHtml(c.name)}">
<span class="o_ai_conv_item_name">${escapeHtml(c.name)}</span>
<span class="o_ai_conv_item_count">${c.message_count} 条</span>
<button class="o_ai_conv_del" data-id="${c.id}" title="删除">
<i class="fa fa-trash-o"></i>
</button>
</div>
`).join('');
listEl.querySelectorAll(".o_ai_conv_item").forEach(el => {
el.addEventListener("click", (e) => {
if (e.target.closest('.o_ai_conv_del')) return;
switchConv(parseInt(el.dataset.id), el.dataset.name);
});
});
listEl.querySelectorAll(".o_ai_conv_del").forEach(btn => {
btn.addEventListener("click", async (e) => {
e.stopPropagation();
const delId = parseInt(btn.dataset.id);
if (!confirm("确定删除这个对话吗?")) return;
try {
await rpcCall("/ai/conversation/delete", { conversation_id: delId });
if (delId === convId) {
convId = null;
convName = "";
pendingAttachments = [];
}
loadConvListUI();
} catch (err) {
console.error("[AI] Delete failed:", err);
}
});
});
}
function toggleConvList() {
showConvList = !showConvList;
renderChat();
}
async function sendMsg() {
const input = document.getElementById("yuthon_chat_input");
const text = input?.value.trim();
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);
// 构建附件渲染 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(text || '')}</div>
${attachHtml}
<div class="o_ai_msg_time">刚刚</div>
</div>
<div id="yuthon_loading" class="o_ai_loading">
<i class="fa fa-spinner fa-spin"></i> AI 思考中...
</div>
`;
msgsEl.scrollTop = msgsEl.scrollHeight;
}
// 清空附件列表
pendingAttachments = [];
renderAttachmentPreview();
const loadingTimer = setTimeout(() => {
const el = document.getElementById("yuthon_loading");
if (el) {
el.innerHTML = '<i class="fa fa-spinner fa-spin"></i> 正在处理,请稍候...';
}
}, 5000);
// 构建请求参数(发送原始文本,不拼接附件信息)
const params = { conversation_id: id, message: text || '' };
if (attIds.length > 0) {
params.attachment_ids = attIds;
}
try {
const res = await rpcCall("/ai/chat/send", params);
clearTimeout(loadingTimer);
const content = res.error || res.content || "无响应";
const action = res.action;
document.getElementById("yuthon_loading")?.remove();
if (msgsEl) {
const actionHtml = action ? `
<div class="o_ai_action_wrap">
<button class="o_ai_action_btn" data-action='${escapeAttr(JSON.stringify(action))}'>
<i class="fa fa-arrow-right"></i> ${escapeHtml(action.name || '打开页面')}
</button>
</div>` : '';
msgsEl.innerHTML += `
<div class="o_ai_msg o_ai_msg_ai">
<div class="o_ai_msg_bubble">${renderContent(content)}${actionHtml}</div>
<div class="o_ai_msg_time">刚刚</div>
</div>
`;
msgsEl.scrollTop = msgsEl.scrollHeight;
// 绑定 action 按钮点击事件
if (action) {
msgsEl.querySelectorAll('.o_ai_action_btn:not(.o_ai_bound)').forEach(btn => {
btn.classList.add('o_ai_bound');
btn.addEventListener('click', function () {
const act = JSON.parse(this.dataset.action);
doOdooAction(act);
});
});
}
}
if (convName === '新对话' || convName === '个人对话') {
convName = text.substring(0, 20) + (text.length > 20 ? '...' : '');
const titleEl = document.querySelector(".o_ai_chat_title");
if (titleEl) titleEl.textContent = convName;
}
} catch (e) {
clearTimeout(loadingTimer);
document.getElementById("yuthon_loading")?.remove();
if (msgsEl) {
msgsEl.innerHTML += `
<div class="o_ai_msg o_ai_msg_ai">
<div class="o_ai_msg_bubble">网络错误,请重试</div>
</div>
`;
}
}
}
function toggleExpand() {
isExpanded = !isExpanded;
renderChat();
}
/**
* 通过 Odoo webclient 的 action service 执行导航。
* 也可被 AI 消息中的操作按钮调用。
*/
function doOdooAction(action) {
if (!action) return;
let usedService = false;
try {
// 尝试通过 OWL app 的 action service 执行(无刷新导航)
const appEl = document.querySelector('.o_web_client');
if (appEl && appEl.__owl__ && appEl.__owl__.component) {
const env = appEl.__owl__.component.env;
if (env && env.services && env.services.action) {
env.services.action.doAction(action, { clearBreadcrumbs: true });
usedService = true;
return;
}
}
} catch (e) {
console.warn('[AI] 无法访问 action service,降级为 URL 跳转:', e);
}
if (usedService) return;
// 降级方案:构造正确的 Odoo URL hash
if (action.type === 'ir.actions.act_window') {
const parts = [];
// 优先用真实 action id 生成最短路径,没有 id 时塞整个 action JSON
if (action.id) {
parts.push('action=' + action.id);
} else {
parts.push('action=' + encodeURIComponent(JSON.stringify(action)));
}
if (action.res_model) parts.push('model=' + encodeURIComponent(action.res_model));
if (action.res_id) parts.push('id=' + action.res_id);
const viewType = action.res_id ? 'form' : ((action.view_mode || 'list').split(',')[0]);
parts.push('view_type=' + viewType);
window.location.hash = parts.join('&');
} else if (action.type === 'ir.actions.client') {
window.location.hash = '#action=' + encodeURIComponent(JSON.stringify(action));
}
}
function toggleChat() {
isVisible = !isVisible;
showConvList = false;
if (isVisible) {
if (!chatEl) {
chatEl = document.createElement("div");
chatEl.id = "yuthon_ai_floating";
document.body.appendChild(chatEl);
}
renderChat();
} else if (chatEl) {
chatEl.innerHTML = "";
}
}
// ====== 通过 patch NavBar 动态插入 AI 按钮 ======
patch(NavBar.prototype, {
setup() {
super.setup(...arguments);
onMounted(() => {
this._insertAiButton();
});
},
_insertAiButton() {
const anchor = document.getElementById("show_top_bar");
if (!anchor || document.getElementById("ai_chat_toggle")) return;
const btn = document.createElement("span");
btn.id = "ai_chat_toggle";
btn.className = "o_ai_navbar_btn";
btn.title = "AI 助手";
btn.style.cssText = "margin-left: 12px;margin-top: 0px; font-size: 20px; cursor: pointer; color: white;";
btn.innerHTML = '<i class="fa fa-bolt" aria-hidden="true"></i>';
btn.addEventListener("click", () => toggleChat());
anchor.after(btn);
},
});