- Refactored types in `Knowledge/types.ts` to introduce new interfaces for document handling. - Added `KnowledgeDocItem`, `KnowledgeDocsListResponse`, `KnowledgeDocsUploadInput`, `KnowledgeDocsUploadResponse`, and `KnowledgeDocsDeleteResponse` for better structure and clarity. - Updated `KnowledgeDocsApiClient` interface to include methods for listing, uploading, and deleting documents. fix: replace deprecated icons in AccountSettingsPanel and SettingMenu - Replaced `CheckCircleIcon` with `CheckCircle` from `lucide-react` in `AccountSettingsPanel.tsx`. - Updated `SettingMenu.tsx` to use `Settings` and `User` from `lucide-react` instead of custom icons. test: add tests for knowledge docs routes and KnowledgePage - Created `knowledge-docs-routes.test.ts` to test API routes for listing, uploading, and deleting knowledge documents. - Added `knowledge-page.test.tsx` to test the rendering and functionality of the KnowledgePage component, including document loading and deletion.
146 lines
4.1 KiB
TypeScript
146 lines
4.1 KiB
TypeScript
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<boolean> {
|
|
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<void> {
|
|
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<string> {
|
|
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<KnowledgeDocMetadata[]> {
|
|
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<KnowledgeDocMetadata> {
|
|
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<void> {
|
|
await ensureKnowledgeDocsDir();
|
|
const filePath = resolveKnowledgeDocPath(fileName);
|
|
await rm(filePath, { force: true });
|
|
}
|