Skip to content
Open
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions quadratic-client/src/app/ai/tools/aiToolsActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,10 @@ import {
removeValidationsToolCall,
} from '@/app/ai/tools/aiValidations';
import { defaultFormatUpdate, describeFormatUpdates, expectedEnum } from '@/app/ai/tools/formatUpdate';
import { aiCodeCellSummaryStore } from '@/app/ai/utils/aiCodeCellSummaryStore';
import { AICellResultToMarkdown } from '@/app/ai/utils/aiToMarkdown';
import { codeCellToMarkdown } from '@/app/ai/utils/codeCellToMarkdown';
import { generateCodeCellSummary } from '@/app/ai/utils/generateCodeCellSummary';
import { events } from '@/app/events/events';
import { sheets } from '@/app/grid/controller/Sheets';
import type { ColumnRowResize } from '@/app/gridGL/interaction/pointer/PointerHeading';
Expand Down Expand Up @@ -249,6 +251,24 @@ export const aiToolsActions: AIToolActionsRecord = {
ensureRectVisible(sheetId, { x, y }, { x: x + width - 1, y: y + height - 1 });
}

// Generate and store AI summary for the code cell
try {
console.log(
'[aiToolsActions] Generating AI summary for code cell at:',
x,
y,
'language:',
code_cell_language
);
const summary = await generateCodeCellSummary(code_string, code_cell_language, x, y);
console.log('[aiToolsActions] Generated summary:', summary);
aiCodeCellSummaryStore.setSummary(sheetId, x, y, summary, code_string);
console.log('[aiToolsActions] Stored summary in store');
} catch (error) {
console.warn('[aiToolsActions] Failed to generate AI summary for code cell:', error);
// Don't fail the entire operation if summary generation fails
}

