Chat assistant

This commit is contained in:
2026-02-15 17:24:48 +01:00
parent 31150000fd
commit f772659201
7 changed files with 740 additions and 19 deletions

View File

@@ -130,6 +130,13 @@
<svg class="w-5 h-5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg>
<span class="sidebar-text">Post erstellen</span>
</a>
<a href="/chat-create" class="nav-link flex items-center justify-between px-4 py-3 rounded-lg text-gray-300 hover:bg-brand-bg-light transition-colors {% if page == 'chat-create' %}active{% endif %}">
<div class="flex items-center gap-3">
<svg class="w-5 h-5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"/></svg>
<span class="sidebar-text">Chat Assistent</span>
</div>
<span class="sidebar-text px-1.5 py-0.5 bg-brand-highlight text-brand-bg-dark rounded text-xs font-bold">NEU</span>
</a>
<a href="/posts" class="nav-link flex items-center gap-3 px-4 py-3 rounded-lg text-gray-300 hover:bg-brand-bg-light transition-colors {% if page == 'posts' %}active{% endif %}">
<svg class="w-5 h-5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"/></svg>
<span class="sidebar-text">Meine Posts</span>

View File

@@ -0,0 +1,414 @@
{% extends "base.html" %}
{% block title %}Chat Assistent{% endblock %}
{% block content %}
<style>
/* Make chat page full height and remove padding */
#mainContent > div {
padding: 0 !important;
}
/* Fixed elements adjust to sidebar with smooth transition */
.chat-fixed-header,
.chat-fixed-input {
left: 256px;
right: 0;
transition: left 0.3s ease;
}
/* When sidebar is collapsed, adjust fixed elements */
aside.collapsed ~ main .chat-fixed-header,
aside.collapsed ~ main .chat-fixed-input {
left: 64px;
}
/* Bouncing dots animation */
.dot {
animation: bounce 1.4s infinite ease-in-out;
display: inline-block;
}
.dot:nth-child(1) { animation-delay: -0.32s; }
.dot:nth-child(2) { animation-delay: -0.16s; }
@keyframes bounce {
0%, 80%, 100% { transform: translateY(0); }
40% { transform: translateY(-8px); }
}
</style>
<!-- Header (Fixed at top) -->
<div class="chat-fixed-header fixed top-0 bg-brand-bg z-20">
<div class="px-8 py-4">
<h1 class="text-xl font-bold text-white">💬 Chat Assistent</h1>
</div>
</div>
<!-- Messages Area (Full page scroll with padding for fixed header and input) -->
<div id="chat-messages" class="px-8 space-y-4 pb-64 max-w-[80%] mx-auto" style="padding-top: 80px;">
<!-- Welcome Message -->
<div class="flex items-start gap-3">
<div class="w-8 h-8 bg-brand-highlight/20 rounded-full flex items-center justify-center flex-shrink-0">
<span class="text-lg">🤖</span>
</div>
<div class="max-w-[70%]">
<div class="bg-brand-bg-dark rounded-2xl p-4 border border-gray-600">
<p class="text-gray-300">
Hallo! Ich helfe dir beim Erstellen deines LinkedIn-Posts.
Beschreibe mir einfach, worüber du schreiben möchtest, und ich erstelle einen ersten Entwurf für dich.
</p>
<p class="text-gray-400 text-sm mt-2">
Du kannst mich danach bitten, den Post anzupassen, umzuschreiben oder zu verbessern.
</p>
</div>
</div>
</div>
</div>
<!-- Input Area (Fixed at bottom, floating over messages) -->
<div class="chat-fixed-input fixed bottom-0 bg-brand-bg z-10">
<div class="pt-4 pb-6 px-8">
<div class="space-y-3 mx-auto" style="max-width: 768px;">
<!-- Post Type Selector (Chips) - Above Input -->
<div class="flex flex-wrap gap-1.5" id="post-type-chips">
{% for pt in post_types %}
<button
onclick="selectPostType('{{ pt.id }}', this)"
data-post-type-id="{{ pt.id }}"
class="post-type-chip px-3 py-1 rounded-full text-xs font-medium transition-all
{% if loop.first %}bg-brand-highlight text-black{% else %}bg-brand-bg-dark text-gray-400 hover:bg-brand-bg-light hover:text-gray-300{% endif %}"
{% if loop.first %}data-selected="true"{% endif %}>
{{ pt.name }}
</button>
{% endfor %}
</div>
<!-- Input Bar -->
<div class="flex gap-3 items-center">
<div class="flex-1 relative">
<input
type="text"
id="chat-input"
placeholder="Beschreibe deinen Post..."
class="w-full input-bg border border-gray-600 rounded-full px-6 py-3 text-white focus:outline-none focus:border-brand-highlight transition-colors"
onkeydown="handleChatKeydown(event)">
</div>
<button
onclick="sendMessage()"
id="send-btn"
class="w-12 h-12 bg-brand-highlight hover:bg-yellow-500 text-black rounded-full transition-all flex items-center justify-center flex-shrink-0 hover:scale-110"
title="Senden (Enter)">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8"/>
</svg>
</button>
<button
onclick="savePost()"
id="save-btn"
class="hidden w-12 h-12 bg-green-600 hover:bg-green-500 text-white rounded-full transition-all items-center justify-center flex-shrink-0 hover:scale-110"
title="Post speichern">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>
</svg>
</button>
</div>
</div>
</div>
</div>
<script>
let chatHistory = [];
let currentPost = null;
let conversationId = null;
let selectedPostTypeId = null;
// User profile data
const userProfilePicture = "{{ profile_picture or '' }}";
const userInitial = "{{ (session.display_name or session.linkedin_name or 'U')[0] | upper }}";
// Initialize: Select first post type
document.addEventListener('DOMContentLoaded', () => {
const firstChip = document.querySelector('.post-type-chip[data-selected="true"]');
if (firstChip) {
selectedPostTypeId = firstChip.dataset.postTypeId;
}
});
function selectPostType(postTypeId, element) {
// Update selected state
selectedPostTypeId = postTypeId;
// Update chip styles
document.querySelectorAll('.post-type-chip').forEach(chip => {
chip.classList.remove('bg-brand-highlight', 'text-black');
chip.classList.add('bg-brand-bg-dark', 'text-gray-400');
chip.removeAttribute('data-selected');
});
element.classList.remove('bg-brand-bg-dark', 'text-gray-400');
element.classList.add('bg-brand-highlight', 'text-black');
element.setAttribute('data-selected', 'true');
}
function handleChatKeydown(event) {
// Send on Enter (not Shift+Enter)
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
sendMessage();
}
}
async function sendMessage() {
const input = document.getElementById('chat-input');
const message = input.value.trim();
if (!message) {
showToast('Bitte gib eine Nachricht ein', 'error');
return;
}
if (!selectedPostTypeId) {
showToast('Bitte wähle einen Post-Typ aus', 'error');
return;
}
// Add user message to chat
addMessageToChat('user', message);
input.value = '';
// Add temporary "generating" message
const tempMessageId = 'temp-generating-msg';
const container = document.getElementById('chat-messages');
const tempMsg = document.createElement('div');
tempMsg.id = tempMessageId;
tempMsg.className = 'flex items-start gap-3';
tempMsg.innerHTML = `
<div class="w-8 h-8 bg-brand-highlight/20 rounded-full flex items-center justify-center flex-shrink-0">
<span class="text-lg">🤖</span>
</div>
<div class="max-w-[70%]">
<div class="bg-brand-bg-dark rounded-2xl p-4 border border-gray-600">
<p class="text-gray-300">
wird generiert<span class="dot">.</span><span class="dot">.</span><span class="dot">.</span>
</p>
</div>
</div>
`;
container.appendChild(tempMsg);
container.scrollTo({ top: container.scrollHeight, behavior: 'smooth' });
// Disable input while processing - show black spinner in button
const sendBtn = document.getElementById('send-btn');
const originalHTML = sendBtn.innerHTML;
sendBtn.disabled = true;
sendBtn.innerHTML = '<svg class="w-5 h-5 animate-spin" fill="currentColor" viewBox="0 0 24 24"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8z" opacity="0.3"/><path d="M12 2v4c3.31 0 6 2.69 6 6h4c0-5.52-4.48-10-10-10z"/></svg>';
try {
const endpoint = currentPost ? '/api/employee/chat/refine' : '/api/employee/chat/generate';
const response = await fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
message: message,
post_type_id: selectedPostTypeId,
conversation_id: conversationId,
current_post: currentPost,
chat_history: chatHistory
})
});
const result = await response.json();
// Remove temporary "generating" message
const tempMsg = document.getElementById(tempMessageId);
if (tempMsg) tempMsg.remove();
if (result.success) {
currentPost = result.post;
conversationId = result.conversation_id;
// Add AI response to chat
addMessageToChat('ai', result.explanation || 'Hier ist dein Post:', result.post);
// Show save button
const saveBtn = document.getElementById('save-btn');
saveBtn.classList.remove('hidden');
saveBtn.classList.add('flex');
// Store in history
chatHistory.push({
user: message,
ai: result.post,
explanation: result.explanation
});
} else {
showToast('Fehler: ' + (result.error || 'Unbekannter Fehler'), 'error');
addMessageToChat('ai', '❌ Entschuldigung, es gab einen Fehler. Bitte versuche es erneut.');
}
} catch (error) {
console.error('Error:', error);
showToast('Netzwerkfehler beim Generieren', 'error');
// Remove temporary message on error
const tempMsg = document.getElementById(tempMessageId);
if (tempMsg) tempMsg.remove();
addMessageToChat('ai', '❌ Netzwerkfehler. Bitte überprüfe deine Verbindung.');
} finally {
sendBtn.disabled = false;
sendBtn.innerHTML = originalHTML;
}
}
function addMessageToChat(type, text, post = null) {
const container = document.getElementById('chat-messages');
const messageDiv = document.createElement('div');
if (type === 'user') {
// User messages: right-aligned, avatar on right, max-width 70%
messageDiv.className = 'flex items-start gap-3 justify-end';
const userAvatarHtml = userProfilePicture
? `<img src="${userProfilePicture}" alt="User" class="w-full h-full object-cover" referrerpolicy="no-referrer">`
: `<span class="text-brand-bg-dark font-bold text-sm">${userInitial}</span>`;
messageDiv.innerHTML = `
<div class="max-w-[70%]">
<div class="bg-blue-900/30 rounded-2xl p-4 border border-blue-700/50">
<p class="text-white">${escapeHtml(text)}</p>
</div>
</div>
<div class="w-8 h-8 bg-brand-highlight rounded-full overflow-hidden flex items-center justify-center flex-shrink-0">
${userAvatarHtml}
</div>
`;
} else {
// Bot messages: left-aligned, avatar on left, max-width 70%
messageDiv.className = 'flex items-start gap-3';
const postHtml = post ? `
<div class="mt-3 p-4 bg-brand-bg rounded-2xl border border-brand-highlight/30">
<div class="flex items-center gap-2 mb-2">
<svg class="w-4 h-4 text-brand-highlight" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
</svg>
<span class="text-brand-highlight font-medium text-sm">Post-Entwurf</span>
</div>
<div class="text-gray-200 whitespace-pre-wrap text-sm leading-relaxed">${escapeHtml(post)}</div>
</div>
` : '';
messageDiv.innerHTML = `
<div class="w-8 h-8 bg-brand-highlight/20 rounded-full flex items-center justify-center flex-shrink-0">
<span class="text-lg">🤖</span>
</div>
<div class="max-w-[70%]">
<div class="bg-brand-bg-dark rounded-2xl p-4 border border-gray-600">
<p class="text-gray-300">${escapeHtml(text)}</p>
${postHtml}
</div>
</div>
`;
}
container.appendChild(messageDiv);
// Scroll to bottom smoothly
setTimeout(() => {
container.scrollTo({
top: container.scrollHeight,
behavior: 'smooth'
});
}, 100);
}
async function savePost() {
if (!currentPost) {
showToast('Kein Post zum Speichern vorhanden', 'error');
return;
}
if (!selectedPostTypeId) {
showToast('Bitte wähle einen Post-Typ aus', 'error');
return;
}
const saveBtn = document.getElementById('save-btn');
saveBtn.disabled = true;
const originalHTML = saveBtn.innerHTML;
saveBtn.innerHTML = '<svg class="w-5 h-5 animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/></svg>';
try {
const response = await fetch('/api/employee/chat/save', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
post_content: currentPost,
post_type_id: selectedPostTypeId,
conversation_id: conversationId,
chat_history: chatHistory
})
});
const result = await response.json();
if (result.success) {
showToast('Post erfolgreich gespeichert!', 'success');
// Redirect to post details after short delay
setTimeout(() => {
window.location.href = `/posts/${result.post_id}`;
}, 1000);
} else {
showToast('Fehler beim Speichern: ' + (result.error || 'Unbekannter Fehler'), 'error');
saveBtn.disabled = false;
saveBtn.innerHTML = originalHTML;
}
} catch (error) {
console.error('Error:', error);
showToast('Netzwerkfehler beim Speichern', 'error');
saveBtn.disabled = false;
saveBtn.innerHTML = originalHTML;
}
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
function showToast(message, type = 'info') {
const toast = document.createElement('div');
const colors = {
success: 'bg-green-600 border-green-500',
error: 'bg-red-600 border-red-500',
info: 'bg-blue-600 border-blue-500',
warning: 'bg-yellow-600 border-yellow-500'
};
const icons = {
success: '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg>',
error: '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>',
info: '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>',
warning: '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/></svg>'
};
toast.className = `fixed bottom-8 right-8 ${colors[type]} text-white px-6 py-4 rounded-xl shadow-2xl border-2 z-50 flex items-center gap-3 transform transition-all duration-300`;
toast.innerHTML = `
${icons[type]}
<span class="font-medium">${message}</span>
`;
document.body.appendChild(toast);
setTimeout(() => {
toast.style.opacity = '0';
toast.style.transform = 'translateY(1rem)';
setTimeout(() => toast.remove(), 300);
}, 3000);
}
</script>
{% endblock %}

