feat: prepare Zhinian desktop client for pilot release
This commit is contained in:
231
src/pages/Knowledge/index.tsx
Normal file
231
src/pages/Knowledge/index.tsx
Normal file
@@ -0,0 +1,231 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
FileText,
|
||||
FolderUp,
|
||||
Search,
|
||||
ShieldCheck,
|
||||
UploadCloud,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { toast } from 'sonner';
|
||||
import { invokeIpc, toUserMessage } from '@/lib/api-client';
|
||||
import { hostApiFetch } from '@/lib/host-api';
|
||||
import { useYinianStore } from '@/stores/yinian';
|
||||
import {
|
||||
YinianEmptyState,
|
||||
YinianInfoRow,
|
||||
YinianPageHeader,
|
||||
YinianPageShell,
|
||||
YinianPanel,
|
||||
yinianPrimaryButton,
|
||||
} from '@/components/yinian/ui';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
type KnowledgeFile = {
|
||||
id: string;
|
||||
workspaceId: string;
|
||||
name: string;
|
||||
mimeType: string;
|
||||
size: number;
|
||||
storedPath: string;
|
||||
textPath?: string;
|
||||
originalPath?: string;
|
||||
importedAt: number;
|
||||
status: 'stored';
|
||||
};
|
||||
|
||||
const knowledgeFileExtensions = [
|
||||
'txt',
|
||||
'md',
|
||||
'markdown',
|
||||
'csv',
|
||||
'tsv',
|
||||
'json',
|
||||
'jsonl',
|
||||
'xml',
|
||||
'html',
|
||||
'htm',
|
||||
'yaml',
|
||||
'yml',
|
||||
'log',
|
||||
'ini',
|
||||
'conf',
|
||||
'css',
|
||||
'js',
|
||||
'jsx',
|
||||
'ts',
|
||||
'tsx',
|
||||
'py',
|
||||
'sql',
|
||||
'docx',
|
||||
];
|
||||
|
||||
function formatFileSize(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 formatTime(timestamp: number, language: string): string {
|
||||
return new Intl.DateTimeFormat(language === 'zh' ? 'zh-CN' : 'en-US', {
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
}).format(timestamp);
|
||||
}
|
||||
|
||||
export function Knowledge() {
|
||||
const { t, i18n } = useTranslation('skills');
|
||||
const [files, setFiles] = useState<KnowledgeFile[]>([]);
|
||||
const [query, setQuery] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const config = useYinianStore((state) => state.config);
|
||||
const workspaceId = config?.hotel.id ?? 'default';
|
||||
|
||||
const visibleFiles = useMemo(() => {
|
||||
const normalizedQuery = query.trim().toLowerCase();
|
||||
if (!normalizedQuery) return files;
|
||||
return files.filter((file) => file.name.toLowerCase().includes(normalizedQuery));
|
||||
}, [files, query]);
|
||||
|
||||
const refreshFiles = async () => {
|
||||
const result = await hostApiFetch<{ documents: KnowledgeFile[] }>(`/api/knowledge/files?workspaceId=${encodeURIComponent(workspaceId)}`);
|
||||
setFiles(result.documents);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void refreshFiles().catch((error) => {
|
||||
toast.error(t('knowledge.toast.loadFailed', { message: toUserMessage(error) }));
|
||||
});
|
||||
}, [workspaceId]);
|
||||
|
||||
const handleUpload = async () => {
|
||||
try {
|
||||
const dialogResult = await invokeIpc('dialog:open', {
|
||||
properties: ['openFile', 'multiSelections'],
|
||||
filters: [{
|
||||
name: t('knowledge.fileFilter'),
|
||||
extensions: knowledgeFileExtensions,
|
||||
}],
|
||||
}) as { canceled: boolean; filePaths?: string[] };
|
||||
|
||||
if (dialogResult.canceled || !dialogResult.filePaths?.length) return;
|
||||
setLoading(true);
|
||||
|
||||
const result = await hostApiFetch<{
|
||||
success: boolean;
|
||||
documents: KnowledgeFile[];
|
||||
rejected: Array<{ filePath: string; reason: string }>;
|
||||
}>('/api/knowledge/import-paths', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
workspaceId,
|
||||
filePaths: dialogResult.filePaths,
|
||||
}),
|
||||
});
|
||||
|
||||
await refreshFiles();
|
||||
|
||||
if (result.documents.length > 0) {
|
||||
toast.success(t('knowledge.toast.saved', { count: result.documents.length }));
|
||||
}
|
||||
if (result.rejected.length > 0) {
|
||||
toast.warning(t('knowledge.toast.rejected', { count: result.rejected.length, reason: result.rejected[0].reason }));
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(t('knowledge.toast.uploadFailed', { message: toUserMessage(error) }));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<YinianPageShell data-testid="knowledge-page">
|
||||
<YinianPageHeader>
|
||||
<div>
|
||||
<h1 className="text-3xl font-semibold tracking-normal text-slate-950 dark:text-slate-50">
|
||||
{t('knowledge.title')}
|
||||
</h1>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
data-testid="knowledge-upload-button"
|
||||
className={yinianPrimaryButton}
|
||||
onClick={() => void handleUpload()}
|
||||
disabled={loading}
|
||||
>
|
||||
<UploadCloud className="mr-2 h-4 w-4" />
|
||||
{loading ? t('knowledge.saving') : t('knowledge.upload')}
|
||||
</Button>
|
||||
</div>
|
||||
</YinianPageHeader>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<YinianInfoRow label={t('knowledge.stats.documents')} value={t('knowledge.count', { count: files.length })} icon={FileText} />
|
||||
<YinianInfoRow label={t('knowledge.stats.backedUp')} value={t('knowledge.count', { count: files.filter((file) => file.status === 'stored').length })} icon={ShieldCheck} />
|
||||
<YinianInfoRow label={t('knowledge.stats.formats')} value={t('knowledge.supportedFormats')} icon={FolderUp} />
|
||||
</div>
|
||||
|
||||
<YinianPanel className="p-5">
|
||||
<div className="flex flex-col gap-3 border-b border-slate-200 pb-4 dark:border-white/10 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">{t('knowledge.filesTitle')}</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{t('knowledge.description')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="relative w-full md:w-72">
|
||||
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder={t('knowledge.searchPlaceholder')}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{files.length === 0 ? (
|
||||
<YinianEmptyState className="mt-6">
|
||||
<div>
|
||||
<FolderUp className="mx-auto mb-3 h-6 w-6 text-muted-foreground" />
|
||||
<div className="font-medium text-slate-950 dark:text-slate-50">{t('knowledge.empty.title')}</div>
|
||||
<div className="mt-1">{t('knowledge.empty.description')}</div>
|
||||
</div>
|
||||
</YinianEmptyState>
|
||||
) : visibleFiles.length === 0 ? (
|
||||
<YinianEmptyState className="mt-6">
|
||||
<div>
|
||||
<Search className="mx-auto mb-3 h-6 w-6 text-muted-foreground" />
|
||||
<div className="font-medium text-slate-950 dark:text-slate-50">{t('knowledge.noResults.title')}</div>
|
||||
<div className="mt-1">{t('knowledge.noResults.description')}</div>
|
||||
</div>
|
||||
</YinianEmptyState>
|
||||
) : (
|
||||
<div className="mt-4 divide-y divide-slate-200 dark:divide-white/10">
|
||||
{visibleFiles.map((file) => (
|
||||
<div key={file.id} className="flex items-center gap-3 py-3">
|
||||
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-slate-100 text-[#1E3A8A] dark:bg-slate-900 dark:text-blue-100">
|
||||
<FileText className="h-4 w-4" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-medium">{file.name}</div>
|
||||
<div className="mt-1 flex flex-wrap gap-x-3 gap-y-1 text-xs text-muted-foreground">
|
||||
<span>{formatFileSize(file.size)}</span>
|
||||
<span>{file.mimeType}</span>
|
||||
{file.textPath && <span>{t('knowledge.extractedText')}</span>}
|
||||
<span>{formatTime(file.importedAt, i18n.language)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant="success" className="shrink-0">{t('knowledge.backedUp')}</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</YinianPanel>
|
||||
</YinianPageShell>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user