v3.2.0: full QA fixes and in-app virtual environment

This commit is contained in:
admin
2026-05-21 14:10:15 +04:00
Unverified
parent 2e5d2f819a
commit 66354c182b
10 changed files with 290 additions and 95 deletions

View File

@@ -631,6 +631,17 @@ data: [DONE]
## Changelog ## Changelog
### v3.2.0 (2026-05-20)
- **Full Internal Virtual Environment** — `BootstrapPlugin.setupVirtualEnv()` now provisions an app-contained Python venv under app storage, no external Termux app required
- **In-App Module Installation** — `BootstrapPlugin.venvPipInstall()` installs Python modules directly into the internal venv
- **New AI Automation Tags** — `[VENV_SETUP]` and `[VENV_PIP_INSTALL package]` available in coding and agentic modes
- **Critical Runtime Fix** — replaced invalid JS `new File(...).exists` check with shell-based file existence test in PRoot strategy
- **Concurrency Fix** — `ShellPlugin.activeProcesses` migrated to `ConcurrentHashMap` to avoid race/corruption under parallel process events
- **Wake Stability** — added null-safe activity handling and power service guards in `WakePlugin`
- **Bootstrap Resource Safety** — fixed major stream and descriptor leaks in download/extract paths (`downloadFile`, `extractBootstrap`)
- **Accessibility Labels** — added missing `aria-label` attributes for icon-only controls in UI
- **Version sync** — About screen and package/build versions updated to 3.2.0
### v3.1.0 (2026-05-20) ### v3.1.0 (2026-05-20)
- **AutoGLM Device Control** — `AccessibilityService` for full device automation: tap, swipe, long press, type text, screenshot, UI tree - **AutoGLM Device Control** — `AccessibilityService` for full device automation: tap, swipe, long press, type text, screenshot, UI tree
- **Navigation Controls** — press Back, Home, Recents, Notifications, Quick Settings, Power Dialog via global actions - **Navigation Controls** — press Back, Home, Recents, Notifications, Quick Settings, Power Dialog via global actions

View File

@@ -1,5 +1,19 @@
apply plugin: 'com.android.application' apply plugin: 'com.android.application'
def envOrDefault(String key, String fallback) {
def fromEnv = System.getenv(key)
if (fromEnv != null && fromEnv.trim()) return fromEnv.trim()
if (project.hasProperty(key) && project.property(key)?.toString()?.trim()) return project.property(key).toString().trim()
return fallback
}
def debugStorePass = envOrDefault('ZAI_DEBUG_STORE_PASSWORD', 'android')
def debugKeyAlias = envOrDefault('ZAI_DEBUG_KEY_ALIAS', 'androiddebugkey')
def debugKeyPass = envOrDefault('ZAI_DEBUG_KEY_PASSWORD', 'android')
def releaseStorePass = envOrDefault('ZAI_RELEASE_STORE_PASSWORD', 'zaichat')
def releaseKeyAlias = envOrDefault('ZAI_RELEASE_KEY_ALIAS', 'zai-chat')
def releaseKeyPass = envOrDefault('ZAI_RELEASE_KEY_PASSWORD', 'zaichat')
android { android {
namespace = "ai.z.chat" namespace = "ai.z.chat"
compileSdk = rootProject.ext.compileSdkVersion compileSdk = rootProject.ext.compileSdkVersion
@@ -7,8 +21,8 @@ android {
applicationId "ai.z.chat" applicationId "ai.z.chat"
minSdkVersion rootProject.ext.minSdkVersion minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 22 versionCode 23
versionName "3.1.2" versionName "3.2.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions { aaptOptions {
ignoreAssetsPattern = '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~' ignoreAssetsPattern = '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~'
@@ -18,15 +32,15 @@ android {
signingConfigs { signingConfigs {
debug { debug {
storeFile file('debug.keystore') storeFile file('debug.keystore')
storePassword 'android' storePassword debugStorePass
keyAlias 'androiddebugkey' keyAlias debugKeyAlias
keyPassword 'android' keyPassword debugKeyPass
} }
release { release {
storeFile file('release.keystore') storeFile file('release.keystore')
storePassword 'zaichat' storePassword releaseStorePass
keyAlias 'zai-chat' keyAlias releaseKeyAlias
keyPassword 'zaichat' keyPassword releaseKeyPass
} }
} }

View File

@@ -9,7 +9,6 @@ import com.getcapacitor.annotation.Permission;
import android.content.Intent; import android.content.Intent;
import android.provider.Settings; import android.provider.Settings;
import android.text.TextUtils;
@CapacitorPlugin( @CapacitorPlugin(
name = "AutoGLM", name = "AutoGLM",
@@ -130,7 +129,11 @@ public class AutoGLMPlugin extends Plugin {
destPath = getContext().getCacheDir() + "/autoglm_screenshot.png"; destPath = getContext().getCacheDir() + "/autoglm_screenshot.png";
} }
svc.takeScreenshot(destPath); svc.takeScreenshot(destPath);
try { Thread.sleep(500); } catch (Exception e) {} try {
Thread.sleep(500);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
call.resolve(new JSObject().put("path", destPath).put("ok", true)); call.resolve(new JSObject().put("path", destPath).put("ok", true));
} }

View File

@@ -23,7 +23,7 @@ import java.util.List;
public class AutoGLMService extends AccessibilityService { public class AutoGLMService extends AccessibilityService {
private static final String TAG = "AutoGLMService"; private static final String TAG = "AutoGLMService";
private static AutoGLMService instance; private static volatile AutoGLMService instance;
@Override @Override
public void onCreate() { public void onCreate() {
@@ -138,12 +138,14 @@ public class AutoGLMService extends AccessibilityService {
screenshot.getHardwareBuffer(), screenshot.getColorSpace()); screenshot.getHardwareBuffer(), screenshot.getColorSpace());
if (bitmap != null) { if (bitmap != null) {
Bitmap softwareBitmap = bitmap.copy(Bitmap.Config.ARGB_8888, false); Bitmap softwareBitmap = bitmap.copy(Bitmap.Config.ARGB_8888, false);
FileOutputStream fos = new FileOutputStream(destPath); try (FileOutputStream fos = new FileOutputStream(destPath)) {
softwareBitmap.compress(Bitmap.CompressFormat.PNG, 90, fos); softwareBitmap.compress(Bitmap.CompressFormat.PNG, 90, fos);
fos.close(); }
softwareBitmap.recycle(); softwareBitmap.recycle();
} }
if (screenshot.getHardwareBuffer() != null) {
screenshot.getHardwareBuffer().close(); screenshot.getHardwareBuffer().close();
}
} catch (Exception e) { } catch (Exception e) {
Log.e(TAG, "Screenshot save failed", e); Log.e(TAG, "Screenshot save failed", e);
} }
@@ -194,7 +196,9 @@ public class AutoGLMService extends AccessibilityService {
focusNode.recycle(); focusNode.recycle();
return obj.toString(); return obj.toString();
} }
} catch (Exception e) {} } catch (Exception e) {
Log.w(TAG, "getFocusedNodeInfo failed", e);
}
return "{}"; return "{}";
} }
@@ -261,7 +265,9 @@ public class AutoGLMService extends AccessibilityService {
} }
} }
} }
} catch (Exception e) {} } catch (Exception e) {
Log.w(TAG, "getCurrentApp failed", e);
}
return ""; return "";
} }

