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:
parent
feacd84b84
commit
789f907f6d
11 changed files with 485 additions and 64 deletions
|
|
@ -15,8 +15,13 @@ import rehypeRaw from "rehype-raw"
|
||||||
import rehypeSanitize from "rehype-sanitize"
|
import rehypeSanitize from "rehype-sanitize"
|
||||||
import remarkGfm from "remark-gfm"
|
import remarkGfm from "remark-gfm"
|
||||||
|
|
||||||
|
import {
|
||||||
|
MessageCodeBlock,
|
||||||
|
MarkdownCodeBlock,
|
||||||
|
} from "@/components/chat/message-code-block"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { formatMessageTime } from "@/hooks/use-pico-chat"
|
import { formatMessageTime } from "@/hooks/use-pico-chat"
|
||||||
|
import { useCopyToClipboard } from "@/hooks/use-copy-to-clipboard"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import {
|
import {
|
||||||
type AssistantMessageKind,
|
type AssistantMessageKind,
|
||||||
|
|
@ -40,7 +45,7 @@ export function AssistantMessage({
|
||||||
timestamp = "",
|
timestamp = "",
|
||||||
}: AssistantMessageProps) {
|
}: AssistantMessageProps) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const [isCopied, setIsCopied] = useState(false)
|
const { copy, isCopied } = useCopyToClipboard()
|
||||||
const isThought = kind === "thought"
|
const isThought = kind === "thought"
|
||||||
const isToolCalls = kind === "tool_calls"
|
const isToolCalls = kind === "tool_calls"
|
||||||
const isCollapsedBlock = isThought || isToolCalls
|
const isCollapsedBlock = isThought || isToolCalls
|
||||||
|
|
@ -55,44 +60,12 @@ export function AssistantMessage({
|
||||||
const [isExpanded, setIsExpanded] = useState(true)
|
const [isExpanded, setIsExpanded] = useState(true)
|
||||||
const formattedTimestamp =
|
const formattedTimestamp =
|
||||||
timestamp !== "" ? formatMessageTime(timestamp) : ""
|
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
|
const collapsedLabel = isThought
|
||||||
? t("chat.reasoningLabel")
|
? t("chat.reasoningLabel")
|
||||||
: t("chat.toolCallsLabel")
|
: t("chat.toolCallsLabel")
|
||||||
|
const copyMessageLabel = isCopied
|
||||||
|
? t("chat.copiedLabel")
|
||||||
|
: t("chat.copyMessage")
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="group flex w-full flex-col gap-1.5">
|
<div className="group flex w-full flex-col gap-1.5">
|
||||||
|
|
@ -174,6 +147,9 @@ export function AssistantMessage({
|
||||||
rehypeSanitize,
|
rehypeSanitize,
|
||||||
rehypeHighlight,
|
rehypeHighlight,
|
||||||
]}
|
]}
|
||||||
|
components={{
|
||||||
|
pre: MarkdownCodeBlock,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{explanation}
|
{explanation}
|
||||||
</ReactMarkdown>
|
</ReactMarkdown>
|
||||||
|
|
@ -192,15 +168,20 @@ export function AssistantMessage({
|
||||||
{t("chat.toolCallFunctionLabel")}
|
{t("chat.toolCallFunctionLabel")}
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-background/55 border-border/25 space-y-2 rounded-lg border px-3 py-2.5">
|
<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">
|
<div className="text-foreground/75 font-mono text-[12px] font-semibold">
|
||||||
{toolName}
|
{toolName}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{toolArguments && (
|
{toolArguments && (
|
||||||
<pre className="text-muted-foreground/75 overflow-x-auto font-mono text-[12px] leading-relaxed break-words whitespace-pre-wrap">
|
<MessageCodeBlock
|
||||||
{toolArguments}
|
code={toolArguments}
|
||||||
</pre>
|
language="json"
|
||||||
|
label={toolName || t("chat.toolCallArgumentsLabel")}
|
||||||
|
className="my-0 shadow-none"
|
||||||
|
bodyClassName="px-3 py-2 text-[12px] leading-relaxed"
|
||||||
|
wrapLongLines
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -222,6 +203,9 @@ export function AssistantMessage({
|
||||||
<ReactMarkdown
|
<ReactMarkdown
|
||||||
remarkPlugins={[remarkGfm]}
|
remarkPlugins={[remarkGfm]}
|
||||||
rehypePlugins={[rehypeRaw, rehypeSanitize, rehypeHighlight]}
|
rehypePlugins={[rehypeRaw, rehypeSanitize, rehypeHighlight]}
|
||||||
|
components={{
|
||||||
|
pre: MarkdownCodeBlock,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{content}
|
{content}
|
||||||
</ReactMarkdown>
|
</ReactMarkdown>
|
||||||
|
|
@ -235,7 +219,9 @@ export function AssistantMessage({
|
||||||
className={cn(
|
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",
|
"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 ? (
|
{isCopied ? (
|
||||||
<IconCheck className="h-4 w-4 text-green-500" />
|
<IconCheck className="h-4 w-4 text-green-500" />
|
||||||
|
|
|
||||||
165
web/frontend/src/components/chat/message-code-block.tsx
Normal file
165
web/frontend/src/components/chat/message-code-block.tsx
Normal file
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
85
web/frontend/src/components/chat/message-code-block.utils.ts
Normal file
85
web/frontend/src/components/chat/message-code-block.utils.ts
Normal file
|
|
@ -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 { cn } from "@/lib/utils"
|
||||||
import type { ChatAttachment } from "@/store/chat"
|
import type { ChatAttachment } from "@/store/chat"
|
||||||
|
import { useTranslation } from "react-i18next"
|
||||||
|
|
||||||
interface UserMessageProps {
|
interface UserMessageProps {
|
||||||
content: string
|
content: string
|
||||||
|
|
@ -7,14 +12,19 @@ interface UserMessageProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function UserMessage({ content, attachments = [] }: UserMessageProps) {
|
export function UserMessage({ content, attachments = [] }: UserMessageProps) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const { copy, isCopied } = useCopyToClipboard()
|
||||||
const hasText = content.trim().length > 0
|
const hasText = content.trim().length > 0
|
||||||
const isCommand = content.trim().startsWith("/")
|
const isCommand = content.trim().startsWith("/")
|
||||||
const imageAttachments = attachments.filter(
|
const imageAttachments = attachments.filter(
|
||||||
(attachment) => attachment.type === "image",
|
(attachment) => attachment.type === "image",
|
||||||
)
|
)
|
||||||
|
const copyMessageLabel = isCopied
|
||||||
|
? t("chat.copiedLabel")
|
||||||
|
: t("chat.copyMessage")
|
||||||
|
|
||||||
return (
|
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 && (
|
{imageAttachments.length > 0 && (
|
||||||
<div className="flex max-w-[70%] flex-wrap justify-end gap-2">
|
<div className="flex max-w-[70%] flex-wrap justify-end gap-2">
|
||||||
{imageAttachments.map((attachment, index) => (
|
{imageAttachments.map((attachment, index) => (
|
||||||
|
|
@ -29,24 +39,46 @@ export function UserMessage({ content, attachments = [] }: UserMessageProps) {
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{hasText && (
|
{hasText && (
|
||||||
<div
|
<div className="relative max-w-[70%]">
|
||||||
className={cn(
|
<div
|
||||||
"max-w-[70%] wrap-break-word whitespace-pre-wrap",
|
className={cn(
|
||||||
isCommand
|
"wrap-break-word whitespace-pre-wrap",
|
||||||
? "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"
|
isCommand
|
||||||
: "rounded-2xl rounded-tr-sm bg-violet-500 px-5 py-3 text-[15px] leading-relaxed text-white shadow-sm",
|
? "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">
|
{isCommand ? (
|
||||||
<span className="font-bold text-emerald-600 select-none dark:text-emerald-400">
|
<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>
|
</span>
|
||||||
</div>
|
<span className="mt-[1px]">{content}</span>
|
||||||
) : (
|
</div>
|
||||||
content
|
) : (
|
||||||
)}
|
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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -95,9 +95,9 @@ export function ProviderCombobox({
|
||||||
return (
|
return (
|
||||||
<Popover
|
<Popover
|
||||||
open={open}
|
open={open}
|
||||||
onOpenChange={(v) => {
|
onOpenChange={(isOpen: boolean) => {
|
||||||
setOpen(v)
|
setOpen(isOpen)
|
||||||
if (!v) setCustomMode(false)
|
if (!isOpen) setCustomMode(false)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
|
|
|
||||||
45
web/frontend/src/hooks/use-copy-to-clipboard.ts
Normal file
45
web/frontend/src/hooks/use-copy-to-clipboard.ts
Normal file
|
|
@ -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 THEME_STYLE_OWNER_VALUE = "true"
|
||||||
const MANAGED_THEME_STYLE_SELECTOR = `style[${THEME_STYLE_OWNER_ATTR}="${THEME_STYLE_OWNER_VALUE}"]`
|
const MANAGED_THEME_STYLE_SELECTOR = `style[${THEME_STYLE_OWNER_ATTR}="${THEME_STYLE_OWNER_VALUE}"]`
|
||||||
const ID_THEME_STYLE_SELECTOR = `style#${THEME_STYLE_ID}`
|
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 {
|
function getOrCreateThemeStyleElement(): HTMLStyleElement {
|
||||||
const managedStyleElement = document.head.querySelector<HTMLStyleElement>(
|
const managedStyleElement = document.head.querySelector<HTMLStyleElement>(
|
||||||
|
|
@ -49,7 +60,7 @@ export function useHighlightTheme() {
|
||||||
const nextThemeCss = root.classList.contains("dark")
|
const nextThemeCss = root.classList.contains("dark")
|
||||||
? githubDarkCss
|
? githubDarkCss
|
||||||
: githubLightCss
|
: githubLightCss
|
||||||
styleElement.textContent = nextThemeCss
|
styleElement.textContent = `${nextThemeCss}\n${CHAT_CODE_BLOCK_OVERRIDES}`
|
||||||
}
|
}
|
||||||
|
|
||||||
applyTheme()
|
applyTheme()
|
||||||
|
|
|
||||||
|
|
@ -63,8 +63,15 @@
|
||||||
"toolCallsLabel": "Tool calls",
|
"toolCallsLabel": "Tool calls",
|
||||||
"toolCallExplanationLabel": "Call note",
|
"toolCallExplanationLabel": "Call note",
|
||||||
"toolCallFunctionLabel": "Call summary",
|
"toolCallFunctionLabel": "Call summary",
|
||||||
|
"toolCallArgumentsLabel": "Arguments",
|
||||||
"showAssistantDetails": "Show reasoning and tool calls",
|
"showAssistantDetails": "Show reasoning and tool calls",
|
||||||
"toolLabel": "Tool",
|
"toolLabel": "Tool",
|
||||||
|
"codeLabel": "Code",
|
||||||
|
"copyMessage": "Copy message",
|
||||||
|
"copyCode": "Copy code",
|
||||||
|
"copiedLabel": "Copied",
|
||||||
|
"expandCode": "Expand code",
|
||||||
|
"collapseCode": "Collapse code",
|
||||||
"history": "History",
|
"history": "History",
|
||||||
"noHistory": "No chat history yet",
|
"noHistory": "No chat history yet",
|
||||||
"historyLoadFailed": "Failed to load chat history",
|
"historyLoadFailed": "Failed to load chat history",
|
||||||
|
|
|
||||||
|
|
@ -63,8 +63,15 @@
|
||||||
"toolCallsLabel": "Chamadas de ferramentas",
|
"toolCallsLabel": "Chamadas de ferramentas",
|
||||||
"toolCallExplanationLabel": "Nota da chamada",
|
"toolCallExplanationLabel": "Nota da chamada",
|
||||||
"toolCallFunctionLabel": "Resumo da chamada",
|
"toolCallFunctionLabel": "Resumo da chamada",
|
||||||
|
"toolCallArgumentsLabel": "Argumentos",
|
||||||
"showAssistantDetails": "Mostrar raciocínio e chamadas de ferramentas",
|
"showAssistantDetails": "Mostrar raciocínio e chamadas de ferramentas",
|
||||||
"toolLabel": "Ferramenta",
|
"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",
|
"history": "Histórico",
|
||||||
"noHistory": "Nenhum histórico de chat ainda",
|
"noHistory": "Nenhum histórico de chat ainda",
|
||||||
"historyLoadFailed": "Falha ao carregar histórico de chat",
|
"historyLoadFailed": "Falha ao carregar histórico de chat",
|
||||||
|
|
|
||||||
|
|
@ -63,8 +63,15 @@
|
||||||
"toolCallsLabel": "工具调用",
|
"toolCallsLabel": "工具调用",
|
||||||
"toolCallExplanationLabel": "调用提示",
|
"toolCallExplanationLabel": "调用提示",
|
||||||
"toolCallFunctionLabel": "调用摘要",
|
"toolCallFunctionLabel": "调用摘要",
|
||||||
|
"toolCallArgumentsLabel": "参数",
|
||||||
"showAssistantDetails": "展示思考过程与工具调用",
|
"showAssistantDetails": "展示思考过程与工具调用",
|
||||||
"toolLabel": "工具",
|
"toolLabel": "工具",
|
||||||
|
"codeLabel": "代码",
|
||||||
|
"copyMessage": "复制消息",
|
||||||
|
"copyCode": "复制代码",
|
||||||
|
"copiedLabel": "已复制",
|
||||||
|
"expandCode": "展开代码",
|
||||||
|
"collapseCode": "折叠代码",
|
||||||
"history": "历史记录",
|
"history": "历史记录",
|
||||||
"noHistory": "暂无对话历史",
|
"noHistory": "暂无对话历史",
|
||||||
"historyLoadFailed": "加载历史记录失败",
|
"historyLoadFailed": "加载历史记录失败",
|
||||||
|
|
|
||||||
76
web/frontend/src/lib/clipboard.ts
Normal file
76
web/frontend/src/lib/clipboard.ts
Normal file
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue