Spaces:
Sleeping
Sleeping
| // ─── File / VFS tools ───────────────────────────────────────────────────────── | |
| // Contiene: write_file, read_file, iterate_file, list_files, delete_file, | |
| // create_webpage, create_project, find_in_vfs, convert_data, | |
| // generate_mock_data, create_chart | |
| // S560: generate_xlsx · generate_docx · create_zip · edit_image · remove_background · generate_readme | |
| // → estratti in fileToolsGenerators.ts (tryExecuteGenerators) | |
| import { tryExecuteAdvanced } from "./fileToolsAdvanced"; // S742: copy_file, file_info, file_diff, append_to_file, clone_url_to_vfs, batch_file_op | |
| import { vfsAsync, fileBackupsDb } from "../vfsDb"; | |
| import { vfsBulkCommit } from "@/lib/vfsBulkCommit"; // GAP-2 | |
| import DiffMatchPatch from "diff-match-patch"; // G3-S202: patch engine | |
| import { fullDiffValidation } from "../agent/diffValidator"; // S213: pre-apply gate | |
| import * as stateGraph from "../projectStateGraph"; // S403: State Graph | |
| import { tryExecuteGenerators } from "./fileToolsGenerators"; | |
| import { contextBuilder } from "../agent/contextBuilder"; // GAP-3: track file edits for session context // S560: 6 generator tools estratti | |
| import { detectTemplate, getTemplateFiles } from "./templateRegistry"; // P1: scaffolding template-based | |
| // ── Genesis log per-turno (GAP-1) ───────────────────────────────────────────── | |
| // Traccia i file creati ex-novo nel turno corrente (existingBeforeWrite === null). | |
| // Usato da restore_file per distinguere "nessun backup perché file nuovo" vs | |
| // "nessun backup perché file pre-esistente" → nel primo caso: elimina il file (rollback). | |
| let _turnCreatedFiles: string[] = []; | |
| export function resetTurnCreatedFiles(): void { _turnCreatedFiles = []; } | |
| async function _autoSnapshot(path: string, content: string): Promise<void> { | |
| try { | |
| const existing = await fileBackupsDb.list(path); | |
| if (existing.length >= 5) { | |
| const oldest = existing.sort((a, b) => a.ts - b.ts)[0]; | |
| await fileBackupsDb.delete(oldest.id); | |
| } | |
| await fileBackupsDb.save({ | |
| id: `${path}__${Date.now()}`, | |
| path, | |
| content, | |
| ts: Date.now(), | |
| trigger: "pre-write", | |
| }); | |
| } catch { /* non-blocking — mai bloccare il write */ } | |
| } | |
| export async function tryExecute(name: string, args: Record<string, unknown>, signal?: AbortSignal): Promise<string | null> { | |
| if (signal?.aborted) return null; // V-b fix S178 | |
| if (name === "apply_patch") { | |
| const { path, patch, mode = "search_replace" } = args as { path: string; patch: string; mode?: string }; | |
| const original = await vfsAsync.read(path); | |
| if (original === null) { | |
| const all = await vfsAsync.list(); | |
| return `❌ apply_patch: file "${path}" non trovato. Usa write_file per crearlo.\nFile esistenti: ${all.map((f: { path: string }) => f.path).join(", ") || "nessuno"}`; | |
| } | |
| await _autoSnapshot(path, original); // snapshot PRIMA del patch | |
| // S213: gate pre-apply — blocca diff con sintassi non valida o SEARCH non trovata PRIMA dell'apply | |
| const _val = fullDiffValidation(patch, original, path); | |
| if (!_val.preValidation.valid) { | |
| const errs = (_val.preValidation.issues ?? []).join("; ") || "formato diff non valido"; | |
| return `❌ apply_patch: validazione pre-apply fallita — ${errs}. Verifica il formato del patch.`; | |
| } | |
| if (_val.searchFound === false) { | |
| return `❌ apply_patch: testo SEARCH non trovato in "${path}" (gate pre-apply S213). Il file potrebbe essere cambiato — usa read_file per leggere il contenuto attuale, poi aggiorna il patch.`; | |
| } | |
| try { | |
| let patched: string; | |
| if (mode === "diff") { | |
| // Unified diff mode via diff-match-patch | |
| const dmp = new DiffMatchPatch(); | |
| const patches = dmp.patch_fromText(patch); | |
| const [result, applied] = dmp.patch_apply(patches, original); | |
| const failed = applied.filter((ok: boolean) => !ok).length; | |
| if (failed > 0) { | |
| return `⚠️ apply_patch: ${failed}/${applied.length} chunk non applicati — il file sorgente potrebbe essere cambiato. Usa read_file per verificare il contenuto attuale, poi riprova.`; | |
| } | |
| patched = result; | |
| } else { | |
| // search_replace mode — SEARCH già verificata dal gate S213, procedi direttamente | |
| const parts = patch.split(/\nREPLACE:\n/); | |
| if (parts.length !== 2) return `❌ apply_patch: formato non valido. Usa: SEARCH:\n<testo>\nREPLACE:\n<sostituto>`; | |
| const search = parts[0].replace(/^SEARCH:\n/, ""); | |
| const replace = parts[1]; | |
| patched = original.replace(search, replace); | |
| } | |
| await vfsAsync.write(path, patched); | |
| const _written = await vfsAsync.read(path); | |
| if (_written === null) return `⚠️ apply_patch: patch applicata ma file non salvato (quota localStorage esaurita).`; | |
| // S710-5: post-apply verify + rollback automatico | |
| // Confronta il contenuto scritto con il patch atteso — se diverge, ripristina l'originale | |
| if (_written !== patched) { | |
| try { await vfsAsync.write(path, original); } catch { /* best-effort rollback */ } | |
| return `❌ apply_patch: post-apply verify fallita — file scritto diverge dal patch (atteso ${patched.length} chars, trovato ${_written.length} chars). Rollback automatico a "${path}". Usa read_file per verificare, poi riprova.`; | |
| } | |
| // GAP-1: syntax rollback — postValidation già calcolato dal gate pre-apply (zero costo extra) | |
| // Copre il caso: patch logicamente valida ma produce codice sintatticamente rotto | |
| // (parentesi sbilanciate, JSX malformato, import non chiusi, ecc.) | |
| // Catena completa: pre-check → apply → byte-verify → syntax-verify → rollback | |
| if (_val.postValidation && !_val.postValidation.syntaxOk && _val.postValidation.syntaxError) { | |
| try { await vfsAsync.write(path, original); } catch { /* best-effort rollback */ } | |
| return `❌ apply_patch: sintassi non valida dopo il patch — ${_val.postValidation.syntaxError}. Rollback automatico a "${path}". Correggi parentesi/tag sbilanciati nel patch e riprova.`; | |
| } | |
| const dmp2 = new DiffMatchPatch(); | |
| const diff = dmp2.diff_main(original, patched); | |
| const linesChanged = diff.filter(([op]: [number, string]) => op !== 0).length; | |
| // GAP-6 (S765-2): hint TypeScript BLOCCANTE — formato ACTION_REQUIRED interpretato dall'agent loop | |
| // come step obbligatorio (non opzionale come 💡). Previene silent type errors post-patch. | |
| // L'agent deve: 1) chiamare lint_code 2) se errori → restore_file + correggere il patch | |
| const _tsHint = (path.endsWith(".ts") || path.endsWith(".tsx")) | |
| ? `\n⚡ AZIONE OBBLIGATORIA POST-PATCH: chiama lint_code { "path": "${path}" }\n` + | |
| ` Se lint_code riporta errori TypeScript → rollback con restore_file "${path}" e correggi.` | |
| : ""; | |
| try { contextBuilder.recordFileEdit(path); } catch { /* non-blocking — context track */ } | |
| // WORLDMODEL-SYNC: apply_patch aggiorna il file → sincronizza worldModel (stessa chain di write_file) | |
| try { const { updateFileState } = await import("../worldModel"); updateFileState(path, { exists: true, lastModified: Date.now(), layer: "app" }); } catch { /* non-blocking */ } | |
| return `✅ Patch applicata: "${path}" — ${linesChanged} chunk modificati, file ora ${patched.length} chars.${_tsHint}`; | |
| } catch (e) { | |
| return `❌ apply_patch errore: ${e instanceof Error ? e.message : String(e)}`; | |
| } | |
| } | |
| if (name === "write_file") { | |
| const { path, content, mime_type = "text/plain" } = args as { path: string; content: string; mime_type?: string }; | |
| const existingBeforeWrite = await vfsAsync.read(path); | |
| if (!existingBeforeWrite) _turnCreatedFiles.push(path); // GAP-1: genesis log | |
| if (existingBeforeWrite) await _autoSnapshot(path, existingBeforeWrite); | |
| await vfsAsync.write(path, content, mime_type); | |
| // Bug fix: verifica che il file sia stato effettivamente scritto (quota localStorage) | |
| const _written = await vfsAsync.read(path); | |
| if (_written === null) return `⚠️ write_file: file "${path}" non salvato (quota localStorage esaurita). Riduci il contenuto o cancella file vecchi con delete_file.`; | |
| // S403: State Graph — marca il file come generato (non ancora verificato) | |
| stateGraph.markGenerated(path); | |
| try { contextBuilder.recordFileEdit(path); } catch { /* non-blocking — contextBuilder cieco senza questa call */ } // GAP-3 | |
| // WORLDMODEL-SYNC: aggiorna worldModel dopo write_file — era mai sincronizzato dal loop agente | |
| // Il system prompt usa renderWorldModel() che era sempre stantio dopo operazioni agente (cerchio non chiuso) | |
| try { const { updateFileState } = await import("../worldModel"); updateFileState(path, { exists: true, lastModified: Date.now(), layer: "app" }); } catch { /* non-blocking */ } | |
| // S407: Architecture Graph — invalida cache e schedula rebuild (debounce 2s) | |
| try { const { scheduleArchRefresh } = await import("@/lib/projectArchGraph"); scheduleArchRefresh(); } catch { /* silent */ } | |
| // P0: Blast radius warning — avvisa se il file modificato è importato da ≥3 altri file | |
| let _blastWarning = ""; | |
| try { | |
| const { getLastArchGraph } = await import("@/lib/projectArchGraph"); | |
| const _graph = getLastArchGraph(); | |
| if (_graph) { | |
| const _node = _graph.nodes[path]; | |
| if (_node && (_node.importedBy?.length ?? 0) >= 3) { | |
| const _names = _node.importedBy.slice(0, 5).map((p: string) => `\`${p.split("/").pop()}\``).join(", "); | |
| _blastWarning = `\n⚠️ **Blast radius**: \`${path.split("/").pop()}\` è importato da ${_node.importedBy.length} file (${_names}${_node.importedBy.length > 5 ? ", …" : ""}). Verifica le regressioni.`; | |
| } | |
| } | |
| } catch { /* non-blocking */ } | |
| // AUT-1: auto-queue nel DownloadQueue — il file è subito scaricabile/condivisibile su iPhone | |
| try { | |
| const _aqBlob = new Blob([content], { type: mime_type || "text/plain" }); | |
| const _aqUrl = URL.createObjectURL(_aqBlob); | |
| const _aqName = path.split("/").pop() || "file"; | |
| window.dispatchEvent(new CustomEvent("agent:download-ready", { detail: { url: _aqUrl, filename: _aqName } })); | |
| } catch { /* non-blocking */ } | |
| // AUT-6: segnala VFS dirty -> AutoGitSync countdown 5 min | |
| try { window.dispatchEvent(new CustomEvent("agent:vfs-dirty")); } catch { /* */ } | |
| return `✅ Scritto: ${path} (${content.length} caratteri)${_blastWarning}`; | |
| } | |
| if (name === "write_files") { | |
| // GAP-2: atomic multi-file commit — o tutti o nessuno (Dexie transaction) | |
| const { patches } = args as { patches: Array<{ op: "write" | "delete"; path: string; content?: string; type?: string }> }; | |
| if (!Array.isArray(patches) || patches.length === 0) { | |
| return `❌ write_files: "patches" deve essere un array non vuoto.`; | |
| } | |
| // GAP-1: genesis log — traccia file write che sono nuovi (non esistevano prima) | |
| for (const _gp of patches) { | |
| if (_gp.op === 'write') { | |
| const _gex = await vfsAsync.read(_gp.path).catch(() => null); | |
| if (!_gex && !_turnCreatedFiles.includes(_gp.path)) _turnCreatedFiles.push(_gp.path); | |
| } | |
| } | |
| const result = await vfsBulkCommit.apply(patches); | |
| if (!result.ok) { | |
| return `❌ write_files: commit atomico fallito — ${result.error}\nNessun file è stato modificato (rollback automatico).`; | |
| } | |
| // Post-commit: snapshot, stateGraph, contextBuilder, archGraph per ogni file scritto | |
| for (const p of result.written) { | |
| const written = patches.find(x => x.path === p); | |
| if (written?.content) { | |
| await _autoSnapshot(p, written.content).catch(() => {}); | |
| stateGraph.markGenerated(p); | |
| try { contextBuilder.recordFileEdit(p); } catch { /* non-blocking */ } | |
| } | |
| } | |
| try { | |
| const { scheduleArchRefresh } = await import("@/lib/projectArchGraph"); | |
| scheduleArchRefresh(); | |
| } catch { /* non-blocking */ } | |
| // AUT-1: auto-queue tutti i file scritti — accessibili subito su iPhone | |
| try { | |
| for (const _wfp of result.written) { | |
| const _wfpatch = patches.find((x: { op: string; path: string; content?: string; type?: string }) => x.path === _wfp); | |
| if (_wfpatch?.content) { | |
| const _aqBlob = new Blob([_wfpatch.content], { type: _wfpatch.type || "text/plain" }); | |
| const _aqUrl = URL.createObjectURL(_aqBlob); | |
| window.dispatchEvent(new CustomEvent("agent:download-ready", { detail: { url: _aqUrl, filename: _wfp.split("/").pop() || "file" } })); | |
| } | |
| } | |
| } catch { /* non-blocking */ } | |
| // AUT-6: segnala VFS dirty -> AutoGitSync | |
| if (result.written.length > 0) { try { window.dispatchEvent(new CustomEvent("agent:vfs-dirty")); } catch { /* */ } } | |
| const summary = [ | |
| result.written.length > 0 ? `✅ Scritti (${result.written.length}): ${result.written.map(p => `\`${p.split("/").pop()}\``).join(", ")}` : null, | |
| result.deleted.length > 0 ? `🗑 Eliminati (${result.deleted.length}): ${result.deleted.map(p => `\`${p.split("/").pop()}\``).join(", ")}` : null, | |
| ].filter(Boolean).join("\n"); | |
| // WORLDMODEL-SYNC: aggiorna worldModel per ogni file scritto/eliminato nel commit atomico | |
| try { | |
| const { updateFileState } = await import("../worldModel"); | |
| for (const _wfp of patches) { | |
| if (_wfp.op === 'write' && _wfp.path) updateFileState(_wfp.path, { exists: true, lastModified: Date.now(), layer: "app" }); | |
| if (_wfp.op === 'delete' && _wfp.path) updateFileState(_wfp.path, { exists: false, lastModified: Date.now(), layer: "app" }); | |
| } | |
| } catch { /* non-blocking */ } | |
| return `✅ write_files: commit atomico completato\n${summary}`; | |
| } | |
| if (name === "restore_file") { | |
| const { path, version = "latest" } = args as { path: string; version?: string | number }; | |
| if (!path) return "❌ restore_file: fornisci path."; | |
| try { | |
| const snapshots = (await fileBackupsDb.list(String(path))) as Array<{ id: string; path: string; content: string; ts: number; trigger: string }>; | |
| if (!snapshots.length) { | |
| // GAP-1: genesis log — file creato ex-novo in questo turno → eliminalo (rollback) | |
| if (_turnCreatedFiles.includes(String(path))) { | |
| await vfsAsync.delete(String(path)); | |
| return `🗑 File \`${path}\` creato in questo task rimosso (rollback — nessun backup pre-esistente).`; | |
| } | |
| return `❌ Nessun backup trovato per "${path}". Gli snapshot vengono creati automaticamente ad ogni write_file su file pre-esistenti.`; | |
| } | |
| const sorted = [...snapshots].sort((a, b) => b.ts - a.ts); | |
| const idx = version === "latest" ? 0 : Math.max(0, Number(version) - 1); | |
| const snap = sorted[idx]; | |
| if (!snap) return `❌ restore_file: snapshot #${version} non trovato. Disponibili: ${sorted.length}.\nVersioni:\n${sorted.map((s, i) => ` ${i + 1}. ${new Date(s.ts).toLocaleString("it-IT")} (${s.trigger})`).join("\n")}`; | |
| const age = Math.round((Date.now() - snap.ts) / 60_000); | |
| await vfsAsync.write(String(path), snap.content, "text/plain"); | |
| // WORLDMODEL-SYNC: restore_file ripristina un file → worldModel deve saperlo | |
| try { const { updateFileState } = await import("../worldModel"); updateFileState(String(path), { exists: true, lastModified: Date.now(), layer: "app" }); } catch { /* non-blocking */ } | |
| return `✅ Ripristinato "${path}" alla versione di ${age} min fa (${snap.content.length} chars, versione ${idx + 1}/${sorted.length}).`; | |
| } catch (e) { return `❌ restore_file: ${e instanceof Error ? e.message : String(e)}`; } | |
| } | |
| if (name === "read_file") { | |
| const { path, offset = 0, limit, offset_page: _op, limit_pages: _lp } = args as { path: string; offset?: number; limit?: number; offset_page?: number; limit_pages?: number }; | |
| void _op; void _lp; | |
| const content = await vfsAsync.read(path); | |
| if (content === null) { | |
| const all = await vfsAsync.list(); | |
| return `❌ File non trovato: ${path}\nDisponibili: ${all.map((f: { path: string }) => f.path).join(", ") || "nessuno"}`; | |
| } | |
| const MAX_CHUNK = 8000; | |
| const start = Math.min(Number(offset) || 0, content.length); | |
| const end = limit | |
| ? Math.min(start + Number(limit), content.length) | |
| : Math.min(start + MAX_CHUNK, content.length); | |
| const slice = content.slice(start, end); | |
| const hasMore = end < content.length; | |
| if (content.length <= MAX_CHUNK && start === 0) return slice; | |
| const header = `📄 **${path}** — ${content.length} chars totali (mostra ${start}–${end})`; | |
| return hasMore | |
| ? `${header}\n\`\`\`\n${slice}\n\`\`\`\n_…${content.length - end} chars rimanenti — usa \`read_file\` con \`offset=${end}\` per continuare_` | |
| : `${header}\n\`\`\`\n${slice}\n\`\`\``; | |
| } | |
| if (name === "iterate_file") { | |
| const { path, instruction } = args as { path: string; instruction: string }; | |
| const content = await vfsAsync.read(path); | |
| if (content === null) { | |
| const all = await vfsAsync.list(); | |
| return `❌ File non trovato: ${path}\nDisponibili: ${all.map(f => f.path).join(", ") || "nessuno"}`; | |
| } | |
| const preview = content.length > 6000 ? content.slice(0, 6000) + `\n…(+${content.length - 6000} chars)` : content; | |
| return `📄 **${path}** (${content.length} chars)\n\`\`\`\n${preview}\n\`\`\`\n\n🔧 **Istruzione:** ${instruction}\n\n_Applica le modifiche e usa \`write_file\` con path \`${path}\` per salvare._`; | |
| } | |
| if (name === "list_files") { | |
| const { path: dirFilter } = args as { path?: string }; | |
| const allFiles = await vfsAsync.list() as Array<{ path: string; size?: number; mime?: string }>; | |
| const prefix = dirFilter ? (dirFilter.endsWith("/") ? dirFilter : dirFilter + "/") : null; | |
| const files = prefix ? allFiles.filter(f => f.path.startsWith(prefix)) : allFiles; | |
| if (!files.length) { | |
| return dirFilter | |
| ? `📁 Nessun file in \`${dirFilter}\` — directory vuota o inesistente.` | |
| : "📁 Filesystem virtuale vuoto — nessun file creato ancora."; | |
| } | |
| const label = dirFilter ? `File in \`${dirFilter}\` (${files.length})` : `File nel VFS (${files.length})`; | |
| const lines = files.map(f => `• \`${f.path}\`${f.size !== undefined ? ` (${f.size} bytes)` : ""}${f.mime ? ` [${f.mime}]` : ""}`); | |
| return `📁 **${label}:**\n${lines.join("\n")}`; | |
| } | |
| if (name === "delete_file") { | |
| const { path } = args as { path: string }; | |
| const existing = await vfsAsync.read(path); | |
| if (existing === null) { | |
| const all = await vfsAsync.list(); | |
| return `❌ File non trovato: ${path}\nDisponibili: ${all.map((f: { path: string }) => f.path).join(", ") || "nessuno"}`; | |
| } | |
| await vfsAsync.delete(path); | |
| // WORLDMODEL-SYNC: aggiorna worldModel dopo delete_file | |
| try { const { updateFileState } = await import("../worldModel"); updateFileState(path, { exists: false, lastModified: Date.now(), layer: "app" }); } catch { /* non-blocking */ } | |
| return `✅ Cancellato: \`${path}\``; | |
| } | |
| if (name === "move_file") { | |
| const { from, to } = args as { from: string; to: string }; | |
| const content = await vfsAsync.read(from); | |
| if (content === null) { | |
| const all = await vfsAsync.list() as Array<{ path: string }>; | |
| return `❌ File non trovato: ${from}\nDisponibili: ${all.map(f => f.path).join(", ") || "nessuno"}`; | |
| } | |
| const files = await vfsAsync.list() as Array<{ path: string; type?: string }>; | |
| const fileInfo = files.find(f => f.path === from); | |
| const mime = fileInfo?.type ?? "text/plain"; | |
| const destExisting = await vfsAsync.read(to); | |
| if (destExisting !== null) return `❌ move_file: destinazione \`${to}\` esiste già — cancellala prima con delete_file se vuoi sovrascriverla.`; | |
| await vfsAsync.write(to, content, mime); | |
| await vfsAsync.delete(from); | |
| // WORLDMODEL-SYNC: move_file → from non esiste più, to è nuovo | |
| try { const { updateFileState } = await import("../worldModel"); updateFileState(from, { exists: false, lastModified: Date.now(), layer: "app" }); updateFileState(to, { exists: true, lastModified: Date.now(), layer: "app" }); } catch { /* non-blocking */ } | |
| return `✅ Spostato/Rinominato: \`${from}\` → \`${to}\` (${content.length} chars)`; | |
| } | |
| if (name === "create_webpage") { | |
| const { filename, html, show_preview = true } = args as { filename: string; html: string; show_preview?: boolean }; | |
| const path = filename.startsWith("/") ? filename : `/pages/${filename}`; | |
| await vfsAsync.write(path, html, "text/html"); | |
| if (show_preview) { | |
| try { window.dispatchEvent(new CustomEvent("agente:preview_open", { detail: { path, html } })); } catch { /* SSR/test safe */ } | |
| } | |
| return `✅ Pagina creata: ${path} (${html.length} chars)${show_preview ? " — anteprima aperta" : ""}`; | |
| } | |
| if (name === "create_project") { | |
| const { files, project_name = "project", template: _tplArg, goal: _tplGoal } = args as { | |
| files: Array<{ path: string; content: string; mime?: string }>; | |
| project_name?: string; | |
| template?: string; | |
| goal?: string; | |
| }; | |
| if (!Array.isArray(files) || files.length === 0) return "❌ create_project: fornisci almeno un file."; | |
| const created: string[] = []; | |
| const errors: string[] = []; | |
| // P1: inject template files prima dei file LLM (template-based scaffolding) | |
| const _tplName = detectTemplate(_tplGoal ?? project_name, _tplArg); | |
| const _tplFiles = _tplName ? getTemplateFiles(_tplName) : []; | |
| let _tplNote = ""; | |
| if (_tplFiles.length) { | |
| _tplNote = ` (template: ${_tplName}, ${_tplFiles.length} file base)`; | |
| for (const f of _tplFiles) { | |
| // Solo se il file non è già nella lista LLM (override LLM ha precedenza) | |
| const _hasOverride = files.some(lf => lf.path === f.path || lf.path === `/${project_name}/${f.path}`); | |
| if (!_hasOverride) { | |
| try { | |
| const p = f.path.startsWith("/") ? f.path : `/${project_name}/${f.path}`; | |
| await vfsAsync.write(p, f.content, f.mime ?? "text/plain"); | |
| created.push(p); | |
| } catch { /* best-effort template file */ } | |
| } | |
| } | |
| } | |
| for (const f of files) { | |
| try { | |
| const p = f.path.startsWith("/") ? f.path : `/${project_name}/${f.path}`; | |
| await vfsAsync.write(p, f.content, f.mime ?? "text/plain"); | |
| created.push(p); | |
| } catch (e) { errors.push(`${f.path}: ${e instanceof Error ? e.message : String(e)}`); } | |
| } | |
| let out = `✅ **Progetto "${project_name}" creato — ${created.length} file${_tplNote}**\n\n`; | |
| out += created.map(p => `• \`${p}\``).join("\n"); | |
| if (errors.length) out += `\n\n⚠️ Errori:\n${errors.map(e => `• ${e}`).join("\n")}`; | |
| // GAP-1: genesis log — tutti i file del progetto sono nuovi | |
| for (const _cp of created) { if (!_turnCreatedFiles.includes(_cp)) _turnCreatedFiles.push(_cp); } | |
| return out; | |
| } | |
| if (name === "find_in_vfs") { | |
| const { query, case_sensitive = false, file_type, max_results = 15 } = args as { query: string; case_sensitive?: boolean; file_type?: string; max_results?: number }; | |
| const files = await vfsAsync.list() as Array<{ path: string; name?: string }>; | |
| const targets = file_type ? files.filter(f => f.path.endsWith(`.${file_type}`)) : files; | |
| let rx: RegExp; | |
| try { rx = new RegExp(query, case_sensitive ? "g" : "gi"); } catch { return `❌ find_in_vfs: regex non valida: "${query}"`; } | |
| const results: string[] = []; | |
| let fileHits = 0; | |
| for (const f of targets) { | |
| if (fileHits >= max_results) { results.push(`\n_…e altri ${targets.length - fileHits} file non mostrati_`); break; } | |
| let content = ""; try { content = await vfsAsync.read(f.path) ?? ""; } catch { continue; } | |
| if (typeof content !== "string") continue; | |
| const lines = content.split("\n"); | |
| const matches: string[] = []; | |
| for (let i = 0; i < lines.length; i++) { | |
| rx.lastIndex = 0; | |
| if (rx.test(lines[i])) matches.push(` L${i + 1}: \`${lines[i].trim().slice(0, 100)}\``); | |
| } | |
| if (matches.length) { | |
| results.push(`**${f.path}** (${matches.length} match)\n${matches.slice(0, 6).join("\n")}`); | |
| fileHits++; | |
| } | |
| } | |
| if (!results.length) return `🔍 Nessun risultato per \`${query}\` nel VFS${file_type ? ` (*.${file_type})` : ""}.`; | |
| return `🔍 **Ricerca VFS: \`${query}\`** — ${fileHits} file con match\n\n${results.join("\n\n")}`; | |
| } | |
| if (name === "convert_data") { | |
| const { input, from_format, to_format, save_path } = args as { input: string; from_format: string; to_format: string; save_path?: string }; | |
| try { | |
| let result = ""; | |
| const parseCsv = (txt: string, delim = ",") => { | |
| const rows = txt.trim().split(/\r?\n/); | |
| const headers = rows[0].split(delim).map(h => h.trim().replace(/^"|"$/g, "")); | |
| return rows.slice(1).map(r => { const v = r.split(delim).map(x => x.trim().replace(/^"|"$/g, "")); return Object.fromEntries(headers.map((h, i) => [h, v[i] ?? ""])); }); | |
| }; | |
| const toJson = (data: unknown) => JSON.stringify(data, null, 2); | |
| const jsonToCsv = (data: unknown[]) => { const keys = Object.keys(data[0] as object); return [keys.join(","), ...data.map(r => keys.map(k => JSON.stringify((r as Record<string,unknown>)[k] ?? "")).join(","))].join("\n"); }; | |
| const mdToHtml = (md: string) => md.replace(/^### (.+)$/gm, "<h3>$1</h3>").replace(/^## (.+)$/gm, "<h2>$1</h2>").replace(/^# (.+)$/gm, "<h1>$1</h1>").replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>").replace(/\*(.+?)\*/g, "<em>$1</em>").replace(/\n/g, "<br>"); | |
| if (from_format === "csv" && to_format === "json") result = toJson(parseCsv(input)); | |
| else if (from_format === "tsv" && to_format === "json") result = toJson(parseCsv(input, "\t")); | |
| else if (from_format === "json" && to_format === "csv") { const d = JSON.parse(input); result = jsonToCsv(Array.isArray(d) ? d : [d]); } | |
| else if (from_format === "json" && to_format === "tsv") { const d = JSON.parse(input); const csv = jsonToCsv(Array.isArray(d) ? d : [d]); result = csv.replace(/,/g, "\t"); } | |
| else if (from_format === "json" && to_format === "yaml") { | |
| const toYaml = (obj: unknown, indent = 0): string => { | |
| const pad = " ".repeat(indent); | |
| if (obj === null) return "null"; | |
| if (typeof obj !== "object") return typeof obj === "string" ? `"${obj}"` : String(obj); | |
| if (Array.isArray(obj)) return obj.map(v => `${pad}- ${toYaml(v, indent + 2).trimStart()}`).join("\n"); | |
| return Object.entries(obj as Record<string, unknown>).map(([k, v]) => typeof v === "object" && v ? `${pad}${k}:\n${toYaml(v, indent + 2)}` : `${pad}${k}: ${toYaml(v, 0)}`).join("\n"); | |
| }; | |
| result = toYaml(JSON.parse(input)); | |
| } | |
| else if (from_format === "yaml" && to_format === "json") { | |
| const mod = await import("https://esm.sh/js-yaml" as never) as { default?: { load: (s:string) => unknown }; load?: (s:string) => unknown }; | |
| const load = mod.default?.load ?? mod.load; | |
| result = load ? toJson(load(input)) : "❌ yaml parser non disponibile"; | |
| } | |
| else if (from_format === "markdown" && to_format === "html") result = `<!DOCTYPE html><html><head><meta charset="utf-8"></head><body>${mdToHtml(input)}</body></html>`; | |
| else if (from_format === "html" && to_format === "markdown") result = input.replace(/<h[1-3][^>]*>(.+?)<\/h[1-3]>/gi, "### $1\n").replace(/<strong[^>]*>(.+?)<\/strong>/gi, "**$1**").replace(/<em[^>]*>(.+?)<\/em>/gi, "*$1*").replace(/<br\s*\/?>/gi, "\n").replace(/<[^>]+>/g, "").trim(); | |
| else if (from_format === "xml" && to_format === "json") { | |
| const parse = (xml: string): unknown => { | |
| const doc = new DOMParser().parseFromString(xml, "text/xml"); | |
| const nodeToObj = (n: Element | Document): unknown => { | |
| if (n.nodeType === 3) return n.textContent?.trim(); | |
| const el = n as Element; | |
| const children = [...el.childNodes].filter(c => c.nodeType !== 8); | |
| if (children.length === 1 && children[0].nodeType === 3) return children[0].textContent?.trim(); | |
| const obj: Record<string, unknown> = {}; | |
| for (const c of children as Element[]) { | |
| const k = c.nodeName; const v = nodeToObj(c); | |
| if (obj[k]) obj[k] = [].concat(obj[k] as never).concat(v as never); else obj[k] = v; | |
| } | |
| return obj; | |
| }; | |
| return nodeToObj(doc.documentElement); | |
| }; | |
| result = toJson(parse(input)); | |
| } | |
| else if (from_format === "markdown" && to_format === "pdf") { | |
| const mdToHtmlLocal = (md: string) => md | |
| .replace(/^### (.+)$/gm, "<h3>$1</h3>").replace(/^## (.+)$/gm, "<h2>$1</h2>") | |
| .replace(/^# (.+)$/gm, "<h1>$1</h1>").replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>") | |
| .replace(/\*(.+?)\*/g, "<em>$1</em>").replace(/`([^`]+)`/g, "<code>$1</code>") | |
| .replace(/^- (.+)$/gm, "<li>$1</li>").replace(/(<li>.*<\/li>\n?)+/g, "<ul>$&</ul>") | |
| .replace(/\n/g, "<br>"); | |
| const html = mdToHtmlLocal(input); | |
| const win = typeof window !== "undefined" ? window.open("", "_blank") : null; | |
| if (!win) return "❌ Popup bloccato — abilita i popup per esportare PDF."; | |
| win.document.write(`<!DOCTYPE html><html><head><meta charset="utf-8"><style>@page{margin:2cm}body{font-family:system-ui,sans-serif;line-height:1.6;color:#111}h1,h2,h3{margin-top:1.5em}code{background:#f4f4f4;padding:0.1em 0.4em;border-radius:3px}</style></head><body>${html}</body></html>`); | |
| win.document.close(); | |
| setTimeout(() => { try { win.print(); } catch { /**/ } }, 400); | |
| return "✅ Dialogo PDF aperto — salva come PDF dal browser. (Suggerimento: usa A4 verticale con margini normali)"; | |
| } | |
| else if (from_format === "json" && to_format === "xml") { | |
| const toXml = (obj: unknown, tag = "root"): string => { | |
| if (obj === null || obj === undefined) return `<${tag}/>`; | |
| if (typeof obj !== "object") return `<${tag}>${String(obj).replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">")}</${tag}>`; | |
| if (Array.isArray(obj)) return obj.map((v, i) => toXml(v, `item${i}`)).join("\n"); | |
| return `<${tag}>\n${Object.entries(obj as Record<string,unknown>).map(([k,v]) => " " + toXml(v,k)).join("\n")}\n</${tag}>`; | |
| }; | |
| result = `<?xml version="1.0" encoding="UTF-8"?>\n${toXml(JSON.parse(input))}`; | |
| } | |
| else if (from_format === "csv" && to_format === "markdown") { | |
| const rows = input.trim().split(/\r?\n/).map(r => r.split(",").map(c => c.trim().replace(/^"|"$/g,""))); | |
| if (!rows.length) result = ""; | |
| else { const w = rows[0].map((_h,i) => Math.max(...rows.map(r => (r[i]??'').length),3)); result = [`| ${rows[0].map((h,i)=>h.padEnd(w[i])).join(" | ")} |`, `| ${w.map(n=>"-".repeat(n)).join(" | ")} |`, ...rows.slice(1).map(r => `| ${rows[0].map((_,i)=>(r[i]??"").padEnd(w[i])).join(" | ")} |`)].join("\n"); } | |
| } | |
| else if (from_format === "json" && to_format === "html_table") { | |
| const arr = Array.isArray(JSON.parse(input)) ? JSON.parse(input) as Record<string,unknown>[] : [JSON.parse(input) as Record<string,unknown>]; | |
| const hdrs = arr.length ? Object.keys(arr[0]) : []; | |
| const esc2 = (v: unknown) => String(v ?? "").replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">"); | |
| result = `<!DOCTYPE html><html><head><meta charset="utf-8"><style>table{border-collapse:collapse;width:100%}th,td{border:1px solid #ddd;padding:8px;text-align:left}th{background:#f5f5f5;font-weight:bold}tr:nth-child(even){background:#fafafa}</style></head><body><table><thead><tr>${hdrs.map(h=>`<th>${esc2(h)}</th>`).join("")}</tr></thead><tbody>${arr.map(row=>`<tr>${hdrs.map(h=>`<td>${esc2(row[h])}</td>`).join("")}</tr>`).join("")}</tbody></table></body></html>`; | |
| } | |
| else if (from_format === "text" && to_format === "base64") { result = btoa(unescape(encodeURIComponent(input))); } | |
| else if (from_format === "base64" && to_format === "text") { try { result = decodeURIComponent(escape(atob(input.trim()))); } catch { result = atob(input.trim()); } } | |
| else if (from_format === "tsv" && to_format === "csv") { result = input.split("\n").map(l => l.split("\t").map(c => c.includes(",") || c.includes('"') ? `"${c.replace(/"/g,'""')}"` : c).join(",")).join("\n"); } | |
| else if (from_format === "json" && to_format === "sql") { | |
| const arr2 = Array.isArray(JSON.parse(input)) ? JSON.parse(input) as Record<string,unknown>[] : [JSON.parse(input) as Record<string,unknown>]; | |
| const t2 = arr2.length ? Object.keys(arr2[0]) : []; | |
| const esc3 = (v: unknown) => v === null ? "NULL" : typeof v === "number" ? String(v) : `'${String(v).replace(/'/g,"''")}'`; | |
| result = arr2.map(r => `INSERT INTO table_name (${t2.join(",")}) VALUES (${t2.map(k=>esc3(r[k])).join(",")});`).join("\n"); | |
| } | |
| else return `❌ convert_data: conversione ${from_format}→${to_format} non supportata.`; | |
| if (save_path) await vfsAsync.write(save_path, result, "text/plain"); | |
| return `✅ **Conversione ${from_format.toUpperCase()}→${to_format.toUpperCase()}**${save_path ? ` salvata in \`${save_path}\`` : ""}\n\n\`\`\`${to_format}\n${result.slice(0, 3000)}\n\`\`\``; | |
| } catch (e) { return `❌ convert_data: ${e instanceof Error ? e.message : String(e)}`; } | |
| } | |
| if (name === "generate_mock_data") { | |
| const { schema, count = 10, format = "json", save_path } = args as { schema: string; count?: number; format?: string; save_path?: string }; | |
| const n = Math.min(Math.max(Number(count) || 10, 1), 100); | |
| const s = schema.toLowerCase(); | |
| const rnd = <T>(arr: T[]): T => arr[Math.floor(Math.random() * arr.length)]; | |
| const rndInt = (min: number, max: number) => Math.floor(Math.random() * (max - min + 1)) + min; | |
| const FIRST = ["Alice","Bob","Carlo","Diana","Elena","Fabio","Giulia","Hassan","Ivan","Lena","Marco","Nina","Oscar","Paola","Qin","Rosa","Sara","Tom","Uma","Vera"]; | |
| const LAST = ["Rossi","Ferrari","Esposito","Bianchi","Romano","Colombo","Ricci","Marino","Greco","Bruno","Gallo","Conti","De Luca","Mancini","Costa"]; | |
| const DOMAINS = ["gmail.com","yahoo.com","outlook.com","example.com","mail.it"]; | |
| const CATS = ["elettronica","abbigliamento","libri","casa","sport","cibo","beauty","auto","giochi","ufficio"]; | |
| const CITIES = ["Roma","Milano","Napoli","Torino","Bologna","Firenze","Venezia","Bari","Palermo","Genova"]; | |
| const WORDS = ["lorem","ipsum","dolor","sit","amet","consectetur","adipiscing","elit","sed","do","eiusmod","tempor","incididunt","ut","labore"]; | |
| const sent = () => Array.from({length:rndInt(6,14)},()=>WORDS[rndInt(0,WORDS.length-1)]).join(" ") + "."; | |
| const records: Record<string, unknown>[] = []; | |
| for (let i = 0; i < n; i++) { | |
| const firstName = rnd(FIRST), lastName = rnd(LAST); | |
| const r: Record<string, unknown> = {}; | |
| if (s.includes("id") || s.includes("user") || s.includes("product") || s.includes("order")) r.id = `${String(rndInt(1000,9999))}`; | |
| if (s.includes("name") || s.includes("user")) r.name = `${firstName} ${lastName}`; | |
| if (s.includes("first")) r.firstName = firstName; | |
| if (s.includes("last")) r.lastName = lastName; | |
| if (s.includes("email")) r.email = `${firstName.toLowerCase()}.${lastName.toLowerCase()}${rndInt(1,99)}@${rnd(DOMAINS)}`; | |
| if (s.includes("age")) r.age = rndInt(18, 70); | |
| if (s.includes("phone")) r.phone = `+39 ${rndInt(300,399)} ${rndInt(1000000,9999999)}`; | |
| if (s.includes("city") || s.includes("address")) r.city = rnd(CITIES); | |
| if (s.includes("address")) r.address = `Via ${rnd(LAST)} ${rndInt(1,150)}, ${rnd(CITIES)}`; | |
| if (s.includes("title") || s.includes("product")) r.title = `${rnd(CATS).charAt(0).toUpperCase() + rnd(CATS).slice(1)} ${rnd(LAST)} ${rndInt(100,999)}`; | |
| if (s.includes("price")) r.price = parseFloat((rndInt(5,500) + Math.random()).toFixed(2)); | |
| if (s.includes("category") || s.includes("cat")) r.category = rnd(CATS); | |
| if (s.includes("body") || s.includes("content") || s.includes("description")) r.description = `${sent()} ${sent()}`; | |
| if (s.includes("post") || s.includes("author")) r.author = `${firstName} ${lastName}`; | |
| if (s.includes("date") || s.includes("created")) r.createdAt = new Date(Date.now() - rndInt(0, 365*24*3600*1000)).toISOString(); | |
| if (s.includes("status")) r.status = rnd(["active","inactive","pending","archived"]); | |
| if (s.includes("score") || s.includes("rating")) r.rating = parseFloat((rndInt(1,5) + Math.random() * 0.9).toFixed(1)); | |
| if (s.includes("lat") || s.includes("geo")) { r.lat = parseFloat((rndInt(36,47) + Math.random()).toFixed(6)); r.lng = parseFloat((rndInt(6,18) + Math.random()).toFixed(6)); } | |
| if (!Object.keys(r).length) { r.id = String(i+1); r.value = rndInt(1,1000); r.label = `Item ${i+1}`; } | |
| records.push(r); | |
| } | |
| let result = ""; | |
| if (format === "csv") { | |
| const keys = Object.keys(records[0]); | |
| result = [keys.join(","), ...records.map(r => keys.map(k => JSON.stringify(r[k] ?? "")).join(","))].join("\n"); | |
| } else if (format === "typescript") { | |
| const keys = Object.keys(records[0]); | |
| const iface = `export interface MockItem {\n${keys.map(k => ` ${k}: ${typeof records[0][k]};`).join("\n")}\n}\n\nexport const mockData: MockItem[] = ${JSON.stringify(records, null, 2)};`; | |
| result = iface; | |
| } else { | |
| result = JSON.stringify(records, null, 2); | |
| } | |
| if (save_path) await vfsAsync.write(save_path, result, "text/plain"); | |
| return `✅ **Mock data generata** (${n} record, ${format.toUpperCase()})${save_path ? ` → \`${save_path}\`` : ""}\n\n\`\`\`${format === "typescript" ? "typescript" : format}\n${result.slice(0, 2000)}\n\`\`\`${result.length > 2000 ? "\n_…troncato_" : ""}`; | |
| } | |
| if (name === "ocr_image") { | |
| const { path, prompt = "Estrai tutto il testo visibile nell'immagine. Restituisci testo grezzo, senza formattazione extra." } = | |
| args as { path: string; prompt?: string }; | |
| const dataUrl = await vfsAsync.read(path); | |
| if (!dataUrl) { | |
| const all = await vfsAsync.list(); | |
| return `❌ ocr_image: immagine non trovata "${path}"\nDisponibili: ${all.map((f: { path: string }) => f.path).join(", ") || "nessuno"}`; | |
| } | |
| if (!dataUrl.startsWith("data:image/")) { | |
| return `❌ ocr_image: il file "${path}" non è un'immagine (deve essere data URL con prefisso data:image/).`; | |
| } | |
| try { | |
| const { callWithFallback } = await import("../providerChain"); | |
| let _hfTok = ""; | |
| try { _hfTok = (typeof localStorage !== "undefined" && (localStorage.getItem("hf_token") ?? localStorage.getItem("huggingface_token"))) || ""; } catch { /**/ } | |
| const _ocrRes = await callWithFallback(_hfTok, { | |
| messages: [ | |
| { | |
| role: "user", | |
| content: ([ | |
| { type: "image_url", image_url: { url: dataUrl } }, | |
| { type: "text", text: prompt }, | |
| ] as unknown) as string, | |
| }, | |
| ], | |
| max_tokens: 2000, | |
| stream: false, | |
| }, signal); | |
| const _ocrData = _ocrRes.ok ? await _ocrRes.json() as { choices?: Array<{ message?: { content?: string } }> } : null; | |
| const text = _ocrData?.choices?.[0]?.message?.content ?? ""; | |
| if (!text) return "⚠️ ocr_image: nessun testo estratto dall'immagine."; | |
| const outPath = path.replace(/\.[^.]+$/, "") + "_ocr.txt"; | |
| await vfsAsync.write(outPath, text, "text/plain"); | |
| return `✅ OCR completato: "${outPath}" (${text.length} chars)\n\n${text.slice(0, 500)}${text.length > 500 ? "\n…(troncato — leggi il file per il testo completo)" : ""}`; | |
| } catch (e) { | |
| return `❌ ocr_image errore: ${e instanceof Error ? e.message : String(e)}`; | |
| } | |
| } | |
| if (name === "create_chart") { | |
| const { type, title = "Chart", labels, datasets, filename = "chart.html" } = args as { | |
| type: string; title?: string; labels: string[]; datasets: Array<{ label: string; data: number[]; color?: string }>; filename?: string; | |
| }; | |
| const COLORS = ["#FF6384","#36A2EB","#FFCE56","#4BC0C0","#9966FF","#FF9F40","#FF6B6B","#4ECDC4","#45B7D1","#96CEB4"]; | |
| const chartType = type === "area" ? "line" : type === "horizontalBar" ? "bar" : type; | |
| const fill = type === "area"; | |
| const indexAxis = type === "horizontalBar" ? "'y'" : "'x'"; | |
| const chartDatasets = datasets.map((ds, i) => { | |
| const color = ds.color ?? COLORS[i % COLORS.length]; | |
| return `{ label: ${JSON.stringify(ds.label)}, data: ${JSON.stringify(ds.data)}, backgroundColor: '${color}88', borderColor: '${color}', borderWidth: 2, fill: ${fill}, tension: 0.4 }`; | |
| }).join(",\n"); | |
| const html = `<!DOCTYPE html> | |
| <html lang="it"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"> | |
| <title>${title}</title> | |
| <script src="https://cdn.jsdelivr.net/npm/chart.js@4/dist/chart.umd.min.js"></script> | |
| <style>body{margin:0;padding:20px;background:#0f0f0f;color:#fff;font-family:system-ui}h2{text-align:center;color:#aaa;font-size:16px;margin-bottom:16px}.container{max-width:900px;margin:0 auto;background:#1a1a1a;border-radius:12px;padding:24px;box-shadow:0 4px 24px #0008}</style> | |
| </head><body><div class="container"><h2>${title}</h2> | |
| <canvas id="chart" style="max-height:500px"></canvas></div> | |
| <script> | |
| new Chart(document.getElementById('chart'), { | |
| type: ${JSON.stringify(chartType)}, | |
| data: { labels: ${JSON.stringify(labels)}, datasets: [${chartDatasets}] }, | |
| options: { responsive: true, indexAxis: ${indexAxis}, plugins: { legend: { labels: { color: '#ccc' } } }, scales: { x: { ticks: { color: '#aaa' }, grid: { color: '#333' } }, y: { ticks: { color: '#aaa' }, grid: { color: '#333' } } } } | |
| }); | |
| </script></body></html>`; | |
| const path = filename.startsWith("/") ? filename : `/charts/${filename}`; | |
| await vfsAsync.write(path, html, "text/html"); | |
| try { window.dispatchEvent(new CustomEvent("agente:preview_open", { detail: { path, html } })); } catch { /* SSR/test safe */ } | |
| return `✅ **Grafico "${title}" creato** (${type}, ${datasets.length} serie, ${labels.length} etichette)\n📄 File: \`${path}\` — anteprima aperta`; | |
| } | |
| // ── S706: copy_file · file_info · file_diff · append_to_file · clone_url_to_vfs · batch_file_op ── | |
| // → delegati a fileToolsAdvanced.ts (tryExecuteAdvanced) — S742 refactor | |
| const _advResult = await tryExecuteAdvanced(name, args, signal); | |
| if (_advResult !== null) return _advResult; | |
| // ── S560: generate_xlsx · generate_docx · create_zip · edit_image · remove_background · generate_readme ── | |
| // → delegati a fileToolsGenerators.ts (tryExecuteGenerators) | |
| const _genResult = await tryExecuteGenerators(name, args); | |
| if (_genResult !== null) return _genResult; | |
| return null; | |
| } | |