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