Files
zn-ai/electron/utils/knowledge-docs.ts
duanshuwen 5cc9b86e1f refactor: update knowledge document types and API client interfaces
- 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.
2026-04-19 09:40:07 +08:00

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 });
}