mirror of
https://github.com/sipeed/picoclaw.git
synced 2026-05-25 16:00:35 +00:00
feat(chat): add independent code block copy and collapse controls (#2882)
* feat(chat): add independent copy and collapse controls for code blocks * fix(chat): unify code block rendering styles * fix(chat): refine code block labels * feat(chat): highlight tool call code blocks as json
This commit is contained in:
@@ -15,8 +15,13 @@ import rehypeRaw from "rehype-raw"
|
||||
import rehypeSanitize from "rehype-sanitize"
|
||||
import remarkGfm from "remark-gfm"
|
||||
|
||||
import {
|
||||
MessageCodeBlock,
|
||||
MarkdownCodeBlock,
|
||||
} from "@/components/chat/message-code-block"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { formatMessageTime } from "@/hooks/use-pico-chat"
|
||||
import { useCopyToClipboard } from "@/hooks/use-copy-to-clipboard"
|
||||
import { cn } from "@/lib/utils"
|
||||
import {
|
||||
type AssistantMessageKind,
|
||||
@@ -40,7 +45,7 @@ export function AssistantMessage({
|
||||
timestamp = "",
|
||||
}: AssistantMessageProps) {
|
||||
const { t } = useTranslation()
|
||||
const [isCopied, setIsCopied] = useState(false)
|
||||
const { copy, isCopied } = useCopyToClipboard()
|
||||
const isThought = kind === "thought"
|
||||
const isToolCalls = kind === "tool_calls"
|
||||
const isCollapsedBlock = isThought || isToolCalls
|
||||
@@ -55,44 +60,12 @@ export function AssistantMessage({
|
||||
const [isExpanded, setIsExpanded] = useState(true)
|
||||
const formattedTimestamp =
|
||||
timestamp !== "" ? formatMessageTime(timestamp) : ""
|
||||
|
||||
const handleCopy = async () => {
|
||||
const markCopied = () => {
|
||||
setIsCopied(true)
|
||||
setTimeout(() => setIsCopied(false), 2000)
|
||||
}
|
||||
|
||||
try {
|
||||
if (navigator.clipboard?.writeText) {
|
||||
await navigator.clipboard.writeText(content)
|
||||
markCopied()
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
// HTTP 或受限环境下可能不支持 Clipboard API,继续走降级方案
|
||||
}
|
||||
|
||||
const textArea = document.createElement("textarea")
|
||||
textArea.value = content
|
||||
textArea.setAttribute("readonly", "")
|
||||
textArea.style.position = "fixed"
|
||||
textArea.style.left = "-9999px"
|
||||
document.body.appendChild(textArea)
|
||||
textArea.select()
|
||||
|
||||
try {
|
||||
const copied = document.execCommand("copy")
|
||||
if (copied) {
|
||||
markCopied()
|
||||
}
|
||||
} finally {
|
||||
document.body.removeChild(textArea)
|
||||
}
|
||||
}
|
||||
|
||||
const collapsedLabel = isThought
|
||||
? t("chat.reasoningLabel")
|
||||
: t("chat.toolCallsLabel")
|
||||
const copyMessageLabel = isCopied
|
||||
? t("chat.copiedLabel")
|
||||
: t("chat.copyMessage")
|
||||
|
||||
return (
|
||||
<div className="group flex w-full flex-col gap-1.5">
|
||||
@@ -174,6 +147,9 @@ export function AssistantMessage({
|
||||
rehypeSanitize,
|
||||
rehypeHighlight,
|
||||
]}
|
||||
components={{
|
||||
pre: MarkdownCodeBlock,
|
||||
}}
|
||||
>
|
||||
{explanation}
|
||||
</ReactMarkdown>
|
||||
@@ -192,15 +168,20 @@ export function AssistantMessage({
|
||||
{t("chat.toolCallFunctionLabel")}
|
||||
</div>
|
||||
<div className="bg-background/55 border-border/25 space-y-2 rounded-lg border px-3 py-2.5">
|
||||
{toolName && (
|
||||
{toolName && !toolArguments && (
|
||||
<div className="text-foreground/75 font-mono text-[12px] font-semibold">
|
||||
{toolName}
|
||||
</div>
|
||||
)}
|
||||
{toolArguments && (
|
||||
<pre className="text-muted-foreground/75 overflow-x-auto font-mono text-[12px] leading-relaxed break-words whitespace-pre-wrap">
|
||||
{toolArguments}
|
||||
</pre>
|
||||
<MessageCodeBlock
|
||||
code={toolArguments}
|
||||
language="json"
|
||||
label={toolName || t("chat.toolCallArgumentsLabel")}
|
||||
className="my-0 shadow-none"
|
||||
bodyClassName="px-3 py-2 text-[12px] leading-relaxed"
|
||||
wrapLongLines
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -222,6 +203,9 @@ export function AssistantMessage({
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
rehypePlugins={[rehypeRaw, rehypeSanitize, rehypeHighlight]}
|
||||
components={{
|
||||
pre: MarkdownCodeBlock,
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
@@ -235,7 +219,9 @@ export function AssistantMessage({
|
||||
className={cn(
|
||||
"bg-background/50 hover:bg-background/80 absolute top-2 right-2 h-7 w-7 opacity-0 transition-opacity group-hover:opacity-100",
|
||||
)}
|
||||
onClick={handleCopy}
|
||||
onClick={() => void copy(content)}
|
||||
aria-label={copyMessageLabel}
|
||||
title={copyMessageLabel}
|
||||
>
|
||||
{isCopied ? (
|
||||
<IconCheck className="h-4 w-4 text-green-500" />
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
import {
|
||||
IconCheck,
|
||||
IconChevronDown,
|
||||
IconCopy,
|
||||
} from "@tabler/icons-react"
|
||||
import hljs from "highlight.js/lib/core"
|
||||
import json from "highlight.js/lib/languages/json"
|
||||
import { type ComponentProps, type ReactNode, useState } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
|
||||
import { useCopyToClipboard } from "@/hooks/use-copy-to-clipboard"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
import {
|
||||
extractCodeBlockFromPreNode,
|
||||
type MarkdownNode,
|
||||
} from "./message-code-block.utils"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
|
||||
const CODE_LABEL_FONT_FAMILY =
|
||||
'ui-monospace, "SFMono-Regular", Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei UI", "Microsoft YaHei", monospace'
|
||||
|
||||
hljs.registerLanguage("json", json)
|
||||
|
||||
interface MessageCodeBlockProps {
|
||||
code: string
|
||||
language?: string | null
|
||||
label?: string
|
||||
children?: ReactNode
|
||||
className?: string
|
||||
bodyClassName?: string
|
||||
wrapLongLines?: boolean
|
||||
}
|
||||
|
||||
interface MarkdownCodeBlockProps extends ComponentProps<"pre"> {
|
||||
node?: MarkdownNode
|
||||
}
|
||||
|
||||
function getHighlightedHtml(code: string, language?: string | null) {
|
||||
if (!language) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
return hljs.highlight(code, { language }).value
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function MessageCodeBlock({
|
||||
code,
|
||||
language = null,
|
||||
label,
|
||||
children,
|
||||
className,
|
||||
bodyClassName,
|
||||
wrapLongLines = false,
|
||||
}: MessageCodeBlockProps) {
|
||||
const { t } = useTranslation()
|
||||
const { copy, isCopied } = useCopyToClipboard()
|
||||
const [isExpanded, setIsExpanded] = useState(true)
|
||||
const blockLabel =
|
||||
label ??
|
||||
(language
|
||||
? language.toLocaleLowerCase()
|
||||
: t("chat.codeLabel").toLocaleLowerCase())
|
||||
const copyLabel = isCopied ? t("chat.copiedLabel") : t("chat.copyCode")
|
||||
const expandLabel = isExpanded ? t("chat.collapseCode") : t("chat.expandCode")
|
||||
const highlightedHtml = !children ? getHighlightedHtml(code, language) : null
|
||||
|
||||
return (
|
||||
<div
|
||||
data-picoclaw-code-block=""
|
||||
className={cn(
|
||||
"not-prose my-4 overflow-hidden rounded-lg border border-[#d0d7de] bg-[#f6f8fa] text-[#24292f] shadow-xs dark:border-[#30363d] dark:bg-[#0d1117] dark:text-[#c9d1d9]",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2 border-b border-[#d0d7de] bg-black/[0.03] px-3 py-2 dark:border-[#30363d] dark:bg-white/[0.03]">
|
||||
<span
|
||||
className="text-[11px] font-medium text-zinc-600 dark:text-zinc-400"
|
||||
style={{ fontFamily: CODE_LABEL_FONT_FAMILY }}
|
||||
>
|
||||
{blockLabel}
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
className="h-7 text-zinc-600 hover:bg-zinc-300/70 hover:text-zinc-900 dark:text-zinc-400 dark:hover:bg-zinc-800 dark:hover:text-zinc-100"
|
||||
onClick={() => void copy(code)}
|
||||
aria-label={copyLabel}
|
||||
title={copyLabel}
|
||||
>
|
||||
{isCopied ? (
|
||||
<IconCheck className="text-green-500" />
|
||||
) : (
|
||||
<IconCopy />
|
||||
)}
|
||||
<span className="hidden sm:inline">{copyLabel}</span>
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
className="h-7 text-zinc-600 hover:bg-zinc-300/70 hover:text-zinc-900 dark:text-zinc-400 dark:hover:bg-zinc-800 dark:hover:text-zinc-100"
|
||||
onClick={() => setIsExpanded((expanded) => !expanded)}
|
||||
aria-expanded={isExpanded}
|
||||
aria-label={expandLabel}
|
||||
title={expandLabel}
|
||||
>
|
||||
<IconChevronDown
|
||||
className={cn("transition-transform duration-200", isExpanded && "rotate-180")}
|
||||
/>
|
||||
<span className="hidden sm:inline">{expandLabel}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isExpanded && (
|
||||
<pre
|
||||
className={cn(
|
||||
"m-0 overflow-x-auto bg-transparent px-4 py-3 font-mono text-[13px] leading-6 [&_code]:block [&_code]:bg-transparent [&_code]:p-0 [&_code]:text-inherit",
|
||||
wrapLongLines ? "break-words whitespace-pre-wrap" : "whitespace-pre",
|
||||
bodyClassName,
|
||||
)}
|
||||
>
|
||||
{children ?? (
|
||||
highlightedHtml ? (
|
||||
<code
|
||||
className={cn("hljs", language && `language-${language}`)}
|
||||
dangerouslySetInnerHTML={{ __html: highlightedHtml }}
|
||||
/>
|
||||
) : (
|
||||
<code className={language ? `language-${language}` : undefined}>
|
||||
{code}
|
||||
</code>
|
||||
)
|
||||
)}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function MarkdownCodeBlock({
|
||||
children,
|
||||
className,
|
||||
node,
|
||||
}: MarkdownCodeBlockProps) {
|
||||
const { code, language } = extractCodeBlockFromPreNode(node)
|
||||
|
||||
return (
|
||||
<MessageCodeBlock
|
||||
code={code}
|
||||
language={language}
|
||||
bodyClassName={className}
|
||||
>
|
||||
{children}
|
||||
</MessageCodeBlock>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
export interface MarkdownNode {
|
||||
type?: string
|
||||
value?: string
|
||||
tagName?: string
|
||||
properties?: Record<string, unknown>
|
||||
children?: MarkdownNode[]
|
||||
}
|
||||
|
||||
function toClassNameTokens(className: unknown): string[] {
|
||||
if (typeof className === "string") {
|
||||
return className.split(/\s+/).filter(Boolean)
|
||||
}
|
||||
|
||||
if (Array.isArray(className)) {
|
||||
return className.filter(
|
||||
(token): token is string => typeof token === "string" && token.length > 0,
|
||||
)
|
||||
}
|
||||
|
||||
return []
|
||||
}
|
||||
|
||||
function findFirstDescendantByTagName(
|
||||
node: MarkdownNode | undefined,
|
||||
tagName: string,
|
||||
): MarkdownNode | undefined {
|
||||
if (!node) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
if (node.tagName === tagName) {
|
||||
return node
|
||||
}
|
||||
|
||||
if (!Array.isArray(node.children)) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
for (const child of node.children) {
|
||||
const match = findFirstDescendantByTagName(child, tagName)
|
||||
if (match) {
|
||||
return match
|
||||
}
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
export function extractTextFromMarkdownNode(
|
||||
node: MarkdownNode | undefined,
|
||||
): string {
|
||||
if (!node) {
|
||||
return ""
|
||||
}
|
||||
|
||||
if (node.type === "text") {
|
||||
return typeof node.value === "string" ? node.value : ""
|
||||
}
|
||||
|
||||
if (!Array.isArray(node.children)) {
|
||||
return ""
|
||||
}
|
||||
|
||||
return node.children.map(extractTextFromMarkdownNode).join("")
|
||||
}
|
||||
|
||||
export function extractCodeBlockLanguage(className: unknown): string | null {
|
||||
const languageToken = toClassNameTokens(className).find(
|
||||
(token) => token.startsWith("language-") && token.length > "language-".length,
|
||||
)
|
||||
|
||||
return languageToken ? languageToken.slice("language-".length) : null
|
||||
}
|
||||
|
||||
export function extractCodeBlockFromPreNode(node: MarkdownNode | undefined): {
|
||||
code: string
|
||||
language: string | null
|
||||
} {
|
||||
const codeNode = findFirstDescendantByTagName(node, "code")
|
||||
|
||||
return {
|
||||
code: extractTextFromMarkdownNode(codeNode ?? node),
|
||||
language: extractCodeBlockLanguage(codeNode?.properties?.className),
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,10 @@
|
||||
import { IconCheck, IconCopy } from "@tabler/icons-react"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { useCopyToClipboard } from "@/hooks/use-copy-to-clipboard"
|
||||
import { cn } from "@/lib/utils"
|
||||
import type { ChatAttachment } from "@/store/chat"
|
||||
import { useTranslation } from "react-i18next"
|
||||
|
||||
interface UserMessageProps {
|
||||
content: string
|
||||
@@ -7,14 +12,19 @@ interface UserMessageProps {
|
||||
}
|
||||
|
||||
export function UserMessage({ content, attachments = [] }: UserMessageProps) {
|
||||
const { t } = useTranslation()
|
||||
const { copy, isCopied } = useCopyToClipboard()
|
||||
const hasText = content.trim().length > 0
|
||||
const isCommand = content.trim().startsWith("/")
|
||||
const imageAttachments = attachments.filter(
|
||||
(attachment) => attachment.type === "image",
|
||||
)
|
||||
const copyMessageLabel = isCopied
|
||||
? t("chat.copiedLabel")
|
||||
: t("chat.copyMessage")
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-col items-end gap-1.5">
|
||||
<div className="group flex w-full flex-col items-end gap-1.5">
|
||||
{imageAttachments.length > 0 && (
|
||||
<div className="flex max-w-[70%] flex-wrap justify-end gap-2">
|
||||
{imageAttachments.map((attachment, index) => (
|
||||
@@ -29,24 +39,46 @@ export function UserMessage({ content, attachments = [] }: UserMessageProps) {
|
||||
)}
|
||||
|
||||
{hasText && (
|
||||
<div
|
||||
className={cn(
|
||||
"max-w-[70%] wrap-break-word whitespace-pre-wrap",
|
||||
isCommand
|
||||
? "rounded-xl border border-zinc-200 bg-transparent px-4 py-3 font-mono text-[14px] text-zinc-800 dark:border-zinc-800/60 dark:bg-[#121212] dark:text-zinc-200 dark:shadow-sm"
|
||||
: "rounded-2xl rounded-tr-sm bg-violet-500 px-5 py-3 text-[15px] leading-relaxed text-white shadow-sm",
|
||||
)}
|
||||
>
|
||||
{isCommand ? (
|
||||
<div className="flex items-start gap-2.5">
|
||||
<span className="font-bold text-emerald-600 select-none dark:text-emerald-400">
|
||||
❯
|
||||
</span>
|
||||
<span className="mt-[1px]">{content}</span>
|
||||
</div>
|
||||
) : (
|
||||
content
|
||||
)}
|
||||
<div className="relative max-w-[70%]">
|
||||
<div
|
||||
className={cn(
|
||||
"wrap-break-word whitespace-pre-wrap",
|
||||
isCommand
|
||||
? "rounded-xl border border-zinc-200 bg-transparent px-4 py-3 font-mono text-[14px] text-zinc-800 dark:border-zinc-800/60 dark:bg-[#121212] dark:text-zinc-200 dark:shadow-sm"
|
||||
: "rounded-2xl rounded-tr-sm bg-violet-500 px-5 py-3 text-[15px] leading-relaxed text-white shadow-sm",
|
||||
)}
|
||||
>
|
||||
{isCommand ? (
|
||||
<div className="flex items-start gap-2.5">
|
||||
<span className="font-bold text-emerald-600 select-none dark:text-emerald-400">
|
||||
❯
|
||||
</span>
|
||||
<span className="mt-[1px]">{content}</span>
|
||||
</div>
|
||||
) : (
|
||||
content
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn(
|
||||
"bg-background/75 hover:bg-background absolute top-2 right-2 h-7 w-7 opacity-0 shadow-xs transition-opacity group-hover:opacity-100",
|
||||
isCommand
|
||||
? "text-zinc-700 dark:text-zinc-200"
|
||||
: "text-violet-700 dark:text-violet-100",
|
||||
)}
|
||||
onClick={() => void copy(content)}
|
||||
aria-label={copyMessageLabel}
|
||||
title={copyMessageLabel}
|
||||
>
|
||||
{isCopied ? (
|
||||
<IconCheck className="h-4 w-4 text-green-500" />
|
||||
) : (
|
||||
<IconCopy className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -95,9 +95,9 @@ export function ProviderCombobox({
|
||||
return (
|
||||
<Popover
|
||||
open={open}
|
||||
onOpenChange={(v) => {
|
||||
setOpen(v)
|
||||
if (!v) setCustomMode(false)
|
||||
onOpenChange={(isOpen: boolean) => {
|
||||
setOpen(isOpen)
|
||||
if (!isOpen) setCustomMode(false)
|
||||
}}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
|
||||
import { copyText } from "@/lib/clipboard"
|
||||
|
||||
const DEFAULT_RESET_DELAY_MS = 2000
|
||||
|
||||
export function useCopyToClipboard(
|
||||
resetDelayMs: number = DEFAULT_RESET_DELAY_MS,
|
||||
) {
|
||||
const [isCopied, setIsCopied] = useState(false)
|
||||
const resetTimerRef = useRef<number | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (resetTimerRef.current !== null) {
|
||||
window.clearTimeout(resetTimerRef.current)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
const markCopied = () => {
|
||||
if (resetTimerRef.current !== null) {
|
||||
window.clearTimeout(resetTimerRef.current)
|
||||
}
|
||||
|
||||
setIsCopied(true)
|
||||
resetTimerRef.current = window.setTimeout(() => {
|
||||
setIsCopied(false)
|
||||
resetTimerRef.current = null
|
||||
}, resetDelayMs)
|
||||
}
|
||||
|
||||
const copy = async (text: string) => {
|
||||
const didCopy = await copyText(text)
|
||||
if (didCopy) {
|
||||
markCopied()
|
||||
}
|
||||
return didCopy
|
||||
}
|
||||
|
||||
return {
|
||||
copy,
|
||||
isCopied,
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,17 @@ const THEME_STYLE_OWNER_ATTR = "data-picoclaw-highlight-theme"
|
||||
const THEME_STYLE_OWNER_VALUE = "true"
|
||||
const MANAGED_THEME_STYLE_SELECTOR = `style[${THEME_STYLE_OWNER_ATTR}="${THEME_STYLE_OWNER_VALUE}"]`
|
||||
const ID_THEME_STYLE_SELECTOR = `style#${THEME_STYLE_ID}`
|
||||
const CHAT_CODE_BLOCK_OVERRIDES = `
|
||||
[data-picoclaw-code-block] .hljs {
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
[data-picoclaw-code-block] pre code.hljs,
|
||||
[data-picoclaw-code-block] code.hljs {
|
||||
padding: 0 !important;
|
||||
background: transparent !important;
|
||||
}
|
||||
`
|
||||
|
||||
function getOrCreateThemeStyleElement(): HTMLStyleElement {
|
||||
const managedStyleElement = document.head.querySelector<HTMLStyleElement>(
|
||||
@@ -49,7 +60,7 @@ export function useHighlightTheme() {
|
||||
const nextThemeCss = root.classList.contains("dark")
|
||||
? githubDarkCss
|
||||
: githubLightCss
|
||||
styleElement.textContent = nextThemeCss
|
||||
styleElement.textContent = `${nextThemeCss}\n${CHAT_CODE_BLOCK_OVERRIDES}`
|
||||
}
|
||||
|
||||
applyTheme()
|
||||
|
||||
@@ -63,8 +63,15 @@
|
||||
"toolCallsLabel": "Tool calls",
|
||||
"toolCallExplanationLabel": "Call note",
|
||||
"toolCallFunctionLabel": "Call summary",
|
||||
"toolCallArgumentsLabel": "Arguments",
|
||||
"showAssistantDetails": "Show reasoning and tool calls",
|
||||
"toolLabel": "Tool",
|
||||
"codeLabel": "Code",
|
||||
"copyMessage": "Copy message",
|
||||
"copyCode": "Copy code",
|
||||
"copiedLabel": "Copied",
|
||||
"expandCode": "Expand code",
|
||||
"collapseCode": "Collapse code",
|
||||
"history": "History",
|
||||
"noHistory": "No chat history yet",
|
||||
"historyLoadFailed": "Failed to load chat history",
|
||||
|
||||
@@ -63,8 +63,15 @@
|
||||
"toolCallsLabel": "Chamadas de ferramentas",
|
||||
"toolCallExplanationLabel": "Nota da chamada",
|
||||
"toolCallFunctionLabel": "Resumo da chamada",
|
||||
"toolCallArgumentsLabel": "Argumentos",
|
||||
"showAssistantDetails": "Mostrar raciocínio e chamadas de ferramentas",
|
||||
"toolLabel": "Ferramenta",
|
||||
"codeLabel": "Código",
|
||||
"copyMessage": "Copiar mensagem",
|
||||
"copyCode": "Copiar código",
|
||||
"copiedLabel": "Copiado",
|
||||
"expandCode": "Expandir código",
|
||||
"collapseCode": "Recolher código",
|
||||
"history": "Histórico",
|
||||
"noHistory": "Nenhum histórico de chat ainda",
|
||||
"historyLoadFailed": "Falha ao carregar histórico de chat",
|
||||
|
||||
@@ -63,8 +63,15 @@
|
||||
"toolCallsLabel": "工具调用",
|
||||
"toolCallExplanationLabel": "调用提示",
|
||||
"toolCallFunctionLabel": "调用摘要",
|
||||
"toolCallArgumentsLabel": "参数",
|
||||
"showAssistantDetails": "展示思考过程与工具调用",
|
||||
"toolLabel": "工具",
|
||||
"codeLabel": "代码",
|
||||
"copyMessage": "复制消息",
|
||||
"copyCode": "复制代码",
|
||||
"copiedLabel": "已复制",
|
||||
"expandCode": "展开代码",
|
||||
"collapseCode": "折叠代码",
|
||||
"history": "历史记录",
|
||||
"noHistory": "暂无对话历史",
|
||||
"historyLoadFailed": "加载历史记录失败",
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
interface ClipboardTextareaLike {
|
||||
value: string
|
||||
style: {
|
||||
position: string
|
||||
left: string
|
||||
}
|
||||
setAttribute(name: string, value: string): void
|
||||
select(): void
|
||||
}
|
||||
|
||||
interface ClipboardBodyLike {
|
||||
appendChild(node: ClipboardTextareaLike): void
|
||||
removeChild(node: ClipboardTextareaLike): void
|
||||
}
|
||||
|
||||
interface ClipboardDocumentLike {
|
||||
body: ClipboardBodyLike
|
||||
createElement(tagName: "textarea"): ClipboardTextareaLike
|
||||
execCommand(command: "copy"): boolean
|
||||
}
|
||||
|
||||
interface ClipboardNavigatorLike {
|
||||
clipboard?: {
|
||||
writeText(text: string): Promise<void>
|
||||
}
|
||||
}
|
||||
|
||||
export interface ClipboardEnvironment {
|
||||
document?: ClipboardDocumentLike
|
||||
navigator?: ClipboardNavigatorLike
|
||||
}
|
||||
|
||||
function getDefaultClipboardEnvironment(): ClipboardEnvironment {
|
||||
return {
|
||||
document:
|
||||
typeof document === "undefined"
|
||||
? undefined
|
||||
: (document as unknown as ClipboardDocumentLike),
|
||||
navigator:
|
||||
typeof navigator === "undefined"
|
||||
? undefined
|
||||
: (navigator as unknown as ClipboardNavigatorLike),
|
||||
}
|
||||
}
|
||||
|
||||
export async function copyText(
|
||||
text: string,
|
||||
environment: ClipboardEnvironment = getDefaultClipboardEnvironment(),
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
if (environment.navigator?.clipboard?.writeText) {
|
||||
await environment.navigator.clipboard.writeText(text)
|
||||
return true
|
||||
}
|
||||
} catch {
|
||||
// HTTP or restricted environments can reject Clipboard API writes.
|
||||
}
|
||||
|
||||
if (!environment.document) {
|
||||
return false
|
||||
}
|
||||
|
||||
const textArea = environment.document.createElement("textarea")
|
||||
textArea.value = text
|
||||
textArea.setAttribute("readonly", "")
|
||||
textArea.style.position = "fixed"
|
||||
textArea.style.left = "-9999px"
|
||||
environment.document.body.appendChild(textArea)
|
||||
textArea.select()
|
||||
|
||||
try {
|
||||
return environment.document.execCommand("copy")
|
||||
} finally {
|
||||
environment.document.body.removeChild(textArea)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user