Skip to content

Commit 13fefda

Browse files
committed
feat: add sheets tools
1 parent 5e9cd61 commit 13fefda

File tree

12 files changed

+1460
-33
lines changed

12 files changed

+1460
-33
lines changed

backend/agent/run.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
from agent.tools.mcp_tool_wrapper import MCPToolWrapper
3333
from agent.tools.task_list_tool import TaskListTool
3434
from agentpress.tool import SchemaType
35+
from agent.tools.sb_sheets_tool import SandboxSheetsTool
3536

3637
load_dotenv()
3738

@@ -72,6 +73,7 @@ def register_all_tools(self):
7273
self.thread_manager.add_tool(SandboxVisionTool, project_id=self.project_id, thread_id=self.thread_id, thread_manager=self.thread_manager)
7374
self.thread_manager.add_tool(SandboxImageEditTool, project_id=self.project_id, thread_id=self.thread_id, thread_manager=self.thread_manager)
7475
self.thread_manager.add_tool(TaskListTool, project_id=self.project_id, thread_manager=self.thread_manager, thread_id=self.thread_id)
76+
self.thread_manager.add_tool(SandboxSheetsTool, project_id=self.project_id, thread_manager=self.thread_manager)
7577
if config.RAPID_API_KEY:
7678
self.thread_manager.add_tool(DataProvidersTool)
7779

@@ -120,6 +122,8 @@ def safe_tool_check(tool_name: str) -> bool:
120122
self.thread_manager.add_tool(SandboxWebSearchTool, project_id=self.project_id, thread_manager=self.thread_manager)
121123
if safe_tool_check('sb_vision_tool'):
122124
self.thread_manager.add_tool(SandboxVisionTool, project_id=self.project_id, thread_id=self.thread_id, thread_manager=self.thread_manager)
125+
if safe_tool_check('sb_sheets_tool'):
126+
self.thread_manager.add_tool(SandboxSheetsTool, project_id=self.project_id, thread_manager=self.thread_manager)
123127
if config.RAPID_API_KEY and safe_tool_check('data_providers_tool'):
124128
self.thread_manager.add_tool(DataProvidersTool)
125129

backend/agent/suna/config.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,8 @@ class SunaConfig:
1818
"web_search_tool": True,
1919
"sb_vision_tool": True,
2020
"sb_image_edit_tool": True,
21-
"data_providers_tool": True
21+
"data_providers_tool": True,
22+
"sb_sheets_tool": True
2223
}
2324

2425
DEFAULT_MCPS = []

backend/agent/tools/sb_sheets_tool.py

Lines changed: 900 additions & 0 deletions
Large diffs are not rendered by default.

frontend/src/components/agents/tools.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ export const AGENTPRESS_TOOL_DEFINITIONS: Record<string, { enabled: boolean; des
77
'web_search_tool': { enabled: true, description: 'Search the web using Tavily API and scrape webpages with Firecrawl for research', icon: '🔍', color: 'bg-yellow-100 dark:bg-yellow-800/50' },
88
'sb_vision_tool': { enabled: true, description: 'Vision and image processing capabilities for visual content analysis', icon: '👁️', color: 'bg-pink-100 dark:bg-pink-800/50' },
99
'data_providers_tool': { enabled: true, description: 'Access to data providers and external APIs (requires RapidAPI key)', icon: '🔗', color: 'bg-cyan-100 dark:bg-cyan-800/50' },
10+
'sb_sheets_tool': { enabled: true, description: 'Create, view, update, analyze, visualize, and format spreadsheets (XLSX/CSV) with Luckysheet viewer', icon: '📊', color: 'bg-purple-100 dark:bg-purple-800/50' },
1011
};
1112

