289 lines
12 KiB
TypeScript
289 lines
12 KiB
TypeScript
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>
|
||
);
|
||
}
|