feat: add chat page export actions
This commit is contained in:
@@ -0,0 +1,167 @@
|
||||
const W_NS = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main';
|
||||
const R_NS = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships';
|
||||
|
||||
const CONTENT_TYPES = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
|
||||
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
|
||||
<Default Extension="xml" ContentType="application/xml"/>
|
||||
<Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>
|
||||
</Types>`;
|
||||
|
||||
const ROOT_RELS = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
|
||||
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/>
|
||||
</Relationships>`;
|
||||
|
||||
const DOCUMENT_RELS = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"></Relationships>`;
|
||||
|
||||
function escapeXml(value) {
|
||||
return String(value ?? '')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
function decodeHtmlEntities(value) {
|
||||
return String(value ?? '')
|
||||
.replace(/ /gi, ' ')
|
||||
.replace(/&/gi, '&')
|
||||
.replace(/</gi, '<')
|
||||
.replace(/>/gi, '>')
|
||||
.replace(/"/gi, '"')
|
||||
.replace(/'/gi, "'")
|
||||
.replace(/&#(\d+);/g, (_, code) => String.fromCodePoint(Number(code)))
|
||||
.replace(/&#x([0-9a-f]+);/gi, (_, code) => String.fromCodePoint(Number.parseInt(code, 16)));
|
||||
}
|
||||
|
||||
export function extractPlainTextFromHtml(html) {
|
||||
return decodeHtmlEntities(
|
||||
String(html ?? '')
|
||||
.replace(/<script[\s\S]*?<\/script>/gi, ' ')
|
||||
.replace(/<style[\s\S]*?<\/style>/gi, ' ')
|
||||
.replace(/<\/(?:p|div|section|article|header|footer|h[1-6]|li|tr|blockquote)>/gi, '\n')
|
||||
.replace(/<br\s*\/?>/gi, '\n')
|
||||
.replace(/<[^>]+>/g, ' ')
|
||||
.replace(/[ \t\r\f\v]+/g, ' ')
|
||||
.replace(/\n[ \t]+/g, '\n')
|
||||
.replace(/[ \t]+\n/g, '\n')
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.trim(),
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeParagraphs(content) {
|
||||
return String(content ?? '')
|
||||
.split(/\n{1,}/)
|
||||
.map((line) => line.replace(/^#{1,6}\s+/, '').replace(/[*_`~]/g, '').trim())
|
||||
.filter(Boolean)
|
||||
.slice(0, 500);
|
||||
}
|
||||
|
||||
function paragraphXml(text, { heading = false } = {}) {
|
||||
const style = heading ? '<w:pPr><w:pStyle w:val="Title"/></w:pPr>' : '';
|
||||
const runStyle = heading ? '<w:rPr><w:b/><w:sz w:val="32"/></w:rPr>' : '';
|
||||
return `<w:p>${style}<w:r>${runStyle}<w:t xml:space="preserve">${escapeXml(text)}</w:t></w:r></w:p>`;
|
||||
}
|
||||
|
||||
function documentXml({ title, paragraphs }) {
|
||||
const body = [
|
||||
title ? paragraphXml(title, { heading: true }) : '',
|
||||
...paragraphs.map((paragraph) => paragraphXml(paragraph)),
|
||||
].join('');
|
||||
return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<w:document xmlns:w="${W_NS}" xmlns:r="${R_NS}">
|
||||
<w:body>${body}<w:sectPr><w:pgSz w:w="11906" w:h="16838"/><w:pgMar w:top="1440" w:right="1440" w:bottom="1440" w:left="1440"/></w:sectPr></w:body>
|
||||
</w:document>`;
|
||||
}
|
||||
|
||||
const CRC_TABLE = (() => {
|
||||
const table = new Uint32Array(256);
|
||||
for (let n = 0; n < 256; n += 1) {
|
||||
let c = n;
|
||||
for (let k = 0; k < 8; k += 1) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
||||
table[n] = c >>> 0;
|
||||
}
|
||||
return table;
|
||||
})();
|
||||
|
||||
function crc32(buffer) {
|
||||
let crc = 0xffffffff;
|
||||
for (const byte of buffer) crc = CRC_TABLE[(crc ^ byte) & 0xff] ^ (crc >>> 8);
|
||||
return (crc ^ 0xffffffff) >>> 0;
|
||||
}
|
||||
|
||||
function dosDateTime(date = new Date()) {
|
||||
const year = Math.max(1980, date.getFullYear());
|
||||
const dosTime = (date.getHours() << 11) | (date.getMinutes() << 5) | Math.floor(date.getSeconds() / 2);
|
||||
const dosDate = ((year - 1980) << 9) | ((date.getMonth() + 1) << 5) | date.getDate();
|
||||
return { dosTime, dosDate };
|
||||
}
|
||||
|
||||
function writeZipEntry(name, content, offset) {
|
||||
const nameBuffer = Buffer.from(name);
|
||||
const data = Buffer.isBuffer(content) ? content : Buffer.from(String(content), 'utf8');
|
||||
const checksum = crc32(data);
|
||||
const { dosTime, dosDate } = dosDateTime();
|
||||
const local = Buffer.alloc(30 + nameBuffer.length);
|
||||
local.writeUInt32LE(0x04034b50, 0);
|
||||
local.writeUInt16LE(20, 4);
|
||||
local.writeUInt16LE(0, 6);
|
||||
local.writeUInt16LE(0, 8);
|
||||
local.writeUInt16LE(dosTime, 10);
|
||||
local.writeUInt16LE(dosDate, 12);
|
||||
local.writeUInt32LE(checksum, 14);
|
||||
local.writeUInt32LE(data.length, 18);
|
||||
local.writeUInt32LE(data.length, 22);
|
||||
local.writeUInt16LE(nameBuffer.length, 26);
|
||||
nameBuffer.copy(local, 30);
|
||||
|
||||
const central = Buffer.alloc(46 + nameBuffer.length);
|
||||
central.writeUInt32LE(0x02014b50, 0);
|
||||
central.writeUInt16LE(20, 4);
|
||||
central.writeUInt16LE(20, 6);
|
||||
central.writeUInt16LE(0, 8);
|
||||
central.writeUInt16LE(0, 10);
|
||||
central.writeUInt16LE(dosTime, 12);
|
||||
central.writeUInt16LE(dosDate, 14);
|
||||
central.writeUInt32LE(checksum, 16);
|
||||
central.writeUInt32LE(data.length, 20);
|
||||
central.writeUInt32LE(data.length, 24);
|
||||
central.writeUInt16LE(nameBuffer.length, 28);
|
||||
central.writeUInt32LE(offset, 42);
|
||||
nameBuffer.copy(central, 46);
|
||||
return { local, data, central };
|
||||
}
|
||||
|
||||
function zip(entries) {
|
||||
let offset = 0;
|
||||
const locals = [];
|
||||
const centrals = [];
|
||||
for (const [name, content] of entries) {
|
||||
const entry = writeZipEntry(name, content, offset);
|
||||
locals.push(entry.local, entry.data);
|
||||
centrals.push(entry.central);
|
||||
offset += entry.local.length + entry.data.length;
|
||||
}
|
||||
const centralSize = centrals.reduce((sum, item) => sum + item.length, 0);
|
||||
const end = Buffer.alloc(22);
|
||||
end.writeUInt32LE(0x06054b50, 0);
|
||||
end.writeUInt16LE(entries.length, 8);
|
||||
end.writeUInt16LE(entries.length, 10);
|
||||
end.writeUInt32LE(centralSize, 12);
|
||||
end.writeUInt32LE(offset, 16);
|
||||
return Buffer.concat([...locals, ...centrals, end]);
|
||||
}
|
||||
|
||||
export function generateDocxBuffer({ title = '文档', content = '', contentFormat = 'markdown' } = {}) {
|
||||
const plain = contentFormat === 'html' ? extractPlainTextFromHtml(content) : String(content ?? '');
|
||||
const paragraphs = normalizeParagraphs(plain);
|
||||
return zip([
|
||||
['[Content_Types].xml', CONTENT_TYPES],
|
||||
['_rels/.rels', ROOT_RELS],
|
||||
['word/document.xml', documentXml({ title, paragraphs: paragraphs.length > 0 ? paragraphs : [''] })],
|
||||
['word/_rels/document.xml.rels', DOCUMENT_RELS],
|
||||
]);
|
||||
}
|
||||
+37
@@ -109,6 +109,7 @@ import {
|
||||
renderLongImage,
|
||||
renderLongImageBuffer,
|
||||
} from './mindspace-long-image.mjs';
|
||||
import { generateDocxBuffer } from './mindspace-docx-export.mjs';
|
||||
import { scanContent } from './mindspace-content-scan.mjs';
|
||||
import { renderImageAssetViewerHtml, wantsInlineImageViewer } from './mindspace-asset-preview.mjs';
|
||||
import { createRechargeService } from './billing-recharge.mjs';
|
||||
@@ -219,6 +220,7 @@ function csrfOriginCheck(req, res, next) {
|
||||
|
||||
app.use('/api', csrfOriginCheck);
|
||||
app.use('/auth', csrfOriginCheck);
|
||||
app.use('/mindspace', csrfOriginCheck);
|
||||
|
||||
const jsonBody = express.json({ limit: '1mb' });
|
||||
const jsonUnlessMultipart = (req, res, next) => {
|
||||
@@ -3132,6 +3134,40 @@ api.post('/mindspace/v1/pages/quick-share-from-chat', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
async function handleChatSaveDocx(req, res) {
|
||||
if (!mindSpacePages) return res.status(503).json({ message: 'MindSpace 未启用' });
|
||||
try {
|
||||
const bundle = await resolveChatSaveBundle(req.currentUser, __dirname, req.body);
|
||||
const title =
|
||||
bundle.previewTitle ||
|
||||
bundle.resolvedHtml?.suggestedTitle ||
|
||||
bundle.analysis?.suggestedTitle ||
|
||||
'AI 创作文档';
|
||||
const content = bundle.resolvedHtml?.content ?? bundle.source.content;
|
||||
const contentFormat = bundle.resolvedHtml?.content ? 'html' : 'markdown';
|
||||
const buffer = generateDocxBuffer({ title, content, contentFormat });
|
||||
const filenameBase = String(title)
|
||||
.replace(/[\\/:*?"<>|]+/g, '-')
|
||||
.replace(/\s+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.slice(0, 60) || 'mindspace-document';
|
||||
const filename = `${filenameBase}.docx`;
|
||||
res.set('Content-Type', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document');
|
||||
res.set(
|
||||
'Content-Disposition',
|
||||
`attachment; filename="${encodeURIComponent(filename)}"; filename*=UTF-8''${encodeURIComponent(filename)}`,
|
||||
);
|
||||
res.set('Cache-Control', 'no-store');
|
||||
res.setHeader('X-Request-Id', req.requestId);
|
||||
return res.send(buffer);
|
||||
} catch (error) {
|
||||
return mindSpaceError(res, req, error);
|
||||
}
|
||||
}
|
||||
|
||||
api.post('/mindspace/v1/pages/chat-save-docx', handleChatSaveDocx);
|
||||
api.post('/v1/pages/chat-save-docx', handleChatSaveDocx);
|
||||
|
||||
api.post('/mindspace/v1/pages/save-from-chat', async (req, res) => {
|
||||
if (!mindSpacePages || !mindSpaceAssets) {
|
||||
return res.status(503).json({ message: 'MindSpace 未启用' });
|
||||
@@ -4458,6 +4494,7 @@ api.use(
|
||||
);
|
||||
|
||||
app.use('/api', api);
|
||||
app.use('/mindspace', api);
|
||||
|
||||
function scriptSrcDirective({ inline = false, urls = [], hashes = [] } = {}) {
|
||||
const parts = [];
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user