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) + } +}