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.
This commit is contained in:
@@ -9,6 +9,7 @@ import { handleChannelRoutes } from './routes/channels';
|
||||
import { handleCronRoutes } from './routes/cron';
|
||||
import { handleFileRoutes } from './routes/files';
|
||||
import { handleGatewayRoutes } from './routes/gateway';
|
||||
import { handleKnowledgeRoutes } from './routes/knowledge';
|
||||
import { handleModelRoutes } from './routes/models';
|
||||
import { handleProviderRoutes } from './routes/providers';
|
||||
import { handleSessionRoutes } from './routes/sessions';
|
||||
@@ -25,6 +26,7 @@ const routeHandlers: RouteHandler[] = [
|
||||
handleModelRoutes,
|
||||
handleCronRoutes,
|
||||
handleGatewayRoutes,
|
||||
handleKnowledgeRoutes,
|
||||
handleFileRoutes,
|
||||
handleSessionRoutes,
|
||||
];
|
||||
|
||||
86
electron/api/routes/knowledge.ts
Normal file
86
electron/api/routes/knowledge.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import type { HostApiContext } from '../context';
|
||||
import type { NormalizedHostApiRequest } from '../route-utils';
|
||||
import { fail, ok, parseJsonBody } from '../route-utils';
|
||||
import {
|
||||
deleteKnowledgeDoc,
|
||||
listKnowledgeDocs,
|
||||
uploadKnowledgeDoc,
|
||||
isSafeKnowledgeDocName,
|
||||
} from '../../utils/knowledge-docs';
|
||||
|
||||
function getDeleteTarget(request: NormalizedHostApiRequest): string | null {
|
||||
const fromQuery = request.url.searchParams.get('name')?.trim() || '';
|
||||
if (fromQuery) return fromQuery;
|
||||
|
||||
const body = request.body && typeof request.body === 'string'
|
||||
? parseJsonBody<{ fileName?: string; name?: string }>(request.body)
|
||||
: (request.body as { fileName?: string; name?: string } | null);
|
||||
|
||||
const candidate = String(body?.fileName || body?.name || '').trim();
|
||||
return candidate || null;
|
||||
}
|
||||
|
||||
export async function handleKnowledgeRoutes(
|
||||
request: NormalizedHostApiRequest,
|
||||
_ctx: HostApiContext,
|
||||
) {
|
||||
const { pathname, method } = request;
|
||||
|
||||
if (pathname === '/api/knowledge/docs' && method === 'GET') {
|
||||
try {
|
||||
return ok({
|
||||
success: true,
|
||||
files: await listKnowledgeDocs(),
|
||||
});
|
||||
} catch (error) {
|
||||
return fail(500, error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
}
|
||||
|
||||
if (pathname === '/api/knowledge/docs' && method === 'POST') {
|
||||
try {
|
||||
const body = parseJsonBody<{ base64?: string; fileName?: string }>(request.body);
|
||||
const fileName = String(body?.fileName || '').trim();
|
||||
const base64 = String(body?.base64 || '').trim();
|
||||
|
||||
if (!fileName) {
|
||||
return fail(400, 'fileName is required');
|
||||
}
|
||||
if (!base64) {
|
||||
return fail(400, 'base64 is required');
|
||||
}
|
||||
if (!isSafeKnowledgeDocName(fileName)) {
|
||||
return fail(400, 'Invalid file name');
|
||||
}
|
||||
|
||||
return ok({
|
||||
success: true,
|
||||
file: await uploadKnowledgeDoc({ fileName, base64 }),
|
||||
});
|
||||
} catch (error) {
|
||||
return fail(500, error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
}
|
||||
|
||||
if (method === 'DELETE' && (pathname === '/api/knowledge/docs' || pathname.startsWith('/api/knowledge/docs/'))) {
|
||||
try {
|
||||
const fileName = pathname.startsWith('/api/knowledge/docs/')
|
||||
? decodeURIComponent(pathname.slice('/api/knowledge/docs/'.length))
|
||||
: getDeleteTarget(request);
|
||||
|
||||
if (!fileName) {
|
||||
return fail(400, 'fileName is required');
|
||||
}
|
||||
if (!isSafeKnowledgeDocName(fileName)) {
|
||||
return fail(400, 'Invalid file name');
|
||||
}
|
||||
|
||||
await deleteKnowledgeDoc(fileName);
|
||||
return ok({ success: true });
|
||||
} catch (error) {
|
||||
return fail(500, error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
145
electron/utils/knowledge-docs.ts
Normal file
145
electron/utils/knowledge-docs.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
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 });
|
||||
}
|
||||
Reference in New Issue
Block a user