|
1 | | -export default function ChatMessage({ message }) { |
| 1 | +import fsOperation from "fileSystem"; |
| 2 | +import toast from "components/toast"; |
| 3 | +import DOMPurify from "dompurify"; |
| 4 | +import openFile from "lib/openFile"; |
| 5 | +import openFolder from "lib/openFolder"; |
| 6 | +import markdownIt from "markdown-it"; |
| 7 | +import Url from "utils/Url"; |
| 8 | + |
| 9 | +const markdown = markdownIt({ |
| 10 | + breaks: true, |
| 11 | + html: false, |
| 12 | + linkify: true, |
| 13 | +}); |
| 14 | + |
| 15 | +const EXTERNAL_PROTOCOLS = new Set(["http:", "https:", "mailto:", "tel:"]); |
| 16 | +const LOCAL_FILE_PROTOCOLS = new Set(["file:"]); |
| 17 | + |
| 18 | +function getTerminalPaths() { |
| 19 | + const packageName = window.BuildInfo?.packageName || "com.foxdebug.acode"; |
| 20 | + const dataDir = `/data/user/0/${packageName}`; |
| 21 | + return { |
| 22 | + dataDir, |
| 23 | + alpineRoot: `${dataDir}/files/alpine`, |
| 24 | + publicDir: `${dataDir}/files/public`, |
| 25 | + }; |
| 26 | +} |
| 27 | + |
| 28 | +function safeDecode(value = "") { |
| 29 | + try { |
| 30 | + return decodeURIComponent(value); |
| 31 | + } catch { |
| 32 | + return value; |
| 33 | + } |
| 34 | +} |
| 35 | + |
| 36 | +function renderMarkdown(text = "") { |
| 37 | + return DOMPurify.sanitize(markdown.render(text), { |
| 38 | + FORBID_TAGS: ["script", "style"], |
| 39 | + }); |
| 40 | +} |
| 41 | + |
| 42 | +function isExternalHref(href = "") { |
| 43 | + return EXTERNAL_PROTOCOLS.has(Url.getProtocol(href)); |
| 44 | +} |
| 45 | + |
| 46 | +function isLocalFileHref(href = "") { |
| 47 | + return LOCAL_FILE_PROTOCOLS.has(Url.getProtocol(href)); |
| 48 | +} |
| 49 | + |
| 50 | +function getCursorPosFromHash(hash = "") { |
| 51 | + const match = /^L(\d+)(?:C(\d+))?$/i.exec(hash.replace(/^#/, "")); |
| 52 | + if (!match) return null; |
| 53 | + |
| 54 | + return { |
| 55 | + row: Number(match[1]), |
| 56 | + column: match[2] ? Math.max(0, Number(match[2]) - 1) : 0, |
| 57 | + }; |
| 58 | +} |
| 59 | + |
| 60 | +function normalizePathString(value = "") { |
| 61 | + return safeDecode(String(value || "").trim()) |
| 62 | + .replace(/^<|>$/g, "") |
| 63 | + .replace(/^["']|["']$/g, ""); |
| 64 | +} |
| 65 | + |
| 66 | +function convertProotPath(path = "") { |
| 67 | + const normalizedPath = normalizePathString(path); |
| 68 | + if (!normalizedPath) return normalizedPath; |
| 69 | + |
| 70 | + const { dataDir, alpineRoot } = getTerminalPaths(); |
| 71 | + if (isLocalFileHref(normalizedPath)) { |
| 72 | + return normalizedPath; |
| 73 | + } |
| 74 | + if (normalizedPath.startsWith("/public")) { |
| 75 | + return `file://${dataDir}/files${normalizedPath}`; |
| 76 | + } |
| 77 | + if ( |
| 78 | + normalizedPath.startsWith("/sdcard") || |
| 79 | + normalizedPath.startsWith("/storage") || |
| 80 | + normalizedPath.startsWith("/data") |
| 81 | + ) { |
| 82 | + return `file://${normalizedPath}`; |
| 83 | + } |
| 84 | + if (normalizedPath.startsWith("/home/") || normalizedPath === "/home") { |
| 85 | + const suffix = normalizedPath.slice("/home".length); |
| 86 | + return `file://${alpineRoot}/home${suffix}`; |
| 87 | + } |
| 88 | + if (normalizedPath.startsWith("/")) { |
| 89 | + return `file://${alpineRoot}${normalizedPath}`; |
| 90 | + } |
| 91 | + |
| 92 | + return normalizedPath; |
| 93 | +} |
| 94 | + |
| 95 | +function resolveCwd(cwd = "") { |
| 96 | + const normalizedCwd = normalizePathString(cwd); |
| 97 | + if (!normalizedCwd) return ""; |
| 98 | + return isLocalFileHref(normalizedCwd) |
| 99 | + ? normalizedCwd |
| 100 | + : convertProotPath(normalizedCwd); |
| 101 | +} |
| 102 | + |
| 103 | +function buildLocalCandidates(target = "", cwd = "") { |
| 104 | + const normalizedTarget = normalizePathString(target); |
| 105 | + const normalizedCwd = resolveCwd(cwd); |
| 106 | + if (!normalizedTarget) return []; |
| 107 | + |
| 108 | + const candidates = []; |
| 109 | + const addCandidate = (value) => { |
| 110 | + const normalized = normalizePathString(value); |
| 111 | + if (!normalized || candidates.includes(normalized)) return; |
| 112 | + candidates.push(normalized); |
| 113 | + }; |
| 114 | + |
| 115 | + if (Url.getProtocol(normalizedTarget)) { |
| 116 | + addCandidate(normalizedTarget); |
| 117 | + return candidates; |
| 118 | + } |
| 119 | + |
| 120 | + if ( |
| 121 | + normalizedTarget.startsWith("/") || |
| 122 | + normalizedTarget.startsWith("~/") || |
| 123 | + normalizedTarget === "~" |
| 124 | + ) { |
| 125 | + const homePath = |
| 126 | + normalizedTarget === "~" |
| 127 | + ? "/home" |
| 128 | + : normalizedTarget.replace(/^~(?=\/)/, "/home"); |
| 129 | + addCandidate(convertProotPath(homePath)); |
| 130 | + addCandidate(homePath); |
| 131 | + return candidates; |
| 132 | + } |
| 133 | + |
| 134 | + if (normalizedCwd) { |
| 135 | + addCandidate(Url.join(normalizedCwd, normalizedTarget)); |
| 136 | + } |
| 137 | + |
| 138 | + addCandidate(convertProotPath(normalizedTarget)); |
| 139 | + addCandidate(normalizedTarget); |
| 140 | + |
| 141 | + return candidates; |
| 142 | +} |
| 143 | + |
| 144 | +function resolveLocalHref(href = "", cwd = "") { |
| 145 | + const [rawTarget, rawHash = ""] = href.split("#"); |
| 146 | + const target = normalizePathString(rawTarget); |
| 147 | + if (!target) return null; |
| 148 | + |
| 149 | + return { |
| 150 | + candidates: buildLocalCandidates(target, cwd), |
| 151 | + cursorPos: getCursorPosFromHash(rawHash), |
| 152 | + }; |
| 153 | +} |
| 154 | + |
| 155 | +async function resolveExistingPath(candidates = []) { |
| 156 | + for (const candidate of candidates) { |
| 157 | + try { |
| 158 | + const stat = await fsOperation(candidate).stat(); |
| 159 | + return { url: candidate, stat }; |
| 160 | + } catch { |
| 161 | + // Keep trying fallbacks until one resolves. |
| 162 | + } |
| 163 | + } |
| 164 | + |
| 165 | + return null; |
| 166 | +} |
| 167 | + |
| 168 | +export default function ChatMessage({ message, cwd = "" }) { |
| 169 | + let messageCwd = cwd; |
| 170 | + |
2 | 171 | const $content = <div className="acp-message-content"></div>; |
3 | 172 | const $meta = <div className="acp-message-meta"></div>; |
4 | 173 | const $role = <div className="acp-message-role"></div>; |
5 | 174 |
|
| 175 | + $content.onclick = async (event) => { |
| 176 | + const target = |
| 177 | + event.target?.nodeType === Node.TEXT_NODE |
| 178 | + ? event.target.parentElement |
| 179 | + : event.target; |
| 180 | + const $link = target?.closest?.("a[href]"); |
| 181 | + if (!$link || !$content.contains($link)) return; |
| 182 | + |
| 183 | + const href = ($link.getAttribute("href") || "").trim(); |
| 184 | + if (!href || href === "#") return; |
| 185 | + |
| 186 | + event.preventDefault(); |
| 187 | + event.stopPropagation(); |
| 188 | + |
| 189 | + try { |
| 190 | + if (isExternalHref(href)) { |
| 191 | + system.openInBrowser(href); |
| 192 | + return; |
| 193 | + } |
| 194 | + |
| 195 | + const resolved = resolveLocalHref(href, messageCwd); |
| 196 | + const match = await resolveExistingPath(resolved?.candidates || []); |
| 197 | + if (!match?.url || !match.stat) { |
| 198 | + throw new Error(`Unable to resolve path: ${href}`); |
| 199 | + } |
| 200 | + |
| 201 | + if (match.stat.isDirectory) { |
| 202 | + await openFolder(match.url, { |
| 203 | + name: match.stat.name || Url.basename(match.url) || "Folder", |
| 204 | + saveState: true, |
| 205 | + listFiles: true, |
| 206 | + }); |
| 207 | + return; |
| 208 | + } |
| 209 | + |
| 210 | + await openFile(match.url, { |
| 211 | + render: true, |
| 212 | + cursorPos: resolved.cursorPos || undefined, |
| 213 | + }); |
| 214 | + } catch (error) { |
| 215 | + console.error("[ACP] Failed to open linked resource:", error); |
| 216 | + toast(error?.message || "Failed to open linked resource"); |
| 217 | + } |
| 218 | + }; |
| 219 | + |
| 220 | + function appendTextBlock(text) { |
| 221 | + if (message.role === "agent") { |
| 222 | + const $markdown = <div className="acp-markdown-block md"></div>; |
| 223 | + $markdown.innerHTML = renderMarkdown(text); |
| 224 | + $content.append($markdown); |
| 225 | + return; |
| 226 | + } |
| 227 | + |
| 228 | + $content.append(<div className="acp-message-text">{text}</div>); |
| 229 | + } |
| 230 | + |
6 | 231 | function renderContent() { |
7 | 232 | $content.innerHTML = ""; |
8 | 233 | message.content.forEach((block) => { |
9 | 234 | if (block.type === "text") { |
10 | | - $content.append(<span>{block.text}</span>); |
| 235 | + appendTextBlock(block.text); |
11 | 236 | } else if (block.type === "resource_link") { |
12 | 237 | $content.append( |
13 | | - <a className="acp-resource-link" href="#"> |
| 238 | + <a className="acp-resource-link" href={block.uri || "#"}> |
14 | 239 | {block.name || block.uri} |
15 | 240 | </a>, |
16 | 241 | ); |
17 | 242 | } else if (block.type === "image" && block.data) { |
18 | 243 | $content.append( |
19 | 244 | <img |
| 245 | + className="acp-inline-image" |
20 | 246 | src={`data:${block.mimeType};base64,${block.data}`} |
21 | | - style="max-width:100%;border-radius:6px;margin:4px 0" |
22 | 247 | />, |
23 | 248 | ); |
24 | 249 | } |
@@ -49,6 +274,7 @@ export default function ChatMessage({ message }) { |
49 | 274 |
|
50 | 275 | $el.update = (msg) => { |
51 | 276 | message = msg.message || msg; |
| 277 | + if (msg.cwd) messageCwd = msg.cwd; |
52 | 278 | renderContent(); |
53 | 279 | renderMeta(); |
54 | 280 | }; |
|
0 commit comments