708 lines
24 KiB
HTML
708 lines
24 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>P2P Wiki AI</title>
|
|
<style>
|
|
:root {
|
|
--bg-primary: #1a1a2e;
|
|
--bg-secondary: #16213e;
|
|
--bg-tertiary: #0f3460;
|
|
--text-primary: #e8e8e8;
|
|
--text-secondary: #a0a0a0;
|
|
--accent: #e94560;
|
|
--accent-hover: #ff6b6b;
|
|
--success: #4ecdc4;
|
|
--border: #2a2a4a;
|
|
}
|
|
|
|
* {
|
|
box-sizing: border-box;
|
|
margin: 0;
|
|
padding: 0;
|
|
}
|
|
|
|
body {
|
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
background: var(--bg-primary);
|
|
color: var(--text-primary);
|
|
min-height: 100vh;
|
|
}
|
|
|
|
.container {
|
|
max-width: 1200px;
|
|
margin: 0 auto;
|
|
padding: 20px;
|
|
}
|
|
|
|
header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
padding: 20px 0;
|
|
border-bottom: 1px solid var(--border);
|
|
margin-bottom: 30px;
|
|
}
|
|
|
|
h1 {
|
|
font-size: 1.8em;
|
|
font-weight: 600;
|
|
}
|
|
|
|
h1 span {
|
|
color: var(--accent);
|
|
}
|
|
|
|
.tabs {
|
|
display: flex;
|
|
gap: 10px;
|
|
}
|
|
|
|
.tab {
|
|
padding: 10px 20px;
|
|
background: var(--bg-secondary);
|
|
border: 1px solid var(--border);
|
|
border-radius: 8px;
|
|
cursor: pointer;
|
|
transition: all 0.2s;
|
|
}
|
|
|
|
.tab:hover, .tab.active {
|
|
background: var(--bg-tertiary);
|
|
border-color: var(--accent);
|
|
}
|
|
|
|
.panel {
|
|
display: none;
|
|
}
|
|
|
|
.panel.active {
|
|
display: block;
|
|
}
|
|
|
|
/* Chat Panel */
|
|
.chat-container {
|
|
display: flex;
|
|
flex-direction: column;
|
|
height: calc(100vh - 200px);
|
|
background: var(--bg-secondary);
|
|
border-radius: 12px;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.chat-messages {
|
|
flex: 1;
|
|
overflow-y: auto;
|
|
padding: 20px;
|
|
}
|
|
|
|
.message {
|
|
margin-bottom: 20px;
|
|
max-width: 80%;
|
|
}
|
|
|
|
.message.user {
|
|
margin-left: auto;
|
|
}
|
|
|
|
.message-content {
|
|
padding: 15px;
|
|
border-radius: 12px;
|
|
line-height: 1.6;
|
|
}
|
|
|
|
.message.user .message-content {
|
|
background: var(--bg-tertiary);
|
|
}
|
|
|
|
.message.assistant .message-content {
|
|
background: var(--bg-primary);
|
|
border: 1px solid var(--border);
|
|
}
|
|
|
|
.message-sources {
|
|
margin-top: 10px;
|
|
padding: 10px;
|
|
background: rgba(233, 69, 96, 0.1);
|
|
border-radius: 8px;
|
|
font-size: 0.9em;
|
|
}
|
|
|
|
.message-sources h4 {
|
|
color: var(--accent);
|
|
margin-bottom: 5px;
|
|
}
|
|
|
|
.source-tag {
|
|
display: inline-block;
|
|
padding: 3px 8px;
|
|
margin: 2px;
|
|
background: var(--bg-tertiary);
|
|
border-radius: 4px;
|
|
font-size: 0.85em;
|
|
}
|
|
|
|
.chat-input {
|
|
display: flex;
|
|
gap: 10px;
|
|
padding: 20px;
|
|
background: var(--bg-primary);
|
|
border-top: 1px solid var(--border);
|
|
}
|
|
|
|
.chat-input input {
|
|
flex: 1;
|
|
padding: 15px;
|
|
background: var(--bg-secondary);
|
|
border: 1px solid var(--border);
|
|
border-radius: 8px;
|
|
color: var(--text-primary);
|
|
font-size: 1em;
|
|
}
|
|
|
|
.chat-input input:focus {
|
|
outline: none;
|
|
border-color: var(--accent);
|
|
}
|
|
|
|
.chat-input button {
|
|
padding: 15px 30px;
|
|
background: var(--accent);
|
|
border: none;
|
|
border-radius: 8px;
|
|
color: white;
|
|
font-weight: 600;
|
|
cursor: pointer;
|
|
transition: background 0.2s;
|
|
}
|
|
|
|
.chat-input button:hover {
|
|
background: var(--accent-hover);
|
|
}
|
|
|
|
.chat-input button:disabled {
|
|
opacity: 0.5;
|
|
cursor: not-allowed;
|
|
}
|
|
|
|
/* Ingress Panel */
|
|
.ingress-container {
|
|
background: var(--bg-secondary);
|
|
border-radius: 12px;
|
|
padding: 30px;
|
|
}
|
|
|
|
.ingress-form {
|
|
display: flex;
|
|
gap: 10px;
|
|
margin-bottom: 30px;
|
|
}
|
|
|
|
.ingress-form input {
|
|
flex: 1;
|
|
padding: 15px;
|
|
background: var(--bg-primary);
|
|
border: 1px solid var(--border);
|
|
border-radius: 8px;
|
|
color: var(--text-primary);
|
|
font-size: 1em;
|
|
}
|
|
|
|
.ingress-form input:focus {
|
|
outline: none;
|
|
border-color: var(--accent);
|
|
}
|
|
|
|
.ingress-form button {
|
|
padding: 15px 30px;
|
|
background: var(--success);
|
|
border: none;
|
|
border-radius: 8px;
|
|
color: var(--bg-primary);
|
|
font-weight: 600;
|
|
cursor: pointer;
|
|
transition: opacity 0.2s;
|
|
}
|
|
|
|
.ingress-form button:hover {
|
|
opacity: 0.9;
|
|
}
|
|
|
|
.ingress-form button:disabled {
|
|
opacity: 0.5;
|
|
cursor: not-allowed;
|
|
}
|
|
|
|
.ingress-result {
|
|
background: var(--bg-primary);
|
|
border-radius: 8px;
|
|
padding: 20px;
|
|
margin-bottom: 20px;
|
|
}
|
|
|
|
.ingress-result h3 {
|
|
margin-bottom: 15px;
|
|
color: var(--accent);
|
|
}
|
|
|
|
.result-stats {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
|
gap: 15px;
|
|
margin-bottom: 20px;
|
|
}
|
|
|
|
.stat {
|
|
background: var(--bg-secondary);
|
|
padding: 15px;
|
|
border-radius: 8px;
|
|
text-align: center;
|
|
}
|
|
|
|
.stat-value {
|
|
font-size: 2em;
|
|
font-weight: bold;
|
|
color: var(--success);
|
|
}
|
|
|
|
.stat-label {
|
|
color: var(--text-secondary);
|
|
font-size: 0.9em;
|
|
}
|
|
|
|
/* Review Panel */
|
|
.review-container {
|
|
background: var(--bg-secondary);
|
|
border-radius: 12px;
|
|
padding: 30px;
|
|
}
|
|
|
|
.review-item {
|
|
background: var(--bg-primary);
|
|
border-radius: 8px;
|
|
padding: 20px;
|
|
margin-bottom: 20px;
|
|
}
|
|
|
|
.review-item h3 {
|
|
margin-bottom: 10px;
|
|
}
|
|
|
|
.review-meta {
|
|
color: var(--text-secondary);
|
|
font-size: 0.9em;
|
|
margin-bottom: 15px;
|
|
}
|
|
|
|
.review-section {
|
|
margin-top: 20px;
|
|
padding-top: 20px;
|
|
border-top: 1px solid var(--border);
|
|
}
|
|
|
|
.review-section h4 {
|
|
margin-bottom: 10px;
|
|
color: var(--accent);
|
|
}
|
|
|
|
.match-item, .draft-item {
|
|
background: var(--bg-secondary);
|
|
padding: 15px;
|
|
border-radius: 8px;
|
|
margin-bottom: 10px;
|
|
}
|
|
|
|
.match-item .title, .draft-item .title {
|
|
font-weight: 600;
|
|
margin-bottom: 5px;
|
|
}
|
|
|
|
.match-item .score {
|
|
color: var(--success);
|
|
}
|
|
|
|
.action-buttons {
|
|
display: flex;
|
|
gap: 10px;
|
|
margin-top: 10px;
|
|
}
|
|
|
|
.btn-approve {
|
|
padding: 8px 16px;
|
|
background: var(--success);
|
|
border: none;
|
|
border-radius: 4px;
|
|
color: var(--bg-primary);
|
|
cursor: pointer;
|
|
}
|
|
|
|
.btn-reject {
|
|
padding: 8px 16px;
|
|
background: var(--accent);
|
|
border: none;
|
|
border-radius: 4px;
|
|
color: white;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.loading {
|
|
display: inline-block;
|
|
width: 20px;
|
|
height: 20px;
|
|
border: 2px solid var(--text-secondary);
|
|
border-top-color: var(--accent);
|
|
border-radius: 50%;
|
|
animation: spin 1s linear infinite;
|
|
}
|
|
|
|
@keyframes spin {
|
|
to { transform: rotate(360deg); }
|
|
}
|
|
|
|
.empty-state {
|
|
text-align: center;
|
|
padding: 50px;
|
|
color: var(--text-secondary);
|
|
}
|
|
|
|
/* Markdown-like formatting */
|
|
.message-content p { margin-bottom: 10px; }
|
|
.message-content ul, .message-content ol { margin-left: 20px; margin-bottom: 10px; }
|
|
.message-content code { background: var(--bg-tertiary); padding: 2px 6px; border-radius: 4px; }
|
|
.message-content pre { background: var(--bg-tertiary); padding: 15px; border-radius: 8px; overflow-x: auto; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="container">
|
|
<header>
|
|
<h1>P2P Wiki <span>AI</span></h1>
|
|
<div class="tabs">
|
|
<div class="tab active" data-panel="chat">Chat</div>
|
|
<div class="tab" data-panel="ingress">Ingress</div>
|
|
<div class="tab" data-panel="review">Review Queue</div>
|
|
</div>
|
|
</header>
|
|
|
|
<!-- Chat Panel -->
|
|
<div id="chat" class="panel active">
|
|
<div class="chat-container">
|
|
<div class="chat-messages" id="chatMessages">
|
|
<div class="message assistant">
|
|
<div class="message-content">
|
|
<p>Welcome to the P2P Wiki AI assistant! I can help you explore the P2P Foundation Wiki's knowledge about peer-to-peer culture, commons-based peer production, alternative economics, and collaborative governance.</p>
|
|
<p>Ask me anything about these topics!</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="chat-input">
|
|
<input type="text" id="chatInput" placeholder="Ask about P2P, commons, cooperative economics..." />
|
|
<button id="chatSend">Send</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Ingress Panel -->
|
|
<div id="ingress" class="panel">
|
|
<div class="ingress-container">
|
|
<h2>Article Ingress</h2>
|
|
<p style="color: var(--text-secondary); margin-bottom: 20px;">
|
|
Drop an article URL to analyze it for wiki content. The AI will identify relevant topics,
|
|
find matching wiki articles for citations, and draft new articles.
|
|
</p>
|
|
<div class="ingress-form">
|
|
<input type="url" id="ingressUrl" placeholder="https://example.com/article-about-commons" />
|
|
<button id="ingressSubmit">Process Article</button>
|
|
</div>
|
|
<div id="ingressResult"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Review Panel -->
|
|
<div id="review" class="panel">
|
|
<div class="review-container">
|
|
<h2>Review Queue</h2>
|
|
<p style="color: var(--text-secondary); margin-bottom: 20px;">
|
|
Review and approve AI-generated wiki content before it's added to the wiki.
|
|
</p>
|
|
<div id="reviewItems">
|
|
<div class="empty-state">Loading review items...</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
const API_BASE = ''; // Same origin
|
|
|
|
// Tab switching
|
|
document.querySelectorAll('.tab').forEach(tab => {
|
|
tab.addEventListener('click', () => {
|
|
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
|
|
document.querySelectorAll('.panel').forEach(p => p.classList.remove('active'));
|
|
tab.classList.add('active');
|
|
document.getElementById(tab.dataset.panel).classList.add('active');
|
|
|
|
// Load review items when switching to review tab
|
|
if (tab.dataset.panel === 'review') {
|
|
loadReviewItems();
|
|
}
|
|
});
|
|
});
|
|
|
|
// Chat functionality
|
|
const chatMessages = document.getElementById('chatMessages');
|
|
const chatInput = document.getElementById('chatInput');
|
|
const chatSend = document.getElementById('chatSend');
|
|
|
|
function addMessage(content, role, sources = []) {
|
|
const div = document.createElement('div');
|
|
div.className = `message ${role}`;
|
|
|
|
let html = `<div class="message-content">${formatMessage(content)}</div>`;
|
|
|
|
if (sources.length > 0) {
|
|
html += `<div class="message-sources">
|
|
<h4>Sources</h4>
|
|
${sources.map(s => `<span class="source-tag">${s.title}</span>`).join('')}
|
|
</div>`;
|
|
}
|
|
|
|
div.innerHTML = html;
|
|
chatMessages.appendChild(div);
|
|
chatMessages.scrollTop = chatMessages.scrollHeight;
|
|
}
|
|
|
|
function formatMessage(text) {
|
|
// Basic markdown-like formatting
|
|
return text
|
|
.replace(/\n\n/g, '</p><p>')
|
|
.replace(/\n/g, '<br>')
|
|
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
|
.replace(/\*(.+?)\*/g, '<em>$1</em>')
|
|
.replace(/`(.+?)`/g, '<code>$1</code>');
|
|
}
|
|
|
|
async function sendChat() {
|
|
const query = chatInput.value.trim();
|
|
if (!query) return;
|
|
|
|
chatInput.value = '';
|
|
chatSend.disabled = true;
|
|
|
|
addMessage(query, 'user');
|
|
|
|
// Show loading
|
|
const loadingDiv = document.createElement('div');
|
|
loadingDiv.className = 'message assistant';
|
|
loadingDiv.innerHTML = '<div class="message-content"><span class="loading"></span> Thinking...</div>';
|
|
chatMessages.appendChild(loadingDiv);
|
|
chatMessages.scrollTop = chatMessages.scrollHeight;
|
|
|
|
try {
|
|
const response = await fetch(`${API_BASE}/chat`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ query, n_results: 5 })
|
|
});
|
|
|
|
const data = await response.json();
|
|
|
|
chatMessages.removeChild(loadingDiv);
|
|
|
|
if (response.ok) {
|
|
addMessage(data.answer, 'assistant', data.sources);
|
|
} else {
|
|
addMessage(`Error: ${data.detail || 'Something went wrong'}`, 'assistant');
|
|
}
|
|
} catch (error) {
|
|
chatMessages.removeChild(loadingDiv);
|
|
addMessage(`Error: ${error.message}`, 'assistant');
|
|
}
|
|
|
|
chatSend.disabled = false;
|
|
chatInput.focus();
|
|
}
|
|
|
|
chatSend.addEventListener('click', sendChat);
|
|
chatInput.addEventListener('keypress', (e) => {
|
|
if (e.key === 'Enter') sendChat();
|
|
});
|
|
|
|
// Ingress functionality
|
|
const ingressUrl = document.getElementById('ingressUrl');
|
|
const ingressSubmit = document.getElementById('ingressSubmit');
|
|
const ingressResult = document.getElementById('ingressResult');
|
|
|
|
async function processIngress() {
|
|
const url = ingressUrl.value.trim();
|
|
if (!url) return;
|
|
|
|
ingressSubmit.disabled = true;
|
|
ingressSubmit.textContent = 'Processing...';
|
|
|
|
ingressResult.innerHTML = `
|
|
<div class="ingress-result">
|
|
<h3>Processing Article</h3>
|
|
<p><span class="loading"></span> Scraping and analyzing content...</p>
|
|
</div>
|
|
`;
|
|
|
|
try {
|
|
const response = await fetch(`${API_BASE}/ingress`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ url })
|
|
});
|
|
|
|
const data = await response.json();
|
|
|
|
if (response.ok) {
|
|
ingressResult.innerHTML = `
|
|
<div class="ingress-result">
|
|
<h3>Analysis Complete: ${data.scraped_title || 'Article'}</h3>
|
|
<div class="result-stats">
|
|
<div class="stat">
|
|
<div class="stat-value">${data.topics_found}</div>
|
|
<div class="stat-label">Topics Found</div>
|
|
</div>
|
|
<div class="stat">
|
|
<div class="stat-value">${data.wiki_matches}</div>
|
|
<div class="stat-label">Wiki Matches</div>
|
|
</div>
|
|
<div class="stat">
|
|
<div class="stat-value">${data.drafts_generated}</div>
|
|
<div class="stat-label">Drafts Generated</div>
|
|
</div>
|
|
</div>
|
|
<p style="color: var(--success);">
|
|
Results added to review queue. Check the Review tab to approve or reject suggestions.
|
|
</p>
|
|
</div>
|
|
`;
|
|
} else {
|
|
ingressResult.innerHTML = `
|
|
<div class="ingress-result">
|
|
<h3 style="color: var(--accent);">Error</h3>
|
|
<p>${data.detail || 'Failed to process article'}</p>
|
|
</div>
|
|
`;
|
|
}
|
|
} catch (error) {
|
|
ingressResult.innerHTML = `
|
|
<div class="ingress-result">
|
|
<h3 style="color: var(--accent);">Error</h3>
|
|
<p>${error.message}</p>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
ingressSubmit.disabled = false;
|
|
ingressSubmit.textContent = 'Process Article';
|
|
}
|
|
|
|
ingressSubmit.addEventListener('click', processIngress);
|
|
ingressUrl.addEventListener('keypress', (e) => {
|
|
if (e.key === 'Enter') processIngress();
|
|
});
|
|
|
|
// Review functionality
|
|
const reviewItems = document.getElementById('reviewItems');
|
|
|
|
async function loadReviewItems() {
|
|
try {
|
|
const response = await fetch(`${API_BASE}/review`);
|
|
const data = await response.json();
|
|
|
|
if (data.count === 0) {
|
|
reviewItems.innerHTML = '<div class="empty-state">No items in the review queue.</div>';
|
|
return;
|
|
}
|
|
|
|
reviewItems.innerHTML = data.items.map(item => `
|
|
<div class="review-item">
|
|
<h3>${item.scraped?.title || 'Unknown Article'}</h3>
|
|
<div class="review-meta">
|
|
Source: <a href="${item.scraped?.url}" target="_blank">${item.scraped?.domain}</a>
|
|
| Processed: ${new Date(item.timestamp).toLocaleString()}
|
|
</div>
|
|
|
|
${item.wiki_matches?.length > 0 ? `
|
|
<div class="review-section">
|
|
<h4>Suggested Citations (${item.wiki_matches.length})</h4>
|
|
${item.wiki_matches.map((match, i) => `
|
|
<div class="match-item" ${match.approved ? 'style="opacity: 0.5"' : ''}>
|
|
<div class="title">${match.title}</div>
|
|
<div class="score">Relevance: ${(match.relevance_score * 100).toFixed(0)}%</div>
|
|
<div>${match.suggested_citation}</div>
|
|
${!match.approved && !match.rejected ? `
|
|
<div class="action-buttons">
|
|
<button class="btn-approve" onclick="reviewAction('${item._filepath}', 'match', ${i}, 'approve')">Approve</button>
|
|
<button class="btn-reject" onclick="reviewAction('${item._filepath}', 'match', ${i}, 'reject')">Reject</button>
|
|
</div>
|
|
` : `<em>${match.approved ? 'Approved' : 'Rejected'}</em>`}
|
|
</div>
|
|
`).join('')}
|
|
</div>
|
|
` : ''}
|
|
|
|
${item.draft_articles?.length > 0 ? `
|
|
<div class="review-section">
|
|
<h4>Draft Articles (${item.draft_articles.length})</h4>
|
|
${item.draft_articles.map((draft, i) => `
|
|
<div class="draft-item" ${draft.approved ? 'style="opacity: 0.5"' : ''}>
|
|
<div class="title">${draft.title}</div>
|
|
<div style="color: var(--text-secondary); font-size: 0.9em; margin-bottom: 10px;">
|
|
${draft.summary || ''}
|
|
</div>
|
|
<details>
|
|
<summary style="cursor: pointer; color: var(--accent);">View Draft Content</summary>
|
|
<pre style="margin-top: 10px; white-space: pre-wrap; font-size: 0.85em;">${draft.content}</pre>
|
|
</details>
|
|
${!draft.approved && !draft.rejected ? `
|
|
<div class="action-buttons">
|
|
<button class="btn-approve" onclick="reviewAction('${item._filepath}', 'draft', ${i}, 'approve')">Approve</button>
|
|
<button class="btn-reject" onclick="reviewAction('${item._filepath}', 'draft', ${i}, 'reject')">Reject</button>
|
|
</div>
|
|
` : `<em>${draft.approved ? 'Approved' : 'Rejected'}</em>`}
|
|
</div>
|
|
`).join('')}
|
|
</div>
|
|
` : ''}
|
|
</div>
|
|
`).join('');
|
|
} catch (error) {
|
|
reviewItems.innerHTML = `<div class="empty-state">Error loading review items: ${error.message}</div>`;
|
|
}
|
|
}
|
|
|
|
async function reviewAction(filepath, itemType, itemIndex, action) {
|
|
try {
|
|
const response = await fetch(`${API_BASE}/review/action`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
filepath,
|
|
item_type: itemType,
|
|
item_index: itemIndex,
|
|
action
|
|
})
|
|
});
|
|
|
|
if (response.ok) {
|
|
loadReviewItems(); // Refresh the list
|
|
} else {
|
|
const data = await response.json();
|
|
alert(`Error: ${data.detail || 'Action failed'}`);
|
|
}
|
|
} catch (error) {
|
|
alert(`Error: ${error.message}`);
|
|
}
|
|
}
|
|
|
|
// Make reviewAction available globally
|
|
window.reviewAction = reviewAction;
|
|
</script>
|
|
</body>
|
|
</html>
|