Files
memind/src/components/ConversationPackagePanel.tsx
T
2026-07-03 00:34:03 +08:00

289 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
ApiError,
buildMindSpaceConversationPackageManifestDownloadUrl,
getMindSpaceConversationPackage,
} from '../api/client';
import type {
MindSpaceConversationArtifact,
MindSpaceConversationPackage,
} from '../types';
function packageArtifactLabel(kind: MindSpaceConversationArtifact['kind']) {
switch (kind) {
case 'input_image':
return '输入图片';
case 'input_file':
return '输入文件';
case 'generated_image':
return '生成图片';
case 'generated_file':
return '生成文件';
case 'page':
return '页面草稿';
case 'public_html':
return '公开页面';
case 'long_image':
return '长图';
case 'thumbnail':
return '缩略图';
case 'docx':
return 'Word 文档';
case 'pdf':
return 'PDF';
default:
return '文件';
}
}
function formatPackageArtifactSize(sizeBytes?: number) {
if (!Number.isFinite(sizeBytes)) return null;
const bytes = Number(sizeBytes);
if (bytes < 1024) return `${bytes}B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(bytes < 10 * 1024 ? 1 : 0)}KB`;
return `${(bytes / 1024 / 1024).toFixed(bytes < 10 * 1024 * 1024 ? 1 : 0)}MB`;
}
function groupPackageArtifacts(artifacts: MindSpaceConversationArtifact[]) {
const groups = new Map<string, MindSpaceConversationArtifact[]>();
artifacts.forEach((artifact) => {
const label = packageArtifactLabel(artifact.kind);
groups.set(label, [...(groups.get(label) ?? []), artifact]);
});
return Array.from(groups.entries());
}
function resolveConversationPackageName(
pkg?: MindSpaceConversationPackage | null,
sessionName?: string | null,
) {
return pkg?.displayFolderName || pkg?.title || sessionName || '本次对话文件夹';
}
export function ConversationPackagePanel({
sessionId,
sessionName,
open,
onClose,
}: {
sessionId: string | null;
sessionName?: string | null;
open: boolean;
onClose: () => void;
}) {
const [packageLoading, setPackageLoading] = useState(false);
const [conversationPackage, setConversationPackage] = useState<MindSpaceConversationPackage | null>(null);
const [packageError, setPackageError] = useState<string | null>(null);
const [packageFilter, setPackageFilter] = useState<MindSpaceConversationArtifact['kind'] | 'all'>('all');
const [packageMessageFilter, setPackageMessageFilter] = useState<string | 'all'>('all');
const packageCacheRef = useRef(new Map<string, MindSpaceConversationPackage>());
useEffect(() => {
const cachedPackage = sessionId ? packageCacheRef.current.get(sessionId) ?? null : null;
setPackageLoading(false);
setConversationPackage(cachedPackage);
setPackageError(null);
setPackageFilter('all');
setPackageMessageFilter('all');
}, [sessionId]);
const loadConversationPackage = useCallback(async () => {
if (!sessionId) return;
const cachedPackage = packageCacheRef.current.get(sessionId) ?? null;
setPackageLoading(true);
setPackageError(null);
try {
const data = await getMindSpaceConversationPackage(sessionId);
packageCacheRef.current.set(sessionId, data);
setConversationPackage(data);
setPackageFilter((current) =>
current === 'all' || data.artifacts.some((artifact) => artifact.kind === current) ? current : 'all',
);
setPackageMessageFilter((current) =>
current === 'all' || (data.messages ?? []).some((message) => message.messageId === current) ? current : 'all',
);
} catch (err) {
if (!cachedPackage) setConversationPackage(null);
if (err instanceof ApiError && err.status === 404) {
setPackageError('这个对话还没有形成文件夹内容');
} else {
setPackageError(err instanceof Error ? err.message : '对话文件夹加载失败');
}
} finally {
setPackageLoading(false);
}
}, [sessionId]);
useEffect(() => {
if (open) void loadConversationPackage();
}, [loadConversationPackage, open]);
const packageArtifacts = conversationPackage?.artifacts ?? [];
const conversationPackageName = resolveConversationPackageName(conversationPackage, sessionName ?? null);
const packageFilterOptions = Array.from(
packageArtifacts.reduce((counts, artifact) => {
counts.set(artifact.kind, (counts.get(artifact.kind) ?? 0) + 1);
return counts;
}, new Map<MindSpaceConversationArtifact['kind'], number>()),
).sort(([left], [right]) => packageArtifactLabel(left).localeCompare(packageArtifactLabel(right), 'zh-CN'));
const packageMessageOptions = (conversationPackage?.messages ?? [])
.filter((message) => message.artifactIds.length > 0)
.map((message, index) => ({
id: message.messageId,
label: `消息 ${index + 1}`,
count: message.artifactIds.length,
artifactIds: new Set(message.artifactIds),
}));
const selectedMessageArtifacts =
packageMessageFilter === 'all'
? null
: packageMessageOptions.find((message) => message.id === packageMessageFilter)?.artifactIds ?? null;
const filteredPackageArtifacts =
packageArtifacts.filter((artifact) => {
const kindMatched = packageFilter === 'all' || artifact.kind === packageFilter;
const messageMatched = !selectedMessageArtifacts || selectedMessageArtifacts.has(artifact.artifactId);
return kindMatched && messageMatched;
});
const packageGroups = useMemo(() => groupPackageArtifacts(filteredPackageArtifacts), [filteredPackageArtifacts]);
if (!open) return null;
return (
<div className="conversation-package-backdrop" role="presentation" onClick={onClose}>
<section
className="conversation-package-panel"
role="dialog"
aria-modal="true"
aria-label="本次对话文件夹"
onClick={(event) => event.stopPropagation()}
>
<div className="conversation-package-head">
<div>
<p>MindSpace 对话包</p>
<h2>{conversationPackageName}</h2>
</div>
<button type="button" className="conversation-package-close" onClick={onClose}>
关闭
</button>
</div>
<div className="conversation-package-meta">
<span>{packageArtifacts.length} 个项目</span>
<span>文件夹名:{conversationPackageName}</span>
</div>
{packageLoading && <div className="conversation-package-state">正在整理这个对话里的文件…</div>}
{!packageLoading && packageError && (
<div className="conversation-package-state conversation-package-state-muted">
<strong>对话包暂时打不开</strong>
<span>{packageError}</span>
<button type="button" className="ghost-btn" onClick={() => void loadConversationPackage()}>
重试加载
</button>
</div>
)}
{!packageLoading && !packageError && conversationPackage && packageArtifacts.length === 0 && (
<div className="conversation-package-state conversation-package-state-muted">
<strong>这个对话还没有文件</strong>
<span>发送图片、保存页面、生成公开页或下载文档后,这里会自动聚合成一个对话文件夹。</span>
</div>
)}
{!packageLoading && !packageError && conversationPackage && packageArtifacts.length > 0 && (
<>
<div className="conversation-package-filters" aria-label="筛选对话包文件">
<button
type="button"
className={packageFilter === 'all' ? 'is-active' : ''}
onClick={() => setPackageFilter('all')}
>
全部 <span>{packageArtifacts.length}</span>
</button>
{packageFilterOptions.map(([kind, count]) => (
<button
type="button"
key={kind}
className={packageFilter === kind ? 'is-active' : ''}
onClick={() => setPackageFilter(kind)}
>
{packageArtifactLabel(kind)} <span>{count}</span>
</button>
))}
</div>
{packageMessageOptions.length > 0 && (
<div className="conversation-package-message-filters" aria-label="按消息筛选对话包文件">
<span>按消息</span>
<button
type="button"
className={packageMessageFilter === 'all' ? 'is-active' : ''}
onClick={() => setPackageMessageFilter('all')}
>
全部消息
</button>
{packageMessageOptions.map((message) => (
<button
type="button"
key={message.id}
className={packageMessageFilter === message.id ? 'is-active' : ''}
onClick={() => setPackageMessageFilter(message.id)}
>
{message.label} <span>{message.count}</span>
</button>
))}
</div>
)}
{filteredPackageArtifacts.length === 0 ? (
<div className="conversation-package-state conversation-package-state-muted">
<strong>当前筛选没有项目</strong>
<span>切换到“全部”或“全部消息”可以查看当前对话包里的其它产物。</span>
</div>
) : (
<div className="conversation-package-groups">
{packageGroups.map(([label, artifacts]) => (
<div className="conversation-package-group" key={label}>
<h3>{label}</h3>
<div className="conversation-package-items">
{artifacts.map((artifact) => {
const sizeLabel = formatPackageArtifactSize(artifact.sizeBytes);
return (
<a
key={artifact.artifactId}
className="conversation-package-item"
href={artifact.canonicalUrl || undefined}
target="_blank"
rel="noreferrer"
aria-disabled={!artifact.canonicalUrl}
onClick={(event) => {
if (!artifact.canonicalUrl) event.preventDefault();
}}
>
<span className="conversation-package-item-kind">{packageArtifactLabel(artifact.kind)}</span>
<strong>{artifact.displayName || artifact.canonicalUrl || artifact.artifactId}</strong>
<span>{[artifact.mimeType, sizeLabel].filter(Boolean).join(' · ') || '已记录到对话包'}</span>
</a>
);
})}
</div>
</div>
))}
</div>
)}
</>
)}
<div className="conversation-package-actions">
<button type="button" className="ghost-btn" onClick={() => void loadConversationPackage()}>
刷新
</button>
{sessionId && conversationPackage && (
<a
className="ghost-btn conversation-package-download"
href={buildMindSpaceConversationPackageManifestDownloadUrl(sessionId)}
target="_blank"
rel="noreferrer"
>
下载清单
</a>
)}
</div>
</section>
</div>
);
}