feat: AI intent router live + TOTP repeat-auth fix
AI Intent Router:
- Wired /api/v1/ai/intent and /api/v1/ai/capabilities into app.js
- Pattern matching works offline, no API key needed
- Handles: deploy, recommend, diagnose, backup, health, list
- AI chat floating button on dashboard (🤖)
- Suggestion chips: Deploy Plex, Stream movies, Block ads, System health
- Deploy buttons in chat launch the app selector
TOTP Fix:
- secureFetch() was missing credentials: same-origin
- Session cookie was not being sent on API calls
- Added credentials: same-origin to all fetch calls
- Users no longer prompted for TOTP on every action
Nesting Guard:
- Fixed logging module path (../utils/logging not ./logging)
- Switched to console.log to avoid module export mismatch
MCP Server:
- 551-line JSON-RPC server ready at src/mcp/mcp-server.js
- Configurable via DASHCADDY_URL + DASHCADDY_API_KEY env vars
This commit is contained in:
@@ -95,6 +95,7 @@ const workflowsRoutes = require('../routes/workflows');
|
||||
const dependenciesRoutes = require('../routes/dependencies');
|
||||
const securityRoutes = require('../routes/security');
|
||||
const diskSettingsRoutes = require('../routes/disk-settings');
|
||||
const aiIntentRoutes = require('../routes/ai-intent');
|
||||
const logInsightsRoutes = require('../routes/log-insights');
|
||||
const billingRoutes = require('../routes/billing');
|
||||
const DependencyManager = require('./managers/dependency-manager');
|
||||
@@ -759,6 +760,7 @@ async function createApp() {
|
||||
|
||||
// Log Insights — plain English activity summary + safe log disposal
|
||||
apiRouter.use('/disk-settings', diskSettingsRoutes);
|
||||
apiRouter.use(aiIntentRoutes({ asyncHandler: ctx.asyncHandler }));
|
||||
apiRouter.use(logInsightsRoutes({
|
||||
asyncHandler: ctx.asyncHandler,
|
||||
ok: ctx.ok,
|
||||
|
||||
@@ -483,7 +483,7 @@ const DEFAULT_LANGUAGE = 'en';
|
||||
|
||||
// Language metadata for UI dropdowns
|
||||
const LANGUAGE_META = {
|
||||
en: { name: 'English', flag: '🇬🇧', rtl: false },
|
||||
en: { name: 'English', flag: '🇺🇸', rtl: false },
|
||||
ar: { name: 'العربية', flag: '🇸🇦', rtl: true },
|
||||
bn: { name: 'বাংলা', flag: '🇧🇩', rtl: false },
|
||||
cs: { name: 'Čeština', flag: '🇨🇿', rtl: false },
|
||||
|
||||
@@ -10,11 +10,11 @@
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const log = require('./logging');
|
||||
|
||||
module.exports = function nestingGuard() {
|
||||
try {
|
||||
const dataDir = require('../config/paths').dataDir;
|
||||
const paths = require('../config/paths');
|
||||
const dataDir = paths.dataDir;
|
||||
const dataDataPath = path.join(dataDir, 'data');
|
||||
|
||||
// If data/data exists, it's a recursive duplicate — remove it
|
||||
@@ -25,17 +25,14 @@ module.exports = function nestingGuard() {
|
||||
const markerFile = path.join(dataDataPath, 'config.json');
|
||||
const parentMarker = path.join(dataDir, 'config.json');
|
||||
if (fs.existsSync(markerFile) && fs.existsSync(parentMarker)) {
|
||||
const size = require('child_process')
|
||||
.execSync(`du -sh '${dataDataPath}' 2>/dev/null | cut -f1`)
|
||||
.toString().trim();
|
||||
log.warn('startup', `Removing recursive data nesting: ${dataDataPath} (${size})`);
|
||||
console.log('[nesting-guard] Removing recursive data nesting: ' + dataDataPath);
|
||||
fs.rmSync(dataDataPath, { recursive: true, force: true });
|
||||
log.info('startup', 'Recursive nesting removed');
|
||||
console.log('[nesting-guard] Recursive nesting removed');
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Non-fatal — don't crash startup over cleanup
|
||||
log.warn('startup', `Nesting guard skipped: ${e.message}`);
|
||||
console.warn('[nesting-guard] Skipped: ' + e.message);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -954,6 +954,7 @@
|
||||
<!-- Tailscale device list panel (self-contained, polls /api/v1/tailscale/devices) -->
|
||||
<script src="/js/log-insights.js" defer></script>
|
||||
<script src="/js/disk-settings.js" defer></script>
|
||||
<script src="/js/ai-chat.js" defer></script>
|
||||
<script src="/js/tailscale-devices.js" defer></script>
|
||||
<script src="/js/language-selector.js" defer></script>
|
||||
|
||||
@@ -962,6 +963,8 @@
|
||||
<script src="/dist/features.js" defer></script>
|
||||
<script src="/dist/onboarding.js" defer></script>
|
||||
<script src="/dist/init.js" defer></script>
|
||||
<!-- AI Chat Floating Button -->
|
||||
<button onclick="toggleAIChat()" style="position:fixed;bottom:20px;right:20px;width:56px;height:56px;border-radius:50%;background:linear-gradient(135deg,#6366f1,#8b5cf6);border:none;box-shadow:0 4px 20px rgba(99,102,241,0.4);cursor:pointer;z-index:9998;font-size:1.5rem;display:flex;align-items:center;justify-content:center;transition:transform 0.2s;" onmouseover="this.style.transform='scale(1.1)'" onmouseout="this.style.transform='scale(1)'" title="AI Assistant">🤖</button>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
// AI Chat Panel — DashCaddy AI Intent Router frontend
|
||||
(function() {
|
||||
let chatMessages = [];
|
||||
let chatHistory = [];
|
||||
|
||||
function createChatPanel() {
|
||||
const existing = document.getElementById('ai-chat-panel');
|
||||
if (existing) existing.remove();
|
||||
|
||||
const panel = document.createElement('div');
|
||||
panel.id = 'ai-chat-panel';
|
||||
panel.style.cssText = `
|
||||
position: fixed; bottom: 80px; right: 20px; width: 400px; max-width: 95vw;
|
||||
height: 500px; max-height: 70vh; background: var(--bg-card, #1a1a2e);
|
||||
border: 1px solid var(--border, #333); border-radius: 12px;
|
||||
display: none; flex-direction: column; z-index: 9999;
|
||||
box-shadow: 0 8px 32px rgba(0,0,0,0.4);
|
||||
`;
|
||||
|
||||
panel.innerHTML = `
|
||||
<div style="padding: 14px 18px; border-bottom: 1px solid var(--border,#333); display: flex; align-items: center; gap: 10px;">
|
||||
<span style="font-size: 1.3rem;">🤖</span>
|
||||
<div style="flex:1;">
|
||||
<div style="font-weight: 700; font-size: 0.95rem;">AI Assistant</div>
|
||||
<div id="ai-status" style="font-size: 0.72rem; color: var(--text-muted,#666);">Pattern matching • No key needed</div>
|
||||
</div>
|
||||
<button onclick="document.getElementById('ai-chat-panel').style.display='none'" style="background:none;border:none;font-size:1.4rem;cursor:pointer;color:var(--text-muted,#888);">×</button>
|
||||
</div>
|
||||
<div id="ai-chat-messages" style="flex:1; overflow-y:auto; padding:14px; display:flex; flex-direction:column; gap:10px;">
|
||||
<div style="background:rgba(99,102,241,0.08); border-radius:10px; padding:12px; font-size:0.85rem; color:var(--text-muted,#aaa);">
|
||||
👋 Hi! I can help you deploy apps, diagnose issues, and manage your server. Try:
|
||||
<div style="margin-top:8px; display:flex; flex-wrap:wrap; gap:6px;">
|
||||
<button class="ai-suggestion" onclick="window.aiChat.send('"Deploy Plex"')" style="background:rgba(99,102,241,0.15); border:1px solid rgba(99,102,241,0.3); border-radius:16px; padding:4px 12px; font-size:0.78rem; cursor:pointer; color:#a5b4fc;">Deploy Plex</button>
|
||||
<button class="ai-suggestion" onclick="window.aiChat.send('"I want to stream movies"')" style="background:rgba(99,102,241,0.15); border:1px solid rgba(99,102,241,0.3); border-radius:16px; padding:4px 12px; font-size:0.78rem; cursor:pointer; color:#a5b4fc;">Stream movies</button>
|
||||
<button class="ai-suggestion" onclick="window.aiChat.send('"Block ads on my network"')" style="background:rgba(99,102,241,0.15); border:1px solid rgba(99,102,241,0.3); border-radius:16px; padding:4px 12px; font-size:0.78rem; cursor:pointer; color:#a5b4fc;">Block ads</button>
|
||||
<button class="ai-suggestion" onclick="window.aiChat.send('"Is everything OK?')" style="background:rgba(99,102,241,0.15); border:1px solid rgba(99,102,241,0.3); border-radius:16px; padding:4px 12px; font-size:0.78rem; cursor:pointer; color:#a5b4fc;">System health</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="padding:12px; border-top:1px solid var(--border,#333);">
|
||||
<div style="display:flex; gap:8px;">
|
||||
<input id="ai-chat-input" type="text" placeholder="Ask me anything..."
|
||||
onkeypress="if(event.key==='Enter') window.aiChat.send()"
|
||||
style="flex:1; background:var(--bg-input,#0f0f1a); border:1px solid var(--border,#333); border-radius:8px; padding:10px 14px; color:var(--text,#fff); font-size:0.88rem; outline:none;">
|
||||
<button onclick="window.aiChat.send()" style="background:#6366f1; border:none; border-radius:8px; padding:0 16px; color:#fff; cursor:pointer; font-weight:600;">→</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(panel);
|
||||
}
|
||||
|
||||
function toggle() {
|
||||
let panel = document.getElementById('ai-chat-panel');
|
||||
if (!panel) { createChatPanel(); panel = document.getElementById('ai-chat-panel'); }
|
||||
panel.style.display = panel.style.display === 'flex' ? 'none' : 'flex';
|
||||
if (panel.style.display === 'flex') document.getElementById('ai-chat-input').focus();
|
||||
}
|
||||
|
||||
function addMessage(role, text, extra) {
|
||||
const container = document.getElementById('ai-chat-messages');
|
||||
if (!container) return;
|
||||
const div = document.createElement('div');
|
||||
const isUser = role === 'user';
|
||||
div.style.cssText = isUser
|
||||
? 'align-self:flex-end; background:rgba(99,102,241,0.15); border-radius:10px 10px 2px 10px; padding:10px 14px; max-width:85%; font-size:0.85rem;'
|
||||
: 'align-self:flex-start; background:rgba(255,255,255,0.05); border-radius:10px 10px 10px 2px; padding:10px 14px; max-width:85%; font-size:0.85rem;';
|
||||
div.textContent = text;
|
||||
container.appendChild(div);
|
||||
|
||||
// Extra content (recommendations, action buttons)
|
||||
if (extra) {
|
||||
const extraDiv = document.createElement('div');
|
||||
extraDiv.style.cssText = 'align-self:flex-start; max-width:85%; margin-top:4px;';
|
||||
if (extra.recommendations) {
|
||||
extra.recommendations.forEach(rec => {
|
||||
const btn = document.createElement('button');
|
||||
btn.textContent = `📦 Deploy ${rec.app}`;
|
||||
btn.style.cssText = 'display:block; margin:4px 0; background:rgba(34,197,94,0.12); border:1px solid rgba(34,197,94,0.3); border-radius:8px; padding:8px 12px; cursor:pointer; font-size:0.8rem; color:#4ade80;';
|
||||
btn.onclick = () => window.aiChat.send('Deploy ' + rec.app);
|
||||
extraDiv.appendChild(btn);
|
||||
});
|
||||
}
|
||||
if (extra.appId) {
|
||||
const btn = document.createElement('button');
|
||||
btn.textContent = `🚀 Deploy ${extra.appId}`;
|
||||
btn.style.cssText = 'display:block; margin:4px 0; background:rgba(34,197,94,0.12); border:1px solid rgba(34,197,94,0.3); border-radius:8px; padding:8px 12px; cursor:pointer; font-size:0.8rem; color:#4ade80;';
|
||||
btn.onclick = () => window.aiChat.deploy(extra.appId);
|
||||
extraDiv.appendChild(btn);
|
||||
}
|
||||
if (extra.suggestions) {
|
||||
const list = document.createElement('div');
|
||||
list.style.cssText = 'font-size:0.78rem; color:var(--text-muted,#888); padding:4px 0;';
|
||||
list.innerHTML = extra.suggestions.map(s => '• ' + s).join('<br>');
|
||||
extraDiv.appendChild(list);
|
||||
}
|
||||
if (extra.action === 'diagnose') {
|
||||
const btn = document.createElement('button');
|
||||
btn.textContent = '🔍 Run Diagnostics';
|
||||
btn.style.cssText = 'display:block; margin:4px 0; background:rgba(251,191,36,0.12); border:1px solid rgba(251,191,36,0.3); border-radius:8px; padding:8px 12px; cursor:pointer; font-size:0.8rem; color:#fbbf24;';
|
||||
btn.onclick = () => window.location.href = '#health';
|
||||
extraDiv.appendChild(btn);
|
||||
}
|
||||
if (extra.action === 'backup') {
|
||||
const btn = document.createElement('button');
|
||||
btn.textContent = '💾 Create Backup';
|
||||
btn.style.cssText = 'display:block; margin:4px 0; background:rgba(99,102,241,0.12); border:1px solid rgba(99,102,241,0.3); border-radius:8px; padding:8px 12px; cursor:pointer; font-size:0.8rem; color:#a5b4fc;';
|
||||
btn.onclick = () => { if (window.createBackup) window.createBackup(); };
|
||||
extraDiv.appendChild(btn);
|
||||
}
|
||||
container.appendChild(extraDiv);
|
||||
}
|
||||
container.scrollTop = container.scrollHeight;
|
||||
}
|
||||
|
||||
async function send(prefilled) {
|
||||
const input = document.getElementById('ai-chat-input');
|
||||
const message = prefilled || input.value.trim();
|
||||
if (!message) return;
|
||||
input.value = '';
|
||||
|
||||
addMessage('user', message);
|
||||
|
||||
// Typing indicator
|
||||
const container = document.getElementById('ai-chat-messages');
|
||||
const typing = document.createElement('div');
|
||||
typing.id = 'ai-typing';
|
||||
typing.style.cssText = 'align-self:flex-start; color:var(--text-muted,#666); font-size:0.8rem; padding:8px 14px;';
|
||||
typing.textContent = '🤖 Thinking...';
|
||||
container.appendChild(typing);
|
||||
container.scrollTop = container.scrollHeight;
|
||||
|
||||
try {
|
||||
const res = await secureFetch('/api/v1/ai/intent', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ message, context: {} })
|
||||
});
|
||||
const data = await res.json();
|
||||
typing.remove();
|
||||
|
||||
if (data.success !== false && data.data) {
|
||||
const r = data.data;
|
||||
const response = r.response || {};
|
||||
addMessage('ai', response.message || r.response?.message || 'Action: ' + (r.action || r.intent), response);
|
||||
} else {
|
||||
addMessage('ai', data.error || 'Sorry, something went wrong.');
|
||||
}
|
||||
} catch (e) {
|
||||
typing.remove();
|
||||
addMessage('ai', 'Connection error: ' + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function deploy(appId) {
|
||||
addMessage('user', 'Deploy ' + appId);
|
||||
addMessage('ai', `Launching ${appId} from the catalog...`);
|
||||
// Trigger the app selector with this app
|
||||
if (window.openAppSelector) {
|
||||
window.openAppSelector(appId);
|
||||
} else {
|
||||
addMessage('ai', `Search for ${appId} in the App Catalog to deploy it.`);
|
||||
}
|
||||
}
|
||||
|
||||
window.aiChat = { send, deploy, toggle };
|
||||
window.toggleAIChat = toggle;
|
||||
})();
|
||||
@@ -197,6 +197,7 @@ async function secureFetch(url, options = {}) {
|
||||
options = { ...options, signal: AbortSignal.timeout(15000) };
|
||||
}
|
||||
|
||||
options.credentials = options.credentials || 'same-origin';
|
||||
const response = await fetch(url, options);
|
||||
|
||||
// Auto-retry once on CSRF failure (token may have been rotated by TOTP re-auth)
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
const CONFIG_ENDPOINT = '/api/v1/config';
|
||||
|
||||
const LANGUAGES = [
|
||||
{ code: 'en', flag: '🇬🇧', label: 'English' },
|
||||
{ code: 'en', flag: '🇺🇸', label: 'English' },
|
||||
{ code: 'es', flag: '🇪🇸', label: 'Español' },
|
||||
{ code: 'zh', flag: '🇨🇳', label: '中文' },
|
||||
{ code: 'ar', flag: '🇸🇦', label: 'العربية' },
|
||||
|
||||
Reference in New Issue
Block a user