feat: Add Docker support and improve browser compatibility

This commit adds Docker support for easy deployment and improves browser compatibility:

- Add Dockerfile and docker-compose.yml for containerized deployment
- Fix Pyodide integration to work properly in browser-only mode
- Add mock implementation for server-side rendering
- Clean up markdown formatting in analysis output
- Update README with Docker deployment instructions
- Add platform-specific keyboard shortcuts (Mac/Windows)
- Ensure public directory exists in Docker build
- Fix TypeScript type definitions for Pyodide

The application now properly handles the browser/server environment difference,
with Python analysis running exclusively in the browser while the server
provides API proxying and static file serving.
This commit is contained in:
tobiadefami
2025-02-28 12:30:29 +01:00
parent 4eedd3f537
commit f0735525c1
17 changed files with 319 additions and 77 deletions

61
Dockerfile Normal file
View File

@@ -0,0 +1,61 @@
FROM node:20-alpine AS base
# Install dependencies only when needed
FROM base AS deps
WORKDIR /app
# Copy package files
COPY package.json package-lock.json* ./
RUN npm ci
# Install missing dependency
RUN npm install class-variance-authority
# Rebuild the source code only when needed
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# Ensure public directory exists
RUN mkdir -p ./public
# Set environment variables with build-time defaults
ARG OPENAI_API_KEY
ENV OPENAI_API_KEY=$OPENAI_API_KEY
# Build the application
RUN npm run build
# Production image, copy all the files and run next
FROM base AS runner
WORKDIR /app
ENV NODE_ENV production
# Create a non-root user
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
# Create public directory if it doesn't exist
RUN mkdir -p ./public
# Set proper permissions
RUN chown -R nextjs:nodejs ./public
# Switch to non-root user
USER nextjs
# Copy necessary files from builder
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/public ./public
# Set environment variables with runtime defaults
ENV PORT 3000
ENV HOSTNAME "0.0.0.0"
# Expose the port the app will run on
EXPOSE 3000
# Start the application
CMD ["node", "server.js"]

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2024 Probly
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -7,17 +7,55 @@ An AI-powered spreadsheet application that combines spreadsheet functionality wi
## Features
- **Interactive Spreadsheet**: Full-featured spreadsheet with formula support
- **Python Analysis**: Run Python code directly on your spreadsheet data
- **Python Analysis**: Run Python code directly in your browser using WebAssembly
- **Data Visualization**: Create charts and visualizations from your data
- **AI-Powered**: Get intelligent suggestions and automated analysis
## Architecture
Probly uses a modern architecture:
- **Frontend**: Next.js application that runs in the browser
- **Python Execution**: Pyodide (Python compiled to WebAssembly) runs entirely in the browser
- **LLM Integration**: OpenAI API calls are proxied through the server
This design means that data analysis happens locally in your browser, providing better performance and privacy.
## Requirements
- Node.js 18 or higher
- npm or yarn
- A modern web browser (Chrome, Firefox, Edge, or Safari)
- OpenAI API key
## Installation and Setup
## Quick Start with Docker
The easiest way to get started with Probly is using Docker:
1. Clone the repository:
```bash
git clone https://github.com/PragmaticMachineLearning/probly.git
cd probly
```
2. Create a simple `.env` file with your OpenAI API key:
```
OPENAI_API_KEY=your_api_key_here
```
3. Build and start the application:
```bash
docker compose build
docker compose up -d
```
4. Access Probly at http://localhost:3000
The Docker container serves the Next.js application and handles API requests to OpenAI, while all Python code execution happens in your browser using WebAssembly.
## Manual Installation
If you prefer to run Probly without Docker:
1. Clone the repository
```bash
@@ -30,32 +68,57 @@ An AI-powered spreadsheet application that combines spreadsheet functionality wi
npm install
```
3. Create a `.env` file in the root directory with your OpenAI API key:
3. Create a `.env` file with your OpenAI API key:
```
OPENAI_API_KEY=your_api_key_here
```
## Running the Application
4. Start the development server:
```bash
npm run dev
```
Development mode:
```bash
# Start Next.js development server
npm run dev
```
5. For production, build and start:
```bash
npm run build
npm start
```
Production build:
```bash
# Build Next.js
npm run build
```
## Quick Start
## Using Probly
1. Start the application and open it in your browser
2. Import data using the import button or start with a blank spreadsheet
3. Open the AI chat with `Ctrl+Shift+/` to start interacting with Probly
3. Open the AI chat with the keyboard shortcut:
- **Windows/Linux**: `Ctrl+Shift+?`
- **Mac**: `⌘+Shift+?` (Command+Shift+?)
4. Ask questions about your data or request analysis
## Keyboard Shortcuts
| Action | Windows/Linux | Mac |
|--------|--------------|-----|
| Toggle AI Chat | `Ctrl+Shift+?` | `⌘+Shift+?` (Command+Shift+?) |
## Advanced Docker Options
### Alternative Docker Setup
You can also pass the API key directly to Docker Compose:
```bash
OPENAI_API_KEY=your_api_key_here docker compose build
OPENAI_API_KEY=your_api_key_here docker compose up -d
```
### Building Custom Docker Images
To build a custom Docker image with your API key baked in:
```bash
docker build -t probly:custom --build-arg OPENAI_API_KEY=your_api_key_here .
docker run -p 3000:3000 probly:custom
```
## Tech Stack
- **Frontend**: Next.js 14, TypeScript, React

