import { access, mkdir, readdir, rm, stat, writeFile } from 'node:fs/promises'; import { constants } from 'node:fs'; import { extname, isAbsolute, join, normalize, parse, resolve, sep } from 'node:path'; export interface KnowledgeDocMetadata { name: string; size: number; modifiedAt: string; type: string; } export interface KnowledgeDocUploadInput { base64: string; fileName: string; } export const KNOWLEDGE_DOCS_DIR_ENV_NAME = 'ZN_AI_KNOWLEDGE_DOCS_DIR'; function getConfiguredDocsDir(): string { const override = process.env[KNOWLEDGE_DOCS_DIR_ENV_NAME]?.trim(); if (override) { return resolve(override); } return resolve(process.cwd(), 'docs'); } async function fileExists(filePath: string): Promise { try { await access(filePath, constants.F_OK); return true; } catch { return false; } } export function getKnowledgeDocsDir(): string { return getConfiguredDocsDir(); } export function isSafeKnowledgeDocName(fileName: string): boolean { const trimmed = fileName.trim(); if (!trimmed) return false; if (trimmed === '.' || trimmed === '..') return false; if (isAbsolute(trimmed)) return false; return normalize(trimmed) === trimmed && parse(trimmed).base === trimmed; } export function resolveKnowledgeDocPath(fileName: string): string { if (!isSafeKnowledgeDocName(fileName)) { throw new Error('Invalid file name'); } const docsDir = getKnowledgeDocsDir(); const docsRoot = resolve(docsDir); const candidate = resolve(docsDir, fileName.trim()); const rootWithSep = `${docsRoot}${docsRoot.endsWith(sep) ? '' : sep}`; if (candidate !== docsRoot && !candidate.startsWith(rootWithSep)) { throw new Error('Invalid file path'); } return candidate; } async function ensureKnowledgeDocsDir(): Promise { await mkdir(getKnowledgeDocsDir(), { recursive: true }); } function getDocType(fileName: string, isDirectory: boolean): string { if (isDirectory) return 'directory'; return extname(fileName).slice(1).toLowerCase() || 'file'; } async function resolveAvailableFileName(fileName: string): Promise { const docsDir = getKnowledgeDocsDir(); const parsed = parse(fileName); let candidate = fileName; let counter = 1; while (await fileExists(join(docsDir, candidate))) { candidate = `${parsed.name}-${counter}${parsed.ext}`; counter += 1; } return candidate; } export async function listKnowledgeDocs(): Promise { await ensureKnowledgeDocsDir(); const entries = await readdir(getKnowledgeDocsDir(), { withFileTypes: true }); const results: KnowledgeDocMetadata[] = []; for (const entry of entries) { if (!entry.isFile() && !entry.isDirectory()) { continue; } const entryPath = join(getKnowledgeDocsDir(), entry.name); const info = await stat(entryPath); results.push({ name: entry.name, size: info.size, modifiedAt: info.mtime.toISOString(), type: getDocType(entry.name, entry.isDirectory()), }); } return results.sort((left, right) => { const leftTime = new Date(left.modifiedAt).getTime() || 0; const rightTime = new Date(right.modifiedAt).getTime() || 0; return rightTime - leftTime; }); } export async function uploadKnowledgeDoc(input: KnowledgeDocUploadInput): Promise { await ensureKnowledgeDocsDir(); if (!isSafeKnowledgeDocName(input.fileName)) { throw new Error('Invalid file name'); } const buffer = Buffer.from(String(input.base64 || ''), 'base64'); if (!buffer.length && String(input.base64 || '').trim()) { throw new Error('Invalid base64 payload'); } const fileName = await resolveAvailableFileName(input.fileName.trim()); const filePath = join(getKnowledgeDocsDir(), fileName); await writeFile(filePath, buffer); const info = await stat(filePath); return { name: fileName, size: info.size, modifiedAt: info.mtime.toISOString(), type: getDocType(fileName, false), }; } export async function deleteKnowledgeDoc(fileName: string): Promise { await ensureKnowledgeDocsDir(); const filePath = resolveKnowledgeDocPath(fileName); await rm(filePath, { force: true }); }