From 772b70e151484079ea0186042fab0cabc55a0334 Mon Sep 17 00:00:00 2001
From: Raunak Raj <71929976+bajrangCoder@users.noreply.github.com>
Date: Thu, 18 Sep 2025 21:31:31 +0530
Subject: [PATCH 1/2] feat: add terminal, searchCode, openUrl tool
---
src/pages/aiAssistant/tools/index.js | 7 +
src/pages/aiAssistant/tools/openUrl.js | 58 +++
src/pages/aiAssistant/tools/searchCode.js | 430 ++++++++++++++++++++++
src/pages/aiAssistant/tools/terminal.js | 283 ++++++++++++++
4 files changed, 778 insertions(+)
create mode 100644 src/pages/aiAssistant/tools/openUrl.js
create mode 100644 src/pages/aiAssistant/tools/searchCode.js
create mode 100644 src/pages/aiAssistant/tools/terminal.js
diff --git a/src/pages/aiAssistant/tools/index.js b/src/pages/aiAssistant/tools/index.js
index d3e055c06..787f32f45 100644
--- a/src/pages/aiAssistant/tools/index.js
+++ b/src/pages/aiAssistant/tools/index.js
@@ -2,7 +2,10 @@ import { createDir } from "./createDir";
import { editFile } from "./editFile";
import { fetchTool } from "./fetch";
import { listDirectory } from "./listDirectory";
+import { openUrl } from "./openUrl";
import { readFile } from "./readFile";
+import { searchCode } from "./searchCode";
+import { interactiveTerminal, terminal } from "./terminal";
// Export all tools as a single object
export const allTools = {
@@ -11,4 +14,8 @@ export const allTools = {
listDirectory,
editFile,
createDir,
+ terminal,
+ interactiveTerminal,
+ searchCode,
+ openUrl,
};
diff --git a/src/pages/aiAssistant/tools/openUrl.js b/src/pages/aiAssistant/tools/openUrl.js
new file mode 100644
index 000000000..2865b5cdf
--- /dev/null
+++ b/src/pages/aiAssistant/tools/openUrl.js
@@ -0,0 +1,58 @@
+import { StructuredTool } from "@langchain/core/tools";
+import browser from "plugins/browser";
+import { z } from "zod";
+
+const SUPPORTED_PROTOCOL = /^https?:\/\//i;
+
+function normalizeTarget(target) {
+ if (!target) return "inApp";
+ if (target === "in-app" || target === "inapp") return "inApp";
+ return target;
+}
+
+class OpenUrlTool extends StructuredTool {
+ name = "openUrl";
+ description =
+ "Opens a URL in the system browser or the in-app browser. " +
+ "Use this to hand off documentation, dashboards, or other web resources to the user.";
+
+ schema = z.object({
+ url: z
+ .string()
+ .min(1)
+ .describe("Full URL including scheme (http or https)."),
+ target: z
+ .enum(["external", "inApp"])
+ .default("inApp")
+ .describe(
+ "Destination: external (system browser) or inApp (Acode's built-in browser). Default inApp.",
+ ),
+ });
+
+ async _call({ url, target = "inApp" }) {
+ const trimmedUrl = url.trim();
+ if (!SUPPORTED_PROTOCOL.test(trimmedUrl)) {
+ return "Error: Only http and https URLs are supported.";
+ }
+
+ const normalizedTarget = normalizeTarget(target);
+
+ try {
+ if (normalizedTarget === "inApp") {
+ browser.open(trimmedUrl);
+ return `Opened ${trimmedUrl} in the in-app browser.`;
+ }
+
+ if (typeof window.system.openInBrowser !== "function") {
+ return "Error: External browser integration is unavailable.";
+ }
+
+ window.system.openInBrowser(trimmedUrl);
+ return `Opened ${trimmedUrl} in the system browser.`;
+ } catch (error) {
+ return `Error opening URL: ${error.message}`;
+ }
+ }
+}
+
+export const openUrl = new OpenUrlTool();
diff --git a/src/pages/aiAssistant/tools/searchCode.js b/src/pages/aiAssistant/tools/searchCode.js
new file mode 100644
index 000000000..7764eb4b7
--- /dev/null
+++ b/src/pages/aiAssistant/tools/searchCode.js
@@ -0,0 +1,430 @@
+import fsOperation from "fileSystem";
+import { StructuredTool } from "@langchain/core/tools";
+import { addedFolder } from "lib/openFolder";
+import { z } from "zod";
+
+const DEFAULT_MAX_RESULTS = 50;
+const DEFAULT_CONTEXT_LINES = 2;
+const MAX_RESULTS_LIMIT = 200;
+const MAX_FILE_SIZE_BYTES = 1024 * 1024; // Skip files over 1MB to avoid heavy reads
+const DEFAULT_EXCLUDED_DIRS = new Set([
+ ".git",
+ ".svn",
+ ".hg",
+ "node_modules",
+ "build",
+ "dist",
+ ".gradle",
+ ".idea",
+ "Pods",
+ ".expo",
+ ".turbo",
+ ".cache",
+ ".output",
+ "android/app/build",
+ "ios/Pods",
+ "www/build",
+]);
+
+function shellSafePreview(text) {
+ if (!text) return "";
+ const trimmed = text.replace(/\t/g, " ").replace(/\r/g, "");
+ return trimmed.length > 160 ? `${trimmed.slice(0, 159)}…` : trimmed;
+}
+
+function formatContext(lines, matchIndex, contextLines) {
+ const start = Math.max(0, matchIndex - contextLines);
+ const end = Math.min(lines.length - 1, matchIndex + contextLines);
+ const formatted = [];
+ for (let i = start; i <= end; i++) {
+ const prefix = i === matchIndex ? ">" : " ";
+ const lineNo = String(i + 1).padStart(4, " ");
+ formatted.push(`${prefix}${lineNo} ${lines[i]}`);
+ }
+ return formatted.join("\n");
+}
+
+function normalizePathForFilters(path) {
+ return path.replace(/\\/g, "/");
+}
+
+function matchesIncludeExclude(
+ path,
+ includePatterns,
+ excludePatterns,
+ { treatAsDirectory = false } = {},
+) {
+ const normalized = normalizePathForFilters(path);
+ if (excludePatterns?.some((pattern) => normalized.includes(pattern))) {
+ return false;
+ }
+ if (includePatterns && includePatterns.length > 0) {
+ if (treatAsDirectory) {
+ return includePatterns.some(
+ (pattern) =>
+ normalized.includes(pattern) || pattern.includes(normalized),
+ );
+ }
+ return includePatterns.some((pattern) => normalized.includes(pattern));
+ }
+ return true;
+}
+
+async function listDirectorySafe(url) {
+ try {
+ return await fsOperation(url).lsDir();
+ } catch (error) {
+ console.error("searchCode: failed to list directory", url, error);
+ return [];
+ }
+}
+
+async function readFileSafe(url) {
+ try {
+ return await fsOperation(url).readFile("utf8");
+ } catch (error) {
+ return null;
+ }
+}
+
+function shouldSkipDirectory(relPath) {
+ if (!relPath) return false;
+ const normalized = normalizePathForFilters(relPath);
+ for (const dir of DEFAULT_EXCLUDED_DIRS) {
+ if (normalized === dir || normalized.endsWith(`/${dir}`)) {
+ return true;
+ }
+ }
+ return false;
+}
+
+function buildRegex(query, caseSensitive) {
+ const flags = caseSensitive ? "g" : "gi";
+ return new RegExp(query, flags);
+}
+
+function findMatchesInLine({
+ line,
+ lineIndex,
+ query,
+ regex,
+ caseSensitive,
+ path,
+ results,
+ maxResults,
+}) {
+ if (results.length >= maxResults) return;
+ if (typeof line !== "string" || line.length === 0) return;
+
+ if (regex) {
+ regex.lastIndex = 0;
+ let match = regex.exec(line);
+ while (match && results.length < maxResults) {
+ const column = match.index + 1;
+ results.push({
+ path,
+ line: lineIndex + 1,
+ column,
+ lineText: line,
+ matchIndex: lineIndex,
+ });
+ match = regex.exec(line);
+ }
+ return;
+ }
+
+ const haystack = caseSensitive ? line : line.toLowerCase();
+ const needle = caseSensitive ? query : query.toLowerCase();
+ let startIndex = haystack.indexOf(needle);
+ while (startIndex !== -1 && results.length < maxResults) {
+ results.push({
+ path,
+ line: lineIndex + 1,
+ column: startIndex + 1,
+ lineText: line,
+ matchIndex: lineIndex,
+ });
+ startIndex = haystack.indexOf(needle, startIndex + needle.length);
+ }
+}
+
+async function searchFile({
+ fileUrl,
+ relativePath,
+ query,
+ regex,
+ caseSensitive,
+ contextLines,
+ maxResults,
+ results,
+}) {
+ if (results.length >= maxResults) return;
+
+ const content = await readFileSafe(fileUrl);
+ if (content === null) return;
+
+ const lines = content.split(/\r?\n/);
+ const re = regex ? regex : null;
+ const startLength = results.length;
+
+ for (let i = 0; i < lines.length && results.length < maxResults; i++) {
+ findMatchesInLine({
+ line: lines[i],
+ lineIndex: i,
+ query,
+ regex: re,
+ caseSensitive,
+ path: relativePath,
+ results,
+ maxResults,
+ });
+ }
+
+ for (let idx = startLength; idx < results.length; idx++) {
+ const match = results[idx];
+ if (!match.lineText) continue;
+ match.preview = shellSafePreview(match.lineText);
+ match.context = formatContext(lines, match.matchIndex, contextLines);
+ delete match.lineText;
+ }
+}
+
+async function traverse({
+ project,
+ currentUrl,
+ relativePath,
+ query,
+ regex,
+ caseSensitive,
+ contextLines,
+ maxResults,
+ results,
+ includePatterns,
+ excludePatterns,
+}) {
+ if (results.length >= maxResults) return;
+
+ const entries = await listDirectorySafe(currentUrl);
+ if (!entries || entries.length === 0) return;
+
+ for (const entry of entries) {
+ if (results.length >= maxResults) break;
+ if (!entry || entry.name === "." || entry.name === "..") continue;
+
+ const relPath = relativePath
+ ? `${relativePath}/${entry.name}`
+ : `${project.title}/${entry.name}`;
+ const childUrl = entry.url || `${currentUrl}/${entry.name}`;
+
+ if (entry.isDirectory) {
+ const directoryAllowed = matchesIncludeExclude(
+ relPath,
+ includePatterns,
+ excludePatterns,
+ { treatAsDirectory: true },
+ );
+ if (shouldSkipDirectory(relPath) || !directoryAllowed) {
+ continue;
+ }
+ await traverse({
+ project,
+ currentUrl: childUrl,
+ relativePath: relPath,
+ query,
+ regex,
+ caseSensitive,
+ contextLines,
+ maxResults,
+ results,
+ includePatterns,
+ excludePatterns,
+ });
+ } else if (entry.isFile) {
+ if (!matchesIncludeExclude(relPath, includePatterns, excludePatterns)) {
+ continue;
+ }
+ if (
+ typeof entry.length === "number" &&
+ entry.length > MAX_FILE_SIZE_BYTES
+ ) {
+ continue;
+ }
+ await searchFile({
+ fileUrl: childUrl,
+ relativePath: relPath,
+ query,
+ regex,
+ caseSensitive,
+ contextLines,
+ maxResults,
+ results,
+ });
+ }
+ }
+}
+
+class SearchCodeTool extends StructuredTool {
+ name = "searchCode";
+ description =
+ "Searches files in the current project using a plain string or regular expression." +
+ " Returns the path, line, column, and a short preview for each match.";
+ schema = z.object({
+ query: z
+ .string()
+ .min(1)
+ .describe("Search term or regex pattern depending on 'isRegex'."),
+ path: z
+ .string()
+ .optional()
+ .describe(
+ "Optional relative path to scope the search. Start with the project root name as shown in the sidebar (e.g. 'project/src').",
+ ),
+ maxResults: z
+ .number()
+ .int()
+ .min(1)
+ .max(MAX_RESULTS_LIMIT)
+ .optional()
+ .describe("Maximum number of matches to return (default 50, cap 200)."),
+ caseSensitive: z
+ .boolean()
+ .optional()
+ .describe("Perform a case-sensitive search when true."),
+ isRegex: z
+ .boolean()
+ .optional()
+ .describe("Interpret the query as a regular expression when true."),
+ contextLines: z
+ .number()
+ .int()
+ .min(0)
+ .max(5)
+ .optional()
+ .describe(
+ "Number of context lines to include before and after each match (default 2).",
+ ),
+ include: z
+ .array(z.string())
+ .optional()
+ .describe(
+ "Optional list of path substrings to include (any match qualifies).",
+ ),
+ exclude: z
+ .array(z.string())
+ .optional()
+ .describe("Optional list of path substrings to exclude."),
+ });
+
+ resolvePath(inputPath) {
+ if (
+ !inputPath ||
+ inputPath === "." ||
+ inputPath === "*" ||
+ inputPath === "./"
+ ) {
+ return null;
+ }
+ const parts = inputPath.split("/").filter(Boolean);
+ const projectName = parts[0];
+ const project = addedFolder.find((folder) => folder.title === projectName);
+ if (!project) {
+ return {
+ error: `Error: Project '${projectName}' not found in opened folders.`,
+ };
+ }
+ const subPath = parts.slice(1).join("/");
+ const targetUrl = subPath ? `${project.url}/${subPath}` : project.url;
+ const relativeBase = subPath ? `${projectName}/${subPath}` : projectName;
+ return { project, targetUrl, relativeBase };
+ }
+
+ async _call(options) {
+ const {
+ query,
+ path,
+ maxResults = DEFAULT_MAX_RESULTS,
+ caseSensitive = false,
+ isRegex = false,
+ contextLines = DEFAULT_CONTEXT_LINES,
+ include,
+ exclude,
+ } = options;
+
+ try {
+ const results = [];
+ let roots = [];
+
+ if (path) {
+ const resolved = this.resolvePath(path.trim());
+ if (resolved?.error) {
+ return resolved.error;
+ }
+ if (!resolved) {
+ // Path pointed to root wildcard; fall back to all projects
+ roots = addedFolder.slice();
+ } else {
+ roots = [resolved];
+ }
+ } else {
+ roots = addedFolder.map((folder) => ({
+ project: folder,
+ targetUrl: folder.url,
+ relativeBase: folder.title,
+ }));
+ }
+
+ if (!roots || roots.length === 0) {
+ return "No open folders to search.";
+ }
+
+ let compiledRegex = null;
+ if (isRegex) {
+ try {
+ compiledRegex = buildRegex(query, caseSensitive);
+ } catch (error) {
+ return `Invalid regular expression: ${error.message}`;
+ }
+ }
+
+ for (const root of roots) {
+ if (results.length >= maxResults) break;
+ const projectInfo = root.project
+ ? root
+ : { project: root, targetUrl: root.url, relativeBase: root.title };
+ await traverse({
+ project: projectInfo.project,
+ currentUrl: projectInfo.targetUrl,
+ relativePath: projectInfo.relativeBase,
+ query,
+ regex: compiledRegex,
+ caseSensitive,
+ contextLines,
+ maxResults,
+ results,
+ includePatterns: include,
+ excludePatterns: exclude,
+ });
+ }
+
+ if (results.length === 0) {
+ return `No matches for "${query}"${path ? ` in ${path}` : ""}.`;
+ }
+
+ const formatted = results.slice(0, maxResults).map((match) => {
+ const header = `${match.path}:${match.line}:${match.column}`;
+ return `${header}\n${match.context}`;
+ });
+
+ if (results.length > maxResults) {
+ formatted.push(`\n… truncated after ${maxResults} matches.`);
+ }
+
+ return formatted.join("\n\n");
+ } catch (error) {
+ console.error("searchCode tool error", error);
+ return `Error during search: ${error.message}`;
+ }
+ }
+}
+
+export const searchCode = new SearchCodeTool();
diff --git a/src/pages/aiAssistant/tools/terminal.js b/src/pages/aiAssistant/tools/terminal.js
new file mode 100644
index 000000000..87eef2f29
--- /dev/null
+++ b/src/pages/aiAssistant/tools/terminal.js
@@ -0,0 +1,283 @@
+import { StructuredTool } from "@langchain/core/tools";
+import terminalManager from "components/terminal/terminalManager";
+import confirm from "dialogs/confirm";
+import { z } from "zod";
+
+const executor = () => {
+ const ref = window.Executor;
+ if (!ref) {
+ throw new Error(
+ "Terminal subsystem is unavailable. Ensure the terminal plugin is installed.",
+ );
+ }
+ return ref;
+};
+
+const DEFAULT_CWD = "/home";
+const REMOTE_PROTOCOL_REGEX = /^(?:content|ftp|sftp|smb):\/\//i;
+const BLOCKED_PATTERNS = [
+ /\b(npm|pnpm|yarn)\s+(run\s+)?(dev|start|serve)\b/i,
+ /\b(vite|expo|nx|next|nuxt|astro|svelte-kit|webpack)\b.*\b(dev|start|serve)\b/i,
+ /\b(pnpm|npm|yarn)\s+watch\b/i,
+ /\bnode\b.*\b(--watch|-w)\b/i,
+ /\bpython\b.*-m\s+http\.server\b/i,
+ /\btail\b\s+-f\b/i,
+];
+
+function waitForTerminalConnection(component, timeoutMs = 10000) {
+ if (!component || component.isConnected) {
+ return Promise.resolve();
+ }
+
+ return new Promise((resolve, reject) => {
+ const start = Date.now();
+ const interval = setInterval(() => {
+ if (component.isConnected) {
+ clearInterval(interval);
+ resolve();
+ return;
+ }
+
+ if (Date.now() - start > timeoutMs) {
+ clearInterval(interval);
+ reject(new Error("Timed out waiting for terminal to become ready."));
+ }
+ }, 150);
+ });
+}
+
+function resolvePosixPath(base, target) {
+ const raw = target.startsWith("/")
+ ? target
+ : `${base.replace(/\/$/, "")}/${target}`;
+ const parts = raw.split("/");
+ const stack = [];
+ for (const part of parts) {
+ if (!part || part === ".") continue;
+ if (part === "..") {
+ stack.pop();
+ } else {
+ stack.push(part);
+ }
+ }
+ return `/${stack.join("/")}` || "/";
+}
+
+function toShellPreview(output) {
+ if (!output) return "";
+ return output.length > 4000
+ ? `${output.slice(0, 4000)}\n… (truncated)`
+ : output;
+}
+
+class TerminalTool extends StructuredTool {
+ name = "terminal";
+ description =
+ "Runs a single shell command inside Acode's Alpine proot environment. " +
+ "Long-running dev servers or watch tasks are blocked; use the interactive terminal instead.";
+
+ schema = z.object({
+ command: z
+ .string()
+ .min(1)
+ .describe(
+ "Shell command to execute. Avoid long-running dev/watch tasks.",
+ ),
+ cwd: z
+ .string()
+ .optional()
+ .describe(
+ "Optional working directory. Defaults to the last directory (initially /home). Accepts absolute or relative POSIX paths.",
+ ),
+ requireConfirmation: z
+ .boolean()
+ .default(true)
+ .describe("Prompt the user for confirmation before executing."),
+ });
+
+ constructor() {
+ super();
+ this.currentWorkingDirectory = DEFAULT_CWD;
+ }
+
+ isSessionAlive() {
+ return Promise.resolve(false);
+ }
+
+ getCurrentWorkingDirectory() {
+ return this.currentWorkingDirectory;
+ }
+
+ async resetSession() {
+ this.currentWorkingDirectory = DEFAULT_CWD;
+ }
+
+ async stopCurrentProcess() {
+ await this.resetSession();
+ }
+
+ validateCommand(command) {
+ for (const pattern of BLOCKED_PATTERNS) {
+ if (pattern.test(command)) {
+ throw new Error(
+ "This command starts a long-running process. Please use the interactive terminal instead.",
+ );
+ }
+ }
+ }
+
+ normalizeCwd(input) {
+ if (!input || !input.trim()) {
+ return this.currentWorkingDirectory;
+ }
+
+ let path = input.trim();
+
+ if (REMOTE_PROTOCOL_REGEX.test(path)) {
+ throw new Error(
+ "Remote or SAF paths are not accessible from the terminal tool. Only local paths are supported.",
+ );
+ }
+
+ if (path.startsWith("~")) {
+ path = path.replace(/^~(?=\/|$)/, DEFAULT_CWD);
+ }
+
+ const resolved = resolvePosixPath(this.currentWorkingDirectory, path);
+ return resolved || DEFAULT_CWD;
+ }
+
+ async confirmExecution({ command, cwd }) {
+ const message =
+ `Environment: Alpine proot sandbox
` +
+ `Working directory: ${cwd}
` +
+ `Command:
${command}` +
+ `([\s\S]*?)<\/code><\/pre>/g,
- (match, language, code) => {
- language = language || "plaintext";
- return `
-
-
-
-
- ${language}
-
-
-
-
-
-
- ${code}
-
-
-
- `;
- },
- );
+ const sessionManager = createSessionManager(agent);
- contentElement.querySelectorAll(".code-block").forEach((codeBlock) => {
- const codeContent = codeBlock.querySelector(".code-content");
- const codeElement = codeBlock.querySelector("pre code");
- const copyButton = codeBlock.querySelector(".code-copy");
- const expandButton = codeBlock.querySelector(".code-expand");
-
- // Apply Ace highlighting
- if (codeElement) {
- const langMatch = codeElement.className.match(/language-(\w+)/);
- if (langMatch) {
- const langMap = {
- bash: "sh",
- shell: "sh",
- };
- const lang = langMatch[1];
- const mappedLang = langMap[lang] || lang;
- const highlight = ace.require("ace/ext/static_highlight");
- highlight.render(
- codeElement.textContent,
- `ace/mode/${mappedLang}`,
- settings.value.editorTheme.startsWith("ace/theme/")
- ? settings.value.editorTheme
- : "ace/theme/" + settings.value.editorTheme,
- 1,
- true,
- (highlighted) => {
- aiTabInstance?.addStyle(highlighted.css);
- codeElement.innerHTML = highlighted.html;
- },
- );
- }
- }
+ function getActiveConversationId() {
+ return chatStore.getConversationId();
+ }
- // copy functionality
- copyButton.addEventListener("click", async () => {
- const code = codeElement?.textContent || "";
- try {
- cordova.plugins.clipboard.copy(code);
- copyButton.querySelector("i").className = "icon check";
- setTimeout(() => {
- copyButton.querySelector("i").className = "icon copy";
- }, 2000);
- } catch (err) {
- copyButton.querySelector("i").className =
- "icon warningreport_problem";
- setTimeout(() => {
- copyButton.querySelector("i").className = "icon copy";
- }, 2000);
- }
- });
+ function handleProfileSwitch(profile, { updateStore = true } = {}) {
+ const iconEl = profileBtnRef.el?.querySelector("i:first-child");
+ const textEl = profileBtnRef.el?.querySelector("span");
- // expand/collapse functionality
- expandButton.addEventListener("click", () => {
- const isExpanded = codeContent.classList.contains("expanded");
- codeContent.classList.toggle("expanded", !isExpanded);
- expandButton.innerHTML = isExpanded
- ? ` Show more`
- : ` Show less`;
- });
+ currentProfile = profile;
- // Only show expand button if content overflows
- if (codeContent.scrollHeight <= codeContent.clientHeight) {
- expandButton.style.display = "none";
+ if (iconEl && textEl) {
+ if (profile === "ask") {
+ iconEl.className = "icon help";
+ textEl.textContent = "Ask";
+ } else {
+ iconEl.className = "icon edit";
+ textEl.textContent = "Write";
}
- });
- };
-
- const addMessage = (message) => {
- const messageEl = tag("div", {
- className: `message ${message.role === "user" ? "user" : ""}`,
- id: `message-${message.id}`,
- });
- const messageHeader = tag("div", {
- className: "message-header",
- });
- const messageSender = tag("div", {
- className: `message-sender ${message.role === "user" ? "user" : "ai"}`,
- textContent: message.role === "user" ? "You" : "AI",
- });
- const messageActions = tag("div", {
- className: "message-actions",
- });
- const messageTime = tag("div", {
- className: "message-time",
- textContent: formatTime(message.timestamp),
- });
- messageActions.appendChild(messageTime);
-
- if (message.role === "assistant") {
- const copyBtn = tag("button", {
- className: "btn btn-icon",
- title: "Copy message",
- child: tag("i", {
- className: "icon copy",
- }),
- onclick: () => copyToClipboard(message.content),
- });
- messageActions.appendChild(copyBtn);
}
- if (message.role === "user") {
- const editBtn = tag("button", {
- className: "btn btn-icon",
- title: "Edit message",
- child: tag("i", {
- className: "icon edit",
- }),
- onclick: () => editMessage(message.id),
- });
- messageActions.appendChild(editBtn);
- }
-
- messageHeader.appendChild(messageSender);
- messageHeader.appendChild(messageActions);
-
- const messageContent = tag("div", {
- className: "message-content md",
- });
-
- if (message.role === "user") {
- messageContent.textContent = message.content;
- } else {
- const md = markdownIt({
- html: true,
- linkify: true,
- typographer: true,
- });
- messageContent.innerHTML = md.render(message.content);
- }
-
- messageEl.appendChild(messageHeader);
- messageEl.appendChild(messageContent);
- messageContainerRef.el.appendChild(messageEl);
- scrollToBottom();
- };
-
- const editMessage = (messageId) => {
- const message = chatHistory.find((msg) => msg.id === messageId);
- if (!message) return;
-
- const messageEl = messageContainerRef.el.querySelector(
- `#message-${message.id}`,
- );
- const messageContent = messageEl.querySelector(".message-content");
-
- const editContainer = ;
-
- const textarea = (
-