add missing components

This commit is contained in:
zane
2026-03-15 17:43:45 -04:00
parent 9351774fbf
commit 3c2c59a80e
6 changed files with 356 additions and 0 deletions
+35
View File
@@ -0,0 +1,35 @@
export const PHASES = {
1: {
name: 'Capable Single Agent',
steps: ['00', '01', '02', '03', '04', '05', '06'],
description: 'Build a fully-functional agent that can chat, use tools, learn skills, remember conversations, and access the internet.',
},
2: {
name: 'Event-Driven Architecture',
steps: ['07', '08', '09', '10'],
description: 'Refactor to event-driven architecture for scalability and multi-platform support.',
},
3: {
name: 'Autonomous & Multi-Agent',
steps: ['11', '12', '13', '14', '15'],
description: 'Add scheduled tasks, agent collaboration, and intelligent routing.',
},
4: {
name: 'Production & Scale',
steps: ['16', '17'],
description: 'Production features for reliability and long-term memory.',
},
} as const
export const SKIP_PATTERNS = [
'**/.venv/**',
'**/__pycache__/**',
'**/node_modules/**',
'**/*.lock',
'**/*.svg',
'**/*.png',
'**/*.jpg',
'**/*.pyc',
]
export const STEPS_DIR = '../' // Relative to web/ directory
+62
View File
@@ -0,0 +1,62 @@
import * as Diff from 'diff'
export interface DiffLine {
type: 'added' | 'removed' | 'unchanged'
content: string
oldLineNumber?: number
newLineNumber?: number
}
export interface DiffResult {
lines: DiffLine[]
addedCount: number
removedCount: number
}
/**
* Compute line-by-line diff between two strings
*/
export function computeDiff(fromContent: string, toContent: string): DiffResult {
const changes = Diff.diffLines(fromContent, toContent)
const lines: DiffLine[] = []
let addedCount = 0
let removedCount = 0
let oldLine = 1
let newLine = 1
for (const change of changes) {
const changeLines = change.value.split('\n')
// Remove last empty string if content ends with newline
if (changeLines[changeLines.length - 1] === '') {
changeLines.pop()
}
for (const line of changeLines) {
if (change.added) {
lines.push({
type: 'added',
content: line,
newLineNumber: newLine++,
})
addedCount++
} else if (change.removed) {
lines.push({
type: 'removed',
content: line,
oldLineNumber: oldLine++,
})
removedCount++
} else {
lines.push({
type: 'unchanged',
content: line,
oldLineNumber: oldLine++,
newLineNumber: newLine++,
})
}
}
}
return { lines, addedCount, removedCount }
}
+114
View File
@@ -0,0 +1,114 @@
import fs from 'fs'
import path from 'path'
import { glob } from 'glob'
import { SKIP_PATTERNS, STEPS_DIR } from './constants'
export interface FileDiff {
path: string
status: 'added' | 'removed' | 'modified' | 'unchanged'
fromContent?: string
toContent?: string
}
/**
* Get all Python files in a step directory
*/
function getPythonFiles(stepFolder: string): string[] {
const stepPath = path.join(process.cwd(), STEPS_DIR, stepFolder, 'src', 'mybot')
if (!fs.existsSync(stepPath)) {
return []
}
const files = glob.sync('**/*.py', {
cwd: stepPath,
ignore: SKIP_PATTERNS,
nodir: true,
})
return files
}
/**
* Read file content from a step directory
*/
function readFileContent(stepFolder: string, relativePath: string): string | null {
const filePath = path.join(process.cwd(), STEPS_DIR, stepFolder, 'src', 'mybot', relativePath)
if (!fs.existsSync(filePath)) {
return null
}
return fs.readFileSync(filePath, 'utf-8')
}
/**
* Discover and compare files between two steps
*/
export function discoverFiles(fromFolder: string, toFolder: string): FileDiff[] {
const fromFiles = new Set(getPythonFiles(fromFolder))
const toFiles = new Set(getPythonFiles(toFolder))
const allFiles = new Set([...fromFiles, ...toFiles])
const results: FileDiff[] = []
for (const file of allFiles) {
const inFrom = fromFiles.has(file)
const inTo = toFiles.has(file)
const fromContent = inFrom ? (readFileContent(fromFolder, file) ?? undefined) : undefined
const toContent = inTo ? (readFileContent(toFolder, file) ?? undefined) : undefined
let status: FileDiff['status']
if (!inFrom && inTo) {
status = 'added'
} else if (inFrom && !inTo) {
status = 'removed'
} else if (fromContent !== toContent) {
status = 'modified'
} else {
status = 'unchanged'
}
results.push({
path: file,
status,
fromContent,
toContent,
})
}
// Sort: modified first, then added, then removed, then unchanged
const statusOrder: Record<FileDiff['status'], number> = {
modified: 0,
added: 1,
removed: 2,
unchanged: 3,
}
results.sort((a, b) => {
const orderDiff = statusOrder[a.status] - statusOrder[b.status]
if (orderDiff !== 0) return orderDiff
return a.path.localeCompare(b.path)
})
return results
}
/**
* Get only changed files (exclude unchanged)
*/
export function getChangedFiles(fromFolder: string, toFolder: string): FileDiff[] {
return discoverFiles(fromFolder, toFolder).filter(
(f) => f.status !== 'unchanged'
)
}
/**
* Get only unchanged files
*/
export function getUnchangedFiles(fromFolder: string, toFolder: string): FileDiff[] {
return discoverFiles(fromFolder, toFolder).filter(
(f) => f.status === 'unchanged'
)
}
+19
View File
@@ -0,0 +1,19 @@
'use client'
import * as React from 'react'
/**
* Hook to detect if viewport is mobile-sized
*/
export function useIsMobile(breakpoint = 768) {
const [isMobile, setIsMobile] = React.useState(false)
React.useEffect(() => {
const checkMobile = () => setIsMobile(window.innerWidth < breakpoint)
checkMobile()
window.addEventListener('resize', checkMobile)
return () => window.removeEventListener('resize', checkMobile)
}, [breakpoint])
return isMobile
}
+120
View File
@@ -0,0 +1,120 @@
import fs from 'fs'
import path from 'path'
import { PHASES, STEPS_DIR } from './constants'
export interface Step {
id: string
title: string
description: string
phase: number
folderName: string
readmePath: string
}
/**
* Get the folder name for a step ID (e.g., "00" -> "00-chat-loop")
*/
function getFolderName(stepId: string): string | null {
const stepsDir = path.join(process.cwd(), STEPS_DIR)
const entries = fs.readdirSync(stepsDir, { withFileTypes: true })
const folder = entries.find(
(e) => e.isDirectory() && e.name.startsWith(`${stepId}-`)
)
return folder?.name ?? null
}
/**
* Parse title from README H1
* Format: "# Step XX: Title Here" -> "Title Here"
*/
function parseTitle(readmeContent: string): string {
const h1Match = readmeContent.match(/^# .+/m)
if (!h1Match) return 'Unknown'
const fullTitle = h1Match[0].replace('# ', '')
return fullTitle.split(': ')[1] ?? fullTitle
}
/**
* Parse the first blockquote from README
* Format: "> Description text here" -> "Description text here"
*/
function parseDescription(readmeContent: string): string {
const blockquoteMatch = readmeContent.match(/^>\s*(.+)$/m)
return blockquoteMatch?.[1]?.trim() ?? ''
}
/**
* Get phase number for a step ID
*/
function getPhase(stepId: string): number {
const phaseEntries = Object.entries(PHASES) as [string, typeof PHASES[1]][]
for (const [phase, data] of phaseEntries) {
if (data.steps.includes(stepId as any)) {
return parseInt(phase, 10)
}
}
return 0
}
/**
* Load all steps from the parent directory
*/
export function loadSteps(): Step[] {
const steps: Step[] = []
const phaseEntries = Object.entries(PHASES) as [string, typeof PHASES[1]][]
for (const [phaseStr, phaseData] of phaseEntries) {
for (const stepId of phaseData.steps) {
const folderName = getFolderName(stepId)
if (!folderName) continue
const readmePath = path.join(process.cwd(), STEPS_DIR, folderName, 'README.md')
let title = `Step ${stepId}`
let description = ''
if (fs.existsSync(readmePath)) {
const content = fs.readFileSync(readmePath, 'utf-8')
title = parseTitle(content)
description = parseDescription(content)
}
steps.push({
id: stepId,
title,
description,
phase: parseInt(phaseStr, 10),
folderName,
readmePath,
})
}
}
return steps
}
// Cache steps at module level for build
let _steps: Step[] | null = null
export function getSteps(): Step[] {
if (!_steps) {
_steps = loadSteps()
}
return _steps
}
export function getStep(id: string): Step | undefined {
return getSteps().find((s) => s.id === id)
}
export function getStepsByPhase(): Record<number, Step[]> {
const steps = getSteps()
const result: Record<number, Step[]> = {}
for (const step of steps) {
if (!result[step.phase]) {
result[step.phase] = []
}
result[step.phase].push(step)
}
return result
}
+6
View File
@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}