1213
export const DEFAULT_AGENTPRESS_TOOLS: Record<string, boolean> = Object.entries(AGENTPRESS_TOOL_DEFINITIONS).reduce((acc, [key, value]) => {
@@ -24,6 +25,7 @@ export const getToolDisplayName = (toolName: string): string => {
2425
'web_search_tool': 'Web Search',
2526
'sb_vision_tool': 'Image Processing',
2627
'data_providers_tool': 'Data Providers',
28+
'sb_sheets_tool': 'Sheets Tool',
2729
};
2830

2931
return displayNames[toolName] || toolName.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase());

frontend/src/components/agents/workflows/conditional-workflow-builder.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ const normalizeToolName = (toolName: string, toolType: 'agentpress' | 'mcp') =>
5656
'web_search_tool': 'Web Search',
5757
'sb_vision_tool': 'Vision Tool',
5858
'data_providers_tool': 'Data Providers',
59+
'sb_sheets_tool': 'Sheets Tool',
5960
};
6061
return agentPressMapping[toolName] || toolName;
6162
} else {

frontend/src/components/file-renderers/binary-renderer.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -68,9 +68,9 @@ export function BinaryRenderer({
6868
disabled={isDownloading}
6969
>
7070
{isDownloading ? (
71-
<Loader className="h-4 w-4 mr-2 animate-spin" />
71+
<Loader className="h-4 w-4 animate-spin" />
7272
) : (
73-
<Download className="h-4 w-4 mr-2" />
73+
<Download className="h-4 w-4" />
7474
)}
7575
Download
7676
</Button>
Lines changed: 238 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,238 @@
1+
import React from 'react';
2+
import { useAuth } from '@/components/AuthProvider';
3+
import { fetchFileContent } from '@/hooks/react-query/files/use-file-queries';
4+
5+
declare global {
6+
interface Window {
7+
XLSX?: any;
8+
luckysheet?: any;
9+
$?: any;
10+
jQuery?: any;
11+
}
12+
}
13+
14+
function loadScript(src: string): Promise<void> {
15+
return new Promise((resolve, reject) => {
16+
if (document.querySelector(`script[src="${src}"]`)) return resolve();
17+
const s = document.createElement('script');
18+
s.src = src;
19+
s.async = true;
20+
s.onload = () => resolve();
21+
s.onerror = () => reject(new Error(`Failed to load ${src}`));
22+
document.body.appendChild(s);
23+
});
24+
}
25+
26+
function loadStyle(href: string): void {
27+
if (document.querySelector(`link[href="${href}"]`)) return;
28+
const l = document.createElement('link');
29+
l.rel = 'stylesheet';
30+
l.href = href;
31+
document.head.appendChild(l);
32+
}
33+
34+
function argbToHex(argb?: string): string | undefined {
35+
if (!argb || typeof argb !== 'string') return undefined;
36+
const v = argb.replace(/^#/, '');
37+
if (v.length === 8) return `#${v.slice(2)}`;
38+
if (v.length === 6) return `#${v}`;
39+
return undefined;
40+
}
41+
42+
function mapType(t: string | undefined): string {
43+
switch (t) {
44+
case 'n':
45+
case 'd':
46+
case 'b':
47+
case 's':
48+
case 'str':
49+
case 'e':
50+
return t;
51+
default:
52+
return 'g';
53+
}
54+
}
55+
56+
export interface LuckysheetViewerProps {
57+
xlsxPath: string;
58+
sandboxId?: string;
59+
className?: string;
60+
height?: number | string;
61+
}
62+
63+
export function LuckysheetViewer({ xlsxPath, sandboxId, className, height }: LuckysheetViewerProps) {
64+
const { session } = useAuth();
65+
const wrapperRef = React.useRef<HTMLDivElement | null>(null);
66+
const containerRef = React.useRef<HTMLDivElement | null>(null);
67+
const containerIdRef = React.useRef<string>(`luckysheet-${Math.random().toString(36).slice(2)}`);
68+
const [error, setError] = React.useState<string | null>(null);
69+
const [loading, setLoading] = React.useState<boolean>(true);
70+
const [measuredHeight, setMeasuredHeight] = React.useState<number>(0);
71+
72+
React.useEffect(() => {
73+
const el = wrapperRef.current;
74+
if (!el || height) return;
75+
const ro = new ResizeObserver(() => {
76+
const rect = el.getBoundingClientRect();
77+
setMeasuredHeight(Math.max(0, rect.height));
78+
try { window.luckysheet?.resize?.(); } catch {}
79+
});
80+
ro.observe(el);
81+
const rect = el.getBoundingClientRect();
82+
setMeasuredHeight(Math.max(0, rect.height));
83+
return () => ro.disconnect();
84+
}, [height]);
85+
86+
React.useEffect(() => {
87+
let disposed = false;
88+
async function init() {
89+
try {
90+
setLoading(true);
91+
setError(null);
92+
loadStyle('https://cdn.jsdelivr.net/npm/luckysheet@latest/dist/plugins/css/pluginsCss.css');
93+
loadStyle('https://cdn.jsdelivr.net/npm/luckysheet@latest/dist/plugins/plugins.css');
94+
loadStyle('https://cdn.jsdelivr.net/npm/luckysheet@latest/dist/css/luckysheet.css');
95+
96+
await loadScript('https://cdn.jsdelivr.net/npm/[email protected]/dist/jquery.min.js');
97+
if (!window.$ && (window as any).jQuery) window.$ = (window as any).jQuery;
98+
await loadScript('https://cdn.jsdelivr.net/npm/[email protected]/jquery.mousewheel.min.js');
99+
await loadScript('https://cdn.jsdelivr.net/npm/[email protected]/dist/xlsx.full.min.js');
100+
await loadScript('https://cdn.jsdelivr.net/npm/luckysheet@latest/dist/plugins/js/plugin.js');
101+
await loadScript('https://cdn.jsdelivr.net/npm/luckysheet@latest/dist/luckysheet.umd.js');
102+
if (disposed) return;
103+
104+
let ab: ArrayBuffer;
105+
if (sandboxId && session?.access_token) {
106+
const blob = (await fetchFileContent(
107+
sandboxId,
108+
xlsxPath,
109+
'blob',
110+
session.access_token
111+
)) as Blob;
112+
ab = await blob.arrayBuffer();
113+
} else {
114+
const resp = await fetch(xlsxPath);
115+
if (!resp.ok) throw new Error(`Fetch failed: ${resp.status}`);
116+
ab = await resp.arrayBuffer();
117+
}
118+
119+
const XLSX = window.XLSX;
120+
const wb = XLSX.read(ab, { type: 'array', cellStyles: true });
121+
122+
const sheetsForLucky: any[] = [];
123+
124+
wb.SheetNames.forEach((name: string, idx: number) => {
125+
const ws = wb.Sheets[name];
126+
const ref = ws['!ref'] || 'A1:A1';
127+
const range = XLSX.utils.decode_range(ref);
128+
129+
const celldata: any[] = [];
130+
for (let r = range.s.r; r <= range.e.r; r++) {
131+
for (let c = range.s.c; c <= range.e.c; c++) {
132+
const addr = XLSX.utils.encode_cell({ r, c });
133+
const cell = ws[addr];
134+
if (!cell) continue;
135+
const v: any = {
136+
v: cell.v,
137+
m: (cell.w ?? String(cell.v ?? '')),
138+
ct: { t: mapType(cell.t), fa: cell.z || 'General' },
139+
};
140+
const s = (cell as any).s || {};
141+
const font = s.font || {};
142+
const fill = s.fill || {};
143+
const alignment = s.alignment || {};
144+
145+
if (font.bold) v.bl = 1;
146+
if (font.italic) v.it = 1;
147+
if (font.sz) v.fs = Number(font.sz);
148+
const fc = font.color?.rgb || font.color?.rgbColor || font.color;
149+
const bg = fill.fgColor?.rgb || fill.bgColor?.rgb || fill.fgColor || fill.bgColor;
150+
const fcHex = argbToHex(typeof fc === 'string' ? fc : undefined);
151+
const bgHex = argbToHex(typeof bg === 'string' ? bg : undefined);
152+
if (fcHex) v.fc = fcHex;
153+
if (bgHex) v.bg = bgHex;
154+
155+
if (alignment) {
156+
if (alignment.horizontal) v.ht = alignment.horizontal;
157+
if (alignment.vertical) v.vt = alignment.vertical;
158+
if (alignment.wrapText) v.tb = 1;
159+
}
160+
161+
celldata.push({ r, c, v });
162+
}
163+
}
164+
165+
const mergeConfig: Record<string, any> = {};
166+
const merges = ws['!merges'] || [];
167+
merges.forEach((m: any) => {
168+
const rs = m.e.r - m.s.r + 1;
169+
const cs = m.e.c - m.s.c + 1;
170+
mergeConfig[`${m.s.r}_${m.s.c}`] = { r: m.s.r, c: m.s.c, rs, cs };
171+
});
172+
173+
const columnlen: Record<number, number> = {};
174+
const cols = ws['!cols'] || [];
175+
cols.forEach((col: any, i: number) => {
176+
const wpx = col.wpx || (col.wch ? Math.round(col.wch * 7) : undefined);
177+
if (wpx) columnlen[i] = wpx;
178+
});
179+
180+
const rowlen: Record<number, number> = {};
181+
const rows = ws['!rows'] || [];
182+
rows.forEach((row: any, i: number) => {
183+
const hpx = row.hpx || (row.hpt ? Math.round(row.hpt * 1.33) : undefined);
184+
if (hpx) rowlen[i] = hpx;
185+
});
186+
187+
const config: any = {};
188+
if (Object.keys(mergeConfig).length) config.merge = mergeConfig;
189+
if (Object.keys(columnlen).length) config.columnlen = columnlen;
190+
if (Object.keys(rowlen).length) config.rowlen = rowlen;
191+
192+
sheetsForLucky.push({
193+
name,
194+
index: idx,
195+
status: 1,
196+
order: idx,
197+
celldata,
198+
config,
199+
});
200+
});
201+
202+
if (!containerRef.current) return;
203+
containerRef.current.innerHTML = '';
204+
window.luckysheet?.create({
205+
container: containerIdRef.current,
206+
data: sheetsForLucky,
207+
showtoolbar: true,
208+
showinfobar: false,
209+
showsheetbar: true,
210+
allowCopy: true,
211+
});
212+
if (!disposed) setLoading(false);
213+
} catch (e: any) {
214+
if (!disposed) {
215+
setError(e?.message || 'Failed to load sheet');
216+
setLoading(false);
217+
}
218+
}
219+
}
220+
init();
221+
return () => { disposed = true; };
222+
}, [xlsxPath, sandboxId, session?.access_token]);
223+
224+
const resolvedHeight = height ?? measuredHeight ?? 0;
225+
226+
return (
227+
<div ref={wrapperRef} className={className} style={{ height: height ? (typeof height === 'number' ? `${height}px` : height) : undefined }}>
228+
{error ? (
229+
<div className="text-sm text-red-600">{error}</div>
230+
) : (
231+
<div id={containerIdRef.current} ref={containerRef} style={{ height: resolvedHeight, width: '100%' }} />
232+
)}
233+
{loading && !error && (
234+
<div className="text-xs text-muted-foreground mt-2">Loading formatted viewer…</div>
235+
)}
236+
</div>
237+
);
238+
}

0 commit comments

Comments
 (0)