chat: improve copy with icon and plain/markdown modes
parent
2be250b56c
commit
088ab7ab73
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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)}>
|
||||
🔄
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
@ -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%;
|
||||
|
|
|
|||
Loading…
Reference in New Issue