diff --git a/web/frontend/src/components/agent/skills/detail-sheet.tsx b/web/frontend/src/components/agent/skills/detail-sheet.tsx index 4579926d8..9ec0c5a0c 100644 --- a/web/frontend/src/components/agent/skills/detail-sheet.tsx +++ b/web/frontend/src/components/agent/skills/detail-sheet.tsx @@ -13,6 +13,10 @@ import rehypeSanitize from "rehype-sanitize" import remarkGfm from "remark-gfm" import type { SkillDetailResponse, SkillSupportItem } from "@/api/skills" +import { + MarkdownCodeBlock, + MessageCodeBlock, +} from "@/components/chat/message-code-block" import { Sheet, SheetContent, @@ -176,6 +180,9 @@ export function DetailSheet({ {selectedSkillDetail.content} @@ -183,11 +190,12 @@ export function DetailSheet({ ) : null} {detailView === "raw" ? ( -
-
-                    {selectedSkillDetail.content}
-                  
-
+ ) : null} {detailView === "meta" ? ( diff --git a/web/frontend/src/components/channels/channel-forms/mqtt-form.tsx b/web/frontend/src/components/channels/channel-forms/mqtt-form.tsx index c52ad601c..0be02649f 100644 --- a/web/frontend/src/components/channels/channel-forms/mqtt-form.tsx +++ b/web/frontend/src/components/channels/channel-forms/mqtt-form.tsx @@ -1,4 +1,5 @@ import type { ChannelConfig } from "@/api/channels" +import { MessageCodeBlock } from "@/components/chat/message-code-block" import { getSecretInputPlaceholder } from "@/components/channels/channel-config-fields" import { Field, KeyInput } from "@/components/shared-form" import { @@ -180,9 +181,12 @@ export function MqttForm({ {t("channels.mqtt.uplink")}

{`${topicBase}/request`} -
-              {`{\n  "text": "your message"\n}`}
-            
+

@@ -199,9 +203,12 @@ export function MqttForm({ {t("channels.mqtt.downlink")}

{`${topicBase}/response`} -
-              {`{\n  "text": "agent response"\n}`}
-            
+

diff --git a/web/frontend/src/components/chat/assistant-message.tsx b/web/frontend/src/components/chat/assistant-message.tsx index 6527562fe..0a197b25e 100644 --- a/web/frontend/src/components/chat/assistant-message.tsx +++ b/web/frontend/src/components/chat/assistant-message.tsx @@ -197,7 +197,6 @@ export function AssistantMessage({ label={toolName || t("chat.toolCallArgumentsLabel")} className="my-0 shadow-none" bodyClassName="px-3 py-2 text-[12px] leading-relaxed" - wrapLongLines /> )}

diff --git a/web/frontend/src/components/chat/message-code-block.tsx b/web/frontend/src/components/chat/message-code-block.tsx index 0da081194..79a3706a2 100644 --- a/web/frontend/src/components/chat/message-code-block.tsx +++ b/web/frontend/src/components/chat/message-code-block.tsx @@ -3,17 +3,30 @@ import { IconChevronDown, IconCopy, } from "@tabler/icons-react" +import { useAtom } from "jotai" import hljs from "highlight.js/lib/core" import json from "highlight.js/lib/languages/json" -import { type ComponentProps, type ReactNode, useState } from "react" +import { + type ComponentProps, + type CSSProperties, + type ReactNode, + useState, +} from "react" import { useTranslation } from "react-i18next" import { useCopyToClipboard } from "@/hooks/use-copy-to-clipboard" import { cn } from "@/lib/utils" +import { codeBlockWrapAtom } from "@/store/code-block" import { extractCodeBlockFromPreNode, + extractCodeBlockRenderState, type MarkdownNode, + splitCodeIntoLines, + splitHighlightedHtmlIntoLines, + splitRenderedCodeContentIntoLines, + trimTrailingEmptyRenderedCodeLine, + trimTrailingEmptyStringLine, } from "./message-code-block.utils" import { Button } from "@/components/ui/button" @@ -27,10 +40,10 @@ interface MessageCodeBlockProps { code: string language?: string | null label?: string - children?: ReactNode className?: string bodyClassName?: string - wrapLongLines?: boolean + children?: ReactNode + trimTrailingEmptyLine?: boolean } interface MarkdownCodeBlockProps extends ComponentProps<"pre"> { @@ -53,13 +66,14 @@ export function MessageCodeBlock({ code, language = null, label, - children, className, bodyClassName, - wrapLongLines = false, + children, + trimTrailingEmptyLine = false, }: MessageCodeBlockProps) { const { t } = useTranslation() const { copy, isCopied } = useCopyToClipboard() + const [wrapLongLines, setWrapLongLines] = useAtom(codeBlockWrapAtom) const [isExpanded, setIsExpanded] = useState(true) const blockLabel = label ?? @@ -68,7 +82,31 @@ export function MessageCodeBlock({ : t("chat.codeLabel").toLocaleLowerCase()) const copyLabel = isCopied ? t("chat.copiedLabel") : t("chat.copyCode") const expandLabel = isExpanded ? t("chat.collapseCode") : t("chat.expandCode") + const wrapLabel = wrapLongLines + ? t("chat.disableCodeWrap") + : t("chat.enableCodeWrap") + const renderedCodeState = children + ? extractCodeBlockRenderState(children) + : { + renderedContent: null, + className: undefined, + } const highlightedHtml = !children ? getHighlightedHtml(code, language) : null + const highlightedLines = highlightedHtml + ? splitHighlightedHtmlIntoLines(highlightedHtml) + : null + const codeLines = children + ? (trimTrailingEmptyLine + ? trimTrailingEmptyRenderedCodeLine( + splitRenderedCodeContentIntoLines(renderedCodeState.renderedContent), + ) + : splitRenderedCodeContentIntoLines(renderedCodeState.renderedContent)) + : (trimTrailingEmptyLine + ? trimTrailingEmptyStringLine( + highlightedLines ?? splitCodeIntoLines(code), + ) + : (highlightedLines ?? splitCodeIntoLines(code))) + const lineNumberWidth = `${String(codeLines.length).length + 1}ch` return (
{copyLabel} +
@@ -158,6 +241,7 @@ export function MarkdownCodeBlock({ code={code} language={language} bodyClassName={className} + trimTrailingEmptyLine > {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 index 2133ec638..40e76a2a8 100644 --- a/web/frontend/src/components/chat/message-code-block.utils.ts +++ b/web/frontend/src/components/chat/message-code-block.utils.ts @@ -1,3 +1,11 @@ +import { + Children, + cloneElement, + Fragment, + isValidElement, + type ReactNode, +} from "react" + export interface MarkdownNode { type?: string value?: string @@ -6,7 +14,7 @@ export interface MarkdownNode { children?: MarkdownNode[] } -function toClassNameTokens(className: unknown): string[] { +export function toClassNameTokens(className: unknown): string[] { if (typeof className === "string") { return className.split(/\s+/).filter(Boolean) } @@ -72,6 +80,10 @@ export function extractCodeBlockLanguage(className: unknown): string | null { return languageToken ? languageToken.slice("language-".length) : null } +export function stripSingleTrailingLineBreak(value: string): string { + return value.replace(/\r?\n$/, "") +} + export function extractCodeBlockFromPreNode(node: MarkdownNode | undefined): { code: string language: string | null @@ -79,7 +91,248 @@ export function extractCodeBlockFromPreNode(node: MarkdownNode | undefined): { const codeNode = findFirstDescendantByTagName(node, "code") return { - code: extractTextFromMarkdownNode(codeNode ?? node), + code: stripSingleTrailingLineBreak(extractTextFromMarkdownNode(codeNode ?? node)), language: extractCodeBlockLanguage(codeNode?.properties?.className), } } + +export function extractCodeBlockRenderState(children: ReactNode): { + renderedContent: ReactNode + className: string | undefined +} { + const childNodes = Children.toArray(children) + const codeChild = childNodes.find( + (child) => + isValidElement<{ children?: ReactNode; className?: unknown }>(child) && + typeof child.type === "string" && + child.type === "code", + ) + + if ( + isValidElement<{ children?: ReactNode; className?: unknown }>(codeChild) + ) { + const classNameTokens = toClassNameTokens(codeChild.props.className) + return { + renderedContent: codeChild.props.children, + className: + classNameTokens.length > 0 ? classNameTokens.join(" ") : undefined, + } + } + + return { + renderedContent: children, + className: undefined, + } +} + +function mergeNodeLineGroups( + currentLines: Node[][], + nextLines: Node[][], +): Node[][] { + if (nextLines.length === 0) { + return currentLines + } + + const mergedLines = currentLines.map((line) => [...line]) + mergedLines[mergedLines.length - 1].push(...nextLines[0]) + + for (const line of nextLines.slice(1)) { + mergedLines.push([...line]) + } + + return mergedLines +} + +function splitDomNodeIntoLines(node: Node, ownerDocument: Document): Node[][] { + if (node.nodeType === Node.TEXT_NODE) { + return (node.textContent ?? "").split("\n").map((line) => + line.length > 0 ? [ownerDocument.createTextNode(line)] : [], + ) + } + + if (node.nodeType !== Node.ELEMENT_NODE) { + return [[]] + } + + const element = node as Element + if (element.tagName.toLowerCase() === "br") { + return [ + [], + [], + ] + } + + const childLines = splitHighlightedHtmlIntoNodeLines( + Array.from(element.childNodes), + ownerDocument, + ) + + return childLines.map((lineChildren) => { + const clonedElement = element.cloneNode(false) + for (const child of lineChildren) { + clonedElement.appendChild(child) + } + + return [clonedElement] + }) +} + +function splitHighlightedHtmlIntoNodeLines( + nodes: Node[], + ownerDocument: Document, +): Node[][] { + let lines: Node[][] = [[]] + + for (const node of nodes) { + lines = mergeNodeLineGroups( + lines, + splitDomNodeIntoLines(node, ownerDocument), + ) + } + + return lines +} + +export function splitCodeIntoLines(code: string): string[] { + return code.split("\n") +} + +export function splitHighlightedHtmlIntoLines(highlightedHtml: string): string[] { + if (typeof document === "undefined") { + return splitCodeIntoLines(highlightedHtml) + } + + const container = document.createElement("div") + container.innerHTML = highlightedHtml + + return splitHighlightedHtmlIntoNodeLines( + Array.from(container.childNodes), + document, + ).map((lineNodes) => { + const lineContainer = document.createElement("div") + for (const node of lineNodes) { + lineContainer.appendChild(node) + } + + return lineContainer.innerHTML + }) +} + +export function trimTrailingEmptyStringLine(lines: string[]): string[] { + if (lines.length > 1 && lines[lines.length - 1] === "") { + return lines.slice(0, -1) + } + + return lines +} + +function isEmptyRenderedCodeNode(node: ReactNode): boolean { + if (node === null || node === undefined || typeof node === "boolean") { + return true + } + + if (typeof node === "string" || typeof node === "number") { + return String(node).length === 0 + } + + if (Array.isArray(node)) { + return node.every(isEmptyRenderedCodeNode) + } + + if (!isValidElement<{ children?: ReactNode }>(node)) { + return false + } + + return Children.toArray(node.props.children).every(isEmptyRenderedCodeNode) +} + +export function trimTrailingEmptyRenderedCodeLine( + lines: ReactNode[][], +): ReactNode[][] { + if ( + lines.length > 1 && + lines[lines.length - 1].every(isEmptyRenderedCodeNode) + ) { + return lines.slice(0, -1) + } + + return lines +} + +function mergeReactLineGroups( + currentLines: ReactNode[][], + nextLines: ReactNode[][], +): ReactNode[][] { + if (nextLines.length === 0) { + return currentLines + } + + const mergedLines = currentLines.map((line) => [...line]) + mergedLines[mergedLines.length - 1].push(...nextLines[0]) + + for (const line of nextLines.slice(1)) { + mergedLines.push([...line]) + } + + return mergedLines +} + +function splitTextNodeIntoLines(value: string | number): ReactNode[][] { + return String(value).split("\n").map((line) => (line.length > 0 ? [line] : [])) +} + +function splitReactNodeIntoLines(node: ReactNode): ReactNode[][] { + if (node === null || node === undefined || typeof node === "boolean") { + return [[]] + } + + if (typeof node === "string" || typeof node === "number") { + return splitTextNodeIntoLines(node) + } + + if (Array.isArray(node)) { + return splitRenderedCodeContentIntoLines(node) + } + + if (!isValidElement<{ children?: ReactNode }>(node)) { + return [[node]] + } + + if (node.type === Fragment) { + return splitRenderedCodeContentIntoLines(Children.toArray(node.props.children)) + } + + if (typeof node.type === "string" && node.type === "br") { + return [ + [], + [], + ] + } + + const childLines = splitRenderedCodeContentIntoLines( + Children.toArray(node.props.children), + ) + + return childLines.map((lineChildren, lineIndex) => [ + cloneElement( + node, + { + key: `${node.key ?? "code-line"}-${lineIndex}`, + }, + ...lineChildren, + ), + ]) +} + +export function splitRenderedCodeContentIntoLines( + content: ReactNode, +): ReactNode[][] { + const contentNodes = Array.isArray(content) ? content : [content] + let lines: ReactNode[][] = [[]] + + for (const node of contentNodes) { + lines = mergeReactLineGroups(lines, splitReactNodeIntoLines(node)) + } + + return lines +} diff --git a/web/frontend/src/i18n/locales/en.json b/web/frontend/src/i18n/locales/en.json index 1f15cee11..cb97f0a5e 100644 --- a/web/frontend/src/i18n/locales/en.json +++ b/web/frontend/src/i18n/locales/en.json @@ -76,6 +76,8 @@ "copyMessage": "Copy message", "copyCode": "Copy code", "copiedLabel": "Copied", + "enableCodeWrap": "Wrap lines", + "disableCodeWrap": "Disable wrap", "expandCode": "Expand code", "collapseCode": "Collapse code", "history": "History", diff --git a/web/frontend/src/i18n/locales/pt-br.json b/web/frontend/src/i18n/locales/pt-br.json index 8e27078fc..ca1f9ed32 100644 --- a/web/frontend/src/i18n/locales/pt-br.json +++ b/web/frontend/src/i18n/locales/pt-br.json @@ -76,6 +76,8 @@ "copyMessage": "Copiar mensagem", "copyCode": "Copiar código", "copiedLabel": "Copiado", + "enableCodeWrap": "Quebrar linhas", + "disableCodeWrap": "Desativar quebra", "expandCode": "Expandir código", "collapseCode": "Recolher código", "history": "Histórico", diff --git a/web/frontend/src/i18n/locales/zh.json b/web/frontend/src/i18n/locales/zh.json index bd027fee4..5590adab2 100644 --- a/web/frontend/src/i18n/locales/zh.json +++ b/web/frontend/src/i18n/locales/zh.json @@ -76,6 +76,8 @@ "copyMessage": "复制消息", "copyCode": "复制代码", "copiedLabel": "已复制", + "enableCodeWrap": "开启换行", + "disableCodeWrap": "关闭换行", "expandCode": "展开代码", "collapseCode": "折叠代码", "history": "历史记录", diff --git a/web/frontend/src/store/code-block.ts b/web/frontend/src/store/code-block.ts new file mode 100644 index 000000000..612e45fca --- /dev/null +++ b/web/frontend/src/store/code-block.ts @@ -0,0 +1,11 @@ +import { atomWithStorage } from "jotai/utils" + +export const CODE_BLOCK_WRAP_STORAGE_KEY = "picoclaw:code-block-wrap" +export const DEFAULT_CODE_BLOCK_WRAP = false + +export const codeBlockWrapAtom = atomWithStorage( + CODE_BLOCK_WRAP_STORAGE_KEY, + DEFAULT_CODE_BLOCK_WRAP, + undefined, + { getOnInit: true }, +) diff --git a/web/frontend/src/store/index.ts b/web/frontend/src/store/index.ts index a13b7b161..2ef7631fe 100644 --- a/web/frontend/src/store/index.ts +++ b/web/frontend/src/store/index.ts @@ -1,3 +1,4 @@ export * from "./gateway" export * from "./chat" +export * from "./code-block" export * from "./tour"