chat: improve copy with icon and plain/markdown modes

main
sp mac bookpro 2605 2026-06-05 23:23:56 +08:00
parent 2be250b56c
commit 088ab7ab73
6 changed files with 136 additions and 10 deletions

33
src/components/icons.tsx Normal file
View File

@ -0,0 +1,33 @@
import type { SVGProps } from 'react';
export function ProductCopyIcon(props: SVGProps<SVGSVGElement>) {
const { width = 20, height = 20, ...rest } = props;
return (
<svg
width={width}
height={height}
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...rest}
>
<path
d="M4.90476 4.66663H3C2.63181 4.66663 2.33334 4.9651 2.33334 5.33329V13C2.33334 13.3681 2.63181 13.6666 3 13.6666H10.6667C11.0349 13.6666 11.3333 13.3681 11.3333 13V11.0952"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
/>
<rect
x="5.33334"
y="2.33331"
width="8.33333"
height="8.33333"
rx="0.666667"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}

View File

@ -10,6 +10,7 @@ import ChatDrawers from './components/ChatDrawers';
import { useChatScroll } from './hooks/useChatScroll';
import { useChatData } from './hooks/useChatData';
import { useChatSender } from './hooks/useChatSender';
import { markdownToPlainText } from './utils/copy';
export default function ChatPage() {
const { id } = useParams();
@ -106,7 +107,10 @@ export default function ChatPage() {
streaming={sender.streaming}
onRegenerate={sender.handleRegenerate}
onSwitchBranch={sender.handleSwitchBranch}
onCopy={(text) => navigator.clipboard?.writeText(text).then(() => message.success('已复制'))}
onCopy={(text, mode) => {
const content = mode === 'markdown' ? text : markdownToPlainText(text);
return navigator.clipboard?.writeText(content).then(() => message.success(mode === 'markdown' ? '已复制Markdown' : '已复制(纯文本)'));
}}
/>
<ChatInput
@ -156,4 +160,3 @@ export default function ChatPage() {
</div>
);
}

View File

@ -2,6 +2,7 @@ import { Divider, Tag } from 'antd';
import type { Agent, BranchInfo, ChatMessage } from '../../../api';
import Markdown from '../../../components/Markdown';
import type { StreamingState } from '../hooks/useChatSender';
import type { CopyMode } from '../utils/copy';
import MessageItem from './messages/MessageItem';
import { RetrievedView, ToolCallView } from './messages/MetaViews';
@ -17,7 +18,7 @@ export default function ChatBody(props: {
streaming: StreamingState;
onRegenerate: (assistantId: string) => void;
onSwitchBranch: (userMsgId: string, branchId: string) => void;
onCopy: (text: string) => void;
onCopy: (text: string, mode: CopyMode) => void;
}) {
const { bodyRef, agent, messages, branches, highlightId, sending, streaming, onRegenerate, onSwitchBranch, onCopy } = props;

View File

@ -1,6 +1,8 @@
import { Button, Space, Tag, Tooltip } from 'antd';
import { Button, Dropdown, Space, Tag, Tooltip } from 'antd';
import type { BranchInfo, ChatMessage } from '../../../../api';
import Markdown from '../../../../components/Markdown';
import { ProductCopyIcon } from '../../../../components/icons';
import type { CopyMode } from '../../utils/copy';
import { ReasoningView, RetrievedView, ToolCallView } from './MetaViews';
export default function MessageItem(props: {
@ -10,7 +12,7 @@ export default function MessageItem(props: {
busy?: boolean;
onRegenerate?: (id: string) => void;
onSwitchBranch?: (userMsgId: string, branchId: string) => void;
onCopy?: (text: string) => void;
onCopy?: (text: string, mode: CopyMode) => void;
}) {
const { message, highlighted, branch, busy, onRegenerate, onSwitchBranch, onCopy } = props;
const hasBranches = !!branch && branch.total > 1;
@ -66,11 +68,21 @@ export default function MessageItem(props: {
</Space>
)}
{message.meta?.aborted && <Tag color="orange"></Tag>}
<Tooltip title="复制">
<Button size="small" type="text" onClick={() => onCopy?.(message.content)}>
📋
</Button>
</Tooltip>
<Dropdown
trigger={['click']}
menu={{
items: [
{ key: 'plain', label: '复制纯文本', onClick: () => onCopy?.(message.content, 'plain') },
{ key: 'markdown', label: '复制 Markdown', onClick: () => onCopy?.(message.content, 'markdown') }
]
}}
>
<Tooltip title="复制">
<span className="chat-copy-icon" aria-label="copy">
<ProductCopyIcon />
</span>
</Tooltip>
</Dropdown>
<Tooltip title="重新生成(开新分支)">
<Button size="small" type="text" disabled={busy} onClick={() => onRegenerate?.(message.id)}>
🔄

View File

@ -0,0 +1,63 @@
export type CopyMode = 'plain' | 'markdown';
const splitTableRow = (line: string) =>
line
.trim()
.replace(/^\|/, '')
.replace(/\|$/, '')
.split('|')
.map((c) => c.trim());
const isTableDividerLine = (line: string) =>
/^\s*\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)+\|?\s*$/.test(line);
export function markdownToPlainText(input: string) {
let text = String(input ?? '').replace(/\r\n/g, '\n');
const lines = text.split('\n');
const out: string[] = [];
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const next = lines[i + 1];
if (line.includes('|') && next && isTableDividerLine(next)) {
const header = splitTableRow(line);
out.push(header.join('\t'));
i += 1;
for (let j = i + 1; j < lines.length; j++) {
const rowLine = lines[j];
if (!rowLine.trim()) {
i = j;
break;
}
if (!rowLine.includes('|')) {
i = j - 1;
break;
}
out.push(splitTableRow(rowLine).join('\t'));
i = j;
}
continue;
}
out.push(line);
}
text = out.join('\n');
text = text.replace(/```[^\n]*\n([\s\S]*?)```/g, (_, code) => String(code).replace(/\n$/, ''));
text = text.replace(/`([^`]+)`/g, '$1');
text = text.replace(/^#{1,6}\s+/gm, '');
text = text.replace(/^>\s?/gm, '');
text = text.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (_, alt, url) => (alt ? `${alt} (${url})` : String(url)));
text = text.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1 ($2)');
text = text.replace(/\*\*([^*]+)\*\*/g, '$1');
text = text.replace(/__([^_]+)__/g, '$1');
text = text.replace(/\*([^*]+)\*/g, '$1');
text = text.replace(/_([^_]+)_/g, '$1');
text = text.replace(/\n{3,}/g, '\n\n').trim();
return text;
}

View File

@ -633,6 +633,20 @@ body {
margin: 0.2em 0;
}
.chat-copy-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
cursor: pointer;
color: var(--color-text-secondary);
}
.chat-copy-icon:hover {
color: var(--color-text);
}
.bubble.assistant table {
border-collapse: collapse;
width: 100%;