13
docker-compose.yml Normal file
View File

@@ -0,0 +1,13 @@
version: '3.8'
services:
probly:
build:
context: .
args:
- OPENAI_API_KEY=${OPENAI_API_KEY:-}
ports:
- "3000:3000"
environment:
- OPENAI_API_KEY=${OPENAI_API_KEY:-}
restart: unless-stopped

View File

@@ -1,7 +1,10 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: false,
// other configurations...
reactStrictMode: true,
output: "standalone",
experimental: {
serverComponentsExternalPackages: ["sharp", "canvas"],
},
images: {
unoptimized: true,
},

View File

@@ -14,6 +14,7 @@
"@types/react": "18.2.55",
"@types/react-dom": "18.2.19",
"autoprefixer": "10.4.17",
"class-variance-authority": "^0.7.1",
"dotenv": "^16.4.1",
"echarts-for-react": "^3.0.2",
"handsontable": "^13.1.0",
@@ -23,9 +24,11 @@
"openai": "^4.26.0",
"postcss": "8.4.35",
"pyodide": "^0.27.2",
"radix-ui": "^1.1.3",
"react": "^18.3.1",
"react-dom": "18.2.0",
"react-icons": "^5.4.0",
"tailwind-merge": "^3.0.2",
"tailwindcss": "3.4.1",
"typescript": "5.3.3",
"uuid": "^11.0.5",

0
public/.gitkeep Normal file
View File

View File

