Merge remote changes and add SEO fields to Tasks model, improve content generation response handling, and enhance progress bar animation

This commit is contained in:
Gitea Deploy
2025-11-09 21:25:11 +00:00
17 changed files with 199 additions and 1173 deletions

View File

@@ -128,70 +128,60 @@ export default function ResourceDebugOverlay({ enabled }: ResourceDebugOverlayPr
headers['Authorization'] = `Bearer ${token}`;
}
const response = await nativeFetch.call(window, `${API_BASE_URL}/v1/system/request-metrics/${requestId}/`, {
method: 'GET',
headers,
credentials: 'include', // Include session cookies for authentication
});
if (response.ok) {
const data = await response.json();
// Only log in debug mode to reduce console noise
if (import.meta.env.DEV) {
console.debug('Fetched metrics for request:', requestId, data);
}
metricsRef.current = [...metricsRef.current, data];
setMetrics([...metricsRef.current]);
} else if (response.status === 401) {
// Token might be expired - try to refresh and retry once
try {
await useAuthStore.getState().refreshToken();
const newToken = useAuthStore.getState().token;
if (newToken) {
const retryHeaders: HeadersInit = {
'Content-Type': 'application/json',
'Authorization': `Bearer ${newToken}`,
};
const retryResponse = await nativeFetch.call(window, `${API_BASE_URL}/v1/system/request-metrics/${requestId}/`, {
method: 'GET',
headers: retryHeaders,
credentials: 'include',
});
if (retryResponse.ok) {
const data = await retryResponse.json();
metricsRef.current = [...metricsRef.current, data];
setMetrics([...metricsRef.current]);
return;
}
// Silently handle 404s and other errors - metrics might not exist for all requests
try {
const response = await nativeFetch.call(window, `${API_BASE_URL}/v1/system/request-metrics/${requestId}/`, {
method: 'GET',
headers,
credentials: 'include', // Include session cookies for authentication
});
if (response.ok) {
const data = await response.json();
// Only log in debug mode to reduce console noise
if (import.meta.env.DEV) {
console.debug('Fetched metrics for request:', requestId, data);
}
} catch (refreshError) {
// Refresh failed - user needs to re-login
console.warn('Token refresh failed, user may need to re-authenticate');
}
// Silently ignore 401 errors - user might not be authenticated
} else if (response.status === 404) {
// Metrics not found - could be race condition, retry once after short delay
if (retryCount === 0) {
// First attempt failed, retry once after 200ms (middleware might still be storing)
setTimeout(() => fetchRequestMetrics(requestId, 1), 200);
metricsRef.current = [...metricsRef.current, data];
setMetrics([...metricsRef.current]);
} else if (response.status === 401) {
// Token might be expired - try to refresh and retry once
try {
await useAuthStore.getState().refreshToken();
const newToken = useAuthStore.getState().token;
if (newToken) {
const retryHeaders: HeadersInit = {
'Content-Type': 'application/json',
'Authorization': `Bearer ${newToken}`,
};
const retryResponse = await nativeFetch.call(window, `${API_BASE_URL}/v1/system/request-metrics/${requestId}/`, {
method: 'GET',
headers: retryHeaders,
credentials: 'include',
});
if (retryResponse.ok) {
const data = await retryResponse.json();
metricsRef.current = [...metricsRef.current, data];
setMetrics([...metricsRef.current]);
return;
}
}
} catch (refreshError) {
// Refresh failed - silently ignore
}
// Silently ignore 401 errors - user might not be authenticated
} else if (response.status === 404) {
// Metrics not found - silently ignore (metrics might not exist for all requests)
return;
} else {
// Other errors - silently ignore
return;
}
// Second attempt also failed - metrics truly not available
// This is expected: metrics expired (5min TTL), request wasn't tracked, or middleware error
// Silently ignore - no need to log or show error
return;
} else {
// Only log non-404/401 errors (500, 403, etc.)
console.warn('Failed to fetch metrics:', response.status, response.statusText, 'for request:', requestId);
}
} catch (error) {
// Only log non-network errors
if (error instanceof TypeError && error.message.includes('fetch')) {
// Network error - silently ignore
} catch (error) {
// Silently ignore all fetch errors (network errors, etc.)
// Metrics are optional and not critical for functionality
return;
}
console.error('Failed to fetch request metrics:', error);
}
};
// Calculate page load time

View File

@@ -430,11 +430,14 @@ export function useProgressModal(): UseProgressModalReturn {
}
} else if (response.state === 'FAILURE') {
const meta = response.meta || {};
const errorMsg = meta.error || 'Task failed';
// Try multiple error message sources
const errorMsg = meta.error || meta.message || response.error || 'Task failed - exception details unavailable';
const errorType = meta.error_type || 'Error';
setProgress({
percentage: 0,
message: `Error: ${errorMsg}`,
message: errorMsg.includes('exception details unavailable') ? errorMsg : `Error: ${errorMsg}`,
status: 'error',
details: meta.error_type ? `${errorType}: ${errorMsg}` : errorMsg,
});
// Update step logs from failure response

View File

@@ -54,21 +54,6 @@ export default function Clusters() {
const [totalPages, setTotalPages] = useState(1);
const [totalCount, setTotalCount] = useState(0);
// AI Function logging state
const [aiLogs, setAiLogs] = useState<Array<{
timestamp: string;
type: 'request' | 'success' | 'error' | 'step';
action: string;
data: any;
stepName?: string;
percentage?: number;
}>>([]);
// Track last logged step to avoid duplicates
const lastLoggedStepRef = useRef<string | null>(null);
const lastLoggedPercentageRef = useRef<number>(-1);
const hasReloadedRef = useRef<boolean>(false);
// Sorting state
const [sortBy, setSortBy] = useState<string>('name');
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc');
@@ -86,6 +71,7 @@ export default function Clusters() {
// Progress modal for AI functions
const progressModal = useProgressModal();
const hasReloadedRef = useRef(false);
// Load clusters - wrapped in useCallback to prevent infinite loops
const loadClusters = useCallback(async () => {
@@ -216,62 +202,20 @@ export default function Clusters() {
// Row action handler
const handleRowAction = useCallback(async (action: string, row: Cluster) => {
if (action === 'generate_ideas') {
const requestData = {
ids: [row.id],
cluster_name: row.name,
cluster_id: row.id,
};
// Log request
setAiLogs(prev => [...prev, {
timestamp: new Date().toISOString(),
type: 'request',
action: 'generate_ideas (Row Action)',
data: requestData,
}]);
try {
const result = await autoGenerateIdeas([row.id]);
if (result.success && result.task_id) {
// Log success with task_id
setAiLogs(prev => [...prev, {
timestamp: new Date().toISOString(),
type: 'success',
action: 'generate_ideas (Row Action)',
data: { task_id: result.task_id, message: result.message },
}]);
// Async task - show progress modal
progressModal.openModal(result.task_id, 'Generating Ideas');
} else if (result.success && result.ideas_created) {
// Log success with ideas_created
setAiLogs(prev => [...prev, {
timestamp: new Date().toISOString(),
type: 'success',
action: 'generate_ideas (Row Action)',
data: { ideas_created: result.ideas_created, message: result.message },
}]);
// Synchronous completion
toast.success(result.message || 'Ideas generated successfully');
await loadClusters();
} else {
// Log error
setAiLogs(prev => [...prev, {
timestamp: new Date().toISOString(),
type: 'error',
action: 'generate_ideas (Row Action)',
data: { error: result.error || 'Failed to generate ideas' },
}]);
toast.error(result.error || 'Failed to generate ideas');
}
} catch (error: any) {
// Log error
setAiLogs(prev => [...prev, {
timestamp: new Date().toISOString(),
type: 'error',
action: 'generate_ideas (Row Action)',
data: { error: error.message || 'Unknown error occurred' },
}]);
toast.error(`Failed to generate ideas: ${error.message}`);
}
}
@@ -284,192 +228,58 @@ export default function Clusters() {
toast.error('Please select at least one cluster to generate ideas');
return;
}
if (ids.length > 10) {
toast.error('Maximum 10 clusters allowed for idea generation');
if (ids.length > 5) {
toast.error('Maximum 5 clusters allowed for idea generation');
return;
}
const numIds = ids.map(id => parseInt(id));
const selectedClusters = clusters.filter(c => numIds.includes(c.id));
const requestData = {
ids: numIds,
cluster_count: numIds.length,
cluster_names: selectedClusters.map(c => c.name),
};
// Log request
setAiLogs(prev => [...prev, {
timestamp: new Date().toISOString(),
type: 'request',
action: 'auto_generate_ideas (Bulk Action)',
data: requestData,
}]);
try {
const numIds = ids.map(id => parseInt(id));
const result = await autoGenerateIdeas(numIds);
if (result.success) {
// Check if result has success field - if false, it's an error response
if (result && result.success === false) {
// Error response from API
const errorMsg = result.error || 'Failed to generate ideas';
toast.error(errorMsg);
return;
}
if (result && result.success) {
if (result.task_id) {
// Log success with task_id
setAiLogs(prev => [...prev, {
timestamp: new Date().toISOString(),
type: 'success',
action: 'auto_generate_ideas (Bulk Action)',
data: { task_id: result.task_id, message: result.message, cluster_count: numIds.length },
}]);
// Async task - show progress modal
// Async task - open progress modal
hasReloadedRef.current = false;
progressModal.openModal(result.task_id, 'Generating Content Ideas');
// Don't show toast - progress modal will show status
} else {
// Log success with ideas_created
setAiLogs(prev => [...prev, {
timestamp: new Date().toISOString(),
type: 'success',
action: 'auto_generate_ideas (Bulk Action)',
data: { ideas_created: result.ideas_created || 0, message: result.message, cluster_count: numIds.length },
}]);
// Synchronous completion
toast.success(`Ideas generation complete: ${result.ideas_created || 0} ideas created`);
await loadClusters();
if (!hasReloadedRef.current) {
hasReloadedRef.current = true;
loadClusters();
}
}
} else {
// Log error
setAiLogs(prev => [...prev, {
timestamp: new Date().toISOString(),
type: 'error',
action: 'auto_generate_ideas (Bulk Action)',
data: { error: result.error || 'Failed to generate ideas', cluster_count: numIds.length },
}]);
toast.error(result.error || 'Failed to generate ideas');
// Unexpected response format - show error
const errorMsg = result?.error || 'Unexpected response format';
toast.error(errorMsg);
}
} catch (error: any) {
// Log error
setAiLogs(prev => [...prev, {
timestamp: new Date().toISOString(),
type: 'error',
action: 'auto_generate_ideas (Bulk Action)',
data: { error: error.message || 'Unknown error occurred', cluster_count: numIds.length },
}]);
toast.error(`Failed to generate ideas: ${error.message}`);
// API error (network error, parse error, etc.)
let errorMsg = 'Failed to generate ideas';
if (error.message) {
// Extract clean error message from API error format
errorMsg = error.message.replace(/^API Error \(\d+\): [^-]+ - /, '').trim();
if (!errorMsg || errorMsg === error.message) {
errorMsg = error.message;
}
}
toast.error(errorMsg);
}
} else {
toast.info(`Bulk action "${action}" for ${ids.length} items`);
}
}, [toast, loadClusters, progressModal, clusters]);
// Log AI function progress steps
useEffect(() => {
if (!progressModal.taskId || !progressModal.isOpen) {
return;
}
const progress = progressModal.progress;
const currentStep = progress.details?.phase || '';
const currentPercentage = progress.percentage;
const currentMessage = progress.message;
const currentStatus = progress.status;
// Log step changes
if (currentStep && currentStep !== lastLoggedStepRef.current) {
const stepType = currentStatus === 'error' ? 'error' :
currentStatus === 'completed' ? 'success' : 'step';
setAiLogs(prev => [...prev, {
timestamp: new Date().toISOString(),
type: stepType,
action: progressModal.title || 'AI Function',
stepName: currentStep,
percentage: currentPercentage,
data: {
step: currentStep,
message: currentMessage,
percentage: currentPercentage,
status: currentStatus,
details: progress.details,
},
}]);
lastLoggedStepRef.current = currentStep;
lastLoggedPercentageRef.current = currentPercentage;
}
// Log percentage changes for same step (if significant change)
else if (currentStep && Math.abs(currentPercentage - lastLoggedPercentageRef.current) >= 10) {
const stepType = currentStatus === 'error' ? 'error' :
currentStatus === 'completed' ? 'success' : 'step';
setAiLogs(prev => [...prev, {
timestamp: new Date().toISOString(),
type: stepType,
action: progressModal.title || 'AI Function',
stepName: currentStep,
percentage: currentPercentage,
data: {
step: currentStep,
message: currentMessage,
percentage: currentPercentage,
status: currentStatus,
details: progress.details,
},
}]);
lastLoggedPercentageRef.current = currentPercentage;
}
// Log status changes (error, completed)
else if (currentStatus === 'error' || currentStatus === 'completed') {
// Only log if we haven't already logged this status for this step
if (currentStep !== lastLoggedStepRef.current ||
(currentStatus === 'error' && lastLoggedStepRef.current !== 'error') ||
(currentStatus === 'completed' && lastLoggedStepRef.current !== 'completed')) {
const stepType = currentStatus === 'error' ? 'error' : 'success';
setAiLogs(prev => [...prev, {
timestamp: new Date().toISOString(),
type: stepType,
action: progressModal.title || 'AI Function',
stepName: currentStep || 'Final',
percentage: currentPercentage,
data: {
step: currentStep || 'Final',
message: currentMessage,
percentage: currentPercentage,
status: currentStatus,
details: progress.details,
},
}]);
lastLoggedStepRef.current = currentStep || currentStatus;
}
}
}, [progressModal.progress, progressModal.taskId, progressModal.isOpen, progressModal.title]);
// Reset step tracking when modal closes or opens
useEffect(() => {
if (!progressModal.isOpen) {
lastLoggedStepRef.current = null;
lastLoggedPercentageRef.current = -1;
hasReloadedRef.current = false; // Reset reload flag when modal closes
} else {
// Reset reload flag when modal opens for a new task
hasReloadedRef.current = false;
}
}, [progressModal.isOpen, progressModal.taskId]);
// Handle modal close - memoized to prevent repeated calls
const handleProgressModalClose = useCallback(() => {
const wasCompleted = progressModal.progress.status === 'completed';
progressModal.closeModal();
// Reload data after modal closes (if completed) - only once
if (wasCompleted && !hasReloadedRef.current) {
hasReloadedRef.current = true;
// Use setTimeout to ensure modal is fully closed before reloading
setTimeout(() => {
loadClusters();
// Reset the flag after a delay to allow for future reloads
setTimeout(() => {
hasReloadedRef.current = false;
}, 1000);
}, 100);
}
}, [progressModal.progress.status, progressModal.closeModal, loadClusters]);
}, [toast, loadClusters, progressModal]);
// Close volume dropdown when clicking outside
useEffect(() => {
@@ -671,77 +481,16 @@ export default function Clusters() {
message={progressModal.progress.message}
details={progressModal.progress.details}
taskId={progressModal.taskId || undefined}
onClose={handleProgressModalClose}
onClose={() => {
progressModal.closeModal();
// Reload once when modal closes if task was completed
if (progressModal.progress.status === 'completed' && !hasReloadedRef.current) {
hasReloadedRef.current = true;
loadClusters();
}
}}
/>
{/* AI Function Logs - Display below table */}
{aiLogs.length > 0 && (
<div className="mt-6 bg-gray-50 dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-semibold text-gray-900 dark:text-gray-100">
AI Function Logs
</h3>
<button
onClick={() => setAiLogs([])}
className="text-xs text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
>
Clear Logs
</button>
</div>
<div className="space-y-2 max-h-96 overflow-y-auto">
{aiLogs.slice().reverse().map((log, index) => (
<div
key={index}
className={`p-3 rounded border text-xs font-mono ${
log.type === 'request'
? 'bg-blue-50 dark:bg-blue-900/20 border-blue-200 dark:border-blue-800'
: log.type === 'success'
? 'bg-green-50 dark:bg-green-900/20 border-green-200 dark:border-green-800'
: log.type === 'error'
? 'bg-red-50 dark:bg-red-900/20 border-red-200 dark:border-red-800'
: 'bg-purple-50 dark:bg-purple-900/20 border-purple-200 dark:border-purple-800'
}`}
>
<div className="flex items-center justify-between mb-1">
<div className="flex items-center gap-2 flex-wrap">
<span className={`font-semibold ${
log.type === 'request'
? 'text-blue-700 dark:text-blue-300'
: log.type === 'success'
? 'text-green-700 dark:text-green-300'
: log.type === 'error'
? 'text-red-700 dark:text-red-300'
: 'text-purple-700 dark:text-purple-300'
}`}>
[{log.type.toUpperCase()}]
</span>
<span className="text-gray-700 dark:text-gray-300">
{log.action}
</span>
{log.stepName && (
<span className="text-xs px-2 py-0.5 rounded bg-gray-200 dark:bg-gray-700 text-gray-600 dark:text-gray-400">
{log.stepName}
</span>
)}
{log.percentage !== undefined && (
<span className="text-xs text-gray-500 dark:text-gray-400">
{log.percentage}%
</span>
)}
</div>
<span className="text-gray-500 dark:text-gray-400">
{new Date(log.timestamp).toLocaleTimeString()}
</span>
</div>
<pre className="text-xs text-gray-700 dark:text-gray-300 whitespace-pre-wrap break-words">
{JSON.stringify(log.data, null, 2)}
</pre>
</div>
))}
</div>
</div>
)}
{/* Create/Edit Modal */}
<FormModal
isOpen={isModalOpen}