diff --git a/libs/remix-ui/remix-ai-assistant/src/components/prompt.tsx b/libs/remix-ui/remix-ai-assistant/src/components/prompt.tsx index 797231940bf..531aa2caa1f 100644 --- a/libs/remix-ui/remix-ai-assistant/src/components/prompt.tsx +++ b/libs/remix-ui/remix-ai-assistant/src/components/prompt.tsx @@ -42,8 +42,8 @@ export interface PromptAreaProps { aiAssistantGroupList: groupListType[] textareaRef?: React.RefObject maximizePanel: () => Promise - aiMode: 'ask' | 'edit' - setAiMode: React.Dispatch> + aiMode: 'ask' | 'edit' | 'learn' + setAiMode: React.Dispatch> isMaximized: boolean setIsMaximized: React.Dispatch> } @@ -126,7 +126,7 @@ export const PromptArea: React.FC = ({
- {/* Ask/Edit Mode Toggle */} + {/* Ask/Edit/Learn Mode Toggle */}
+
([]) const [selectedModel, setSelectedModel] = useState(null) const [isOllamaFailureFallback, setIsOllamaFailureFallback] = useState(false) - const [aiMode, setAiMode] = useState<'ask' | 'edit'>('ask') + const [aiMode, setAiMode] = useState<'ask' | 'edit' | 'learn'>('ask') const [themeTracker, setThemeTracker] = useState(null) const [isMaximized, setIsMaximized] = useState(false) const historyRef = useRef(null) @@ -806,25 +807,39 @@ export const RemixUiRemixAiAssistant = React.forwardRef< className="d-flex flex-column h-100 w-100 overflow-x-hidden" ref={aiChatRef} > -
-
- {/* hidden hook for E2E tests: data-streaming="true|false" */} -
- { + // Switch to ask mode and send the question with tutorial context + setAiMode('ask') + const contextualQuestion = `[Tutorial Context: ${context.roadmapTitle} - Step ${context.stepIndex + 1}: ${context.stepTitle}]\n\n${question}` + sendPrompt(contextualQuestion) + }} + onExit={() => setAiMode('ask')} /> -
-
+ ) : ( + <> +
+
+ {/* hidden hook for E2E tests: data-streaming="true|false" */} +
+ +
+
{showAssistantOptions && (
+ + )}
) }) diff --git a/libs/remix-ui/remix-ai-assistant/src/components/tutorial/PointingHand.tsx b/libs/remix-ui/remix-ai-assistant/src/components/tutorial/PointingHand.tsx new file mode 100644 index 00000000000..d895cf83f58 --- /dev/null +++ b/libs/remix-ui/remix-ai-assistant/src/components/tutorial/PointingHand.tsx @@ -0,0 +1,202 @@ +import React, { useEffect, useState, useRef } from 'react' +import { PointingHandPosition } from '../../lib/tutorials/types' +import './tutorial-styles.css' + +export interface PointingHandProps { + targetElement?: string | HTMLElement | null + position?: PointingHandPosition + visible: boolean + animationSpeed?: 'slow' | 'normal' | 'fast' + onAnimationComplete?: () => void +} + +/** + * PointingHand Component + * Displays an animated hand icon that points to tutorial target elements + */ +export const PointingHand: React.FC = ({ + targetElement, + position, + visible, + animationSpeed = 'normal', + onAnimationComplete +}) => { + const [handPosition, setHandPosition] = useState({ + top: 0, + left: 0, + rotation: 0, + visible: false + }) + const handRef = useRef(null) + const animationTimeoutRef = useRef(null) + const highlightedElementRef = useRef(null) + + // Calculate position based on target element + useEffect(() => { + // Remove previous highlight + if (highlightedElementRef.current) { + highlightedElementRef.current.classList.remove('tutorial-spotlight') + highlightedElementRef.current = null + } + + if (!visible) { + setHandPosition(prev => ({ ...prev, visible: false })) + return + } + + // If explicit position is provided, use it + if (position) { + setHandPosition({ ...position, visible: true }) + return + } + + // Otherwise, calculate position from target element + if (!targetElement) { + setHandPosition(prev => ({ ...prev, visible: false })) + return + } + + const element = typeof targetElement === 'string' + ? document.querySelector(targetElement) as HTMLElement + : targetElement + + if (!element) { + console.warn(`[PointingHand] Target element not found: ${targetElement}`) + setHandPosition(prev => ({ ...prev, visible: false })) + return + } + + // Calculate position relative to the element + const calculatePosition = () => { + const rect = element.getBoundingClientRect() + + // Center the hand on the target element + const handSize = 48 // emoji size in pixels + const top = rect.top + (rect.height / 2) - (handSize / 2) - 30 + const left = rect.left + (rect.width / 2) - (handSize / 2) + 20 + const rotation = 0 + + setHandPosition({ + top, + left, + rotation, + visible: true + }) + + // Add highlight to target element + element.classList.add('tutorial-spotlight') + highlightedElementRef.current = element + } + + calculatePosition() + + // Recalculate on scroll or resize + const handleUpdate = () => calculatePosition() + window.addEventListener('scroll', handleUpdate, true) + window.addEventListener('resize', handleUpdate) + + return () => { + window.removeEventListener('scroll', handleUpdate, true) + window.removeEventListener('resize', handleUpdate) + + // Remove highlight on cleanup + if (highlightedElementRef.current) { + highlightedElementRef.current.classList.remove('tutorial-spotlight') + highlightedElementRef.current = null + } + } + }, [targetElement, position, visible]) + + // Handle animation complete callback + useEffect(() => { + if (!handPosition.visible || !onAnimationComplete) return + + const duration = animationSpeed === 'slow' ? 3000 : animationSpeed === 'fast' ? 1000 : 2000 + + animationTimeoutRef.current = setTimeout(() => { + onAnimationComplete() + }, duration) + + return () => { + if (animationTimeoutRef.current) { + clearTimeout(animationTimeoutRef.current) + } + } + }, [handPosition.visible, animationSpeed, onAnimationComplete]) + + if (!handPosition.visible) return null + + const animationClass = `tutorial-hand-animation-${animationSpeed}` + + return ( +
+ {/* Pointing hand emoji with enhanced visibility */} +
+ 👉 +
+
+
+ ) +} + +/** + * Hook to get element position + */ +export function useElementPosition(elementSelector: string | null): PointingHandPosition | null { + const [position, setPosition] = useState(null) + + useEffect(() => { + if (!elementSelector) { + setPosition(null) + return + } + + const element = document.querySelector(elementSelector) as HTMLElement + if (!element) { + setPosition(null) + return + } + + const updatePosition = () => { + const rect = element.getBoundingClientRect() + const scrollTop = window.pageYOffset || document.documentElement.scrollTop + const scrollLeft = window.pageXOffset || document.documentElement.scrollLeft + + setPosition({ + top: rect.top + scrollTop - 40, + left: rect.left + scrollLeft - 60, + rotation: -45, + visible: true + }) + } + + updatePosition() + + window.addEventListener('scroll', updatePosition, true) + window.addEventListener('resize', updatePosition) + + return () => { + window.removeEventListener('scroll', updatePosition, true) + window.removeEventListener('resize', updatePosition) + } + }, [elementSelector]) + + return position +} diff --git a/libs/remix-ui/remix-ai-assistant/src/components/tutorial/TutorialMode.tsx b/libs/remix-ui/remix-ai-assistant/src/components/tutorial/TutorialMode.tsx new file mode 100644 index 00000000000..48c976eaf48 --- /dev/null +++ b/libs/remix-ui/remix-ai-assistant/src/components/tutorial/TutorialMode.tsx @@ -0,0 +1,338 @@ +import React, { useEffect, useRef, useState, useCallback } from 'react' +import { Plugin } from '@remixproject/engine' +import { useTutorialState } from '../../lib/tutorials/useTutorialState' +import { TutorialEventDetector } from '../../lib/tutorials/TutorialEventDetector' +import { TutorialRoadmap, TutorialEventCallbacks } from '../../lib/tutorials/types' +import { tutorialRoadmaps, getRecommendedTutorial } from '../../lib/tutorials/roadmaps' +import { PointingHand } from './PointingHand' +import { TutorialStepCard } from './TutorialStepCard' +import { TutorialRoadmapView } from './TutorialRoadmapView' +import './tutorial-styles.css' + +export interface TutorialModeProps { + plugin: Plugin + onAskAI?: (question: string, context: any) => void + onExit?: () => void +} + +/** + * TutorialMode Component + * Main component that manages the tutorial experience + */ +export const TutorialMode: React.FC = ({ + plugin, + onAskAI, + onExit +}) => { + const { + state, + preferences, + startTutorial, + resumeTutorial, + completeStep, + goToStep, + skipStep, + exitTutorial, + isTutorialCompleted + } = useTutorialState() + + const [showRoadmapSelector, setShowRoadmapSelector] = useState(true) + const eventDetectorRef = useRef(null) + const completionInProgress = useRef(false) + + /** + * Handle automatic step completion from event detector + * Protected against multiple rapid calls + */ + const handleAutoStepComplete = useCallback((stepId: string) => { + // Prevent multiple simultaneous completions + if (completionInProgress.current) { + console.log('[TutorialMode] Completion already in progress, ignoring:', stepId) + return + } + + completionInProgress.current = true + console.log('[TutorialMode] Auto-completing step:', stepId) + + // Add a small delay for visual feedback + setTimeout(() => { + const isComplete = completeStep() + + if (isComplete) { + // Tutorial finished + setShowRoadmapSelector(true) + } else if (preferences.autoAdvance) { + // Auto-advance to next step + console.log('[TutorialMode] Auto-advancing to next step') + } + + // Reset completion flag after a brief delay + setTimeout(() => { + completionInProgress.current = false + }, 300) + }, 500) + }, [completeStep, preferences.autoAdvance]) + + // Initialize event detector + useEffect(() => { + if (!state.isActive || !state.currentRoadmap) { + return + } + + const callbacks: TutorialEventCallbacks = { + onStepComplete: handleAutoStepComplete, + onStepStart: (stepId) => { + console.log('[TutorialMode] Step started:', stepId) + }, + onTutorialComplete: (roadmapId) => { + console.log('[TutorialMode] Tutorial completed:', roadmapId) + }, + onTutorialExit: () => { + console.log('[TutorialMode] Tutorial exited') + } + } + + eventDetectorRef.current = new TutorialEventDetector(plugin, callbacks) + + return () => { + if (eventDetectorRef.current) { + eventDetectorRef.current.destroy() + } + } + }, [plugin, state.isActive, handleAutoStepComplete]) + + // Monitor current step and set up event detection + useEffect(() => { + if (!state.isActive || !state.currentRoadmap || !eventDetectorRef.current) { + return + } + + // Reset completion flag when moving to a new step + completionInProgress.current = false + + const currentStep = state.currentRoadmap.steps[state.currentStepIndex] + if (currentStep) { + eventDetectorRef.current.startMonitoring(currentStep) + } + }, [state.isActive, state.currentRoadmap, state.currentStepIndex]) + + /** + * Handle manual step completion (Next button) + */ + const handleManualNext = useCallback(() => { + const currentStep = state.currentRoadmap?.steps[state.currentStepIndex] + if (currentStep) { + // Simply trigger the step completion handler + // No need to call completeCurrentStep as handleAutoStepComplete will handle it + handleAutoStepComplete(currentStep.id) + } + }, [state.currentRoadmap, state.currentStepIndex, handleAutoStepComplete]) + + /** + * Handle tutorial selection + */ + const handleSelectTutorial = (roadmap: TutorialRoadmap) => { + if (state.progress?.roadmapId === roadmap.id && !isTutorialCompleted(roadmap.id)) { + resumeTutorial(roadmap) + } else { + startTutorial(roadmap) + } + setShowRoadmapSelector(false) + } + + /** + * Handle exit tutorial + */ + const handleExitTutorial = () => { + if (eventDetectorRef.current) { + eventDetectorRef.current.cleanup() + } + exitTutorial() + setShowRoadmapSelector(true) + onExit?.() + } + + /** + * Handle ask AI with tutorial context + */ + const handleAskAI = (question: string) => { + if (!state.currentRoadmap || !onAskAI) return + + const currentStep = state.currentRoadmap.steps[state.currentStepIndex] + const context = { + roadmapId: state.currentRoadmap.id, + roadmapTitle: state.currentRoadmap.title, + stepId: currentStep.id, + stepIndex: state.currentStepIndex, + totalSteps: state.currentRoadmap.steps.length, + stepTitle: currentStep.title, + stepDescription: currentStep.description, + aiPrompt: currentStep.aiPrompt, + userQuestion: question + } + + onAskAI(question, context) + } + + // Show roadmap selector when not in active tutorial + if (showRoadmapSelector || !state.isActive || !state.currentRoadmap) { + return ( +
+ +
+ ) + } + + const currentStep = state.currentRoadmap.steps[state.currentStepIndex] + + return ( +
+ {/* Pointing hand indicator */} + {currentStep.targetElement && ( + + )} + + {/* Step card */} + +
+ ) +} + +/** + * Tutorial Selector View + * Shows available tutorials and recommendations + */ +interface TutorialSelectorViewProps { + roadmaps: TutorialRoadmap[] + completedTutorials: string[] + onSelectTutorial: (roadmap: TutorialRoadmap) => void + onClose?: () => void +} + +const TutorialSelectorView: React.FC = ({ + roadmaps, + completedTutorials, + onSelectTutorial, + onClose +}) => { + return ( +
+
+

Choose a Tutorial

+ {onClose && ( + + )} +
+ + {/* All Tutorials */} +
+ {roadmaps.map(roadmap => ( + onSelectTutorial(roadmap)} + /> + ))} +
+
+ ) +} + +/** + * Tutorial Card Component + */ +interface TutorialCardProps { + roadmap: TutorialRoadmap + isCompleted: boolean + onSelect: () => void + highlighted?: boolean +} + +const TutorialCard: React.FC = ({ + roadmap, + isCompleted, + onSelect, + highlighted = false +}) => { + return ( +
+
+

+ {roadmap.title} + {isCompleted && ( + + )} +

+ + {roadmap.difficulty} + +
+ +

+ {roadmap.description} +

+ +
+ + + {roadmap.estimatedTime} + + + + {roadmap.steps.length} steps + +
+ + {roadmap.tags && roadmap.tags.length > 0 && ( +
+ {roadmap.tags.slice(0, 3).map(tag => ( + + {tag} + + ))} +
+ )} +
+ ) +} diff --git a/libs/remix-ui/remix-ai-assistant/src/components/tutorial/TutorialRoadmapView.tsx b/libs/remix-ui/remix-ai-assistant/src/components/tutorial/TutorialRoadmapView.tsx new file mode 100644 index 00000000000..67b83a0d70e --- /dev/null +++ b/libs/remix-ui/remix-ai-assistant/src/components/tutorial/TutorialRoadmapView.tsx @@ -0,0 +1,170 @@ +import React from 'react' +import { TutorialRoadmap, TutorialProgress } from '../../lib/tutorials/types' +import './tutorial-styles.css' + +export interface TutorialRoadmapViewProps { + roadmap: TutorialRoadmap + progress?: TutorialProgress + onStartStep?: (stepIndex: number) => void + onClose?: () => void +} + +/** + * TutorialRoadmapView Component + * Displays the tutorial roadmap with progress visualization + */ +export const TutorialRoadmapView: React.FC = ({ + roadmap, + progress, + onStartStep, + onClose +}) => { + const currentStepIndex = progress?.currentStepIndex ?? 0 + const completedSteps = progress?.completedSteps ?? [] + + const getStepIcon = (stepIndex: number, stepId: string): string => { + if (completedSteps.includes(stepId)) { + return '✓' + } + if (stepIndex === currentStepIndex) { + return '▶' + } + return (stepIndex + 1).toString() + } + + const getStepClass = (stepIndex: number, stepId: string): string => { + if (completedSteps.includes(stepId)) { + return 'completed' + } + if (stepIndex === currentStepIndex) { + return 'current' + } + return '' + } + + return ( +
+ {/* Header */} +
+
+

{roadmap.title}

+

+ {roadmap.description} +

+
+ {onClose && ( + + )} +
+ + {/* Roadmap Info */} +
+
+ + {roadmap.estimatedTime} +
+
+ + {roadmap.difficulty} +
+
+ + + {completedSteps.length} / {roadmap.steps.length} completed + +
+
+ + {/* Progress Bar */} +
+
+
+
+
+ + {/* Steps List */} +
+ {roadmap.steps.map((step, index) => { + const isCompleted = completedSteps.includes(step.id) + const isCurrent = index === currentStepIndex + const isClickable = index <= currentStepIndex || isCompleted + + return ( +
isClickable && onStartStep?.(index)} + style={{ + cursor: isClickable ? 'pointer' : 'default', + opacity: isClickable ? 1 : 0.6 + }} + > +
+ {getStepIcon(index, step.id)} +
+
+
+ {step.title} + {step.optional && ( + + Optional + + )} +
+
+ {step.description} +
+ {step.estimatedTime && ( +
+ + + {step.estimatedTime} + +
+ )} +
+
+ ) + })} +
+ + {/* Footer Actions */} +
+ {progress?.completed ? ( +
+
+ +
+

Tutorial Completed!

+

+ Great job! You've completed this tutorial. +

+
+ ) : ( + + )} +
+
+ ) +} diff --git a/libs/remix-ui/remix-ai-assistant/src/components/tutorial/TutorialStepCard.tsx b/libs/remix-ui/remix-ai-assistant/src/components/tutorial/TutorialStepCard.tsx new file mode 100644 index 00000000000..159224e87bc --- /dev/null +++ b/libs/remix-ui/remix-ai-assistant/src/components/tutorial/TutorialStepCard.tsx @@ -0,0 +1,219 @@ +import React, { useState } from 'react' +import { TutorialStep } from '../../lib/tutorials/types' +import './tutorial-styles.css' + +export interface TutorialStepCardProps { + step: TutorialStep + stepIndex: number + totalSteps: number + onNext?: () => void + onSkip?: () => void + onExit?: () => void + onAskAI?: (question: string) => void + showHints?: boolean + position?: 'top-right' | 'top-left' | 'bottom-right' | 'bottom-left' | 'center' +} + +/** + * TutorialStepCard Component + * Displays the current tutorial step with description, hints, and navigation + */ +export const TutorialStepCard: React.FC = ({ + step, + stepIndex, + totalSteps, + onNext, + onSkip, + onExit, + onAskAI, + showHints = false, + position = 'bottom-right' +}) => { + const [hintsExpanded, setHintsExpanded] = useState(showHints) + const [aiQuestion, setAiQuestion] = useState('') + const [showAIInput, setShowAIInput] = useState(false) + + const progress = ((stepIndex + 1) / totalSteps) * 100 + + const getPositionStyles = (): React.CSSProperties => { + const baseStyles: React.CSSProperties = { + position: 'fixed', + zIndex: 10001 + } + + switch (position) { + case 'top-right': + return { ...baseStyles, top: '20px', right: '20px' } + case 'top-left': + return { ...baseStyles, top: '20px', left: '20px' } + case 'bottom-right': + return { ...baseStyles, bottom: '20px', right: '20px' } + case 'bottom-left': + return { ...baseStyles, bottom: '20px', left: '20px' } + case 'center': + return { + ...baseStyles, + top: '50%', + left: '50%', + transform: 'translate(-50%, -50%)' + } + default: + return { ...baseStyles, bottom: '20px', right: '20px' } + } + } + + const handleAskAI = () => { + if (aiQuestion.trim() && onAskAI) { + onAskAI(aiQuestion) + setAiQuestion('') + setShowAIInput(false) + } + } + + return ( +
+ {/* Header */} +
+

{step.title}

+ + Step {stepIndex + 1} of {totalSteps} + +
+ + {/* Progress Bar */} +
+
+
+ + {/* Body */} +
+

{step.description}

+ + {step.targetDescription && ( +
+ + + Target: {step.targetDescription} + +
+ )} + + {step.estimatedTime && ( +
+ + + Estimated time: {step.estimatedTime} + +
+ )} + + {/* Hints Section */} + {step.hints && step.hints.length > 0 && ( +
+
setHintsExpanded(!hintsExpanded)} + style={{ cursor: 'pointer' }} + > + + Hints + +
+ {hintsExpanded && ( +
    + {step.hints.map((hint, index) => ( +
  • {hint}
  • + ))} +
+ )} +
+ )} + + {/* AI Help Section */} + {onAskAI && ( +
+ {!showAIInput ? ( + + ) : ( +
+ setAiQuestion(e.target.value)} + onKeyPress={(e) => e.key === 'Enter' && handleAskAI()} + autoFocus + /> +
+ + +
+
+ )} +
+ )} +
+ + {/* Footer */} +
+ + +
+ + {step.optional && onSkip && ( + + )} + + {onNext && ( + + )} +
+
+ ) +} diff --git a/libs/remix-ui/remix-ai-assistant/src/components/tutorial/tutorial-styles.css b/libs/remix-ui/remix-ai-assistant/src/components/tutorial/tutorial-styles.css new file mode 100644 index 00000000000..ca0164944cf --- /dev/null +++ b/libs/remix-ui/remix-ai-assistant/src/components/tutorial/tutorial-styles.css @@ -0,0 +1,400 @@ +/* Tutorial Overlay Styles */ + +/* Pointing Hand Component */ +.tutorial-pointing-hand { + position: absolute; + z-index: 10000; + pointer-events: none; + transition: all 0.5s cubic-bezier(0.4, 0, 0.2, 1); + display: flex; + align-items: center; + justify-content: center; +} + +/* Pulsing animation ring around hand */ +.tutorial-hand-pulse { + position: absolute; + top: 50%; + left: 50%; + width: 70px; + height: 70px; + margin: -35px 0 0 -35px; + border: 4px solid #2196F3; + border-radius: 50%; + opacity: 0; + animation: tutorial-pulse 2s ease-out infinite; +} + +@keyframes tutorial-pulse { + 0% { + transform: scale(0.5); + opacity: 1; + } + 50% { + opacity: 0.5; + } + 100% { + transform: scale(1.5); + opacity: 0; + } +} + +/* Hand pointing animation - slow */ +.tutorial-hand-animation-slow { + animation: tutorial-point-slow 3s ease-in-out infinite; +} + +@keyframes tutorial-point-slow { + 0%, 100% { + transform: translate(0, 0) rotate(var(--rotation, -45deg)); + } + 50% { + transform: translate(10px, 10px) rotate(var(--rotation, -45deg)); + } +} + +/* Hand pointing animation - normal */ +.tutorial-hand-animation-normal { + animation: tutorial-point-normal 2s ease-in-out infinite; +} + +@keyframes tutorial-point-normal { + 0%, 100% { + transform: translate(0, 0) rotate(var(--rotation, -45deg)); + } + 50% { + transform: translate(8px, 8px) rotate(var(--rotation, -45deg)); + } +} + +/* Hand pointing animation - fast */ +.tutorial-hand-animation-fast { + animation: tutorial-point-fast 1s ease-in-out infinite; +} + +@keyframes tutorial-point-fast { + 0%, 100% { + transform: translate(0, 0) rotate(var(--rotation, -45deg)); + } + 50% { + transform: translate(6px, 6px) rotate(var(--rotation, -45deg)); + } +} + +/* Spotlight effect for highlighted elements (no dark overlay) */ +.tutorial-spotlight { + position: relative; + z-index: 9999 !important; + box-shadow: 0 0 0 4px rgba(33, 150, 243, 0.5), + 0 0 20px 5px rgba(33, 150, 243, 0.3); + border-radius: 4px; + transition: all 0.3s ease-in-out; + animation: tutorial-highlight-pulse 2s ease-in-out infinite; +} + +@keyframes tutorial-highlight-pulse { + 0%, 100% { + box-shadow: 0 0 0 4px rgba(33, 150, 243, 0.5), + 0 0 20px 5px rgba(33, 150, 243, 0.3); + } + 50% { + box-shadow: 0 0 0 6px rgba(33, 150, 243, 0.7), + 0 0 25px 8px rgba(33, 150, 243, 0.5); + } +} + +.tutorial-spotlight-subtle { + box-shadow: 0 0 0 3px rgba(33, 150, 243, 0.3), + 0 0 15px 3px rgba(33, 150, 243, 0.2); +} + +.tutorial-spotlight-strong { + box-shadow: 0 0 0 5px rgba(33, 150, 243, 0.8), + 0 0 30px 10px rgba(33, 150, 243, 0.6); +} + +/* Tutorial Overlay Backdrop */ +.tutorial-overlay-backdrop { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.5); + z-index: 9998; + pointer-events: none; + transition: opacity 0.3s ease-in-out; +} + +.tutorial-overlay-backdrop.subtle { + background: rgba(0, 0, 0, 0.3); +} + +.tutorial-overlay-backdrop.strong { + background: rgba(0, 0, 0, 0.7); +} + +/* Tutorial Step Card */ +.tutorial-step-card { + position: fixed; + max-width: 400px; + background: var(--bg-color, #ffffff); + border: 1px solid var(--border-color, #e0e0e0); + border-radius: 12px; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15); + z-index: 10001; + animation: tutorial-card-slide-in 0.3s ease-out; +} + +@keyframes tutorial-card-slide-in { + from { + opacity: 0; + transform: translateY(-20px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.tutorial-step-card-header { + padding: 16px 20px; + border-bottom: 1px solid var(--border-color, #e0e0e0); + display: flex; + justify-content: space-between; + align-items: center; +} + +.tutorial-step-card-title { + font-size: 18px; + font-weight: 600; + color: var(--text-color, #333); + margin: 0; +} + +.tutorial-step-card-progress { + font-size: 12px; + color: var(--secondary-text-color, #666); + background: var(--bg-secondary, #f5f5f5); + padding: 4px 8px; + border-radius: 12px; +} + +.tutorial-step-card-body { + padding: 20px; +} + +.tutorial-step-description { + font-size: 14px; + line-height: 1.6; + color: var(--text-color, #333); + margin-bottom: 16px; +} + +.tutorial-step-hints { + margin-top: 16px; + padding: 12px; + background: var(--info-bg, #e3f2fd); + border-left: 3px solid var(--info-color, #2196f3); + border-radius: 4px; +} + +.tutorial-step-hints-title { + font-size: 13px; + font-weight: 600; + color: var(--info-color, #2196f3); + margin-bottom: 8px; + display: flex; + align-items: center; + gap: 6px; +} + +.tutorial-step-hints-list { + list-style: none; + padding: 0; + margin: 0; +} + +.tutorial-step-hints-list li { + font-size: 13px; + color: var(--text-color, #333); + padding: 4px 0; + padding-left: 18px; + position: relative; +} + +.tutorial-step-hints-list li:before { + content: "💡"; + position: absolute; + left: 0; +} + +.tutorial-step-card-footer { + padding: 16px 20px; + border-top: 1px solid var(--border-color, #e0e0e0); + display: flex; + justify-content: space-between; + align-items: center; + gap: 12px; +} + +.tutorial-btn { + padding: 8px 16px; + border-radius: 6px; + border: none; + font-size: 14px; + font-weight: 500; + cursor: pointer; + transition: all 0.2s ease; +} + +.tutorial-btn-primary { + background: var(--primary-color, #2196f3); + color: white; +} + +.tutorial-btn-primary:hover { + background: var(--primary-hover, #1976d2); +} + +.tutorial-btn-secondary { + background: transparent; + color: var(--primary-color, #2196f3); + border: 1px solid var(--primary-color, #2196f3); +} + +.tutorial-btn-secondary:hover { + background: var(--primary-light, rgba(33, 150, 243, 0.1)); +} + +.tutorial-btn-danger { + background: transparent; + color: var(--danger-color, #f44336); + border: none; +} + +.tutorial-btn-danger:hover { + background: var(--danger-light, rgba(244, 67, 54, 0.1)); +} + +/* Progress Bar */ +.tutorial-progress-bar { + width: 100%; + height: 6px; + background: var(--bg-secondary, #e0e0e0); + border-radius: 3px; + overflow: hidden; + margin: 12px 0; +} + +.tutorial-progress-fill { + height: 100%; + background: linear-gradient(90deg, #4CAF50, #8BC34A); + transition: width 0.4s ease; + border-radius: 3px; +} + +/* Roadmap View */ +.tutorial-roadmap-container { + padding: 20px; + max-height: 60vh; + overflow-y: auto; +} + +.tutorial-roadmap-step { + display: flex; + gap: 16px; + margin-bottom: 20px; + position: relative; +} + +.tutorial-roadmap-step::before { + content: ''; + position: absolute; + left: 15px; + top: 30px; + bottom: -20px; + width: 2px; + background: var(--border-color, #e0e0e0); +} + +.tutorial-roadmap-step:last-child::before { + display: none; +} + +.tutorial-roadmap-icon { + width: 32px; + height: 32px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + font-size: 16px; + background: var(--bg-secondary, #f5f5f5); + border: 2px solid var(--border-color, #e0e0e0); + position: relative; + z-index: 1; +} + +.tutorial-roadmap-icon.completed { + background: #4CAF50; + border-color: #4CAF50; + color: white; +} + +.tutorial-roadmap-icon.current { + background: var(--primary-color, #2196f3); + border-color: var(--primary-color, #2196f3); + color: white; + animation: tutorial-pulse-icon 2s ease-in-out infinite; +} + +@keyframes tutorial-pulse-icon { + 0%, 100% { + transform: scale(1); + } + 50% { + transform: scale(1.1); + } +} + +.tutorial-roadmap-content { + flex: 1; +} + +.tutorial-roadmap-step-title { + font-size: 15px; + font-weight: 600; + color: var(--text-color, #333); + margin-bottom: 4px; +} + +.tutorial-roadmap-step-description { + font-size: 13px; + color: var(--secondary-text-color, #666); + line-height: 1.5; +} + +/* Fade-in animation for tutorial mode */ +.tutorial-mode-enter { + opacity: 0; + transform: scale(0.95); +} + +.tutorial-mode-enter-active { + opacity: 1; + transform: scale(1); + transition: opacity 0.3s ease, transform 0.3s ease; +} + +.tutorial-mode-exit { + opacity: 1; + transform: scale(1); +} + +.tutorial-mode-exit-active { + opacity: 0; + transform: scale(0.95); + transition: opacity 0.2s ease, transform 0.2s ease; +} diff --git a/libs/remix-ui/remix-ai-assistant/src/lib/tutorials/TutorialEventDetector.ts b/libs/remix-ui/remix-ai-assistant/src/lib/tutorials/TutorialEventDetector.ts new file mode 100644 index 00000000000..4224840f8a0 --- /dev/null +++ b/libs/remix-ui/remix-ai-assistant/src/lib/tutorials/TutorialEventDetector.ts @@ -0,0 +1,286 @@ +import { Plugin } from '@remixproject/engine' +import { + TutorialStep, + EventTrigger, + DOMEventTrigger, + PluginEventTrigger, + TutorialEventCallbacks +} from './types' + +/** + * TutorialEventDetector + * Monitors DOM and plugin events to automatically detect step completion + */ +export class TutorialEventDetector { + private plugin: Plugin + private callbacks: TutorialEventCallbacks + private activeListeners: Map void> = new Map() + private pluginEventListeners: Map = new Map() + private stepCompleted: boolean = false + private currentStepId: string | null = null + + constructor(plugin: Plugin, callbacks: TutorialEventCallbacks) { + this.plugin = plugin + this.callbacks = callbacks + } + + /** + * Start monitoring events for a specific step + */ + public startMonitoring(step: TutorialStep): void { + this.cleanup() // Clean up previous listeners + this.stepCompleted = false + this.currentStepId = step.id + + console.log(`[TutorialEventDetector] Starting monitoring for step: ${step.id}`) + + // Notify that step has started + this.callbacks.onStepStart(step.id) + + // Set up listeners for each event trigger + step.eventTriggers.forEach((trigger, index) => { + this.setupEventListener(trigger, step, index) + }) + } + + /** + * Set up an individual event listener + */ + private setupEventListener( + trigger: EventTrigger, + step: TutorialStep, + index: number + ): void { + switch (trigger.type) { + case 'dom': + this.setupDOMListener(trigger, step, index) + break + case 'plugin': + this.setupPluginListener(trigger, step, index) + break + case 'manual': + // Manual triggers are handled by the UI (Next button) + console.log(`[TutorialEventDetector] Manual trigger for step: ${step.id}`) + break + } + } + + /** + * Set up a DOM event listener + */ + private setupDOMListener( + trigger: DOMEventTrigger, + step: TutorialStep, + index: number + ): void { + const listenerKey = `dom-${step.id}-${index}` + let retryCount = 0 + const maxRetries = 10 + + // Wait for element to be available + const checkElement = () => { + // Stop retrying if we've moved to a different step + if (this.currentStepId !== step.id) { + console.log(`[TutorialEventDetector] Step changed, stopping element search for ${trigger.selector}`) + return + } + + const element = document.querySelector(trigger.selector) + + if (element) { + const handler = (event: Event) => { + // Ignore events for previous steps + if (this.currentStepId !== step.id) { + console.log(`[TutorialEventDetector] Ignoring event for previous step: ${step.id}`) + return + } + + // Validate event if validator is provided + if (trigger.validator && !trigger.validator(event)) { + return + } + + console.log(`[TutorialEventDetector] DOM event triggered: ${trigger.eventType} on ${trigger.selector}`) + this.handleStepCompletion(step) + } + + element.addEventListener(trigger.eventType, handler) + + // Store cleanup function + this.activeListeners.set(listenerKey, () => { + element.removeEventListener(trigger.eventType, handler) + }) + + console.log(`[TutorialEventDetector] DOM listener added for ${trigger.selector}`) + } else if (retryCount < maxRetries) { + // Retry after a short delay + retryCount++ + console.log(`[TutorialEventDetector] Element not found: ${trigger.selector}, retrying... (${retryCount}/${maxRetries})`) + setTimeout(checkElement, 500) + } else { + console.warn(`[TutorialEventDetector] Element not found after ${maxRetries} retries: ${trigger.selector}`) + } + } + + checkElement() + } + + /** + * Set up a plugin event listener + */ + private setupPluginListener( + trigger: PluginEventTrigger, + step: TutorialStep, + index: number + ): void { + const listenerKey = `plugin-${step.id}-${index}` + + try { + const handler = (data: any) => { + // Ignore events for previous steps + if (this.currentStepId !== step.id) { + console.log(`[TutorialEventDetector] Ignoring plugin event for previous step: ${step.id}`) + return + } + + // Validate data if validator is provided + if (trigger.validator && !trigger.validator(data)) { + console.log(`[TutorialEventDetector] Plugin event validation failed for ${trigger.eventName}`) + return + } + + console.log(`[TutorialEventDetector] Plugin event triggered: ${trigger.pluginName}.${trigger.eventName}`) + this.handleStepCompletion(step) + } + + // Listen to the plugin event + this.plugin.on(trigger.pluginName, trigger.eventName, handler) + + // Store reference for cleanup + this.pluginEventListeners.set(listenerKey, { + pluginName: trigger.pluginName, + eventName: trigger.eventName, + handler + }) + + console.log(`[TutorialEventDetector] Plugin listener added for ${trigger.pluginName}.${trigger.eventName}`) + } catch (error) { + console.error(`[TutorialEventDetector] Failed to set up plugin listener:`, error) + } + } + + /** + * Handle step completion + */ + private async handleStepCompletion(step: TutorialStep): Promise { + // Prevent multiple completions + if (this.stepCompleted) { + return + } + + // Run additional validations if provided + if (step.validations && step.validations.length > 0) { + const validationResults = await Promise.all( + step.validations.map(validation => validation.check()) + ) + + if (!validationResults.every(result => result === true)) { + console.log(`[TutorialEventDetector] Step validation failed for: ${step.id}`) + return + } + } + + this.stepCompleted = true + console.log(`[TutorialEventDetector] Step completed: ${step.id}`) + + // Notify callback + this.callbacks.onStepComplete(step.id) + } + + /** + * Manually mark step as complete + */ + public completeCurrentStep(stepId: string): void { + if (!this.stepCompleted) { + this.stepCompleted = true + this.callbacks.onStepComplete(stepId) + } + } + + /** + * Check if current step is completed + */ + public isStepCompleted(): boolean { + return this.stepCompleted + } + + /** + * Clean up all active listeners + */ + public cleanup(): void { + // Clean up DOM listeners + this.activeListeners.forEach((cleanup, key) => { + cleanup() + }) + this.activeListeners.clear() + + // Clean up plugin listeners + this.pluginEventListeners.forEach((listener, key) => { + try { + this.plugin.off(listener.pluginName, listener.eventName) + } catch (error) { + console.error(`[TutorialEventDetector] Failed to remove plugin listener:`, error) + } + }) + this.pluginEventListeners.clear() + + this.currentStepId = null + + console.log('[TutorialEventDetector] All listeners cleaned up') + } + + /** + * Destroy the detector and clean up + */ + public destroy(): void { + this.cleanup() + this.stepCompleted = false + this.currentStepId = null + } +} + +/** + * Helper function to wait for element to appear + */ +export function waitForElement( + selector: string, + timeout: number = 10000 +): Promise { + return new Promise((resolve) => { + const element = document.querySelector(selector) as HTMLElement + + if (element) { + resolve(element) + return + } + + const observer = new MutationObserver((mutations) => { + const element = document.querySelector(selector) as HTMLElement + if (element) { + observer.disconnect() + resolve(element) + } + }) + + observer.observe(document.body, { + childList: true, + subtree: true + }) + + // Timeout + setTimeout(() => { + observer.disconnect() + resolve(null) + }, timeout) + }) +} diff --git a/libs/remix-ui/remix-ai-assistant/src/lib/tutorials/roadmaps/basic-navigation.roadmap.ts b/libs/remix-ui/remix-ai-assistant/src/lib/tutorials/roadmaps/basic-navigation.roadmap.ts new file mode 100644 index 00000000000..e96b6dc1528 --- /dev/null +++ b/libs/remix-ui/remix-ai-assistant/src/lib/tutorials/roadmaps/basic-navigation.roadmap.ts @@ -0,0 +1,177 @@ +import { TutorialRoadmap } from '../types' + +/** + * Basic Navigation Tutorial + * Introduces users to the Remix IDE interface and basic navigation + */ +export const basicNavigationRoadmap: TutorialRoadmap = { + id: 'basic-navigation', + title: 'Remix IDE Basics', + description: 'Learn how to navigate the Remix IDE interface, manage files, and use essential panels', + category: 'navigation', + difficulty: 'beginner', + estimatedTime: '10 minutes', + tags: ['beginner', 'interface', 'navigation', 'files'], + thumbnail: 'assets/img/tutorials/basic-navigation.webp', + + steps: [ + { + id: 'welcome', + title: 'Welcome to Remix IDE!', + description: 'Remix IDE is a powerful web-based development environment for Ethereum smart contracts. Let\'s explore the interface together.', + aiPrompt: 'Introduce the user to Remix IDE and explain its main purpose as a smart contract development environment', + hints: [ + 'Remix IDE works entirely in your browser', + 'No installation required', + 'Perfect for learning and developing smart contracts' + ], + eventTriggers: [{ + type: 'manual' + }], + estimatedTime: '30 seconds' + }, + { + id: 'file-explorer', + title: 'File Explorer Panel', + description: 'The File Explorer on the left shows your workspace files. Click on the File Explorer icon to open it.', + aiPrompt: 'Explain the File Explorer panel, its purpose, and how users can organize their smart contract projects', + targetElement: '[data-id="verticalIconsKindfilePanel"]', + targetDescription: 'File Explorer icon on the left sidebar', + eventTriggers: [ + { + type: 'dom', + selector: '[data-id="verticalIconsKindfilePanel"]', + eventType: 'click' + } + ], + hints: [ + 'Look for the folder icon on the left sidebar', + 'It\'s usually the second icon from the top', + 'Click it to open the File Explorer' + ], + estimatedTime: '1 minute' + }, + { + id: 'editor-basics', + title: 'Understanding the Editor', + description: 'The center area is the code editor where you write your smart contracts. It supports syntax highlighting, auto-completion, and more!', + aiPrompt: 'Explain the code editor features including syntax highlighting, auto-completion, and error detection', + targetDescription: 'Main code editor area', + eventTriggers: [ + { + type: 'plugin', + pluginName: 'fileManager', + eventName: 'currentFileChanged' + }, + { + type: 'manual' + } + ], + hints: [ + 'Try typing in the editor to see syntax highlighting', + 'Press Space for auto-completion suggestions', + 'Errors will appear with red squiggly lines' + ], + estimatedTime: '2 minutes' + }, + { + id: 'icon-panel', + title: 'Left Icon Panel', + description: 'The icon panel on the left provides access to different plugins and features like compiler, deploy & run, and more.', + aiPrompt: 'Introduce the left icon panel and explain how users can access different plugins and tools', + targetElement: '[data-id="remixIdeIconPanel"]', + targetDescription: 'Vertical icon panel on the left side', + eventTriggers: [{ + type: 'manual' + }], + hints: [ + 'Each icon represents a different plugin or tool', + 'Hover over icons to see their names', + 'Click an icon to open that plugin' + ], + estimatedTime: '2 minutes' + }, + { + id: 'solidity-compiler', + title: 'Solidity Compiler', + description: 'Click the Solidity Compiler icon (looks like "S" letter) to open the compiler panel.', + aiPrompt: 'Explain the Solidity Compiler plugin, its purpose, and how it compiles smart contracts', + targetElement: '[data-id="verticalIconsKindsolidity"]', + targetDescription: 'Solidity Compiler icon', + eventTriggers: [ + { + type: 'dom', + selector: '[data-id="verticalIconsKindsolidity"]', + eventType: 'click' + }, + { + type: 'plugin', + pluginName: 'solidity', + eventName: 'activate' + } + ], + hints: [ + 'Look for the "S" icon in the left panel', + 'It\'s typically the third or fourth icon', + 'This is where you\'ll compile your contracts' + ], + estimatedTime: '1 minute' + }, + { + id: 'terminal-panel', + title: 'Terminal Panel', + description: 'The terminal at the bottom shows output, errors, and transaction details. It\'s your window into what\'s happening in Remix.', + aiPrompt: 'Explain the terminal panel, what information it displays, and how users can interact with it', + targetElement: '[data-id="terminalContainer-view"]', + targetDescription: 'Terminal panel at the bottom', + eventTriggers: [{ + type: 'manual' + }], + hints: [ + 'The terminal is usually at the bottom of the screen', + 'You can drag the divider to resize it', + 'All compilation and deployment logs appear here' + ], + estimatedTime: '1 minute' + }, + { + id: 'terminal-panel-open-or-close', + title: 'Terminal Panel', + description: 'Use this button for hiding and opening the terminal', + aiPrompt: 'Explain the terminal panel, what information it displays, and how users can interact with it', + targetElement: '[data-id="terminalToggleIcon"]', + targetDescription: 'Terminal panel at the bottom', + eventTriggers: [ + { + type: 'manual' + }, + { + type: 'dom', + selector: '[data-id="terminalToggleIcon"]', + eventType: 'click' + }, + ], + hints: [ + 'Open or hide the terminal', + 'Listen on transactions', + 'Filter on secific inputs' + ], + estimatedTime: '1 minute' + }, + { + id: 'completion', + title: 'Congratulations!', + description: 'You\'ve completed the basics! You now know how to navigate Remix IDE. Ready to start developing smart contracts?', + aiPrompt: 'Congratulate the user and suggest next steps or related tutorials they might want to try', + eventTriggers: [{ + type: 'manual' + }], + hints: [ + 'Try the "Smart Contract Development" tutorial next', + 'Explore the other plugins and features', + 'Start writing your first smart contract!' + ], + estimatedTime: '30 seconds' + } + ] +} diff --git a/libs/remix-ui/remix-ai-assistant/src/lib/tutorials/roadmaps/index.ts b/libs/remix-ui/remix-ai-assistant/src/lib/tutorials/roadmaps/index.ts new file mode 100644 index 00000000000..baaad888db2 --- /dev/null +++ b/libs/remix-ui/remix-ai-assistant/src/lib/tutorials/roadmaps/index.ts @@ -0,0 +1,62 @@ +import { TutorialRoadmap } from '../types' +import { basicNavigationRoadmap } from './basic-navigation.roadmap' +import { smartContractDevRoadmap } from './smart-contract-dev.roadmap' + +/** + * All available tutorial roadmaps + */ +export const tutorialRoadmaps: TutorialRoadmap[] = [ + basicNavigationRoadmap, + smartContractDevRoadmap + // Additional roadmaps will be added here +] + +/** + * Get roadmap by ID + */ +export function getRoadmapById(id: string): TutorialRoadmap | undefined { + return tutorialRoadmaps.find(roadmap => roadmap.id === id) +} + +/** + * Get roadmaps by category + */ +export function getRoadmapsByCategory(category: string): TutorialRoadmap[] { + return tutorialRoadmaps.filter(roadmap => roadmap.category === category) +} + +/** + * Get roadmaps by difficulty + */ +export function getRoadmapsByDifficulty(difficulty: string): TutorialRoadmap[] { + return tutorialRoadmaps.filter(roadmap => roadmap.difficulty === difficulty) +} + +/** + * Get recommended next tutorial based on completed ones + */ +export function getRecommendedTutorial(completedIds: string[]): TutorialRoadmap | null { + // Find tutorials that haven't been completed + const remaining = tutorialRoadmaps.filter(roadmap => !completedIds.includes(roadmap.id)) + + if (remaining.length === 0) return null + + // Filter by tutorials whose prerequisites are met + const available = remaining.filter(roadmap => { + if (!roadmap.prerequisites || roadmap.prerequisites.length === 0) return true + return roadmap.prerequisites.every(prereq => completedIds.includes(prereq)) + }) + + // Return the first beginner tutorial if none are completed + if (completedIds.length === 0) { + return available.find(r => r.difficulty === 'beginner') || available[0] + } + + // Otherwise, return the first available tutorial + return available[0] || null +} + +export { + basicNavigationRoadmap, + smartContractDevRoadmap +} diff --git a/libs/remix-ui/remix-ai-assistant/src/lib/tutorials/roadmaps/smart-contract-dev.roadmap.ts b/libs/remix-ui/remix-ai-assistant/src/lib/tutorials/roadmaps/smart-contract-dev.roadmap.ts new file mode 100644 index 00000000000..76c79f6f967 --- /dev/null +++ b/libs/remix-ui/remix-ai-assistant/src/lib/tutorials/roadmaps/smart-contract-dev.roadmap.ts @@ -0,0 +1,262 @@ +import { TutorialRoadmap } from '../types' + +/** + * Smart Contract Development Tutorial + * Teaches users how to write, compile, and deploy their first smart contract + */ +export const smartContractDevRoadmap: TutorialRoadmap = { + id: 'smart-contract-dev', + title: 'Your First Smart Contract', + description: 'Learn how to write, compile, and deploy a simple smart contract on Remix IDE', + category: 'development', + difficulty: 'beginner', + estimatedTime: '15 minutes', + prerequisites: ['basic-navigation'], + tags: ['solidity', 'compile', 'deploy', 'smart-contract'], + thumbnail: 'assets/img/tutorials/smart-contract-dev.webp', + + steps: [ + { + id: 'intro', + title: 'Building Your First Smart Contract', + description: 'In this tutorial, you\'ll create a simple storage contract that can store and retrieve a number. Let\'s get started!', + aiPrompt: 'Introduce smart contracts and explain what we\'ll be building in this tutorial', + hints: [ + 'Smart contracts are programs that run on the blockchain', + 'Solidity is the most popular language for Ethereum smart contracts', + 'We\'ll build something simple but functional' + ], + eventTriggers: [{ + type: 'manual' + }], + estimatedTime: '30 seconds' + }, + { + id: 'create-contract-file', + title: 'Create a New Contract File', + description: 'Create a new file called "Storage.sol" in the contracts folder.', + aiPrompt: 'Guide the user to create a new Solidity file and explain the .sol extension', + targetElement: '[data-id="fileExplorerNewFilecreateNewFile"]', + targetDescription: 'New File button in File Explorer', + eventTriggers: [ + { + type: 'plugin', + pluginName: 'fileManager', + eventName: 'fileAdded', + validator: (filePath: string) => { + return filePath && (filePath.includes('Storage.sol') || filePath.endsWith('.sol')) + } + } + ], + hints: [ + 'Click the "+" icon in the File Explorer', + 'Name it "Storage.sol"', + 'Make sure to include the .sol extension' + ], + estimatedTime: '1 minute' + }, + { + id: 'write-contract', + title: 'Write the Contract Code', + description: 'Copy and paste this simple storage contract into your file, or type it yourself to learn the syntax.', + aiPrompt: 'Explain the basic structure of a Solidity contract and what each part of the Storage contract does', + targetElement: '#editorView', + targetDescription: 'Code editor', + eventTriggers: [ + { + type: 'plugin', + pluginName: 'fileManager', + eventName: 'fileSaved', + validator: (filePath: string) => { + return filePath && filePath.endsWith('.sol') + } + }, + { + type: 'manual' + } + ], + hints: [ + 'Start with: pragma solidity ^0.8.0;', + 'Define your contract: contract Storage { }', + 'Add a state variable and functions inside the contract' + ], + estimatedTime: '3 minutes' + }, + { + id: 'open-compiler', + title: 'Open the Solidity Compiler', + description: 'Click on the Solidity Compiler icon in the left panel to open the compiler.', + aiPrompt: 'Explain what compilation does and why it\'s necessary for smart contracts', + targetElement: '[data-id="verticalIconsKindsolidity"]', + targetDescription: 'Solidity Compiler icon', + eventTriggers: [ + { + type: 'dom', + selector: '[data-id="verticalIconsKindsolidity"]', + eventType: 'click' + } + ], + hints: [ + 'Look for the "S" icon on the left sidebar', + 'It\'s the Solidity Compiler plugin', + 'Click it to see compilation options' + ], + estimatedTime: '30 seconds' + }, + { + id: 'compile-contract', + title: 'Compile Your Contract', + description: 'Click the "Compile Storage.sol" button to compile your smart contract.', + aiPrompt: 'Explain the compilation process, compiler versions, and what to do if there are errors', + targetElement: '[data-id="compilerContainerCompileBtn"]', + targetDescription: 'Compile button in Solidity Compiler panel', + eventTriggers: [ + { + type: 'plugin', + pluginName: 'solidity', + eventName: 'compilationFinished', + validator: (data: any) => { + return data && !data.error && data.data && data.data.contracts + } + }, + { + type: 'dom', + selector: '[data-id="compilerContainerCompileBtn"]', + eventType: 'click' + } + ], + hints: [ + 'The button is in the Solidity Compiler panel', + 'Make sure your code has no errors first', + 'A green checkmark means successful compilation' + ], + estimatedTime: '2 minutes' + }, + { + id: 'open-deploy', + title: 'Open Deploy & Run', + description: 'Click on the "Deploy & Run Transactions" icon to open the deployment panel.', + aiPrompt: 'Introduce the Deploy & Run plugin and explain different deployment environments', + targetElement: '[data-id="verticalIconsKindudapp"]', + targetDescription: 'Deploy & Run Transactions icon', + eventTriggers: [ + { + type: 'dom', + selector: '[data-id="verticalIconsKindudapp"]', + eventType: 'click' + } + ], + hints: [ + 'Look for the Ethereum logo icon', + 'It\'s usually right after the compiler icon', + 'This is where you deploy and interact with contracts' + ], + estimatedTime: '30 seconds' + }, + { + id: 'select-environment', + title: 'Choose Deployment Environment', + description: 'Make sure "Remix VM (Shanghai)" is selected in the Environment dropdown. This is a safe, local testing environment.', + aiPrompt: 'Explain the different environments (Remix VM, Injected Provider, etc.) and when to use each', + targetElement: '[data-id="settingsSelectEnvOptions"]', + targetDescription: 'Environment dropdown', + eventTriggers: [{ + type: 'manual' + }], + hints: [ + 'Remix VM runs entirely in your browser', + 'No real cryptocurrency is needed', + 'Perfect for testing and learning' + ], + estimatedTime: '1 minute' + }, + { + id: 'deploy-contract', + title: 'Deploy Your Contract', + description: 'Click the orange "Deploy" button to deploy your Storage contract to the Remix VM.', + aiPrompt: 'Explain what happens when you deploy a contract and what the deployment transaction does', + targetElement: '[data-id="contractActionsContainerSingle"]', + targetDescription: 'Deploy button', + eventTriggers: [ + { + type: 'plugin', + pluginName: 'udapp', + eventName: 'newContractInstanceAdded', + validator: (data: any) => { + return data && data.address + } + }, + { + type: 'dom', + selector: '[data-id="Deploy - transact (not payable)"]', + eventType: 'click' + } + ], + hints: [ + 'The Deploy button is orange', + 'Make sure your contract is compiled first', + 'Check the terminal for deployment confirmation' + ], + estimatedTime: '1 minute' + }, + { + id: 'interact-contract', + title: 'Interact with Your Contract', + description: 'Your contract is now deployed! Expand it in the "Deployed Contracts" section to see its functions. Try calling the store function with a number.', + aiPrompt: 'Explain how to interact with deployed contracts and what the different buttons mean', + targetElement: '[data-id="universalDappUiInstance"]', + targetDescription: 'Deployed contract instance', + eventTriggers: [ + { + type: 'plugin', + pluginName: 'udapp', + eventName: 'transactionExecuted', + validator: (data: any) => { + return data && data.receipt + } + }, + { + type: 'manual' + } + ], + hints: [ + 'Orange buttons are state-changing functions', + 'Blue buttons are view functions (read-only)', + 'Enter a number and click the store button' + ], + estimatedTime: '2 minutes' + }, + { + id: 'retrieve-value', + title: 'Retrieve the Stored Value', + description: 'Click the "retrieve" button to read back the number you stored. See it in the terminal!', + aiPrompt: 'Explain the difference between state-changing transactions and view functions', + targetElement: '[data-id="universalDappUiInstance"]', + targetDescription: 'Retrieve button on deployed contract', + eventTriggers: [{ + type: 'manual' + }], + hints: [ + 'The retrieve button is blue (view function)', + 'It doesn\'t cost gas because it only reads data', + 'The result appears below the button' + ], + estimatedTime: '1 minute' + }, + { + id: 'completion', + title: 'Congratulations!', + description: 'You\'ve successfully created, compiled, deployed, and interacted with your first smart contract! This is the foundation of blockchain development.', + aiPrompt: 'Congratulate the user and suggest advanced topics or next tutorials to explore', + eventTriggers: [{ + type: 'manual' + }], + hints: [ + 'Try the "Testing & Debugging" tutorial next', + 'Experiment with modifying the contract', + 'Learn about more complex Solidity features' + ], + estimatedTime: '30 seconds' + } + ] +} diff --git a/libs/remix-ui/remix-ai-assistant/src/lib/tutorials/types.ts b/libs/remix-ui/remix-ai-assistant/src/lib/tutorials/types.ts new file mode 100644 index 00000000000..504c7cf1f5f --- /dev/null +++ b/libs/remix-ui/remix-ai-assistant/src/lib/tutorials/types.ts @@ -0,0 +1,160 @@ +import { Plugin } from '@remixproject/engine' + +/** + * Tutorial category types + */ +export type TutorialCategory = 'navigation' | 'development' | 'testing' | 'advanced' + +/** + * Event trigger types for detecting step completion + */ +export type EventTriggerType = 'dom' | 'plugin' | 'manual' + +/** + * DOM event types + */ +export type DOMEventType = 'click' | 'input' | 'change' | 'focus' + +/** + * Plugin event trigger configuration + */ +export interface PluginEventTrigger { + type: 'plugin' + pluginName: string + eventName: string + validator?: (data: any) => boolean +} + +/** + * DOM event trigger configuration + */ +export interface DOMEventTrigger { + type: 'dom' + selector: string + eventType: DOMEventType + validator?: (event: Event) => boolean +} + +/** + * Manual confirmation trigger + */ +export interface ManualTrigger { + type: 'manual' +} + +/** + * Union type for all event triggers + */ +export type EventTrigger = PluginEventTrigger | DOMEventTrigger | ManualTrigger + +/** + * Step validation configuration + */ +export interface StepValidation { + type: 'dom' | 'plugin' | 'custom' + check: () => Promise | boolean + errorMessage?: string +} + +/** + * Tutorial step definition + */ +export interface TutorialStep { + id: string + title: string + description: string + aiPrompt: string // AI uses this for enhanced explanations + targetElement?: string // CSS selector or data-id attribute + targetDescription?: string // Human-readable description of target + eventTriggers: EventTrigger[] // For auto-detection + validations?: StepValidation[] // Additional validation checks + hints: string[] + optional?: boolean + estimatedTime?: string // e.g., "2 minutes" +} + +/** + * Tutorial roadmap definition + */ +export interface TutorialRoadmap { + id: string + title: string + description: string + category: TutorialCategory + difficulty: 'beginner' | 'intermediate' | 'advanced' + estimatedTime: string + prerequisites?: string[] // IDs of required tutorials + steps: TutorialStep[] + thumbnail?: string + tags: string[] +} + +/** + * Tutorial progress tracking + */ +export interface TutorialProgress { + roadmapId: string + currentStepIndex: number + completedSteps: string[] // Step IDs + startedAt: number // Timestamp + lastUpdated: number // Timestamp + completed: boolean + completedAt?: number // Timestamp +} + +/** + * Tutorial state for React components + */ +export interface TutorialState { + isActive: boolean + currentRoadmap: TutorialRoadmap | null + currentStepIndex: number + progress: TutorialProgress | null + completedTutorials: string[] // Roadmap IDs + showHints: boolean + autoAdvance: boolean +} + +/** + * Tutorial preferences + */ +export interface TutorialPreferences { + autoAdvance: boolean // Automatically move to next step when completed + showHints: boolean // Show hints by default + playSound: boolean // Play sound on step completion + highlightIntensity: 'subtle' | 'normal' | 'strong' + animationSpeed: 'slow' | 'normal' | 'fast' +} + +/** + * Tutorial event detector callbacks + */ +export interface TutorialEventCallbacks { + onStepComplete: (stepId: string) => void + onStepStart: (stepId: string) => void + onTutorialComplete: (roadmapId: string) => void + onTutorialExit: () => void +} + +/** + * Pointing hand position configuration + */ +export interface PointingHandPosition { + top: number + left: number + rotation?: number // degrees + visible: boolean +} + +/** + * Tutorial context for AI enhancement + */ +export interface TutorialAIContext { + roadmapId: string + stepId: string + stepIndex: number + totalSteps: number + userQuestion?: string + previousAttempts?: number + errorMessages?: string[] +} diff --git a/libs/remix-ui/remix-ai-assistant/src/lib/tutorials/useTutorialState.ts b/libs/remix-ui/remix-ai-assistant/src/lib/tutorials/useTutorialState.ts new file mode 100644 index 00000000000..e01b52dfe8d --- /dev/null +++ b/libs/remix-ui/remix-ai-assistant/src/lib/tutorials/useTutorialState.ts @@ -0,0 +1,314 @@ +import { useState, useEffect, useCallback } from 'react' +import { + TutorialRoadmap, + TutorialProgress, + TutorialState, + TutorialPreferences +} from './types' + +const STORAGE_KEYS = { + PROGRESS: 'remix-tutorial-progress', + COMPLETED: 'remix-tutorial-completed', + PREFERENCES: 'remix-tutorial-preferences' +} + +const DEFAULT_PREFERENCES: TutorialPreferences = { + autoAdvance: false, + showHints: true, + playSound: false, + highlightIntensity: 'normal', + animationSpeed: 'normal' +} + +/** + * Custom hook for managing tutorial state + * Handles tutorial progress, persistence, and state management + */ +export function useTutorialState() { + const [state, setState] = useState({ + isActive: false, + currentRoadmap: null, + currentStepIndex: 0, + progress: null, + completedTutorials: [], + showHints: DEFAULT_PREFERENCES.showHints, + autoAdvance: DEFAULT_PREFERENCES.autoAdvance + }) + + const [preferences, setPreferences] = useState(DEFAULT_PREFERENCES) + + // Load persisted data on mount + useEffect(() => { + loadPersistedData() + }, []) + + // Persist state changes + useEffect(() => { + if (state.progress) { + localStorage.setItem(STORAGE_KEYS.PROGRESS, JSON.stringify(state.progress)) + } + if (state.completedTutorials.length > 0) { + localStorage.setItem(STORAGE_KEYS.COMPLETED, JSON.stringify(state.completedTutorials)) + } + }, [state.progress, state.completedTutorials]) + + // Persist preferences + useEffect(() => { + localStorage.setItem(STORAGE_KEYS.PREFERENCES, JSON.stringify(preferences)) + }, [preferences]) + + /** + * Load persisted data from localStorage + */ + const loadPersistedData = useCallback(() => { + try { + // Load preferences + const savedPrefs = localStorage.getItem(STORAGE_KEYS.PREFERENCES) + if (savedPrefs) { + const prefs = JSON.parse(savedPrefs) + setPreferences({ ...DEFAULT_PREFERENCES, ...prefs }) + setState(prev => ({ + ...prev, + showHints: prefs.showHints ?? prev.showHints, + autoAdvance: prefs.autoAdvance ?? prev.autoAdvance + })) + } + + // Load completed tutorials + const savedCompleted = localStorage.getItem(STORAGE_KEYS.COMPLETED) + if (savedCompleted) { + setState(prev => ({ + ...prev, + completedTutorials: JSON.parse(savedCompleted) + })) + } + + // Load progress + const savedProgress = localStorage.getItem(STORAGE_KEYS.PROGRESS) + if (savedProgress) { + setState(prev => ({ + ...prev, + progress: JSON.parse(savedProgress) + })) + } + } catch (error) { + console.error('[Tutorial] Failed to load persisted data:', error) + } + }, []) + + /** + * Start a tutorial roadmap + */ + const startTutorial = useCallback((roadmap: TutorialRoadmap, resumeFromStep?: number) => { + const startStepIndex = resumeFromStep ?? 0 + const now = Date.now() + + const progress: TutorialProgress = { + roadmapId: roadmap.id, + currentStepIndex: startStepIndex, + completedSteps: [], + startedAt: now, + lastUpdated: now, + completed: false + } + + setState({ + isActive: true, + currentRoadmap: roadmap, + currentStepIndex: startStepIndex, + progress, + completedTutorials: state.completedTutorials, + showHints: preferences.showHints, + autoAdvance: preferences.autoAdvance + }) + }, [preferences, state.completedTutorials]) + + /** + * Resume an existing tutorial + */ + const resumeTutorial = useCallback((roadmap: TutorialRoadmap) => { + if (state.progress && state.progress.roadmapId === roadmap.id) { + setState(prev => ({ + ...prev, + isActive: true, + currentRoadmap: roadmap, + currentStepIndex: state.progress?.currentStepIndex ?? 0 + })) + } else { + startTutorial(roadmap) + } + }, [state.progress, startTutorial]) + + /** + * Complete the current step and move to next + */ + const completeStep = useCallback(() => { + if (!state.currentRoadmap || !state.progress) { + console.warn('[Tutorial] Cannot complete step: no active roadmap or progress') + return false + } + + const currentStep = state.currentRoadmap.steps[state.currentStepIndex] + + // Check if this step was already completed (防止重复完成) + if (state.progress.completedSteps.includes(currentStep.id)) { + console.log(`[Tutorial] Step ${currentStep.id} already completed, skipping`) + return state.currentStepIndex >= state.currentRoadmap.steps.length - 1 + } + + const newCompletedSteps = [...state.progress.completedSteps, currentStep.id] + const isLastStep = state.currentStepIndex >= state.currentRoadmap.steps.length - 1 + + console.log(`[Tutorial] Completing step ${state.currentStepIndex + 1}/${state.currentRoadmap.steps.length}: ${currentStep.id}`) + + const updatedProgress: TutorialProgress = { + ...state.progress, + completedSteps: newCompletedSteps, + currentStepIndex: isLastStep ? state.currentStepIndex : state.currentStepIndex + 1, + lastUpdated: Date.now(), + completed: isLastStep, + completedAt: isLastStep ? Date.now() : undefined + } + + // If tutorial is completed, add to completed list + let newCompletedTutorials = state.completedTutorials + if (isLastStep && !state.completedTutorials.includes(state.currentRoadmap.id)) { + newCompletedTutorials = [...state.completedTutorials, state.currentRoadmap.id] + } + + setState(prev => ({ + ...prev, + currentStepIndex: updatedProgress.currentStepIndex, + progress: updatedProgress, + completedTutorials: newCompletedTutorials, + isActive: !isLastStep || !preferences.autoAdvance + })) + + return isLastStep + }, [state, preferences.autoAdvance]) + + /** + * Skip to a specific step + */ + const goToStep = useCallback((stepIndex: number) => { + if (!state.currentRoadmap || !state.progress) return + + if (stepIndex < 0 || stepIndex >= state.currentRoadmap.steps.length) { + console.warn('[Tutorial] Invalid step index:', stepIndex) + return + } + + setState(prev => ({ + ...prev, + currentStepIndex: stepIndex, + progress: prev.progress ? { + ...prev.progress, + currentStepIndex: stepIndex, + lastUpdated: Date.now() + } : null + })) + }, [state.currentRoadmap, state.progress]) + + /** + * Skip the current step (for optional steps) + */ + const skipStep = useCallback(() => { + if (!state.currentRoadmap) return + + const nextStepIndex = state.currentStepIndex + 1 + if (nextStepIndex < state.currentRoadmap.steps.length) { + goToStep(nextStepIndex) + } + }, [state.currentRoadmap, state.currentStepIndex, goToStep]) + + /** + * Exit the current tutorial + */ + const exitTutorial = useCallback(() => { + setState(prev => ({ + ...prev, + isActive: false + })) + }, []) + + /** + * Reset tutorial progress + */ + const resetTutorial = useCallback((roadmapId?: string) => { + if (roadmapId) { + // Reset specific tutorial + if (state.progress?.roadmapId === roadmapId) { + setState(prev => ({ + ...prev, + progress: null, + isActive: false, + currentRoadmap: null, + currentStepIndex: 0 + })) + } + const newCompleted = state.completedTutorials.filter(id => id !== roadmapId) + setState(prev => ({ ...prev, completedTutorials: newCompleted })) + localStorage.setItem(STORAGE_KEYS.COMPLETED, JSON.stringify(newCompleted)) + } else { + // Reset all + setState({ + isActive: false, + currentRoadmap: null, + currentStepIndex: 0, + progress: null, + completedTutorials: [], + showHints: preferences.showHints, + autoAdvance: preferences.autoAdvance + }) + localStorage.removeItem(STORAGE_KEYS.PROGRESS) + localStorage.removeItem(STORAGE_KEYS.COMPLETED) + } + }, [state.progress, state.completedTutorials, preferences]) + + /** + * Update preferences + */ + const updatePreferences = useCallback((updates: Partial) => { + setPreferences(prev => ({ ...prev, ...updates })) + setState(prev => ({ + ...prev, + showHints: updates.showHints ?? prev.showHints, + autoAdvance: updates.autoAdvance ?? prev.autoAdvance + })) + }, []) + + /** + * Check if a tutorial is completed + */ + const isTutorialCompleted = useCallback((roadmapId: string): boolean => { + return state.completedTutorials.includes(roadmapId) + }, [state.completedTutorials]) + + /** + * Get progress percentage + */ + const getProgressPercentage = useCallback((): number => { + if (!state.currentRoadmap || !state.progress) return 0 + return (state.progress.completedSteps.length / state.currentRoadmap.steps.length) * 100 + }, [state.currentRoadmap, state.progress]) + + return { + // State + state, + preferences, + + // Actions + startTutorial, + resumeTutorial, + completeStep, + goToStep, + skipStep, + exitTutorial, + resetTutorial, + updatePreferences, + + // Helpers + isTutorialCompleted, + getProgressPercentage + } +}