@@ -124,6 +124,7 @@ const SpreadsheetApp = () => {
let accumulatedResponse = "";
let updates: CellUpdate[] | undefined;
let chartData: any | undefined;
let lastParsedData: any | undefined;
while (true) {
const { done, value } = await reader.read();
@@ -137,7 +138,7 @@ const SpreadsheetApp = () => {
const jsonData = event.substring(6);
try {
const parsedData = JSON.parse(jsonData);
lastParsedData = parsedData;
if (parsedData.response) {
if (parsedData.streaming) {
// For streaming content, append to the existing response
@@ -188,7 +189,7 @@ const SpreadsheetApp = () => {
response: accumulatedResponse,
updates: updates,
chartData: chartData,
analysis: parsedData?.analysis,
analysis: lastParsedData?.analysis,
streaming: false,
status: updates || chartData ? "pending" : null,
}

View File

@@ -39,7 +39,7 @@ interface ChartInfo {
const Spreadsheet = forwardRef<SpreadsheetRef, SpreadsheetProps>(
({ onDataChange, initialData }, ref) => {
const spreadsheetRef = useRef<HTMLDivElement>(null);
const hotInstanceRef = useRef<Handsontable | null>(null);
const hotInstanceRef = useRef<any>(null);
const { formulaQueue, clearFormula, setFormulas } = useSpreadsheet();
const [currentData, setCurrentData] = useState(
initialData || [

View File

@@ -50,7 +50,9 @@ const ToolResponse: React.FC<ToolResponseProps> = ({
const col = match[1];
const row = parseInt(match[2]);
return { col, row, formula: update.formula, target: update.target };
}).filter(Boolean);
}).filter(Boolean) as { col: string; row: number; formula: string; target: string }[];
if (cellInfo.length === 0) return null;
// Find the range of rows and columns
const minRow = Math.min(...cellInfo.map(cell => cell.row));

View File

@@ -1,7 +1,7 @@
import { HyperFormula } from "hyperformula";
import * as XLSX from "xlsx";
import { HyperFormula } from "hyperformula";
// Helper function to convert HyperFormula cell address to string format
const cellAddressToString = (address: any) => {
if (typeof address !== "object" || address === null) {
@@ -69,18 +69,31 @@ const calculateCellValue = (
const existingValue = cellValues.get(cellRef);
if (existingValue) {
// set up data in hyperformula if the value is already provided, rather than attempting to perform an evaluation
hyperformulaInstance.setCellContents(cellAddress, existingValue);
hyperformulaInstance.setCellContents({
col: cellAddress.c,
row: cellAddress.r,
sheet: 0
}, existingValue);
}
// Parse hyperformula formula
const ast = hyperformulaInstance.parse(formula);
if (ast.result === "ERROR") {
console.error("HyperFormula parse error:", ast.error);
return "#ERROR";
}
// // Parse hyperformula formula
// const ast = hyperformulaInstance.parseFormula(formula, {
// col: cellAddress.c,
// row: cellAddress.r,
// sheet: 0
// });
// if (ast.error) {
// console.error("HyperFormula parse error:", ast.error);
// return "#ERROR";
// }
// Calculate using HyperFormula
const calculatedValue = hyperformulaInstance.getCellValue(cellAddress);
const calculatedValue = hyperformulaInstance.getCellValue({
col: cellAddress.c,
row: cellAddress.r,
sheet: 0
});
return calculatedValue;
// return hyperformulaInstance.getCellValue(cellAddress);
}

20
src/types/handsontable.d.ts vendored Normal file
View File

@@ -0,0 +1,20 @@
declare module 'handsontable' {
// Define the Handsontable class/interface
interface HandsontableInstance {
// Add any methods or properties you need to use
updateSettings(settings: any): void;
getData(): any[][];
render(): void;
destroy(): void;
// Add other methods as needed
}
// Define the constructor function
interface HandsontableStatic {
new (element: HTMLElement, options: any): HandsontableInstance;
}
// Export the namespace and default
const Handsontable: HandsontableStatic;
export = Handsontable;
}

View File

@@ -1,22 +1,18 @@
export interface PyodideInterface {
loadPackagesFromImports(code: string): Promise<void>;
runPython(code: string): any;
runPythonAsync(code: string): Promise<any>;
loadPackage(packages: string | string[]): Promise<any>;
globals: {
get(key: string): any;
set(key: string, value: any): void;
};
setStdout(options: { batched: (msg: string) => void }): void;
setStderr(options: { batched: (msg: string) => void }): void;
setStdout(options: { batched: (s: string) => void }): void;
setStderr(options: { batched: (s: string) => void }): void;
globals: any;
}
declare global {
interface Window {
loadPyodide(options?: { indexURL?: string }): Promise<PyodideInterface>;
}
// Make loadPyodide available globally in browser environments
const loadPyodide: (options?: { indexURL?: string }) => Promise<PyodideInterface>;
}
// For Node environment
declare module 'pyodide' {
export function loadPyodide(options?: { indexURL?: string }): Promise<PyodideInterface>;
}

View File

@@ -41,17 +41,26 @@ export function formatSpreadsheetData(data: any[][]): string {
* @returns Promise<string> - Structured CSV-like output with headers
*/
export async function structureAnalysisOutput(rawOutput: string, analysisGoal: string): Promise<string> {
// First, clean up the raw output in case it already contains backticks
const cleanedRawOutput = rawOutput
.replace(/```[\s\S]*?```/g, '') // Remove code blocks with content
.replace(/```/g, '') // Remove any remaining backticks
.trim(); // Remove extra whitespace
const messages: ChatCompletionMessageParam[] = [
{
role: "system",
content: `Convert the following analysis output into a clean tabular format.
Each row should be comma-separated values, with the first row being headers.
Ensure numbers are properly formatted and aligned.
The output should be ready to insert into a spreadsheet.`
The output should be ready to insert into a spreadsheet.
IMPORTANT: Do not use any markdown formatting or code blocks in your response.
Just return plain text with comma-separated values.`
},
{
role: "user",
content: `Analysis Goal: ${analysisGoal}\n\nRaw Output:\n${rawOutput}\n\nConvert this into comma-separated rows with headers.`
content: `Analysis Goal: ${analysisGoal}\n\nRaw Output:\n${cleanedRawOutput}\n\nConvert this into comma-separated rows with headers.`
}
];
@@ -61,7 +70,15 @@ export async function structureAnalysisOutput(rawOutput: string, analysisGoal: s
temperature: 0.1,
});
return completion.choices[0]?.message?.content || '';
// Get the content from the completion, or empty string if undefined
let result = completion.choices[0]?.message?.content || '';
// Remove any code blocks (text between triple backticks) and any remaining backticks
result = result.replace(/```[\s\S]*?```/g, '') // Remove code blocks with content
.replace(/```/g, '') // Remove any remaining backticks
.trim(); // Remove extra whitespace
return result;
}
/**

View File

@@ -1,4 +1,4 @@
import type { PyodideInterface } from '../types/pyodide';
import { PyodideInterface } from '@/types/pyodide';
interface SandboxResult {
stdout: string;
@@ -7,42 +7,64 @@ interface SandboxResult {
}
export class PyodideSandbox {
private pyodide: PyodideInterface | null = null;
private pyodide: any = null;
private initialized = false;
async initialize(): Promise<void> {
if (this.initialized) return;
try {
// Check if we're in browser or Node environment
// Only try to load Pyodide in browser environments
if (typeof window !== 'undefined') {
// Browser environment - use global loadPyodide
this.pyodide = await window.loadPyodide();
} else {
// Node environment - use local pyodide from node_modules
const { loadPyodide } = await import('pyodide');
// Browser environment - load from CDN
// @ts-ignore - loadPyodide is loaded from a script tag
this.pyodide = await loadPyodide({
indexURL: 'node_modules/pyodide'
indexURL: 'https://cdn.jsdelivr.net/pyodide/v0.27.2/full/'
});
// Initialize Python environment
await this.pyodide.loadPackagesFromImports(`
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import io
import base64
`);
this.initialized = true;
} else {
// In Node.js environment (including Docker), we'll use a mock implementation
console.log("Running in Node.js environment - using mock Pyodide implementation");
this.mockPyodideImplementation();
this.initialized = true;
}
if (!this.pyodide) throw new Error('Failed to initialize Pyodide');
await this.pyodide.loadPackage(['pandas', 'numpy']);
await this.pyodide.runPython(`
import pandas as pd
import numpy as np
pd.set_option('display.max_rows', 100)
pd.set_option('display.max_columns', 50)
`);
this.initialized = true;
} catch (error) {
console.error('Failed to initialize Pyodide:', error);
throw error;
}
}
// Create a mock implementation for server-side rendering
private mockPyodideImplementation() {
console.log("Creating mock Pyodide implementation for server-side rendering");
this.pyodide = {
runPython: (code: string) => {
console.log("[Server] Mock Pyodide runPython called - this would run in browser");
return null;
},
runPythonAsync: async (code: string) => {
console.log("[Server] Mock Pyodide runPythonAsync called - this would run in browser");
return null;
},
setStdout: (options: { batched: (s: string) => void }) => {
console.log("[Server] Mock Pyodide setStdout called");
},
setStderr: (options: { batched: (s: string) => void }) => {
console.log("[Server] Mock Pyodide setStderr called");
}
};
}
async runDataAnalysis(
code: string,
csvData: string,
@@ -56,6 +78,16 @@ export class PyodideSandbox {
throw new Error('Pyodide not initialized');
}
// If we're in a server environment, return a mock result
if (typeof window === 'undefined') {
console.log("[Server] Returning mock analysis result - actual analysis will run in browser");
return {
stdout: "Pyodide analysis is only available in browser environments.\nServer received code:\n" + code,
stderr: "",
result: null
};
}
const stdout: string[] = [];
const stderr: string[] = [];
@@ -97,7 +129,7 @@ df = pd.read_csv(io.StringIO('''${csvData}'''))
}
async destroy(): Promise<void> {
if (this.pyodide) {
if (this.pyodide && typeof window !== 'undefined') {
try {
// Simple cleanup - just delete our main DataFrame
await this.pyodide.runPython(`
@@ -107,8 +139,8 @@ if 'df' in globals():
} catch (error) {
console.warn('Error during cleanup:', error);
}
this.pyodide = null;
}
this.pyodide = null;
this.initialized = false;
}
}

View File

@@ -1,12 +1,8 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
"./src/pages/**/*.{js,ts,jsx,tsx,mdx}",
"./src/components/**/*.{js,ts,jsx,tsx,mdx}",
"./src/app/**/*.{js,ts,jsx,tsx,mdx}",
],
content: ["./src/**/*.{js,ts,jsx,tsx}"],
theme: {
extend: {},
},
plugins: [],
}
};

View File

@@ -27,7 +27,8 @@
"./src/*"
]
},
"baseUrl": "."
"baseUrl": ".",
"typeRoots": ["./node_modules/@types", "./src/types"]
},
"include": [
"next-env.d.ts",