diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 0e745adce..832ac35ad 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -23,11 +23,13 @@ "expo-sharing": "~14.0.8", "expo-status-bar": "~3.0.0", "lucide-react-native": "^0.468.0", + "markdown-it": "14.1.0", "react": "19.1.0", "react-dom": "19.1.0", "react-native": "0.81.5", "react-native-blob-util": "^0.22.2", "react-native-get-random-values": "~1.11.0", + "react-native-markdown-renderer": "4.1.1", "react-native-pdf": "^7.0.3", "react-native-safe-area-context": "~5.6.0", "react-native-svg": "15.12.1", @@ -38,6 +40,7 @@ "devDependencies": { "@config-plugins/react-native-blob-util": "^12.0.0", "@config-plugins/react-native-pdf": "^12.0.0", + "@types/markdown-it": "14.1.2", "@types/react": "~19.1.0", "typescript": "~5.9.2" } diff --git a/apps/mobile/src/assistant-markdown.ts b/apps/mobile/src/assistant-markdown.ts new file mode 100644 index 000000000..190f242e6 --- /dev/null +++ b/apps/mobile/src/assistant-markdown.ts @@ -0,0 +1,17 @@ +import MarkdownIt from "markdown-it"; + +export const assistantMarkdown = new MarkdownIt({ + html: false, + linkify: true, + breaks: true, + typographer: false, +}); + +export function isSafeAssistantUrl(value: string): boolean { + try { + const url = new URL(value); + return (url.protocol === "http:" || url.protocol === "https:") && !!url.hostname; + } catch { + return false; + } +} diff --git a/apps/mobile/src/assistant-response.tsx b/apps/mobile/src/assistant-response.tsx new file mode 100644 index 000000000..9b89ffbbf --- /dev/null +++ b/apps/mobile/src/assistant-response.tsx @@ -0,0 +1,63 @@ +import { useCallback, useState } from "react"; +import { Linking, Text, type TextStyle } from "react-native"; +import Markdown, { type MarkdownStyles, type RenderRules } from "react-native-markdown-renderer"; +import { assistantMarkdown, isSafeAssistantUrl } from "./assistant-markdown"; +import { colors, ErrorNotice } from "./ui"; + +const textStyle = { color: colors.text, fontSize: 16, lineHeight: 24 }; +const style: Partial = { + text: textStyle, + paragraph: { marginTop: 0, marginBottom: 6 }, + list: { marginBottom: 6 }, + headingContainer: { marginTop: 8, marginBottom: 4 }, + heading1: { fontSize: 21, lineHeight: 27 }, + heading2: { fontSize: 19, lineHeight: 25 }, + heading3: { fontSize: 17, lineHeight: 23 }, + link: { color: colors.blueDark, textDecorationLine: "underline" }, + codeInline: { backgroundColor: "#E2E4E7", color: colors.text }, + codeBlock: { backgroundColor: "#E2E4E7", color: colors.text }, +}; +const renderCodeBlock: RenderRules["fence"] = (node, _children, _parent, styles) => ( + + {node.content.replace(/\n$/, "")} + +); +const rules: RenderRules = { + textgroup: (node, children) => ( + + {children} + + ), + image: (node) => ( + + {node.attributes.alt ? `[Image: ${node.attributes.alt}]` : "[Image]"} + + ), + code_block: renderCodeBlock, + fence: renderCodeBlock, +}; + +export function AssistantResponse({ content }: { content: string }) { + const [linkError, setLinkError] = useState(""); + const onLinkPress = useCallback((url: string) => { + if (!isSafeAssistantUrl(url)) return false; + setLinkError(""); + void Linking.openURL(url).catch((error) => + setLinkError(error instanceof Error ? error.message : String(error)), + ); + return false; + }, []); + return ( + <> + + {content} + + + + ); +} diff --git a/apps/mobile/src/chat.tsx b/apps/mobile/src/chat.tsx index 4cfac33a5..26f3d36e3 100644 --- a/apps/mobile/src/chat.tsx +++ b/apps/mobile/src/chat.tsx @@ -21,6 +21,7 @@ import { import { z } from "zod"; import { ArtifactCard } from "./agent-ui"; import { useAgentWorkspace } from "./agent-workspace"; +import { AssistantResponse } from "./assistant-response"; import { BackgroundUpdates } from "./background-updates"; import { BrowserRunContext, BrowserToolCard } from "./browser-tool-card"; import { BrowserThreadCard } from "./computer"; @@ -434,9 +435,13 @@ export function ChatScreen({ backgroundColor: user ? colors.blue : "#EEEEF0", }} > - - {text} - + {user ? ( + + {text} + + ) : ( + + )} )} { + const html = assistantMarkdown.render( + "**Short answer:** Use `SKILL.md`.\n\n- First check\n- Second check\n\n**Source URL:** https://docs.copilotkit.ai/learning", + ); + assert.match(html, /Short answer:<\/strong>/); + assert.match(html, /SKILL\.md<\/code>/); + assert.match(html, /
  • /); + assert.match(html, /href="https:\/\/docs\.copilotkit\.ai\/learning"/); + assert.doesNotMatch(html, /\*\*Short answer/); +}); + +test("incomplete Markdown and raw HTML stay harmless", () => { + assert.doesNotThrow(() => assistantMarkdown.render("**Still streaming")); + assert.match(assistantMarkdown.render(""), /<script>/); +}); + +test("only absolute HTTP(S) links can open", () => { + assert.equal(isSafeAssistantUrl("https://docs.copilotkit.ai/learning"), true); + assert.equal(isSafeAssistantUrl("http://example.com"), true); + for (const url of ["javascript:alert(1)", "file:///tmp/private", "//example.com", "not a URL"]) { + assert.equal(isSafeAssistantUrl(url), false); + } +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 08f23e720..2ea139bfb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -93,6 +93,9 @@ importers: lucide-react-native: specifier: ^0.468.0 version: 0.468.0(react-native-svg@15.12.1(react-native@0.81.5(@babel/core@7.29.7(supports-color@8.1.1))(@types/react@19.1.17)(react@19.1.0)(supports-color@8.1.1))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.7(supports-color@8.1.1))(@types/react@19.1.17)(react@19.1.0)(supports-color@8.1.1))(react@19.1.0) + markdown-it: + specifier: 14.1.0 + version: 14.1.0 react: specifier: 19.1.0 version: 19.1.0 @@ -108,6 +111,9 @@ importers: react-native-get-random-values: specifier: ~1.11.0 version: 1.11.0(react-native@0.81.5(@babel/core@7.29.7(supports-color@8.1.1))(@types/react@19.1.17)(react@19.1.0)(supports-color@8.1.1)) + react-native-markdown-renderer: + specifier: 4.1.1 + version: 4.1.1(react-native@0.81.5(@babel/core@7.29.7(supports-color@8.1.1))(@types/react@19.1.17)(react@19.1.0)(supports-color@8.1.1))(react@19.1.0) react-native-pdf: specifier: ^7.0.3 version: 7.0.5(react-native-blob-util@0.22.2(react-native@0.81.5(@babel/core@7.29.7(supports-color@8.1.1))(@types/react@19.1.17)(react@19.1.0)(supports-color@8.1.1))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.7(supports-color@8.1.1))(@types/react@19.1.17)(react@19.1.0)(supports-color@8.1.1))(react@19.1.0) @@ -133,6 +139,9 @@ importers: '@config-plugins/react-native-pdf': specifier: ^12.0.0 version: 12.0.0(expo@54.0.37) + '@types/markdown-it': + specifier: 14.1.2 + version: 14.1.2 '@types/react': specifier: ~19.1.0 version: 19.1.17 @@ -2234,12 +2243,21 @@ packages: '@types/katex@0.16.8': resolution: {integrity: sha512-trgaNyfU+Xh2Tc+ABIb44a5AYUpicB3uwirOioeOkNPPbmgRNtcWyDeeFRzjPZENO9Vq8gvVqfhaaXWLlevVwg==} + '@types/linkify-it@5.0.0': + resolution: {integrity: sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==} + + '@types/markdown-it@14.1.2': + resolution: {integrity: sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==} + '@types/mdast@3.0.15': resolution: {integrity: sha512-LnwD+mUEfxWMa1QpDraczIn6k0Ee3SMicuYSSzS6ZYl2gKS09EClnJYGd8Du6rfc5r/GZEk5o1mRb8TaTj03sQ==} '@types/mdast@4.0.4': resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==} + '@types/mdurl@2.0.0': + resolution: {integrity: sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==} + '@types/mime@1.3.5': resolution: {integrity: sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==} @@ -4124,6 +4142,9 @@ packages: lines-and-columns@1.2.4: resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + linkify-it@5.0.2: + resolution: {integrity: sha512-ONTm2jCMAVZjgQa/Fy1kScXsuOoF5NPTsoFBdE1KVIZ2vAh/r9+Bqo+0jINCBYnavTPQZz38QzFTme79ENoN3Q==} + lit-element@4.2.2: resolution: {integrity: sha512-aFKhNToWxoyhkNDmWZwEva2SlQia+jfG0fjIWV//YeTaWrVnOxD89dPKfigCUspXFmjzOEUQpOkejH5Ly6sG0w==} @@ -4221,6 +4242,10 @@ packages: makeerror@1.0.12: resolution: {integrity: sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==} + markdown-it@14.1.0: + resolution: {integrity: sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==} + hasBin: true + markdown-table@3.0.4: resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==} @@ -4304,6 +4329,9 @@ packages: mdn-data@2.0.14: resolution: {integrity: sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow==} + mdurl@2.1.0: + resolution: {integrity: sha512-1+HBaOx0zi/dQWht8rNv9MYf9qqpqL/kxI0hXImU6Y547zM6Sni8BQibt7ifgMcYtQg41ao3Ivd6cnSM86inpg==} + media-typer@0.3.0: resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==} engines: {node: '>= 0.6'} @@ -5098,6 +5126,10 @@ packages: pump@3.0.4: resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==} + punycode.js@2.3.1: + resolution: {integrity: sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==} + engines: {node: '>=6'} + punycode@2.3.1: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} @@ -5173,6 +5205,12 @@ packages: react: '*' react-native: '*' + react-native-markdown-renderer@4.1.1: + resolution: {integrity: sha512-6Bmtr3KKhbt8CQrIowtsrhzgqfinP+QAkkV6qhVyuy0x1bGrA3qBb3BAonJyVoJifm2X7DHHW/LcCZf9rxpGJg==} + peerDependencies: + react: '>=18.0.0' + react-native: '>=0.73.0' + react-native-pdf@7.0.5: resolution: {integrity: sha512-Dho2k2TKmplmd7XGgfW+rMAInaw6Pf6g88BSYPX423b/fCzLB5K/h+YaoW+YRUhtWO/cJ4EEnz5uD3RwwigDPQ==} peerDependencies: @@ -5787,6 +5825,9 @@ packages: resolution: {integrity: sha512-LbBDqdIC5s8iROCUjMbW1f5dJQTEFB1+KO9ogbvlb3nm9n4YHa5p4KTvFPWvh2Hs8gZMBuiB1/8+pdfe/tDPug==} hasBin: true + uc.micro@2.1.0: + resolution: {integrity: sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==} + undici-types@7.18.2: resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} @@ -8864,6 +8905,13 @@ snapshots: '@types/katex@0.16.8': {} + '@types/linkify-it@5.0.0': {} + + '@types/markdown-it@14.1.2': + dependencies: + '@types/linkify-it': 5.0.0 + '@types/mdurl': 2.0.0 + '@types/mdast@3.0.15': dependencies: '@types/unist': 2.0.11 @@ -8872,6 +8920,8 @@ snapshots: dependencies: '@types/unist': 3.0.3 + '@types/mdurl@2.0.0': {} + '@types/mime@1.3.5': {} '@types/ms@2.1.0': {} @@ -11013,6 +11063,10 @@ snapshots: lines-and-columns@1.2.4: {} + linkify-it@5.0.2: + dependencies: + uc.micro: 2.1.0 + lit-element@4.2.2: dependencies: '@lit-labs/ssr-dom-shim': 1.6.0 @@ -11100,6 +11154,15 @@ snapshots: dependencies: tmpl: 1.0.5 + markdown-it@14.1.0: + dependencies: + argparse: 2.0.1 + entities: 4.5.0 + linkify-it: 5.0.2 + mdurl: 2.1.0 + punycode.js: 2.3.1 + uc.micro: 2.1.0 + markdown-table@3.0.4: {} marked@12.0.2: {} @@ -11315,6 +11378,8 @@ snapshots: mdn-data@2.0.14: {} + mdurl@2.1.0: {} + media-typer@0.3.0: {} media-typer@1.1.1: {} @@ -12508,6 +12573,8 @@ snapshots: end-of-stream: 1.4.5 once: 1.4.0 + punycode.js@2.3.1: {} + punycode@2.3.1: {} qrcode-terminal@0.11.0: {} @@ -12604,6 +12671,12 @@ snapshots: react: 19.1.0 react-native: 0.81.5(@babel/core@7.29.7(supports-color@8.1.1))(@types/react@19.1.17)(react@19.1.0)(supports-color@8.1.1) + react-native-markdown-renderer@4.1.1(react-native@0.81.5(@babel/core@7.29.7(supports-color@8.1.1))(@types/react@19.1.17)(react@19.1.0)(supports-color@8.1.1))(react@19.1.0): + dependencies: + markdown-it: 14.1.0 + react: 19.1.0 + react-native: 0.81.5(@babel/core@7.29.7(supports-color@8.1.1))(@types/react@19.1.17)(react@19.1.0)(supports-color@8.1.1) + react-native-pdf@7.0.5(react-native-blob-util@0.22.2(react-native@0.81.5(@babel/core@7.29.7(supports-color@8.1.1))(@types/react@19.1.17)(react@19.1.0)(supports-color@8.1.1))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.7(supports-color@8.1.1))(@types/react@19.1.17)(react@19.1.0)(supports-color@8.1.1))(react@19.1.0): dependencies: crypto-js: 4.2.0 @@ -13351,6 +13424,8 @@ snapshots: ua-parser-js@1.0.41: {} + uc.micro@2.1.0: {} + undici-types@7.18.2: {} undici@5.29.0: