update
This commit is contained in:
544
fusion_clock_ai/static/src/css/portal_ai.css
Normal file
544
fusion_clock_ai/static/src/css/portal_ai.css
Normal file
@@ -0,0 +1,544 @@
|
||||
/* Copyright 2026 Nexa Systems Inc. */
|
||||
/* License OPL-1 (Odoo Proprietary License v1.0) */
|
||||
/* Portal AI Chat Widget Styles */
|
||||
|
||||
/* ================================================================
|
||||
Container
|
||||
================================================================ */
|
||||
.fclk-ai-portal-chat {
|
||||
position: fixed;
|
||||
bottom: 24px;
|
||||
right: 24px;
|
||||
z-index: 10500;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
}
|
||||
|
||||
/* ================================================================
|
||||
Floating Bubble
|
||||
================================================================ */
|
||||
.fclk-ai-portal-bubble {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
background: linear-gradient(135deg, #7c3aed 0%, #a855f7 100%);
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 4px 16px rgba(124, 58, 237, 0.4), 0 2px 6px rgba(0, 0, 0, 0.15);
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease, background 0.2s ease;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.fclk-ai-portal-bubble:hover {
|
||||
transform: scale(1.08);
|
||||
box-shadow: 0 6px 24px rgba(124, 58, 237, 0.5), 0 3px 10px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.fclk-ai-portal-bubble:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.fclk-ai-portal-bubble--open {
|
||||
background: linear-gradient(135deg, #6d28d9 0%, #7c3aed 100%);
|
||||
}
|
||||
|
||||
/* ================================================================
|
||||
Chat Panel
|
||||
================================================================ */
|
||||
.fclk-ai-portal-panel {
|
||||
position: absolute;
|
||||
bottom: 64px;
|
||||
right: 0;
|
||||
width: 360px;
|
||||
max-height: 480px;
|
||||
background: #fff;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.15), 0 4px 12px rgba(0, 0, 0, 0.08);
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
opacity: 0;
|
||||
transform: translateY(12px) scale(0.96);
|
||||
transition: opacity 0.25s ease, transform 0.25s ease;
|
||||
}
|
||||
|
||||
.fclk-ai-portal-panel.fclk-ai-pp--visible {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
|
||||
/* ================================================================
|
||||
Panel Header
|
||||
================================================================ */
|
||||
.fclk-ai-pp-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 14px 16px;
|
||||
background: linear-gradient(135deg, #7c3aed 0%, #a855f7 100%);
|
||||
color: #fff;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.fclk-ai-pp-header-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.fclk-ai-pp-avatar {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.fclk-ai-pp-title {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.fclk-ai-pp-subtitle {
|
||||
font-size: 11px;
|
||||
opacity: 0.85;
|
||||
line-height: 1.2;
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
.fclk-ai-pp-close {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
border-radius: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.fclk-ai-pp-close:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
/* ================================================================
|
||||
Messages Area
|
||||
================================================================ */
|
||||
.fclk-ai-pp-msgs {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 14px 14px 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
min-height: 180px;
|
||||
max-height: 280px;
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
.fclk-ai-pp-msgs::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
|
||||
.fclk-ai-pp-msgs::-webkit-scrollbar-thumb {
|
||||
background: rgba(0, 0, 0, 0.12);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.fclk-ai-portal-msg {
|
||||
display: flex;
|
||||
max-width: 85%;
|
||||
animation: fclk-ai-msg-in 0.2s ease-out;
|
||||
}
|
||||
|
||||
.fclk-ai-portal-msg--user {
|
||||
align-self: flex-end;
|
||||
}
|
||||
|
||||
.fclk-ai-portal-msg--assistant {
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.fclk-ai-portal-msg-text {
|
||||
padding: 10px 14px;
|
||||
border-radius: 14px;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
word-wrap: break-word;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.fclk-ai-portal-msg--user .fclk-ai-portal-msg-text {
|
||||
background: linear-gradient(135deg, #7c3aed, #a855f7);
|
||||
color: #fff;
|
||||
border-bottom-right-radius: 4px;
|
||||
}
|
||||
|
||||
.fclk-ai-portal-msg--assistant .fclk-ai-portal-msg-text {
|
||||
background: #f3f4f6;
|
||||
color: #1f2937;
|
||||
border-bottom-left-radius: 4px;
|
||||
}
|
||||
|
||||
@keyframes fclk-ai-msg-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(6px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* ================================================================
|
||||
Typing Indicator
|
||||
================================================================ */
|
||||
.fclk-ai-portal-typing .fclk-ai-portal-dots {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
padding: 10px 16px;
|
||||
background: #f3f4f6;
|
||||
border-radius: 14px;
|
||||
border-bottom-left-radius: 4px;
|
||||
}
|
||||
|
||||
.fclk-ai-portal-dots span {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
background: #9ca3af;
|
||||
border-radius: 50%;
|
||||
animation: fclk-ai-dot-bounce 1.2s infinite ease-in-out;
|
||||
}
|
||||
|
||||
.fclk-ai-portal-dots span:nth-child(2) {
|
||||
animation-delay: 0.15s;
|
||||
}
|
||||
|
||||
.fclk-ai-portal-dots span:nth-child(3) {
|
||||
animation-delay: 0.3s;
|
||||
}
|
||||
|
||||
@keyframes fclk-ai-dot-bounce {
|
||||
0%, 60%, 100% { transform: translateY(0); }
|
||||
30% { transform: translateY(-5px); }
|
||||
}
|
||||
|
||||
/* ================================================================
|
||||
Quick Actions
|
||||
================================================================ */
|
||||
.fclk-ai-pp-quick {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
padding: 8px 14px;
|
||||
flex-wrap: wrap;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.fclk-ai-pp-qbtn {
|
||||
background: #faf5ff;
|
||||
border: 1px solid #e9d5ff;
|
||||
border-radius: 20px;
|
||||
padding: 5px 12px;
|
||||
font-size: 11.5px;
|
||||
color: #7c3aed;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
white-space: nowrap;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.fclk-ai-pp-qbtn:hover {
|
||||
background: #ede9fe;
|
||||
border-color: #c4b5fd;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.fclk-ai-pp-qbtn:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.fclk-ai-pp-qbtn .fa {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
/* ================================================================
|
||||
Input Row
|
||||
================================================================ */
|
||||
.fclk-ai-pp-input-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 14px 14px;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.fclk-ai-pp-input {
|
||||
flex: 1;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 10px;
|
||||
padding: 9px 14px;
|
||||
font-size: 13px;
|
||||
outline: none;
|
||||
transition: border-color 0.15s ease, box-shadow 0.15s ease;
|
||||
background: #fafafa;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.fclk-ai-pp-input:focus {
|
||||
border-color: #a78bfa;
|
||||
box-shadow: 0 0 0 3px rgba(167, 139, 250, 0.15);
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.fclk-ai-pp-input::placeholder {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.fclk-ai-pp-send {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 10px;
|
||||
border: none;
|
||||
background: linear-gradient(135deg, #7c3aed, #a855f7);
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
transition: opacity 0.15s ease, transform 0.1s ease;
|
||||
}
|
||||
|
||||
.fclk-ai-pp-send:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.fclk-ai-pp-send:not(:disabled):hover {
|
||||
transform: scale(1.06);
|
||||
}
|
||||
|
||||
.fclk-ai-pp-send:not(:disabled):active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
/* ================================================================
|
||||
Polish Overlay
|
||||
================================================================ */
|
||||
.fclk-ai-pp-polish-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(255, 255, 255, 0.97);
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
padding: 24px 20px;
|
||||
gap: 14px;
|
||||
z-index: 5;
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
.fclk-ai-pp-polish-title {
|
||||
font-weight: 600;
|
||||
font-size: 15px;
|
||||
color: #7c3aed;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.fclk-ai-pp-polish-title .fa {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.fclk-ai-pp-polish-input {
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 10px;
|
||||
padding: 10px 14px;
|
||||
font-size: 13px;
|
||||
resize: none;
|
||||
outline: none;
|
||||
font-family: inherit;
|
||||
transition: border-color 0.15s ease, box-shadow 0.15s ease;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.fclk-ai-pp-polish-input:focus {
|
||||
border-color: #a78bfa;
|
||||
box-shadow: 0 0 0 3px rgba(167, 139, 250, 0.15);
|
||||
}
|
||||
|
||||
.fclk-ai-pp-polish-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.fclk-ai-pp-polish-cancel {
|
||||
padding: 8px 16px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e5e7eb;
|
||||
background: #fff;
|
||||
color: #6b7280;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.fclk-ai-pp-polish-cancel:hover {
|
||||
background: #f3f4f6;
|
||||
}
|
||||
|
||||
.fclk-ai-pp-polish-send {
|
||||
padding: 8px 20px;
|
||||
border-radius: 8px;
|
||||
border: none;
|
||||
background: linear-gradient(135deg, #7c3aed, #a855f7);
|
||||
color: #fff;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
|
||||
.fclk-ai-pp-polish-send:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.fclk-ai-pp-polish-send:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* ================================================================
|
||||
Dark Theme Support (Odoo portal dark mode)
|
||||
================================================================ */
|
||||
html[data-color-scheme="dark"] .fclk-ai-portal-panel,
|
||||
.o_dark .fclk-ai-portal-panel {
|
||||
background: #1e1e2e;
|
||||
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.4), 0 4px 12px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
html[data-color-scheme="dark"] .fclk-ai-portal-msg--assistant .fclk-ai-portal-msg-text,
|
||||
.o_dark .fclk-ai-portal-msg--assistant .fclk-ai-portal-msg-text {
|
||||
background: #2a2a3e;
|
||||
color: #e5e7eb;
|
||||
}
|
||||
|
||||
html[data-color-scheme="dark"] .fclk-ai-portal-typing .fclk-ai-portal-dots,
|
||||
.o_dark .fclk-ai-portal-typing .fclk-ai-portal-dots {
|
||||
background: #2a2a3e;
|
||||
}
|
||||
|
||||
html[data-color-scheme="dark"] .fclk-ai-portal-dots span,
|
||||
.o_dark .fclk-ai-portal-dots span {
|
||||
background: #6b7280;
|
||||
}
|
||||
|
||||
html[data-color-scheme="dark"] .fclk-ai-pp-quick,
|
||||
.o_dark .fclk-ai-pp-quick {
|
||||
border-top-color: #333;
|
||||
}
|
||||
|
||||
html[data-color-scheme="dark"] .fclk-ai-pp-qbtn,
|
||||
.o_dark .fclk-ai-pp-qbtn {
|
||||
background: #2a2a3e;
|
||||
border-color: #444;
|
||||
color: #c4b5fd;
|
||||
}
|
||||
|
||||
html[data-color-scheme="dark"] .fclk-ai-pp-qbtn:hover,
|
||||
.o_dark .fclk-ai-pp-qbtn:hover {
|
||||
background: #333;
|
||||
border-color: #7c3aed;
|
||||
}
|
||||
|
||||
html[data-color-scheme="dark"] .fclk-ai-pp-input-row,
|
||||
.o_dark .fclk-ai-pp-input-row {
|
||||
border-top-color: #333;
|
||||
}
|
||||
|
||||
html[data-color-scheme="dark"] .fclk-ai-pp-input,
|
||||
.o_dark .fclk-ai-pp-input {
|
||||
background: #2a2a3e;
|
||||
border-color: #444;
|
||||
color: #e5e7eb;
|
||||
}
|
||||
|
||||
html[data-color-scheme="dark"] .fclk-ai-pp-input:focus,
|
||||
.o_dark .fclk-ai-pp-input:focus {
|
||||
background: #1e1e2e;
|
||||
border-color: #7c3aed;
|
||||
}
|
||||
|
||||
html[data-color-scheme="dark"] .fclk-ai-pp-input::placeholder,
|
||||
.o_dark .fclk-ai-pp-input::placeholder {
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
html[data-color-scheme="dark"] .fclk-ai-pp-polish-overlay,
|
||||
.o_dark .fclk-ai-pp-polish-overlay {
|
||||
background: rgba(30, 30, 46, 0.97);
|
||||
}
|
||||
|
||||
html[data-color-scheme="dark"] .fclk-ai-pp-polish-input,
|
||||
.o_dark .fclk-ai-pp-polish-input {
|
||||
background: #2a2a3e;
|
||||
border-color: #444;
|
||||
color: #e5e7eb;
|
||||
}
|
||||
|
||||
html[data-color-scheme="dark"] .fclk-ai-pp-polish-cancel,
|
||||
.o_dark .fclk-ai-pp-polish-cancel {
|
||||
background: #2a2a3e;
|
||||
border-color: #444;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
html[data-color-scheme="dark"] .fclk-ai-pp-polish-cancel:hover,
|
||||
.o_dark .fclk-ai-pp-polish-cancel:hover {
|
||||
background: #333;
|
||||
}
|
||||
|
||||
/* ================================================================
|
||||
Responsive: Full-width below 480px
|
||||
================================================================ */
|
||||
@media (max-width: 480px) {
|
||||
.fclk-ai-portal-chat {
|
||||
bottom: 16px;
|
||||
right: 16px;
|
||||
left: 16px;
|
||||
}
|
||||
|
||||
.fclk-ai-portal-bubble {
|
||||
position: fixed;
|
||||
bottom: 16px;
|
||||
right: 16px;
|
||||
}
|
||||
|
||||
.fclk-ai-portal-panel {
|
||||
position: fixed;
|
||||
bottom: 80px;
|
||||
right: 16px;
|
||||
left: 16px;
|
||||
width: auto;
|
||||
max-height: calc(100dvh - 120px);
|
||||
}
|
||||
|
||||
.fclk-ai-pp-msgs {
|
||||
max-height: none;
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
BIN
fusion_clock_ai/static/src/img/ai_icon.png
Normal file
BIN
fusion_clock_ai/static/src/img/ai_icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 33 KiB |
158
fusion_clock_ai/static/src/js/ai_chat_backend.js
Normal file
158
fusion_clock_ai/static/src/js/ai_chat_backend.js
Normal file
@@ -0,0 +1,158 @@
|
||||
/** @odoo-module **/
|
||||
// Copyright 2026 Nexa Systems Inc.
|
||||
// License OPL-1 (Odoo Proprietary License v1.0)
|
||||
|
||||
import { Component, useState, useRef, onMounted, onWillUnmount } from "@odoo/owl";
|
||||
import { Dropdown } from "@web/core/dropdown/dropdown";
|
||||
import { DropdownItem } from "@web/core/dropdown/dropdown_item";
|
||||
import { useDropdownState } from "@web/core/dropdown/dropdown_hooks";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
import { rpc } from "@web/core/network/rpc";
|
||||
|
||||
const SUGGESTIONS = [
|
||||
"Who had the most overtime this week?",
|
||||
"Show me late arrivals today",
|
||||
"Absence summary this month",
|
||||
"Which team is understaffed?",
|
||||
];
|
||||
|
||||
const TOOLS = [
|
||||
{ key: "anomaly", icon: "fa-exclamation-triangle", label: "Anomaly Scan" },
|
||||
{ key: "understaffing", icon: "fa-users", label: "Staffing Forecast" },
|
||||
{ key: "shift", icon: "fa-calendar", label: "Shift Optimizer" },
|
||||
{ key: "compliance", icon: "fa-gavel", label: "Compliance Check" },
|
||||
{ key: "geofence", icon: "fa-map-marker", label: "Geofence Tuning" },
|
||||
];
|
||||
|
||||
export class FusionClockAIChat extends Component {
|
||||
static props = {};
|
||||
static template = "fusion_clock_ai.AISystray";
|
||||
static components = { Dropdown, DropdownItem };
|
||||
|
||||
setup() {
|
||||
this.notification = useService("notification");
|
||||
this.chatBodyRef = useRef("chatBody");
|
||||
this.dropdown = useDropdownState();
|
||||
|
||||
this.state = useState({
|
||||
activeTab: "chat",
|
||||
messages: [],
|
||||
inputText: "",
|
||||
loading: false,
|
||||
conversationId: null,
|
||||
toolLoading: null,
|
||||
toolResult: null,
|
||||
});
|
||||
}
|
||||
|
||||
get suggestions() {
|
||||
return SUGGESTIONS;
|
||||
}
|
||||
|
||||
get tools() {
|
||||
return TOOLS;
|
||||
}
|
||||
|
||||
get hasMessages() {
|
||||
return this.state.messages.length > 0;
|
||||
}
|
||||
|
||||
get canSend() {
|
||||
return this.state.inputText.trim().length > 0 && !this.state.loading;
|
||||
}
|
||||
|
||||
switchTab(tab) {
|
||||
this.state.activeTab = tab;
|
||||
this.state.toolResult = null;
|
||||
}
|
||||
|
||||
newConversation() {
|
||||
this.state.messages = [];
|
||||
this.state.conversationId = null;
|
||||
this.state.inputText = "";
|
||||
}
|
||||
|
||||
onInput(ev) {
|
||||
this.state.inputText = ev.target.value;
|
||||
}
|
||||
|
||||
onKeydown(ev) {
|
||||
if (ev.key === "Enter" && !ev.shiftKey) {
|
||||
ev.preventDefault();
|
||||
this.sendMessage();
|
||||
}
|
||||
}
|
||||
|
||||
onSuggestionClick(text) {
|
||||
this.state.inputText = text;
|
||||
this.sendMessage();
|
||||
}
|
||||
|
||||
_scrollToBottom() {
|
||||
const el = this.chatBodyRef.el;
|
||||
if (el) {
|
||||
requestAnimationFrame(() => {
|
||||
el.scrollTop = el.scrollHeight;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async sendMessage() {
|
||||
const text = this.state.inputText.trim();
|
||||
if (!text || this.state.loading) return;
|
||||
|
||||
this.state.messages.push({ role: "user", content: text });
|
||||
this.state.inputText = "";
|
||||
this.state.loading = true;
|
||||
this._scrollToBottom();
|
||||
|
||||
try {
|
||||
const result = await rpc("/fusion_clock_ai/manager_chat", {
|
||||
message: text,
|
||||
conversation_id: this.state.conversationId || undefined,
|
||||
});
|
||||
if (result.error) {
|
||||
this.state.messages.push({ role: "assistant", content: result.error });
|
||||
this.notification.add(result.error, { type: "warning" });
|
||||
} else {
|
||||
this.state.messages.push({ role: "assistant", content: result.response });
|
||||
this.state.conversationId = result.conversation_id;
|
||||
}
|
||||
} catch (e) {
|
||||
const errorMsg = "Failed to get AI response. Please try again.";
|
||||
this.state.messages.push({ role: "assistant", content: errorMsg });
|
||||
this.notification.add(errorMsg, { type: "danger" });
|
||||
}
|
||||
|
||||
this.state.loading = false;
|
||||
this._scrollToBottom();
|
||||
}
|
||||
|
||||
async runTool(toolKey) {
|
||||
if (this.state.toolLoading) return;
|
||||
this.state.toolLoading = toolKey;
|
||||
this.state.toolResult = null;
|
||||
|
||||
try {
|
||||
const result = await rpc("/fusion_clock_ai/run_analysis", {
|
||||
analysis_type: toolKey,
|
||||
});
|
||||
if (result.error) {
|
||||
this.state.toolResult = { error: true, text: result.error };
|
||||
this.notification.add(result.error, { type: "warning" });
|
||||
} else {
|
||||
this.state.toolResult = { error: false, text: result.response };
|
||||
}
|
||||
} catch (e) {
|
||||
this.state.toolResult = { error: true, text: "Analysis failed. Please try again." };
|
||||
this.notification.add("Analysis failed.", { type: "danger" });
|
||||
}
|
||||
|
||||
this.state.toolLoading = null;
|
||||
}
|
||||
}
|
||||
|
||||
registry.category("systray").add("fusion_clock_ai.AISystray", {
|
||||
Component: FusionClockAIChat,
|
||||
}, { sequence: 100 });
|
||||
353
fusion_clock_ai/static/src/js/ai_chat_portal.js
Normal file
353
fusion_clock_ai/static/src/js/ai_chat_portal.js
Normal file
@@ -0,0 +1,353 @@
|
||||
/** @odoo-module **/
|
||||
// Copyright 2026 Nexa Systems Inc.
|
||||
// License OPL-1 (Odoo Proprietary License v1.0)
|
||||
|
||||
import { Interaction } from "@web/public/interaction";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { rpc } from "@web/core/network/rpc";
|
||||
|
||||
const QUICK_ACTIONS = [
|
||||
{ key: "hours", label: "My hours this week", icon: "fa-clock-o" },
|
||||
{ key: "coach", label: "Coach tip", icon: "fa-lightbulb-o" },
|
||||
{ key: "polish", label: "Polish my reason", icon: "fa-magic" },
|
||||
];
|
||||
|
||||
const BUBBLE_SVG = `<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2">
|
||||
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
|
||||
</svg>`;
|
||||
|
||||
const CLOSE_SVG = `<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2">
|
||||
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
|
||||
</svg>`;
|
||||
|
||||
const SEND_SVG = `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/>
|
||||
</svg>`;
|
||||
|
||||
export class FusionClockAIPortalChat extends Interaction {
|
||||
static selector = ".fclk-ai-portal-chat";
|
||||
|
||||
setup() {
|
||||
this.panelOpen = false;
|
||||
this.loading = false;
|
||||
this.messages = [];
|
||||
this.conversationId = null;
|
||||
this.polishMode = false;
|
||||
|
||||
this._buildDOM();
|
||||
this._bindEvents();
|
||||
}
|
||||
|
||||
destroy() {
|
||||
if (this._onDocClick) {
|
||||
document.removeEventListener("click", this._onDocClick, true);
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// DOM Construction
|
||||
// =========================================================================
|
||||
|
||||
_buildDOM() {
|
||||
this.el.innerHTML = "";
|
||||
|
||||
this.bubbleEl = this._createElement("button", "fclk-ai-portal-bubble", BUBBLE_SVG);
|
||||
this.el.appendChild(this.bubbleEl);
|
||||
|
||||
this.panelEl = this._createElement("div", "fclk-ai-portal-panel");
|
||||
this.panelEl.style.display = "none";
|
||||
this.panelEl.innerHTML = this._panelHTML();
|
||||
this.el.appendChild(this.panelEl);
|
||||
|
||||
this.headerCloseEl = this.panelEl.querySelector(".fclk-ai-pp-close");
|
||||
this.msgsEl = this.panelEl.querySelector(".fclk-ai-pp-msgs");
|
||||
this.quickEl = this.panelEl.querySelector(".fclk-ai-pp-quick");
|
||||
this.inputEl = this.panelEl.querySelector(".fclk-ai-pp-input");
|
||||
this.sendBtnEl = this.panelEl.querySelector(".fclk-ai-pp-send");
|
||||
this.polishOverlayEl = this.panelEl.querySelector(".fclk-ai-pp-polish-overlay");
|
||||
this.polishInputEl = this.panelEl.querySelector(".fclk-ai-pp-polish-input");
|
||||
this.polishSendEl = this.panelEl.querySelector(".fclk-ai-pp-polish-send");
|
||||
this.polishCancelEl = this.panelEl.querySelector(".fclk-ai-pp-polish-cancel");
|
||||
}
|
||||
|
||||
_panelHTML() {
|
||||
const quickBtns = QUICK_ACTIONS.map(
|
||||
(a) => `<button class="fclk-ai-pp-qbtn" data-action="${a.key}"><i class="fa ${a.icon}"></i> ${a.label}</button>`
|
||||
).join("");
|
||||
|
||||
return `
|
||||
<div class="fclk-ai-pp-header">
|
||||
<div class="fclk-ai-pp-header-info">
|
||||
<div class="fclk-ai-pp-avatar">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2">
|
||||
<path d="M12 2a4 4 0 0 1 4 4v2a4 4 0 0 1-8 0V6a4 4 0 0 1 4-4z"/>
|
||||
<path d="M6 10v1a6 6 0 0 0 12 0v-1"/>
|
||||
<line x1="12" y1="17" x2="12" y2="22"/>
|
||||
<line x1="8" y1="22" x2="16" y2="22"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<div class="fclk-ai-pp-title">AI Assistant</div>
|
||||
<div class="fclk-ai-pp-subtitle">Ask about your hours, tips & more</div>
|
||||
</div>
|
||||
</div>
|
||||
<button class="fclk-ai-pp-close">${CLOSE_SVG}</button>
|
||||
</div>
|
||||
<div class="fclk-ai-pp-msgs">
|
||||
<div class="fclk-ai-portal-msg fclk-ai-portal-msg--assistant">
|
||||
<div class="fclk-ai-portal-msg-text">Hi! I can help you check your hours, give you attendance tips, or polish leave request reasons. What can I help with?</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="fclk-ai-pp-quick">${quickBtns}</div>
|
||||
<div class="fclk-ai-pp-input-row">
|
||||
<input type="text" class="fclk-ai-pp-input" placeholder="Type a message..." autocomplete="off"/>
|
||||
<button class="fclk-ai-pp-send" disabled>${SEND_SVG}</button>
|
||||
</div>
|
||||
<div class="fclk-ai-pp-polish-overlay" style="display:none;">
|
||||
<div class="fclk-ai-pp-polish-title"><i class="fa fa-magic"></i> Polish Leave Reason</div>
|
||||
<textarea class="fclk-ai-pp-polish-input" rows="3" placeholder="Type your rough reason..."></textarea>
|
||||
<div class="fclk-ai-pp-polish-actions">
|
||||
<button class="fclk-ai-pp-polish-cancel">Cancel</button>
|
||||
<button class="fclk-ai-pp-polish-send">Polish It</button>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
_createElement(tag, className, innerHTML) {
|
||||
const el = document.createElement(tag);
|
||||
el.className = className;
|
||||
if (innerHTML) el.innerHTML = innerHTML;
|
||||
return el;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Event Binding
|
||||
// =========================================================================
|
||||
|
||||
_bindEvents() {
|
||||
this.bubbleEl.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
this._togglePanel();
|
||||
});
|
||||
|
||||
this.headerCloseEl.addEventListener("click", () => this._togglePanel());
|
||||
|
||||
this.sendBtnEl.addEventListener("click", () => this._sendMessage());
|
||||
|
||||
this.inputEl.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
this._sendMessage();
|
||||
}
|
||||
});
|
||||
|
||||
this.inputEl.addEventListener("input", () => {
|
||||
this.sendBtnEl.disabled = !this.inputEl.value.trim();
|
||||
});
|
||||
|
||||
this.panelEl.querySelectorAll(".fclk-ai-pp-qbtn").forEach((btn) => {
|
||||
btn.addEventListener("click", () => this._onQuickAction(btn.dataset.action));
|
||||
});
|
||||
|
||||
this.polishSendEl.addEventListener("click", () => this._sendPolish());
|
||||
this.polishCancelEl.addEventListener("click", () => this._closePolish());
|
||||
|
||||
this._onDocClick = (ev) => {
|
||||
if (!this.panelOpen) return;
|
||||
if (!ev.target.closest(".fclk-ai-portal-chat")) {
|
||||
this._closePanel();
|
||||
}
|
||||
};
|
||||
document.addEventListener("click", this._onDocClick, true);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Panel Toggle
|
||||
// =========================================================================
|
||||
|
||||
_togglePanel() {
|
||||
this.panelOpen ? this._closePanel() : this._openPanel();
|
||||
}
|
||||
|
||||
_openPanel() {
|
||||
this.panelOpen = true;
|
||||
this.panelEl.style.display = "flex";
|
||||
this.bubbleEl.innerHTML = CLOSE_SVG;
|
||||
this.bubbleEl.classList.add("fclk-ai-portal-bubble--open");
|
||||
requestAnimationFrame(() => {
|
||||
this.panelEl.classList.add("fclk-ai-pp--visible");
|
||||
});
|
||||
this.inputEl.focus();
|
||||
}
|
||||
|
||||
_closePanel() {
|
||||
this.panelOpen = false;
|
||||
this.panelEl.classList.remove("fclk-ai-pp--visible");
|
||||
this.bubbleEl.innerHTML = BUBBLE_SVG;
|
||||
this.bubbleEl.classList.remove("fclk-ai-portal-bubble--open");
|
||||
setTimeout(() => {
|
||||
if (!this.panelOpen) this.panelEl.style.display = "none";
|
||||
}, 250);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Messages
|
||||
// =========================================================================
|
||||
|
||||
_appendMessage(role, text) {
|
||||
const msg = document.createElement("div");
|
||||
msg.className = `fclk-ai-portal-msg fclk-ai-portal-msg--${role}`;
|
||||
|
||||
const inner = document.createElement("div");
|
||||
inner.className = "fclk-ai-portal-msg-text";
|
||||
inner.textContent = text;
|
||||
msg.appendChild(inner);
|
||||
|
||||
this.msgsEl.appendChild(msg);
|
||||
this._scrollToBottom();
|
||||
}
|
||||
|
||||
_appendTyping() {
|
||||
const msg = document.createElement("div");
|
||||
msg.className = "fclk-ai-portal-msg fclk-ai-portal-msg--assistant fclk-ai-portal-typing";
|
||||
msg.innerHTML = '<div class="fclk-ai-portal-dots"><span></span><span></span><span></span></div>';
|
||||
this.msgsEl.appendChild(msg);
|
||||
this._scrollToBottom();
|
||||
return msg;
|
||||
}
|
||||
|
||||
_removeTyping(el) {
|
||||
if (el && el.parentNode) el.parentNode.removeChild(el);
|
||||
}
|
||||
|
||||
_scrollToBottom() {
|
||||
requestAnimationFrame(() => {
|
||||
this.msgsEl.scrollTop = this.msgsEl.scrollHeight;
|
||||
});
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Send / Receive
|
||||
// =========================================================================
|
||||
|
||||
async _sendMessage() {
|
||||
const text = this.inputEl.value.trim();
|
||||
if (!text || this.loading) return;
|
||||
|
||||
this._appendMessage("user", text);
|
||||
this.inputEl.value = "";
|
||||
this.sendBtnEl.disabled = true;
|
||||
this.loading = true;
|
||||
|
||||
const typingEl = this._appendTyping();
|
||||
|
||||
try {
|
||||
const result = await rpc("/fusion_clock_ai/employee_chat", {
|
||||
message: text,
|
||||
conversation_id: this.conversationId || undefined,
|
||||
});
|
||||
this._removeTyping(typingEl);
|
||||
|
||||
if (result.error) {
|
||||
this._appendMessage("assistant", result.error);
|
||||
} else {
|
||||
this._appendMessage("assistant", result.response);
|
||||
this.conversationId = result.conversation_id;
|
||||
}
|
||||
} catch {
|
||||
this._removeTyping(typingEl);
|
||||
this._appendMessage("assistant", "Something went wrong. Please try again.");
|
||||
}
|
||||
|
||||
this.loading = false;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Quick Actions
|
||||
// =========================================================================
|
||||
|
||||
async _onQuickAction(key) {
|
||||
if (this.loading) return;
|
||||
|
||||
if (key === "hours") {
|
||||
this.inputEl.value = "How many hours have I worked this week?";
|
||||
this._sendMessage();
|
||||
return;
|
||||
}
|
||||
|
||||
if (key === "coach") {
|
||||
this._appendMessage("user", "Give me a coach tip");
|
||||
this.loading = true;
|
||||
const typingEl = this._appendTyping();
|
||||
|
||||
try {
|
||||
const result = await rpc("/fusion_clock_ai/my_coach_tip", {});
|
||||
this._removeTyping(typingEl);
|
||||
|
||||
if (result.error) {
|
||||
this._appendMessage("assistant", result.error);
|
||||
} else {
|
||||
this._appendMessage("assistant", result.tip);
|
||||
}
|
||||
} catch {
|
||||
this._removeTyping(typingEl);
|
||||
this._appendMessage("assistant", "Could not fetch coach tip.");
|
||||
}
|
||||
this.loading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (key === "polish") {
|
||||
this._openPolish();
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Polish Reason
|
||||
// =========================================================================
|
||||
|
||||
_openPolish() {
|
||||
this.polishOverlayEl.style.display = "flex";
|
||||
this.polishInputEl.value = "";
|
||||
this.polishInputEl.focus();
|
||||
}
|
||||
|
||||
_closePolish() {
|
||||
this.polishOverlayEl.style.display = "none";
|
||||
}
|
||||
|
||||
async _sendPolish() {
|
||||
const text = this.polishInputEl.value.trim();
|
||||
if (!text || this.loading) return;
|
||||
|
||||
this.loading = true;
|
||||
this.polishSendEl.disabled = true;
|
||||
this.polishSendEl.textContent = "Polishing...";
|
||||
|
||||
try {
|
||||
const result = await rpc("/fusion_clock_ai/polish_reason", {
|
||||
rough_text: text,
|
||||
});
|
||||
|
||||
this._closePolish();
|
||||
this._appendMessage("user", `Polish this reason: "${text}"`);
|
||||
|
||||
if (result.error) {
|
||||
this._appendMessage("assistant", result.error);
|
||||
} else {
|
||||
this._appendMessage("assistant", result.polished);
|
||||
}
|
||||
} catch {
|
||||
this._closePolish();
|
||||
this._appendMessage("assistant", "Could not polish reason.");
|
||||
}
|
||||
|
||||
this.polishSendEl.disabled = false;
|
||||
this.polishSendEl.textContent = "Polish It";
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
registry
|
||||
.category("public.interactions")
|
||||
.add("fusion_clock_ai.PortalChat", FusionClockAIPortalChat);
|
||||
366
fusion_clock_ai/static/src/scss/fusion_clock_ai.scss
Normal file
366
fusion_clock_ai/static/src/scss/fusion_clock_ai.scss
Normal file
@@ -0,0 +1,366 @@
|
||||
// Copyright 2026 Nexa Systems Inc.
|
||||
// License OPL-1 (Odoo Proprietary License v1.0)
|
||||
//
|
||||
// Fusion Clock AI - Systray + Manager Chat styles
|
||||
// All colors use Odoo/Bootstrap CSS variables for dark mode support
|
||||
|
||||
// AI brand color tokens (adapt to dark mode)
|
||||
:root {
|
||||
--fclk-ai-brand: #764ba2;
|
||||
--fclk-ai-brand-light: #8b5fbf;
|
||||
--fclk-ai-brand-bg: rgba(118, 75, 162, 0.08);
|
||||
}
|
||||
|
||||
html.o_dark {
|
||||
--fclk-ai-brand: #a87fd4;
|
||||
--fclk-ai-brand-light: #c4a6e6;
|
||||
--fclk-ai-brand-bg: rgba(168, 127, 212, 0.12);
|
||||
}
|
||||
|
||||
// Systray icon
|
||||
.fclk-ai-systray-btn {
|
||||
position: relative;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.fclk-ai-systray-img {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
object-fit: contain;
|
||||
transition: transform 0.2s;
|
||||
|
||||
.fclk-ai-systray-btn:hover & {
|
||||
transform: scale(1.15);
|
||||
}
|
||||
}
|
||||
|
||||
// Dropdown container
|
||||
.fclk-ai-systray-dropdown {
|
||||
width: 380px;
|
||||
border-radius: 12px !important;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12) !important;
|
||||
}
|
||||
|
||||
html.o_dark .fclk-ai-systray-dropdown {
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4) !important;
|
||||
}
|
||||
|
||||
// Panel inside dropdown
|
||||
.fclk-ai-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
max-height: 520px;
|
||||
background: var(--o-view-background-color, var(--bs-body-bg, #fff));
|
||||
}
|
||||
|
||||
.fclk-ai-panel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--o-border-color, var(--bs-border-color, #dee2e6));
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.fclk-ai-panel-title {
|
||||
font-weight: 700;
|
||||
font-size: 14px;
|
||||
color: var(--o-main-text-color, var(--bs-body-color, #212529));
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.fclk-ai-tabs {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.fclk-ai-tab {
|
||||
padding: 4px 12px;
|
||||
border-radius: 20px;
|
||||
border: 1px solid var(--o-border-color, var(--bs-border-color, #dee2e6));
|
||||
background: transparent;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
color: var(--o-main-text-color, var(--bs-body-color, #212529));
|
||||
transition: background 0.15s;
|
||||
|
||||
&.active {
|
||||
background: var(--fclk-ai-brand);
|
||||
color: #fff;
|
||||
border-color: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
.fclk-ai-chat-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 12px 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
min-height: 300px;
|
||||
max-height: 380px;
|
||||
}
|
||||
|
||||
.fclk-ai-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
gap: 12px;
|
||||
color: var(--o-main-text-color, var(--bs-body-color, #212529));
|
||||
opacity: 0.6;
|
||||
text-align: center;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.fclk-ai-empty-icon {
|
||||
font-size: 40px;
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
.fclk-ai-suggestions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.fclk-ai-suggestion {
|
||||
padding: 6px 14px;
|
||||
border-radius: 20px;
|
||||
border: 1px solid var(--o-border-color, var(--bs-border-color, #dee2e6));
|
||||
background: transparent;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
color: var(--o-main-text-color, var(--bs-body-color, #212529));
|
||||
transition: background 0.15s, border-color 0.15s;
|
||||
|
||||
&:hover {
|
||||
background: var(--fclk-ai-brand);
|
||||
color: #fff;
|
||||
border-color: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
.fclk-ai-msg {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: flex-start;
|
||||
|
||||
&--user {
|
||||
flex-direction: row-reverse;
|
||||
|
||||
.fclk-ai-msg-content {
|
||||
background: var(--fclk-ai-brand);
|
||||
color: #fff;
|
||||
border-radius: 16px 16px 4px 16px;
|
||||
}
|
||||
|
||||
.fclk-ai-msg-avatar {
|
||||
background: var(--fclk-ai-brand);
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
&--assistant {
|
||||
.fclk-ai-msg-content {
|
||||
background: var(--o-action-color-light, var(--bs-tertiary-bg, #f0f0f0));
|
||||
color: var(--o-main-text-color, var(--bs-body-color, #212529));
|
||||
border-radius: 16px 16px 16px 4px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.fclk-ai-msg-avatar {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 12px;
|
||||
flex-shrink: 0;
|
||||
background: var(--o-action-color-light, var(--bs-tertiary-bg, #e9ecef));
|
||||
color: var(--o-main-text-color, var(--bs-body-color, #212529));
|
||||
}
|
||||
|
||||
.fclk-ai-msg-content {
|
||||
padding: 10px 14px;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
max-width: 80%;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.fclk-ai-typing {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
padding: 12px 16px !important;
|
||||
|
||||
span {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: var(--o-main-text-color, var(--bs-body-color, #212529));
|
||||
opacity: 0.4;
|
||||
animation: fclk-ai-bounce 1.4s infinite ease-in-out both;
|
||||
|
||||
&:nth-child(1) { animation-delay: 0s; }
|
||||
&:nth-child(2) { animation-delay: 0.16s; }
|
||||
&:nth-child(3) { animation-delay: 0.32s; }
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fclk-ai-bounce {
|
||||
0%, 80%, 100% { transform: scale(0.6); opacity: 0.4; }
|
||||
40% { transform: scale(1); opacity: 1; }
|
||||
}
|
||||
|
||||
.fclk-ai-chat-input {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
border-top: 1px solid var(--o-border-color, var(--bs-border-color, #dee2e6));
|
||||
flex-shrink: 0;
|
||||
|
||||
textarea {
|
||||
flex: 1;
|
||||
border: 1px solid var(--o-border-color, var(--bs-border-color, #dee2e6));
|
||||
border-radius: 20px;
|
||||
padding: 8px 14px;
|
||||
font-size: 13px;
|
||||
resize: none;
|
||||
outline: none;
|
||||
background: var(--o-view-background-color, var(--bs-body-bg, #fff));
|
||||
color: var(--o-main-text-color, var(--bs-body-color, #212529));
|
||||
max-height: 80px;
|
||||
min-height: 36px;
|
||||
|
||||
&:focus {
|
||||
border-color: var(--fclk-ai-brand);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.fclk-ai-new-chat,
|
||||
.fclk-ai-send {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
font-size: 14px;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.fclk-ai-new-chat {
|
||||
background: var(--o-action-color-light, var(--bs-tertiary-bg, #e9ecef));
|
||||
color: var(--o-main-text-color, var(--bs-body-color, #212529));
|
||||
|
||||
&:hover { opacity: 0.8; }
|
||||
}
|
||||
|
||||
.fclk-ai-send {
|
||||
background: var(--fclk-ai-brand);
|
||||
color: #fff;
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.fclk-ai-tools-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 16px;
|
||||
min-height: 300px;
|
||||
max-height: 380px;
|
||||
}
|
||||
|
||||
.fclk-ai-tool-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 10px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.fclk-ai-tool-btn {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 16px 10px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--o-border-color, var(--bs-border-color, #dee2e6));
|
||||
background: var(--o-view-background-color, var(--bs-body-bg, #fff));
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s, box-shadow 0.15s;
|
||||
color: var(--o-main-text-color, var(--bs-body-color, #212529));
|
||||
|
||||
i {
|
||||
font-size: 22px;
|
||||
color: var(--fclk-ai-brand);
|
||||
}
|
||||
|
||||
span {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
border-color: var(--fclk-ai-brand);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
}
|
||||
|
||||
.fclk-ai-analysis-loading {
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
color: var(--o-main-text-color, var(--bs-body-color, #212529));
|
||||
opacity: 0.6;
|
||||
font-size: 13px;
|
||||
|
||||
i { margin-right: 6px; }
|
||||
}
|
||||
|
||||
.fclk-ai-analysis-result {
|
||||
pre {
|
||||
background: var(--o-action-color-light, var(--bs-tertiary-bg, #f8f9fa));
|
||||
color: var(--o-main-text-color, var(--bs-body-color, #212529));
|
||||
padding: 14px;
|
||||
border-radius: 10px;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.fclk-ai-systray-dropdown {
|
||||
width: calc(100vw - 32px);
|
||||
}
|
||||
}
|
||||
126
fusion_clock_ai/static/src/xml/ai_chat_backend.xml
Normal file
126
fusion_clock_ai/static/src/xml/ai_chat_backend.xml
Normal file
@@ -0,0 +1,126 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
Copyright 2026 Nexa Systems Inc.
|
||||
License OPL-1 (Odoo Proprietary License v1.0)
|
||||
-->
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="fusion_clock_ai.AISystray">
|
||||
<Dropdown position="'bottom-end'" state="dropdown"
|
||||
menuClass="'fclk-ai-systray-dropdown p-0'">
|
||||
<button class="fclk-ai-systray-btn" title="AI Assistant">
|
||||
<img src="/fusion_clock_ai/static/src/img/ai_icon.png" alt="AI" class="fclk-ai-systray-img"/>
|
||||
</button>
|
||||
<t t-set-slot="content">
|
||||
<div class="fclk-ai-panel" t-on-click.stop="">
|
||||
|
||||
<!-- Header -->
|
||||
<div class="fclk-ai-panel-header">
|
||||
<span class="fclk-ai-panel-title">AI Assistant</span>
|
||||
<div class="fclk-ai-tabs">
|
||||
<button t-attf-class="fclk-ai-tab {{ state.activeTab === 'chat' ? 'active' : '' }}"
|
||||
t-on-click="() => this.switchTab('chat')">Chat</button>
|
||||
<button t-attf-class="fclk-ai-tab {{ state.activeTab === 'tools' ? 'active' : '' }}"
|
||||
t-on-click="() => this.switchTab('tools')">Tools</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Chat Body -->
|
||||
<div t-if="state.activeTab === 'chat'" class="fclk-ai-chat-body" t-ref="chatBody">
|
||||
|
||||
<!-- Empty state -->
|
||||
<t t-if="!hasMessages and !state.loading">
|
||||
<div class="fclk-ai-empty">
|
||||
<i class="fa fa-magic fclk-ai-empty-icon"/>
|
||||
<div>
|
||||
<strong>Ask me anything</strong><br/>
|
||||
about your team's attendance
|
||||
</div>
|
||||
<div class="fclk-ai-suggestions">
|
||||
<t t-foreach="suggestions" t-as="s" t-key="s_index">
|
||||
<button class="fclk-ai-suggestion"
|
||||
t-on-click="() => this.onSuggestionClick(s)">
|
||||
<t t-esc="s"/>
|
||||
</button>
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<!-- Messages -->
|
||||
<t t-foreach="state.messages" t-as="msg" t-key="msg_index">
|
||||
<div t-attf-class="fclk-ai-msg fclk-ai-msg--{{ msg.role }}">
|
||||
<div class="fclk-ai-msg-avatar">
|
||||
<i t-if="msg.role === 'user'" class="fa fa-user"/>
|
||||
<i t-else="" class="fa fa-magic"/>
|
||||
</div>
|
||||
<div class="fclk-ai-msg-content">
|
||||
<t t-esc="msg.content"/>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<!-- Typing indicator -->
|
||||
<t t-if="state.loading">
|
||||
<div class="fclk-ai-msg fclk-ai-msg--assistant">
|
||||
<div class="fclk-ai-msg-avatar">
|
||||
<i class="fa fa-magic"/>
|
||||
</div>
|
||||
<div class="fclk-ai-msg-content fclk-ai-typing">
|
||||
<span/><span/><span/>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
|
||||
<!-- Tools Body -->
|
||||
<div t-if="state.activeTab === 'tools'" class="fclk-ai-tools-body">
|
||||
<div class="fclk-ai-tool-grid">
|
||||
<t t-foreach="tools" t-as="tool" t-key="tool.key">
|
||||
<button class="fclk-ai-tool-btn"
|
||||
t-on-click="() => this.runTool(tool.key)"
|
||||
t-att-disabled="state.toolLoading">
|
||||
<i t-attf-class="fa {{ tool.icon }}"/>
|
||||
<span t-esc="tool.label"/>
|
||||
</button>
|
||||
</t>
|
||||
</div>
|
||||
|
||||
<!-- Tool loading -->
|
||||
<div t-if="state.toolLoading" class="fclk-ai-analysis-loading">
|
||||
<i class="fa fa-circle-o-notch fa-spin"/>
|
||||
Running analysis...
|
||||
</div>
|
||||
|
||||
<!-- Tool result -->
|
||||
<div t-if="state.toolResult" class="fclk-ai-analysis-result">
|
||||
<pre t-esc="state.toolResult.text"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Input Area (chat tab only) -->
|
||||
<div t-if="state.activeTab === 'chat'" class="fclk-ai-chat-input">
|
||||
<button class="fclk-ai-new-chat" t-on-click="newConversation"
|
||||
title="New conversation">
|
||||
<i class="fa fa-plus"/>
|
||||
</button>
|
||||
<textarea rows="1"
|
||||
placeholder="Ask about attendance..."
|
||||
t-on-input="onInput"
|
||||
t-on-keydown="onKeydown"
|
||||
t-att-value="state.inputText"
|
||||
t-att-disabled="state.loading"/>
|
||||
<button class="fclk-ai-send"
|
||||
t-on-click="sendMessage"
|
||||
t-att-disabled="!canSend"
|
||||
title="Send">
|
||||
<i class="fa fa-paper-plane"/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</t>
|
||||
</Dropdown>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
Reference in New Issue
Block a user