feat: polish mindspace decoupling flows
This commit is contained in:
+25
-284
@@ -5,10 +5,7 @@ import { CHAT_SKILL_OPTIONS, filterChatSkills } from '../utils/chatSkills';
|
||||
import { getMessageSaveActions } from '../utils/messageSave';
|
||||
import { getDisplayText } from '../utils/message';
|
||||
import {
|
||||
ApiError,
|
||||
buildMindSpaceConversationPackageManifestDownloadUrl,
|
||||
downloadChatMessageDocx,
|
||||
getMindSpaceConversationPackage,
|
||||
} from '../api/client';
|
||||
import {
|
||||
CHAT_IMAGE_UPLOAD_MAX_COUNT,
|
||||
@@ -22,16 +19,7 @@ import { ChatSharePreviewModal } from './ChatSharePreviewModal';
|
||||
import { MessageList } from './MessageList';
|
||||
import { PageSaveDialog } from './PageSaveDialog';
|
||||
import { VoiceInputButton } from './VoiceInputButton';
|
||||
import type {
|
||||
CapabilityMap,
|
||||
ChatState,
|
||||
Message,
|
||||
MindSpaceConversationArtifact,
|
||||
MindSpaceConversationPackage,
|
||||
PortalUser,
|
||||
Session,
|
||||
ToolConfirmation,
|
||||
} from '../types';
|
||||
import type { CapabilityMap, ChatState, Message, PortalUser, Session, ToolConfirmation } from '../types';
|
||||
import type { MindSpaceSaveCategory } from '../types';
|
||||
|
||||
const CHAT_PLACEHOLDER_PROMPTS = [
|
||||
@@ -121,50 +109,6 @@ function downloadBlob(blob: Blob, filename: string) {
|
||||
window.setTimeout(() => URL.revokeObjectURL(url), 60_000);
|
||||
}
|
||||
|
||||
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());
|
||||
}
|
||||
|
||||
export function ChatPanel({
|
||||
variant,
|
||||
user,
|
||||
@@ -227,12 +171,6 @@ export function ChatPanel({
|
||||
const [uploadingImage, setUploadingImage] = useState(false);
|
||||
const [imageError, setImageError] = useState<string | null>(null);
|
||||
const [voiceStopSignal, setVoiceStopSignal] = useState(0);
|
||||
const [packagePanelOpen, setPackagePanelOpen] = useState(false);
|
||||
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)],
|
||||
);
|
||||
@@ -299,43 +237,8 @@ export function ChatPanel({
|
||||
initializedSessionRef.current = null;
|
||||
pendingOlderLoadRef.current = null;
|
||||
nearBottomRef.current = true;
|
||||
setPackagePanelOpen(false);
|
||||
setPackageLoading(false);
|
||||
setConversationPackage(null);
|
||||
setPackageError(null);
|
||||
setPackageFilter('all');
|
||||
setPackageMessageFilter('all');
|
||||
}, [session?.id]);
|
||||
|
||||
const loadConversationPackage = useCallback(async () => {
|
||||
if (!session?.id) return;
|
||||
setPackageLoading(true);
|
||||
setPackageError(null);
|
||||
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) {
|
||||
setPackageError('这个对话还没有形成文件夹内容');
|
||||
} else {
|
||||
setPackageError(err instanceof Error ? err.message : '对话文件夹加载失败');
|
||||
}
|
||||
} finally {
|
||||
setPackageLoading(false);
|
||||
}
|
||||
}, [session?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (packagePanelOpen) void loadConversationPackage();
|
||||
}, [loadConversationPackage, packagePanelOpen]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const container = mainRef.current;
|
||||
if (!container) return;
|
||||
@@ -420,10 +323,20 @@ export function ChatPanel({
|
||||
? '随时召唤你的小助手吧'
|
||||
: randomPrompt;
|
||||
const connectStatusText =
|
||||
chatState === 'connecting'
|
||||
? '正在连接会话…'
|
||||
: chatState === 'waiting'
|
||||
? '消息已收到,正在连接后台…'
|
||||
uploadingImage
|
||||
? '正在上传图片,发出后会自动继续…'
|
||||
: chatState === 'connecting'
|
||||
? '正在创建会话…'
|
||||
: chatState === 'waiting'
|
||||
? '请求已发出,正在等待后台开始处理…'
|
||||
: null;
|
||||
const sendButtonLabel =
|
||||
uploadingImage
|
||||
? '上传中…'
|
||||
: chatState === 'connecting'
|
||||
? '连接中…'
|
||||
: chatState === 'waiting'
|
||||
? '提交中…'
|
||||
: null;
|
||||
|
||||
const mergeVoiceText = (spoken: string) => {
|
||||
@@ -457,32 +370,6 @@ 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);
|
||||
@@ -544,8 +431,9 @@ export function ChatPanel({
|
||||
return { accepted, message };
|
||||
}, [pendingImageBytes, pendingImages.length]);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const trimmed = input.trim();
|
||||
const submitText = useCallback(async (textOverride?: string) => {
|
||||
const baseText = typeof textOverride === 'string' ? textOverride : input;
|
||||
const trimmed = baseText.trim();
|
||||
if ((!trimmed && pendingImages.length === 0) || voiceDisabled) return;
|
||||
if (pendingImages.length > 0 && !onUploadImage) {
|
||||
setImageError('当前会话暂不支持图片发送');
|
||||
@@ -642,7 +530,11 @@ export function ChatPanel({
|
||||
}
|
||||
setUploadingImage(false);
|
||||
suppressVoiceUpdateRef.current = false;
|
||||
};
|
||||
}, [input, onSubmit, onUploadImage, pendingImages, revokePendingImage, voiceDisabled, voiceRecording]);
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
await submitText();
|
||||
}, [submitText]);
|
||||
|
||||
const handleImageInputChange = async (event: ChangeEvent<HTMLInputElement>) => {
|
||||
if (!onUploadImage) return;
|
||||
@@ -821,158 +713,7 @@ export function ChatPanel({
|
||||
/>
|
||||
)}
|
||||
|
||||
{packagePanelOpen && !compact && (
|
||||
<div className="conversation-package-backdrop" role="presentation" onClick={() => setPackagePanelOpen(false)}>
|
||||
<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>{conversationPackage?.title || session?.name || '本次对话文件夹'}</h2>
|
||||
</div>
|
||||
<button type="button" className="conversation-package-close" onClick={() => setPackagePanelOpen(false)}>
|
||||
关闭
|
||||
</button>
|
||||
</div>
|
||||
<div className="conversation-package-meta">
|
||||
<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">
|
||||
<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>
|
||||
{session?.id && conversationPackage && (
|
||||
<a
|
||||
className="ghost-btn conversation-package-download"
|
||||
href={buildMindSpaceConversationPackageManifestDownloadUrl(session.id)}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
下载清单
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<footer className={compact ? 'space-chat-panel-footer' : `footer${showHomeWelcome ? ' footer-home' : ''}`}>
|
||||
{!compact && session?.id && (
|
||||
<div className="conversation-package-strip">
|
||||
<button type="button" onClick={() => setPackagePanelOpen(true)}>
|
||||
本次对话文件夹
|
||||
</button>
|
||||
<span>查看这次对话里的图片、文件、页面和公开产物</span>
|
||||
</div>
|
||||
)}
|
||||
{connectStatusText && (
|
||||
<div className="chat-connect-status" role="status" aria-live="polite">
|
||||
<ChatLoadingSpinner />
|
||||
@@ -1077,7 +818,7 @@ export function ChatPanel({
|
||||
<ChatSkillPicker
|
||||
skills={chatSkills}
|
||||
disabled={voiceDisabled}
|
||||
onSelect={onSubmit}
|
||||
onSelect={submitText}
|
||||
onPrefill={setInput}
|
||||
/>
|
||||
)}
|
||||
@@ -1092,7 +833,7 @@ export function ChatPanel({
|
||||
disabled={!canSubmit || voiceDisabled || uploadingImage}
|
||||
onClick={() => void handleSubmit()}
|
||||
>
|
||||
发送
|
||||
{sendButtonLabel ?? '发送'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
+226
-12
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useChat } from '../context/ChatProvider';
|
||||
import { INSUFFICIENT_BALANCE_NOTICE } from '../hooks/useTKMindChat';
|
||||
import type { CapabilityMap, PortalUser } from '../types';
|
||||
import type { CapabilityMap, PortalUser, SessionSummary } from '../types';
|
||||
import { useNetworkStatus } from '../hooks/useNetworkStatus';
|
||||
import { getSessionDisplayName } from '../utils/sessions';
|
||||
import { BalanceRing } from './BalanceRing';
|
||||
@@ -9,12 +9,149 @@ import { HistorySidebar } from './HistorySidebar';
|
||||
import { TKMindAvatar } from './TKMindAvatar';
|
||||
import { ChatPanel } from './ChatPanel';
|
||||
import { ChatLoadingSpinner } from './ChatLoadingSpinner';
|
||||
import { ConversationPackagePanel } from './ConversationPackagePanel';
|
||||
import { WechatBindPrompt } from './WechatBindPrompt';
|
||||
import { WechatAccountButton } from './WechatAccountButton';
|
||||
import { ChatHeaderMoreMenu } from './ChatHeaderMoreMenu';
|
||||
import { NotificationCenter } from './NotificationCenter';
|
||||
import type { MindSpaceSaveCategory } from '../types';
|
||||
|
||||
function RecentSessionRail({
|
||||
sessions,
|
||||
activeSessionId,
|
||||
armedSessionId,
|
||||
loading,
|
||||
loadingMore,
|
||||
hasMore,
|
||||
chipWidth,
|
||||
onSelect,
|
||||
onLoadMore,
|
||||
}: {
|
||||
sessions: SessionSummary[];
|
||||
activeSessionId?: string | null;
|
||||
armedSessionId?: string | null;
|
||||
loading: boolean;
|
||||
loadingMore: boolean;
|
||||
hasMore: boolean;
|
||||
chipWidth?: number | null;
|
||||
onSelect: (session: SessionSummary) => void;
|
||||
onLoadMore: () => void;
|
||||
}) {
|
||||
const compactLabel = (value: string, maxChars = 8) => {
|
||||
const trimmed = String(value ?? '').trim();
|
||||
if (!trimmed) return '未命名';
|
||||
return trimmed.length > maxChars ? `${trimmed.slice(0, maxChars)}...` : trimmed;
|
||||
};
|
||||
|
||||
const railRef = useRef<HTMLDivElement | null>(null);
|
||||
const recentSessions = useMemo(() => sessions, [sessions]);
|
||||
const [canScrollLeft, setCanScrollLeft] = useState(false);
|
||||
const [canScrollRight, setCanScrollRight] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const node = railRef.current;
|
||||
if (!node) return;
|
||||
|
||||
const updateScrollState = () => {
|
||||
const maxScrollLeft = Math.max(0, node.scrollWidth - node.clientWidth);
|
||||
setCanScrollLeft(node.scrollLeft > 8);
|
||||
setCanScrollRight(node.scrollLeft < maxScrollLeft - 8);
|
||||
};
|
||||
|
||||
updateScrollState();
|
||||
node.addEventListener('scroll', updateScrollState, { passive: true });
|
||||
window.addEventListener('resize', updateScrollState);
|
||||
return () => {
|
||||
node.removeEventListener('scroll', updateScrollState);
|
||||
window.removeEventListener('resize', updateScrollState);
|
||||
};
|
||||
}, [recentSessions.length]);
|
||||
|
||||
useEffect(() => {
|
||||
const node = railRef.current;
|
||||
if (!node) return;
|
||||
|
||||
const maybeLoadMore = () => {
|
||||
if (!hasMore || loadingMore) return;
|
||||
const remaining = node.scrollWidth - (node.scrollLeft + node.clientWidth);
|
||||
if (remaining <= 96) onLoadMore();
|
||||
};
|
||||
|
||||
maybeLoadMore();
|
||||
node.addEventListener('scroll', maybeLoadMore, { passive: true });
|
||||
return () => node.removeEventListener('scroll', maybeLoadMore);
|
||||
}, [hasMore, loadingMore, onLoadMore, recentSessions.length]);
|
||||
|
||||
const scrollByPage = (direction: -1 | 1) => {
|
||||
const node = railRef.current;
|
||||
if (!node) return;
|
||||
const amount = Math.max(180, Math.round(node.clientWidth * 0.82)) * direction;
|
||||
node.scrollBy({ left: amount, behavior: 'smooth' });
|
||||
};
|
||||
|
||||
if (!loading && recentSessions.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="header-session-rail"
|
||||
aria-label="最近对话入口"
|
||||
style={
|
||||
chipWidth && chipWidth > 0
|
||||
? ({ ['--header-session-chip-width' as string]: `${chipWidth}px` })
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="header-session-rail-arrow header-session-rail-arrow-left"
|
||||
aria-label="查看更早的最近对话"
|
||||
disabled={!canScrollLeft}
|
||||
onClick={() => scrollByPage(-1)}
|
||||
>
|
||||
‹
|
||||
</button>
|
||||
<div
|
||||
className="header-session-rail-track"
|
||||
ref={railRef}
|
||||
>
|
||||
{recentSessions.map((item) => {
|
||||
const active = item.id === activeSessionId;
|
||||
const armed = item.id === armedSessionId;
|
||||
const displayName = getSessionDisplayName(item);
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
className={`header-session-chip${active ? ' is-active' : ''}${armed ? ' is-armed' : ''}`}
|
||||
aria-pressed={active || armed}
|
||||
title={displayName}
|
||||
onClick={() => onSelect(item)}
|
||||
>
|
||||
<strong>{compactLabel(displayName, 8)}</strong>
|
||||
<span>{armed ? '再次点击' : item.message_count > 0 ? `${item.message_count}条` : '继续'}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{(loading || loadingMore) && (
|
||||
<div className="header-session-chip header-session-chip-loading" aria-hidden="true">
|
||||
<strong>{loading ? '加载中…' : '继续加载…'}</strong>
|
||||
<span>{loading ? '正在准备最近对话' : '滑到这里会自动接上更多'}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="header-session-rail-arrow header-session-rail-arrow-right"
|
||||
aria-label="查看更多最近对话"
|
||||
disabled={!canScrollRight}
|
||||
onClick={() => scrollByPage(1)}
|
||||
>
|
||||
›
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChatView({
|
||||
user,
|
||||
capabilities,
|
||||
@@ -72,6 +209,29 @@ export function ChatView({
|
||||
} = useChat();
|
||||
const online = useNetworkStatus();
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
const [packagePanelSession, setPackagePanelSession] = useState<SessionSummary | null>(null);
|
||||
const [armedSessionId, setArmedSessionId] = useState<string | null>(null);
|
||||
const [desktopChipWidth, setDesktopChipWidth] = useState<number | null>(null);
|
||||
const spaceButtonRef = useRef<HTMLButtonElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const node = spaceButtonRef.current;
|
||||
if (!node) return;
|
||||
|
||||
const updateWidth = () => {
|
||||
const nextWidth = Math.round(node.getBoundingClientRect().width);
|
||||
if (nextWidth > 0) setDesktopChipWidth(nextWidth);
|
||||
};
|
||||
|
||||
updateWidth();
|
||||
const resizeObserver = typeof ResizeObserver !== 'undefined' ? new ResizeObserver(updateWidth) : null;
|
||||
resizeObserver?.observe(node);
|
||||
window.addEventListener('resize', updateWidth);
|
||||
return () => {
|
||||
resizeObserver?.disconnect();
|
||||
window.removeEventListener('resize', updateWidth);
|
||||
};
|
||||
}, [onOpenSpace]);
|
||||
|
||||
useEffect(() => {
|
||||
if (sidebarOpen) {
|
||||
@@ -86,17 +246,43 @@ export function ChatView({
|
||||
setSidebarOpen(false);
|
||||
};
|
||||
|
||||
const handleOpenConversationPackage = (sessionSummary: SessionSummary) => {
|
||||
setPackagePanelSession(sessionSummary);
|
||||
};
|
||||
|
||||
const handleRecentSessionSelect = (sessionSummary: SessionSummary) => {
|
||||
const isSameSession = session?.id === sessionSummary.id;
|
||||
const isArmed = armedSessionId === sessionSummary.id;
|
||||
if (isSameSession && isArmed) {
|
||||
handleOpenConversationPackage(sessionSummary);
|
||||
setArmedSessionId(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setPackagePanelSession(null);
|
||||
setArmedSessionId(sessionSummary.id);
|
||||
if (!isSameSession) {
|
||||
handleSelectSession(sessionSummary.id);
|
||||
}
|
||||
};
|
||||
|
||||
const handleNewSession = () => {
|
||||
void newSession();
|
||||
setSidebarOpen(false);
|
||||
};
|
||||
|
||||
const isConnectingTitle = !session && chatState !== 'idle';
|
||||
const pendingSessionTitle =
|
||||
chatState === 'connecting'
|
||||
? '正在创建新对话…'
|
||||
: chatState === 'waiting'
|
||||
? '请求已发出…'
|
||||
: '连接中…';
|
||||
const sessionTitle = session
|
||||
? getSessionDisplayName(session)
|
||||
: chatState === 'idle'
|
||||
? '新对话'
|
||||
: '连接中…';
|
||||
: pendingSessionTitle;
|
||||
|
||||
const moreMenuItems = [
|
||||
{
|
||||
@@ -104,9 +290,6 @@ export function ChatView({
|
||||
label: '新会话',
|
||||
onClick: () => void handleNewSession(),
|
||||
},
|
||||
...(onOpenFeedback
|
||||
? [{ id: 'feedback', label: '反馈与建议', onClick: () => onOpenFeedback() }]
|
||||
: []),
|
||||
...(onOpenAdmin
|
||||
? [{ id: 'admin', label: '管理', onClick: () => onOpenAdmin() }]
|
||||
: []),
|
||||
@@ -154,6 +337,17 @@ export function ChatView({
|
||||
</div>
|
||||
</div>
|
||||
<div className="header-actions header-actions-desktop">
|
||||
<RecentSessionRail
|
||||
sessions={sessions}
|
||||
activeSessionId={session?.id}
|
||||
armedSessionId={armedSessionId}
|
||||
loading={sessionsLoading}
|
||||
loadingMore={sessionsLoadingMore}
|
||||
hasMore={sessionsHasMore}
|
||||
chipWidth={desktopChipWidth}
|
||||
onSelect={handleRecentSessionSelect}
|
||||
onLoadMore={() => void loadMoreSessions()}
|
||||
/>
|
||||
{typeof balanceCents === 'number' && (
|
||||
<BalanceRing
|
||||
balanceCents={balanceCents}
|
||||
@@ -171,15 +365,15 @@ export function ChatView({
|
||||
</button>
|
||||
)}
|
||||
{onOpenSpace && (
|
||||
<button type="button" className="ghost-btn" onClick={() => onOpenSpace?.()}>
|
||||
<button
|
||||
ref={spaceButtonRef}
|
||||
type="button"
|
||||
className="ghost-btn"
|
||||
onClick={() => onOpenSpace?.()}
|
||||
>
|
||||
我的空间
|
||||
</button>
|
||||
)}
|
||||
{onOpenFeedback && (
|
||||
<button type="button" className="ghost-btn" onClick={() => onOpenFeedback()}>
|
||||
反馈
|
||||
</button>
|
||||
)}
|
||||
<button type="button" className="ghost-btn" onClick={() => void handleNewSession()}>
|
||||
新会话
|
||||
</button>
|
||||
@@ -192,6 +386,17 @@ export function ChatView({
|
||||
</div>
|
||||
|
||||
<div className="header-actions header-actions-mobile">
|
||||
<RecentSessionRail
|
||||
sessions={sessions}
|
||||
activeSessionId={session?.id}
|
||||
armedSessionId={armedSessionId}
|
||||
loading={sessionsLoading}
|
||||
loadingMore={sessionsLoadingMore}
|
||||
hasMore={sessionsHasMore}
|
||||
chipWidth={null}
|
||||
onSelect={handleRecentSessionSelect}
|
||||
onLoadMore={() => void loadMoreSessions()}
|
||||
/>
|
||||
{typeof balanceCents === 'number' && (
|
||||
<BalanceRing
|
||||
balanceCents={balanceCents}
|
||||
@@ -311,6 +516,15 @@ export function ChatView({
|
||||
onOpenSpace?.({ categoryCode: result.categoryCode });
|
||||
}}
|
||||
/>
|
||||
<ConversationPackagePanel
|
||||
sessionId={packagePanelSession?.id ?? null}
|
||||
sessionName={packagePanelSession ? getSessionDisplayName(packagePanelSession) : null}
|
||||
open={Boolean(packagePanelSession)}
|
||||
onClose={() => {
|
||||
setPackagePanelSession(null);
|
||||
setArmedSessionId(null);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,288 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user