640 lines
25 KiB
JavaScript
640 lines
25 KiB
JavaScript
/** @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, '&').replace(/'/g, ''').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>');
|
||
}
|
||
|
||
/**
|
||
* 将消息文本渲染为安全的 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(/&/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(/&/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);
|
||
},
|
||
});
|