View File

@@ -10,8 +10,8 @@
onclick="window.location.href='/company/manage/post/{{ post.id }}?employee_id={{ employee_id }}'">
<div class="flex items-start justify-between gap-2 mb-2">
<h4 class="post-card-title">{{ post.topic_title or 'Untitled' }}</h4>
{% if post.critic_feedback and post.critic_feedback | length > 0 %}
{% set score = post.critic_feedback[-1].overall_score %}
{% if post.critic_feedback and post.critic_feedback | length > 0 and post.critic_feedback[-1].get('overall_score') is not none %}
{% set score = post.critic_feedback[-1].get('overall_score', 0) %}
<span class="score-badge flex-shrink-0 {{ 'score-high' if score >= 85 else 'score-medium' if score >= 70 else 'score-low' }}">
{{ score }}
</span>

View File

@@ -510,9 +510,9 @@
<span class="px-3 py-1.5 rounded-lg text-sm font-medium {{ 'bg-green-600/30 text-green-300 border border-green-600/50' if post.status == 'approved' else 'bg-yellow-600/30 text-yellow-300 border border-yellow-600/50' }}">
{{ post.status | capitalize }}
</span>
{% if final_feedback %}
<span class="px-3 py-1.5 rounded-lg text-sm font-bold {{ 'bg-green-600/30 text-green-300' if final_feedback.overall_score >= 85 else 'bg-yellow-600/30 text-yellow-300' if final_feedback.overall_score >= 70 else 'bg-red-600/30 text-red-300' }}">
Score: {{ final_feedback.overall_score }}/100
{% if final_feedback and final_feedback.get('overall_score') is not none %}
<span class="px-3 py-1.5 rounded-lg text-sm font-bold {{ 'bg-green-600/30 text-green-300' if final_feedback.get('overall_score', 0) >= 85 else 'bg-yellow-600/30 text-yellow-300' if final_feedback.get('overall_score', 0) >= 70 else 'bg-red-600/30 text-red-300' }}">
Score: {{ final_feedback.get('overall_score', 0) }}/100
</span>
{% endif %}
</div>
@@ -833,11 +833,11 @@
<!-- Bottom Section: Score, Feedback, Actions -->
<div class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-6">
<!-- Score Breakdown -->
{% if final_feedback and final_feedback.scores %}
{% if final_feedback and final_feedback.get('scores') and final_feedback.get('overall_score') is not none %}
<div class="section-card rounded-xl p-6">
<h3 class="font-semibold text-white mb-4 flex items-center gap-2">
<svg class="w-5 h-5 text-brand-highlight" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"/></svg>
Score: {{ final_feedback.overall_score }}/100
Score: {{ final_feedback.get('overall_score', 0) }}/100
</h3>
<div class="space-y-3">
<div>
@@ -943,10 +943,12 @@
<span class="px-3 py-1 bg-brand-highlight/20 text-brand-highlight rounded-lg text-sm font-medium">Version {{ i + 1 }}</span>
{% if post.critic_feedback and i < post.critic_feedback | length %}
<div class="flex items-center gap-2">
<span class="px-2 py-1 rounded text-xs font-medium {{ 'bg-green-600/30 text-green-300' if post.critic_feedback[i].overall_score >= 85 else 'bg-yellow-600/30 text-yellow-300' }}">
Score: {{ post.critic_feedback[i].overall_score }}/100
{% if post.critic_feedback[i].get('overall_score') is not none %}
<span class="px-2 py-1 rounded text-xs font-medium {{ 'bg-green-600/30 text-green-300' if post.critic_feedback[i].get('overall_score', 0) >= 85 else 'bg-yellow-600/30 text-yellow-300' }}">
Score: {{ post.critic_feedback[i].get('overall_score', 0) }}/100
</span>
{% if post.critic_feedback[i].approved %}
{% endif %}
{% if post.critic_feedback[i].get('approved') %}
<span class="px-2 py-1 bg-green-600/30 text-green-300 rounded text-xs">Approved</span>
{% endif %}
</div>

View File

@@ -10,8 +10,8 @@
onclick="window.location.href='/posts/{{ post.id }}'">
<div class="flex items-start justify-between gap-2 mb-2">
<h4 class="post-card-title">{{ post.topic_title or 'Untitled' }}</h4>
{% if post.critic_feedback and post.critic_feedback | length > 0 %}
{% set score = post.critic_feedback[-1].overall_score %}
{% if post.critic_feedback and post.critic_feedback | length > 0 and post.critic_feedback[-1].get('overall_score') is not none %}
{% set score = post.critic_feedback[-1].get('overall_score', 0) %}
<span class="score-badge flex-shrink-0 {{ 'score-high' if score >= 85 else 'score-medium' if score >= 70 else 'score-low' }}">
{{ score }}
</span>