Add accountant/investor skills, Excel editor, email attachment fixes
Skills:
- Add accountant (CPA) and investor (CFA) skills with site: hints
- Add site: hints to lawyer, psychiatrist, musician skills
- Allow multiple skills active simultaneously (remove exclusive mode)
Tools:
- Add excel_read / excel_write tools (openpyxl-based)
- Fix python_eval packages: retry with --break-system-packages on PEP 668 failure
- Fix email_read: spaces→underscores in filenames, return /api/files/ links,
remove absolute savedPaths from data, send SSE 'files' event for attachments
Server:
- FILE_OP v2: fall through to primary when secondary model unavailable
- Add PUT /api/files/{*filePath} endpoint for xlsx editor save
UI:
- xlsx viewer: Luckysheet modal (lazy CDN load, ~3MB on first use)
with edit mode, 💾 save button, Nanum Gothic font
- Email attachment preview: SSE 'files' event appends file links to final reply
- ✏️ 편집 button (blue) for xlsx cards
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+7
-5
@@ -364,7 +364,7 @@ export const emailReadTool = {
|
||||
fs.mkdirSync(saveDir, { recursive: true });
|
||||
for (const att of parsed.attachments) {
|
||||
const filename = att.filename || `attachment-${savedPaths.length + 1}`;
|
||||
const safeName = filename.replace(/[/\\:*?"<>|]/g, '_');
|
||||
const safeName = filename.replace(/[/\\:*?"<>|]/g, '_').replace(/\s+/g, '_');
|
||||
const dest = path.join(saveDir, safeName);
|
||||
fs.writeFileSync(dest, att.content);
|
||||
savedPaths.push(dest);
|
||||
@@ -410,13 +410,14 @@ export const emailReadTool = {
|
||||
`날짜: ${r.date}`,
|
||||
r.attachments.length ? `첨부파일: ${r.attachments.join(', ')}` : '',
|
||||
r.savedRelPaths?.length
|
||||
? `✅ 첨부파일 저장 완료 — pdf_read/read 등에서 아래 경로를 그대로 사용하세요:\n${r.savedRelPaths.map((p: string) => ` "${p}"`).join('\n')}`
|
||||
? `✅ 첨부파일 저장 완료 — pdf_read 등에서 아래 상대경로를 그대로 사용하세요:\n${r.savedRelPaths.map((p: string) => ` - [${path.basename(p)}](/api/files/${p.replace(/\\/g, '/')})`).join('\n')}`
|
||||
: '',
|
||||
'',
|
||||
'─── 본문 ───',
|
||||
r.body,
|
||||
].filter((l: string) => l !== '');
|
||||
return { success: true, stdout: lines.join('\n'), data: { ...r, account: found.account } };
|
||||
const { savedPaths: _sp, ...safeData } = { ...r, account: found.account };
|
||||
return { success: true, stdout: lines.join('\n'), data: safeData };
|
||||
}
|
||||
|
||||
// No account specified — try all accounts
|
||||
@@ -438,13 +439,14 @@ export const emailReadTool = {
|
||||
`날짜: ${r.date}`,
|
||||
r.attachments.length ? `첨부파일: ${r.attachments.join(', ')}` : '',
|
||||
r.savedRelPaths?.length
|
||||
? `✅ 첨부파일 저장 완료 — pdf_read/read 등에서 아래 경로를 그대로 사용하세요:\n${r.savedRelPaths.map((p: string) => ` "${p}"`).join('\n')}`
|
||||
? `✅ 첨부파일 저장 완료 — pdf_read 등에서 아래 상대경로를 그대로 사용하세요:\n${r.savedRelPaths.map((p: string) => ` - [${path.basename(p)}](/api/files/${p.replace(/\\/g, '/')})`).join('\n')}`
|
||||
: '',
|
||||
'',
|
||||
'─── 본문 ───',
|
||||
r.body,
|
||||
].filter((l: string) => l !== '');
|
||||
return { success: true, stdout: lines.join('\n'), data: { ...r, account: found.account } };
|
||||
const { savedPaths: _sp, ...safeData } = { ...r, account: found.account };
|
||||
return { success: true, stdout: lines.join('\n'), data: safeData };
|
||||
}
|
||||
errors.push(acct.id);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,311 @@
|
||||
import { execFile } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import { getConfig } from '../config/config.js';
|
||||
import { ToolResult } from '../types.js';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
function getWorkspacePath(args?: any): string {
|
||||
const sessionPath = args?._workspacePath || args?._workspace;
|
||||
if (sessionPath) return sessionPath;
|
||||
try {
|
||||
return getConfig().getConfig()?.workspace?.path || path.join(process.cwd(), 'workspace');
|
||||
} catch {
|
||||
return path.join(process.cwd(), 'workspace');
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Python scripts ────────────────────────────────────────────────────────────
|
||||
|
||||
const READ_SCRIPT = `
|
||||
import sys, json
|
||||
try:
|
||||
import openpyxl
|
||||
except ImportError:
|
||||
print(json.dumps({"error": "openpyxl_missing"}))
|
||||
sys.exit(1)
|
||||
|
||||
xlsx_path = sys.argv[1]
|
||||
sheet_name = sys.argv[2] if len(sys.argv) > 2 and sys.argv[2] != '__ALL__' else None
|
||||
cell_range = sys.argv[3] if len(sys.argv) > 3 and sys.argv[3] != '__NONE__' else None
|
||||
max_rows = int(sys.argv[4]) if len(sys.argv) > 4 else 500
|
||||
|
||||
wb = openpyxl.load_workbook(xlsx_path, read_only=True, data_only=True)
|
||||
sheet_names = wb.sheetnames
|
||||
|
||||
if sheet_name is None:
|
||||
# Return sheet list + preview of first sheet
|
||||
ws = wb.active
|
||||
target_sheets = [ws.title]
|
||||
else:
|
||||
if sheet_name not in sheet_names:
|
||||
print(json.dumps({"error": f"Sheet '{sheet_name}' not found. Available: {sheet_names}"}))
|
||||
sys.exit(1)
|
||||
ws = wb[sheet_name]
|
||||
target_sheets = [sheet_name]
|
||||
|
||||
results = []
|
||||
for sname in target_sheets:
|
||||
ws = wb[sname]
|
||||
|
||||
if cell_range:
|
||||
cells = ws[cell_range]
|
||||
if not hasattr(cells, '__iter__') or isinstance(cells, openpyxl.cell.cell.Cell):
|
||||
cells = [[cells]]
|
||||
elif not hasattr(cells[0], '__iter__'):
|
||||
cells = [cells]
|
||||
rows = [[str(c.value) if c.value is not None else '' for c in row] for row in cells]
|
||||
else:
|
||||
rows = []
|
||||
for i, row in enumerate(ws.iter_rows(values_only=True)):
|
||||
if i >= max_rows:
|
||||
rows.append([f'... ({ws.max_row - max_rows} more rows truncated)'])
|
||||
break
|
||||
rows.append([str(v) if v is not None else '' for v in row])
|
||||
|
||||
# Strip trailing empty rows
|
||||
while rows and all(c == '' for c in rows[-1]):
|
||||
rows.pop()
|
||||
|
||||
results.append({"sheet": sname, "rows": rows, "total_rows": ws.max_row, "total_cols": ws.max_column})
|
||||
|
||||
print(json.dumps({"sheets": sheet_names, "data": results}))
|
||||
`;
|
||||
|
||||
const WRITE_SCRIPT = `
|
||||
import sys, json, os
|
||||
try:
|
||||
import openpyxl
|
||||
from openpyxl.styles import Font, PatternFill, Alignment
|
||||
except ImportError:
|
||||
print(json.dumps({"error": "openpyxl_missing"}))
|
||||
sys.exit(1)
|
||||
|
||||
spec_path = sys.argv[1]
|
||||
with open(spec_path) as f:
|
||||
spec = json.load(f)
|
||||
|
||||
xlsx_path = spec['path']
|
||||
sheet_name = spec.get('sheet', 'Sheet1')
|
||||
data = spec.get('data', [])
|
||||
headers = spec.get('headers', [])
|
||||
start_cell = spec.get('start_cell', 'A1')
|
||||
mode = spec.get('mode', 'create') # create | append | overwrite_sheet
|
||||
|
||||
# Load or create workbook
|
||||
if mode != 'create' and os.path.exists(xlsx_path):
|
||||
wb = openpyxl.load_workbook(xlsx_path)
|
||||
if sheet_name in wb.sheetnames:
|
||||
if mode == 'overwrite_sheet':
|
||||
del wb[sheet_name]
|
||||
ws = wb.create_sheet(sheet_name)
|
||||
else:
|
||||
ws = wb[sheet_name]
|
||||
# append: find last row
|
||||
start_cell = f'A{ws.max_row + 1}'
|
||||
else:
|
||||
ws = wb.create_sheet(sheet_name)
|
||||
else:
|
||||
wb = openpyxl.Workbook()
|
||||
ws = wb.active
|
||||
ws.title = sheet_name
|
||||
|
||||
# Parse start cell
|
||||
import re
|
||||
m = re.match(r'([A-Za-z]+)([0-9]+)', start_cell)
|
||||
col_letter = m.group(1).upper() if m else 'A'
|
||||
start_row = int(m.group(2)) if m else 1
|
||||
start_col = openpyxl.utils.column_index_from_string(col_letter)
|
||||
|
||||
# Write headers
|
||||
row_offset = 0
|
||||
if headers:
|
||||
for ci, h in enumerate(headers):
|
||||
cell = ws.cell(row=start_row, column=start_col + ci, value=h)
|
||||
cell.font = Font(bold=True)
|
||||
cell.fill = PatternFill('solid', fgColor='D9E1F2')
|
||||
row_offset = 1
|
||||
|
||||
# Write data
|
||||
for ri, row in enumerate(data):
|
||||
for ci, val in enumerate(row):
|
||||
ws.cell(row=start_row + row_offset + ri, column=start_col + ci, value=val)
|
||||
|
||||
# Auto-fit column widths (approximate)
|
||||
for col in ws.columns:
|
||||
max_len = 0
|
||||
col_letter2 = openpyxl.utils.get_column_letter(col[0].column)
|
||||
for cell in col:
|
||||
try:
|
||||
if cell.value:
|
||||
max_len = max(max_len, len(str(cell.value)))
|
||||
except:
|
||||
pass
|
||||
ws.column_dimensions[col_letter2].width = min(max(max_len + 2, 8), 50)
|
||||
|
||||
os.makedirs(os.path.dirname(os.path.abspath(xlsx_path)), exist_ok=True)
|
||||
wb.save(xlsx_path)
|
||||
rel = os.path.relpath(xlsx_path)
|
||||
print(json.dumps({"success": True, "path": xlsx_path, "sheet": sheet_name, "rows_written": len(data)}))
|
||||
`;
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function formatTable(rows: string[][]): string {
|
||||
if (rows.length === 0) return '(empty)';
|
||||
const widths = rows[0].map((_, ci) =>
|
||||
Math.min(40, Math.max(...rows.map(r => (r[ci] ?? '').length)))
|
||||
);
|
||||
return rows
|
||||
.map(row => row.map((cell, ci) => (cell ?? '').padEnd(widths[ci])).join(' '))
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
// ─── Tools ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export const excelReadTool = {
|
||||
name: 'excel_read',
|
||||
description: 'Read data from an Excel (.xlsx) file. Returns sheet names, row data as a formatted table. Supports reading a specific sheet, cell range, and row limit.',
|
||||
schema: {
|
||||
path: 'Path to the .xlsx file (absolute or relative to workspace)',
|
||||
sheet: 'Sheet name to read (default: active/first sheet)',
|
||||
range: 'Cell range to read, e.g. "A1:F20" (default: entire sheet)',
|
||||
max_rows: 'Maximum rows to return (default: 500)',
|
||||
},
|
||||
jsonSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
path: { type: 'string', description: 'Path to .xlsx file' },
|
||||
sheet: { type: 'string', description: 'Sheet name (default: first sheet)' },
|
||||
range: { type: 'string', description: 'Cell range e.g. "A1:F20"' },
|
||||
max_rows: { type: 'number', description: 'Max rows to return (default: 500)' },
|
||||
},
|
||||
required: ['path'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
execute: async (args: any): Promise<ToolResult> => {
|
||||
const filePath = String(args?.path || '').trim();
|
||||
if (!filePath) return { success: false, error: 'path is required' };
|
||||
|
||||
const workspacePath = getWorkspacePath(args);
|
||||
const resolved = path.isAbsolute(filePath) ? filePath : path.resolve(workspacePath, filePath);
|
||||
|
||||
if (!fs.existsSync(resolved)) {
|
||||
return { success: false, error: `File not found: ${resolved}` };
|
||||
}
|
||||
const ext = path.extname(resolved).toLowerCase();
|
||||
if (ext !== '.xlsx') {
|
||||
return { success: false, error: 'Only .xlsx files are supported (not .xls or .csv)' };
|
||||
}
|
||||
|
||||
const sheetArg = args?.sheet ? String(args.sheet) : '__ALL__';
|
||||
const rangeArg = args?.range ? String(args.range) : '__NONE__';
|
||||
const maxRows = args?.max_rows ? Math.min(5000, Math.max(1, Number(args.max_rows))) : 500;
|
||||
|
||||
let result: any;
|
||||
try {
|
||||
const { stdout } = await execFileAsync(
|
||||
'python3', ['-c', READ_SCRIPT, resolved, sheetArg, rangeArg, String(maxRows)],
|
||||
{ maxBuffer: 20 * 1024 * 1024, timeout: 30_000 }
|
||||
);
|
||||
result = JSON.parse(stdout.trim());
|
||||
} catch (err: any) {
|
||||
return { success: false, error: `excel_read failed: ${err.message}` };
|
||||
}
|
||||
|
||||
if (result.error) {
|
||||
if (result.error === 'openpyxl_missing') {
|
||||
return { success: false, error: 'openpyxl not installed. Run: pip3 install openpyxl' };
|
||||
}
|
||||
return { success: false, error: result.error };
|
||||
}
|
||||
|
||||
const lines: string[] = [`Sheets: ${result.sheets.join(', ')}\n`];
|
||||
for (const block of result.data) {
|
||||
lines.push(`=== Sheet: ${block.sheet} (${block.total_rows} rows × ${block.total_cols} cols) ===`);
|
||||
lines.push(formatTable(block.rows));
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
stdout: lines.join('\n'),
|
||||
data: { sheets: result.sheets, sheetCount: result.sheets.length },
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
export const excelWriteTool = {
|
||||
name: 'excel_write',
|
||||
description: 'Create or update an Excel (.xlsx) file. Writes data rows with optional bold headers. Supports creating new files, appending rows, or overwriting a specific sheet.',
|
||||
schema: {
|
||||
path: 'Output path for .xlsx file (absolute or relative to workspace)',
|
||||
sheet: 'Sheet name (default: "Sheet1")',
|
||||
headers: 'Array of column header strings (written in bold)',
|
||||
data: 'Array of rows, each row is an array of values',
|
||||
start_cell: 'Top-left cell to start writing, e.g. "A1" (default: "A1")',
|
||||
mode: 'create | append | overwrite_sheet (default: create)',
|
||||
},
|
||||
jsonSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
path: { type: 'string', description: 'Output .xlsx path' },
|
||||
sheet: { type: 'string', description: 'Sheet name (default: Sheet1)' },
|
||||
headers: { type: 'array', items: { type: 'string' }, description: 'Column headers (bold)' },
|
||||
data: { type: 'array', items: { type: 'array' }, description: 'Rows of data (array of arrays)' },
|
||||
start_cell: { type: 'string', description: 'Start cell e.g. "A1"' },
|
||||
mode: { type: 'string', enum: ['create', 'append', 'overwrite_sheet'], description: 'Write mode' },
|
||||
},
|
||||
required: ['path', 'data'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
execute: async (args: any): Promise<ToolResult> => {
|
||||
const filePath = String(args?.path || '').trim();
|
||||
if (!filePath) return { success: false, error: 'path is required' };
|
||||
if (!Array.isArray(args?.data)) return { success: false, error: 'data must be an array of rows' };
|
||||
|
||||
const workspacePath = getWorkspacePath(args);
|
||||
const resolved = path.isAbsolute(filePath) ? filePath : path.resolve(workspacePath, filePath);
|
||||
|
||||
if (path.extname(resolved).toLowerCase() !== '.xlsx') {
|
||||
return { success: false, error: 'Output file must have .xlsx extension' };
|
||||
}
|
||||
|
||||
const spec = {
|
||||
path: resolved,
|
||||
sheet: args?.sheet || 'Sheet1',
|
||||
headers: args?.headers || [],
|
||||
data: args?.data || [],
|
||||
start_cell: args?.start_cell || 'A1',
|
||||
mode: args?.mode || 'create',
|
||||
};
|
||||
|
||||
const tmpSpec = resolved + `.spec_${Date.now()}.json`;
|
||||
try {
|
||||
fs.writeFileSync(tmpSpec, JSON.stringify(spec), 'utf-8');
|
||||
const { stdout } = await execFileAsync(
|
||||
'python3', ['-c', WRITE_SCRIPT, tmpSpec],
|
||||
{ maxBuffer: 5 * 1024 * 1024, timeout: 30_000 }
|
||||
);
|
||||
const result = JSON.parse(stdout.trim());
|
||||
if (result.error) {
|
||||
if (result.error === 'openpyxl_missing') {
|
||||
return { success: false, error: 'openpyxl not installed. Run: pip3 install openpyxl' };
|
||||
}
|
||||
return { success: false, error: result.error };
|
||||
}
|
||||
const filename = path.basename(resolved);
|
||||
const rel = path.relative(workspacePath, resolved);
|
||||
return {
|
||||
success: true,
|
||||
stdout: `Excel file saved: ${filename} | Sheet: ${result.sheet} | Rows written: ${result.rows_written}`,
|
||||
data: { path: resolved, filename, sheet: result.sheet, rowsWritten: result.rows_written },
|
||||
};
|
||||
} catch (err: any) {
|
||||
return { success: false, error: `excel_write failed: ${err.message}` };
|
||||
} finally {
|
||||
try { fs.unlinkSync(tmpSpec); } catch {}
|
||||
}
|
||||
},
|
||||
};
|
||||
+14
-5
@@ -76,14 +76,23 @@ export const pythonEvalTool = {
|
||||
if (args?.packages) {
|
||||
const pkgs = String(args.packages).split(',').map((p: string) => p.trim()).filter((p: string) => /^[a-zA-Z0-9_\-\.]+$/.test(p));
|
||||
for (const pkg of pkgs) {
|
||||
await new Promise<void>((resolve) => {
|
||||
// Try --user first; if blocked by externally-managed-environment (Python 3.12+), retry with --break-system-packages
|
||||
const exitCode = await new Promise<number>((resolve) => {
|
||||
const pip = spawn('python3', ['-m', 'pip', 'install', '--quiet', '--user', pkg], {
|
||||
cwd: workspacePath,
|
||||
timeout: 60_000,
|
||||
cwd: workspacePath, timeout: 60_000,
|
||||
});
|
||||
pip.on('close', resolve);
|
||||
pip.on('error', resolve);
|
||||
pip.on('close', (code) => resolve(code ?? 1));
|
||||
pip.on('error', () => resolve(1));
|
||||
});
|
||||
if (exitCode !== 0) {
|
||||
await new Promise<void>((resolve) => {
|
||||
const pip = spawn('python3', ['-m', 'pip', 'install', '--quiet', '--break-system-packages', pkg], {
|
||||
cwd: workspacePath, timeout: 60_000,
|
||||
});
|
||||
pip.on('close', resolve);
|
||||
pip.on('error', resolve);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ import { sqliteTool } from './sqlite.js';
|
||||
import { weatherSearchTool } from './weather.js';
|
||||
import { emailListTool, emailReadTool, emailSendTool, emailSearchTool, emailDeleteTool } from './email.js';
|
||||
import { koreanLawSearchTool, koreanLawFetchTool, usCaseSearchTool } from './legal.js';
|
||||
import { excelReadTool, excelWriteTool } from './excel.js';
|
||||
|
||||
export interface Tool {
|
||||
name: string;
|
||||
@@ -82,6 +83,8 @@ const TOOL_PROFILE_TOOL_NAMES: Record<Exclude<ToolProfile, 'full'>, ReadonlySet<
|
||||
'korean_law_search',
|
||||
'korean_law_fetch',
|
||||
'us_case_search',
|
||||
'excel_read',
|
||||
'excel_write',
|
||||
]),
|
||||
};
|
||||
|
||||
@@ -215,6 +218,9 @@ class ToolRegistry {
|
||||
this.registerSafe(koreanLawSearchTool);
|
||||
this.registerSafe(koreanLawFetchTool);
|
||||
this.registerSafe(usCaseSearchTool);
|
||||
// Excel tools
|
||||
this.registerSafe(excelReadTool);
|
||||
this.registerSafe(excelWriteTool);
|
||||
// Multi-agent spawn tool
|
||||
this.registerSafe(spawnAgentTool);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user