diff --git a/.clinerules b/.clinerules new file mode 100644 index 00000000..206a6a30 --- /dev/null +++ b/.clinerules @@ -0,0 +1,3 @@ +# Code Quality Rules + +1. Never use Chinese comments \ No newline at end of file diff --git a/docker/traefik-config/services.yml b/docker/traefik-config/services.yml index 50370057..ae6456bb 100644 --- a/docker/traefik-config/services.yml +++ b/docker/traefik-config/services.yml @@ -63,5 +63,6 @@ http: - DELETE - OPTIONS accessControlAllowHeaders: - - Content-Type - - Authorization + - '*' + accessControlAllowOriginList: + - '*' diff --git a/frontend/package.json b/frontend/package.json index 23aa05fe..92269dc7 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -39,6 +39,7 @@ "@radix-ui/react-visually-hidden": "^1.1.1", "@types/apollo-upload-client": "^18.0.0", "@types/dom-speech-recognition": "^0.0.4", + "@types/react-window": "^1.8.8", "apollo-upload-client": "^18.0.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -62,6 +63,7 @@ "react-markdown": "^9.0.1", "react-resizable-panels": "^2.1.3", "react-textarea-autosize": "^8.5.3", + "react-window": "^1.8.11", "remark-gfm": "^4.0.0", "sharp": "^0.33.5", "sonner": "^1.5.0", diff --git a/frontend/src/app/api/runProject/route.ts b/frontend/src/app/api/runProject/route.ts index 73b44b67..2b9cac37 100644 --- a/frontend/src/app/api/runProject/route.ts +++ b/frontend/src/app/api/runProject/route.ts @@ -2,22 +2,124 @@ import { NextResponse } from 'next/server'; import { exec } from 'child_process'; import * as path from 'path'; import * as net from 'net'; +import * as fs from 'fs'; import { getProjectPath } from 'codefox-common'; -import puppetter from 'puppeteer'; -import { useMutation } from '@apollo/client/react/hooks/useMutation'; -import { toast } from 'sonner'; -import { UPDATE_PROJECT_PHOTO_URL } from '@/graphql/request'; -import { TLS } from '@/utils/const'; +import { URL_PROTOCOL_PREFIX } from '@/utils/const'; -const runningContainers = new Map< +// Persist container state to file system to recover after service restarts +const CONTAINER_STATE_FILE = path.join(process.cwd(), 'container-state.json'); +const PORT_STATE_FILE = path.join(process.cwd(), 'port-state.json'); + +// In-memory container and port state +let runningContainers = new Map< string, - { domain: string; containerId: string } + { domain: string; containerId: string; port?: number; timestamp: number } >(); -const allocatedPorts = new Set(); +let allocatedPorts = new Set(); + +// Set to track projects being processed +const processingRequests = new Set(); + +// State lock to prevent concurrent reads/writes to state files +let isUpdatingState = false; + +/** + * Initialize function, loads persisted state when service starts + */ +async function initializeState() { + try { + // Load container state + if (fs.existsSync(CONTAINER_STATE_FILE)) { + const containerData = await fs.promises.readFile( + CONTAINER_STATE_FILE, + 'utf8' + ); + const containerMap = JSON.parse(containerData); + runningContainers = new Map(Object.entries(containerMap)); + + // Verify each container is still running + for (const [projectPath, container] of Array.from( + runningContainers.entries() + )) { + const isRunning = await checkContainerRunning(container.containerId); + if (!isRunning) { + runningContainers.delete(projectPath); + } + } + } + + // Load port state + if (fs.existsSync(PORT_STATE_FILE)) { + const portData = await fs.promises.readFile(PORT_STATE_FILE, 'utf8'); + const ports = JSON.parse(portData); + allocatedPorts = new Set(ports); + + // Clear expired port allocations (older than 24 hours) + const currentTime = Date.now(); + const containers = Array.from(runningContainers.values()); + const validPorts = new Set(); + + // Only retain ports used by currently running containers + containers.forEach((container) => { + if ( + container.port && + currentTime - container.timestamp < 24 * 60 * 60 * 1000 + ) { + validPorts.add(container.port); + } + }); + + allocatedPorts = validPorts; + } + + // Save cleaned-up state + await saveState(); + + console.log( + 'State initialization complete, cleaned up non-running containers and expired port allocations' + ); + } catch (error) { + console.error('Error initializing state:', error); + // If loading fails, continue with empty state + runningContainers = new Map(); + allocatedPorts = new Set(); + } +} + +/** + * Save current state to file system + */ +async function saveState() { + if (isUpdatingState) { + // If an update is already in progress, wait + await new Promise((resolve) => setTimeout(resolve, 100)); + return saveState(); + } + + isUpdatingState = true; + try { + // Save container state + const containerObject = Object.fromEntries(runningContainers); + await fs.promises.writeFile( + CONTAINER_STATE_FILE, + JSON.stringify(containerObject, null, 2) + ); + + // Save port state + const portsArray = Array.from(allocatedPorts); + await fs.promises.writeFile( + PORT_STATE_FILE, + JSON.stringify(portsArray, null, 2) + ); + } catch (error) { + console.error('Error saving state:', error); + } finally { + isUpdatingState = false; + } +} /** - * Finds an available port in the given range. - * It checks each port to see if it's free. + * Find an available port */ function findAvailablePort( minPort: number = 38000, @@ -29,6 +131,7 @@ function findAvailablePort( if (allocatedPorts.has(port)) { return resolveCheck(false); } + const server = net.createServer(); server.listen(port, '127.0.0.1', () => { server.close(() => resolveCheck(true)); @@ -38,13 +141,21 @@ function findAvailablePort( } async function scanPorts() { - for (let port = minPort; port <= maxPort; port++) { + // Add randomness to avoid sequential port allocation + const portRange = Array.from( + { length: maxPort - minPort + 1 }, + (_, i) => i + minPort + ); + const shuffledPorts = portRange.sort(() => Math.random() - 0.5); + + for (const port of shuffledPorts) { if (await checkPort(port)) { allocatedPorts.add(port); + await saveState(); return resolve(port); } } - reject(new Error('No available ports found.')); + reject(new Error('No available ports found')); } scanPorts(); @@ -52,8 +163,25 @@ function findAvailablePort( } /** - * Checks if there is already a container running that matches the - * traefik.http.routers..rule label. + * Check if a container is still running + */ +function checkContainerRunning(containerId: string): Promise { + return new Promise((resolve) => { + exec( + `docker inspect -f "{{.State.Running}}" ${containerId}`, + (err, stdout) => { + if (err || stdout.trim() !== 'true') { + resolve(false); + } else { + resolve(true); + } + } + ); + }); +} + +/** + * Check if there's already a container running with the specified label */ async function checkExistingContainer( projectPath: string @@ -62,6 +190,7 @@ async function checkExistingContainer( const subdomain = projectPath.replace(/[^\w-]/g, '').toLowerCase(); exec( `docker ps --filter "label=traefik.http.routers.${subdomain}.rule" --format "{{.ID}}"`, + { timeout: 10000 }, // Set timeout to prevent command hanging (error, stdout) => { if (error || !stdout.trim()) { resolve(null); @@ -74,23 +203,22 @@ async function checkExistingContainer( } /** - * Remove local node_modules and lock files before building the Docker image. - * This is based on Linux/macOS commands. If you are on Windows, you may need - * to adapt the removal command accordingly. + * Remove node_modules and lock files */ async function removeNodeModulesAndLockFiles(directory: string) { return new Promise((resolve, reject) => { - // Linux/macOS command. On Windows, you might need a different approach. const removeCmd = `rm -rf "${path.join(directory, 'node_modules')}" \ "${path.join(directory, 'yarn.lock')}" \ "${path.join(directory, 'package-lock.json')}" \ "${path.join(directory, 'pnpm-lock.yaml')}"`; console.log(`Cleaning up node_modules and lock files in: ${directory}`); - exec(removeCmd, (err, stdout, stderr) => { + exec(removeCmd, { timeout: 30000 }, (err, stdout, stderr) => { if (err) { console.error('Error removing node_modules or lock files:', stderr); - return reject(err); + // Don't block the process, continue even if cleanup fails + resolve(); + return; } console.log(`Cleanup done: ${stdout}`); resolve(); @@ -99,26 +227,84 @@ async function removeNodeModulesAndLockFiles(directory: string) { } /** - * Builds and runs a Docker container for the given projectPath. - * 1. Removes node_modules and lock files - * 2. Builds the Docker image - * 3. Runs the container with appropriate labels for Traefik + * Execute Docker command with timeout and retry logic + */ +function execWithTimeout( + command: string, + options: { timeout: number; retries?: number } = { + timeout: 60000, + retries: 2, + } +): Promise { + let retryCount = 0; + const maxRetries = options.retries || 0; + + const executeWithRetry = (): Promise => { + return new Promise((resolve, reject) => { + console.log(`Executing command: ${command}`); + exec(command, { timeout: options.timeout }, (error, stdout, stderr) => { + if (error) { + console.error(`Command execution error: ${stderr}`); + if (retryCount < maxRetries) { + retryCount++; + console.log(`Retry ${retryCount}/${maxRetries}`); + setTimeout(() => { + executeWithRetry().then(resolve).catch(reject); + }, 2000); // Wait 2 seconds before retry + } else { + reject(new Error(`${error.message}\n${stderr}`)); + } + } else { + resolve(stdout.trim()); + } + }); + }); + }; + + return executeWithRetry(); +} + +/** + * Build and run Docker container */ async function buildAndRunDocker( projectPath: string -): Promise<{ domain: string; containerId: string }> { +): Promise<{ domain: string; containerId: string; port: number }> { const traefikDomain = process.env.TRAEFIK_DOMAIN || 'docker.localhost'; - // Check if a container is already running for this project + // Check for existing container const existingContainerId = await checkExistingContainer(projectPath); if (existingContainerId) { - const subdomain = projectPath.replace(/[^\w-]/g, '').toLowerCase(); - const domain = `${subdomain}.${traefikDomain}`; - runningContainers.set(projectPath, { - domain, - containerId: existingContainerId, - }); - return { domain, containerId: existingContainerId }; + // Verify container is running + const isRunning = await checkContainerRunning(existingContainerId); + if (isRunning) { + const subdomain = projectPath.replace(/[^\w-]/g, '').toLowerCase(); + const domain = `${subdomain}.${traefikDomain}`; + const containerInfo = runningContainers.get(projectPath); + const port = containerInfo?.port || 0; + + // Update container state + runningContainers.set(projectPath, { + domain, + containerId: existingContainerId, + port, + timestamp: Date.now(), + }); + await saveState(); + + return { domain, containerId: existingContainerId, port }; + } + + // If container is no longer running, try to remove it + try { + await execWithTimeout(`docker rm -f ${existingContainerId}`, { + timeout: 30000, + }); + console.log(`Removed non-running container: ${existingContainerId}`); + } catch (error) { + console.error(`Error removing non-running container:`, error); + // Continue processing even if removal fails + } } const directory = path.join(getProjectPath(projectPath), 'frontend'); @@ -126,131 +312,151 @@ async function buildAndRunDocker( const imageName = subdomain; const containerName = `container-${subdomain}`; const domain = `${subdomain}.${traefikDomain}`; + + // Allocate port const exposedPort = await findAvailablePort(); - // 1. Remove node_modules and lock files - await removeNodeModulesAndLockFiles(directory); + // Remove node_modules and lock files + try { + await removeNodeModulesAndLockFiles(directory); + } catch (error) { + console.error( + 'Error during cleanup phase, but will continue with build:', + error + ); + } - return new Promise((resolve, reject) => { - // 2. Build the Docker image + try { + // Check if a container with the same name already exists, remove it if found + try { + await execWithTimeout(`docker inspect ${containerName}`, { + timeout: 10000, + }); + console.log( + `Found container with same name ${containerName}, removing it first` + ); + await execWithTimeout(`docker rm -f ${containerName}`, { + timeout: 20000, + }); + } catch (error) { + // If container doesn't exist, this will error out which is expected + } + + // Build Docker image console.log( `Starting Docker build for image: ${imageName} in directory: ${directory}` ); - exec( + await execWithTimeout( `docker build -t ${imageName} ${directory}`, - (buildErr, buildStdout, buildStderr) => { - if (buildErr) { - console.error(`Error during Docker build: ${buildStderr}`); - return reject(buildErr); - } + { timeout: 300000, retries: 1 } // 5 minutes timeout, 1 retry + ); - console.log(`Docker build output:\n${buildStdout}`); - console.log(`Running Docker container: ${containerName}`); - - // 3. Run the Docker container - let runCommand; - if (TLS) { - runCommand = `docker run -d --name ${containerName} -l "traefik.enable=true" \ - -l "traefik.http.routers.${subdomain}.rule=Host(\\"${domain}\\")" \ - -l "traefik.http.routers.${subdomain}.entrypoints=websecure" \ - -l "traefik.http.routers.${subdomain}.tls=true" \ - -l "traefik.http.services.${subdomain}.loadbalancer.server.port=5173" \ - --network=docker_traefik_network -p ${exposedPort}:5173 \ - -v "${directory}:/app" \ - ${imageName}`; - } else { - runCommand = `docker run -d --name ${containerName} -l "traefik.enable=true" \ - -l "traefik.http.routers.${subdomain}.rule=Host(\\"${domain}\\")" \ - -l "traefik.http.routers.${subdomain}.entrypoints=web" \ - -l "traefik.http.services.${subdomain}.loadbalancer.server.port=5173" \ - --network=docker_traefik_network -p ${exposedPort}:5173 \ - -v "${directory}:/app" \ - ${imageName}`; - } + // Determine whether to use TLS or non-TLS configuration + const TLS = process.env.TLS === 'true'; - console.log(`Executing run command: ${runCommand}`); - - exec(runCommand, (runErr, runStdout, runStderr) => { - if (runErr) { - // If the container name already exists - console.error(`Error during Docker run: ${runStderr}`); - if (runStderr.includes('Conflict. The container name')) { - console.log( - `Container name conflict detected. Removing existing container ${containerName}.` - ); - // Remove the existing container - exec( - `docker rm -f ${containerName}`, - (removeErr, removeStdout, removeStderr) => { - if (removeErr) { - console.error( - `Error removing existing container: ${removeStderr}` - ); - return reject(removeErr); - } - console.log( - `Existing container ${containerName} removed. Retrying to run the container.` - ); - - // Retry running the Docker container - exec( - runCommand, - (retryRunErr, retryRunStdout, retryRunStderr) => { - if (retryRunErr) { - console.error( - `Error during Docker run: ${retryRunStderr}` - ); - return reject(retryRunErr); - } - - const containerActualId = retryRunStdout.trim(); - runningContainers.set(projectPath, { - domain, - containerId: containerActualId, - }); - - console.log( - `Container ${containerName} is now running at http://${domain}` - ); - resolve({ domain, containerId: containerActualId }); - } - ); - } - ); - return; - } - console.error(`Error during Docker run: ${runStderr}`); - return reject(runErr); - } + // Configure Docker run command + let runCommand; + if (TLS) { + runCommand = `docker run -d --name ${containerName} -l "traefik.enable=true" \ + -l "traefik.http.routers.${subdomain}.rule=Host(\\"${domain}\\")" \ + -l "traefik.http.routers.${subdomain}.entrypoints=websecure" \ + -l "traefik.http.routers.${subdomain}.tls=true" \ + -l "traefik.http.services.${subdomain}.loadbalancer.server.port=5173" \ + -l "traefik.http.middlewares.${subdomain}-cors.headers.accessControlAllowOriginList=*" \ + -l "traefik.http.middlewares.${subdomain}-cors.headers.accessControlAllowMethods=GET,POST,PUT,DELETE,OPTIONS" \ + -l "traefik.http.middlewares.${subdomain}-cors.headers.accessControlAllowHeaders=*" \ + -l "traefik.http.routers.${subdomain}.middlewares=${subdomain}-cors" \ + --network=docker_traefik_network -p ${exposedPort}:5173 \ + -v "${directory}:/app" \ + ${imageName}`; + } else { + runCommand = `docker run -d --name ${containerName} -l "traefik.enable=true" \ + -l "traefik.http.routers.${subdomain}.rule=Host(\\"${domain}\\")" \ + -l "traefik.http.routers.${subdomain}.entrypoints=web" \ + -l "traefik.http.services.${subdomain}.loadbalancer.server.port=5173" \ + -l "traefik.http.middlewares.${subdomain}-cors.headers.accessControlAllowOriginList=*" \ + -l "traefik.http.middlewares.${subdomain}-cors.headers.accessControlAllowMethods=GET,POST,PUT,DELETE,OPTIONS" \ + -l "traefik.http.middlewares.${subdomain}-cors.headers.accessControlAllowHeaders=*" \ + -l "traefik.http.routers.${subdomain}.middlewares=${subdomain}-cors" \ + --network=docker_traefik_network -p ${exposedPort}:5173 \ + -v "${directory}:/app" \ + ${imageName}`; + } - const containerActualId = runStdout.trim(); - runningContainers.set(projectPath, { - domain, - containerId: containerActualId, - }); + // Run container + console.log(`Executing run command: ${runCommand}`); + const containerActualId = await execWithTimeout( + runCommand, + { timeout: 60000, retries: 2 } // 1 minute timeout, 2 retries + ); - console.log( - `Container ${containerName} is now running at http://${domain}` - ); - resolve({ domain, containerId: containerActualId }); - }); - } + // Verify container started successfully + const containerStatus = await execWithTimeout( + `docker inspect -f "{{.State.Running}}" ${containerActualId}`, + { timeout: 10000 } ); - }); + + if (containerStatus !== 'true') { + throw new Error(`Container failed to start, status: ${containerStatus}`); + } + + // Update container state + runningContainers.set(projectPath, { + domain, + containerId: containerActualId, + port: exposedPort, + timestamp: Date.now(), + }); + await saveState(); + + console.log( + `Container ${containerName} is now running at ${URL_PROTOCOL_PREFIX}://${domain} (port: ${exposedPort})` + ); + return { domain, containerId: containerActualId, port: exposedPort }; + } catch (error: any) { + console.error(`Error building or running container:`, error); + + // Clean up allocated port + allocatedPorts.delete(exposedPort); + await saveState(); + + throw error; + } } -/** - * A set to track projects currently being processed, - * preventing duplicate builds for the same project. - */ -const processingRequests = new Set(); +// Initialize state when service starts +initializeState().catch((error) => { + console.error('Error initializing state:', error); +}); + +// Periodically check container status (hourly) +setInterval( + async () => { + const projectPaths = Array.from(runningContainers.keys()); + + for (const projectPath of projectPaths) { + const container = runningContainers.get(projectPath); + if (!container) continue; + + const isRunning = await checkContainerRunning(container.containerId); + if (!isRunning) { + console.log( + `Container ${container.containerId} is no longer running, removing from state` + ); + runningContainers.delete(projectPath); + if (container.port) { + allocatedPorts.delete(container.port); + } + } + } + + await saveState(); + }, + 60 * 60 * 1000 +); /** - * GET handler for starting a Docker container. - * - Checks if projectPath is provided - * - Checks if a container is already running for that project - * - If not, triggers buildAndRunDocker - * - Returns the domain and containerId + * GET request handler for starting a Docker container */ export async function GET(req: Request) { const { searchParams } = new URL(req.url); @@ -266,37 +472,27 @@ export async function GET(req: Request) { // Check if a container is already running const existingContainer = runningContainers.get(projectPath); if (existingContainer) { - // Check if the container is running - const containerStatus = await new Promise((resolve) => { - exec( - `docker inspect -f "{{.State.Running}}" ${existingContainer.containerId}`, - (err, stdout) => { - if (err) { - resolve('not found'); - } else { - resolve(stdout.trim()); - } - } - ); - }); - - if (containerStatus === 'true') { + // Verify container is still running + const isRunning = await checkContainerRunning( + existingContainer.containerId + ); + if (isRunning) { return NextResponse.json({ message: 'Docker container already running', domain: existingContainer.domain, containerId: existingContainer.containerId, }); } else { - // Remove the existing container if it's not running - exec(`docker rm -f ${existingContainer.containerId}`, (removeErr) => { - if (removeErr) { - console.error(`Error removing existing container: ${removeErr}`); - } else { - console.log( - `Removed existing container: ${existingContainer.containerId}` - ); - } - }); + // Remove non-running container from state + runningContainers.delete(projectPath); + if (existingContainer.port) { + allocatedPorts.delete(existingContainer.port); + } + await saveState(); + + console.log( + `Container ${existingContainer.containerId} is no longer running, will create a new one` + ); } } @@ -319,6 +515,7 @@ export async function GET(req: Request) { containerId, }); } catch (error: any) { + console.error(`Failed to start Docker container:`, error); return NextResponse.json( { error: error.message || 'Failed to start Docker container' }, { status: 500 } diff --git a/frontend/src/app/api/screenshot/route.ts b/frontend/src/app/api/screenshot/route.ts index ed7a6441..642945c3 100644 --- a/frontend/src/app/api/screenshot/route.ts +++ b/frontend/src/app/api/screenshot/route.ts @@ -1,10 +1,26 @@ -import { randomUUID } from 'crypto'; import { NextResponse } from 'next/server'; -import puppeteer from 'puppeteer'; +import puppeteer, { Browser } from 'puppeteer'; + +// Global browser instance that will be reused across requests +let browserInstance: Browser | null = null; + +// Function to get browser instance +async function getBrowser(): Promise { + if (!browserInstance || !browserInstance.isConnected()) { + console.log('Creating new browser instance...'); + browserInstance = await puppeteer.launch({ + headless: true, + protocolTimeout: 240000, + args: ['--no-sandbox', '--disable-setuid-sandbox'], + }); + } + return browserInstance; +} export async function GET(req: Request) { const { searchParams } = new URL(req.url); const url = searchParams.get('url'); + let page = null; if (!url) { return NextResponse.json( @@ -14,10 +30,11 @@ export async function GET(req: Request) { } try { - const browser = await puppeteer.launch({ - headless: true, - }); - const page = await browser.newPage(); + // Get browser instance + const browser = await getBrowser(); + + // Create a new page + page = await browser.newPage(); // Set viewport to a reasonable size await page.setViewport({ @@ -25,19 +42,22 @@ export async function GET(req: Request) { height: 720, }); + // Navigate to URL with increased timeout and more reliable wait condition await page.goto(url, { - waitUntil: 'networkidle0', - timeout: 30000, + waitUntil: 'domcontentloaded', // Less strict than networkidle0 + timeout: 60000, // Increased timeout to 60 seconds }); // Take screenshot const screenshot = await page.screenshot({ - path: `dsadas.png`, type: 'png', - fullPage: true, + fullPage: false, }); - await browser.close(); + // Always close the page when done + if (page) { + await page.close(); + } // Return the screenshot as a PNG image return new Response(screenshot, { @@ -48,9 +68,45 @@ export async function GET(req: Request) { }); } catch (error: any) { console.error('Screenshot error:', error); + + // Ensure page is closed even if an error occurs + if (page) { + try { + await page.close(); + } catch (closeError) { + console.error('Error closing page:', closeError); + } + } + + // If browser seems to be in a bad state, recreate it + if ( + error.message.includes('Target closed') || + error.message.includes('Protocol error') || + error.message.includes('Target.createTarget') + ) { + try { + if (browserInstance) { + await browserInstance.close(); + browserInstance = null; + } + } catch (closeBrowserError) { + console.error('Error closing browser:', closeBrowserError); + } + } + return NextResponse.json( { error: error.message || 'Failed to capture screenshot' }, { status: 500 } ); } } + +// Handle process termination to close browser +process.on('SIGINT', async () => { + if (browserInstance) { + console.log('Closing browser instance...'); + await browserInstance.close(); + browserInstance = null; + } + process.exit(0); +}); diff --git a/frontend/src/components/chat/code-engine/code-engine.tsx b/frontend/src/components/chat/code-engine/code-engine.tsx index 64f732e7..efcff2d7 100644 --- a/frontend/src/components/chat/code-engine/code-engine.tsx +++ b/frontend/src/components/chat/code-engine/code-engine.tsx @@ -1,24 +1,14 @@ 'use client'; -import { Button } from '@/components/ui/button'; -import Editor from '@monaco-editor/react'; -import { ExclamationTriangleIcon } from '@radix-ui/react-icons'; -import { motion, AnimatePresence } from 'framer-motion'; -import { - Code as CodeIcon, - Copy, - Eye, - GitFork, - Loader, - Share2, - Terminal, -} from 'lucide-react'; -import { useTheme } from 'next-themes'; import { useContext, useEffect, useRef, useState } from 'react'; +import { motion, AnimatePresence } from 'framer-motion'; +import { Loader } from 'lucide-react'; import { TreeItem, TreeItemIndex } from 'react-complex-tree'; -import FileExplorerButton from './file-explorer-button'; -import FileStructure from './file-structure'; import { ProjectContext } from './project-context'; -import WebPreview from './web-view'; +import CodeTab from './tabs/code-tab'; +import PreviewTab from './tabs/preview-tab'; +import ConsoleTab from './tabs/console-tab'; +import ResponsiveToolbar from './responsive-toolbar'; +import SaveChangesBar from './save-changes-bar'; export function CodeEngine({ chatId, @@ -29,88 +19,176 @@ export function CodeEngine({ isProjectReady?: boolean; projectId?: string; }) { - // Initialize state, refs, and context - const editorRef = useRef(null); - const { curProject, filePath } = useContext(ProjectContext); - const [preCode, setPrecode] = useState('// some comment'); - const [newCode, setCode] = useState('// some comment'); + const { curProject, projectLoading, pollChatProject } = + useContext(ProjectContext); + const [localProject, setLocalProject] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [filePath, setFilePath] = useState(null); const [saving, setSaving] = useState(false); - const [type, setType] = useState('javascript'); - const [isLoading, setIsLoading] = useState(false); - const [isExplorerCollapsed, setIsExplorerCollapsed] = useState(false); + const [preCode, setPrecode] = useState('// Loading...'); + const [newCode, setCode] = useState('// Loading...'); + const [activeTab, setActiveTab] = useState<'preview' | 'code' | 'console'>( + 'code' + ); + const [isFileStructureLoading, setIsFileStructureLoading] = useState(false); const [fileStructureData, setFileStructureData] = useState< Record> >({}); - const theme = useTheme(); - const [activeTab, setActiveTab] = useState<'preview' | 'code' | 'console'>( - 'code' - ); + const editorRef = useRef(null); + const projectPathRef = useRef(null); - // Callback: Handle editor mount - const handleEditorMount = (editorInstance) => { - editorRef.current = editorInstance; - // Set the editor DOM node's position for layout control - editorInstance.getDomNode().style.position = 'absolute'; - }; + // Poll for project if needed using chatId + useEffect(() => { + if (!curProject && chatId && !projectLoading) { + const loadProjectFromChat = async () => { + try { + setIsLoading(true); + const project = await pollChatProject(chatId); + if (project) { + setLocalProject(project); + } + } catch (error) { + console.error('Failed to load project from chat:', error); + } finally { + setIsLoading(false); + } + }; + + loadProjectFromChat(); + } else { + setIsLoading(projectLoading); + } + }, [chatId, curProject, projectLoading, pollChatProject]); - // Effect: Fetch file content when filePath or projectId changes + // Use either curProject from context or locally polled project + const activeProject = curProject || localProject; + + // Update projectPathRef when project changes useEffect(() => { - async function getCode() { - if (!curProject || !filePath) return; + if (activeProject?.projectPath) { + projectPathRef.current = activeProject.projectPath; + } + }, [activeProject]); - const file_node = fileStructureData[`root/${filePath}`]; - if (filePath == '' || !file_node) return; - const isFolder = file_node.isFolder; - if (isFolder) return; - try { - setIsLoading(true); - const res = await fetch( - `/api/file?path=${encodeURIComponent(`${curProject.projectPath}/${filePath}`)}` - ).then((res) => res.json()); - setCode(res.content); - setPrecode(res.content); - setType(res.type); - setIsLoading(false); - } catch (error: any) { - console.error(error.message); + async function fetchFiles() { + const projectPath = activeProject?.projectPath || projectPathRef.current; + if (!projectPath) { + return; + } + + try { + setIsFileStructureLoading(true); + const response = await fetch(`/api/project?path=${projectPath}`); + if (!response.ok) { + throw new Error(`Failed to fetch file structure: ${response.status}`); } + const data = await response.json(); + if (data && data.res) { + setFileStructureData(data.res); + } else { + console.warn('Empty or invalid file structure data received'); + } + } catch (error) { + console.error('Error fetching file structure:', error); + } finally { + setIsFileStructureLoading(false); } - getCode(); - }, [filePath, curProject, fileStructureData]); + } + + // Effect for loading file structure when project is ready + useEffect(() => { + const shouldFetchFiles = + isProjectReady && + (activeProject?.projectPath || projectPathRef.current) && + Object.keys(fileStructureData).length === 0 && + !isFileStructureLoading; - // Effect: Fetch file structure when projectId changes + if (shouldFetchFiles) { + fetchFiles(); + } + }, [ + isProjectReady, + activeProject, + isFileStructureLoading, + fileStructureData, + ]); + + // Effect for selecting default file once structure is loaded useEffect(() => { - async function fetchFiles() { - if (!curProject?.projectPath) { - console.log('no project path found'); + if ( + !isFileStructureLoading && + Object.keys(fileStructureData).length > 0 && + !filePath + ) { + selectDefaultFile(); + } + }, [isFileStructureLoading, fileStructureData, filePath]); + + // Retry mechanism for fetching files if needed + useEffect(() => { + let retryTimeout; + + if ( + isProjectReady && + activeProject?.projectPath && + Object.keys(fileStructureData).length === 0 && + !isFileStructureLoading + ) { + retryTimeout = setTimeout(() => { + console.log('Retrying file structure fetch...'); + fetchFiles(); + }, 3000); + } + + return () => { + if (retryTimeout) clearTimeout(retryTimeout); + }; + }, [ + isProjectReady, + activeProject, + fileStructureData, + isFileStructureLoading, + ]); + + function selectDefaultFile() { + const defaultFiles = [ + 'src/App.tsx', + 'src/App.js', + 'src/index.tsx', + 'src/index.js', + 'app/page.tsx', + 'pages/index.tsx', + 'index.html', + 'README.md', + ]; + + for (const defaultFile of defaultFiles) { + if (fileStructureData[`root/${defaultFile}`]) { + setFilePath(defaultFile); return; } + } - try { - const response = await fetch( - `/api/project?path=${curProject.projectPath}` - ); - console.log('loading file structure'); - const data = await response.json(); - setFileStructureData(data.res || {}); - } catch (error) { - console.error('Error fetching file structure:', error); - } + const firstFile = Object.entries(fileStructureData).find( + ([key, item]) => + key.startsWith('root/') && !item.isFolder && key !== 'root/' + ); + + if (firstFile) { + setFilePath(firstFile[0].replace('root/', '')); } - fetchFiles(); - }, [curProject?.projectPath]); + } - // Reset code to previous state and update editor const handleReset = () => { setCode(preCode); editorRef.current?.setValue(preCode); setSaving(false); }; - // Update file content on the server const updateCode = async (value) => { - if (!curProject) return; + const projectPath = activeProject?.projectPath || projectPathRef.current; + if (!projectPath || !filePath) return; try { const response = await fetch('/api/file', { @@ -118,170 +196,103 @@ export function CodeEngine({ credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ - filePath: `${curProject.projectPath}/${filePath}`, + filePath: `${projectPath}/${filePath}`, newContent: JSON.stringify(value), }), }); + + if (!response.ok) { + throw new Error(`Failed to update file: ${response.status}`); + } + await response.json(); } catch (error) { - console.error(error); + console.error('Error updating file:', error); } }; - // Save the new code and update the previous state const handleSave = () => { setSaving(false); setPrecode(newCode); updateCode(newCode); }; - // Update code in state and mark as saving const updateSavingStatus = (value) => { setCode(value); setSaving(true); }; - // Responsive toolbar component for header tabs and buttons - const ResponsiveToolbar = ({ isLoading }: { isLoading: boolean }) => { - const containerRef = useRef(null); - const [containerWidth, setContainerWidth] = useState(700); - const [visibleTabs, setVisibleTabs] = useState(3); - const [compactIcons, setCompactIcons] = useState(false); - - // Observe container width changes - useEffect(() => { - const observer = new ResizeObserver((entries) => { - for (const entry of entries) { - setContainerWidth(entry.contentRect.width); + const renderTabContent = () => { + switch (activeTab) { + case 'code': + return ( + + ); + case 'preview': + return ; + case 'console': + return ; + default: + return null; + } + }; + + useEffect(() => { + async function getCode() { + const projectPath = activeProject?.projectPath || projectPathRef.current; + if (!projectPath || !filePath) return; + + const file_node = fileStructureData[`root/${filePath}`]; + if (!file_node) return; + + const isFolder = file_node.isFolder; + if (isFolder) return; + + try { + const res = await fetch( + `/api/file?path=${encodeURIComponent(`${projectPath}/${filePath}`)}` + ); + + if (!res.ok) { + throw new Error(`Failed to fetch file content: ${res.status}`); } - }); - if (containerRef.current) { - observer.observe(containerRef.current); - } - return () => observer.disconnect(); - }, []); - - // Adjust visible tabs and icon style based on container width - useEffect(() => { - if (containerWidth > 650) { - setVisibleTabs(3); - setCompactIcons(false); - } else if (containerWidth > 550) { - setVisibleTabs(2); - setCompactIcons(false); - } else if (containerWidth > 450) { - setVisibleTabs(1); - setCompactIcons(true); - } else { - setVisibleTabs(0); - setCompactIcons(true); + const data = await res.json(); + setCode(data.content); + setPrecode(data.content); + } catch (error) { + console.error('Error loading file content:', error); } - }, [containerWidth]); - - return ( -
-
- - {visibleTabs >= 2 && ( - - )} - {visibleTabs >= 3 && ( - - )} -
- -
-
- - - -
-
- {!compactIcons && ( - <> - - - - )} - {compactIcons && ( - - )} -
-
-
- ); - }; + } + + getCode(); + }, [filePath, activeProject, fileStructureData]); + + // Determine if we're truly ready to render + const showLoader = + !isProjectReady || + isLoading || + (!activeProject?.projectPath && !projectPathRef.current && !localProject); - // Render the CodeEngine layout return (
- {/* Header Bar */} - + - {/* Main Content Area with Loading */}
- {!isProjectReady && ( + {showLoader && (

- Initializing project... + {projectLoading + ? 'Loading project...' + : 'Initializing project...'}

)}
-
- {activeTab === 'code' ? ( - <> - {/* File Explorer Panel (collapsible) */} - - - -
- -
- - ) : activeTab === 'preview' ? ( -
- -
- ) : activeTab === 'console' ? ( -
Console Content (Mock)
- ) : null} -
- - {/* Save Changes Bar */} - {saving && ( - - )} +
{renderTabContent()}
- {/* File Explorer Toggle Button */} - {activeTab === 'code' && ( - - )} + {saving && }
); } -// SaveChangesBar component for showing unsaved changes status -const SaveChangesBar = ({ saving, onSave, onReset }) => { - return ( - saving && ( -
- - Unsaved Changes - - -
- ) - ); -}; +export default CodeEngine; diff --git a/frontend/src/components/chat/code-engine/file-structure.tsx b/frontend/src/components/chat/code-engine/file-structure.tsx index a84d68fc..dcddb982 100644 --- a/frontend/src/components/chat/code-engine/file-structure.tsx +++ b/frontend/src/components/chat/code-engine/file-structure.tsx @@ -1,6 +1,5 @@ 'use client'; - -import { useContext, useEffect, useState } from 'react'; +import { useEffect, useState } from 'react'; import { StaticTreeDataProvider, Tree, @@ -9,94 +8,152 @@ import { UncontrolledTreeEnvironment, } from 'react-complex-tree'; import 'react-complex-tree/lib/style-modern.css'; -import { ProjectContext } from './project-context'; +import { Loader } from 'lucide-react'; export interface FileNodeType { name: string; type: 'file' | 'folder'; children?: FileNodeType[]; } -export default function FileStructure({ - filePath, - data, -}: { + +interface FileStructureProps { filePath: string; data: Record>; -}) { - const { setFilePath } = useContext(ProjectContext); + isLoading?: boolean; + onFileSelect?: (path: string | null) => void; +} - const [dataProvider, setDataprovider] = useState( +export default function FileStructure({ + filePath, + data, + isLoading = false, + onFileSelect, +}: FileStructureProps) { + const [dataProvider, setDataProvider] = useState( new StaticTreeDataProvider(data, (item, newName) => ({ ...item, data: newName, })) ); + + // 判断是否显示加载状态 + const isEmpty = Object.keys(data).length === 0; + const showLoading = isLoading || isEmpty; + + // 当数据变化时更新数据提供者 useEffect(() => { - setDataprovider( - new StaticTreeDataProvider(data, (item, newName) => ({ - ...item, - data: newName, - })) - ); - }, [data]); + if (!isEmpty) { + setDataProvider( + new StaticTreeDataProvider(data, (item, newName) => ({ + ...item, + data: newName, + })) + ); + } + }, [data, isEmpty]); + + // 处理选择文件事件 + const handleSelectItems = (items) => { + if (items.length > 0) { + const newPath = items[0].toString().replace(/^root\//, ''); + const selectedItem = data[items[0]]; + + // 只有当选择的是文件时才设置文件路径 + if (selectedItem && !selectedItem.isFolder) { + onFileSelect?.(newPath); + } + } + }; + + // 根据文件路径获取要展开的文件夹 + const getExpandedFolders = () => { + if (!filePath) return ['root']; + + const parts = filePath.split('/'); + const expandedFolders = ['root']; + + // 逐级构建路径 + for (let i = 0; i < parts.length - 1; i++) { + const folderPath = parts.slice(0, i + 1).join('/'); + expandedFolders.push(`root/${folderPath}`); + } + + return expandedFolders; + }; return (

File Explorer

- {filePath &&
{filePath}
} + {filePath && ( +
+ Current file: {filePath} +
+ )} - item.data} - viewState={{}} - onSelectItems={(items) => { - setFilePath(items[0].toString().replace(/^root\//, '')); - }} - renderItem={({ item, depth, children, title, context, arrow }) => { - const InteractiveComponent = context.isRenaming ? 'div' : 'button'; - const type = context.isRenaming ? undefined : 'button'; - return ( -
  • -
    + +

    Loading files...

    +
    + ) : ( + item.data} + viewState={{ + // 展开包含当前文件的目录 + ['fileTree']: { + expandedItems: getExpandedFolders(), + }, + }} + onSelectItems={handleSelectItems} + renderItem={({ item, depth, children, title, context, arrow }) => { + const InteractiveComponent = context.isRenaming ? 'div' : 'button'; + const type = context.isRenaming ? undefined : 'button'; + return ( +
  • - {arrow} - - {title} - -
  • - {children} - - ); - }} - > - - + {arrow} + + {title} + + + {children} + + ); + }} + > + + + )} ); } diff --git a/frontend/src/components/chat/code-engine/project-context.tsx b/frontend/src/components/chat/code-engine/project-context.tsx index dcef750d..b2f0e4b4 100644 --- a/frontend/src/components/chat/code-engine/project-context.tsx +++ b/frontend/src/components/chat/code-engine/project-context.tsx @@ -18,7 +18,7 @@ import { } from '@/graphql/request'; import { Project } from '../project-modal'; import { useRouter } from 'next/navigation'; -import { toast } from 'sonner'; // Assuming you use Sonner for toasts +import { toast } from 'sonner'; import { useAuthContext } from '@/providers/AuthProvider'; import { URL_PROTOCOL_PREFIX } from '@/utils/const'; @@ -27,11 +27,11 @@ export interface ProjectContextType { setProjects: React.Dispatch>; curProject: Project | undefined; setCurProject: React.Dispatch>; + projectLoading: boolean; // 新增字段 filePath: string | null; setFilePath: React.Dispatch>; createNewProject: (projectName: string, description: string) => Promise; createProjectFromPrompt: ( - // TODO(Sma1lboy): should adding packages prompt: string, isPublic: boolean, model?: string @@ -47,113 +47,396 @@ export interface ProjectContextType { projectPath: string ) => Promise<{ domain: string; containerId: string }>; takeProjectScreenshot: (projectId: string, url: string) => Promise; + refreshProjects: () => Promise; } export const ProjectContext = createContext( undefined ); -const checkUrlStatus = async (url: string) => { - let status = 0; - while (status !== 200) { + +/** + * Utility function to check if a URL is accessible + * @param url URL to check + * @param maxRetries Maximum number of retries + * @param delayMs Delay between retries in milliseconds + */ +const checkUrlStatus = async ( + url: string, + maxRetries = 30, + delayMs = 1000 +): Promise => { + let retries = 0; + + while (retries < maxRetries) { try { - const res = await fetch(url, { method: 'HEAD' }); - status = res.status; - if (status !== 200) { - console.log(`URL status: ${status}. Retrying...`); - await new Promise((resolve) => setTimeout(resolve, 1000)); + const res = await fetch(url, { + method: 'HEAD', + // Add shorter timeout to avoid long waits + signal: AbortSignal.timeout(5000), + }); + + if (res.status === 200) { + return true; } + + console.log( + `URL status: ${res.status}. Retry ${retries + 1}/${maxRetries}...` + ); + retries++; + await new Promise((resolve) => setTimeout(resolve, delayMs)); } catch (err) { console.error('Error checking URL status:', err); - await new Promise((resolve) => setTimeout(resolve, 1000)); + retries++; + await new Promise((resolve) => setTimeout(resolve, delayMs)); } } + + return false; // Return false after max retries }; + export function ProjectProvider({ children }: { children: ReactNode }) { const router = useRouter(); const { isAuthorized } = useAuthContext(); const [projects, setProjects] = useState([]); const [curProject, setCurProject] = useState(undefined); + const [projectLoading, setProjectLoading] = useState(true); const [filePath, setFilePath] = useState(null); const [isLoading, setIsLoading] = useState(false); - const chatProjectCache = useRef>(new Map()); - const MAX_RETRIES = 100; - // Effect to clean up cache on unmount + interface ChatProjectCacheEntry { + project: Project | null; + timestamp: number; + retryCount?: number; + } + + interface ProjectSyncState { + lastSyncTime: number; + syncInProgress: boolean; + lastError?: Error; + } + + // Use maps with timestamps for better cache management + const chatProjectCache = useRef>( + new Map() + ); + const pendingOperations = useRef>(new Map()); + const projectSyncState = useRef({ + lastSyncTime: 0, + syncInProgress: false, + }); + + const MAX_RETRIES = 30; + const CACHE_TTL = 5 * 60 * 1000; // 5 minutes TTL for cache + const SYNC_DEBOUNCE_TIME = 1000; // 1 second debounce for sync operations + + // Mounted ref to prevent state updates after unmount + const isMounted = useRef(true); + useEffect(() => { return () => { + isMounted.current = false; chatProjectCache.current.clear(); + pendingOperations.current.clear(); }; }, []); - // Effect to restore current project state if needed + // Function to clean expired cache entries + const cleanCache = useCallback(() => { + const now = Date.now(); + for (const [key, value] of chatProjectCache.current.entries()) { + if (now - value.timestamp > CACHE_TTL) { + chatProjectCache.current.delete(key); + } + } + }, [CACHE_TTL]); + + // Periodically clean the cache useEffect(() => { - if (projects.length > 0 && !curProject) { + const intervalId = setInterval(cleanCache, 60000); // Clean every minute + return () => clearInterval(intervalId); + }, [cleanCache]); + + // Project state synchronization function + const syncProjectState = useCallback(async () => { + if (!isMounted.current || projectSyncState.current.syncInProgress) return; + + const now = Date.now(); + if (now - projectSyncState.current.lastSyncTime < SYNC_DEBOUNCE_TIME) { + return; + } + + try { + projectSyncState.current.syncInProgress = true; const lastProjectId = localStorage.getItem('lastProjectId'); - if (lastProjectId) { - const project = projects.find((p) => p.id === lastProjectId); - if (project) { - setCurProject(project); + + if (projects.length > 0) { + if (curProject) { + const updatedProject = projects.find((p) => p.id === curProject.id); + if (updatedProject) { + if (JSON.stringify(updatedProject) !== JSON.stringify(curProject)) { + setCurProject(updatedProject); + projectSyncState.current.lastSyncTime = now; + } + } else { + const fallbackProject = lastProjectId + ? projects.find((p) => p.id === lastProjectId) + : projects[0]; + if (fallbackProject) { + setCurProject(fallbackProject); + projectSyncState.current.lastSyncTime = now; + } + } + } else if (lastProjectId) { + const savedProject = projects.find((p) => p.id === lastProjectId); + if (savedProject) { + setCurProject(savedProject); + projectSyncState.current.lastSyncTime = now; + } + } + + // Persist current project id if valid + if (curProject?.id && projects.some((p) => p.id === curProject.id)) { + localStorage.setItem('lastProjectId', curProject.id); } } + } catch (error) { + projectSyncState.current.lastError = error as Error; + console.error('Error syncing project state:', error); + } finally { + projectSyncState.current.syncInProgress = false; } }, [projects, curProject]); - // Effect to save current project id + // Enhanced initial loading for projects and curProject useEffect(() => { - if (curProject?.id) { + if (!isAuthorized) { + setProjectLoading(false); + return; + } + + // Try to get last project ID from localStorage + const lastProjectId = localStorage.getItem('lastProjectId'); + + // Load initial project data + const loadInitialData = async () => { + try { + setProjectLoading(true); + const result = await refetch(); + + if (result.data?.getUserProjects) { + const projectsList = result.data.getUserProjects; + setProjects(projectsList); + + // Find last active project if exists + if (lastProjectId) { + const savedProject = projectsList.find( + (p) => p.id === lastProjectId + ); + if (savedProject) { + setCurProject(savedProject); + // If we're on a specific project page, ensure localStorage is updated + const urlParams = new URLSearchParams(window.location.search); + const urlProjectId = urlParams.get('id'); + if (urlProjectId && urlProjectId !== lastProjectId) { + const urlProject = projectsList.find( + (p) => p.id === urlProjectId + ); + if (urlProject) { + setCurProject(urlProject); + localStorage.setItem('lastProjectId', urlProjectId); + } + } + } else if (projectsList.length > 0) { + // Fallback to first project if saved project not found + setCurProject(projectsList[0]); + localStorage.setItem('lastProjectId', projectsList[0].id); + } + } else if (projectsList.length > 0) { + // No last project, set to first if available + setCurProject(projectsList[0]); + localStorage.setItem('lastProjectId', projectsList[0].id); + } + } + } catch (error) { + console.error('Error loading initial project data:', error); + toast.error('Failed to load projects. Please refresh the page.'); + } finally { + setProjectLoading(false); + } + }; + + loadInitialData(); + }, [isAuthorized]); + + // Initialization and update effects + useEffect(() => { + const syncInterval = setInterval(() => { + if (isMounted.current && !projectSyncState.current.syncInProgress) { + syncProjectState(); + } + }, 30000); // Sync every 30 seconds + + return () => clearInterval(syncInterval); + }, [syncProjectState]); + + // Check URL for project ID on navigation/initial load + useEffect(() => { + if (!isAuthorized || projectLoading || projects.length === 0) return; + + const checkUrlForProject = () => { + try { + // Get project ID from URL if present + const urlParams = new URLSearchParams(window.location.search); + const urlProjectId = urlParams.get('id'); + + if (urlProjectId) { + const urlProject = projects.find((p) => p.id === urlProjectId); + if (urlProject && (!curProject || curProject.id !== urlProjectId)) { + setCurProject(urlProject); + localStorage.setItem('lastProjectId', urlProjectId); + } + } + } catch (error) { + console.error('Error checking URL for project:', error); + } + }; + + checkUrlForProject(); + // Listen for route changes + window.addEventListener('popstate', checkUrlForProject); + + return () => { + window.removeEventListener('popstate', checkUrlForProject); + }; + }, [isAuthorized, projectLoading, projects, curProject]); + + // Persist current project id with validation + useEffect(() => { + if (curProject?.id && projects.some((p) => p.id === curProject.id)) { localStorage.setItem('lastProjectId', curProject.id); } - }, [curProject?.id]); + }, [curProject?.id, projects]); - const { loading, error, refetch } = useQuery(GET_USER_PROJECTS, { + // Project data fetching with sync + const { refetch } = useQuery(GET_USER_PROJECTS, { fetchPolicy: 'network-only', skip: !isAuthorized, onCompleted: (data) => { + if (!isMounted.current) return; + setProjects(data.getUserProjects); - // If we have a current project in the list, update it - if (curProject) { - const updatedProject = data.getUserProjects.find( - (p) => p.id === curProject.id - ); - if ( - updatedProject && - JSON.stringify(updatedProject) !== JSON.stringify(curProject) - ) { - setCurProject(updatedProject); - } + + // Trigger state sync after data update + const now = Date.now(); + if (now - projectSyncState.current.lastSyncTime >= SYNC_DEBOUNCE_TIME) { + syncProjectState().catch((error) => { + console.error('Error during project sync:', error); + projectSyncState.current.lastError = error as Error; + }); } }, onError: (error) => { console.error('Error fetching projects:', error); - // Retry after 5 seconds on error - setTimeout(refetch, 5000); + projectSyncState.current.lastError = error; + + if (isMounted.current) { + toast.error('Failed to fetch projects. Retrying...'); + setTimeout(async () => { + if (isMounted.current && !projectSyncState.current.syncInProgress) { + try { + await refetch(); + } catch (retryError) { + console.error('Retry failed:', retryError); + } + } + }, 5000); + } }, }); + // Enhanced refresh function with sync and error handling + const refreshProjects = useCallback(async () => { + if (projectSyncState.current.syncInProgress) { + console.debug('Refresh skipped - sync in progress'); + return; + } + + try { + projectSyncState.current.syncInProgress = true; + await refetch(); + + // Reset error state on successful refresh + projectSyncState.current.lastError = undefined; + + // Trigger state sync if enough time has passed + const now = Date.now(); + if (now - projectSyncState.current.lastSyncTime >= SYNC_DEBOUNCE_TIME) { + await syncProjectState(); + } + } catch (error) { + console.error('Error refreshing projects:', error); + if (isMounted.current) { + projectSyncState.current.lastError = error as Error; + toast.error('Failed to refresh projects'); + } + } finally { + projectSyncState.current.syncInProgress = false; + } + }, [refetch, syncProjectState, SYNC_DEBOUNCE_TIME]); + + // Auto-refresh setup + useEffect(() => { + if (!isAuthorized) return; + + const refreshInterval = setInterval(() => { + if (isMounted.current && !projectSyncState.current.syncInProgress) { + refreshProjects().catch((error) => { + console.error('Auto-refresh failed:', error); + }); + } + }, 60000); // Auto-refresh every minute + + return () => clearInterval(refreshInterval); + }, [refreshProjects, isAuthorized]); + // Create project mutation const [createProject] = useMutation(CREATE_PROJECT, { onCompleted: (data) => { + if (!isMounted.current) return; + // Navigate to chat page after project creation if (data?.createProject?.id) { toast.success('Project created successfully!'); router.push(`/chat?id=${data.createProject.id}`); + + // Refresh the projects list + refreshProjects(); } }, onError: (error) => { - toast.error(`Failed to create project: ${error.message}`); + if (isMounted.current) { + toast.error(`Failed to create project: ${error.message}`); + } }, }); // Fork project mutation const [forkProjectMutation] = useMutation(FORK_PROJECT, { onCompleted: (data) => { + if (!isMounted.current) return; + if (data?.forkProject?.id) { toast.success('Project forked successfully!'); router.push(`/chat/${data.forkProject.id}`); + + // Refresh the projects list + refreshProjects(); } }, onError: (error) => { - toast.error(`Failed to fork project: ${error.message}`); + if (isMounted.current) { + toast.error(`Failed to fork project: ${error.message}`); + } }, }); @@ -162,6 +445,8 @@ export function ProjectProvider({ children }: { children: ReactNode }) { UPDATE_PROJECT_PUBLIC_STATUS, { onCompleted: (data) => { + if (!isMounted.current) return; + toast.success( `Project visibility updated to ${data.updateProjectPublicStatus.isPublic ? 'public' : 'private'}` ); @@ -191,13 +476,17 @@ export function ProjectProvider({ children }: { children: ReactNode }) { } }, onError: (error) => { - toast.error(`Failed to update project visibility: ${error.message}`); + if (isMounted.current) { + toast.error(`Failed to update project visibility: ${error.message}`); + } }, } ); const [updateProjectPhotoMutation] = useMutation(UPDATE_PROJECT_PHOTO_URL, { onCompleted: (data) => { + if (!isMounted.current) return; + // Update projects list setProjects((prev) => prev.map((project) => @@ -223,45 +512,83 @@ export function ProjectProvider({ children }: { children: ReactNode }) { } }, onError: (error) => { - toast.error(`Failed to update project photo: ${error.message}`); + if (isMounted.current) { + toast.error(`Failed to update project photo: ${error.message}`); + } }, }); const takeProjectScreenshot = useCallback( - async (projectId: string, url: string) => { + async (projectId: string, url: string): Promise => { + // Check if this screenshot operation is already in progress + const operationKey = `screenshot_${projectId}`; + if (pendingOperations.current.get(operationKey)) { + return; + } + + pendingOperations.current.set(operationKey, true); + try { - await checkUrlStatus(url); + // Check if the URL is accessible + const isUrlAccessible = await checkUrlStatus(url); + if (!isUrlAccessible) { + console.warn(`URL ${url} is not accessible after multiple retries`); + return; + } - const screenshotResponse = await fetch( - `/api/screenshot?url=${encodeURIComponent(url)}` - ); + // Add a cache buster to avoid previous screenshot caching + const screenshotUrl = `/api/screenshot?url=${encodeURIComponent(url)}&t=${Date.now()}`; + const screenshotResponse = await fetch(screenshotUrl); if (!screenshotResponse.ok) { - throw new Error('Failed to capture screenshot'); + throw new Error( + `Failed to capture screenshot: ${screenshotResponse.status} ${screenshotResponse.statusText}` + ); } const arrayBuffer = await screenshotResponse.arrayBuffer(); const blob = new Blob([arrayBuffer], { type: 'image/png' }); - const file = new File([blob], 'screenshot.png', { type: 'image/png' }); - await updateProjectPhotoMutation({ - variables: { - input: { - projectId, - file, + if (isMounted.current) { + await updateProjectPhotoMutation({ + variables: { + input: { + projectId, + file, + }, }, - }, - }); + }); + } } catch (error) { - console.error('Error:', error); + console.error('Error taking screenshot:', error); + } finally { + pendingOperations.current.delete(operationKey); } }, [updateProjectPhotoMutation] ); const getWebUrl = useCallback( - async (projectPath: string) => { + async ( + projectPath: string + ): Promise<{ domain: string; containerId: string }> => { + // Check if this operation is already in progress + const operationKey = `getWebUrl_${projectPath}`; + if (pendingOperations.current.get(operationKey)) { + // Wait for operation to complete + await new Promise((resolve) => { + const checkInterval = setInterval(() => { + if (!pendingOperations.current.get(operationKey)) { + clearInterval(checkInterval); + resolve(true); + } + }, 500); + }); + } + + pendingOperations.current.set(operationKey, true); + try { const response = await fetch( `/api/runProject?projectPath=${encodeURIComponent(projectPath)}`, @@ -274,14 +601,28 @@ export function ProjectProvider({ children }: { children: ReactNode }) { ); if (!response.ok) { - throw new Error('Failed to get web URL'); + throw new Error( + `Failed to get web URL: ${response.status} ${response.statusText}` + ); } const data = await response.json(); + + if (!data.domain || !data.containerId) { + throw new Error( + 'Invalid response from API: missing domain or containerId' + ); + } + const baseUrl = `${URL_PROTOCOL_PREFIX}://${data.domain}`; + + // Find project and take screenshot if needed const project = projects.find((p) => p.projectPath === projectPath); if (project) { - await takeProjectScreenshot(project.id, baseUrl); + // Don't await this - let it run in background + takeProjectScreenshot(project.id, baseUrl).catch((err) => + console.error('Background screenshot error:', err) + ); } return { @@ -290,10 +631,15 @@ export function ProjectProvider({ children }: { children: ReactNode }) { }; } catch (error) { console.error('Error getting web URL:', error); + if (isMounted.current) { + toast.error('Failed to prepare web preview'); + } throw error; + } finally { + pendingOperations.current.delete(operationKey); } }, - [projects, updateProjectPhotoMutation] + [projects, takeProjectScreenshot] ); const [getChatDetail] = useLazyQuery(GET_CHAT_DETAILS, { @@ -302,14 +648,19 @@ export function ProjectProvider({ children }: { children: ReactNode }) { // Original createNewProject function const createNewProject = useCallback( - async (projectName: string, description: string) => { + async (projectName: string, description: string): Promise => { if (!projectName || !description) { - toast.error('Please fill in all fields!'); + if (isMounted.current) { + toast.error('Please fill in all fields!'); + } return; } try { - setIsLoading(true); + if (isMounted.current) { + setIsLoading(true); + } + await createProject({ variables: { createProjectInput: { @@ -323,8 +674,13 @@ export function ProjectProvider({ children }: { children: ReactNode }) { }); } catch (err) { console.error('Failed to create project:', err); + if (isMounted.current) { + toast.error('An error occurred while creating the project'); + } } finally { - setIsLoading(false); + if (isMounted.current) { + setIsLoading(false); + } } }, [createProject] @@ -338,12 +694,16 @@ export function ProjectProvider({ children }: { children: ReactNode }) { model = 'gpt-4o-mini' ): Promise => { if (!prompt.trim()) { - toast.error('Please enter a project description'); + if (isMounted.current) { + toast.error('Please enter a project description'); + } return false; } try { - setIsLoading(true); + if (isMounted.current) { + setIsLoading(true); + } // Default packages based on typical web project needs const defaultPackages = [ @@ -366,9 +726,14 @@ export function ProjectProvider({ children }: { children: ReactNode }) { return !!result.data?.createProject; } catch (error) { console.error('Error creating project:', error); + if (isMounted.current) { + toast.error('Failed to create project from prompt'); + } return false; } finally { - setIsLoading(false); + if (isMounted.current) { + setIsLoading(false); + } } }, [createProject] @@ -376,9 +741,12 @@ export function ProjectProvider({ children }: { children: ReactNode }) { // New function to fork a project const forkProject = useCallback( - async (projectId: string) => { + async (projectId: string): Promise => { try { - setIsLoading(true); + if (isMounted.current) { + setIsLoading(true); + } + await forkProjectMutation({ variables: { projectId, @@ -386,8 +754,13 @@ export function ProjectProvider({ children }: { children: ReactNode }) { }); } catch (error) { console.error('Error forking project:', error); + if (isMounted.current) { + toast.error('Failed to fork project'); + } } finally { - setIsLoading(false); + if (isMounted.current) { + setIsLoading(false); + } } }, [forkProjectMutation] @@ -395,7 +768,14 @@ export function ProjectProvider({ children }: { children: ReactNode }) { // Function to update project public status const setProjectPublicStatus = useCallback( - async (projectId: string, isPublic: boolean) => { + async (projectId: string, isPublic: boolean): Promise => { + const operationKey = `publicStatus_${projectId}`; + if (pendingOperations.current.get(operationKey)) { + return; + } + + pendingOperations.current.set(operationKey, true); + try { await updateProjectPublicStatusMutation({ variables: { @@ -405,6 +785,11 @@ export function ProjectProvider({ children }: { children: ReactNode }) { }); } catch (error) { console.error('Error updating project visibility:', error); + if (isMounted.current) { + toast.error('Failed to update project visibility'); + } + } finally { + pendingOperations.current.delete(operationKey); } }, [updateProjectPublicStatusMutation] @@ -412,55 +797,108 @@ export function ProjectProvider({ children }: { children: ReactNode }) { const pollChatProject = useCallback( async (chatId: string): Promise => { - if (chatProjectCache.current.has(chatId)) { - return chatProjectCache.current.get(chatId) || null; + // Check cache first (with validity) + const cachedData = chatProjectCache.current.get(chatId); + if (cachedData) { + const now = Date.now(); + if (now - cachedData.timestamp < CACHE_TTL) { + return cachedData.project; + } } - let retries = 0; - while (retries < MAX_RETRIES) { - try { - console.log('testing ' + chatId); - const { data } = await getChatDetail({ variables: { chatId } }); + // Check if this poll operation is already in progress + const operationKey = `poll_${chatId}`; + if (pendingOperations.current.get(operationKey)) { + // Wait for any pending operation to complete + let retries = 0; + while (pendingOperations.current.get(operationKey) && retries < 10) { + await new Promise((resolve) => setTimeout(resolve, 500)); + retries++; + } - if (data?.getChatDetails?.project) { - const project = data.getChatDetails.project; - chatProjectCache.current.set(chatId, project); + const currentTime = Date.now(); + const updatedCache = chatProjectCache.current.get(chatId); + if (updatedCache && currentTime - updatedCache.timestamp < CACHE_TTL) { + return updatedCache.project; + } + } - try { - // Get web URL and wait for it to be ready - const response = await fetch( - `/api/runProject?projectPath=${encodeURIComponent(project.projectPath)}`, - { - method: 'GET', - headers: { - 'Content-Type': 'application/json', - }, - } - ); + if (projectSyncState.current.syncInProgress) { + console.debug('Poll skipped - sync in progress'); + return cachedData?.project ?? null; + } - if (response.ok) { - const data = await response.json(); - const baseUrl = `${URL_PROTOCOL_PREFIX}://${data.domain}`; - await takeProjectScreenshot(project.id, baseUrl); + pendingOperations.current.set(operationKey, true); + let retries = 0; + + try { + while (retries < MAX_RETRIES) { + try { + const { data } = await getChatDetail({ variables: { chatId } }); + + if (data?.getChatDetails?.project) { + const project = data.getChatDetails.project; + const now = Date.now(); + + // Update cache with timestamp and retry count + chatProjectCache.current.set(chatId, { + project, + timestamp: now, + retryCount: retries, + }); + + // Trigger state sync if needed + if ( + now - projectSyncState.current.lastSyncTime >= + SYNC_DEBOUNCE_TIME + ) { + syncProjectState().catch((error) => { + console.warn('Background sync failed:', error); + }); + } + + // Try to get web URL in background + if (isMounted.current && project.projectPath) { + getWebUrl(project.projectPath).catch((error) => { + console.warn('Background web URL fetch failed:', error); + }); } - } catch (error) { - console.error('Error capturing project screenshot:', error); - } - return project; + return project; + } + } catch (error) { + console.error( + `Error polling chat (attempt ${retries + 1}/${MAX_RETRIES}):`, + error + ); + projectSyncState.current.lastError = error as Error; } - } catch (error) { - console.error('Error polling chat:', error); + + if (!isMounted.current) return null; + await new Promise((resolve) => setTimeout(resolve, 6000)); + retries++; } - await new Promise((resolve) => setTimeout(resolve, 6000)); - retries++; - } + // Cache the null result with retry info + chatProjectCache.current.set(chatId, { + project: null, + timestamp: Date.now(), + retryCount: retries, + }); - chatProjectCache.current.set(chatId, null); - return null; + return null; + } finally { + pendingOperations.current.delete(operationKey); + } }, - [getChatDetail, updateProjectPhotoMutation] + [ + getChatDetail, + getWebUrl, + syncProjectState, + MAX_RETRIES, + CACHE_TTL, + SYNC_DEBOUNCE_TIME, + ] ); const contextValue = useMemo( @@ -469,6 +907,7 @@ export function ProjectProvider({ children }: { children: ReactNode }) { setProjects, curProject, setCurProject, + projectLoading, filePath, setFilePath, createNewProject, @@ -479,10 +918,12 @@ export function ProjectProvider({ children }: { children: ReactNode }) { isLoading, getWebUrl, takeProjectScreenshot, + refreshProjects, }), [ projects, curProject, + projectLoading, filePath, createNewProject, createProjectFromPrompt, @@ -491,6 +932,8 @@ export function ProjectProvider({ children }: { children: ReactNode }) { pollChatProject, isLoading, getWebUrl, + takeProjectScreenshot, + refreshProjects, ] ); diff --git a/frontend/src/components/chat/code-engine/responsive-toolbar.tsx b/frontend/src/components/chat/code-engine/responsive-toolbar.tsx new file mode 100644 index 00000000..46161cac --- /dev/null +++ b/frontend/src/components/chat/code-engine/responsive-toolbar.tsx @@ -0,0 +1,153 @@ +'use client'; +import { useEffect, useRef, useState } from 'react'; +import { Button } from '@/components/ui/button'; +import { + Code as CodeIcon, + Copy, + Eye, + GitFork, + Share2, + Terminal, +} from 'lucide-react'; + +interface ResponsiveToolbarProps { + isLoading: boolean; + activeTab: 'preview' | 'code' | 'console'; + setActiveTab: (tab: 'preview' | 'code' | 'console') => void; +} + +const ResponsiveToolbar = ({ + isLoading, + activeTab, + setActiveTab, +}: ResponsiveToolbarProps) => { + const containerRef = useRef(null); + const [containerWidth, setContainerWidth] = useState(700); + const [visibleTabs, setVisibleTabs] = useState(3); + const [compactIcons, setCompactIcons] = useState(false); + + // Observe container width changes + useEffect(() => { + const observer = new ResizeObserver((entries) => { + for (const entry of entries) { + setContainerWidth(entry.contentRect.width); + } + }); + + if (containerRef.current) { + observer.observe(containerRef.current); + } + return () => observer.disconnect(); + }, []); + + // Adjust visible tabs and icon style based on container width + useEffect(() => { + if (containerWidth > 650) { + setVisibleTabs(3); + setCompactIcons(false); + } else if (containerWidth > 550) { + setVisibleTabs(2); + setCompactIcons(false); + } else if (containerWidth > 450) { + setVisibleTabs(1); + setCompactIcons(true); + } else { + setVisibleTabs(0); + setCompactIcons(true); + } + }, [containerWidth]); + + return ( +
    +
    + + {visibleTabs >= 2 && ( + + )} + {visibleTabs >= 3 && ( + + )} +
    + +
    +
    + + + +
    +
    + {!compactIcons && ( + <> + + + + )} + {compactIcons && ( + + )} +
    +
    +
    + ); +}; + +export default ResponsiveToolbar; diff --git a/frontend/src/components/chat/code-engine/save-changes-bar.tsx b/frontend/src/components/chat/code-engine/save-changes-bar.tsx new file mode 100644 index 00000000..556c58c9 --- /dev/null +++ b/frontend/src/components/chat/code-engine/save-changes-bar.tsx @@ -0,0 +1,33 @@ +'use client'; +import { Button } from '@/components/ui/button'; +import { ExclamationTriangleIcon } from '@radix-ui/react-icons'; + +interface SaveChangesBarProps { + onSave: () => void; + onReset: () => void; +} + +const SaveChangesBar = ({ onSave, onReset }: SaveChangesBarProps) => { + return ( +
    + + Unsaved Changes + + +
    + ); +}; + +export default SaveChangesBar; diff --git a/frontend/src/components/chat/code-engine/tabs/code-tab.tsx b/frontend/src/components/chat/code-engine/tabs/code-tab.tsx new file mode 100644 index 00000000..344a6140 --- /dev/null +++ b/frontend/src/components/chat/code-engine/tabs/code-tab.tsx @@ -0,0 +1,135 @@ +'use client'; +import { + useState, + useEffect, + useContext, + useRef, + MutableRefObject, +} from 'react'; +import { motion } from 'framer-motion'; +import Editor from '@monaco-editor/react'; +import { useTheme } from 'next-themes'; +import { TreeItem, TreeItemIndex } from 'react-complex-tree'; +import { ProjectContext } from '../project-context'; +import FileExplorerButton from '../file-explorer-button'; +import FileStructure from '../file-structure'; + +interface CodeTabProps { + editorRef: MutableRefObject; + fileStructureData: Record>; + newCode: string; + isFileStructureLoading: boolean; + updateSavingStatus: (value: string) => void; + filePath: string | null; + setFilePath: (path: string | null) => void; +} + +const CodeTab = ({ + editorRef, + fileStructureData, + newCode, + isFileStructureLoading, + updateSavingStatus, + filePath, + setFilePath, +}: CodeTabProps) => { + const theme = useTheme(); + const [isExplorerCollapsed, setIsExplorerCollapsed] = useState(false); + const [isLoading] = useState(false); + const [type, setType] = useState('javascript'); + + useEffect(() => { + if (filePath) { + const extension = filePath.split('.').pop()?.toLowerCase(); + switch (extension) { + case 'js': + setType('javascript'); + break; + case 'ts': + setType('typescript'); + break; + case 'jsx': + case 'tsx': + setType('typescriptreact'); + break; + case 'html': + setType('html'); + break; + case 'css': + setType('css'); + break; + case 'json': + setType('json'); + break; + case 'md': + setType('markdown'); + break; + default: + setType('plaintext'); + } + } + }, [filePath]); + + // Handle editor mount + const handleEditorMount = (editorInstance) => { + editorRef.current = editorInstance; + editorInstance.getDomNode().style.position = 'absolute'; + }; + + return ( + <> + {/* File Explorer Panel (collapsible) */} + + + + + {/* Code Editor */} +
    + +
    + + {/* File Explorer Toggle Button */} + + + ); +}; + +export default CodeTab; diff --git a/frontend/src/components/chat/code-engine/tabs/console-tab.tsx b/frontend/src/components/chat/code-engine/tabs/console-tab.tsx new file mode 100644 index 00000000..a2fcfd4f --- /dev/null +++ b/frontend/src/components/chat/code-engine/tabs/console-tab.tsx @@ -0,0 +1,7 @@ +'use client'; + +const ConsoleTab = () => { + return
    Console Content (Mock)
    ; +}; + +export default ConsoleTab; diff --git a/frontend/src/components/chat/code-engine/tabs/preview-tab.tsx b/frontend/src/components/chat/code-engine/tabs/preview-tab.tsx new file mode 100644 index 00000000..fa9690ef --- /dev/null +++ b/frontend/src/components/chat/code-engine/tabs/preview-tab.tsx @@ -0,0 +1,12 @@ +'use client'; +import WebPreview from '../web-view'; + +const PreviewTab = () => { + return ( +
    + +
    + ); +}; + +export default PreviewTab; diff --git a/frontend/src/components/chat/code-engine/web-view.tsx b/frontend/src/components/chat/code-engine/web-view.tsx index a0d97122..d40f30c1 100644 --- a/frontend/src/components/chat/code-engine/web-view.tsx +++ b/frontend/src/components/chat/code-engine/web-view.tsx @@ -11,7 +11,6 @@ import { ZoomIn, ZoomOut, } from 'lucide-react'; -import puppeteer from 'puppeteer'; import { URL_PROTOCOL_PREFIX } from '@/utils/const'; function PreviewContent({ @@ -132,6 +131,10 @@ function PreviewContent({ setScale((prevScale) => Math.max(prevScale - 0.1, 0.5)); // 最小缩放比例为 0.5 }; + // print all stat + console.log('baseUrl outside:', baseUrl); + console.log('current project: ', curProject); + return (
    {/* URL Bar */} @@ -246,9 +249,9 @@ function PreviewContent({ } export default function WebPreview() { - const context = useContext(ProjectContext); + const { curProject, getWebUrl } = useContext(ProjectContext); - if (!context) { + if (!curProject || !getWebUrl) { return (

    Loading project...

    @@ -256,10 +259,5 @@ export default function WebPreview() { ); } - return ( - - ); + return ; } diff --git a/frontend/src/components/chat/index.tsx b/frontend/src/components/chat/index.tsx index 4ed551c8..b17f102f 100644 --- a/frontend/src/components/chat/index.tsx +++ b/frontend/src/components/chat/index.tsx @@ -110,8 +110,8 @@ export default function Chat() { key="with-chat" > @@ -131,7 +131,7 @@ export default function Chat() { - {project.name} { setShowSidebar(isAuthorized); }, [isAuthorized]); return ( - -
    - {showSidebar && ( - - {}} - isCollapsed={isCollapsed} - setIsCollapsed={setIsCollapsed} - isMobile={false} - currentChatId={''} - chatListUpdated={chatListUpdated} - setChatListUpdated={setChatListUpdated} - chats={chats} - loading={loading} - error={error} - onRefetch={refetchChats} - /> - - )} - -
    -
    {children}
    + + {showSidebar ? ( + {children} + ) : ( +
    +
    {children}
    -
    + )} ); } diff --git a/frontend/src/components/sidebar-item.tsx b/frontend/src/components/sidebar-item.tsx index d405dca3..a9810ec3 100644 --- a/frontend/src/components/sidebar-item.tsx +++ b/frontend/src/components/sidebar-item.tsx @@ -18,9 +18,8 @@ import { DELETE_CHAT } from '@/graphql/request'; import { cn } from '@/lib/utils'; import { useMutation } from '@apollo/client'; import { MoreHorizontal, Trash2 } from 'lucide-react'; -import Link from 'next/link'; import { useRouter } from 'next/navigation'; -import { useEffect, useState } from 'react'; +import { memo, useState } from 'react'; import { toast } from 'sonner'; import { EventEnum } from '../const/EventEnum'; @@ -32,7 +31,7 @@ interface SideBarItemProps { refetchChats: () => void; } -export function SideBarItem({ +function SideBarItemComponent({ id, currentChatId, title, @@ -41,27 +40,13 @@ export function SideBarItem({ }: SideBarItemProps) { const [isDropdownOpen, setIsDropdownOpen] = useState(false); const [isDialogOpen, setIsDialogOpen] = useState(false); - const [isSelected, setIsSelected] = useState(false); - const [variant, setVariant] = useState< - 'ghost' | 'link' | 'secondary' | 'default' | 'destructive' | 'outline' - >('ghost'); - useEffect(() => { - const selected = currentChatId === id; - setIsSelected(selected); - if (selected) { - setVariant('secondary'); // 类型安全 - } else { - setVariant('ghost'); // 类型安全 - } - refetchChats(); - console.log(`update sidebar ${currentChatId}`); - }, [currentChatId]); + const isSelected = currentChatId === id; + const variant = isSelected ? 'secondary' : 'ghost'; const [deleteChat] = useMutation(DELETE_CHAT, { onCompleted: () => { toast.success('Chat deleted successfully'); - console.log(`${id} ${isSelected}`); if (isSelected) { window.history.replaceState({}, '', '/'); const event = new Event(EventEnum.NEW_CHAT); @@ -119,7 +104,7 @@ export function SideBarItem({
    ); } + +export const SideBarItem = memo( + SideBarItemComponent, + (prevProps, nextProps) => { + return ( + prevProps.currentChatId === nextProps.currentChatId && + prevProps.id === nextProps.id && + prevProps.title === nextProps.title + ); + } +); diff --git a/frontend/src/components/sidebar.tsx b/frontend/src/components/sidebar.tsx index 8d22d661..b4f6f055 100644 --- a/frontend/src/components/sidebar.tsx +++ b/frontend/src/components/sidebar.tsx @@ -9,6 +9,7 @@ import { SideBarItem } from './sidebar-item'; import { Chat } from '@/graphql/type'; import { EventEnum } from '../const/EventEnum'; import { useRouter } from 'next/navigation'; +import { FixedSizeList } from 'react-window'; import { SidebarContent, @@ -18,8 +19,11 @@ import { Sidebar, SidebarRail, SidebarFooter, + useSidebar, } from './ui/sidebar'; import { ProjectContext } from './chat/code-engine/project-context'; +import { useChatList } from '@/hooks/useChatList'; +import { motion } from 'framer-motion'; interface SidebarProps { setIsModalOpen: (value: boolean) => void; @@ -35,7 +39,44 @@ interface SidebarProps { onRefetch: () => void; } -export function ChatSideBar({ +// Row renderer for react-window +const ChatRow = memo( + ({ index, style, data }: any) => { + const { chats, currentChatId, setCurProject, pollChatProject } = data; + const chat = chats[index]; + + const handleSelect = useCallback(() => { + setCurProject(null); + pollChatProject(chat.id).then((p) => { + setCurProject(p); + }); + }, [chat.id, setCurProject, pollChatProject]); + + return ( +
    + +
    + ); + }, + (prevProps, nextProps) => { + return ( + prevProps.data.chats[prevProps.index].id === + nextProps.data.chats[nextProps.index].id && + prevProps.data.currentChatId === nextProps.data.currentChatId + ); + } +); + +ChatRow.displayName = 'ChatRow'; + +function ChatSideBarComponent({ setIsModalOpen, isCollapsed, setIsCollapsed, @@ -54,6 +95,15 @@ export function ChatSideBar({ const event = new Event(EventEnum.NEW_CHAT); window.dispatchEvent(event); }, []); + + const handleChatSelect = useCallback( + (chatId: string) => { + router.push(`/chat?id=${chatId}`); + setCurrentChatid(chatId); + }, + [router] + ); + if (loading) return ; if (error) { console.error('Error loading chats:', error); @@ -75,8 +125,7 @@ export function ChatSideBar({ - {/* SidebarTrigger 保证在 CodeFox 按钮的中间 */} setIsCollapsed(!isCollapsed)} />
    @@ -109,7 +157,7 @@ export function ChatSideBar({ {/* Divider Line */}
    - {/* New Project 按钮 - 依然占据整行 */} + {/* New Project Button */}
    @@ -153,35 +201,33 @@ export function ChatSideBar({
    - {/* 聊天列表 */} + {/* Chat List with Virtualization */} - {loading - ? 'Loading...' - : !isCollapsed && - chats.map((chat) => ( - { - setCurProject(null); - pollChatProject(chat.id).then((p) => { - setCurProject(p); - }); - router.push(`/chat?id=${chat.id}`); - setCurrentChatid(chat.id); - }} - refetchChats={onRefetch} - /> - ))} + {!isCollapsed && chats.length > 0 && ( + + {ChatRow} + + )} - {/* 底部设置 */} + {/* Footer Settings */} @@ -197,13 +243,85 @@ export function ChatSideBar({ ); } -export default memo(ChatSideBar, (prevProps, nextProps) => { +// Optimized memo comparison +export const ChatSideBar = memo( + ChatSideBarComponent, + (prevProps, nextProps) => { + if (prevProps.isCollapsed !== nextProps.isCollapsed) return false; + if (prevProps.loading !== nextProps.loading) return false; + if (prevProps.error !== nextProps.error) return false; + if (prevProps.chats.length !== nextProps.chats.length) return false; + + // Only compare chat IDs instead of full objects + const prevIds = prevProps.chats.map((chat) => chat.id).join(','); + const nextIds = nextProps.chats.map((chat) => chat.id).join(','); + return prevIds === nextIds; + } +); + +ChatSideBar.displayName = 'ChatSideBar'; + +export function SidebarWrapper({ + children, + isAuthorized, +}: { + children: React.ReactNode; + isAuthorized: boolean; +}) { + const { state, setOpen } = useSidebar(); + const [isCollapsed, setIsCollapsed] = useState(state === 'collapsed'); + const { + chats, + loading, + error, + chatListUpdated, + setChatListUpdated, + refetchChats, + } = useChatList(); + + // When user collapses or expands the sidebar, update both local state and Sidebar context + const handleCollapsedChange = useCallback( + (collapsed: boolean) => { + setIsCollapsed(collapsed); + setOpen(!collapsed); + }, + [setOpen] + ); + return ( - prevProps.isCollapsed === nextProps.isCollapsed && - prevProps.isMobile === nextProps.isMobile && - prevProps.chatListUpdated === nextProps.chatListUpdated && - prevProps.loading === nextProps.loading && - prevProps.error === nextProps.error && - JSON.stringify(prevProps.chats) === JSON.stringify(nextProps.chats) +
    + {isAuthorized && ( + + {}} + isCollapsed={isCollapsed} + setIsCollapsed={handleCollapsedChange} + isMobile={false} + currentChatId={''} + chatListUpdated={chatListUpdated} + setChatListUpdated={setChatListUpdated} + chats={chats} + loading={loading} + error={error} + onRefetch={refetchChats} + /> + + )} +
    +
    {children}
    +
    +
    ); -}); +} diff --git a/frontend/src/utils/const.ts b/frontend/src/utils/const.ts index 00daabb2..0c1cc0c6 100644 --- a/frontend/src/utils/const.ts +++ b/frontend/src/utils/const.ts @@ -1,22 +1,11 @@ /** - * @description: API URL - * @type {string} - * @example 'https://api.example.com' - */ -/** - * Always use HTTPS when the page is loaded over HTTPS + * Validate if the current environment is using TLS */ -export const URL_PROTOCOL_PREFIX = - typeof window !== 'undefined' && window.location.protocol === 'https:' - ? 'https' - : process.env.TLS == 'false' - ? 'http' - : 'https'; +export const TLS = + (typeof window !== 'undefined' && window.location.protocol === 'https:') || + process.env.TLS === 'true'; /** - * Validate if the current environment is using TLS + * Always use HTTPS when the page is loaded over HTTPS */ -export const TLS = - typeof window !== 'undefined' && window.location.protocol === 'https:' - ? true - : process.env.TLS == 'true'; +export const URL_PROTOCOL_PREFIX = TLS ? 'https' : 'http'; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ef2e465e..b8784744 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -41,7 +41,7 @@ importers: version: 3.758.0 '@huggingface/hub': specifier: latest - version: 1.0.1 + version: 1.0.2 '@huggingface/transformers': specifier: latest version: 3.3.3 @@ -140,7 +140,7 @@ importers: version: 3.0.0 openai: specifier: ^4.77.0 - version: 4.86.1(ws@8.18.1)(zod@3.24.2) + version: 4.86.2(ws@8.18.1)(zod@3.24.2) p-queue-es5: specifier: ^6.0.2 version: 6.0.2 @@ -244,7 +244,7 @@ importers: dependencies: openai: specifier: ^4.0.0 - version: 4.86.1(ws@8.18.1)(zod@3.24.2) + version: 4.86.2(ws@8.18.1)(zod@3.24.2) devDependencies: '@nestjs/common': specifier: 10.4.15 @@ -330,7 +330,7 @@ importers: dependencies: '@apollo/client': specifier: ^3.11.8 - version: 3.13.1(@types/react@18.3.18)(graphql-ws@5.16.2)(graphql@16.10.0)(react-dom@18.3.1)(react@18.3.1)(subscriptions-transport-ws@0.11.0) + version: 3.13.2(@types/react@18.3.18)(graphql-ws@5.16.2)(graphql@16.10.0)(react-dom@18.3.1)(react@18.3.1)(subscriptions-transport-ws@0.11.0) '@emoji-mart/data': specifier: ^1.2.1 version: 1.2.1 @@ -342,10 +342,10 @@ importers: version: 3.10.0(react-hook-form@7.54.2) '@langchain/community': specifier: ^0.3.1 - version: 0.3.34(@browserbasehq/stagehand@1.14.0)(@ibm-cloud/watsonx-ai@1.5.1)(@langchain/core@0.3.42)(axios@1.7.9)(ibm-cloud-sdk-core@5.1.3)(openai@4.86.1)(puppeteer@24.3.1)(ws@8.18.1) + version: 0.3.34(@browserbasehq/stagehand@1.14.0)(@ibm-cloud/watsonx-ai@1.5.1)(@langchain/core@0.3.42)(axios@1.7.9)(ibm-cloud-sdk-core@5.2.0)(openai@4.86.2)(puppeteer@24.4.0)(ws@8.18.1) '@langchain/core': specifier: ^0.3.3 - version: 0.3.42(openai@4.86.1) + version: 0.3.42(openai@4.86.2) '@monaco-editor/react': specifier: ^4.6.0 version: 4.7.0(monaco-editor@0.52.2)(react-dom@18.3.1)(react@18.3.1) @@ -397,9 +397,12 @@ importers: '@types/dom-speech-recognition': specifier: ^0.0.4 version: 0.0.4 + '@types/react-window': + specifier: ^1.8.8 + version: 1.8.8 apollo-upload-client: specifier: ^18.0.0 - version: 18.0.1(@apollo/client@3.13.1)(graphql@16.10.0) + version: 18.0.1(@apollo/client@3.13.2)(graphql@16.10.0) class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -429,13 +432,13 @@ importers: version: 12.4.10(react-dom@18.3.1)(react@18.3.1) next: specifier: ^14.2.13 - version: 14.2.24(@babel/core@7.26.9)(@playwright/test@1.50.1)(react-dom@18.3.1)(react@18.3.1) + version: 14.2.24(@babel/core@7.26.9)(@playwright/test@1.51.0)(react-dom@18.3.1)(react@18.3.1) next-themes: specifier: ^0.3.0 version: 0.3.0(react-dom@18.3.1)(react@18.3.1) puppeteer: specifier: ^24.3.1 - version: 24.3.1(typescript@5.6.3) + version: 24.4.0(typescript@5.6.3) react: specifier: ^18.3.1 version: 18.3.1 @@ -466,6 +469,9 @@ importers: react-textarea-autosize: specifier: ^8.5.3 version: 8.5.7(@types/react@18.3.18)(react@18.3.1) + react-window: + specifier: ^1.8.11 + version: 1.8.11(react-dom@18.3.1)(react@18.3.1) remark-gfm: specifier: ^4.0.0 version: 4.0.1 @@ -659,7 +665,7 @@ importers: version: 29.7.0 openai: specifier: ^4.78.1 - version: 4.86.1(ws@8.18.1)(zod@3.24.2) + version: 4.86.2(ws@8.18.1)(zod@3.24.2) prettier: specifier: ^3.0.0 version: 3.5.3 @@ -1094,8 +1100,8 @@ packages: graphql: 16.10.0 dev: false - /@apollo/client@3.13.1(@types/react@18.3.18)(graphql-ws@5.16.2)(graphql@16.10.0)(react-dom@18.3.1)(react@18.3.1)(subscriptions-transport-ws@0.11.0): - resolution: {integrity: sha512-HaAt62h3jNUXpJ1v5HNgUiCzPP1c5zc2Q/FeTb2cTk/v09YlhoqKKHQFJI7St50VCJ5q8JVIc03I5bRcBrQxsg==} + /@apollo/client@3.13.2(@types/react@18.3.18)(graphql-ws@5.16.2)(graphql@16.10.0)(react-dom@18.3.1)(react@18.3.1)(subscriptions-transport-ws@0.11.0): + resolution: {integrity: sha512-czLeqQuRB3RqcpEWFTJ/wfT+povpLfGAsprP2i9rsKj5PkH94IrFaI7ETtTMwOrycWFw/MJX9HCFGBslB/MGNg==} peerDependencies: graphql: ^15.0.0 || ^16.0.0 graphql-ws: ^5.5.5 || ^6.0.3 @@ -3291,7 +3297,7 @@ packages: - encoding dev: false - /@browserbasehq/stagehand@1.14.0(@playwright/test@1.50.1)(deepmerge@4.3.1)(dotenv@16.4.7)(openai@4.86.1)(zod@3.24.2): + /@browserbasehq/stagehand@1.14.0(@playwright/test@1.51.0)(deepmerge@4.3.1)(dotenv@16.4.7)(openai@4.86.2)(zod@3.24.2): resolution: {integrity: sha512-Hi/EzgMFWz+FKyepxHTrqfTPjpsuBS4zRy3e9sbMpBgLPv+9c0R+YZEvS7Bw4mTS66QtvvURRT6zgDGFotthVQ==} peerDependencies: '@playwright/test': ^1.42.1 @@ -3302,10 +3308,10 @@ packages: dependencies: '@anthropic-ai/sdk': 0.27.3 '@browserbasehq/sdk': 2.3.0 - '@playwright/test': 1.50.1 + '@playwright/test': 1.51.0 deepmerge: 4.3.1 dotenv: 16.4.7 - openai: 4.86.1(ws@8.18.1)(zod@3.24.2) + openai: 4.86.2(ws@8.18.1)(zod@3.24.2) ws: 8.18.1 zod: 3.24.2 zod-to-json-schema: 3.24.3(zod@3.24.2) @@ -3996,7 +4002,7 @@ packages: postcss-loader: 7.3.4(postcss@8.5.3)(typescript@5.6.3)(webpack@5.98.0) postcss-preset-env: 10.1.5(postcss@8.5.3) react-dev-utils: 12.0.1(eslint@8.57.1)(typescript@5.6.3)(webpack@5.98.0) - terser-webpack-plugin: 5.3.13(webpack@5.98.0) + terser-webpack-plugin: 5.3.14(webpack@5.98.0) tslib: 2.8.1 url-loader: 4.1.1(file-loader@6.2.0)(webpack@5.98.0) webpack: 5.98.0(webpack-cli@5.1.4) @@ -4847,26 +4853,26 @@ packages: resolution: {integrity: sha512-KOEGMu6dmJZtpadb476IsZBclKvILjopjUii3V+7MnXIQCYh8W3NgNcgwo21n9LXZX6EDIKvqfjYxXebDwxKmQ==} dev: false - /@envelop/core@5.2.1: - resolution: {integrity: sha512-iH/GUc7WNEukD2TKszx+e5MWuCpUuVfc1eS4mSvDpQ2ROA2lCJGqac6PxAC3A171bLqNk7IWlNOXxJ4qUPU1WA==} + /@envelop/core@5.2.3: + resolution: {integrity: sha512-KfoGlYD/XXQSc3BkM1/k15+JQbkQ4ateHazeZoWl9P71FsLTDXSjGy6j7QqfhpIDSbxNISqhPMfZHYSbDFOofQ==} engines: {node: '>=18.0.0'} dependencies: - '@envelop/instruments': 1.0.0 - '@envelop/types': 5.2.0 + '@envelop/instrumentation': 1.0.0 + '@envelop/types': 5.2.1 '@whatwg-node/promise-helpers': 1.2.4 tslib: 2.8.1 dev: true - /@envelop/instruments@1.0.0: - resolution: {integrity: sha512-f4lHoti7QgUIluIGTM0mG9Wf9/w6zc1mosYmyFkrApeHSP2PIUC6a8fMoqkdk6pgVOps39kLdIhOPF8pIKS8/A==} + /@envelop/instrumentation@1.0.0: + resolution: {integrity: sha512-cxgkB66RQB95H3X27jlnxCRNTmPuSTgmBAq6/4n2Dtv4hsk4yz8FadA1ggmd0uZzvKqWD6CR+WFgTjhDqg7eyw==} engines: {node: '>=18.0.0'} dependencies: '@whatwg-node/promise-helpers': 1.2.4 tslib: 2.8.1 dev: true - /@envelop/types@5.2.0: - resolution: {integrity: sha512-vCJY6URc8bK1O6p4zVRFpv/ASdyXvLM+Iqn2HP44UfTgEUQLyN4buwLawlkAv/KtzAL7VOeefpF2eKPWk7rHjg==} + /@envelop/types@5.2.1: + resolution: {integrity: sha512-CsFmA3u3c2QoLDTfEpGr4t25fjMU31nyvse7IzWTvb0ZycuPjMjb0fjlheh+PbhBYb9YLugnT2uY6Mwcg1o+Zg==} engines: {node: '>=18.0.0'} dependencies: '@whatwg-node/promise-helpers': 1.2.4 @@ -5569,7 +5575,7 @@ packages: peerDependencies: graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 dependencies: - '@envelop/core': 5.2.1 + '@envelop/core': 5.2.3 '@graphql-tools/utils': 10.8.4(graphql@16.10.0) graphql: 16.10.0 dev: true @@ -6010,11 +6016,11 @@ packages: react-hook-form: 7.54.2(react@18.3.1) dev: false - /@huggingface/hub@1.0.1: - resolution: {integrity: sha512-wogGVETaNUV/wYBkny0uQD48L0rK9cttVtbaA1Rw/pGCuSYoZ8YlvTV6zymsGJfXaxQU8zup0aOR2XLIf6HVfg==} + /@huggingface/hub@1.0.2: + resolution: {integrity: sha512-O1suBM/WKEJ6Ad+Dp7eMtd+C2W6MCL3LTF/EjSKVqG9jTHjjozA/kpcRmd/XJh1ZgtLigx1zMtNeyhXI9B9RWA==} engines: {node: '>=18'} dependencies: - '@huggingface/tasks': 0.15.9 + '@huggingface/tasks': 0.17.1 dev: false /@huggingface/jinja@0.3.3: @@ -6022,8 +6028,8 @@ packages: engines: {node: '>=18'} dev: false - /@huggingface/tasks@0.15.9: - resolution: {integrity: sha512-cbnZcpMHKdhURWIplVP4obHxAZcxjyRm0zI7peTPksZN4CtIOMmJC4ZqGEymo0lk+0VNkXD7ULwFJ3JjT/VpkQ==} + /@huggingface/tasks@0.17.1: + resolution: {integrity: sha512-kN5F/pzwxtmdZ0jORumNyegNKOX/ciU5G/DMZcqK3SJShod4C6yfvBRCMn5sEDzanxtU8VjX+7TaInQFmmU8Nw==} dev: false /@huggingface/transformers@3.3.3: @@ -6061,7 +6067,7 @@ packages: '@langchain/textsplitters': 0.1.0(@langchain/core@0.3.42) '@types/node': 18.19.79 extend: 3.0.2 - ibm-cloud-sdk-core: 5.1.3 + ibm-cloud-sdk-core: 5.2.0 transitivePeerDependencies: - '@langchain/core' - supports-color @@ -6542,7 +6548,7 @@ packages: resolution: {integrity: sha512-GaHYm+c0O9MjZRu0ongGBRbinu8gVAMd2UZjji6jVmqKtZluZnptXGWhz1E8j8D2HJ3f/yMxKAUC0b+57wncIw==} dev: false - /@langchain/community@0.3.34(@browserbasehq/stagehand@1.14.0)(@ibm-cloud/watsonx-ai@1.5.1)(@langchain/core@0.3.42)(axios@1.7.9)(ibm-cloud-sdk-core@5.1.3)(openai@4.86.1)(puppeteer@24.3.1)(ws@8.18.1): + /@langchain/community@0.3.34(@browserbasehq/stagehand@1.14.0)(@ibm-cloud/watsonx-ai@1.5.1)(@langchain/core@0.3.42)(axios@1.7.9)(ibm-cloud-sdk-core@5.2.0)(openai@4.86.2)(puppeteer@24.4.0)(ws@8.18.1): resolution: {integrity: sha512-s0KVulgVIPd90s3m6XZtWrCRGQPWsY93uY62seFMmNhzcyF+I65kKnN04Nbiouthrn/YJ9HB4hW8MJAFuX6RRg==} engines: {node: '>=18'} peerDependencies: @@ -6916,19 +6922,19 @@ packages: youtubei.js: optional: true dependencies: - '@browserbasehq/stagehand': 1.14.0(@playwright/test@1.50.1)(deepmerge@4.3.1)(dotenv@16.4.7)(openai@4.86.1)(zod@3.24.2) + '@browserbasehq/stagehand': 1.14.0(@playwright/test@1.51.0)(deepmerge@4.3.1)(dotenv@16.4.7)(openai@4.86.2)(zod@3.24.2) '@ibm-cloud/watsonx-ai': 1.5.1(@langchain/core@0.3.42) - '@langchain/core': 0.3.42(openai@4.86.1) + '@langchain/core': 0.3.42(openai@4.86.2) '@langchain/openai': 0.4.4(@langchain/core@0.3.42)(ws@8.18.1) binary-extensions: 2.3.0 expr-eval: 2.0.2 flat: 5.0.2 - ibm-cloud-sdk-core: 5.1.3 + ibm-cloud-sdk-core: 5.2.0 js-yaml: 4.1.0 - langchain: 0.3.19(@langchain/core@0.3.42)(axios@1.7.9)(openai@4.86.1)(ws@8.18.1) - langsmith: 0.3.12(openai@4.86.1) - openai: 4.86.1(ws@8.18.1)(zod@3.24.2) - puppeteer: 24.3.1(typescript@5.6.3) + langchain: 0.3.19(@langchain/core@0.3.42)(axios@1.7.9)(openai@4.86.2)(ws@8.18.1) + langsmith: 0.3.12(openai@4.86.2) + openai: 4.86.2(ws@8.18.1)(zod@3.24.2) + puppeteer: 24.4.0(typescript@5.6.3) uuid: 10.0.0 ws: 8.18.1 zod: 3.24.2 @@ -6952,7 +6958,7 @@ packages: - peggy dev: false - /@langchain/core@0.3.42(openai@4.86.1): + /@langchain/core@0.3.42(openai@4.86.2): resolution: {integrity: sha512-pT/jC5lqWK3YGDq8dQwgKoa6anqAhMtG1x5JbnrOj9NdaLeBbCKBDQ+/Ykzk3nZ8o+0UMsaXNZo7IVL83VVjHg==} engines: {node: '>=18'} dependencies: @@ -6961,7 +6967,7 @@ packages: camelcase: 6.3.0 decamelize: 1.2.0 js-tiktoken: 1.0.19 - langsmith: 0.3.12(openai@4.86.1) + langsmith: 0.3.12(openai@4.86.2) mustache: 4.2.0 p-queue: 6.6.2 p-retry: 4.6.2 @@ -6978,9 +6984,9 @@ packages: peerDependencies: '@langchain/core': '>=0.3.39 <0.4.0' dependencies: - '@langchain/core': 0.3.42(openai@4.86.1) + '@langchain/core': 0.3.42(openai@4.86.2) js-tiktoken: 1.0.19 - openai: 4.86.1(ws@8.18.1)(zod@3.24.2) + openai: 4.86.2(ws@8.18.1)(zod@3.24.2) zod: 3.24.2 zod-to-json-schema: 3.24.3(zod@3.24.2) transitivePeerDependencies: @@ -6994,7 +7000,7 @@ packages: peerDependencies: '@langchain/core': '>=0.2.21 <0.4.0' dependencies: - '@langchain/core': 0.3.42(openai@4.86.1) + '@langchain/core': 0.3.42(openai@4.86.2) js-tiktoken: 1.0.19 dev: false @@ -8052,12 +8058,12 @@ packages: engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} dev: true - /@playwright/test@1.50.1: - resolution: {integrity: sha512-Jii3aBg+CEDpgnuDxEp/h7BimHcUTDlpEtce89xEumlJ5ef2hqepZ+PWp1DDpYC/VO9fmWVI1IlEaoI5fK9FXQ==} + /@playwright/test@1.51.0: + resolution: {integrity: sha512-dJ0dMbZeHhI+wb77+ljx/FeC8VBP6j/rj9OAojO08JI80wTZy6vRk9KvHKiDCUh4iMpEiseMgqRBIeW+eKX6RA==} engines: {node: '>=18'} hasBin: true dependencies: - playwright: 1.50.1 + playwright: 1.51.0 dev: false /@pnpm/config.env-replace@1.1.0: @@ -8128,8 +8134,8 @@ packages: resolution: {integrity: sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==} dev: false - /@puppeteer/browsers@2.7.1: - resolution: {integrity: sha512-MK7rtm8JjaxPN7Mf1JdZIZKPD2Z+W7osvrC1vjpvfOX1K0awDIHYbNi89f7eotp7eMUn2shWnt03HwVbriXtKQ==} + /@puppeteer/browsers@2.8.0: + resolution: {integrity: sha512-yTwt2KWRmCQAfhvbCRjebaSX8pV1//I0Y3g+A7f/eS7gf0l4eRJoUCvcYdVtboeU4CTOZQuqYbZNS8aBYb8ROQ==} engines: {node: '>=18'} hasBin: true dependencies: @@ -9818,7 +9824,7 @@ packages: /@types/apollo-upload-client@18.0.0(@types/react@18.3.18)(graphql-ws@5.16.2)(react-dom@18.3.1)(react@18.3.1)(subscriptions-transport-ws@0.11.0): resolution: {integrity: sha512-cMgITNemktxasqvp6jiPj15dv84n3FTMvMoYBP1+xonDS+0l6JygIJrj2LJh85rShRzTOOkrElrAsCXXARa3KA==} dependencies: - '@apollo/client': 3.13.1(@types/react@18.3.18)(graphql-ws@5.16.2)(graphql@16.10.0)(react-dom@18.3.1)(react@18.3.1)(subscriptions-transport-ws@0.11.0) + '@apollo/client': 3.13.2(@types/react@18.3.18)(graphql-ws@5.16.2)(graphql@16.10.0)(react-dom@18.3.1)(react@18.3.1)(subscriptions-transport-ws@0.11.0) '@types/extract-files': 13.0.1 graphql: 16.10.0 transitivePeerDependencies: @@ -10128,10 +10134,6 @@ packages: '@types/node': 20.17.23 dev: false - /@types/node@10.14.22: - resolution: {integrity: sha512-9taxKC944BqoTVjE+UT3pQH0nHZlTvITwfsOZqyc+R3sfJuxaTtxWjfn1K2UlxyPcKHf0rnaXcVFrS9F9vf0bw==} - dev: false - /@types/node@16.18.126: resolution: {integrity: sha512-OTcgaiwfGFBKacvfwuHzzn1KLxH/er8mluiy8/uM3sGXHaRe73RrSIj01jow9t4kJEW633Ov+cOexXeiApTyAw==} dev: true @@ -10207,6 +10209,12 @@ packages: '@types/history': 4.7.11 '@types/react': 18.3.18 + /@types/react-window@1.8.8: + resolution: {integrity: sha512-8Ls660bHR1AUA2kuRvVG9D/4XpRC6wjAaPT9dil7Ckc76eP9TKWZwwmgfq8Q1LANX3QNDnoU4Zp48A3w+zK69Q==} + dependencies: + '@types/react': 18.3.18 + dev: false + /@types/react@18.3.18: resolution: {integrity: sha512-t4yC+vtgnkYjNSKlFx1jkAhH8LgTo2N/7Qvi83kdEaUtMDiwpbLAktKDaAMlRcJ5eSxZkH74eEGt1ky31d7kfQ==} dependencies: @@ -11128,14 +11136,14 @@ packages: normalize-path: 3.0.0 picomatch: 2.3.1 - /apollo-upload-client@18.0.1(@apollo/client@3.13.1)(graphql@16.10.0): + /apollo-upload-client@18.0.1(@apollo/client@3.13.2)(graphql@16.10.0): resolution: {integrity: sha512-OQvZg1rK05VNI79D658FUmMdoI2oB/KJKb6QGMa2Si25QXOaAvLMBFUEwJct7wf+19U8vk9ILhidBOU1ZWv6QA==} engines: {node: ^18.15.0 || >=20.4.0} peerDependencies: '@apollo/client': ^3.8.0 graphql: 14 - 16 dependencies: - '@apollo/client': 3.13.1(@types/react@18.3.18)(graphql-ws@5.16.2)(graphql@16.10.0)(react-dom@18.3.1)(react@18.3.1)(subscriptions-transport-ws@0.11.0) + '@apollo/client': 3.13.2(@types/react@18.3.18)(graphql-ws@5.16.2)(graphql@16.10.0)(react-dom@18.3.1)(react@18.3.1)(subscriptions-transport-ws@0.11.0) extract-files: 13.0.0 graphql: 16.10.0 dev: false @@ -11809,7 +11817,7 @@ packages: hasBin: true dependencies: caniuse-lite: 1.0.30001702 - electron-to-chromium: 1.5.112 + electron-to-chromium: 1.5.113 node-releases: 2.0.19 update-browserslist-db: 1.1.3(browserslist@4.24.4) @@ -12170,12 +12178,12 @@ packages: resolution: {integrity: sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==} engines: {node: '>=6.0'} - /chromium-bidi@2.1.2(devtools-protocol@0.0.1402036): + /chromium-bidi@2.1.2(devtools-protocol@0.0.1413902): resolution: {integrity: sha512-vtRWBK2uImo5/W2oG6/cDkkHSm+2t6VHgnj+Rcwhb0pP74OoUb4GipyRX/T/y39gYQPhioP0DPShn+A7P6CHNw==} peerDependencies: devtools-protocol: '*' dependencies: - devtools-protocol: 0.0.1402036 + devtools-protocol: 0.0.1413902 mitt: 3.0.1 zod: 3.24.2 dev: false @@ -13360,8 +13368,8 @@ packages: dependencies: dequal: 2.0.3 - /devtools-protocol@0.0.1402036: - resolution: {integrity: sha512-JwAYQgEvm3yD45CHB+RmF5kMbWtXBaOGwuxa87sZogHcLCv8c/IqnThaoQ1y60d7pXWjSKWQphPEc+1rAScVdg==} + /devtools-protocol@0.0.1413902: + resolution: {integrity: sha512-yRtvFD8Oyk7C9Os3GmnFZLu53yAfsnyw1s+mLmHHUK0GQEc9zthHWvS1r67Zqzm5t7v56PILHIVZ7kmFMaL2yQ==} dev: false /dezalgo@1.0.4: @@ -13584,8 +13592,8 @@ packages: dependencies: jake: 10.9.2 - /electron-to-chromium@1.5.112: - resolution: {integrity: sha512-oen93kVyqSb3l+ziUgzIOlWt/oOuy4zRmpwestMn4rhFWAoFJeFuCVte9F2fASjeZZo7l/Cif9TiyrdW4CwEMA==} + /electron-to-chromium@1.5.113: + resolution: {integrity: sha512-wjT2O4hX+wdWPJ76gWSkMhcHAV2PTMX+QetUCPYEdCIe+cxmgzzSSiGRCKW8nuh4mwKZlpv0xvoW7OF2X+wmHg==} /emittery@0.13.1: resolution: {integrity: sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==} @@ -16025,12 +16033,12 @@ packages: dependencies: ms: 2.1.3 - /ibm-cloud-sdk-core@5.1.3: - resolution: {integrity: sha512-FCJSK4Gf5zdmR3yEM2DDlaYDrkfhSwP3hscKzPrQEfc4/qMnFn6bZuOOw5ulr3bB/iAbfeoGF0CkIe+dWdpC7Q==} + /ibm-cloud-sdk-core@5.2.0: + resolution: {integrity: sha512-urz3bArghYe4AgTpxPBm1ohrzyeRRlekBN9kQAeQn39ixjoHo0qhN9zL6iCayvcZITA2zpuk/FnzP2gEiz8PZg==} engines: {node: '>=18'} dependencies: '@types/debug': 4.1.12 - '@types/node': 10.14.22 + '@types/node': 22.13.9 '@types/tough-cookie': 4.0.5 axios: 1.7.9(debug@4.4.0) camelcase: 6.3.0 @@ -17782,7 +17790,7 @@ packages: resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==} engines: {node: '>=6'} - /langchain@0.3.19(@langchain/core@0.3.42)(axios@1.7.9)(openai@4.86.1)(ws@8.18.1): + /langchain@0.3.19(@langchain/core@0.3.42)(axios@1.7.9)(openai@4.86.2)(ws@8.18.1): resolution: {integrity: sha512-aGhoTvTBS5ulatA67RHbJ4bcV5zcYRYdm5IH+hpX99RYSFXG24XF3ghSjhYi6sxW+SUnEQ99fJhA5kroVpKNhw==} engines: {node: '>=18'} peerDependencies: @@ -17840,14 +17848,14 @@ packages: typeorm: optional: true dependencies: - '@langchain/core': 0.3.42(openai@4.86.1) + '@langchain/core': 0.3.42(openai@4.86.2) '@langchain/openai': 0.4.4(@langchain/core@0.3.42)(ws@8.18.1) '@langchain/textsplitters': 0.1.0(@langchain/core@0.3.42) axios: 1.7.9(debug@4.4.0) js-tiktoken: 1.0.19 js-yaml: 4.1.0 jsonpointer: 5.0.1 - langsmith: 0.3.12(openai@4.86.1) + langsmith: 0.3.12(openai@4.86.2) openapi-types: 12.1.3 p-retry: 4.6.2 uuid: 10.0.0 @@ -17860,7 +17868,7 @@ packages: - ws dev: false - /langsmith@0.3.12(openai@4.86.1): + /langsmith@0.3.12(openai@4.86.2): resolution: {integrity: sha512-e4qWM27hxEr8GfO6dgXrc3W8La+wxkX1zEtMhxhqS/Th2ujTt5OH7x0uXfXFDqCv9WaC3nquo1Y2s4vpYmLLtg==} peerDependencies: openai: '*' @@ -17871,7 +17879,7 @@ packages: '@types/uuid': 10.0.0 chalk: 4.1.2 console-table-printer: 2.12.1 - openai: 4.86.1(ws@8.18.1)(zod@3.24.2) + openai: 4.86.2(ws@8.18.1)(zod@3.24.2) p-queue: 6.6.2 p-retry: 4.6.2 semver: 7.7.1 @@ -18584,6 +18592,10 @@ packages: dependencies: fs-monkey: 1.0.6 + /memoize-one@5.2.1: + resolution: {integrity: sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==} + dev: false + /memory-stream@1.0.0: resolution: {integrity: sha512-Wm13VcsPIMdG96dzILfij09PvuS3APtcKNh7M28FsCA/w6+1mjR7hhPmfFNoilX9xU7wTdhsH5lJAm6XNzdtww==} dependencies: @@ -19766,7 +19778,7 @@ packages: react-dom: 18.3.1(react@18.3.1) dev: false - /next@14.2.24(@babel/core@7.26.9)(@playwright/test@1.50.1)(react-dom@18.3.1)(react@18.3.1): + /next@14.2.24(@babel/core@7.26.9)(@playwright/test@1.51.0)(react-dom@18.3.1)(react@18.3.1): resolution: {integrity: sha512-En8VEexSJ0Py2FfVnRRh8gtERwDRaJGNvsvad47ShkC2Yi8AXQPXEA2vKoDJlGFSj5WE5SyF21zNi4M5gyi+SQ==} engines: {node: '>=18.17.0'} hasBin: true @@ -19785,7 +19797,7 @@ packages: optional: true dependencies: '@next/env': 14.2.24 - '@playwright/test': 1.50.1 + '@playwright/test': 1.51.0 '@swc/helpers': 0.5.5 busboy: 1.6.0 caniuse-lite: 1.0.30001702 @@ -20290,8 +20302,8 @@ packages: is-wsl: 2.2.0 dev: false - /openai@4.86.1(ws@8.18.1)(zod@3.24.2): - resolution: {integrity: sha512-x3iCLyaC3yegFVZaxOmrYJjitKxZ9hpVbLi+ZlT5UHuHTMlEQEbKXkGOM78z9qm2T5GF+XRUZCP2/aV4UPFPJQ==} + /openai@4.86.2(ws@8.18.1)(zod@3.24.2): + resolution: {integrity: sha512-nvYeFjmjdSu6/msld+22JoUlCICNk/lUFpSMmc6KNhpeNLpqL70TqbD/8Vura/tFmYqHKW0trcjgPwUpKSPwaA==} hasBin: true peerDependencies: ws: ^8.18.0 @@ -20426,7 +20438,7 @@ packages: resolution: {integrity: sha512-5b0R4txpzjPWVw/cXXUResoD4hb6U/x9BH08L7nw+GN1sezDzPdxeRvpc9c433fZhBan/wusjbCsqwqm4EIBIQ==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} dependencies: - yocto-queue: 1.1.1 + yocto-queue: 1.2.0 dev: false /p-locate@3.0.0: @@ -20810,18 +20822,18 @@ packages: resolution: {integrity: sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg==} dev: false - /playwright-core@1.50.1: - resolution: {integrity: sha512-ra9fsNWayuYumt+NiM069M6OkcRb1FZSK8bgi66AtpFoWkg2+y0bJSNmkFrWhMbEBbVKC/EruAHH3g0zmtwGmQ==} + /playwright-core@1.51.0: + resolution: {integrity: sha512-x47yPE3Zwhlil7wlNU/iktF7t2r/URR3VLbH6EknJd/04Qc/PSJ0EY3CMXipmglLG+zyRxW6HNo2EGbKLHPWMg==} engines: {node: '>=18'} hasBin: true dev: false - /playwright@1.50.1: - resolution: {integrity: sha512-G8rwsOQJ63XG6BbKj2w5rHeavFjy5zynBA9zsJMMtBoe/Uf757oG12NXz6e6OirF7RCrTVAKFXbLmn1RbL7Qaw==} + /playwright@1.51.0: + resolution: {integrity: sha512-442pTfGM0xxfCYxuBa/Pu6B2OqxqqaYq39JS8QDMGThUvIOCd6s0ANDog3uwA0cHavVlnTQzGCN7Id2YekDSXA==} engines: {node: '>=18'} hasBin: true dependencies: - playwright-core: 1.50.1 + playwright-core: 1.51.0 optionalDependencies: fsevents: 2.3.2 dev: false @@ -22050,14 +22062,14 @@ packages: escape-goat: 4.0.0 dev: false - /puppeteer-core@24.3.1: - resolution: {integrity: sha512-585ccfcTav4KmlSmYbwwOSeC8VdutQHn2Fuk0id/y/9OoeO7Gg5PK1aUGdZjEmos0TAq+pCpChqFurFbpNd3wA==} + /puppeteer-core@24.4.0: + resolution: {integrity: sha512-eFw66gCnWo0X8Hyf9KxxJtms7a61NJVMiSaWfItsFPzFBsjsWdmcNlBdsA1WVwln6neoHhsG+uTVesKmTREn/g==} engines: {node: '>=18'} dependencies: - '@puppeteer/browsers': 2.7.1 - chromium-bidi: 2.1.2(devtools-protocol@0.0.1402036) + '@puppeteer/browsers': 2.8.0 + chromium-bidi: 2.1.2(devtools-protocol@0.0.1413902) debug: 4.4.0(supports-color@5.5.0) - devtools-protocol: 0.0.1402036 + devtools-protocol: 0.0.1413902 typed-query-selector: 2.12.0 ws: 8.18.1 transitivePeerDependencies: @@ -22067,17 +22079,17 @@ packages: - utf-8-validate dev: false - /puppeteer@24.3.1(typescript@5.6.3): - resolution: {integrity: sha512-k0OJ7itRwkr06owp0CP3f/PsRD7Pdw4DjoCUZvjGr+aNgS1z6n/61VajIp0uBjl+V5XAQO1v/3k9bzeZLWs9OQ==} + /puppeteer@24.4.0(typescript@5.6.3): + resolution: {integrity: sha512-E4JhJzjS8AAI+6N/b+Utwarhz6zWl3+MR725fal+s3UlOlX2eWdsvYYU+Q5bXMjs9eZEGkNQroLkn7j11s2k1Q==} engines: {node: '>=18'} hasBin: true requiresBuild: true dependencies: - '@puppeteer/browsers': 2.7.1 - chromium-bidi: 2.1.2(devtools-protocol@0.0.1402036) + '@puppeteer/browsers': 2.8.0 + chromium-bidi: 2.1.2(devtools-protocol@0.0.1413902) cosmiconfig: 9.0.0(typescript@5.6.3) - devtools-protocol: 0.0.1402036 - puppeteer-core: 24.3.1 + devtools-protocol: 0.0.1413902 + puppeteer-core: 24.4.0 typed-query-selector: 2.12.0 transitivePeerDependencies: - bare-buffer @@ -22482,6 +22494,19 @@ packages: - '@types/react' dev: false + /react-window@1.8.11(react-dom@18.3.1)(react@18.3.1): + resolution: {integrity: sha512-+SRbUVT2scadgFSWx+R1P754xHPEqvcfSfVX10QYg6POOz+WNgkN48pS+BtZNIMGiL1HYrSEiCkwsMS15QogEQ==} + engines: {node: '>8.0.0'} + peerDependencies: + react: ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + dependencies: + '@babel/runtime': 7.26.9 + memoize-one: 5.2.1 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + dev: false + /react@18.3.1: resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==} engines: {node: '>=0.10.0'} @@ -24280,8 +24305,8 @@ packages: yallist: 5.0.0 dev: false - /terser-webpack-plugin@5.3.13(webpack@5.97.1): - resolution: {integrity: sha512-JG3pBixF6kx2o0Yfz2K6pqh72DpwTI08nooHd06tcj5WyIt5SsSiUYqRT+kemrGUNSuSzVhwfZ28aO8gogajNQ==} + /terser-webpack-plugin@5.3.14(webpack@5.97.1): + resolution: {integrity: sha512-vkZjpUjb6OMS7dhV+tILUW6BhpDR7P2L/aQSAv+Uwk+m8KATX9EccViHTJR2qDtACKPIYndLGCyl3FMo+r2LMw==} engines: {node: '>= 10.13.0'} peerDependencies: '@swc/core': '*' @@ -24304,8 +24329,8 @@ packages: webpack: 5.97.1 dev: true - /terser-webpack-plugin@5.3.13(webpack@5.98.0): - resolution: {integrity: sha512-JG3pBixF6kx2o0Yfz2K6pqh72DpwTI08nooHd06tcj5WyIt5SsSiUYqRT+kemrGUNSuSzVhwfZ28aO8gogajNQ==} + /terser-webpack-plugin@5.3.14(webpack@5.98.0): + resolution: {integrity: sha512-vkZjpUjb6OMS7dhV+tILUW6BhpDR7P2L/aQSAv+Uwk+m8KATX9EccViHTJR2qDtACKPIYndLGCyl3FMo+r2LMw==} engines: {node: '>= 10.13.0'} peerDependencies: '@swc/core': '*' @@ -25669,7 +25694,7 @@ packages: neo-async: 2.6.2 schema-utils: 3.3.0 tapable: 2.2.1 - terser-webpack-plugin: 5.3.13(webpack@5.97.1) + terser-webpack-plugin: 5.3.14(webpack@5.97.1) watchpack: 2.4.2 webpack-sources: 3.2.3 transitivePeerDependencies: @@ -25708,7 +25733,7 @@ packages: neo-async: 2.6.2 schema-utils: 4.3.0 tapable: 2.2.1 - terser-webpack-plugin: 5.3.13(webpack@5.98.0) + terser-webpack-plugin: 5.3.14(webpack@5.98.0) watchpack: 2.4.2 webpack-cli: 5.1.4(webpack@5.98.0) webpack-sources: 3.2.3 @@ -26100,8 +26125,8 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} - /yocto-queue@1.1.1: - resolution: {integrity: sha512-b4JR1PFR10y1mKjhHY9LaGo6tmrgjit7hxVIeAmyMw3jegXR4dhYqLaQF5zMXZxY7tLpMyJeLjr1C4rLmkVe8g==} + /yocto-queue@1.2.0: + resolution: {integrity: sha512-KHBC7z61OJeaMGnF3wqNZj+GGNXOyypZviiKpQeiHirG5Ib1ImwcLBH70rbMSkKfSmUNBsdf2PwaEJtKvgmkNw==} engines: {node: '>=12.20'} dev: false