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}
` + + `
Please confirm before executing.`; + + const userConfirmed = await confirm("Run Terminal Command", message, true); + return Boolean(userConfirmed); + } + + async runExecutor(command) { + try { + const stdout = await executor().execute( + `sh -c "${command.replace(/"/g, '\\"')}"`, + true, + ); + return { success: true, stdout: stdout || "" }; + } catch (error) { + const message = + typeof error === "string" ? error : error?.message || String(error); + return { success: false, stderr: message }; + } + } + + async _call({ command, cwd, requireConfirmation = true }) { + try { + const trimmedCommand = command.trim(); + if (!trimmedCommand) { + return "Error: Empty command provided."; + } + + this.validateCommand(trimmedCommand); + + const normalizedCwd = this.normalizeCwd(cwd); + + if (requireConfirmation) { + const consent = await this.confirmExecution({ + command: trimmedCommand, + cwd: normalizedCwd, + }); + if (!consent) { + return "Command cancelled by user."; + } + } + + const wrappedCommand = `cd "${normalizedCwd.replace(/"/g, '\\"')}" && ${trimmedCommand}`; + + const result = await this.runExecutor(wrappedCommand); + + if (result.success) { + this.currentWorkingDirectory = normalizedCwd; + } + + const lines = [ + "Environment: Alpine proot sandbox", + `Working directory: ${normalizedCwd}`, + `Command: ${trimmedCommand}`, + result.success ? "Status: SUCCESS" : "Status: FAILED", + ]; + + const body = []; + if (result.success && result.stdout) { + body.push(`STDOUT:\n${toShellPreview(result.stdout)}`); + } else if (result.success) { + body.push("STDOUT:\n(no output)\n"); + } + + if (!result.success && result.stderr) { + body.push(`STDERR:\n${toShellPreview(result.stderr)}`); + } + + if (!result.success) { + body.push( + "Hint: Use the interactive terminal tool for commands that need an ongoing session.", + ); + } + + return [...lines, "", ...body].join("\n"); + } catch (error) { + return `Error: ${error.message}`; + } + } +} + +class InteractiveTerminalTool extends StructuredTool { + name = "interactiveTerminal"; + description = + "Opens a dedicated terminal tab in the UI (server-backed) for manual interaction. " + + "Use this for long-running or interactive workflows."; + + schema = z.object({ + name: z.string().optional().describe("Optional custom tab title."), + command: z + .string() + .optional() + .describe( + "Optional command to send once the terminal is ready (a newline is appended automatically).", + ), + }); + + async _call({ name, command }) { + try { + const terminal = await terminalManager.createTerminal({ + name: name || "AI Assistant Terminal", + serverMode: true, + }); + + if (!terminal) { + return "Failed to create terminal session."; + } + + try { + await waitForTerminalConnection(terminal.component); + } catch (connectionError) { + return `Terminal tab opened, but the backend did not become ready: ${connectionError.message}`; + } + + if (command && command.trim()) { + terminalManager.writeToTerminal(terminal.id, `${command.trim()}\r\n`); + return `Interactive terminal "${terminal.name}" opened and command sent.`; + } + + return `Interactive terminal "${terminal.name}" opened. You can find it in the tab bar.`; + } catch (error) { + return `Error creating terminal: ${error.message}`; + } + } +} + +const terminalToolInstance = new TerminalTool(); + +export const terminal = terminalToolInstance; +export const interactiveTerminal = new InteractiveTerminalTool(); From 5aad9bba3d8185e677ea49098325d17dd536c2d8 Mon Sep 17 00:00:00 2001 From: Raunak Raj <71929976+bajrangCoder@users.noreply.github.com> Date: Thu, 18 Sep 2025 21:32:58 +0530 Subject: [PATCH 2/2] refactor: ai agent behaviour and logic with lots of fixes - organise the logics - show tool call on ui - fix ui issues - and a lot more --- AGENTS.md | 40 + src/pages/aiAssistant/agent/sessionManager.js | 202 ++++ src/pages/aiAssistant/assistant.js | 982 ++++++------------ src/pages/aiAssistant/assistant.m.scss | 266 +++++ src/pages/aiAssistant/state/chatStore.js | 198 ++++ src/pages/aiAssistant/ui/messageRenderer.js | 545 ++++++++++ 6 files changed, 1583 insertions(+), 650 deletions(-) create mode 100644 AGENTS.md create mode 100644 src/pages/aiAssistant/agent/sessionManager.js create mode 100644 src/pages/aiAssistant/state/chatStore.js create mode 100644 src/pages/aiAssistant/ui/messageRenderer.js diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..1807794e6 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,40 @@ +# Repository Guidelines + +## Project Structure & Module Organization +- Source: `src/` (ES modules, SCSS). Key areas: `src/components/`, `src/pages/`, `src/lib/`, `src/plugins/` (Cordova plugins), `src/styles/`. +- Web assets: `www/` (entry `index.html`, build output in `www/build/`, css/js subfolders). +- Config: `webpack.config.js`, `.babelrc`, `biome.json`, `config.xml`. +- Utilities: `utils/` (build, start, setup scripts; language and tooling helpers). + +## Build, Test, and Development Commands +- `pnpm run setup`: Install deps, add Android platform, create build dirs, add plugins. +- `pnpm run start -- android d`: Build and run on device/emulator (default dev). For release-like: `pnpm run start -- android p`. +- `pnpm run build`: Build app (defaults to dev). Production example: `pnpm run build -- paid prod`. F-Droid variant: `pnpm run build -- paid prod fdroid`. +- `pnpm run clean -- android android`: Recreate Android platform. +- `pnpm run plugin -- [path]`: Reinstall a Cordova plugin. +- Linting/format: `pnpm run lint`, `pnpm run format`, `pnpm run check` (Biome). + +## Coding Style & Naming Conventions +- Formatting: Biome enforced; indent with tabs (`biome.json`). Run `npm run format` before commits. +- Language: ESM JavaScript, SCSS; prefer imports from `src` root (webpack `resolve.modules: ["node_modules", "src"]`). +- Naming: camelCase for files/dirs (e.g., `fileSystem`, `quickTools`), PascalCase for classes/components, kebab-case for SCSS partials when applicable. + +## Testing Guidelines +- No formal test runner configured. Provide clear manual verification steps for Android (device/emulator), including affected screens, repro steps, and expected/actual behavior. +- Attach logs when relevant (see issue templates mention of `Acode.log`). Screenshots or screen recordings are encouraged for UI changes. + +## Commit & Pull Request Guidelines +- Commits: Follow conventional style when possible (`feat:`, `fix:`, `chore(scope):`, etc.), reference issues (`(#123)`). +- PRs: Include a concise summary, linked issues, screenshots for UI, and test/verification steps. Note any Cordova plugin or config changes. +- Keep diffs focused; run `npm run lint` and ensure `www/build/` is generated by the pipeline, not committed. + +## Security & Configuration Tips +- Building for F-Droid: pass `fdroid` as the third arg to `build` to toggle plugin set. +- Do not commit secrets/keystores. Android signing is handled outside the repo; keep `keystore.jks` private. + +# When you need to call tools from the shell, use this rubric: + +- Find Files: `fd` +- Find Text: `rg` (ripgrep) +- Select among matches: pipe to `fzf` +- JSON: `jq` \ No newline at end of file diff --git a/src/pages/aiAssistant/agent/sessionManager.js b/src/pages/aiAssistant/agent/sessionManager.js new file mode 100644 index 000000000..cafbf246a --- /dev/null +++ b/src/pages/aiAssistant/agent/sessionManager.js @@ -0,0 +1,202 @@ +import { + isAIMessageChunk, + isToolMessage, + isToolMessageChunk, +} from "@langchain/core/messages"; + +function extractText(contentLike) { + if (!contentLike) return ""; + if (typeof contentLike === "string") return contentLike; + if (Array.isArray(contentLike)) { + return contentLike + .map((item) => { + if (!item) return ""; + if (typeof item === "string") return item; + if (typeof item === "object" && "text" in item) return item.text ?? ""; + if (typeof item === "object" && "content" in item) + return extractText(item.content); + return ""; + }) + .join(""); + } + if (typeof contentLike === "object" && "text" in contentLike) { + return contentLike.text ?? ""; + } + return ""; +} + +function ensureToolState(toolStates, toolCallId, defaults = {}) { + if (!toolStates.has(toolCallId)) { + toolStates.set(toolCallId, { + id: toolCallId, + name: defaults.name || "Tool", + argsBuffer: "", + argsObject: undefined, + output: "", + status: "running", + isNew: true, + stage: "start", + }); + } + const state = toolStates.get(toolCallId); + Object.assign(state, defaults); + return state; +} + +export function createSessionManager(agent) { + return { + async runTurn({ + conversationId, + userMessage, + signal, + assistantMessageId, + onStart, + onToken, + onToolEvent, + }) { + const toolStates = new Map(); + let accumulatedText = ""; + + const emitToolState = (state) => { + if (!onToolEvent) return; + state.parentMessageId = assistantMessageId; + onToolEvent({ + id: state.id, + name: state.name, + argsText: state.argsBuffer, + argsObject: state.argsObject, + output: state.output, + status: state.status, + stage: state.stage, + isNew: state.isNew, + parentMessageId: assistantMessageId, + }); + state.isNew = false; + }; + + try { + const stream = await agent.stream( + { messages: [userMessage] }, + { + streamMode: "messages", + signal, + configurable: { + thread_id: conversationId, + }, + }, + ); + + onStart?.(); + + for await (const event of stream) { + const payload = Array.isArray(event) ? event[0] : event; + if (!payload) continue; + + if (isToolMessageChunk(payload) || isToolMessage(payload)) { + const toolCallId = + payload.tool_call_id || payload.id || `tool_${toolStates.size}`; + const state = ensureToolState(toolStates, toolCallId); + const chunkText = extractText(payload.content ?? payload); + if (chunkText) { + state.output = `${state.output || ""}${chunkText}`; + } + if (payload.status) { + state.status = payload.status === "error" ? "error" : "success"; + } + state.stage = "output"; + emitToolState(state); + continue; + } + + if (isAIMessageChunk(payload)) { + const { tool_call_chunks: toolCallChunks, tool_calls: toolCalls } = + payload; + + if (Array.isArray(toolCallChunks) && toolCallChunks.length) { + for (const chunk of toolCallChunks) { + const toolCallId = + chunk.id || + chunk.tool_call_id || + chunk.name || + `tool_${toolStates.size}`; + const state = ensureToolState(toolStates, toolCallId, { + name: + chunk.name || toolStates.get(toolCallId)?.name || "Tool", + }); + if (chunk.args) { + state.argsBuffer = `${state.argsBuffer || ""}${chunk.args}`; + } + state.stage = "args-delta"; + emitToolState(state); + } + } + + if (Array.isArray(toolCalls) && toolCalls.length) { + for (const call of toolCalls) { + const toolCallId = + call.id || call.name || `tool_${toolStates.size}`; + const state = ensureToolState(toolStates, toolCallId, { + name: call.name || toolStates.get(toolCallId)?.name || "Tool", + }); + state.argsObject = call.args; + try { + state.argsBuffer = JSON.stringify(call.args, null, 2); + } catch (error) { + state.argsBuffer = String(call.args); + } + state.stage = "args-final"; + emitToolState(state); + } + } + } + + const chunkText = extractText(payload?.content ?? payload); + if (chunkText) { + accumulatedText += chunkText; + onToken?.({ + fullText: accumulatedText, + delta: chunkText, + }); + } + } + + const toolRuns = Array.from(toolStates.values()).map((state) => { + if (!state.status || state.status === "running") { + state.status = "success"; + } + state.stage = "complete"; + emitToolState(state); + return { + id: state.id, + name: state.name, + status: state.status, + argsText: state.argsBuffer, + argsObject: state.argsObject, + output: state.output, + timestamp: Date.now(), + parentMessageId: assistantMessageId, + }; + }); + + return { + content: accumulatedText, + toolRuns, + }; + } catch (error) { + if (toolStates.size) { + error.toolRuns = Array.from(toolStates.values()).map((state) => ({ + id: state.id, + name: state.name, + status: state.status, + argsText: state.argsBuffer, + argsObject: state.argsObject, + output: state.output, + timestamp: Date.now(), + parentMessageId: assistantMessageId, + })); + } + throw error; + } + }, + }; +} diff --git a/src/pages/aiAssistant/assistant.js b/src/pages/aiAssistant/assistant.js index da148c31a..c2d40e93e 100644 --- a/src/pages/aiAssistant/assistant.js +++ b/src/pages/aiAssistant/assistant.js @@ -1,25 +1,18 @@ -import { isAIMessageChunk, isToolMessageChunk } from "@langchain/core/messages"; -import { ChatGoogleGenerativeAI } from "@langchain/google-genai"; import { createReactAgent } from "@langchain/langgraph/prebuilt"; +import { ChatOpenAI } from "@langchain/openai"; import confirm from "dialogs/confirm"; import select from "dialogs/select"; import Ref from "html-tag-js/ref"; import EditorFile from "lib/editorFile"; -import settings from "lib/settings"; -import markdownIt from "markdown-it"; +import { createSessionManager } from "./agent/sessionManager"; import styles from "./assistant.m.scss"; -import { - addConversation, - addMessageToDB, - deleteConversation, - getAllConversations, - getConversation, - getMessagesForConversation, - updateConversation, -} from "./db"; import { CordovaSqliteSaver } from "./memory"; +import { createChatStore } from "./state/chatStore"; import { SYSTEM_PROMPT } from "./system_prompt"; import { allTools } from "./tools"; +import { createMessageRenderer } from "./ui/messageRenderer"; + +const GEMINI_API_KEY = ""; // Replace export default function openAIAssistantPage() { // References @@ -31,21 +24,26 @@ export default function openAIAssistantPage() { const stopBtnRef = Ref(); let currentProfile = "ask"; - let currentConversationId = null; - let currentConversation = null; - let chatHistory = []; let currentController = null; let aiTabInstance; - const GEMINI_API_KEY = ""; // Replace + const chatStore = createChatStore(); + + const messageRenderer = createMessageRenderer({ + messageContainerRef, + onUserEdit: handleEditMessage, + }); const agentCheckpointer = new CordovaSqliteSaver(); - const model = new ChatGoogleGenerativeAI({ - model: "gemini-2.0-flash", + const model = new ChatOpenAI({ + model: "openrouter/sonoma-sky-alpha", apiKey: GEMINI_API_KEY, + streaming: true, + configuration: { + baseURL: "https://openrouter.ai/api/v1", + }, }); - // Get all tools as an array for the agent including search const toolsArray = Object.values(allTools); const agent = createReactAgent({ @@ -55,296 +53,34 @@ export default function openAIAssistantPage() { stateModifier: SYSTEM_PROMPT, }); - const generateConversationId = () => - `conv_${Date.now()}_${Math.random().toString(36).substring(2, 7)}`; - const generateMessageId = (() => { - let counter = 0; - return () => `msg_${Date.now()}_${++counter}`; - })(); - - const formatTime = (timestamp) => - new Date(timestamp).toLocaleTimeString([], { - hour: "2-digit", - minute: "2-digit", - }); - - const copyToClipboard = (text) => { - cordova.plugins.clipboard.copy(text || ""); - }; - - const scrollToBottom = () => { - messageContainerRef.el.scrollTop = messageContainerRef.el.scrollHeight; - }; - - // Format code blocks with custom UI elements - const formatCodeBlocks = (contentElement, content) => { - if (!contentElement) return; - - const md = markdownIt({ - html: true, - linkify: true, - typographer: true, - }); - - contentElement.innerHTML = md.render(content); - - contentElement.innerHTML = contentElement.innerHTML.replace( - /
([\s\S]*?)<\/code><\/pre>/g,
-			(match, language, code) => {
-				language = language || "plaintext";
-				return `
-					
-
-
- - ${language} -
-
- -
-
-
-
${code}
-
-
- - Show more -
-
- `; - }, - ); + 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 = ( -