agnes-the-ai-analyst/webapp/templates/corporate_memory.html
Petr c56905d34f Initial commit: OSS data distribution platform
Open-source AI data analyst platform extracted from internal repo.
Includes data sync engine, Keboola adapter, Flask web portal,
server deployment scripts, and configuration templates.
2026-03-08 23:31:28 +01:00

815 lines
30 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Corporate Memory - Data Analyst Portal</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="{{ url_for('static', filename='style-custom.css') }}">
<style>
/* Corporate Memory Page - Design System Styles */
.container-memory {
max-width: 1000px;
margin: 0 auto;
padding: var(--space-6);
}
/* Header */
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
padding-bottom: var(--space-5);
border-bottom: 1px solid var(--border);
margin-bottom: var(--space-6);
}
.header-left {
display: flex;
align-items: center;
gap: var(--space-4);
}
.back-link {
display: flex;
align-items: center;
gap: var(--space-2);
color: var(--text-secondary);
text-decoration: none;
font-size: var(--text-sm);
padding: var(--space-2) var(--space-3);
border-radius: var(--radius-md);
transition: all 0.15s;
}
.back-link:hover {
background: var(--border-light);
color: var(--text-primary);
}
.page-title {
display: flex;
align-items: center;
gap: var(--space-3);
}
.page-title h1 {
font-size: var(--text-xl);
font-weight: var(--font-semibold);
}
.page-icon {
width: 36px;
height: 36px;
background: var(--warning);
border-radius: var(--radius-lg);
display: flex;
align-items: center;
justify-content: center;
color: white;
}
/* Stats Bar */
.stats-bar {
display: flex;
gap: var(--space-6);
padding: var(--space-5);
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-xl);
box-shadow: var(--shadow-sm);
margin-bottom: var(--space-6);
}
.stat-item {
text-align: center;
}
.stat-item .value {
font-size: var(--text-xl);
font-weight: var(--font-bold);
color: var(--text-primary);
}
.stat-item .label {
font-size: var(--text-xs);
color: var(--text-secondary);
text-transform: uppercase;
}
.stat-item.highlight .value {
color: var(--warning);
}
.stat-divider {
width: 1px;
background: var(--border);
}
.stats-bar-right {
margin-left: auto;
display: flex;
align-items: center;
gap: var(--space-3);
}
.sync-info {
font-size: var(--text-sm);
color: var(--success);
display: flex;
align-items: center;
gap: var(--space-2);
}
/* Toolbar */
.toolbar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--space-5);
gap: var(--space-4);
}
.search-box {
flex: 1;
max-width: 320px;
position: relative;
}
.search-box input {
width: 100%;
padding: var(--space-2) var(--space-3) var(--space-2) 36px;
border: 1px solid var(--border);
border-radius: var(--radius-md);
font-size: var(--text-base);
background: var(--surface);
}
.search-box input:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 2px var(--primary-light);
}
.search-box svg {
position: absolute;
left: var(--space-3);
top: 50%;
transform: translateY(-50%);
color: var(--text-secondary);
}
.filter-group {
display: flex;
gap: var(--space-2);
}
.filter-btn {
padding: var(--space-2) var(--space-3);
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-md);
font-size: var(--text-sm);
color: var(--text-secondary);
cursor: pointer;
transition: all 0.15s;
}
.filter-btn:hover {
border-color: var(--text-secondary);
}
.filter-btn.active {
background: var(--primary-light);
border-color: var(--primary);
color: var(--primary);
}
.sort-select {
padding: var(--space-2) var(--space-3);
border: 1px solid var(--border);
border-radius: var(--radius-md);
font-size: var(--text-sm);
background: var(--surface);
color: var(--text-primary);
cursor: pointer;
}
/* Knowledge List */
.knowledge-list {
display: flex;
flex-direction: column;
gap: var(--space-4);
}
.knowledge-item {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-xl);
box-shadow: var(--shadow-sm);
padding: var(--space-5);
transition: all 0.15s;
}
.knowledge-item:hover {
border-color: var(--primary);
box-shadow: var(--shadow-md);
}
.knowledge-item.synced {
border-left: 3px solid var(--success);
}
.knowledge-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: var(--space-3);
}
.knowledge-title {
font-size: var(--text-md);
font-weight: var(--font-semibold);
color: var(--text-primary);
margin-bottom: var(--space-1);
}
.knowledge-meta {
display: flex;
align-items: center;
gap: var(--space-3);
font-size: var(--text-xs);
color: var(--text-secondary);
}
.category-badge {
padding: 2px 8px;
border-radius: var(--radius-full);
font-size: var(--text-xs);
font-weight: var(--font-medium);
}
.category-badge.data_analysis { background: rgba(16, 183, 127, 0.1); color: var(--success); }
.category-badge.api_integration { background: var(--primary-light); color: var(--primary); }
.category-badge.debugging { background: rgba(234, 88, 12, 0.1); color: var(--error); }
.category-badge.performance { background: rgba(245, 159, 10, 0.1); color: #b45309; }
.category-badge.workflow { background: rgba(139, 92, 246, 0.1); color: #7c3aed; }
.category-badge.infrastructure { background: rgba(107, 114, 128, 0.1); color: var(--text-secondary); }
.category-badge.business_logic { background: rgba(16, 183, 127, 0.1); color: #0d9668; }
.knowledge-content {
font-size: var(--text-base);
color: var(--text-secondary);
line-height: 1.7;
margin-bottom: var(--space-4);
}
.knowledge-content code {
font-family: var(--font-mono);
font-size: var(--text-sm);
background: var(--background);
padding: 2px 6px;
border-radius: var(--radius-sm);
color: var(--text-primary);
}
.knowledge-footer {
display: flex;
justify-content: space-between;
align-items: center;
padding-top: var(--space-3);
border-top: 1px solid var(--border-light);
}
.knowledge-tags {
display: flex;
gap: var(--space-2);
}
.tag {
font-size: var(--text-xs);
color: var(--text-secondary);
background: var(--background);
padding: 2px 8px;
border-radius: var(--radius-sm);
}
.contributors {
font-size: var(--text-xs);
color: var(--text-secondary);
display: flex;
align-items: center;
gap: var(--space-2);
}
.contributor-avatars {
display: flex;
}
.contributor-avatar {
width: 20px;
height: 20px;
border-radius: var(--radius-full);
background: var(--primary-light);
border: 2px solid var(--surface);
margin-left: -6px;
display: flex;
align-items: center;
justify-content: center;
font-size: 8px;
font-weight: var(--font-semibold);
color: var(--primary);
}
.contributor-avatar:first-child {
margin-left: 0;
}
/* Voting */
.vote-section {
display: flex;
align-items: center;
gap: var(--space-4);
}
.vote-buttons {
display: flex;
align-items: center;
gap: var(--space-2);
}
.vote-btn {
display: flex;
align-items: center;
gap: var(--space-1);
padding: var(--space-2) var(--space-3);
border: 1px solid var(--border);
border-radius: var(--radius-md);
background: var(--surface);
font-size: var(--text-sm);
color: var(--text-secondary);
cursor: pointer;
transition: all 0.15s;
}
.vote-btn:hover {
border-color: var(--text-secondary);
}
.vote-btn.upvote.active {
background: rgba(16, 183, 127, 0.1);
border-color: var(--success);
color: var(--success);
}
.vote-btn.downvote.active {
background: rgba(234, 88, 12, 0.1);
border-color: var(--error);
color: var(--error);
}
.vote-btn svg {
width: 16px;
height: 16px;
}
.synced-badge {
display: flex;
align-items: center;
gap: var(--space-1);
font-size: var(--text-xs);
color: var(--success);
background: rgba(16, 183, 127, 0.1);
padding: var(--space-1) var(--space-2);
border-radius: var(--radius-full);
}
/* Pagination */
.pagination {
display: flex;
justify-content: center;
gap: var(--space-2);
margin-top: var(--space-6);
}
.page-btn {
padding: var(--space-2) var(--space-3);
border: 1px solid var(--border);
border-radius: var(--radius-md);
background: var(--surface);
font-size: var(--text-sm);
color: var(--text-secondary);
cursor: pointer;
transition: all 0.15s;
}
.page-btn:hover:not(:disabled) {
border-color: var(--primary);
color: var(--primary);
}
.page-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.page-btn.active {
background: var(--primary);
border-color: var(--primary);
color: white;
}
/* Empty State */
.empty-state {
text-align: center;
padding: var(--space-8);
color: var(--text-secondary);
}
.empty-state svg {
width: 48px;
height: 48px;
margin-bottom: var(--space-4);
opacity: 0.5;
}
/* Responsive */
@media (max-width: 768px) {
.page-header {
flex-direction: column;
align-items: flex-start;
gap: var(--space-3);
}
.stats-bar {
flex-wrap: wrap;
gap: var(--space-4);
}
.toolbar {
flex-direction: column;
align-items: stretch;
}
.search-box {
max-width: none;
}
.filter-group {
flex-wrap: wrap;
}
.knowledge-header {
flex-direction: column;
gap: var(--space-3);
}
.knowledge-footer {
flex-direction: column;
gap: var(--space-3);
align-items: flex-start;
}
}
</style>
</head>
<body>
<div class="container-memory">
<!-- Header -->
<header class="page-header">
<div class="header-left">
<a href="{{ url_for('dashboard') }}" class="back-link">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M15 18l-6-6 6-6"/>
</svg>
Dashboard
</a>
<div class="page-title">
<span class="page-icon">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 2L2 7l10 5 10-5-10-5z"/>
<path d="M2 17l10 5 10-5"/>
<path d="M2 12l10 5 10-5"/>
</svg>
</span>
<h1>Corporate Memory</h1>
</div>
</div>
<div class="user-info-v2">
{% if session.user.picture %}
<img src="{{ session.user.picture }}" alt="Profile" class="avatar-v2">
{% else %}
<div class="avatar-v2" style="background: var(--primary-light); display: flex; align-items: center; justify-content: center; font-weight: 600; color: var(--primary); font-size: 12px;">
{{ session.user.email[:2].upper() }}
</div>
{% endif %}
{{ session.user.email }}
</div>
</header>
<!-- Stats Bar -->
<div class="stats-bar">
<div class="stat-item">
<div class="value">{{ stats.contributors }}</div>
<div class="label">Contributors</div>
</div>
<div class="stat-divider"></div>
<div class="stat-item">
<div class="value">{{ stats.knowledge_count }}</div>
<div class="label">Knowledge Items</div>
</div>
<div class="stat-divider"></div>
<div class="stat-item highlight">
<div class="value">{{ user_stats.your_rules }}</div>
<div class="label">Your Rules</div>
</div>
<div class="stats-bar-right">
<div class="sync-info">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="20 6 9 17 4 12"/>
</svg>
Synced to .claude/rules/
</div>
</div>
</div>
<!-- Toolbar -->
<div class="toolbar">
<div class="search-box">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="11" cy="11" r="8"/>
<path d="M21 21l-4.35-4.35"/>
</svg>
<input type="text" id="searchInput" placeholder="Search knowledge..." onkeyup="debounceSearch()">
</div>
<div class="filter-group">
<button class="filter-btn active" data-category="" onclick="setFilter(this)">All</button>
<button class="filter-btn" data-category="my_rules" onclick="setFilter(this)">My Rules</button>
<button class="filter-btn" data-category="data_analysis" onclick="setFilter(this)">Data Analysis</button>
<button class="filter-btn" data-category="api_integration" onclick="setFilter(this)">API</button>
<button class="filter-btn" data-category="performance" onclick="setFilter(this)">Performance</button>
</div>
<select class="sort-select" id="sortSelect" onchange="applyFilters()">
<option value="score">Most Popular</option>
<option value="updated_at">Most Recent</option>
<option value="contributors">Most Contributors</option>
</select>
</div>
<!-- Knowledge List -->
<div id="knowledgeList" class="knowledge-list">
{% for item in knowledge['items'] %}
<div class="knowledge-item {% if item.synced %}synced{% endif %}" data-id="{{ item.id }}">
<div class="knowledge-header">
<div>
<div class="knowledge-title">{{ item.title }}</div>
<div class="knowledge-meta">
<span class="category-badge {{ item.category }}">{{ item.category.replace('_', ' ').title() }}</span>
<span>Added {{ item.extracted_at[:10] if item.extracted_at else 'recently' }}</span>
</div>
</div>
<div class="vote-section">
<div class="vote-buttons">
<button class="vote-btn upvote {% if user_votes.get(item.id, 0) == 1 %}active{% endif %}"
onclick="vote('{{ item.id }}', 1)" title="Upvote">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M14 9V5a3 3 0 0 0-3-3l-4 9v11h11.28a2 2 0 0 0 2-1.7l1.38-9a2 2 0 0 0-2-2.3H14z"/>
<path d="M7 22H4a2 2 0 0 1-2-2v-7a2 2 0 0 1 2-2h3"/>
</svg>
<span>{{ item.upvotes or 0 }}</span>
</button>
<button class="vote-btn downvote {% if user_votes.get(item.id, 0) == -1 %}active{% endif %}"
onclick="vote('{{ item.id }}', -1)" title="Downvote">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M10 15v4a3 3 0 0 0 3 3l4-9V2H5.72a2 2 0 0 0-2 1.7l-1.38 9a2 2 0 0 0 2 2.3H10z"/>
<path d="M17 2h3a2 2 0 0 1 2 2v7a2 2 0 0 1-2 2h-3"/>
</svg>
<span>{{ item.downvotes or 0 }}</span>
</button>
</div>
{% if item.synced %}
<span class="synced-badge">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="20 6 9 17 4 12"/>
</svg>
In your rules
</span>
{% endif %}
</div>
</div>
<div class="knowledge-content">{{ item.content }}</div>
<div class="knowledge-footer">
<div class="knowledge-tags">
{% for tag in item.tags %}
<span class="tag">{{ tag }}</span>
{% endfor %}
</div>
<div class="contributors">
<div class="contributor-avatars">
{% for user_info in item.source_users_display[:3] %}
<span class="contributor-avatar" title="{{ user_info.name }}">{{ user_info.initials }}</span>
{% endfor %}
{% if item.source_users_display|length > 3 %}
<span class="contributor-avatar">+{{ item.source_users_display|length - 3 }}</span>
{% endif %}
</div>
<span>{{ item.source_users_display|length }} contributor{{ 's' if item.source_users_display|length != 1 else '' }}</span>
</div>
</div>
</div>
{% else %}
<div class="empty-state">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"/>
</svg>
<p>No knowledge items yet.</p>
<p style="font-size: var(--text-sm);">Knowledge is extracted from team CLAUDE.local.md files every 30 minutes.</p>
</div>
{% endfor %}
</div>
<!-- Pagination -->
<div id="pagination" class="pagination">
{% if knowledge.total_pages > 1 %}
<button class="page-btn" onclick="goToPage({{ knowledge.page - 1 }})" {% if knowledge.page == 0 %}disabled{% endif %}>Prev</button>
{% for p in range(knowledge.total_pages) %}
<button class="page-btn {% if p == knowledge.page %}active{% endif %}" onclick="goToPage({{ p }})">{{ p + 1 }}</button>
{% endfor %}
<button class="page-btn" onclick="goToPage({{ knowledge.page + 1 }})" {% if knowledge.page >= knowledge.total_pages - 1 %}disabled{% endif %}>Next</button>
{% endif %}
</div>
</div>
<script>
// Store user votes locally for UI updates
let userVotes = {{ user_votes | tojson }};
let currentPage = {{ knowledge.page }};
let currentCategory = '';
let searchTimeout = null;
function debounceSearch() {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(applyFilters, 300);
}
function setFilter(btn) {
document.querySelectorAll('.filter-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
currentCategory = btn.dataset.category;
currentPage = 0;
applyFilters();
}
async function applyFilters() {
const search = document.getElementById('searchInput').value;
const sort = document.getElementById('sortSelect').value;
await loadKnowledge(currentCategory, search, currentPage, sort);
}
async function goToPage(page) {
currentPage = page;
await applyFilters();
}
async function loadKnowledge(category, search, page, sort) {
const params = new URLSearchParams();
if (category && category !== 'my_rules') params.append('category', category);
if (category === 'my_rules') params.append('my_rules', 'true');
if (search) params.append('search', search);
if (sort) params.append('sort', sort);
params.append('page', page);
try {
params.append('_t', Date.now());
const resp = await fetch(`/api/corporate-memory/knowledge?${params}`);
const data = await resp.json();
renderKnowledge(data);
} catch (e) {
console.error('Failed to load knowledge:', e);
}
}
function renderKnowledge(data) {
const list = document.getElementById('knowledgeList');
const pagination = document.getElementById('pagination');
if (data.items.length === 0) {
list.innerHTML = `
<div class="empty-state">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"/>
</svg>
<p>No matching knowledge items found.</p>
</div>`;
pagination.innerHTML = '';
return;
}
list.innerHTML = data.items.map(item => `
<div class="knowledge-item ${item.synced ? 'synced' : ''}" data-id="${item.id}">
<div class="knowledge-header">
<div>
<div class="knowledge-title">${escapeHtml(item.title)}</div>
<div class="knowledge-meta">
<span class="category-badge ${item.category}">${escapeHtml(item.category.replace('_', ' '))}</span>
<span>Added ${item.extracted_at ? item.extracted_at.substring(0, 10) : 'recently'}</span>
</div>
</div>
<div class="vote-section">
<div class="vote-buttons">
<button class="vote-btn upvote ${userVotes[item.id] === 1 ? 'active' : ''}"
onclick="vote('${item.id}', 1)" title="Upvote">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M14 9V5a3 3 0 0 0-3-3l-4 9v11h11.28a2 2 0 0 0 2-1.7l1.38-9a2 2 0 0 0-2-2.3H14z"/>
<path d="M7 22H4a2 2 0 0 1-2-2v-7a2 2 0 0 1 2-2h3"/>
</svg>
<span>${item.upvotes || 0}</span>
</button>
<button class="vote-btn downvote ${userVotes[item.id] === -1 ? 'active' : ''}"
onclick="vote('${item.id}', -1)" title="Downvote">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M10 15v4a3 3 0 0 0 3 3l4-9V2H5.72a2 2 0 0 0-2 1.7l-1.38 9a2 2 0 0 0 2 2.3H10z"/>
<path d="M17 2h3a2 2 0 0 1 2 2v7a2 2 0 0 1-2 2h-3"/>
</svg>
<span>${item.downvotes || 0}</span>
</button>
</div>
${item.synced ? `
<span class="synced-badge">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="20 6 9 17 4 12"/>
</svg>
In your rules
</span>` : ''}
</div>
</div>
<div class="knowledge-content">${escapeHtml(item.content)}</div>
<div class="knowledge-footer">
<div class="knowledge-tags">
${(item.tags || []).map(tag => `<span class="tag">${escapeHtml(tag)}</span>`).join('')}
</div>
<div class="contributors">
<div class="contributor-avatars">
${(item.source_users_display || []).slice(0, 3).map(u =>
`<span class="contributor-avatar" title="${escapeHtml(u.name)}">${escapeHtml(u.initials)}</span>`
).join('')}
${(item.source_users_display || []).length > 3 ?
`<span class="contributor-avatar">+${item.source_users_display.length - 3}</span>` : ''}
</div>
<span>${(item.source_users_display || []).length} contributor${item.source_users_display?.length !== 1 ? 's' : ''}</span>
</div>
</div>
</div>
`).join('');
// Render pagination
if (data.total_pages > 1) {
let paginationHtml = `
<button class="page-btn" onclick="goToPage(${data.page - 1})" ${data.page === 0 ? 'disabled' : ''}>Prev</button>
`;
for (let p = 0; p < data.total_pages; p++) {
paginationHtml += `<button class="page-btn ${p === data.page ? 'active' : ''}" onclick="goToPage(${p})">${p + 1}</button>`;
}
paginationHtml += `
<button class="page-btn" onclick="goToPage(${data.page + 1})" ${data.page >= data.total_pages - 1 ? 'disabled' : ''}>Next</button>
`;
pagination.innerHTML = paginationHtml;
} else {
pagination.innerHTML = '';
}
}
async function vote(itemId, voteValue) {
// Toggle vote if clicking same button
const currentVote = userVotes[itemId] || 0;
const newVote = currentVote === voteValue ? 0 : voteValue;
try {
const resp = await fetch('/api/corporate-memory/vote', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({item_id: itemId, vote: newVote})
});
if (resp.ok) {
userVotes[itemId] = newVote;
// Reload to get updated scores
applyFilters();
} else {
const err = await resp.json().catch(() => ({}));
console.error('Vote failed:', resp.status, err);
}
} catch (e) {
console.error('Failed to vote:', e);
}
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
</script>
</body>
</html>