diff --git a/web/frontend/src/components/chat/assistant-message.tsx b/web/frontend/src/components/chat/assistant-message.tsx
index 157ca636..3857c674 100644
--- a/web/frontend/src/components/chat/assistant-message.tsx
+++ b/web/frontend/src/components/chat/assistant-message.tsx
@@ -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 (
@@ -174,6 +147,9 @@ export function AssistantMessage({
rehypeSanitize,
rehypeHighlight,
]}
+ components={{
+ pre: MarkdownCodeBlock,
+ }}
>
{explanation}
@@ -192,15 +168,20 @@ export function AssistantMessage({
{t("chat.toolCallFunctionLabel")}
- {toolName && (
+ {toolName && !toolArguments && (
{toolName}
)}
{toolArguments && (
-
- {toolArguments}
-
+
)}
@@ -222,6 +203,9 @@ export function AssistantMessage({
{content}
@@ -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 ? (
diff --git a/web/frontend/src/components/chat/message-code-block.tsx b/web/frontend/src/components/chat/message-code-block.tsx
new file mode 100644
index 00000000..0da08119
--- /dev/null
+++ b/web/frontend/src/components/chat/message-code-block.tsx
@@ -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 (
+
+
+
+ {blockLabel}
+
+
+
+
+
+
+
+ {isExpanded && (
+
+ {children ?? (
+ highlightedHtml ? (
+
+ ) : (
+
+ {code}
+
+ )
+ )}
+
+ )}
+
+ )
+}
+
+export function MarkdownCodeBlock({
+ children,
+ className,
+ node,
+}: MarkdownCodeBlockProps) {
+ const { code, language } = extractCodeBlockFromPreNode(node)
+
+ return (
+
+ {children}
+
+ )
+}
diff --git a/web/frontend/src/components/chat/message-code-block.utils.ts b/web/frontend/src/components/chat/message-code-block.utils.ts
new file mode 100644
index 00000000..2133ec63
--- /dev/null
+++ b/web/frontend/src/components/chat/message-code-block.utils.ts
@@ -0,0 +1,85 @@
+export interface MarkdownNode {
+ type?: string
+ value?: string
+ tagName?: string
+ properties?: Record
+ 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),
+ }
+}
diff --git a/web/frontend/src/components/chat/user-message.tsx b/web/frontend/src/components/chat/user-message.tsx
index 8bfdf24c..44b873aa 100644
--- a/web/frontend/src/components/chat/user-message.tsx
+++ b/web/frontend/src/components/chat/user-message.tsx
@@ -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 (
-
+
{imageAttachments.length > 0 && (
{imageAttachments.map((attachment, index) => (
@@ -29,24 +39,46 @@ export function UserMessage({ content, attachments = [] }: UserMessageProps) {
)}
{hasText && (
-
- {isCommand ? (
-
-
- ❯
-
- {content}
-
- ) : (
- content
- )}
+
+
+ {isCommand ? (
+
+
+ ❯
+
+ {content}
+
+ ) : (
+ content
+ )}
+
+
)}
diff --git a/web/frontend/src/components/models/provider-combobox.tsx b/web/frontend/src/components/models/provider-combobox.tsx
index 1edc458f..c023694c 100644
--- a/web/frontend/src/components/models/provider-combobox.tsx
+++ b/web/frontend/src/components/models/provider-combobox.tsx
@@ -95,9 +95,9 @@ export function ProviderCombobox({
return (
{
- setOpen(v)
- if (!v) setCustomMode(false)
+ onOpenChange={(isOpen: boolean) => {
+ setOpen(isOpen)
+ if (!isOpen) setCustomMode(false)
}}
>
diff --git a/web/frontend/src/hooks/use-copy-to-clipboard.ts b/web/frontend/src/hooks/use-copy-to-clipboard.ts
new file mode 100644
index 00000000..811fb25a
--- /dev/null
+++ b/web/frontend/src/hooks/use-copy-to-clipboard.ts
@@ -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(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,
+ }
+}
diff --git a/web/frontend/src/hooks/use-highlight-theme.ts b/web/frontend/src/hooks/use-highlight-theme.ts
index cbefa129..fa829850 100644
--- a/web/frontend/src/hooks/use-highlight-theme.ts
+++ b/web/frontend/src/hooks/use-highlight-theme.ts
@@ -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(
@@ -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()
diff --git a/web/frontend/src/i18n/locales/en.json b/web/frontend/src/i18n/locales/en.json
index be00f905..a177acba 100644
--- a/web/frontend/src/i18n/locales/en.json
+++ b/web/frontend/src/i18n/locales/en.json
@@ -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",
diff --git a/web/frontend/src/i18n/locales/pt-br.json b/web/frontend/src/i18n/locales/pt-br.json
index 4f7ab799..a3e9a756 100644
--- a/web/frontend/src/i18n/locales/pt-br.json
+++ b/web/frontend/src/i18n/locales/pt-br.json
@@ -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",
diff --git a/web/frontend/src/i18n/locales/zh.json b/web/frontend/src/i18n/locales/zh.json
index d0aa1361..75b26029 100644
--- a/web/frontend/src/i18n/locales/zh.json
+++ b/web/frontend/src/i18n/locales/zh.json
@@ -63,8 +63,15 @@
"toolCallsLabel": "工具调用",
"toolCallExplanationLabel": "调用提示",
"toolCallFunctionLabel": "调用摘要",
+ "toolCallArgumentsLabel": "参数",
"showAssistantDetails": "展示思考过程与工具调用",
"toolLabel": "工具",
+ "codeLabel": "代码",
+ "copyMessage": "复制消息",
+ "copyCode": "复制代码",
+ "copiedLabel": "已复制",
+ "expandCode": "展开代码",
+ "collapseCode": "折叠代码",
"history": "历史记录",
"noHistory": "暂无对话历史",
"historyLoadFailed": "加载历史记录失败",
diff --git a/web/frontend/src/lib/clipboard.ts b/web/frontend/src/lib/clipboard.ts
new file mode 100644
index 00000000..d42f5e39
--- /dev/null
+++ b/web/frontend/src/lib/clipboard.ts
@@ -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
+ }
+}
+
+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 {
+ 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)
+ }
+}