feat: add chat page export actions

This commit is contained in:
john
2026-07-02 19:52:26 +08:00
parent 2d777ef394
commit ddfa60607a
7 changed files with 544 additions and 17 deletions
+35
View File
@@ -958,6 +958,41 @@ export async function quickShareFromChat(input: {
return result.data;
}
export async function downloadChatMessageDocx(input: {
sessionId: string;
messageId: string;
selectedLinkIndex?: number;
}): Promise<{ blob: Blob; filename: string }> {
let res: Response;
try {
res = await fetch(`${API}/mindspace/v1/pages/chat-save-docx`, {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
session_id: input.sessionId,
message_id: input.messageId,
selected_link_index: input.selectedLinkIndex ?? 0,
}),
});
} catch (err) {
throw new ApiError(0, formatNetworkError(err));
}
if (res.status === 401) {
notifyUnauthorized();
throw new ApiError(401, '未授权,请重新登录');
}
if (!res.ok) {
const parsed = await parseErrorResponse(res);
throw new ApiError(res.status, parsed.message || `${res.status} ${res.statusText}`, parsed.code);
}
const disposition = res.headers.get('Content-Disposition') ?? '';
const filenameMatch =
disposition.match(/filename\*=UTF-8''([^;]+)/i) || disposition.match(/filename="?([^";]+)"?/i);
const filename = filenameMatch ? decodeURIComponent(filenameMatch[1]) : 'mindspace-document.docx';
return { blob: await res.blob(), filename };
}
export async function fetchPreviewAsset(path: string): Promise<string> {
let res: Response;
try {
+76 -2
View File
@@ -2,6 +2,9 @@ import { ChangeEvent, useCallback, useEffect, useLayoutEffect, useRef, useState,
import { useNetworkStatus } from '../hooks/useNetworkStatus';
import { openAvatarPicker } from '../utils/userAvatar';
import { CHAT_SKILL_OPTIONS, filterChatSkills } from '../utils/chatSkills';
import { getMessageSaveActions } from '../utils/messageSave';
import { getDisplayText } from '../utils/message';
import { downloadChatMessageDocx } from '../api/client';
import {
CHAT_IMAGE_UPLOAD_MAX_COUNT,
CHAT_IMAGE_UPLOAD_MAX_INPUT_BYTES,
@@ -10,6 +13,7 @@ import {
import { AvatarPicker } from './AvatarPicker';
import { ChatSkillPicker } from './ChatSkillPicker';
import { ChatLoadingSpinner } from './ChatLoadingSpinner';
import { ChatSharePreviewModal } from './ChatSharePreviewModal';
import { MessageList } from './MessageList';
import { PageSaveDialog } from './PageSaveDialog';
import { VoiceInputButton } from './VoiceInputButton';
@@ -76,6 +80,32 @@ function FileIcon() {
);
}
function appendLongImageDownloadParam(url: string) {
const next = new URL(url, window.location.href);
next.searchParams.set('download', 'long-image');
return next.toString();
}
function triggerUrlDownload(url: string) {
const link = document.createElement('a');
link.href = url;
link.download = '';
document.body.appendChild(link);
link.click();
link.remove();
}
function downloadBlob(blob: Blob, filename: string) {
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
link.remove();
window.setTimeout(() => URL.revokeObjectURL(url), 60_000);
}
export function ChatPanel({
variant,
user,
@@ -123,6 +153,7 @@ export function ChatPanel({
const [input, setInput] = useState('');
const [voiceRecording, setVoiceRecording] = useState(false);
const [pageSource, setPageSource] = useState<Message | null>(null);
const [sharePreviewSource, setSharePreviewSource] = useState<Message | null>(null);
const [voiceNotice, setVoiceNotice] = useState<string | null>(null);
const [pendingImages, setPendingImages] = useState<PendingChatImage[]>([]);
const [uploadingImage, setUploadingImage] = useState(false);
@@ -531,6 +562,35 @@ export function ChatPanel({
});
};
const openSaveActions = (message: Message) => {
const actions = getMessageSaveActions(getDisplayText(message), {
userId: user?.id,
username: user?.username,
});
if (actions.kind === 'page') {
setSharePreviewSource(message);
return;
}
setPageSource(message);
};
const downloadLongImage = (_message: Message, publicUrl: string) => {
triggerUrlDownload(appendLongImageDownloadParam(publicUrl));
};
const downloadDocx = async (message: Message) => {
if (!session?.id || !message.id) return;
try {
const result = await downloadChatMessageDocx({
sessionId: session.id,
messageId: message.id,
});
downloadBlob(result.blob, result.filename);
} catch (err) {
setVoiceNotice(err instanceof Error ? err.message : '文档下载失败,请重试');
}
};
return (
<>
<main
@@ -549,10 +609,11 @@ export function ChatPanel({
messages={messages}
streaming={chatState === 'streaming'}
onAvatarClick={compact ? undefined : openAvatarPicker}
onSaveAsPage={(message) => setPageSource(message)}
onSaveAsPage={openSaveActions}
onDownloadLongImage={downloadLongImage}
onDownloadDocx={(message) => void downloadDocx(message)}
publishUserId={user?.id}
publishUsername={user?.username}
sessionId={session?.id}
compact={compact}
/>
{!compact && (
@@ -587,11 +648,24 @@ export function ChatPanel({
onClose={() => setPageSource(null)}
onSaved={(result) => {
setPageSource(null);
setSharePreviewSource(null);
onPageSaved?.(result);
}}
/>
)}
{sharePreviewSource?.id && session?.id && (
<ChatSharePreviewModal
sessionId={session.id}
messageId={sharePreviewSource.id}
onClose={() => setSharePreviewSource(null)}
onSave={() => {
setPageSource(sharePreviewSource);
setSharePreviewSource(null);
}}
/>
)}
<footer className={compact ? 'space-chat-panel-footer' : `footer${showHomeWelcome ? ' footer-home' : ''}`}>
{connectStatusText && (
<div className="chat-connect-status" role="status" aria-live="polite">
+86 -3
View File
@@ -1,6 +1,6 @@
import { useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { buildChatSavePreviewFrameUrl, quickShareFromChat } from '../api/client';
import { buildChatSavePreviewFrameUrl, downloadChatMessageDocx, quickShareFromChat } from '../api/client';
type SharePhase =
| { kind: 'idle' }
@@ -8,6 +8,38 @@ type SharePhase =
| { kind: 'done'; publicUrl: string }
| { kind: 'error'; message: string };
type ExportPhase =
| { kind: 'idle' }
| { kind: 'image-loading' }
| { kind: 'docx-loading' }
| { kind: 'error'; message: string };
function appendLongImageDownloadParam(url: string) {
const next = new URL(url, window.location.href);
next.searchParams.set('download', 'long-image');
return next.toString();
}
function triggerUrlDownload(url: string) {
const link = document.createElement('a');
link.href = url;
link.download = '';
document.body.appendChild(link);
link.click();
link.remove();
}
function downloadBlob(blob: Blob, filename: string) {
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
link.remove();
window.setTimeout(() => URL.revokeObjectURL(url), 60_000);
}
export function ChatSharePreviewModal({
sessionId,
messageId,
@@ -22,6 +54,7 @@ export function ChatSharePreviewModal({
onSave?: () => void;
}) {
const [phase, setPhase] = useState<SharePhase>({ kind: 'idle' });
const [exportPhase, setExportPhase] = useState<ExportPhase>({ kind: 'idle' });
const [copied, setCopied] = useState(false);
const copyTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -52,6 +85,39 @@ export function ChatSharePreviewModal({
}
};
const ensurePublicUrl = async () => {
if (phase.kind === 'done') return phase.publicUrl;
setPhase({ kind: 'loading' });
const result = await quickShareFromChat({ sessionId, messageId, selectedLinkIndex });
setPhase({ kind: 'done', publicUrl: result.publicUrl });
return result.publicUrl;
};
const downloadImage = async () => {
if (exportPhase.kind === 'image-loading' || exportPhase.kind === 'docx-loading') return;
setExportPhase({ kind: 'image-loading' });
try {
const publicUrl = await ensurePublicUrl();
triggerUrlDownload(appendLongImageDownloadParam(publicUrl));
setExportPhase({ kind: 'idle' });
} catch (err) {
setExportPhase({ kind: 'error', message: err instanceof Error ? err.message : '长图下载失败,请重试' });
setPhase((current) => (current.kind === 'loading' ? { kind: 'idle' } : current));
}
};
const downloadDocx = async () => {
if (exportPhase.kind === 'image-loading' || exportPhase.kind === 'docx-loading') return;
setExportPhase({ kind: 'docx-loading' });
try {
const result = await downloadChatMessageDocx({ sessionId, messageId, selectedLinkIndex });
downloadBlob(result.blob, result.filename);
setExportPhase({ kind: 'idle' });
} catch (err) {
setExportPhase({ kind: 'error', message: err instanceof Error ? err.message : '文档下载失败,请重试' });
}
};
const copy = async (url: string) => {
try {
await navigator.clipboard.writeText(url);
@@ -94,15 +160,32 @@ export function ChatSharePreviewModal({
onClick={() => void share()}
title="将页面转为公开链接,私有图片自动转为公开地址"
>
{phase.kind === 'loading' ? '处理中…' : '🌐 公开分享'}
{phase.kind === 'loading' ? '处理中…' : '公开分享'}
</button>
)}
{phase.kind === 'error' && <p className="chat-share-side-error">{phase.message}</p>}
{onSave && (
<button type="button" className="chat-share-side-btn" onClick={handleSave}>
📄
</button>
)}
<button
type="button"
className="chat-share-side-btn"
disabled={exportPhase.kind === 'image-loading' || exportPhase.kind === 'docx-loading'}
onClick={() => void downloadImage()}
>
{exportPhase.kind === 'image-loading' ? '生成图片…' : '下载图片'}
</button>
<button
type="button"
className="chat-share-side-btn"
disabled={exportPhase.kind === 'image-loading' || exportPhase.kind === 'docx-loading'}
onClick={() => void downloadDocx()}
>
{exportPhase.kind === 'docx-loading' ? '生成文档…' : '保存文档'}
</button>
{exportPhase.kind === 'error' && <p className="chat-share-side-error">{exportPhase.message}</p>}
</div>
</div>
</div>,
+127 -11
View File
@@ -38,6 +38,65 @@ function CheckIcon() {
);
}
function PageIcon() {
return (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path
d="M7 3.5h7.5L19 8v12.5H7z"
stroke="currentColor"
strokeWidth="1.8"
strokeLinejoin="round"
/>
<path d="M14.5 3.5V8H19M10 12h6M10 15h6M10 18h4" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" />
</svg>
);
}
function ImageIcon() {
return (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<rect x="4" y="5" width="16" height="14" rx="2" stroke="currentColor" strokeWidth="1.8" />
<path d="M8 15l3-3 3 3 2-2 3 3" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" />
<circle cx="9" cy="9" r="1.2" fill="currentColor" />
</svg>
);
}
function DocumentIcon() {
return (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path
d="M6.5 4h8L18 7.5V20H6.5z"
stroke="currentColor"
strokeWidth="1.8"
strokeLinejoin="round"
/>
<path d="M14.5 4v4H18" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" />
<path
d="M8.7 11l1 5 1.3-4 1.3 4 1-5"
stroke="currentColor"
strokeWidth="1.45"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
function LinkIcon() {
return (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path
d="M10.5 13.5l3-3M9.5 8.5l1.2-1.2a4 4 0 0 1 5.7 5.7l-1.2 1.2M14.5 15.5l-1.2 1.2a4 4 0 0 1-5.7-5.7l1.2-1.2"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
function ToolActivityIcon({ spinning }: { spinning: boolean }) {
return (
<span className={`tool-badge-spinner${spinning ? ' is-spinning' : ''}`} aria-hidden="true">
@@ -139,9 +198,10 @@ function PublicLinkCopyButton({ url }: { url: string }) {
type="button"
className={`msg-public-share-link${copied ? ' is-copied' : ''}`}
onClick={() => void copy()}
title={url}
aria-label={copied ? '链接已复制' : '复制链接'}
title={copied ? '链接已复制' : '复制链接'}
>
{copied ? '已复制' : '复制链接'}
{copied ? <CheckIcon /> : <LinkIcon />}
</button>
);
}
@@ -180,6 +240,8 @@ function MessageRow({
avatarUrl,
onAvatarClick,
onSaveAsPage,
onDownloadLongImage,
onDownloadDocx,
saveDisabled,
publishUserId,
publishUsername,
@@ -191,6 +253,8 @@ function MessageRow({
avatarUrl: string | null;
onAvatarClick?: () => void;
onSaveAsPage?: (message: Message) => void;
onDownloadLongImage?: (message: Message, publicUrl: string) => void;
onDownloadDocx?: (message: Message) => void;
saveDisabled?: boolean;
publishUserId?: string;
publishUsername?: string;
@@ -221,6 +285,7 @@ function MessageRow({
const hasAssistantActions =
!isUser && Boolean(message.id && onSaveAsPage && copyText);
const hasPageDownloadActions = hasAssistantActions && saveActions.kind === 'page' && Boolean(saveActions.previewUrl);
const showActionsToggle = compact && Boolean(copyText);
return (
@@ -280,19 +345,43 @@ function MessageRow({
<div className="msg-actions">
<CopyButton text={copyText} />
{hasAssistantActions && (
<>
<div className="msg-page-actions">
<button
type="button"
className="msg-save-page"
disabled={saveDisabled}
onClick={() => onSaveAsPage!(message)}
aria-label={saveActions.kind === 'page' ? '保存页面' : '保存为文章'}
title={saveActions.kind === 'page' ? '保存页面' : '保存为文章'}
>
{saveActions.kind === 'page' ? '保存页面' : '保存为文章'}
<PageIcon />
</button>
{saveActions.previewUrl && (
<PublicLinkCopyButton url={saveActions.previewUrl} />
)}
</>
{hasPageDownloadActions && (
<>
<button
type="button"
className="msg-save-page"
onClick={() => onDownloadLongImage?.(message, saveActions.previewUrl!)}
aria-label="下载图片"
title="下载图片"
>
<ImageIcon />
</button>
<button
type="button"
className="msg-save-page"
onClick={() => onDownloadDocx?.(message)}
aria-label="保存文档"
title="保存文档"
>
<DocumentIcon />
</button>
</>
)}
</div>
)}
</div>
)}
@@ -300,19 +389,43 @@ function MessageRow({
<div className="msg-actions msg-actions-compact">
<CopyButton text={copyText} />
{hasAssistantActions && (
<>
<div className="msg-page-actions">
<button
type="button"
className="msg-save-page"
disabled={saveDisabled}
onClick={() => onSaveAsPage!(message)}
aria-label={saveActions.kind === 'page' ? '保存页面' : '保存为文章'}
title={saveActions.kind === 'page' ? '保存页面' : '保存为文章'}
>
{saveActions.kind === 'page' ? '保存页面' : '保存为文章'}
<PageIcon />
</button>
{saveActions.previewUrl && (
<PublicLinkCopyButton url={saveActions.previewUrl} />
)}
</>
{hasPageDownloadActions && (
<>
<button
type="button"
className="msg-save-page"
onClick={() => onDownloadLongImage?.(message, saveActions.previewUrl!)}
aria-label="下载图片"
title="下载图片"
>
<ImageIcon />
</button>
<button
type="button"
className="msg-save-page"
onClick={() => onDownloadDocx?.(message)}
aria-label="保存文档"
title="保存文档"
>
<DocumentIcon />
</button>
</>
)}
</div>
)}
</div>
)}
@@ -335,18 +448,20 @@ export function MessageList({
streaming,
onAvatarClick,
onSaveAsPage,
onDownloadLongImage,
onDownloadDocx,
publishUserId,
publishUsername,
sessionId,
compact = false,
}: {
messages: Message[];
streaming: boolean;
onAvatarClick?: () => void;
onSaveAsPage?: (message: Message) => void;
onDownloadLongImage?: (message: Message, publicUrl: string) => void;
onDownloadDocx?: (message: Message) => void;
publishUserId?: string;
publishUsername?: string;
sessionId?: string;
compact?: boolean;
}) {
const { avatarUrl } = useUserAvatar();
@@ -381,10 +496,11 @@ export function MessageList({
avatarUrl={avatarUrl}
onAvatarClick={onAvatarClick}
onSaveAsPage={onSaveAsPage}
onDownloadLongImage={onDownloadLongImage}
onDownloadDocx={onDownloadDocx}
saveDisabled={streaming}
publishUserId={publishUserId}
publishUsername={publishUsername}
sessionId={sessionId}
compact={compact}
activeToolMessage={message === activeToolMessage}
/>
+16 -1
View File
@@ -1193,9 +1193,22 @@ body,
gap: 6px;
}
.msg-page-actions {
display: grid;
grid-template-columns: repeat(2, 28px);
grid-auto-rows: 28px;
gap: 6px;
}
.msg-save-page,
.msg-public-share-link {
padding: 7px 10px;
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
min-width: 28px;
height: 28px;
padding: 0;
border: 1px solid var(--color-border);
border-radius: 999px;
color: var(--color-text-secondary);
@@ -1209,7 +1222,9 @@ body,
}
.msg-bubble-wrap:hover .msg-save-page,
.msg-bubble-wrap:hover .msg-page-actions,
.msg-bubble-wrap:focus-within .msg-save-page,
.msg-bubble-wrap:focus-within .msg-page-actions,
.msg-bubble-wrap:hover .msg-public-share-link,
.msg-bubble-wrap:focus-within .msg-public-share-link {
opacity: 1;