193 lines
8.8 KiB
TypeScript

import { CloseBotClient, err } from "../client.js";
import type { ToolDefinition, ToolResult, FileDto } from "../types.js";
export const tools: ToolDefinition[] = [
{
name: "library_manager_app",
description: "Knowledge base library viewer showing files with type icons, source attachments, scrape status, and size. Returns HTML visualization.",
inputSchema: {
type: "object",
properties: {
fileId: { type: "string", description: "Optional: show detail for a specific file including scrape pages" },
},
},
},
];
function escapeHtml(s: string): string {
return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
}
function fileIcon(fileType: string | null | undefined): string {
const t = (fileType || "").toLowerCase();
if (t.includes("pdf")) return "📄";
if (t.includes("doc") || t.includes("word")) return "📝";
if (t.includes("csv") || t.includes("excel") || t.includes("spreadsheet")) return "📊";
if (t.includes("image") || t.includes("png") || t.includes("jpg")) return "🖼️";
if (t.includes("webscrape") || t.includes("web") || t.includes("html")) return "🌐";
if (t.includes("text") || t.includes("txt")) return "📃";
if (t.includes("json")) return "🔧";
return "📁";
}
function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
function statusBadge(status: string | null | undefined): string {
const s = (status || "").toLowerCase();
if (s === "ready" || s === "completed" || s === "processed")
return '<span style="background:#1a3a2a;color:#4ecdc4;padding:2px 8px;border-radius:4px;font-size:10px;">✅ Ready</span>';
if (s === "processing" || s === "pending")
return '<span style="background:#3a3a1a;color:#ffd93d;padding:2px 8px;border-radius:4px;font-size:10px;">⏳ Processing</span>';
if (s === "error" || s === "failed")
return '<span style="background:#3a1a1a;color:#ff6b6b;padding:2px 8px;border-radius:4px;font-size:10px;">❌ Error</span>';
return `<span style="background:#1a1a2e;color:#888;padding:2px 8px;border-radius:4px;font-size:10px;">${escapeHtml(status || "Unknown")}</span>`;
}
function renderFileList(files: FileDto[]): string {
const totalSize = files.reduce((sum, f) => sum + (f.fileSize || 0), 0);
const rows = files
.map((f) => {
const icon = fileIcon(f.fileType);
const sources = (f.sources || [])
.map(
(s) =>
`<span style="background:#1a2a3e;padding:1px 6px;border-radius:4px;font-size:10px;color:#7ec8e3;">${escapeHtml(s.name || s.id || "")}</span>`
)
.join(" ");
const modified = f.lastModified
? new Date(f.lastModified).toLocaleDateString()
: "—";
return `
<div style="background:#1a1a2e;border-radius:10px;padding:14px;display:flex;align-items:center;gap:14px;">
<div style="font-size:28px;width:36px;text-align:center;">${icon}</div>
<div style="flex:1;min-width:0;">
<div style="font-weight:600;font-size:13px;color:#e0e0e0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">
${escapeHtml(f.fileName || "Unnamed")}
</div>
<div style="display:flex;gap:8px;align-items:center;margin-top:4px;">
${statusBadge(f.fileStatus)}
<span style="font-size:11px;color:#666;">${escapeHtml(f.fileType || "")}</span>
<span style="font-size:11px;color:#666;">${formatSize(f.fileSize || 0)}</span>
<span style="font-size:11px;color:#666;">${escapeHtml(modified)}</span>
</div>
${sources ? `<div style="margin-top:6px;display:flex;gap:4px;flex-wrap:wrap;">${sources}</div>` : ""}
</div>
<div style="font-size:10px;color:#444;font-family:monospace;white-space:nowrap;">${escapeHtml(f.fileId || "")}</div>
</div>`;
})
.join("");
return `
<div style="font-family:-apple-system,BlinkMacSystemFont,sans-serif;background:#0d0d1a;color:#e0e0e0;padding:20px;border-radius:16px;">
<h2 style="margin:0 0 4px;color:#fff;">📚 Library Manager</h2>
<div style="font-size:12px;color:#666;margin-bottom:16px;">
${files.length} files · ${formatSize(totalSize)} total
</div>
<div style="display:flex;flex-direction:column;gap:8px;">
${rows || '<div style="padding:40px;text-align:center;color:#666;">No files. Use upload_file or create_web_scrape to add content.</div>'}
</div>
<div style="margin-top:12px;font-size:11px;color:#555;">
💡 Use <code>upload_file</code> to upload, <code>create_web_scrape</code> to scrape websites, <code>attach_file_to_source</code> to connect to sources
</div>
</div>`;
}
function renderFileDetail(file: FileDto, scrapePages: Array<{ url?: string; enabled?: boolean }>): string {
const sources = (file.sources || [])
.map(
(s) =>
`<div style="background:#1a2a3e;padding:8px 12px;border-radius:6px;display:flex;justify-content:space-between;">
<span style="font-size:12px;color:#7ec8e3;">${escapeHtml(s.name || "Unnamed")}</span>
<span style="font-size:10px;color:#666;">${escapeHtml(s.category || "")} · ${escapeHtml(s.id || "")}</span>
</div>`
)
.join("");
const pages = scrapePages
.map(
(p) =>
`<div style="display:flex;align-items:center;gap:8px;padding:4px 0;">
<span style="font-size:14px;">${p.enabled ? "✅" : "❌"}</span>
<span style="font-size:12px;color:#aaa;word-break:break-all;">${escapeHtml(p.url || "")}</span>
</div>`
)
.join("");
return `
<div style="font-family:-apple-system,BlinkMacSystemFont,sans-serif;background:#0d0d1a;color:#e0e0e0;padding:20px;border-radius:16px;">
<div style="display:flex;align-items:center;gap:12px;margin-bottom:16px;">
<span style="font-size:36px;">${fileIcon(file.fileType)}</span>
<div>
<h2 style="margin:0;color:#fff;">${escapeHtml(file.fileName || "Unnamed")}</h2>
<div style="font-size:11px;color:#555;font-family:monospace;">${escapeHtml(file.fileId || "")}</div>
</div>
</div>
<div style="display:grid;grid-template-columns:repeat(4,1fr);gap:12px;margin-bottom:20px;">
<div style="background:#1a1a2e;padding:12px;border-radius:8px;text-align:center;">
<div style="font-size:11px;color:#888;">Status</div>
<div style="margin-top:4px;">${statusBadge(file.fileStatus)}</div>
</div>
<div style="background:#1a1a2e;padding:12px;border-radius:8px;text-align:center;">
<div style="font-size:11px;color:#888;">Type</div>
<div style="font-size:13px;margin-top:4px;">${escapeHtml(file.fileType || "—")}</div>
</div>
<div style="background:#1a1a2e;padding:12px;border-radius:8px;text-align:center;">
<div style="font-size:11px;color:#888;">Size</div>
<div style="font-size:13px;margin-top:4px;">${formatSize(file.fileSize || 0)}</div>
</div>
<div style="background:#1a1a2e;padding:12px;border-radius:8px;text-align:center;">
<div style="font-size:11px;color:#888;">Modified</div>
<div style="font-size:13px;margin-top:4px;">${file.lastModified ? new Date(file.lastModified).toLocaleDateString() : "—"}</div>
</div>
</div>
<h3 style="color:#ccc;margin:16px 0 8px;">📡 Attached Sources (${file.sources?.length || 0})</h3>
<div style="display:flex;flex-direction:column;gap:4px;">
${sources || '<div style="color:#666;font-size:12px;">Not attached to any sources</div>'}
</div>
${scrapePages.length > 0 ? `
<h3 style="color:#ccc;margin:16px 0 8px;">🌐 Scrape Pages (${scrapePages.length})</h3>
<div style="background:#111;border-radius:8px;padding:12px;max-height:300px;overflow-y:auto;">
${pages}
</div>
` : ""}
</div>`;
}
export async function handler(
client: CloseBotClient,
name: string,
args: Record<string, unknown>
): Promise<ToolResult> {
try {
if (args.fileId) {
const [file, scrapePages] = await Promise.all([
client.get<FileDto>(`/library/files/${args.fileId}`),
client.get<Array<{ url?: string; enabled?: boolean }>>(`/library/files/${args.fileId}/scrape-pages`).catch(() => []),
]);
const html = renderFileDetail(file, scrapePages);
return {
content: [{ type: "text", text: `File: ${file.fileName} (${file.fileType})` }],
structuredContent: { type: "html", html },
};
}
const files = await client.get<FileDto[]>("/library/files");
const html = renderFileList(files);
return {
content: [{ type: "text", text: `Library: ${files.length} files` }],
structuredContent: { type: "html", html },
};
} catch (error) {
return err(error);
}
}