feat: prepare Zhinian desktop client for pilot release

This commit is contained in:
inman
2026-04-29 10:23:20 +08:00
parent f9361e686a
commit 47b83b79fc
149 changed files with 15341 additions and 3590 deletions

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