const result = await setCodeCellResult(sheetId, x, y, messageMetaData);
return result;
} else {
Expand Down Expand Up @@ -484,6 +504,18 @@ export const aiToolsActions: AIToolActionsRecord = {
ensureRectVisible(sheetId, { x, y }, { x: x + width - 1, y: y + height - 1 });
}

// Generate and store AI summary for the formula cell
try {
console.log('[aiToolsActions] Generating AI summary for formula cell at:', x, y, 'formula:', formula_string);
const summary = await generateCodeCellSummary(formula_string, 'Formula', x, y);
console.log('[aiToolsActions] Generated formula summary:', summary);
aiCodeCellSummaryStore.setSummary(sheetId, x, y, summary, formula_string);
console.log('[aiToolsActions] Stored formula summary in store');
} catch (error) {
console.warn('[aiToolsActions] Failed to generate AI summary for formula cell:', error);
// Don't fail the entire operation if summary generation fails
}

const result = await setCodeCellResult(sheetId, x, y, messageMetaData);
return result;
} else {
Expand Down
152 changes: 152 additions & 0 deletions quadratic-client/src/app/ai/utils/aiCodeCellSummaryStore.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
/**
* Client-side storage for AI-generated code cell summaries
* This stores summaries in memory and optionally persists them to localStorage
*/

interface CodeCellSummary {
sheetId: string;
x: number;
y: number;
summary: string;
codeString: string; // Store code string to detect changes
timestamp: number;
}

class AICodeCellSummaryStore {
private summaries = new Map<string, CodeCellSummary>();
private readonly STORAGE_KEY = 'quadratic_ai_code_cell_summaries';
private readonly MAX_SUMMARIES = 1000; // Limit to prevent memory issues

constructor() {
this.loadFromStorage();
}

private getKey(sheetId: string, x: number, y: number): string {
return `${sheetId}:${x}:${y}`;
}

/**
* Store a summary for an AI-generated code cell
*/
setSummary(sheetId: string, x: number, y: number, summary: string, codeString: string): void {
const key = this.getKey(sheetId, x, y);
console.log('[aiCodeCellSummaryStore] Storing summary for key:', key, 'summary:', summary);

this.summaries.set(key, {
sheetId,
x,
y,
summary,
codeString,
timestamp: Date.now(),
});

// Limit the number of stored summaries
if (this.summaries.size > this.MAX_SUMMARIES) {
this.cleanupOldSummaries();
}

this.saveToStorage();
console.log('[aiCodeCellSummaryStore] Total summaries stored:', this.summaries.size);
}

/**
* Get a summary for a code cell
* Returns null if no summary exists or if the code has changed
*/
getSummary(sheetId: string, x: number, y: number, currentCodeString?: string): string | null {
const key = this.getKey(sheetId, x, y);
const summary = this.summaries.get(key);
console.log('[aiCodeCellSummaryStore] Getting summary for key:', key, 'found:', !!summary);

if (!summary) {
console.log('[aiCodeCellSummaryStore] No summary found for key:', key);
return null;
}

// If code has changed, remove the outdated summary
if (currentCodeString && summary.codeString !== currentCodeString) {
console.log('[aiCodeCellSummaryStore] Code changed, removing outdated summary for key:', key);
this.summaries.delete(key);
this.saveToStorage();
return null;
}

console.log('[aiCodeCellSummaryStore] Returning summary for key:', key, 'summary:', summary.summary);
return summary.summary;
}

/**
* Check if a code cell has an AI summary
*/
hasSummary(sheetId: string, x: number, y: number): boolean {
const key = this.getKey(sheetId, x, y);
return this.summaries.has(key);
}

/**
* Remove a summary for a code cell
*/
removeSummary(sheetId: string, x: number, y: number): void {
const key = this.getKey(sheetId, x, y);
this.summaries.delete(key);
this.saveToStorage();
}

/**
* Clear all summaries for a sheet
*/
clearSheet(sheetId: string): void {
for (const [key, summary] of this.summaries.entries()) {
if (summary.sheetId === sheetId) {
this.summaries.delete(key);
}
}
this.saveToStorage();
}

/**
* Clean up old summaries to prevent memory issues
*/
private cleanupOldSummaries(): void {
const entries = Array.from(this.summaries.entries());
entries.sort((a, b) => a[1].timestamp - b[1].timestamp);

// Remove oldest 20% of summaries
const toRemove = Math.floor(entries.length * 0.2);
for (let i = 0; i < toRemove; i++) {
this.summaries.delete(entries[i][0]);
}
}

/**
* Save summaries to localStorage
*/
private saveToStorage(): void {
try {
const data = Array.from(this.summaries.entries());
localStorage.setItem(this.STORAGE_KEY, JSON.stringify(data));
} catch (error) {
console.warn('Failed to save AI code cell summaries to localStorage:', error);
}
}

/**
* Load summaries from localStorage
*/
private loadFromStorage(): void {
try {
const data = localStorage.getItem(this.STORAGE_KEY);
if (data) {
const entries: [string, CodeCellSummary][] = JSON.parse(data);
this.summaries = new Map(entries);
}
} catch (error) {
console.warn('Failed to load AI code cell summaries from localStorage:', error);
this.summaries.clear();
}
}
}

// Export a singleton instance
export const aiCodeCellSummaryStore = new AICodeCellSummaryStore();
167 changes: 167 additions & 0 deletions quadratic-client/src/app/ai/utils/generateCodeCellSummary.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
import { pixiAppSettings } from '@/app/gridGL/pixiApp/PixiAppSettings';
import { xyToA1 } from '@/app/quadratic-core/quadratic_core';
import { authClient } from '@/auth/auth';
import { apiClient } from '@/shared/api/apiClient';
import { createTextContent } from 'quadratic-shared/ai/helpers/message.helper';
import { v4 as uuidv4 } from 'uuid';

/**
* Generates a concise summary of what a code cell does using AI
*/
export const generateCodeCellSummary = async (
codeString: string,
language: string,
x?: number,
y?: number,
signal?: AbortSignal
): Promise<string> => {
try {
console.log('[generateCodeCellSummary] Generating AI summary for:', language, codeString.substring(0, 100) + '...');

// Get file UUID from pixiAppSettings
const fileUuid = pixiAppSettings.editorInteractionState.fileUuid;
if (!fileUuid) {
console.warn('[generateCodeCellSummary] No file UUID available, falling back to simple summary');
return getFallbackSummary(codeString, language);
}

// Generate cell reference if coordinates are provided
const cellRef = x !== undefined && y !== undefined ? xyToA1(x, y) : null;
const cellLocationText = cellRef ? ` at ${cellRef}` : '';

// Prepare AI request following the same pattern as useAIRequestToAPI
const chatId = uuidv4();
const messages = [
{
role: 'user' as const,
content: [
createTextContent(`Analyze this ${language} code and provide a response in this exact format:

[One concise sentence describing what the code does${cellLocationText} - LIMIT THIS FIRST LINE TO EXACTLY 12 WORDS OR FEWER]

1. [First key step or operation]
2. [Second key step or operation]
3. [Continue with additional steps as needed]

Code to analyze:
\`\`\`${language.toLowerCase()}
${codeString}
\`\`\`

Start with the summary sentence${cellLocationText ? ` (include the cell location ${cellRef} in the sentence)` : ''}, then provide numbered steps. Be concise but informative. IMPORTANT: The first line summary sentence must be 12 words or fewer.`),
],
contextType: 'userPrompt' as const,
},
];

// Make AI request using the same structure as handleAIRequestToAPI
const endpoint = `${apiClient.getApiUrl()}/v0/ai/chat`;
const token = await authClient.getTokenOrRedirect();

const requestBody = {
chatId,
fileUuid,
messageSource: 'CodeCellSummary',
modelKey: 'vertexai:gemini-2.5-flash:thinking-toggle-off' as const,
source: 'AIAssistant' as const,
messages,
useToolsPrompt: false,
useQuadraticContext: false,
useStream: false,
toolName: undefined,
language: undefined,
};

const response = await fetch(endpoint, {
method: 'POST',
signal,
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(requestBody),
});

if (!response.ok) {
console.warn('[generateCodeCellSummary] AI request failed, falling back to simple summary');
return getFallbackSummary(codeString, language);
}

const aiResponse = await response.json();

// Extract text content from AI response
if (aiResponse.content && aiResponse.content.length > 0) {
const textContent = aiResponse.content.find((c: any) => c.type === 'text');
if (textContent && textContent.text) {
const fullResponse = textContent.text.trim();
console.log('[generateCodeCellSummary] AI generated response:', fullResponse);

// Parse the response to extract summary and explanation
const lines = fullResponse.split('\n');
const summaryLine = lines[0]?.trim();

// If we have a multi-line response, store both parts
if (lines.length > 1) {
const explanation = lines.slice(1).join('\n').trim();
// Store the full response for the expanded view
return JSON.stringify({
summary: summaryLine,
explanation: explanation,
fullText: fullResponse,
});
}

// Fallback to just the summary if no explanation
return summaryLine || fullResponse;
}
}

console.warn('[generateCodeCellSummary] No valid content in AI response, falling back to simple summary');
return getFallbackSummary(codeString, language);
} catch (error) {
console.error('[generateCodeCellSummary] Error in AI summary generation:', error);
return getFallbackSummary(codeString, language);
}
};

/**
* Fallback summary generation for when AI is unavailable
*/
function getFallbackSummary(codeString: string, language: string): string {
const lowerCode = codeString.toLowerCase();

// Quick pattern matching for common cases
if (
lowerCode.includes('plot') ||
lowerCode.includes('chart') ||
lowerCode.includes('matplotlib') ||
lowerCode.includes('plotly')
) {
return 'Creates a data visualization';
}

if (lowerCode.includes('pandas') || lowerCode.includes('dataframe')) {
return 'Processes data using pandas';
}

if (lowerCode.includes('sum(') || lowerCode.includes('.sum()')) {
return 'Calculates sum of values';
}

if (lowerCode.includes('mean(') || lowerCode.includes('.mean()')) {
return 'Calculates average of values';
}

if (lowerCode.includes('read_csv') || lowerCode.includes('read_excel')) {
return 'Loads data from file';
}

// Generic fallbacks
if (language === 'Python') {
return 'Executes Python code';
} else if (language === 'Javascript') {
return 'Executes JavaScript code';
} else {
return `Executes ${language} code`;
}
}
6 changes: 6 additions & 0 deletions quadratic-client/src/app/atoms/formulaBarAtom.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { atom } from 'recoil';

export const formulaBarExpandedAtom = atom({
key: 'formulaBarExpanded',
default: false,
});
Loading
Loading