View File

@@ -59,7 +59,7 @@ public class BootstrapPlugin extends Plugin {
private String stagingDir; private String stagingDir;
private String homeDir; private String homeDir;
private String binDir; private String binDir;
private boolean isInstalling = false; private volatile boolean isInstalling = false;
@Override @Override
public void load() { public void load() {
@@ -203,9 +203,8 @@ public class BootstrapPlugin extends Plugin {
conn.connect(); conn.connect();
int total = conn.getContentLength(); int total = conn.getContentLength();
BufferedInputStream in = new BufferedInputStream(conn.getInputStream()); try (BufferedInputStream in = new BufferedInputStream(conn.getInputStream());
FileOutputStream out = new FileOutputStream(outputFile); FileOutputStream out = new FileOutputStream(outputFile)) {
byte[] buffer = new byte[BUFFER_SIZE]; byte[] buffer = new byte[BUFFER_SIZE];
long downloaded = 0; long downloaded = 0;
int read; int read;
@@ -220,22 +219,19 @@ public class BootstrapPlugin extends Plugin {
lastNotify = now; lastNotify = now;
} }
} }
out.flush(); out.flush();
out.close(); } finally {
in.close();
conn.disconnect(); conn.disconnect();
} }
}
private List<String[]> extractBootstrap(File zipFile, String destDir, ExtractCallback callback) throws Exception { private List<String[]> extractBootstrap(File zipFile, String destDir, ExtractCallback callback) throws Exception {
List<String[]> symlinks = new ArrayList<>(); List<String[]> symlinks = new ArrayList<>();
ZipInputStream zis = new ZipInputStream(new FileInputStream(zipFile)); ZipInputStream zis;
ZipEntry entry; ZipEntry entry;
int extracted = 0; int extracted = 0;
int total = 0; int total = 0;
java.util.Enumeration<java.util.zip.ZipEntry> entries = java.util.Collections.emptyEnumeration();
java.util.zip.ZipFile zf = new java.util.zip.ZipFile(zipFile); java.util.zip.ZipFile zf = new java.util.zip.ZipFile(zipFile);
total = zf.size(); total = zf.size();
zf.close(); zf.close();
@@ -261,13 +257,13 @@ public class BootstrapPlugin extends Plugin {
} else { } else {
File parent = outFile.getParentFile(); File parent = outFile.getParentFile();
if (parent != null) parent.mkdirs(); if (parent != null) parent.mkdirs();
FileOutputStream fos = new FileOutputStream(outFile); try (FileOutputStream fos = new FileOutputStream(outFile)) {
byte[] buf = new byte[BUFFER_SIZE]; byte[] buf = new byte[BUFFER_SIZE];
int len; int len;
while ((len = zis.read(buf)) > 0) { while ((len = zis.read(buf)) > 0) {
fos.write(buf, 0, len); fos.write(buf, 0, len);
} }
fos.close(); }
} }
} }
extracted++; extracted++;
@@ -726,6 +722,93 @@ public class BootstrapPlugin extends Plugin {
}).start(); }).start();
} }
@PluginMethod
public void setupVirtualEnv(PluginCall call) {
call.setKeepAlive(true);
new Thread(() -> {
try {
String prefix = call.getString("prefix", prefixDir);
String home = call.getString("home", homeDir + "/home");
String venvDir = call.getString("venv", filesDir + "/venv/default");
new File(venvDir).getParentFile().mkdirs();
String pkgBin = prefix + "/bin/pkg";
String aptBin = prefix + "/bin/apt";
String python3 = prefix + "/bin/python3";
if (!new File(pkgBin).exists() && !new File(aptBin).exists()) {
call.reject("Bootstrap tools missing. Install internal dev environment first.");
return;
}
if (!new File(python3).exists()) {
String installer = new File(pkgBin).exists() ? pkgBin : aptBin;
runBootstrapCommand(prefix, home,
"sh \"" + installer + "\" install -y python clang rust make pkg-config libffi openssl");
}
if (!new File(python3).exists()) {
call.reject("python3 unavailable after install");
return;
}
runBootstrapCommand(prefix, home,
"\"" + python3 + "\" -m venv \"" + venvDir + "\" && " +
"\"" + venvDir + "/bin/pip\" install --upgrade pip setuptools wheel");
call.resolve(new JSObject()
.put("ok", true)
.put("venv", venvDir)
.put("python", venvDir + "/bin/python")
.put("pip", venvDir + "/bin/pip"));
} catch (Exception e) {
try { call.reject("setupVirtualEnv failed: " + e.getMessage()); } catch (Exception ignored) {}
}
}).start();
}
@PluginMethod
public void venvPipInstall(PluginCall call) {
call.setKeepAlive(true);
new Thread(() -> {
try {
String prefix = call.getString("prefix", prefixDir);
String home = call.getString("home", homeDir + "/home");
String venvDir = call.getString("venv", filesDir + "/venv/default");
String packages = call.getString("packages", "");
if (packages.trim().isEmpty()) {
call.reject("packages required");
return;
}
String pip = venvDir + "/bin/pip";
if (!new File(pip).exists()) {
call.reject("venv pip not found. Run setupVirtualEnv first.");
return;
}
String output = runBootstrapCommand(prefix, home,
"\"" + pip + "\" install " + packages);
call.resolve(new JSObject().put("ok", true).put("output", output));
} catch (Exception e) {
try { call.reject("venvPipInstall failed: " + e.getMessage()); } catch (Exception ignored) {}
}
}).start();
}
private String runBootstrapCommand(String prefix, String home, String command) throws Exception {
ProcessBuilder pb = new ProcessBuilder("/system/bin/sh", "-c",
"export PREFIX=\"" + prefix + "\" HOME=\"" + home + "\" " +
"LD_LIBRARY_PATH=\"" + prefix + "/lib\" PATH=\"" + prefix + "/bin:/system/bin:$PATH\" " +
"ANDROID_API_LEVEL=\"" + android.os.Build.VERSION.SDK_INT + "\" && " + command);
pb.redirectErrorStream(true);
Process p = pb.start();
String out = drainProcess(p);
int code = p.waitFor();
if (code != 0) throw new RuntimeException("cmd failed(" + code + "): " + out);
return out;
}
private String drainProcess(Process p) throws Exception { private String drainProcess(Process p) throws Exception {
java.io.InputStream is = p.getInputStream(); java.io.InputStream is = p.getInputStream();
java.io.ByteArrayOutputStream baos = new java.io.ByteArrayOutputStream(); java.io.ByteArrayOutputStream baos = new java.io.ByteArrayOutputStream();

View File

@@ -13,10 +13,9 @@ import com.getcapacitor.annotation.Permission;
import java.io.BufferedReader; import java.io.BufferedReader;
import java.io.File; import java.io.File;
import java.io.InputStreamReader; import java.io.InputStreamReader;
import java.io.InputStream;
import java.io.OutputStream;
import java.util.HashMap; import java.util.HashMap;
import java.util.Map; import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
@CapacitorPlugin(name = "Shell", @CapacitorPlugin(name = "Shell",
permissions = { permissions = {
@@ -25,7 +24,7 @@ import java.util.Map;
) )
public class ShellPlugin extends Plugin { public class ShellPlugin extends Plugin {
private static final String TAG = "ShellPlugin"; private static final String TAG = "ShellPlugin";
private final Map<String, Process> activeProcesses = new HashMap<>(); private final Map<String, Process> activeProcesses = new ConcurrentHashMap<>();
private String currentCwd = null; private String currentCwd = null;
private String homeDir = null; private String homeDir = null;
private String toolsDir = null; private String toolsDir = null;
@@ -228,9 +227,9 @@ public class ShellPlugin extends Plugin {
File file = new File(path); File file = new File(path);
File parent = file.getParentFile(); File parent = file.getParentFile();
if (parent != null && !parent.exists()) parent.mkdirs(); if (parent != null && !parent.exists()) parent.mkdirs();
java.io.FileWriter writer = new java.io.FileWriter(file); try (java.io.FileWriter writer = new java.io.FileWriter(file)) {
writer.write(content); writer.write(content);
writer.close(); }
call.resolve(new JSObject().put("path", file.getAbsolutePath()).put("size", file.length())); call.resolve(new JSObject().put("path", file.getAbsolutePath()).put("size", file.length()));
} catch (Exception e) { } catch (Exception e) {
call.reject("Write failed: " + e.getMessage()); call.reject("Write failed: " + e.getMessage());
@@ -250,13 +249,13 @@ public class ShellPlugin extends Plugin {
call.reject("File not found: " + path); call.reject("File not found: " + path);
return; return;
} }
BufferedReader reader = new BufferedReader(new InputStreamReader(new java.io.FileInputStream(file), "UTF-8"));
StringBuilder sb = new StringBuilder(); StringBuilder sb = new StringBuilder();
try (BufferedReader reader = new BufferedReader(new InputStreamReader(new java.io.FileInputStream(file), "UTF-8"))) {
String line; String line;
while ((line = reader.readLine()) != null) { while ((line = reader.readLine()) != null) {
sb.append(line).append("\n"); sb.append(line).append("\n");
} }
reader.close(); }
call.resolve(new JSObject().put("content", sb.toString()).put("path", file.getAbsolutePath())); call.resolve(new JSObject().put("content", sb.toString()).put("path", file.getAbsolutePath()));
} catch (Exception e) { } catch (Exception e) {
call.reject("Read failed: " + e.getMessage()); call.reject("Read failed: " + e.getMessage());
@@ -367,12 +366,15 @@ public class ShellPlugin extends Plugin {
try { try {
File versionFile = new File("/data/data/com.termux/files/usr/share/doc/termux/VERSION"); File versionFile = new File("/data/data/com.termux/files/usr/share/doc/termux/VERSION");
if (versionFile.exists()) { if (versionFile.exists()) {
java.io.BufferedReader reader = new java.io.BufferedReader(new java.io.FileReader(versionFile)); String version;
String version = reader.readLine(); try (java.io.BufferedReader reader = new java.io.BufferedReader(new java.io.FileReader(versionFile))) {
reader.close(); version = reader.readLine();
}
return version != null ? version : "unknown"; return version != null ? version : "unknown";
} }
} catch (Exception e) {} } catch (Exception e) {
Log.w(TAG, "Unable to read Termux version", e);
}
return "installed"; return "installed";
} }

View File

@@ -14,7 +14,7 @@ import com.getcapacitor.annotation.CapacitorPlugin;
public class WakePlugin extends Plugin { public class WakePlugin extends Plugin {
private PowerManager.WakeLock screenWakeLock; private PowerManager.WakeLock screenWakeLock;
private PowerManager.WakeLock cpuWakeLock; private PowerManager.WakeLock cpuWakeLock;
private boolean isHeld = false; private volatile boolean isHeld = false;
@Override @Override
public void load() { public void load() {
@@ -29,11 +29,19 @@ public class WakePlugin extends Plugin {
} }
try { try {
if (getActivity() != null) {
getActivity().runOnUiThread(() -> { getActivity().runOnUiThread(() -> {
if (getActivity() != null) {
getActivity().getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); getActivity().getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
}
}); });
}
PowerManager pm = (PowerManager) getContext().getSystemService(Context.POWER_SERVICE); PowerManager pm = (PowerManager) getContext().getSystemService(Context.POWER_SERVICE);
if (pm == null) {
call.reject("Power service unavailable");
return;
}
screenWakeLock = pm.newWakeLock( screenWakeLock = pm.newWakeLock(
PowerManager.SCREEN_BRIGHT_WAKE_LOCK | PowerManager.ON_AFTER_RELEASE, PowerManager.SCREEN_BRIGHT_WAKE_LOCK | PowerManager.ON_AFTER_RELEASE,
@@ -57,9 +65,13 @@ public class WakePlugin extends Plugin {
@PluginMethod @PluginMethod
public void release(PluginCall call) { public void release(PluginCall call) {
try { try {
if (getActivity() != null) {
getActivity().runOnUiThread(() -> { getActivity().runOnUiThread(() -> {
if (getActivity() != null) {
getActivity().getWindow().clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); getActivity().getWindow().clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
}
}); });
}
if (screenWakeLock != null && screenWakeLock.isHeld()) { if (screenWakeLock != null && screenWakeLock.isHeld()) {
screenWakeLock.release(); screenWakeLock.release();

View File

@@ -1,6 +1,6 @@
{ {
"name": "zai-chat", "name": "zai-chat",
"version": "3.1.2", "version": "3.2.0",
"description": "Z.AI Chat - Full stack AI chat powered by GLM Coding Plan", "description": "Z.AI Chat - Full stack AI chat powered by GLM Coding Plan",
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {

View File

@@ -69,24 +69,24 @@
<div id="chat-screen" class="screen"> <div id="chat-screen" class="screen">
<div class="chat-header"> <div class="chat-header">
<div class="header-left"> <div class="header-left">
<button id="menu-btn" class="icon-btn">&#9776;</button> <button id="menu-btn" class="icon-btn" aria-label="Open sidebar menu">&#9776;</button>
<div class="header-title"> <div class="header-title">
<h2 id="conversation-title">New Chat</h2> <h2 id="conversation-title">New Chat</h2>
<span id="current-mode-label" class="mode-label">Chat</span> <span id="current-mode-label" class="mode-label">Chat</span>
</div> </div>
</div> </div>
<div class="header-right"> <div class="header-right">
<button id="file-tree-btn" class="icon-btn" title="Project Files" style="display:none">&#128193;</button> <button id="file-tree-btn" class="icon-btn" title="Project Files" aria-label="Open project files" style="display:none">&#128193;</button>
<button id="theme-toggle-header" class="theme-toggle-btn" title="Toggle theme">&#9790;</button> <button id="theme-toggle-header" class="theme-toggle-btn" title="Toggle theme" aria-label="Toggle theme">&#9790;</button>
<button id="new-chat-btn" class="icon-btn" title="New chat">+</button> <button id="new-chat-btn" class="icon-btn" title="New chat" aria-label="Start new chat">+</button>
<button id="settings-btn" class="icon-btn" title="Settings">&#9881;</button> <button id="settings-btn" class="icon-btn" title="Settings" aria-label="Open settings">&#9881;</button>
</div> </div>
</div> </div>
<div id="sidebar" class="sidebar"> <div id="sidebar" class="sidebar">
<div class="sidebar-header"> <div class="sidebar-header">
<h3>Conversations</h3> <h3>Conversations</h3>
<button id="sidebar-close" class="icon-btn">&times;</button> <button id="sidebar-close" class="icon-btn" aria-label="Close sidebar">&times;</button>
</div> </div>
<div id="conversation-list" class="conversation-list"></div> <div id="conversation-list" class="conversation-list"></div>
<div class="sidebar-footer"> <div class="sidebar-footer">
@@ -98,7 +98,7 @@
<div id="file-tree-panel" class="file-tree-panel"> <div id="file-tree-panel" class="file-tree-panel">
<div class="file-tree-header"> <div class="file-tree-header">
<h3>Project Files</h3> <h3>Project Files</h3>
<button id="file-tree-close" class="icon-btn">&times;</button> <button id="file-tree-close" class="icon-btn" aria-label="Close file tree">&times;</button>
</div> </div>
<div id="file-tree-body" class="file-tree-body"> <div id="file-tree-body" class="file-tree-body">
<div class="ftree-empty">No files yet.<br>AI-generated files appear here.</div> <div class="ftree-empty">No files yet.<br>AI-generated files appear here.</div>
@@ -118,7 +118,7 @@
<div class="file-viewer-actions"> <div class="file-viewer-actions">
<button id="file-viewer-edit" class="fv-btn">Edit</button> <button id="file-viewer-edit" class="fv-btn">Edit</button>
<button id="file-viewer-save" class="fv-btn fv-btn-save" style="display:none">Save</button> <button id="file-viewer-save" class="fv-btn fv-btn-save" style="display:none">Save</button>
<button id="file-viewer-close" class="icon-btn">&times;</button> <button id="file-viewer-close" class="icon-btn" aria-label="Close file viewer">&times;</button>
</div> </div>
</div> </div>
<div id="file-viewer-body" class="file-viewer-body"> <div id="file-viewer-body" class="file-viewer-body">
@@ -153,10 +153,10 @@
</div> </div>
<div class="input-row"> <div class="input-row">
<textarea id="message-input" placeholder="Type your message..." rows="1"></textarea> <textarea id="message-input" placeholder="Type your message..." rows="1"></textarea>
<button id="send-btn" class="send-btn" disabled> <button id="send-btn" class="send-btn" aria-label="Send message" disabled>
<svg viewBox="0 0 24 24" width="24" height="24"><path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z" fill="currentColor"/></svg> <svg viewBox="0 0 24 24" width="24" height="24"><path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z" fill="currentColor"/></svg>
</button> </button>
<button id="stop-btn" class="stop-btn" style="display:none"> <button id="stop-btn" class="stop-btn" aria-label="Stop generation" style="display:none">
<svg viewBox="0 0 24 24" width="24" height="24"><rect x="6" y="6" width="12" height="12" fill="currentColor" rx="2"/></svg> <svg viewBox="0 0 24 24" width="24" height="24"><rect x="6" y="6" width="12" height="12" fill="currentColor" rx="2"/></svg>
</button> </button>
</div> </div>
@@ -166,11 +166,11 @@
<div id="terminal-screen" class="screen"> <div id="terminal-screen" class="screen">
<div class="term-screen-container"> <div class="term-screen-container">
<div class="term-screen-header"> <div class="term-screen-header">
<button id="term-back-btn" class="icon-btn">&larr;</button> <button id="term-back-btn" class="icon-btn" aria-label="Back to chat">&larr;</button>
<h2>Terminal</h2> <h2>Terminal</h2>
<div class="term-screen-header-right"> <div class="term-screen-header-right">
<span id="term-cwd-display" class="term-cwd-display">~</span> <span id="term-cwd-display" class="term-cwd-display">~</span>
<button id="term-setup-tools-btn" class="icon-btn" title="Setup Dev Tools">&#128736;</button> <button id="term-setup-tools-btn" class="icon-btn" title="Setup Dev Tools" aria-label="Setup development tools">&#128736;</button>
</div> </div>
</div> </div>
<div id="term-output" class="term-output"></div> <div id="term-output" class="term-output"></div>
@@ -186,8 +186,8 @@
<div class="term-input-row"> <div class="term-input-row">
<span class="term-prompt">$</span> <span class="term-prompt">$</span>
<input type="text" id="term-input" class="term-input" placeholder="Enter command..." autocomplete="off" spellcheck="false"> <input type="text" id="term-input" class="term-input" placeholder="Enter command..." autocomplete="off" spellcheck="false">
<button id="term-run-btn" class="term-run-btn">&#9654;</button> <button id="term-run-btn" class="term-run-btn" aria-label="Run terminal command">&#9654;</button>
<button id="term-stop-btn" class="term-stop-btn" style="display:none">&#9632;</button> <button id="term-stop-btn" class="term-stop-btn" aria-label="Stop terminal command" style="display:none">&#9632;</button>
</div> </div>
</div> </div>
</div> </div>
@@ -234,7 +234,7 @@
<div id="settings-screen" class="screen"> <div id="settings-screen" class="screen">
<div class="settings-container"> <div class="settings-container">
<div class="settings-header"> <div class="settings-header">
<button id="settings-back" class="icon-btn">&larr;</button> <button id="settings-back" class="icon-btn" aria-label="Back to chat">&larr;</button>
<h2>Settings</h2> <h2>Settings</h2>
</div> </div>
<div class="settings-body"> <div class="settings-body">
@@ -338,13 +338,25 @@
</div> </div>
<div class="settings-section"> <div class="settings-section">
<h3>About</h3> <h3>About</h3>
<p class="about-text">Z.AI Chat v3.1.0</p> <p class="about-text">Z.AI Chat v3.2.0</p>
<p class="about-text">Built with Z.AI SDK &amp; GLM-5.1</p> <p class="about-text">Built with Z.AI SDK &amp; GLM-5.1</p>
<p class="about-text">Compatible with Android 15/16</p> <p class="about-text">Compatible with Android 15/16</p>
</div> </div>
<div class="settings-section"> <div class="settings-section">
<h3>Changelog</h3> <h3>Changelog</h3>
<ul class="changelog-list"> <ul class="changelog-list">
<li>
<span class="changelog-version">v3.2.0</span>
<span class="changelog-date">2026-05-20</span>
<ul>
<li><strong>Full In-App Virtual Env</strong> — new internal virtual environment setup with no external Termux app dependency</li>
<li><strong>Module Installer</strong> — new AI actions: <code>[VENV_SETUP]</code> and <code>[VENV_PIP_INSTALL package]</code></li>
<li><strong>QA Critical Fix</strong> — fixed invalid JS file check that broke PRoot install fallback</li>
<li><strong>Stability</strong> — thread-safe process map in ShellPlugin and safer wake lock handling</li>
<li><strong>Resource Safety</strong> — fixed high-risk stream/file descriptor leaks in bootstrap extraction and downloads</li>
<li><strong>Accessibility</strong> — improved icon button labels for better screen reader support</li>
</ul>
</li>
<li> <li>
<span class="changelog-version">v3.1.0</span> <span class="changelog-version">v3.1.0</span>
<span class="changelog-date">2026-05-20</span> <span class="changelog-date">2026-05-20</span>

View File

@@ -6,9 +6,9 @@
var STORAGE_KEY = 'zai_chat_'; var STORAGE_KEY = 'zai_chat_';
var MODE_PROMPTS = { var MODE_PROMPTS = {
chat: 'You are a helpful, knowledgeable AI assistant. Be concise and accurate.', chat: 'You are a helpful, knowledgeable AI assistant. Be concise and accurate.',
coding: 'You are an expert coding assistant. Write clean, efficient, well-documented code. Always use markdown code blocks with language tags. Explain your approach briefly before and after code. Handle edge cases and errors properly.', coding: 'You are an expert coding assistant with internal tool access. Use action tags when execution is needed: [CREATE_FILE ...][/CREATE_FILE], [RUN_COMMAND]...[/RUN_COMMAND], [BUILD_APK project], [INSTALL_APK path], [VENV_SETUP], [VENV_PIP_INSTALL package]. Keep responses concise and complete.',
brainstorm: 'You are a creative brainstorming partner. Generate diverse ideas, explore unconventional angles, build on concepts, and help evaluate trade-offs. Think freely and expansively. Present ideas in organized lists or tables when appropriate.', brainstorm: 'You are a creative brainstorming partner. Generate diverse ideas, explore unconventional angles, build on concepts, and help evaluate trade-offs. Think freely and expansively. Present ideas in organized lists or tables when appropriate.',
agentic: 'You are an autonomous agent with FULL control of an Android device. You have a real terminal, can build APKs, control the device UI via AutoGLM, and use Hermes agent tools.\n\n## File Operations:\n[CREATE_FILE path/to/file.ext]\ncontents\n[/CREATE_FILE]\n\n## Shell:\n[RUN_COMMAND]\ncommand\n[/RUN_COMMAND]\n\n## Build:\n[BUILD_APK project_name]\n[INSTALL_APK /path/to/file.apk]\n\n## AutoGLM Device Control:\n[DEVICE_TAP x y]\n[DEVICE_LONG_PRESS x y]\n[DEVICE_SWIPE startX startY endX endY]\n[DEVICE_TYPE text]\n[DEVICE_PRESS_BACK]\n[DEVICE_PRESS_HOME]\n[DEVICE_PRESS_RECENTS]\n[DEVICE_SCREENSHOT]\n[DEVICE_UI_TREE]\n[DEVICE_CLICK_TEXT button text]\n[DEVICE_CLICK_ID com.example:id/viewId]\n[DEVICE_LAUNCH com.example.app]\n[DEVICE_CURRENT_APP]\n\n## Hermes Agent:\n[HERMES_INSTALL]\n[HERMES_EXEC command]\n\n## Rules:\n1. Use [CREATE_FILE] for files, [BUILD_APK] to compile, [INSTALL_APK] to install\n2. Use [DEVICE_*] for device control — first [DEVICE_UI_TREE] to see screen, then interact\n3. Use [HERMES_EXEC] for Hermes capabilities — web search, terminal, skills, memory\n4. Generate COMPLETE files, never stubs\n5. For Java: package ai.z.app, target SDK 36\n6. Output [TASK_COMPLETE] ONLY when ALL work is done\n7. Never say done unless all files written, builds done, installs done' agentic: 'You are an autonomous agent with full control of this app sandbox: internal Linux bootstrap, terminal, virtual env, build tools, and device UI via AutoGLM.\n\n## File Operations:\n[CREATE_FILE path/to/file.ext]\ncontents\n[/CREATE_FILE]\n\n## Shell:\n[RUN_COMMAND]\ncommand\n[/RUN_COMMAND]\n\n## Build:\n[BUILD_APK project_name]\n[INSTALL_APK /path/to/file.apk]\n\n## In-App Virtual Env:\n[VENV_SETUP]\n[VENV_PIP_INSTALL package_name]\n\n## AutoGLM Device Control:\n[DEVICE_TAP x y]\n[DEVICE_LONG_PRESS x y]\n[DEVICE_SWIPE startX startY endX endY]\n[DEVICE_TYPE text]\n[DEVICE_PRESS_BACK]\n[DEVICE_PRESS_HOME]\n[DEVICE_PRESS_RECENTS]\n[DEVICE_SCREENSHOT]\n[DEVICE_UI_TREE]\n[DEVICE_CLICK_TEXT button text]\n[DEVICE_CLICK_ID com.example:id/viewId]\n[DEVICE_LAUNCH com.example.app]\n[DEVICE_CURRENT_APP]\n\n## Hermes Agent:\n[HERMES_INSTALL]\n[HERMES_EXEC command]\n\n## Rules:\n1. Prefer internal sandbox tools and virtual env first\n2. Use [CREATE_FILE], then [BUILD_APK], then [INSTALL_APK]\n3. Use [DEVICE_*] for device control; start with [DEVICE_UI_TREE]\n4. Generate complete files, never stubs\n5. For Java: package ai.z.app, target SDK 36\n6. Output [TASK_COMPLETE] only when all work is done'
}; };
var BUILD_SCRIPT = [ var BUILD_SCRIPT = [
@@ -623,7 +623,7 @@
if (state.streamingContent) { if (state.streamingContent) {
conv.messages.push({ role: 'assistant', content: state.streamingContent, _streaming: false }); conv.messages.push({ role: 'assistant', content: state.streamingContent, _streaming: false });
} }
var retryDiv = appendRetryMessage(err, requestBody, conv); appendRetryMessage(err, requestBody, conv);
} else if (state.streamingContent) { } else if (state.streamingContent) {
conv.messages.push({ role: 'assistant', content: state.streamingContent, _streaming: false }); conv.messages.push({ role: 'assistant', content: state.streamingContent, _streaming: false });
} }
@@ -1237,7 +1237,7 @@
async function installApk(path) { async function installApk(path) {
if (!Installer) { termPrint('[Installer plugin not available]', 'err'); return; } if (!Installer) { termPrint('[Installer plugin not available]', 'err'); return; }
try { try {
var result = await Installer.installApk({ path: path }); await Installer.installApk({ path: path });
termPrint('[APK install triggered: ' + path + ']', 'success'); termPrint('[APK install triggered: ' + path + ']', 'success');
} catch(e) { } catch(e) {
termPrint('[Install failed: ' + e.message + ']', 'err'); termPrint('[Install failed: ' + e.message + ']', 'err');
@@ -1441,6 +1441,8 @@
var deviceCurrentAppRegex = /\[DEVICE_CURRENT_APP\]/gi; var deviceCurrentAppRegex = /\[DEVICE_CURRENT_APP\]/gi;
var hermesInstallRegex = /\[HERMES_INSTALL\]/gi; var hermesInstallRegex = /\[HERMES_INSTALL\]/gi;
var hermesExecRegex = /\[HERMES_EXEC\s+([^\]]+)\]/gi; var hermesExecRegex = /\[HERMES_EXEC\s+([^\]]+)\]/gi;
var venvSetupRegex = /\[VENV_SETUP\]/gi;
var venvPipInstallRegex = /\[VENV_PIP_INSTALL\s+([^\]]+)\]/gi;
var codeBlockFileRegex = /```(\w+)\s*\n([\s\S]*?)```/gi; var codeBlockFileRegex = /```(\w+)\s*\n([\s\S]*?)```/gi;
var match; var match;
@@ -1501,6 +1503,12 @@
while ((match = hermesExecRegex.exec(content)) !== null) { while ((match = hermesExecRegex.exec(content)) !== null) {
actions.push({ type: 'hermes_exec', command: match[1].trim() }); actions.push({ type: 'hermes_exec', command: match[1].trim() });
} }
while ((match = venvSetupRegex.exec(content)) !== null) {
actions.push({ type: 'venv_setup' });
}
while ((match = venvPipInstallRegex.exec(content)) !== null) {
actions.push({ type: 'venv_pip_install', packages: match[1].trim() });
}
while ((match = codeBlockFileRegex.exec(content)) !== null) { while ((match = codeBlockFileRegex.exec(content)) !== null) {
var lang = match[1]; var lang = match[1];
var code = match[2]; var code = match[2];
@@ -1673,7 +1681,8 @@
content.indexOf('[BUILD_APK') >= 0 || content.indexOf('[BUILD_APK') >= 0 ||
content.indexOf('[INSTALL_APK') >= 0 || content.indexOf('[INSTALL_APK') >= 0 ||
content.indexOf('[DEVICE_') >= 0 || content.indexOf('[DEVICE_') >= 0 ||
content.indexOf('[HERMES_') >= 0; content.indexOf('[HERMES_') >= 0 ||
content.indexOf('[VENV_') >= 0;
var hasCodeBlock = content.indexOf('```') >= 0; var hasCodeBlock = content.indexOf('```') >= 0;
if (!hasCodeBlock && !hasAction && content.length < 300) return true; if (!hasCodeBlock && !hasAction && content.length < 300) return true;
if ((content.match(/```/g) || []).length % 2 !== 0) return false; if ((content.match(/```/g) || []).length % 2 !== 0) return false;
@@ -1895,7 +1904,7 @@
var saveBtn = $('#file-viewer-save'); var saveBtn = $('#file-viewer-save');
var editBtn = $('#file-viewer-edit'); var editBtn = $('#file-viewer-edit');
if (!viewer) return; if (!viewer || !nameEl || !langEl || !contentEl || !textareaEl || !bodyEl || !editorEl || !saveBtn || !editBtn) return;
nameEl.textContent = file.path; nameEl.textContent = file.path;
langEl.textContent = file.language; langEl.textContent = file.language;
contentEl.textContent = file.content; contentEl.textContent = file.content;
@@ -2151,8 +2160,9 @@
} }
async function tryProotExec(prootCmd, prefixUsr, pkgBin, aptBin) { async function tryProotExec(prootCmd, prefixUsr, pkgBin, aptBin) {
var hasPkg = await shellExec('test -f "' + pkgBin + '"', termState.homeDir, false);
var pkgCmd = 'sh /usr/bin/pkg update -y 2>&1 && sh /usr/bin/pkg install -y aapt2 ecj dx apksigner 2>&1'; var pkgCmd = 'sh /usr/bin/pkg update -y 2>&1 && sh /usr/bin/pkg install -y aapt2 ecj dx apksigner 2>&1';
if (!new File(pkgBin).exists) pkgCmd = 'sh /usr/bin/apt update -y 2>&1 && sh /usr/bin/apt install -y aapt2 ecj dx apksigner 2>&1'; if (hasPkg.exitCode !== 0) pkgCmd = 'sh /usr/bin/apt update -y 2>&1 && sh /usr/bin/apt install -y aapt2 ecj dx apksigner 2>&1';
var wrappedCmd = prootCmd + ' -0 -b /dev -b /proc -b /sys -r ' + prefixUsr + ' /bin/sh -c \'' + pkgCmd.replace(/'/g, "'\\''") + '\''; var wrappedCmd = prootCmd + ' -0 -b /dev -b /proc -b /sys -r ' + prefixUsr + ' /bin/sh -c \'' + pkgCmd.replace(/'/g, "'\\''") + '\'';
termPrint('[*] Running pkg via PRoot...', 'info'); termPrint('[*] Running pkg via PRoot...', 'info');
@@ -2186,7 +2196,7 @@
termPrint('[OK] Termux detected! Sending install command...', 'success'); termPrint('[OK] Termux detected! Sending install command...', 'success');
try { try {
var runResult = await Bootstrap.runInTermux({command: 'pkg update -y && pkg install -y aapt2 ecj dx apksigner'}); await Bootstrap.runInTermux({command: 'pkg update -y && pkg install -y aapt2 ecj dx apksigner'});
termPrint('[*] Command sent to Termux. Waiting...', 'info'); termPrint('[*] Command sent to Termux. Waiting...', 'info');
await new Promise(function(r) { setTimeout(r, 15000); }); await new Promise(function(r) { setTimeout(r, 15000); });
if (await toolsReady()) return true; if (await toolsReady()) return true;
@@ -2314,8 +2324,9 @@
var hasCommands = actions.some(function(a) { return a.type === 'run_command'; }); var hasCommands = actions.some(function(a) { return a.type === 'run_command'; });
var hasDevice = actions.some(function(a) { return a.type && a.type.indexOf('device_') === 0; }); var hasDevice = actions.some(function(a) { return a.type && a.type.indexOf('device_') === 0; });
var hasHermes = actions.some(function(a) { return a.type && a.type.indexOf('hermes_') === 0; }); var hasHermes = actions.some(function(a) { return a.type && a.type.indexOf('hermes_') === 0; });
var hasVenv = actions.some(function(a) { return a.type && a.type.indexOf('venv_') === 0; });
if (!hasFiles && !hasBuild && !hasInstall && !hasCommands && !hasDevice && !hasHermes) return; if (!hasFiles && !hasBuild && !hasInstall && !hasCommands && !hasDevice && !hasHermes && !hasVenv) return;
_agenticRetryCount = 0; _agenticRetryCount = 0;
var resultLog = []; var resultLog = [];
@@ -2376,6 +2387,21 @@
} }
} }
if (hasVenv) {
for (var v = 0; v < actions.length; v++) {
var vAct = actions[v];
if (vAct.type.indexOf('venv_') !== 0) continue;
try {
var venvResult = await executeVirtualEnvAction(vAct);
resultLog.push(venvResult);
termPrint(venvResult, '');
} catch(e) {
resultLog.push('VENV_ERROR: ' + e.message);
termPrint('[!] Venv: ' + e.message, 'err');
}
}
}
if (hasBuild) { if (hasBuild) {
showStatusToast('Building APK...', 'info'); showStatusToast('Building APK...', 'info');
var toolsReady = await ensureBuildTools(); var toolsReady = await ensureBuildTools();
@@ -2485,6 +2511,32 @@
} }
} }
async function executeVirtualEnvAction(action) {
var bsPlugin = window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.Bootstrap;
if (!bsPlugin) throw new Error('Bootstrap plugin not available');
var venvPath = termState.homeDir ? (termState.homeDir.replace(/\/home$/, '') + '/venv/default') : '';
switch (action.type) {
case 'venv_setup': {
showStatusToast('Setting up in-app virtual environment...', 'info');
var setup = await bsPlugin.setupVirtualEnv({ venv: venvPath });
termState.venvPath = setup.venv;
return '[VENV] Ready: ' + setup.venv;
}
case 'venv_pip_install': {
if (!termState.venvPath) {
var init = await bsPlugin.setupVirtualEnv({ venv: venvPath });
termState.venvPath = init.venv;
}
showStatusToast('Installing module(s): ' + action.packages, 'info');
var installed = await bsPlugin.venvPipInstall({ venv: termState.venvPath, packages: action.packages });
return '[VENV] pip install ' + action.packages + '\n' + (installed.output || '').substring(0, 1200);
}
default:
return '[VENV] Unknown action: ' + action.type;
}
}
async function autoDeployFile(action) { async function autoDeployFile(action) {
var path = action.path; var path = action.path;
if (!path.startsWith('/')) { if (!path.startsWith('/')) {
@@ -2716,7 +2768,7 @@
}); });
try { try {
var result = await Bootstrap.install(); await Bootstrap.install();
progressText.textContent = 'Fixing file permissions...'; progressText.textContent = 'Fixing file permissions...';
try { await Bootstrap.fixPermissions(); } catch(e) {} try { await Bootstrap.fixPermissions(); } catch(e) {}