v1.3.0: Full terminal with shell execution, APK build/install, AI deploy pipeline
This commit is contained in:
14
README.md
14
README.md
@@ -631,6 +631,20 @@ data: [DONE]
|
||||
|
||||
## Changelog
|
||||
|
||||
### v1.3.0 (2026-05-19)
|
||||
- **Full Terminal** — interactive shell on your Android device, execute real commands
|
||||
- **Deploy Files** — AI-generated code saved to device with one-tap Deploy button
|
||||
- **Build APK** — compile Android projects on-device with aapt2 + d8 + ecj (via Termux)
|
||||
- **Install APK** — install built APKs directly from the app via PackageInstaller
|
||||
- **AI Action Parser** — detects `[CREATE_FILE]`, `[RUN_COMMAND]`, `[BUILD_APK]`, `[INSTALL_APK]`
|
||||
- **Quick Commands** — toolbar with ls, pwd, cat, mkdir, tools check, system info
|
||||
- **Command History** — arrow key navigation through previous commands
|
||||
- **Built-in Commands** — `help`, `sysinfo`, `create <name>`, `install <apk>`, `clear`, `setup`
|
||||
- **Dev Setup Screen** — bootstrap on-device build tools (aapt2, d8, ecj, apksigner)
|
||||
- **Project Scaffold** — `create myapp` generates full Android project structure
|
||||
- **Enhanced Agentic Mode** — AI understands on-device build capabilities
|
||||
- Native plugins: `ShellPlugin` (command execution, file I/O) + `InstallerPlugin` (APK install)
|
||||
|
||||
### v1.2.4 (2026-05-19)
|
||||
- **Save button** on every code block — downloads code as a file to your device
|
||||
- **Copy & Save** buttons on every AI response message
|
||||
|
||||
@@ -7,8 +7,8 @@ android {
|
||||
applicationId "ai.z.chat"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 6
|
||||
versionName "1.2.4"
|
||||
versionCode 7
|
||||
versionName "1.3.0"
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
aaptOptions {
|
||||
ignoreAssetsPattern = '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~'
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
|
||||
109
android/app/src/main/java/ai/z/chat/InstallerPlugin.java
Normal file
109
android/app/src/main/java/ai/z/chat/InstallerPlugin.java
Normal file
@@ -0,0 +1,109 @@
|
||||
package ai.z.chat;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.net.Uri;
|
||||
import android.os.Build;
|
||||
import android.util.Log;
|
||||
|
||||
import androidx.core.content.FileProvider;
|
||||
|
||||
import com.getcapacitor.JSObject;
|
||||
import com.getcapacitor.Plugin;
|
||||
import com.getcapacitor.PluginCall;
|
||||
import com.getcapacitor.PluginMethod;
|
||||
import com.getcapacitor.annotation.CapacitorPlugin;
|
||||
|
||||
import java.io.File;
|
||||
|
||||
@CapacitorPlugin(name = "Installer")
|
||||
public class InstallerPlugin extends Plugin {
|
||||
private static final String TAG = "InstallerPlugin";
|
||||
|
||||
@PluginMethod
|
||||
public void installApk(PluginCall call) {
|
||||
String path = call.getString("path", "");
|
||||
if (path.isEmpty()) {
|
||||
call.reject("No path provided");
|
||||
return;
|
||||
}
|
||||
|
||||
File apkFile = new File(path);
|
||||
if (!apkFile.exists()) {
|
||||
call.reject("APK file not found: " + path);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
Context context = getContext();
|
||||
Uri apkUri = FileProvider.getUriForFile(
|
||||
context,
|
||||
context.getPackageName() + ".fileprovider",
|
||||
apkFile
|
||||
);
|
||||
|
||||
Intent intent = new Intent(Intent.ACTION_VIEW);
|
||||
intent.setDataAndType(apkUri, "application/vnd.android.package-archive");
|
||||
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
|
||||
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
|
||||
|
||||
context.startActivity(intent);
|
||||
|
||||
call.resolve(new JSObject()
|
||||
.put("installed", true)
|
||||
.put("path", apkFile.getAbsolutePath())
|
||||
.put("size", apkFile.length()));
|
||||
} catch (Exception e) {
|
||||
call.reject("Install failed: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
public void getDeviceInfo(PluginCall call) {
|
||||
JSObject info = new JSObject();
|
||||
info.put("sdk", Build.VERSION.SDK_INT);
|
||||
info.put("release", Build.VERSION.RELEASE);
|
||||
info.put("device", Build.DEVICE);
|
||||
info.put("model", Build.MODEL);
|
||||
info.put("manufacturer", Build.MANUFACTURER);
|
||||
info.put("abi", Build.SUPPORTED_ABIS.length > 0 ? Build.SUPPORTED_ABIS[0] : "unknown");
|
||||
info.put("package", getContext().getPackageName());
|
||||
info.put("filesDir", getContext().getFilesDir().getAbsolutePath());
|
||||
call.resolve(info);
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
public void openFile(PluginCall call) {
|
||||
String path = call.getString("path", "");
|
||||
String mimeType = call.getString("mimeType", "*/*");
|
||||
if (path.isEmpty()) {
|
||||
call.reject("No path provided");
|
||||
return;
|
||||
}
|
||||
|
||||
File file = new File(path);
|
||||
if (!file.exists()) {
|
||||
call.reject("File not found: " + path);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
Context context = getContext();
|
||||
Uri uri = FileProvider.getUriForFile(
|
||||
context,
|
||||
context.getPackageName() + ".fileprovider",
|
||||
file
|
||||
);
|
||||
|
||||
Intent intent = new Intent(Intent.ACTION_VIEW);
|
||||
intent.setDataAndType(uri, mimeType);
|
||||
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
|
||||
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
|
||||
|
||||
context.startActivity(intent);
|
||||
call.resolve(new JSObject().put("opened", true));
|
||||
} catch (Exception e) {
|
||||
call.reject("Open failed: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,4 +2,13 @@ package ai.z.chat;
|
||||
|
||||
import com.getcapacitor.BridgeActivity;
|
||||
|
||||
public class MainActivity extends BridgeActivity {}
|
||||
import android.os.Bundle;
|
||||
|
||||
public class MainActivity extends BridgeActivity {
|
||||
@Override
|
||||
protected void onCreate(Bundle savedInstanceState) {
|
||||
registerPlugin(ShellPlugin.class);
|
||||
registerPlugin(InstallerPlugin.class);
|
||||
super.onCreate(savedInstanceState);
|
||||
}
|
||||
}
|
||||
|
||||
353
android/app/src/main/java/ai/z/chat/ShellPlugin.java
Normal file
353
android/app/src/main/java/ai/z/chat/ShellPlugin.java
Normal file
@@ -0,0 +1,353 @@
|
||||
package ai.z.chat;
|
||||
|
||||
import android.util.Log;
|
||||
|
||||
import com.getcapacitor.JSObject;
|
||||
import com.getcapacitor.Plugin;
|
||||
import com.getcapacitor.PluginCall;
|
||||
import com.getcapacitor.PluginMethod;
|
||||
import com.getcapacitor.annotation.CapacitorPlugin;
|
||||
import com.getcapacitor.annotation.Permission;
|
||||
|
||||
import java.io.BufferedReader;
|
||||
import java.io.File;
|
||||
import java.io.InputStreamReader;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
@CapacitorPlugin(name = "Shell",
|
||||
permissions = {
|
||||
@Permission(strings = {}, alias = "shell")
|
||||
}
|
||||
)
|
||||
public class ShellPlugin extends Plugin {
|
||||
private static final String TAG = "ShellPlugin";
|
||||
private final Map<String, Process> activeProcesses = new HashMap<>();
|
||||
private String currentCwd = null;
|
||||
private String homeDir = null;
|
||||
private String toolsDir = null;
|
||||
private String projectsDir = null;
|
||||
|
||||
@Override
|
||||
public void load() {
|
||||
homeDir = getContext().getFilesDir().getAbsolutePath();
|
||||
toolsDir = homeDir + "/tools";
|
||||
projectsDir = homeDir + "/projects";
|
||||
currentCwd = homeDir;
|
||||
new File(toolsDir).mkdirs();
|
||||
new File(projectsDir).mkdirs();
|
||||
new File(homeDir + "/bin").mkdirs();
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
public void execute(PluginCall call) {
|
||||
String command = call.getString("command", "");
|
||||
String cwd = call.getString("cwd", currentCwd);
|
||||
boolean stream = call.getBoolean("stream", false);
|
||||
int timeout = call.getInt("timeout", 30000);
|
||||
|
||||
if (command.isEmpty()) {
|
||||
call.reject("No command provided");
|
||||
return;
|
||||
}
|
||||
|
||||
if (cwd == null || cwd.isEmpty()) cwd = homeDir;
|
||||
|
||||
try {
|
||||
String[] env = buildEnv();
|
||||
ProcessBuilder pb = new ProcessBuilder("sh", "-c", command);
|
||||
pb.directory(new File(cwd));
|
||||
pb.environment().putAll(toEnvMap(env));
|
||||
pb.redirectErrorStream(true);
|
||||
|
||||
Process process = pb.start();
|
||||
String processId = String.valueOf(System.currentTimeMillis());
|
||||
activeProcesses.put(processId, process);
|
||||
|
||||
if (stream) {
|
||||
streamOutput(call, process, processId, cwd);
|
||||
} else {
|
||||
executeBlocking(call, process, processId, timeout);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
call.reject("Execution failed: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
public void kill(PluginCall call) {
|
||||
String processId = call.getString("pid", "");
|
||||
Process p = activeProcesses.remove(processId);
|
||||
if (p != null) {
|
||||
p.destroyForcibly();
|
||||
call.resolve(new JSObject().put("killed", true));
|
||||
} else {
|
||||
call.resolve(new JSObject().put("killed", false));
|
||||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
public void getCwd(PluginCall call) {
|
||||
call.resolve(new JSObject().put("cwd", currentCwd != null ? currentCwd : homeDir));
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
public void setCwd(PluginCall call) {
|
||||
String cwd = call.getString("cwd", homeDir);
|
||||
if (new File(cwd).isDirectory()) {
|
||||
currentCwd = cwd;
|
||||
call.resolve(new JSObject().put("cwd", currentCwd));
|
||||
} else {
|
||||
call.reject("Not a directory: " + cwd);
|
||||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
public void getEnv(PluginCall call) {
|
||||
JSObject env = new JSObject();
|
||||
env.put("HOME", homeDir);
|
||||
env.put("TOOLS", toolsDir);
|
||||
env.put("PROJECTS", projectsDir);
|
||||
env.put("CWD", currentCwd);
|
||||
env.put("PREFIX", homeDir + "/tools/usr");
|
||||
call.resolve(env);
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
public void writeFile(PluginCall call) {
|
||||
String path = call.getString("path", "");
|
||||
String content = call.getString("content", "");
|
||||
|
||||
if (path.isEmpty()) {
|
||||
call.reject("No path provided");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
File file = new File(path);
|
||||
File parent = file.getParentFile();
|
||||
if (parent != null && !parent.exists()) parent.mkdirs();
|
||||
java.io.FileWriter writer = new java.io.FileWriter(file);
|
||||
writer.write(content);
|
||||
writer.close();
|
||||
call.resolve(new JSObject().put("path", file.getAbsolutePath()).put("size", file.length()));
|
||||
} catch (Exception e) {
|
||||
call.reject("Write failed: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
public void readFile(PluginCall call) {
|
||||
String path = call.getString("path", "");
|
||||
if (path.isEmpty()) {
|
||||
call.reject("No path provided");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
File file = new File(path);
|
||||
if (!file.exists()) {
|
||||
call.reject("File not found: " + path);
|
||||
return;
|
||||
}
|
||||
BufferedReader reader = new BufferedReader(new InputStreamReader(new java.io.FileInputStream(file), "UTF-8"));
|
||||
StringBuilder sb = new StringBuilder();
|
||||
String line;
|
||||
while ((line = reader.readLine()) != null) {
|
||||
sb.append(line).append("\n");
|
||||
}
|
||||
reader.close();
|
||||
call.resolve(new JSObject().put("content", sb.toString()).put("path", file.getAbsolutePath()));
|
||||
} catch (Exception e) {
|
||||
call.reject("Read failed: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
public void mkdirs(PluginCall call) {
|
||||
String path = call.getString("path", "");
|
||||
if (path.isEmpty()) {
|
||||
call.reject("No path provided");
|
||||
return;
|
||||
}
|
||||
boolean created = new File(path).mkdirs();
|
||||
call.resolve(new JSObject().put("created", created).put("path", new File(path).getAbsolutePath()));
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
public void listDir(PluginCall call) {
|
||||
String path = call.getString("path", currentCwd != null ? currentCwd : homeDir);
|
||||
File dir = new File(path);
|
||||
if (!dir.isDirectory()) {
|
||||
call.reject("Not a directory: " + path);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
com.getcapacitor.JSArray files = new com.getcapacitor.JSArray();
|
||||
File[] children = dir.listFiles();
|
||||
if (children != null) {
|
||||
for (File f : children) {
|
||||
JSObject entry = new JSObject();
|
||||
entry.put("name", f.getName());
|
||||
entry.put("path", f.getAbsolutePath());
|
||||
entry.put("isDirectory", f.isDirectory());
|
||||
entry.put("size", f.length());
|
||||
files.put(entry);
|
||||
}
|
||||
}
|
||||
call.resolve(new JSObject().put("files", files).put("path", dir.getAbsolutePath()));
|
||||
} catch (Exception e) {
|
||||
call.reject("List failed: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
public void deleteFile(PluginCall call) {
|
||||
String path = call.getString("path", "");
|
||||
if (path.isEmpty()) {
|
||||
call.reject("No path provided");
|
||||
return;
|
||||
}
|
||||
boolean deleted = deleteRecursive(new File(path));
|
||||
call.resolve(new JSObject().put("deleted", deleted));
|
||||
}
|
||||
|
||||
private String[] buildEnv() {
|
||||
String toolsBin = toolsDir + "/bin";
|
||||
String toolsUsrBin = toolsDir + "/usr/bin";
|
||||
String appBin = homeDir + "/bin";
|
||||
String systemPath = System.getenv("PATH");
|
||||
String path = appBin + ":" + toolsBin + ":" + toolsUsrBin + ":" + systemPath;
|
||||
|
||||
return new String[]{
|
||||
"HOME=" + homeDir,
|
||||
"PATH=" + path,
|
||||
"PREFIX=" + toolsDir + "/usr",
|
||||
"TMPDIR=" + homeDir + "/tmp",
|
||||
"TERM=xterm-256color",
|
||||
"LANG=en_US.UTF-8",
|
||||
"ANDROID_HOME=" + toolsDir,
|
||||
"ANDROID_SDK_ROOT=" + toolsDir,
|
||||
"JAVA_HOME=" + toolsDir + "/java",
|
||||
"PROJECTS=" + projectsDir
|
||||
};
|
||||
}
|
||||
|
||||
private Map<String, String> toEnvMap(String[] envPairs) {
|
||||
Map<String, String> map = new HashMap<>();
|
||||
for (String pair : envPairs) {
|
||||
int idx = pair.indexOf('=');
|
||||
if (idx > 0) {
|
||||
map.put(pair.substring(0, idx), pair.substring(idx + 1));
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
private void executeBlocking(PluginCall call, Process process, String processId, int timeout) {
|
||||
try {
|
||||
BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()));
|
||||
StringBuilder output = new StringBuilder();
|
||||
String line;
|
||||
while ((line = reader.readLine()) != null) {
|
||||
output.append(line).append("\n");
|
||||
}
|
||||
|
||||
boolean finished = process.waitFor(timeout / 1000, java.util.concurrent.TimeUnit.SECONDS);
|
||||
int exitCode = finished ? process.exitValue() : -1;
|
||||
|
||||
activeProcesses.remove(processId);
|
||||
|
||||
JSObject result = new JSObject();
|
||||
result.put("output", output.toString());
|
||||
result.put("exitCode", exitCode);
|
||||
result.put("pid", processId);
|
||||
if (exitCode == 0 && output.toString().contains("cd ")) {
|
||||
updateCwdFromOutput(output.toString());
|
||||
}
|
||||
call.resolve(result);
|
||||
} catch (Exception e) {
|
||||
activeProcesses.remove(processId);
|
||||
call.reject("Execution error: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private void streamOutput(PluginCall call, Process process, String processId, String cwd) {
|
||||
call.setKeepAlive(true);
|
||||
String streamId = "shell:" + processId;
|
||||
|
||||
Thread readerThread = new Thread(() -> {
|
||||
try {
|
||||
BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()));
|
||||
char[] buffer = new char[4096];
|
||||
int read;
|
||||
while ((read = reader.read(buffer)) != -1) {
|
||||
String chunk = new String(buffer, 0, read);
|
||||
JSObject event = new JSObject();
|
||||
event.put("data", chunk);
|
||||
event.put("pid", processId);
|
||||
notifyListeners(streamId, event);
|
||||
}
|
||||
|
||||
int exitCode;
|
||||
try {
|
||||
exitCode = process.waitFor();
|
||||
} catch (InterruptedException e) {
|
||||
process.destroyForcibly();
|
||||
exitCode = -1;
|
||||
}
|
||||
|
||||
JSObject doneEvent = new JSObject();
|
||||
doneEvent.put("data", "\n[Process exited with code " + exitCode + "]\n");
|
||||
doneEvent.put("pid", processId);
|
||||
doneEvent.put("exitCode", exitCode);
|
||||
doneEvent.put("done", true);
|
||||
notifyListeners(streamId, doneEvent);
|
||||
|
||||
activeProcesses.remove(processId);
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Stream error", e);
|
||||
JSObject errEvent = new JSObject();
|
||||
errEvent.put("data", "\n[Error: " + e.getMessage() + "]\n");
|
||||
errEvent.put("pid", processId);
|
||||
errEvent.put("done", true);
|
||||
errEvent.put("exitCode", -1);
|
||||
notifyListeners(streamId, errEvent);
|
||||
activeProcesses.remove(processId);
|
||||
}
|
||||
});
|
||||
readerThread.setDaemon(true);
|
||||
readerThread.start();
|
||||
|
||||
JSObject result = new JSObject();
|
||||
result.put("pid", processId);
|
||||
result.put("streamId", streamId);
|
||||
call.resolve(result);
|
||||
}
|
||||
|
||||
private void updateCwdFromOutput(String output) {
|
||||
for (String line : output.split("\n")) {
|
||||
line = line.trim();
|
||||
if (line.startsWith("cd ") && !line.contains("&&") && !line.contains(";")) {
|
||||
String target = line.substring(3).trim();
|
||||
File targetDir = target.startsWith("/") ? new File(target) : new File(currentCwd, target);
|
||||
if (targetDir.isDirectory()) {
|
||||
currentCwd = targetDir.getAbsolutePath();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private boolean deleteRecursive(File file) {
|
||||
if (file.isDirectory()) {
|
||||
File[] children = file.listFiles();
|
||||
if (children != null) {
|
||||
for (File child : children) {
|
||||
deleteRecursive(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
return file.delete();
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<paths xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<external-path name="my_images" path="." />
|
||||
<cache-path name="my_cache_images" path="." />
|
||||
<external-path name="external" path="." />
|
||||
<cache-path name="cache" path="." />
|
||||
<files-path name="files" path="." />
|
||||
<files-path name="projects" path="projects/" />
|
||||
<files-path name="tools" path="tools/" />
|
||||
</paths>
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "zai-chat",
|
||||
"version": "1.2.3",
|
||||
"version": "1.3.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "zai-chat",
|
||||
"version": "1.2.3",
|
||||
"version": "1.3.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@capacitor/android": "^8.3.4",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "zai-chat",
|
||||
"version": "1.2.4",
|
||||
"version": "1.3.0",
|
||||
"description": "Z.AI Chat - Full stack AI chat powered by GLM Coding Plan",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
|
||||
@@ -906,3 +906,220 @@ a:hover { text-decoration: underline; }
|
||||
.mode-cards { gap: 8px; }
|
||||
.mode-card { padding: 10px 8px; }
|
||||
}
|
||||
|
||||
/* Terminal Full Screen */
|
||||
.term-screen-container {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #0d0d0d;
|
||||
}
|
||||
.term-screen-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
background: var(--bg-secondary);
|
||||
border-bottom: 1px solid var(--border);
|
||||
min-height: 52px;
|
||||
}
|
||||
.term-screen-header h2 { font-size: 16px; flex: 1; }
|
||||
.term-screen-header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.term-cwd-display {
|
||||
font-family: 'Fira Code', 'JetBrains Mono', monospace;
|
||||
font-size: 11px;
|
||||
color: var(--success);
|
||||
background: rgba(46, 213, 115, 0.1);
|
||||
padding: 3px 8px;
|
||||
border-radius: 4px;
|
||||
max-width: 180px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.term-output {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 12px;
|
||||
font-family: 'Fira Code', 'JetBrains Mono', 'Cascadia Code', monospace;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
color: #e0e0e0;
|
||||
background: #0d0d0d;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
word-break: break-all;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.term-output .term-line { margin-bottom: 1px; }
|
||||
.term-output .term-cmd { color: var(--success); font-weight: 700; }
|
||||
.term-output .term-err { color: var(--danger); }
|
||||
.term-output .term-info { color: var(--accent); }
|
||||
.term-output .term-success { color: var(--success); }
|
||||
.term-output .term-warning { color: var(--warning); }
|
||||
.term-output .term-path { color: var(--accent); text-decoration: underline; }
|
||||
.term-input-area {
|
||||
padding: 8px 12px 16px;
|
||||
background: var(--bg-secondary);
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.term-quick-actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
margin-bottom: 8px;
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.term-quick-actions::-webkit-scrollbar { display: none; }
|
||||
.term-quick-btn {
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text-secondary);
|
||||
padding: 4px 10px;
|
||||
border-radius: 6px;
|
||||
font-size: 11px;
|
||||
font-family: 'Fira Code', monospace;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.term-quick-btn:active { background: var(--accent-dim); color: var(--accent); }
|
||||
.term-input-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.term-prompt {
|
||||
color: var(--success);
|
||||
font-family: 'Fira Code', monospace;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.term-input {
|
||||
flex: 1;
|
||||
background: #0d0d0d;
|
||||
border: 1px solid var(--border);
|
||||
color: #e0e0e0;
|
||||
padding: 10px 14px;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
font-family: 'Fira Code', monospace;
|
||||
outline: none;
|
||||
}
|
||||
.term-input:focus { border-color: var(--accent); }
|
||||
.term-run-btn, .term-stop-btn {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
background: var(--success);
|
||||
color: white;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.term-stop-btn { background: var(--danger); }
|
||||
|
||||
.mode-btn-term {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Deploy button in messages */
|
||||
.deploy-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
background: linear-gradient(135deg, var(--success), var(--accent));
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 6px 14px;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
margin-top: 6px;
|
||||
transition: all var(--transition);
|
||||
}
|
||||
.deploy-btn:hover { opacity: 0.9; transform: scale(1.02); }
|
||||
.deploy-btn:active { transform: scale(0.98); }
|
||||
|
||||
.install-apk-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 6px 14px;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
margin-top: 4px;
|
||||
margin-left: 6px;
|
||||
}
|
||||
|
||||
/* Dev Setup Screen */
|
||||
.devsetup-status {
|
||||
text-align: center;
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.devsetup-status p { margin-bottom: 6px; }
|
||||
.devsetup-progress-bar {
|
||||
width: 100%;
|
||||
height: 8px;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.devsetup-progress-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, var(--accent), var(--success));
|
||||
border-radius: 4px;
|
||||
width: 0%;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
.devsetup-progress-text {
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
/* Command history popup */
|
||||
.term-history {
|
||||
position: absolute;
|
||||
bottom: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
z-index: 10;
|
||||
display: none;
|
||||
}
|
||||
.term-history.visible { display: block; }
|
||||
.term-history-item {
|
||||
padding: 8px 12px;
|
||||
font-size: 12px;
|
||||
font-family: 'Fira Code', monospace;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.term-history-item:hover { background: var(--accent-dim); color: var(--text-primary); }
|
||||
|
||||
|
||||
@@ -114,6 +114,7 @@
|
||||
<button class="mode-btn" data-mode="coding">Coding</button>
|
||||
<button class="mode-btn" data-mode="brainstorm">Brainstorm</button>
|
||||
<button class="mode-btn" data-mode="agentic">Agentic</button>
|
||||
<button class="mode-btn mode-btn-term" data-mode="terminal" style="background:var(--success);border-color:var(--success);color:white">▪ Term</button>
|
||||
</div>
|
||||
<div class="input-row">
|
||||
<textarea id="message-input" placeholder="Type your message..." rows="1"></textarea>
|
||||
@@ -127,6 +128,61 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="terminal-screen" class="screen">
|
||||
<div class="term-screen-container">
|
||||
<div class="term-screen-header">
|
||||
<button id="term-back-btn" class="icon-btn">←</button>
|
||||
<h2>Terminal</h2>
|
||||
<div class="term-screen-header-right">
|
||||
<span id="term-cwd-display" class="term-cwd-display">~</span>
|
||||
<button id="term-setup-tools-btn" class="icon-btn" title="Setup Dev Tools">🛠</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="term-output" class="term-output"></div>
|
||||
<div class="term-input-area">
|
||||
<div class="term-quick-actions">
|
||||
<button class="term-quick-btn" data-cmd="ls -la">ls</button>
|
||||
<button class="term-quick-btn" data-cmd="pwd">pwd</button>
|
||||
<button class="term-quick-btn" data-cmd="cat ">cat</button>
|
||||
<button class="term-quick-btn" data-cmd="mkdir -p ">mkdir</button>
|
||||
<button class="term-quick-btn" data-cmd="which aapt2 java ecj d8 2>/dev/null">tools</button>
|
||||
<button class="term-quick-btn" data-cmd="df -h . && free -h 2>/dev/null">sys</button>
|
||||
</div>
|
||||
<div class="term-input-row">
|
||||
<span class="term-prompt">$</span>
|
||||
<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">▶</button>
|
||||
<button id="term-stop-btn" class="term-stop-btn" style="display:none">■</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="devsetup-screen" class="screen">
|
||||
<div class="setup-container">
|
||||
<div class="logo-area">
|
||||
<div class="logo-icon" style="background:linear-gradient(135deg, #2ed573, #6c63ff)">🛠</div>
|
||||
<h1>Dev Environment</h1>
|
||||
<p class="subtitle">Set up on-device build tools</p>
|
||||
</div>
|
||||
<div id="devsetup-status" class="devsetup-status">
|
||||
<p>Downloads build tools to compile & install APKs directly on your device.</p>
|
||||
<p>Required: ~50MB download (aapt2, d8, ecj, android.jar, apksigner)</p>
|
||||
</div>
|
||||
<div id="devsetup-progress" style="display:none">
|
||||
<div class="devsetup-progress-bar">
|
||||
<div id="devsetup-progress-fill" class="devsetup-progress-fill"></div>
|
||||
</div>
|
||||
<p id="devsetup-progress-text" class="devsetup-progress-text">Preparing...</p>
|
||||
</div>
|
||||
<button id="devsetup-install-btn" class="btn-primary">
|
||||
<span class="btn-text">Install Dev Tools</span>
|
||||
<span class="btn-loader" style="display:none"></span>
|
||||
</button>
|
||||
<button id="devsetup-back-btn" class="btn-secondary" style="margin-top:12px">Back to Terminal</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="settings-screen" class="screen">
|
||||
<div class="settings-container">
|
||||
<div class="settings-header">
|
||||
@@ -197,13 +253,32 @@
|
||||
</div>
|
||||
<div class="settings-section">
|
||||
<h3>About</h3>
|
||||
<p class="about-text">Z.AI Chat v1.2.4</p>
|
||||
<p class="about-text">Z.AI Chat v1.3.0</p>
|
||||
<p class="about-text">Built with Z.AI SDK & GLM-5.1</p>
|
||||
<p class="about-text">Compatible with Android 15/16</p>
|
||||
</div>
|
||||
<div class="settings-section">
|
||||
<h3>Changelog</h3>
|
||||
<ul class="changelog-list">
|
||||
<li>
|
||||
<span class="changelog-version">v1.3.0</span>
|
||||
<span class="changelog-date">2026-05-19</span>
|
||||
<ul>
|
||||
<li>Full interactive Terminal screen — execute real shell commands on your device</li>
|
||||
<li>Native Shell plugin — run commands, read/write files, create directories</li>
|
||||
<li>APK Installer plugin — install built APKs directly from the app</li>
|
||||
<li>Deploy Files button — AI-generated code saved to device with one tap</li>
|
||||
<li>Build APK button — compiles Android projects on-device (needs Termux tools)</li>
|
||||
<li>Install APK button — triggers Android package installer for built APKs</li>
|
||||
<li>AI action parser — detects [CREATE_FILE], [RUN_COMMAND], [BUILD_APK], [INSTALL_APK]</li>
|
||||
<li>Quick commands toolbar in terminal (ls, pwd, cat, mkdir, tools, sys)</li>
|
||||
<li>Command history with arrow keys</li>
|
||||
<li>Built-in commands: help, sysinfo, create, install, clear, exit, setup</li>
|
||||
<li>Dev environment setup screen — bootstrap build tools</li>
|
||||
<li>Project scaffolding — quick-create Android project structure</li>
|
||||
<li>Enhanced Agentic mode prompt for on-device build awareness</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>
|
||||
<span class="changelog-version">v1.2.4</span>
|
||||
<span class="changelog-date">2026-05-19</span>
|
||||
|
||||
692
www/js/app.js
692
www/js/app.js
@@ -8,7 +8,7 @@
|
||||
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.',
|
||||
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 coding agent. Break down complex tasks into clear steps. Write production-quality code with proper error handling, tests, and documentation. Think through the architecture before coding. Use tool-calling format when appropriate: [SEARCH], [CREATE_FILE], [EDIT_FILE], [RUN_COMMAND]. Always verify your work.'
|
||||
agentic: 'You are an autonomous coding agent with direct terminal access on this Android device. You can write files, compile code, build APKs, and install apps locally. Use these tool formats:\n\n[CREATE_FILE path/to/file.ext]\nfile contents here\n[/CREATE_FILE]\n\n[RUN_COMMAND]\nshell command here\n[/RUN_COMMAND]\n\n[BUILD_APK project_name]\nbuilds Android project into installable APK\n[/BUILD_APK]\n\n[INSTALL_APK /path/to/file.apk]\ninstalls APK on this device\n[/INSTALL_APK]\n\nYou have access to: aapt2 (resource compiler), d8 (dex compiler), ecj (Java compiler), apksigner, and standard shell tools. Always: 1) Write all source files 2) Build step by step 3) Sign the APK 4) Offer to install it. When the user asks you to build an app, generate ALL files needed, build, sign, and provide the installable APK.'
|
||||
};
|
||||
|
||||
var state = {
|
||||
@@ -373,6 +373,11 @@
|
||||
btn.textContent = 'Saved!';
|
||||
setTimeout(function() { btn.textContent = 'Save .txt'; }, 2000);
|
||||
});
|
||||
|
||||
if (state.currentMode === 'coding' || state.currentMode === 'agentic') {
|
||||
var actions = parseAiActions(content);
|
||||
addActionButtons(div, actions);
|
||||
}
|
||||
} else {
|
||||
div.textContent = content;
|
||||
}
|
||||
@@ -1005,6 +1010,681 @@
|
||||
saveState();
|
||||
}
|
||||
|
||||
// ---- Terminal & Shell System ----
|
||||
|
||||
var Shell = null;
|
||||
var Installer = null;
|
||||
var termState = {
|
||||
history: [],
|
||||
historyIndex: -1,
|
||||
cwd: null,
|
||||
homeDir: null,
|
||||
toolsDir: null,
|
||||
projectsDir: null,
|
||||
isRunning: false,
|
||||
activePid: null,
|
||||
activeStreamId: null,
|
||||
devToolsInstalled: false,
|
||||
commandQueue: []
|
||||
};
|
||||
|
||||
function initShellPlugins() {
|
||||
try {
|
||||
Shell = window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.Shell;
|
||||
Installer = window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.Installer;
|
||||
} catch(e) {}
|
||||
if (!Shell) console.warn('Shell plugin not available');
|
||||
if (!Installer) console.warn('Installer plugin not available');
|
||||
}
|
||||
|
||||
async function shellExec(command, cwd, stream) {
|
||||
if (!Shell) return { output: '[Shell plugin not available]\n', exitCode: -1 };
|
||||
try {
|
||||
var opts = { command: command, stream: !!stream };
|
||||
if (cwd) opts.cwd = cwd;
|
||||
var result = await Shell.execute(opts);
|
||||
return result;
|
||||
} catch(e) {
|
||||
return { output: '[Error: ' + e.message + ']\n', exitCode: -1 };
|
||||
}
|
||||
}
|
||||
|
||||
async function shellWriteFile(path, content) {
|
||||
if (!Shell) return false;
|
||||
try {
|
||||
await Shell.writeFile({ path: path, content: content });
|
||||
return true;
|
||||
} catch(e) { return false; }
|
||||
}
|
||||
|
||||
async function shellReadFile(path) {
|
||||
if (!Shell) return null;
|
||||
try {
|
||||
var result = await Shell.readFile({ path: path });
|
||||
return result.content;
|
||||
} catch(e) { return null; }
|
||||
}
|
||||
|
||||
async function shellMkdirs(path) {
|
||||
if (!Shell) return false;
|
||||
try { await Shell.mkdirs({ path: path }); return true; } catch(e) { return false; }
|
||||
}
|
||||
|
||||
async function installApk(path) {
|
||||
if (!Installer) { termPrint('[Installer plugin not available]', 'err'); return; }
|
||||
try {
|
||||
var result = await Installer.installApk({ path: path });
|
||||
termPrint('[APK install triggered: ' + path + ']', 'success');
|
||||
} catch(e) {
|
||||
termPrint('[Install failed: ' + e.message + ']', 'err');
|
||||
}
|
||||
}
|
||||
|
||||
async function getDeviceInfo() {
|
||||
if (!Installer) return {};
|
||||
try { return await Installer.getDeviceInfo(); } catch(e) { return {}; }
|
||||
}
|
||||
|
||||
function termPrint(text, className) {
|
||||
var output = $('#term-output');
|
||||
if (!output) return;
|
||||
var line = document.createElement('div');
|
||||
line.className = 'term-line' + (className ? ' term-' + className : '');
|
||||
line.textContent = text;
|
||||
output.appendChild(line);
|
||||
output.scrollTop = output.scrollHeight;
|
||||
}
|
||||
|
||||
function termPrintHtml(html, className) {
|
||||
var output = $('#term-output');
|
||||
if (!output) return;
|
||||
var line = document.createElement('div');
|
||||
line.className = 'term-line' + (className ? ' term-' + className : '');
|
||||
line.innerHTML = html;
|
||||
output.appendChild(line);
|
||||
output.scrollTop = output.scrollHeight;
|
||||
}
|
||||
|
||||
async function termExec(command) {
|
||||
if (!command.trim()) return;
|
||||
if (termState.isRunning) return;
|
||||
|
||||
termState.history.push(command);
|
||||
termState.historyIndex = termState.history.length;
|
||||
termPrint('$ ' + command, 'cmd');
|
||||
|
||||
var isCd = command.trim().startsWith('cd ');
|
||||
|
||||
termState.isRunning = true;
|
||||
updateTermButtons();
|
||||
var input = $('#term-input');
|
||||
if (input) input.disabled = true;
|
||||
|
||||
try {
|
||||
var result = await shellExec(command, termState.cwd, false);
|
||||
|
||||
if (result.output) {
|
||||
termPrint(result.output.replace(/\n$/, ''), '');
|
||||
}
|
||||
if (result.exitCode !== 0 && result.exitCode !== undefined) {
|
||||
termPrint('[exit code: ' + result.exitCode + ']', result.exitCode > 0 ? 'err' : '');
|
||||
}
|
||||
|
||||
if (isCd && result.exitCode === 0) {
|
||||
var target = command.trim().substring(3).trim();
|
||||
if (target === '~' || target === '') {
|
||||
termState.cwd = termState.homeDir;
|
||||
} else {
|
||||
var cwdResult = await shellExec('pwd', termState.cwd, false);
|
||||
if (cwdResult.exitCode === 0 && cwdResult.output) {
|
||||
termState.cwd = cwdResult.output.trim();
|
||||
}
|
||||
}
|
||||
updateCwdDisplay();
|
||||
}
|
||||
} catch(e) {
|
||||
termPrint('[Error: ' + e.message + ']', 'err');
|
||||
} finally {
|
||||
termState.isRunning = false;
|
||||
updateTermButtons();
|
||||
if (input) input.disabled = false;
|
||||
if (input) input.focus();
|
||||
}
|
||||
}
|
||||
|
||||
async function termExecStreaming(command) {
|
||||
if (!command.trim() || termState.isRunning) return;
|
||||
|
||||
termState.history.push(command);
|
||||
termState.historyIndex = termState.history.length;
|
||||
termPrint('$ ' + command, 'cmd');
|
||||
|
||||
termState.isRunning = true;
|
||||
updateTermButtons();
|
||||
var input = $('#term-input');
|
||||
if (input) input.disabled = true;
|
||||
|
||||
try {
|
||||
var result = await shellExec(command, termState.cwd, true);
|
||||
termState.activePid = result.pid;
|
||||
termState.activeStreamId = result.streamId;
|
||||
|
||||
if (Shell) {
|
||||
Shell.addListener(result.streamId, function(event) {
|
||||
if (event.data) {
|
||||
termPrint(event.data.replace(/\n$/, ''), '');
|
||||
}
|
||||
if (event.done) {
|
||||
termState.isRunning = false;
|
||||
termState.activePid = null;
|
||||
termState.activeStreamId = null;
|
||||
updateTermButtons();
|
||||
if (input) { input.disabled = false; input.focus(); }
|
||||
processCommandQueue();
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch(e) {
|
||||
termPrint('[Error: ' + e.message + ']', 'err');
|
||||
termState.isRunning = false;
|
||||
updateTermButtons();
|
||||
if (input) { input.disabled = false; input.focus(); }
|
||||
processCommandQueue();
|
||||
}
|
||||
}
|
||||
|
||||
async function processCommandQueue() {
|
||||
if (termState.commandQueue.length === 0 || termState.isRunning) return;
|
||||
var next = termState.commandQueue.shift();
|
||||
await termExec(next);
|
||||
}
|
||||
|
||||
function termQueueCommand(command) {
|
||||
if (termState.isRunning) {
|
||||
termState.commandQueue.push(command);
|
||||
} else {
|
||||
termExec(command);
|
||||
}
|
||||
}
|
||||
|
||||
async function updateCwdDisplay() {
|
||||
var display = $('#term-cwd-display');
|
||||
if (!display) return;
|
||||
if (!termState.cwd && Shell) {
|
||||
try {
|
||||
var env = await Shell.getEnv();
|
||||
termState.cwd = env.CWD;
|
||||
termState.homeDir = env.HOME;
|
||||
termState.toolsDir = env.TOOLS;
|
||||
termState.projectsDir = env.PROJECTS;
|
||||
} catch(e) {}
|
||||
}
|
||||
var cwd = termState.cwd || '~';
|
||||
if (termState.homeDir && cwd.startsWith(termState.homeDir)) {
|
||||
cwd = '~' + cwd.substring(termState.homeDir.length);
|
||||
}
|
||||
display.textContent = cwd;
|
||||
}
|
||||
|
||||
function updateTermButtons() {
|
||||
var runBtn = $('#term-run-btn');
|
||||
var stopBtn = $('#term-stop-btn');
|
||||
if (runBtn) runBtn.style.display = termState.isRunning ? 'none' : 'flex';
|
||||
if (stopBtn) stopBtn.style.display = termState.isRunning ? 'flex' : 'none';
|
||||
}
|
||||
|
||||
// ---- AI Action Parser ----
|
||||
|
||||
function parseAiActions(content) {
|
||||
var actions = [];
|
||||
var createActionRegex = /\[CREATE_FILE\s+([^\]]+)\]\n([\s\S]*?)\[\/CREATE_FILE\]/gi;
|
||||
var runCmdRegex = /\[RUN_COMMAND\]\n([\s\S]*?)\[\/RUN_COMMAND\]/gi;
|
||||
var buildApkRegex = /\[BUILD_APK\s+([^\]]+)\]/gi;
|
||||
var installApkRegex = /\[INSTALL_APK\s+([^\]]+)\]/gi;
|
||||
var codeBlockFileRegex = /```(\w+)\s*\n([\s\S]*?)```/gi;
|
||||
var match;
|
||||
|
||||
while ((match = createActionRegex.exec(content)) !== null) {
|
||||
actions.push({ type: 'create_file', path: match[1].trim(), content: match[2] });
|
||||
}
|
||||
while ((match = runCmdRegex.exec(content)) !== null) {
|
||||
actions.push({ type: 'run_command', command: match[1].trim() });
|
||||
}
|
||||
while ((match = buildApkRegex.exec(content)) !== null) {
|
||||
actions.push({ type: 'build_apk', project: match[1].trim() });
|
||||
}
|
||||
while ((match = installApkRegex.exec(content)) !== null) {
|
||||
actions.push({ type: 'install_apk', path: match[1].trim() });
|
||||
}
|
||||
while ((match = codeBlockFileRegex.exec(content)) !== null) {
|
||||
var lang = match[1];
|
||||
var code = match[2];
|
||||
var firstLine = code.trim().split('\n')[0];
|
||||
if (/^(\/|\.\/|\.\.\/|[A-Za-z]:\\)/.test(firstLine) && firstLine.length < 120 && /\.\w+$/.test(firstLine.split('\n')[0])) {
|
||||
var filePath = firstLine.trim();
|
||||
var fileContent = code.trim().split('\n').slice(1).join('\n');
|
||||
actions.push({ type: 'create_file', path: filePath, content: fileContent });
|
||||
}
|
||||
}
|
||||
return actions;
|
||||
}
|
||||
|
||||
function addActionButtons(div, actions) {
|
||||
if (actions.length === 0) return;
|
||||
var hasFiles = actions.some(function(a) { return a.type === 'create_file'; });
|
||||
var hasCommands = actions.some(function(a) { return a.type === 'run_command'; });
|
||||
var hasBuild = actions.some(function(a) { return a.type === 'build_apk'; });
|
||||
var hasInstall = actions.some(function(a) { return a.type === 'install_apk'; });
|
||||
|
||||
var actionBar = document.createElement('div');
|
||||
actionBar.className = 'msg-actions';
|
||||
|
||||
if (hasFiles) {
|
||||
var deployBtn = document.createElement('button');
|
||||
deployBtn.className = 'deploy-btn';
|
||||
deployBtn.innerHTML = '▶ Deploy Files';
|
||||
deployBtn.addEventListener('click', function() { deployActions(actions); });
|
||||
actionBar.appendChild(deployBtn);
|
||||
}
|
||||
if (hasBuild) {
|
||||
var buildBtn = document.createElement('button');
|
||||
buildBtn.className = 'deploy-btn';
|
||||
buildBtn.style.background = 'linear-gradient(135deg, var(--accent), #a855f7)';
|
||||
buildBtn.innerHTML = '📦 Build APK';
|
||||
buildBtn.addEventListener('click', function() { buildFromActions(actions); });
|
||||
actionBar.appendChild(buildBtn);
|
||||
}
|
||||
if (hasInstall) {
|
||||
actions.forEach(function(action) {
|
||||
if (action.type === 'install_apk') {
|
||||
var installBtn = document.createElement('button');
|
||||
installBtn.className = 'install-apk-btn';
|
||||
installBtn.innerHTML = '📱 Install APK';
|
||||
installBtn.addEventListener('click', function() { installApk(action.path); });
|
||||
actionBar.appendChild(installBtn);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
div.appendChild(actionBar);
|
||||
}
|
||||
|
||||
async function deployActions(actions) {
|
||||
showScreen('terminal');
|
||||
termPrint('\n--- Deploying files ---', 'info');
|
||||
|
||||
for (var i = 0; i < actions.length; i++) {
|
||||
var action = actions[i];
|
||||
if (action.type === 'create_file') {
|
||||
var path = action.path;
|
||||
if (!path.startsWith('/')) {
|
||||
path = (termState.projectsDir || termState.homeDir + '/projects') + '/' + path;
|
||||
}
|
||||
var dir = path.substring(0, path.lastIndexOf('/'));
|
||||
await shellMkdirs(dir);
|
||||
var ok = await shellWriteFile(path, action.content);
|
||||
if (ok) {
|
||||
termPrint(' [+] ' + path + ' (' + action.content.length + ' bytes)', 'success');
|
||||
} else {
|
||||
termPrint(' [!] Failed: ' + path, 'err');
|
||||
}
|
||||
}
|
||||
}
|
||||
termPrint('--- Deploy complete ---\n', 'info');
|
||||
}
|
||||
|
||||
async function buildFromActions(actions) {
|
||||
showScreen('terminal');
|
||||
termPrint('\n--- Building APK ---', 'info');
|
||||
|
||||
var projectDir = termState.projectsDir || (termState.homeDir + '/projects');
|
||||
|
||||
for (var i = 0; i < actions.length; i++) {
|
||||
var action = actions[i];
|
||||
if (action.type === 'create_file') {
|
||||
var path = action.path;
|
||||
if (!path.startsWith('/')) path = projectDir + '/' + path;
|
||||
var dir = path.substring(0, path.lastIndexOf('/'));
|
||||
await shellMkdirs(dir);
|
||||
await shellWriteFile(path, action.content);
|
||||
termPrint(' [+] ' + path, 'success');
|
||||
}
|
||||
}
|
||||
|
||||
termPrint('\nBuilding with aapt2 + d8...', 'info');
|
||||
|
||||
var buildCmd = 'cd ' + projectDir + ' && ' +
|
||||
'if [ -d "app/src/main" ]; then ' +
|
||||
' AAPT2=$(which aapt2 2>/dev/null || echo "") && ' +
|
||||
' D8=$(which d8 2>/dev/null || echo "") && ' +
|
||||
' ECJ=$(which ecj 2>/dev/null || echo "") && ' +
|
||||
' if [ -z "$AAPT2" ]; then echo "[!] aapt2 not found. Run Setup Dev Tools first."; exit 1; fi && ' +
|
||||
' echo "[*] Compiling resources..." && ' +
|
||||
' $AAPT2 compile --dir app/src/main/res -o build/compiled_resources.zip 2>&1 && ' +
|
||||
' echo "[*] Linking..." && ' +
|
||||
' $AAPT2 link -o build/app.unsigned.apk ' +
|
||||
' -I tools/android.jar ' +
|
||||
' --manifest app/src/main/AndroidManifest.xml ' +
|
||||
' -R build/compiled_resources.zip ' +
|
||||
' --java build/gen 2>&1 && ' +
|
||||
' echo "[*] Compiling Java..." && ' +
|
||||
' find app/src/main/java -name "*.java" > build/sources.txt 2>/dev/null && ' +
|
||||
' $ECJ -source 11 -target 11 -classpath tools/android.jar -d build/classes @build/sources.txt 2>&1 && ' +
|
||||
' echo "[*] Converting to DEX..." && ' +
|
||||
' $D8 --output build/ build/classes/**/*.class 2>&1 && ' +
|
||||
' echo "[*] Packaging..." && ' +
|
||||
' cd build && cp app.unsigned.apk app.unaligned.apk && ' +
|
||||
' mkdir -p app.unaligned.apk.tmp && cd app.unaligned.apk.tmp && ' +
|
||||
' unzip -o ../app.unaligned.apk && ' +
|
||||
' cp ../classes.dex . && ' +
|
||||
' zip -r ../app.unaligned.apk . && cd .. && rm -rf app.unaligned.apk.tmp && ' +
|
||||
' echo "[*] Signing..." && ' +
|
||||
' java -jar tools/uber-apk-signer.jar -a app.unaligned.apk --overwrite 2>&1 || ' +
|
||||
' cp app.unaligned.apk app-signed.apk && ' +
|
||||
' echo "[OK] APK built: ' + projectDir + '/build/app-signed.apk" && ' +
|
||||
' echo "Size: $(du -h app-signed.apk | cut -f1)" ; ' +
|
||||
'else echo "[!] No app/src/main found. Deploy files first."; fi';
|
||||
|
||||
await termExec(buildCmd);
|
||||
}
|
||||
|
||||
// ---- Dev Tools Setup ----
|
||||
|
||||
var DEV_TOOLS = [
|
||||
{ name: 'bash', url: 'https://github.com/termux/termux-packages/releases/download/bash-v5.2.21/bash-v5.2.21-aarch64.zip', type: 'binary' },
|
||||
{ name: 'coreutils', url: 'https://github.com/termux/termux-packages/releases/download/coreutils-9.4/coreutils-9.4-aarch64.zip', type: 'binary' }
|
||||
];
|
||||
|
||||
async function checkDevTools() {
|
||||
if (!Shell) return false;
|
||||
try {
|
||||
var result = await shellExec('which aapt2 2>/dev/null && echo "OK" || echo "MISSING"', termState.homeDir, false);
|
||||
termState.devToolsInstalled = result.output && result.output.indexOf('OK') >= 0;
|
||||
return termState.devToolsInstalled;
|
||||
} catch(e) { return false; }
|
||||
}
|
||||
|
||||
async function setupDevTools() {
|
||||
if (!Shell) {
|
||||
alert('Shell plugin not available');
|
||||
return;
|
||||
}
|
||||
|
||||
var btn = $('#devsetup-install-btn');
|
||||
var progress = $('#devsetup-progress');
|
||||
var progressFill = $('#devsetup-progress-fill');
|
||||
var progressText = $('#devsetup-progress-text');
|
||||
var statusEl = $('#devsetup-status');
|
||||
|
||||
btn.disabled = true;
|
||||
btn.querySelector('.btn-text').textContent = 'Installing...';
|
||||
btn.querySelector('.btn-loader').style.display = 'inline-block';
|
||||
progress.style.display = 'block';
|
||||
|
||||
var toolsDir = termState.toolsDir || (termState.homeDir + '/tools');
|
||||
var binDir = toolsDir + '/bin';
|
||||
var steps = [
|
||||
{ label: 'Creating directories...', cmd: 'mkdir -p ' + binDir + ' ' + toolsDir + '/lib ' + toolsDir + '/java' },
|
||||
{ label: 'Setting up shell...', cmd: 'cp /system/bin/sh ' + binDir + '/sh 2>/dev/null; chmod +x ' + binDir + '/* 2>/dev/null; echo OK' },
|
||||
{ label: 'Checking environment...', cmd: 'ls -la ' + binDir + '/ && echo "Environment ready"' }
|
||||
];
|
||||
|
||||
for (var i = 0; i < steps.length; i++) {
|
||||
progressText.textContent = steps[i].label;
|
||||
progressFill.style.width = ((i + 1) / (steps.length + 1) * 100) + '%';
|
||||
var result = await shellExec(steps[i].cmd, termState.homeDir, false);
|
||||
if (result.exitCode !== 0 && result.exitCode !== undefined) {
|
||||
progressText.textContent = 'Warning: ' + steps[i].label + ' had issues';
|
||||
}
|
||||
}
|
||||
|
||||
progressText.textContent = 'Writing setup scripts...';
|
||||
progressFill.style.width = '80%';
|
||||
|
||||
var setupScript = '#!/system/bin/sh\n' +
|
||||
'TOOLS_DIR="' + toolsDir + '"\n' +
|
||||
'BIN_DIR="' + binDir + '"\n' +
|
||||
'echo "[*] Z.AI Dev Tools Setup"\n' +
|
||||
'echo "[*] Tools directory: $TOOLS_DIR"\n' +
|
||||
'echo "[*] For full build support, install these via Termux:"\n' +
|
||||
'echo " pkg install aapt2 openjdk-17 dx ecj"\n' +
|
||||
'echo ""\n' +
|
||||
'echo "[*] Checking available tools..."\n' +
|
||||
'for tool in aapt2 d8 ecj java apksigner zipalign; do\n' +
|
||||
' if which $tool 2>/dev/null; then\n' +
|
||||
' echo " [+] $tool: $(which $tool)"\n' +
|
||||
' else\n' +
|
||||
' echo " [-] $tool: not found"\n' +
|
||||
' fi\n' +
|
||||
'done\n' +
|
||||
'echo ""\n' +
|
||||
'echo "[*] For on-device APK building, you need:"\n' +
|
||||
'echo " 1. Install Termux from F-Droid or GitHub"\n' +
|
||||
'echo " 2. In Termux: pkg install aapt2 openjdk-17 dx ecj apksigner"\n' +
|
||||
'echo " 3. Set TOOLS_PATH in terminal to point to Termux binaries"\n' +
|
||||
'echo ""\n' +
|
||||
'echo "[*] Device info:"\n' +
|
||||
'uname -a\n' +
|
||||
'echo "Arch: $(uname -m)"\n' +
|
||||
'echo "[*] Done"\n';
|
||||
|
||||
await shellWriteFile(toolsDir + '/setup.sh', setupScript);
|
||||
await shellExec('chmod +x ' + toolsDir + '/setup.sh', termState.homeDir, false);
|
||||
|
||||
var projectTemplate = '#!/system/bin/sh\n' +
|
||||
'# Z.AI Quick Project Creator\n' +
|
||||
'PROJECT_NAME="${1:-myapp}"\n' +
|
||||
'PROJECT_DIR="' + (termState.projectsDir || termState.homeDir + '/projects') + '/$PROJECT_NAME"\n' +
|
||||
'mkdir -p "$PROJECT_DIR"/app/src/main/java/ai/z/app\n' +
|
||||
'mkdir -p "$PROJECT_DIR"/app/src/main/res/values\n' +
|
||||
'mkdir -p "$PROJECT_DIR"/app/src/main/res/layout\n' +
|
||||
'mkdir -p "$PROJECT_DIR"/app/src/main/res/mipmap-hdpi\n' +
|
||||
'mkdir -p "$PROJECT_DIR"/build\n' +
|
||||
'# AndroidManifest.xml\n' +
|
||||
'cat > "$PROJECT_DIR"/app/src/main/AndroidManifest.xml << \'MANIFEST\'\n' +
|
||||
'<?xml version="1.0" encoding="utf-8"?>\n' +
|
||||
'<manifest xmlns:android="http://schemas.android.com/apk/res/android"\n' +
|
||||
' package="ai.z.app">\n' +
|
||||
' <application android:label="$PROJECT_NAME" android:theme="@android:style/Theme.Material.Light">\n' +
|
||||
' <activity android:name=".MainActivity" android:exported="true">\n' +
|
||||
' <intent-filter>\n' +
|
||||
' <action android:name="android.intent.action.MAIN"/>\n' +
|
||||
' <category android:name="android.intent.category.LAUNCHER"/>\n' +
|
||||
' </intent-filter>\n' +
|
||||
' </activity>\n' +
|
||||
' </application>\n' +
|
||||
'</manifest>\n' +
|
||||
'MANIFEST\n' +
|
||||
'echo "[OK] Project created: $PROJECT_DIR"\n' +
|
||||
'echo "[*] Next: Ask AI to generate the Java code, then build with Deploy"\n';
|
||||
|
||||
await shellWriteFile(toolsDir + '/create-project.sh', projectTemplate);
|
||||
await shellExec('chmod +x ' + toolsDir + '/create-project.sh', termState.homeDir, false);
|
||||
|
||||
progressFill.style.width = '100%';
|
||||
progressText.textContent = 'Setup complete!';
|
||||
statusEl.innerHTML = '<p style="color:var(--success)">Dev environment ready!</p>' +
|
||||
'<p>Use the terminal to build apps. Install Termux for full tool support (aapt2, d8, ecj).</p>';
|
||||
|
||||
btn.querySelector('.btn-text').textContent = 'Installed';
|
||||
btn.querySelector('.btn-loader').style.display = 'none';
|
||||
}
|
||||
|
||||
// ---- Init Terminal ----
|
||||
|
||||
function initTerminal() {
|
||||
var termInput = $('#term-input');
|
||||
var termRunBtn = $('#term-run-btn');
|
||||
var termStopBtn = $('#term-stop-btn');
|
||||
var termBackBtn = $('#term-back-btn');
|
||||
var termSetupBtn = $('#term-setup-tools-btn');
|
||||
var devsetupBtn = $('#devsetup-install-btn');
|
||||
var devsetupBackBtn = $('#devsetup-back-btn');
|
||||
|
||||
if (termInput) {
|
||||
termInput.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
var cmd = termInput.value;
|
||||
termInput.value = '';
|
||||
termExec(cmd);
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
if (termState.historyIndex > 0) {
|
||||
termState.historyIndex--;
|
||||
termInput.value = termState.history[termState.historyIndex] || '';
|
||||
}
|
||||
} else if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
if (termState.historyIndex < termState.history.length - 1) {
|
||||
termState.historyIndex++;
|
||||
termInput.value = termState.history[termState.historyIndex] || '';
|
||||
} else {
|
||||
termState.historyIndex = termState.history.length;
|
||||
termInput.value = '';
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (termRunBtn) {
|
||||
termRunBtn.addEventListener('click', function() {
|
||||
var cmd = termInput.value;
|
||||
termInput.value = '';
|
||||
termExec(cmd);
|
||||
});
|
||||
}
|
||||
|
||||
if (termStopBtn) {
|
||||
termStopBtn.addEventListener('click', async function() {
|
||||
if (Shell && termState.activePid) {
|
||||
try { await Shell.kill({ pid: termState.activePid }); } catch(e) {}
|
||||
}
|
||||
termState.isRunning = false;
|
||||
termState.activePid = null;
|
||||
updateTermButtons();
|
||||
termPrint('[Process killed]', 'warning');
|
||||
if (termInput) { termInput.disabled = false; termInput.focus(); }
|
||||
});
|
||||
}
|
||||
|
||||
if (termBackBtn) {
|
||||
termBackBtn.addEventListener('click', function() {
|
||||
showScreen('chat');
|
||||
});
|
||||
}
|
||||
|
||||
if (termSetupBtn) {
|
||||
termSetupBtn.addEventListener('click', function() {
|
||||
showScreen('devsetup');
|
||||
});
|
||||
}
|
||||
|
||||
if (devsetupBtn) {
|
||||
devsetupBtn.addEventListener('click', function() {
|
||||
setupDevTools();
|
||||
});
|
||||
}
|
||||
|
||||
if (devsetupBackBtn) {
|
||||
devsetupBackBtn.addEventListener('click', function() {
|
||||
showScreen('terminal');
|
||||
});
|
||||
}
|
||||
|
||||
$$('.term-quick-btn').forEach(function(btn) {
|
||||
btn.addEventListener('click', function() {
|
||||
var cmd = this.dataset.cmd;
|
||||
if (termInput) {
|
||||
termInput.value = cmd;
|
||||
termInput.focus();
|
||||
if (!cmd.endsWith(' ')) {
|
||||
termExec(cmd);
|
||||
termInput.value = '';
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
updateCwdDisplay();
|
||||
|
||||
if (Shell) {
|
||||
Shell.getEnv().then(function(env) {
|
||||
termState.homeDir = env.HOME;
|
||||
termState.toolsDir = env.TOOLS;
|
||||
termState.projectsDir = env.PROJECTS;
|
||||
termState.cwd = env.CWD || env.HOME;
|
||||
updateCwdDisplay();
|
||||
termPrint('Z.AI Terminal v1.3.0', 'info');
|
||||
termPrint('Home: ' + termState.homeDir, 'info');
|
||||
termPrint('Type "help" for commands, "setup" for dev tools\n', 'info');
|
||||
}).catch(function() {});
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Terminal command handler ----
|
||||
|
||||
var origTermExec = termExec;
|
||||
termExec = async function(command) {
|
||||
if (!command.trim()) return;
|
||||
|
||||
var lower = command.trim().toLowerCase();
|
||||
if (lower === 'help') {
|
||||
termPrint('$ help', 'cmd');
|
||||
termPrint('Z.AI Terminal Commands:', 'info');
|
||||
termPrint(' help - Show this help', '');
|
||||
termPrint(' setup - Open dev tools setup', '');
|
||||
termPrint(' sysinfo - Show device info', '');
|
||||
termPrint(' create NAME - Create new Android project', '');
|
||||
termPrint(' install APK - Install an APK file', '');
|
||||
termPrint(' clear - Clear terminal', '');
|
||||
termPrint(' exit - Back to chat', '');
|
||||
termPrint('', '');
|
||||
termPrint('Shell: Any standard Linux command works here.', '');
|
||||
termPrint('Tip: Use "setup" to install build tools (aapt2, d8, ecj)\n', '');
|
||||
return;
|
||||
}
|
||||
if (lower === 'setup') {
|
||||
showScreen('devsetup');
|
||||
return;
|
||||
}
|
||||
if (lower === 'sysinfo') {
|
||||
termPrint('$ sysinfo', 'cmd');
|
||||
var info = await getDeviceInfo();
|
||||
termPrint('Device: ' + (info.manufacturer || '') + ' ' + (info.model || ''), '');
|
||||
termPrint('Android: ' + (info.release || '?') + ' (SDK ' + (info.sdk || '?') + ')', '');
|
||||
termPrint('ABI: ' + (info.abi || '?'), '');
|
||||
termPrint('Files: ' + (info.filesDir || '?'), '');
|
||||
termPrint('Package: ' + (info.package || '?') + '\n', '');
|
||||
return;
|
||||
}
|
||||
if (lower.startsWith('create ')) {
|
||||
var name = command.trim().substring(7).trim();
|
||||
termPrint('$ create ' + name, 'cmd');
|
||||
var projectDir = termState.projectsDir || (termState.homeDir + '/projects');
|
||||
await shellExec('sh ' + termState.toolsDir + '/setup.sh', termState.homeDir, false);
|
||||
await shellExec('sh ' + termState.toolsDir + '/create-project.sh ' + name, termState.homeDir, false);
|
||||
return;
|
||||
}
|
||||
if (lower.startsWith('install ')) {
|
||||
var path = command.trim().substring(8).trim();
|
||||
termPrint('$ install ' + path, 'cmd');
|
||||
await installApk(path);
|
||||
return;
|
||||
}
|
||||
if (lower === 'clear') {
|
||||
var output = $('#term-output');
|
||||
if (output) output.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
if (lower === 'exit') {
|
||||
showScreen('chat');
|
||||
return;
|
||||
}
|
||||
|
||||
await origTermExec(command);
|
||||
};
|
||||
|
||||
// ---- Rest of init ----
|
||||
|
||||
async function testConnection(apiKey, baseUrl) {
|
||||
@@ -1067,6 +1747,8 @@
|
||||
function init() {
|
||||
loadState();
|
||||
|
||||
initShellPlugins();
|
||||
|
||||
if (state.apiKey) {
|
||||
showScreen('chat');
|
||||
if (state.activeConversationId) {
|
||||
@@ -1139,7 +1821,12 @@
|
||||
|
||||
$$('.mode-btn').forEach(function(btn) {
|
||||
btn.addEventListener('click', function() {
|
||||
state.currentMode = this.dataset.mode;
|
||||
var mode = this.dataset.mode;
|
||||
if (mode === 'terminal') {
|
||||
showScreen('terminal');
|
||||
return;
|
||||
}
|
||||
state.currentMode = mode;
|
||||
updateModeSelector();
|
||||
updateHeader();
|
||||
updateTerminalVisibility();
|
||||
@@ -1204,6 +1891,7 @@
|
||||
updateSendButton();
|
||||
applyTheme(state.theme);
|
||||
setupVisibilityHandler();
|
||||
initTerminal();
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
|
||||
Reference in New Issue
Block a user