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 { useChatScroll } from './hooks/useChatScroll';
|
||||||
import { useChatData } from './hooks/useChatData';
|
import { useChatData } from './hooks/useChatData';
|
||||||
import { useChatSender } from './hooks/useChatSender';
|
import { useChatSender } from './hooks/useChatSender';
|
||||||
|
import { markdownToPlainText } from './utils/copy';
|
||||||
|
|
||||||
export default function ChatPage() {
|
export default function ChatPage() {
|
||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
|
|
@ -106,7 +107,10 @@ export default function ChatPage() {
|
||||||
streaming={sender.streaming}
|
streaming={sender.streaming}
|
||||||
onRegenerate={sender.handleRegenerate}
|
onRegenerate={sender.handleRegenerate}
|
||||||
onSwitchBranch={sender.handleSwitchBranch}
|
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
|
<ChatInput
|
||||||
|
|
@ -156,4 +160,3 @@ export default function ChatPage() {
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { Divider, Tag } from 'antd';
|
||||||
import type { Agent, BranchInfo, ChatMessage } from '../../../api';
|
import type { Agent, BranchInfo, ChatMessage } from '../../../api';
|
||||||
import Markdown from '../../../components/Markdown';
|
import Markdown from '../../../components/Markdown';
|
||||||
import type { StreamingState } from '../hooks/useChatSender';
|
import type { StreamingState } from '../hooks/useChatSender';
|
||||||
|
import type { CopyMode } from '../utils/copy';
|
||||||
import MessageItem from './messages/MessageItem';
|
import MessageItem from './messages/MessageItem';
|
||||||
import { RetrievedView, ToolCallView } from './messages/MetaViews';
|
import { RetrievedView, ToolCallView } from './messages/MetaViews';
|
||||||
|
|
||||||
|
|
@ -17,7 +18,7 @@ export default function ChatBody(props: {
|
||||||
streaming: StreamingState;
|
streaming: StreamingState;
|
||||||
onRegenerate: (assistantId: string) => void;
|
onRegenerate: (assistantId: string) => void;
|
||||||
onSwitchBranch: (userMsgId: string, branchId: 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;
|
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 type { BranchInfo, ChatMessage } from '../../../../api';
|
||||||
import Markdown from '../../../../components/Markdown';
|
import Markdown from '../../../../components/Markdown';
|
||||||
|
import { ProductCopyIcon } from '../../../../components/icons';
|
||||||
|
import type { CopyMode } from '../../utils/copy';
|
||||||
import { ReasoningView, RetrievedView, ToolCallView } from './MetaViews';
|
import { ReasoningView, RetrievedView, ToolCallView } from './MetaViews';
|
||||||
|
|
||||||
export default function MessageItem(props: {
|
export default function MessageItem(props: {
|
||||||
|
|
@ -10,7 +12,7 @@ export default function MessageItem(props: {
|
||||||
busy?: boolean;
|
busy?: boolean;
|
||||||
onRegenerate?: (id: string) => void;
|
onRegenerate?: (id: string) => void;
|
||||||
onSwitchBranch?: (userMsgId: string, branchId: 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 { message, highlighted, branch, busy, onRegenerate, onSwitchBranch, onCopy } = props;
|
||||||
const hasBranches = !!branch && branch.total > 1;
|
const hasBranches = !!branch && branch.total > 1;
|
||||||
|
|
@ -66,11 +68,21 @@ export default function MessageItem(props: {
|
||||||
</Space>
|
</Space>
|
||||||
)}
|
)}
|
||||||
{message.meta?.aborted && <Tag color="orange">已停止</Tag>}
|
{message.meta?.aborted && <Tag color="orange">已停止</Tag>}
|
||||||
|
<Dropdown
|
||||||
|
trigger={['click']}
|
||||||
|
menu={{
|
||||||
|
items: [
|
||||||
|
{ key: 'plain', label: '复制纯文本', onClick: () => onCopy?.(message.content, 'plain') },
|
||||||
|
{ key: 'markdown', label: '复制 Markdown', onClick: () => onCopy?.(message.content, 'markdown') }
|
||||||
|
]
|
||||||
|
}}
|
||||||
|
>
|
||||||
<Tooltip title="复制">
|
<Tooltip title="复制">
|
||||||
<Button size="small" type="text" onClick={() => onCopy?.(message.content)}>
|
<span className="chat-copy-icon" aria-label="copy">
|
||||||
📋
|
<ProductCopyIcon />
|
||||||
</Button>
|
</span>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
</Dropdown>
|
||||||
<Tooltip title="重新生成(开新分支)">
|
<Tooltip title="重新生成(开新分支)">
|
||||||
<Button size="small" type="text" disabled={busy} onClick={() => onRegenerate?.(message.id)}>
|
<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;
|
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 {
|
.bubble.assistant table {
|
||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue