feat: complete mindspace conversation packages

This commit is contained in:
john
2026-07-02 23:46:05 +08:00
parent b084f6d23c
commit b258004dad
31 changed files with 1990 additions and 118 deletions
+180 -58
View File
@@ -73,6 +73,7 @@ type PendingChatImage = {
previewUrl: string;
sizeBytes: number;
uploadedUrl: string | null;
sourceMessageId: string | null;
uploadProgress: number | null;
uploadStatus: 'queued' | 'uploading' | 'uploaded' | 'error';
};
@@ -195,8 +196,17 @@ export function ChatPanel({
session: Session | null;
capabilities?: CapabilityMap;
grantedSkills?: string[];
onSubmit: (text: string, imageUrls?: string[], previewImageUrls?: string[]) => void | Promise<void>;
onUploadImage?: (file: File, onProgress?: (progress: number) => void) => Promise<string>;
onSubmit: (
text: string,
imageUrls?: string[],
previewImageUrls?: string[],
options?: { messageId?: string },
) => void | Promise<void>;
onUploadImage?: (
file: File,
onProgress?: (progress: number) => void,
options?: { messageId?: string },
) => Promise<string>;
onLoadOlderMessages?: () => void | Promise<void>;
onStop: () => void | Promise<void>;
onApproveTool: (allow: boolean) => void | Promise<void>;
@@ -221,6 +231,8 @@ export function ChatPanel({
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 [randomPrompt] = useState(
() => CHAT_PLACEHOLDER_PROMPTS[Math.floor(Math.random() * CHAT_PLACEHOLDER_PROMPTS.length)],
);
@@ -291,6 +303,8 @@ export function ChatPanel({
setPackageLoading(false);
setConversationPackage(null);
setPackageError(null);
setPackageFilter('all');
setPackageMessageFilter('all');
}, [session?.id]);
const loadConversationPackage = useCallback(async () => {
@@ -300,6 +314,12 @@ export function ChatPanel({
try {
const data = await getMindSpaceConversationPackage(session.id);
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) {
setConversationPackage(null);
if (err instanceof ApiError && err.status === 404) {
@@ -437,6 +457,32 @@ export function ChatPanel({
const voiceDisabled = busy || !!pendingTool || chatState === 'error' || offlineBlocked;
const canSubmit = input.trim() || pendingImages.length > 0;
const imageAttachmentDisabled = !onUploadImage || busy || uploadingImage || offlineBlocked || !!pendingTool;
const packageArtifacts = conversationPackage?.artifacts ?? [];
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 = groupPackageArtifacts(filteredPackageArtifacts);
const revokePendingImage = (item: PendingChatImage) => {
if (item.previewUrl.startsWith('blob:')) URL.revokeObjectURL(item.previewUrl);
@@ -505,7 +551,12 @@ export function ChatPanel({
setImageError('当前会话暂不支持图片发送');
return;
}
const sentImages = [...pendingImages];
const existingSourceMessageId = pendingImages.find((item) => item.sourceMessageId)?.sourceMessageId;
const outgoingMessageId = existingSourceMessageId ?? crypto.randomUUID();
const sentImages = pendingImages.map((item) => ({
...item,
sourceMessageId: item.sourceMessageId ?? outgoingMessageId,
}));
let uploadedImages = sentImages;
suppressVoiceUpdateRef.current = true;
@@ -524,28 +575,39 @@ export function ChatPanel({
setPendingImages((current) =>
current.map((candidate) =>
candidate.id === item.id
? { ...candidate, uploadStatus: 'uploading', uploadProgress: 0 }
? {
...candidate,
sourceMessageId: item.sourceMessageId,
uploadStatus: 'uploading',
uploadProgress: 0,
}
: candidate,
),
);
const uploadedUrl = await onUploadImage!(item.file, (progress) => {
setPendingImages((current) =>
current.map((candidate) =>
candidate.id === item.id
? {
...candidate,
uploadStatus: 'uploading',
uploadProgress: Math.round(progress * 100),
}
: candidate,
),
);
});
const uploadedUrl = await onUploadImage!(
item.file,
(progress) => {
setPendingImages((current) =>
current.map((candidate) =>
candidate.id === item.id
? {
...candidate,
sourceMessageId: item.sourceMessageId,
uploadStatus: 'uploading',
uploadProgress: Math.round(progress * 100),
}
: candidate,
),
);
},
{ messageId: item.sourceMessageId ?? outgoingMessageId },
);
setPendingImages((current) =>
current.map((candidate) =>
candidate.id === item.id
? {
...candidate,
sourceMessageId: item.sourceMessageId,
uploadedUrl,
uploadStatus: 'uploaded',
uploadProgress: 100,
@@ -564,7 +626,7 @@ export function ChatPanel({
}));
const imagesToSend = uploadedUrls.filter(Boolean);
const previewImagesToSend = imagesToSend;
await onSubmit(trimmed, imagesToSend, previewImagesToSend);
await onSubmit(trimmed, imagesToSend, previewImagesToSend, { messageId: outgoingMessageId });
uploadedImages.forEach(revokePendingImage);
setPendingImages([]);
} catch (err) {
@@ -607,6 +669,7 @@ export function ChatPanel({
previewUrl: URL.createObjectURL(file),
sizeBytes: file.size,
uploadedUrl: null,
sourceMessageId: null,
uploadProgress: null,
uploadStatus: 'queued' as const,
}));
@@ -639,6 +702,9 @@ export function ChatPanel({
previewUrl: URL.createObjectURL(file),
sizeBytes: file.size,
uploadedUrl: null,
sourceMessageId: null,
uploadProgress: null,
uploadStatus: 'queued' as const,
}));
setPendingImages((prev) => [...prev, ...placeholders]);
};
@@ -774,55 +840,111 @@ export function ChatPanel({
</button>
</div>
<div className="conversation-package-meta">
<span>{conversationPackage?.artifacts.length ?? 0} </span>
<span>{packageArtifacts.length} </span>
{conversationPackage?.uri && <code>{conversationPackage.uri}</code>}
</div>
{packageLoading && <div className="conversation-package-state"></div>}
{!packageLoading && packageError && (
<div className="conversation-package-state conversation-package-state-muted">{packageError}</div>
)}
{!packageLoading && !packageError && conversationPackage && conversationPackage.artifacts.length === 0 && (
<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 && conversationPackage.artifacts.length > 0 && (
<div className="conversation-package-groups">
{groupPackageArtifacts(conversationPackage.artifacts).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,
artifact.messageId ? `消息 ${artifact.messageId}` : null,
].filter(Boolean).join(' · ') || '已记录到对话包'}
</span>
</a>
);
})}
</div>
</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()}>
+5 -3
View File
@@ -294,10 +294,12 @@ export function ChatView({
session={session}
capabilities={capabilities}
grantedSkills={grantedSkills}
onSubmit={(text, imageUrls, previewImageUrls) =>
void submit(text, undefined, imageUrls, previewImageUrls)
onSubmit={(text, imageUrls, previewImageUrls, options) =>
void submit(text, { messageId: options?.messageId }, imageUrls, previewImageUrls)
}
onUploadImage={(file, onProgress, options) =>
uploadChatImage(file, onProgress, { messageId: options?.messageId })
}
onUploadImage={uploadChatImage}
onLoadOlderMessages={loadOlderMessages}
onStop={stop}
onApproveTool={approveTool}
+6 -4
View File
@@ -138,12 +138,14 @@ export function SpaceChatPanel({
session={session}
capabilities={capabilities ?? undefined}
grantedSkills={grantedSkills}
onSubmit={(text, imageUrls, previewImageUrls) =>
onSubmit={(text, imageUrls, previewImageUrls, options) =>
chatBridge
? void submit(text, context, imageUrls, previewImageUrls)
: void submit(text, { mindspaceContext: context }, imageUrls, previewImageUrls)
? void submit(text, { ...context, messageId: options?.messageId }, imageUrls, previewImageUrls)
: void submit(text, { mindspaceContext: context, messageId: options?.messageId }, imageUrls, previewImageUrls)
}
onUploadImage={(file, onProgress, options) =>
uploadChatImage(file, onProgress, { messageId: options?.messageId })
}
onUploadImage={uploadChatImage}
onStop={stop}
onApproveTool={approveTool}
onPageSaved={onPageSaved}