- HTML code blocks now show as syntax-highlighted code with a Preview button - Added sandboxed iframe HTML preview panel - Added 'Open in Browser' button for full-screen preview - Raw HTML from AI responses no longer injected into chat DOM
3614 lines
155 KiB
JavaScript
3614 lines
155 KiB
JavaScript
(function() {
|
|
'use strict';
|
|
|
|
var DEFAULT_BASE_URL = 'https://api.z.ai/api/coding/paas/v4';
|
|
var DEFAULT_MODEL = 'glm-5.1';
|
|
var STORAGE_KEY = 'zai_chat_';
|
|
var MODE_PROMPTS = {
|
|
chat: 'You are a helpful, knowledgeable AI assistant. Be concise and accurate.',
|
|
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.',
|
|
agentic: 'You are an autonomous agent with full control of this app: internal Linux, terminal, virtual env, build tools, device UI, and EXTERNAL network access.\n\n## File Operations:\n[CREATE_FILE path/to/file.ext]\ncontents\n[/CREATE_FILE]\n\n## Shell (any command — runs inside app sandbox):\n[RUN_COMMAND]\ncommand\n[/RUN_COMMAND]\n\n## External SSH — run commands on remote machines:\n[SSH_EXEC user@host command]\n[SSH_UPLOAD local_path user@host:remote_path]\n[SSH_DOWNLOAD user@host:remote_path local_path]\n\n## External network tools:\n[REMOTE_EXEC host command]\n[CURL_EXEC url]\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 first\n2. Use SSH/REMOTE actions for external machines — user must approve each one\n3. Use [CREATE_FILE], then [BUILD_APK], then [INSTALL_APK]\n4. Use [DEVICE_*] for device control; start with [DEVICE_UI_TREE]\n5. Generate complete files, never stubs\n6. For Java: package ai.z.app, target SDK 36\n7. For SSH: assume openssh-client is available, use key-based auth when possible\n8. Output [TASK_COMPLETE] only when all work is done'
|
|
};
|
|
|
|
var BUILD_SCRIPT = [
|
|
'#!/system/bin/sh',
|
|
'set -e',
|
|
'PROJECT_DIR=$(pwd)',
|
|
'BUILD_DIR="$PROJECT_DIR/build"',
|
|
'',
|
|
'AAPT2=$(command -v aapt2 2>/dev/null || echo "$TOOLS/bin/aapt2")',
|
|
'ECJ=$(command -v ecj 2>/dev/null || echo "$TOOLS/bin/ecj")',
|
|
'D8=$(command -v d8 2>/dev/null || echo "$TOOLS/bin/d8")',
|
|
'APKSIGNER=$(command -v apksigner 2>/dev/null || echo "$TOOLS/bin/apksigner")',
|
|
'',
|
|
'if [ ! -x "$AAPT2" ] && [ ! -f "$AAPT2" ]; then echo "[BUILD FAILED] aapt2 not found"; exit 1; fi',
|
|
'if [ ! -f "$ECJ" ]; then echo "[BUILD FAILED] ecj not found"; exit 1; fi',
|
|
'if [ ! -f "$D8" ]; then echo "[BUILD FAILED] d8 not found"; exit 1; fi',
|
|
'',
|
|
'ANDROID_JAR=$(dirname "$AAPT2")/../share/aapt2/android.jar',
|
|
'if [ ! -f "$ANDROID_JAR" ]; then ANDROID_JAR="$TOOLS/share/android.jar"; fi',
|
|
'if [ ! -f "$ANDROID_JAR" ]; then ANDROID_JAR="$PREFIX/share/aapt2/android.jar"; fi',
|
|
'if [ ! -f "$ANDROID_JAR" ]; then echo "[BUILD FAILED] android.jar not found"; exit 1; fi',
|
|
'',
|
|
'rm -rf "$BUILD_DIR"',
|
|
'mkdir -p "$BUILD_DIR/gen" "$BUILD_DIR/classes" "$BUILD_DIR/apk"',
|
|
'',
|
|
'echo "[*] Starting build..."',
|
|
'',
|
|
'if [ -d "app/src/main/res" ] && [ "$(find app/src/main/res -type f 2>/dev/null | head -1)" ]; then',
|
|
' echo "[*] Compiling resources..."',
|
|
' "$AAPT2" compile --dir app/src/main/res -o "$BUILD_DIR/compiled_resources.zip" 2>&1 || { echo "[BUILD FAILED] Resource compilation failed"; exit 1; }',
|
|
' RES_ARG="-R $BUILD_DIR/compiled_resources.zip"',
|
|
'else',
|
|
' echo "[*] No resources, skipping..."',
|
|
' RES_ARG=""',
|
|
'fi',
|
|
'',
|
|
'echo "[*] Linking APK..."',
|
|
'"$AAPT2" link -o "$BUILD_DIR/app.unsigned.apk" \\',
|
|
' -I "$ANDROID_JAR" \\',
|
|
' --manifest app/src/main/AndroidManifest.xml \\',
|
|
' $RES_ARG \\',
|
|
' --java "$BUILD_DIR/gen" 2>&1 || { echo "[BUILD FAILED] APK linking failed"; exit 1; }',
|
|
'',
|
|
'echo "[*] Compiling Java..."',
|
|
'rm -f "$BUILD_DIR/sources.txt"',
|
|
'find app/src/main/java -name "*.java" >> "$BUILD_DIR/sources.txt" 2>/dev/null',
|
|
'find "$BUILD_DIR/gen" -name "*.java" >> "$BUILD_DIR/sources.txt" 2>/dev/null',
|
|
'if [ ! -s "$BUILD_DIR/sources.txt" ]; then echo "[BUILD FAILED] No Java source files found"; exit 1; fi',
|
|
'sh "$ECJ" -source 11 -target 11 -classpath "$ANDROID_JAR" -d "$BUILD_DIR/classes" @"$BUILD_DIR/sources.txt" 2>&1 || { echo "[BUILD FAILED] Java compilation failed"; exit 1; }',
|
|
'',
|
|
'echo "[*] Converting to DEX..."',
|
|
'find "$BUILD_DIR/classes" -name "*.class" > "$BUILD_DIR/classfiles.txt"',
|
|
'if [ ! -s "$BUILD_DIR/classfiles.txt" ]; then echo "[BUILD FAILED] No class files compiled"; exit 1; fi',
|
|
'sh "$D8" --output "$BUILD_DIR" @"$BUILD_DIR/classfiles.txt" 2>&1 || { echo "[BUILD FAILED] D8 failed"; exit 1; }',
|
|
'if [ ! -f "$BUILD_DIR/classes.dex" ]; then echo "[BUILD FAILED] classes.dex not found"; exit 1; fi',
|
|
'',
|
|
'echo "[*] Packaging..."',
|
|
'cd "$BUILD_DIR/apk"',
|
|
'unzip -o ../app.unsigned.apk > /dev/null 2>&1',
|
|
'cp ../classes.dex .',
|
|
'rm -rf META-INF',
|
|
'zip -r ../app.unaligned.apk . > /dev/null 2>&1',
|
|
'cd "$PROJECT_DIR"',
|
|
'',
|
|
'echo "[*] Signing..."',
|
|
'KEYSTORE="$HOME/.android-debug.keystore"',
|
|
'if [ ! -f "$KEYSTORE" ]; then',
|
|
' KEYTOOL=$(command -v keytool 2>/dev/null)',
|
|
' if [ -n "$KEYTOOL" ]; then',
|
|
' "$KEYTOOL" -genkeypair -v -keystore "$KEYSTORE" -storepass android \\',
|
|
' -alias androiddebugkey -keypass android -keyalg RSA -keysize 2048 \\',
|
|
' -validity 10000 -dname "CN=Debug,O=Debug,C=US" 2>/dev/null',
|
|
' else',
|
|
' openssl genrsa -out /tmp/dbg.key 2048 2>/dev/null && \\',
|
|
' openssl req -new -key /tmp/dbg.key -out /tmp/dbg.csr -subj "/CN=Debug/O=Debug/C=US" 2>/dev/null && \\',
|
|
' openssl x509 -req -days 10000 -in /tmp/dbg.csr -signkey /tmp/dbg.key -out /tmp/dbg.crt 2>/dev/null && \\',
|
|
' openssl pkcs12 -export -in /tmp/dbg.crt -inkey /tmp/dbg.key -out "$KEYSTORE" -name androiddebugkey -password pass:android 2>/dev/null && \\',
|
|
' rm -f /tmp/dbg.key /tmp/dbg.csr /tmp/dbg.crt',
|
|
' fi',
|
|
'fi',
|
|
'',
|
|
'sh "$APKSIGNER" sign --ks "$KEYSTORE" --ks-pass pass:android --ks-key-alias androiddebugkey --key-pass pass:android "$BUILD_DIR/app.unaligned.apk" 2>&1 || true',
|
|
'mv "$BUILD_DIR/app.unaligned.apk" "$BUILD_DIR/app-signed.apk"',
|
|
'',
|
|
'APK_PATH="$BUILD_DIR/app-signed.apk"',
|
|
'APK_SIZE=$(du -h "$APK_PATH" 2>/dev/null | cut -f1)',
|
|
'echo "[BUILD OK] APK: $APK_PATH ($APK_SIZE)"'
|
|
].join('\n');
|
|
|
|
var state = {
|
|
apiKey: '',
|
|
baseUrl: DEFAULT_BASE_URL,
|
|
model: DEFAULT_MODEL,
|
|
temperature: 0.7,
|
|
maxTokens: 4096,
|
|
streaming: true,
|
|
webSearch: false,
|
|
currentMode: 'chat',
|
|
theme: 'dark',
|
|
conversations: [],
|
|
activeConversationId: null,
|
|
isGenerating: false,
|
|
abortController: null,
|
|
streamingConvId: null,
|
|
streamingContent: '',
|
|
streamingResponseDiv: null,
|
|
terminalOpen: false,
|
|
keepAwake: false,
|
|
autoDeploy: true,
|
|
autoInstallBuiltApk: true,
|
|
lastBuiltApkPath: '',
|
|
maxRetries: 10,
|
|
autoContinue: true,
|
|
maxAutoContinue: 5
|
|
};
|
|
|
|
function $(sel) { return document.querySelector(sel); }
|
|
function $$(sel) { return document.querySelectorAll(sel); }
|
|
|
|
function loadState() {
|
|
try {
|
|
state.apiKey = localStorage.getItem(STORAGE_KEY + 'apiKey') || '';
|
|
state.baseUrl = localStorage.getItem(STORAGE_KEY + 'baseUrl') || DEFAULT_BASE_URL;
|
|
state.model = localStorage.getItem(STORAGE_KEY + 'model') || DEFAULT_MODEL;
|
|
state.temperature = parseFloat(localStorage.getItem(STORAGE_KEY + 'temperature')) || 0.7;
|
|
state.maxTokens = parseInt(localStorage.getItem(STORAGE_KEY + 'maxTokens')) || 4096;
|
|
state.streaming = localStorage.getItem(STORAGE_KEY + 'streaming') !== 'false';
|
|
state.webSearch = localStorage.getItem(STORAGE_KEY + 'webSearch') === 'true';
|
|
state.currentMode = localStorage.getItem(STORAGE_KEY + 'currentMode') || 'chat';
|
|
state.theme = localStorage.getItem(STORAGE_KEY + 'theme') || 'dark';
|
|
state.terminalOpen = localStorage.getItem(STORAGE_KEY + 'terminalOpen') === 'true';
|
|
state.keepAwake = localStorage.getItem(STORAGE_KEY + 'keepAwake') === 'true';
|
|
state.autoDeploy = localStorage.getItem(STORAGE_KEY + 'autoDeploy') !== 'false';
|
|
state.autoInstallBuiltApk = localStorage.getItem(STORAGE_KEY + 'autoInstallBuiltApk') !== 'false';
|
|
state.maxRetries = parseInt(localStorage.getItem(STORAGE_KEY + 'maxRetries')) || 10;
|
|
state.autoContinue = localStorage.getItem(STORAGE_KEY + 'autoContinue') !== 'false';
|
|
state.maxAutoContinue = parseInt(localStorage.getItem(STORAGE_KEY + 'maxAutoContinue')) || 5;
|
|
var convData = localStorage.getItem(STORAGE_KEY + 'conversations');
|
|
state.conversations = convData ? JSON.parse(convData) : [];
|
|
state.activeConversationId = localStorage.getItem(STORAGE_KEY + 'activeConv') || null;
|
|
} catch(e) { console.error('Load state error:', e); }
|
|
}
|
|
|
|
function saveState() {
|
|
try {
|
|
localStorage.setItem(STORAGE_KEY + 'apiKey', state.apiKey);
|
|
localStorage.setItem(STORAGE_KEY + 'baseUrl', state.baseUrl);
|
|
localStorage.setItem(STORAGE_KEY + 'model', state.model);
|
|
localStorage.setItem(STORAGE_KEY + 'temperature', state.temperature.toString());
|
|
localStorage.setItem(STORAGE_KEY + 'maxTokens', state.maxTokens.toString());
|
|
localStorage.setItem(STORAGE_KEY + 'streaming', state.streaming.toString());
|
|
localStorage.setItem(STORAGE_KEY + 'webSearch', state.webSearch.toString());
|
|
localStorage.setItem(STORAGE_KEY + 'currentMode', state.currentMode);
|
|
localStorage.setItem(STORAGE_KEY + 'theme', state.theme);
|
|
localStorage.setItem(STORAGE_KEY + 'terminalOpen', state.terminalOpen.toString());
|
|
localStorage.setItem(STORAGE_KEY + 'keepAwake', state.keepAwake.toString());
|
|
localStorage.setItem(STORAGE_KEY + 'autoDeploy', state.autoDeploy.toString());
|
|
localStorage.setItem(STORAGE_KEY + 'autoInstallBuiltApk', state.autoInstallBuiltApk.toString());
|
|
localStorage.setItem(STORAGE_KEY + 'maxRetries', state.maxRetries.toString());
|
|
localStorage.setItem(STORAGE_KEY + 'autoContinue', state.autoContinue.toString());
|
|
localStorage.setItem(STORAGE_KEY + 'maxAutoContinue', state.maxAutoContinue.toString());
|
|
localStorage.setItem(STORAGE_KEY + 'conversations', JSON.stringify(state.conversations));
|
|
localStorage.setItem(STORAGE_KEY + 'activeConv', state.activeConversationId || '');
|
|
} catch(e) { console.error('Save state error:', e); }
|
|
}
|
|
|
|
function genId() { return Date.now().toString(36) + Math.random().toString(36).substr(2, 9); }
|
|
|
|
function getConversation(id) {
|
|
var targetId = id || state.activeConversationId;
|
|
if (!targetId) return null;
|
|
return state.conversations.find(function(c) { return c.id === targetId; });
|
|
}
|
|
|
|
function flushStreamingToConversation() {
|
|
if (state.streamingConvId && state.streamingContent) {
|
|
var conv = getConversation(state.streamingConvId);
|
|
if (conv) {
|
|
var lastMsg = conv.messages[conv.messages.length - 1];
|
|
if (lastMsg && lastMsg.role === 'assistant' && lastMsg._streaming) {
|
|
lastMsg.content = state.streamingContent;
|
|
delete lastMsg._streaming;
|
|
} else {
|
|
conv.messages.push({ role: 'assistant', content: state.streamingContent });
|
|
}
|
|
saveState();
|
|
}
|
|
}
|
|
state.streamingConvId = null;
|
|
state.streamingContent = '';
|
|
state.streamingResponseDiv = null;
|
|
}
|
|
|
|
function newConversation() {
|
|
flushStreamingToConversation();
|
|
if (state.isGenerating) {
|
|
stopGeneration();
|
|
}
|
|
var conv = {
|
|
id: genId(),
|
|
title: 'New Chat',
|
|
mode: state.currentMode,
|
|
messages: [],
|
|
files: [],
|
|
createdAt: Date.now()
|
|
};
|
|
state.conversations.unshift(conv);
|
|
state.activeConversationId = conv.id;
|
|
state.isGenerating = false;
|
|
state.abortController = null;
|
|
saveState();
|
|
renderConversationList();
|
|
renderMessages();
|
|
updateHeader();
|
|
updateSendButton();
|
|
updateTerminalVisibility();
|
|
}
|
|
|
|
function switchConversation(id) {
|
|
if (id === state.activeConversationId) {
|
|
closeSidebar();
|
|
return;
|
|
}
|
|
flushStreamingToConversation();
|
|
if (state.isGenerating) {
|
|
stopGeneration();
|
|
state.isGenerating = false;
|
|
state.abortController = null;
|
|
}
|
|
state.activeConversationId = id;
|
|
var conv = getConversation();
|
|
if (conv) {
|
|
state.currentMode = conv.mode || 'chat';
|
|
updateModeSelector();
|
|
}
|
|
saveState();
|
|
renderConversationList();
|
|
renderMessages();
|
|
updateHeader();
|
|
updateSendButton();
|
|
updateTerminalVisibility();
|
|
closeSidebar();
|
|
}
|
|
|
|
function deleteConversation(id) {
|
|
if (state.streamingConvId === id) {
|
|
flushStreamingToConversation();
|
|
if (state.isGenerating) stopGeneration();
|
|
state.isGenerating = false;
|
|
}
|
|
state.conversations = state.conversations.filter(function(c) { return c.id !== id; });
|
|
if (state.activeConversationId === id) {
|
|
state.activeConversationId = state.conversations.length > 0 ? state.conversations[0].id : null;
|
|
}
|
|
saveState();
|
|
renderConversationList();
|
|
renderMessages();
|
|
updateHeader();
|
|
updateTerminalVisibility();
|
|
}
|
|
|
|
function updateHeader() {
|
|
var conv = getConversation();
|
|
$('#conversation-title').textContent = conv ? conv.title : 'Z.AI Chat';
|
|
$('#current-mode-label').textContent = state.currentMode.charAt(0).toUpperCase() + state.currentMode.slice(1);
|
|
}
|
|
|
|
function showScreen(name) {
|
|
$$('.screen').forEach(function(s) { s.classList.remove('active'); });
|
|
$('#' + name + '-screen').classList.add('active');
|
|
}
|
|
|
|
function autoResize(el) {
|
|
el.style.height = 'auto';
|
|
el.style.height = Math.min(el.scrollHeight, 120) + 'px';
|
|
}
|
|
|
|
function renderConversationList() {
|
|
var list = $('#conversation-list');
|
|
if (!list) return;
|
|
list.innerHTML = '';
|
|
state.conversations.forEach(function(conv) {
|
|
var div = document.createElement('div');
|
|
div.className = 'conv-item' + (conv.id === state.activeConversationId ? ' active' : '');
|
|
var msgCount = conv.messages.length;
|
|
div.innerHTML = '<span class="conv-title">' + escapeHtml(conv.title) +
|
|
(msgCount > 0 ? ' <span style="color:var(--text-muted);font-size:11px">(' + msgCount + ')</span>' : '') +
|
|
'</span>' +
|
|
'<button class="conv-delete" data-id="' + conv.id + '">×</button>';
|
|
div.addEventListener('click', function(e) {
|
|
if (e.target.classList.contains('conv-delete')) {
|
|
e.stopPropagation();
|
|
deleteConversation(e.target.dataset.id);
|
|
return;
|
|
}
|
|
switchConversation(conv.id);
|
|
});
|
|
list.appendChild(div);
|
|
});
|
|
}
|
|
|
|
function escapeHtml(text) {
|
|
var d = document.createElement('div');
|
|
d.textContent = text;
|
|
return d.innerHTML;
|
|
}
|
|
|
|
function escapeAttr(text) {
|
|
return (text || '').replace(/&/g, '&').replace(/"/g, '"').replace(/'/g, ''').replace(/</g, '<').replace(/>/g, '>');
|
|
}
|
|
|
|
function renderMarkdown(text) {
|
|
if (typeof marked !== 'undefined') {
|
|
marked.setOptions({
|
|
highlight: function(code, lang) {
|
|
if (typeof hljs !== 'undefined' && lang && hljs.getLanguage(lang)) {
|
|
try { return hljs.highlight(code, { language: lang }).value; } catch(e) {}
|
|
}
|
|
return code;
|
|
},
|
|
breaks: true,
|
|
gfm: true,
|
|
sanitize: false
|
|
});
|
|
var html = marked.parse(text);
|
|
html = html.replace(/<pre><code class="language-html">([\s\S]*?)<\/code><\/pre>/gi, function(match, codeContent) {
|
|
var decoded = codeContent.replace(/</g, '<').replace(/>/g, '>').replace(/&/g, '&').replace(/"/g, '"').replace(/'/g, "'");
|
|
var uid = 'hprev_' + Math.random().toString(36).substr(2, 9);
|
|
return '<div class="html-preview-block" data-uid="' + uid + '">' +
|
|
'<div class="html-preview-bar"><span>HTML</span><button class="html-preview-btn" data-uid="' + uid + '">Preview</button></div>' +
|
|
'<pre><code class="language-html">' + codeContent + '</code></pre>' +
|
|
'<textarea class="html-preview-src" style="display:none">' + escapeHtml(decoded) + '</textarea>' +
|
|
'</div>';
|
|
});
|
|
return html;
|
|
}
|
|
return text.replace(/</g, '<').replace(/>/g, '>').replace(/\n/g, '<br>');
|
|
}
|
|
|
|
var EXT_MAP = {
|
|
python: 'py', py: 'py', javascript: 'js', js: 'js', typescript: 'ts', ts: 'ts',
|
|
java: 'java', kotlin: 'kt', kt: 'kt', html: 'html', css: 'css', json: 'json',
|
|
xml: 'xml', yaml: 'yml', yml: 'yml', markdown: 'md', md: 'md', sql: 'sql',
|
|
shell: 'sh', bash: 'sh', sh: 'sh', powershell: 'ps1', dockerfile: 'Dockerfile',
|
|
ruby: 'rb', go: 'go', rust: 'rs', c: 'c', cpp: 'cpp', csharp: 'cs',
|
|
swift: 'swift', php: 'php', perl: 'pl', scala: 'scala', groovy: 'groovy',
|
|
gradle: 'gradle', properties: 'properties', toml: 'toml', ini: 'ini',
|
|
dart: 'dart', lua: 'lua', r: 'r', protobuf: 'proto'
|
|
};
|
|
|
|
function guessFileName(code, lang) {
|
|
var firstLine = code.trim().split('\n')[0];
|
|
if (/^(\/|\.\/|\.\.\/|[A-Za-z]:\\|[a-zA-Z0-9_\-]+\.[a-zA-Z]{1,10})/.test(firstLine) &&
|
|
firstLine.length < 120 && firstLine.split('\n').length === 1 &&
|
|
/\.\w+$/.test(firstLine)) {
|
|
return firstLine.replace(/^.*[\/\\]/, '');
|
|
}
|
|
var ext = EXT_MAP[lang] || lang || 'txt';
|
|
return 'code.' + ext;
|
|
}
|
|
|
|
function downloadFile(content, filename) {
|
|
var blob = new Blob([content], { type: 'text/plain;charset=utf-8' });
|
|
var url = URL.createObjectURL(blob);
|
|
var a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = filename;
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
document.body.removeChild(a);
|
|
setTimeout(function() { URL.revokeObjectURL(url); }, 5000);
|
|
}
|
|
|
|
function addCodeHeaders(container) {
|
|
container.querySelectorAll('pre code').forEach(function(block) {
|
|
var pre = block.parentElement;
|
|
var lang = (block.className.match(/language-(\w+)/) || [])[1] || 'code';
|
|
if (!pre.previousElementSibling || !pre.previousElementSibling.classList.contains('code-header')) {
|
|
var header = document.createElement('div');
|
|
header.className = 'code-header';
|
|
var fileName = guessFileName(block.textContent, lang);
|
|
header.innerHTML = '<span>' + escapeHtml(lang) + '</span>' +
|
|
'<div class="code-header-actions">' +
|
|
'<button class="download-btn" data-filename="' + escapeHtml(fileName) + '">Save</button>' +
|
|
'<button class="copy-btn">Copy</button></div>';
|
|
pre.parentElement.insertBefore(header, pre);
|
|
header.querySelector('.copy-btn').addEventListener('click', function() {
|
|
navigator.clipboard.writeText(block.textContent).then(function() {
|
|
this.textContent = 'Copied!';
|
|
setTimeout(function() { this.textContent = 'Copy'; }.bind(this), 2000);
|
|
}.bind(this));
|
|
});
|
|
header.querySelector('.download-btn').addEventListener('click', function() {
|
|
var fn = this.getAttribute('data-filename');
|
|
var code = block.textContent;
|
|
if (fn && /\.\w+$/.test(fn)) {
|
|
var lines = code.split('\n');
|
|
if (lines[0].trim() === fn || lines[0].trim().endsWith('/' + fn)) {
|
|
code = lines.slice(1).join('\n');
|
|
}
|
|
}
|
|
downloadFile(code, fn);
|
|
this.textContent = 'Saved!';
|
|
setTimeout(function() { this.textContent = 'Save'; }.bind(this), 2000);
|
|
});
|
|
}
|
|
});
|
|
|
|
container.querySelectorAll('.html-preview-btn').forEach(function(btn) {
|
|
btn.addEventListener('click', function() {
|
|
var uid = this.getAttribute('data-uid');
|
|
var wrapper = container.querySelector('.html-preview-block[data-uid="' + uid + '"]');
|
|
if (!wrapper) return;
|
|
var srcEl = wrapper.querySelector('.html-preview-src');
|
|
if (!srcEl) return;
|
|
var src = srcEl.textContent;
|
|
openHtmlPreview(src);
|
|
});
|
|
});
|
|
}
|
|
|
|
function openHtmlPreview(htmlSource) {
|
|
var preview = $('#html-preview');
|
|
var frame = $('#html-preview-frame');
|
|
var title = $('#html-preview-title');
|
|
if (!preview || !frame) return;
|
|
if (title) title.textContent = 'HTML Preview';
|
|
preview.style.display = 'flex';
|
|
var doc = frame.contentDocument || frame.contentWindow.document;
|
|
doc.open();
|
|
doc.write(htmlSource);
|
|
doc.close();
|
|
}
|
|
|
|
function closeHtmlPreview() {
|
|
var preview = $('#html-preview');
|
|
if (preview) preview.style.display = 'none';
|
|
}
|
|
|
|
function openHtmlInNewTab(htmlSource) {
|
|
var blob = new Blob([htmlSource], { type: 'text/html' });
|
|
var url = URL.createObjectURL(blob);
|
|
window.open(url, '_blank');
|
|
setTimeout(function() { URL.revokeObjectURL(url); }, 60000);
|
|
}
|
|
|
|
function highlightFilePaths(html) {
|
|
return html.replace(
|
|
/(^|[\s(>])(\/(?:[\w\-\.]+\/){1,}[\w\-\.]+\.\w{1,10})([\s)<,]|$)/gm,
|
|
'$1<span class="filepath-badge" tabindex="0">$2</span>$3'
|
|
);
|
|
}
|
|
|
|
function addFilePathHandlers(container) {
|
|
container.querySelectorAll('.filepath-badge').forEach(function(badge) {
|
|
badge.addEventListener('click', function() {
|
|
var path = this.textContent;
|
|
navigator.clipboard.writeText(path).then(function() {
|
|
badge.style.background = 'var(--success)';
|
|
badge.style.borderColor = 'var(--success)';
|
|
badge.style.color = 'white';
|
|
setTimeout(function() {
|
|
badge.style.background = '';
|
|
badge.style.borderColor = '';
|
|
badge.style.color = '';
|
|
}, 1500);
|
|
});
|
|
});
|
|
});
|
|
}
|
|
|
|
function renderMessages() {
|
|
var container = $('#messages');
|
|
if (!container) return;
|
|
container.innerHTML = '';
|
|
var conv = getConversation();
|
|
if (!conv || conv.messages.length === 0) {
|
|
container.innerHTML = '<div class="message system">Start a conversation with Z.AI</div>';
|
|
return;
|
|
}
|
|
conv.messages.forEach(function(msg) {
|
|
appendMessage(msg.role, msg.content, container, false);
|
|
});
|
|
container.scrollTop = container.scrollHeight;
|
|
updateTerminalContent();
|
|
}
|
|
|
|
function appendMessage(role, content, container, animate) {
|
|
container = container || $('#messages');
|
|
var div = document.createElement('div');
|
|
div.className = 'message ' + role;
|
|
if (animate === false) div.style.animation = 'none';
|
|
|
|
if (role === 'assistant') {
|
|
div.innerHTML = renderMarkdown(content);
|
|
addCodeHeaders(div);
|
|
var processed = highlightFilePaths(div.innerHTML);
|
|
if (processed !== div.innerHTML) {
|
|
div.innerHTML = processed;
|
|
addFilePathHandlers(div);
|
|
}
|
|
var actionBar = document.createElement('div');
|
|
actionBar.className = 'msg-actions';
|
|
actionBar.innerHTML =
|
|
'<button class="msg-action-btn msg-copy-btn">Copy</button>' +
|
|
'<button class="msg-action-btn msg-save-btn">Save .txt</button>';
|
|
div.appendChild(actionBar);
|
|
actionBar.querySelector('.msg-copy-btn').addEventListener('click', function() {
|
|
var btn = this;
|
|
navigator.clipboard.writeText(content).then(function() {
|
|
btn.textContent = 'Copied!';
|
|
btn.classList.add('copied');
|
|
setTimeout(function() { btn.textContent = 'Copy'; btn.classList.remove('copied'); }, 2000);
|
|
});
|
|
});
|
|
actionBar.querySelector('.msg-save-btn').addEventListener('click', function() {
|
|
var btn = this;
|
|
var conv = getConversation();
|
|
var fn = (conv ? conv.title : 'response').replace(/[^a-zA-Z0-9_\-]/g, '_').substring(0, 40);
|
|
downloadFile(content, fn + '.txt');
|
|
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;
|
|
}
|
|
container.appendChild(div);
|
|
container.scrollTop = container.scrollHeight;
|
|
return div;
|
|
}
|
|
|
|
function updateStreamingMessage(div, content) {
|
|
div.innerHTML = renderMarkdown(content);
|
|
addCodeHeaders(div);
|
|
var processed = highlightFilePaths(div.innerHTML);
|
|
if (processed !== div.innerHTML) {
|
|
div.innerHTML = processed;
|
|
addFilePathHandlers(div);
|
|
}
|
|
var actions = div.querySelector('.msg-actions');
|
|
if (!actions) {
|
|
var actionBar = document.createElement('div');
|
|
actionBar.className = 'msg-actions';
|
|
actionBar.innerHTML =
|
|
'<button class="msg-action-btn msg-copy-btn">Copy</button>' +
|
|
'<button class="msg-action-btn msg-save-btn">Save .txt</button>';
|
|
div.appendChild(actionBar);
|
|
}
|
|
$('#messages').scrollTop = $('#messages').scrollHeight;
|
|
}
|
|
|
|
function showThinking() {
|
|
var container = $('#messages');
|
|
var div = document.createElement('div');
|
|
div.className = 'message assistant';
|
|
div.id = 'thinking-msg';
|
|
div.innerHTML = '<div class="thinking-indicator"><div class="thinking-dots"><span></span><span></span><span></span></div> Thinking...</div>';
|
|
container.appendChild(div);
|
|
container.scrollTop = container.scrollHeight;
|
|
}
|
|
|
|
function removeThinking() {
|
|
var el = $('#thinking-msg');
|
|
if (el) el.remove();
|
|
}
|
|
|
|
async function sendMessage() {
|
|
var input = $('#message-input');
|
|
var text = input.value.trim();
|
|
if (!text || state.isGenerating) return;
|
|
|
|
if (!state.apiKey) {
|
|
showScreen('setup');
|
|
return;
|
|
}
|
|
|
|
if ((state.currentMode === 'coding' || state.currentMode === 'agentic') && !termState.devToolsInstalled) {
|
|
checkDevEnvironment();
|
|
}
|
|
|
|
if (!state.activeConversationId) {
|
|
newConversation();
|
|
}
|
|
|
|
var conv = getConversation();
|
|
if (!conv) return;
|
|
|
|
conv.mode = state.currentMode;
|
|
|
|
if (conv.messages.length === 0) {
|
|
conv.title = text.substring(0, 50) + (text.length > 50 ? '...' : '');
|
|
updateHeader();
|
|
renderConversationList();
|
|
}
|
|
|
|
conv.messages.push({ role: 'user', content: text });
|
|
saveState();
|
|
input.value = '';
|
|
autoResize(input);
|
|
updateSendButton();
|
|
appendMessage('user', text);
|
|
|
|
state.isGenerating = true;
|
|
state.streamingConvId = conv.id;
|
|
state.streamingContent = '';
|
|
updateSendButton();
|
|
showThinking();
|
|
if (state.keepAwake) setWakeLock(true);
|
|
|
|
var requestBody = null;
|
|
var responseDiv = null;
|
|
|
|
try {
|
|
var systemPrompt = MODE_PROMPTS[state.currentMode] || MODE_PROMPTS.chat;
|
|
|
|
if (Shell && (state.currentMode === 'agentic' || state.currentMode === 'coding')) {
|
|
var wsCtx = await getWorkspaceContext();
|
|
if (wsCtx) systemPrompt += '\n\n## Current Device Context:\n' + wsCtx;
|
|
}
|
|
|
|
var apiMessages = [{ role: 'system', content: systemPrompt }];
|
|
conv.messages.forEach(function(m) {
|
|
if (m.role === 'user' || (m.role === 'assistant' && !m._streaming)) {
|
|
apiMessages.push({ role: m.role, content: m.content });
|
|
}
|
|
});
|
|
|
|
requestBody = {
|
|
model: state.model,
|
|
messages: apiMessages,
|
|
temperature: state.temperature,
|
|
max_tokens: state.maxTokens,
|
|
stream: state.streaming
|
|
};
|
|
|
|
if (state.webSearch) {
|
|
requestBody.tools = [{
|
|
type: 'web_search',
|
|
web_search: { search_query: text, search_result: true }
|
|
}];
|
|
}
|
|
|
|
removeThinking();
|
|
responseDiv = appendMessage('assistant', '');
|
|
state.streamingResponseDiv = responseDiv;
|
|
|
|
if (state.streaming) {
|
|
await streamResponseWithRetry(requestBody, responseDiv, conv);
|
|
} else {
|
|
var result = await apiRequestWithRetry(requestBody);
|
|
var content = result.choices[0].message.content;
|
|
updateStreamingMessage(responseDiv, content);
|
|
state.streamingContent = content;
|
|
conv.messages.push({ role: 'assistant', content: content });
|
|
}
|
|
} catch(err) {
|
|
removeThinking();
|
|
if (err.name !== 'AbortError') {
|
|
if (state.streamingContent) {
|
|
conv.messages.push({ role: 'assistant', content: state.streamingContent, _streaming: false });
|
|
}
|
|
appendRetryMessage(err, requestBody, conv);
|
|
} else if (state.streamingContent) {
|
|
conv.messages.push({ role: 'assistant', content: state.streamingContent, _streaming: false });
|
|
}
|
|
} finally {
|
|
state.abortController = null;
|
|
state.streamingConvId = null;
|
|
state.streamingResponseDiv = null;
|
|
saveState();
|
|
updateTerminalContent();
|
|
|
|
if (state.autoDeploy && (state.currentMode === 'coding' || state.currentMode === 'agentic')) {
|
|
var finalContent = state.streamingContent || '';
|
|
if (!finalContent && conv && conv.messages.length) {
|
|
var last = conv.messages[conv.messages.length - 1];
|
|
if (last && last.role === 'assistant') finalContent = last.content;
|
|
}
|
|
if (finalContent) {
|
|
var autoActions = parseAiActions(finalContent);
|
|
if (autoActions.length > 0) {
|
|
trackFilesFromActions(autoActions, conv);
|
|
await autoExecuteActions(autoActions, conv);
|
|
}
|
|
}
|
|
renderFileTree();
|
|
}
|
|
|
|
if (state.autoContinue && (state.currentMode === 'coding' || state.currentMode === 'agentic')) {
|
|
await autoContinueIfNeeded(conv);
|
|
}
|
|
|
|
state.isGenerating = false;
|
|
updateSendButton();
|
|
if (state.keepAwake) setWakeLock(false);
|
|
}
|
|
}
|
|
|
|
function isNetworkError(err) {
|
|
if (!err) return false;
|
|
var msg = (err.message || '').toLowerCase();
|
|
var name = (err.name || '').toLowerCase();
|
|
return name === 'typeerror' || name === 'networkerror' ||
|
|
msg.indexOf('failed to fetch') >= 0 ||
|
|
msg.indexOf('network') >= 0 ||
|
|
msg.indexOf('load failed') >= 0 ||
|
|
msg.indexOf('connection') >= 0 ||
|
|
msg.indexOf('net::') >= 0 ||
|
|
msg.indexOf('interrupted') >= 0;
|
|
}
|
|
|
|
function sleep(ms) {
|
|
return new Promise(function(r) { setTimeout(r, ms); });
|
|
}
|
|
|
|
async function apiRequestWithRetry(body, maxRetries) {
|
|
maxRetries = maxRetries || 3;
|
|
var lastErr;
|
|
for (var attempt = 0; attempt < maxRetries; attempt++) {
|
|
try {
|
|
return await apiRequest(body);
|
|
} catch(err) {
|
|
lastErr = err;
|
|
if (!isNetworkError(err) || attempt >= maxRetries - 1) throw err;
|
|
var delay = 1000 * Math.pow(2, attempt);
|
|
appendMessage('system', 'Connection lost. Retrying in ' + (delay / 1000) + 's... (attempt ' + (attempt + 2) + '/' + maxRetries + ')');
|
|
await sleep(delay);
|
|
}
|
|
}
|
|
throw lastErr;
|
|
}
|
|
|
|
var _streamAutoSaveCounter = 0;
|
|
|
|
async function streamResponseWithRetry(body, responseDiv, conv, maxRetries) {
|
|
maxRetries = maxRetries || 3;
|
|
var lastErr;
|
|
for (var attempt = 0; attempt < maxRetries; attempt++) {
|
|
try {
|
|
await streamResponse(body, responseDiv, conv, attempt > 0);
|
|
return;
|
|
} catch(err) {
|
|
lastErr = err;
|
|
if (err.name === 'AbortError') throw err;
|
|
if (!isNetworkError(err)) throw err;
|
|
if (attempt >= maxRetries - 1) throw err;
|
|
|
|
if (state.streamingContent) {
|
|
conv.messages.push({ role: 'assistant', content: state.streamingContent, _streaming: true });
|
|
saveState();
|
|
}
|
|
|
|
var delay = 1500 * Math.pow(2, attempt);
|
|
var retryNotice = document.createElement('div');
|
|
retryNotice.className = 'message system';
|
|
retryNotice.innerHTML = '<div style="display:flex;align-items:center;gap:6px;justify-content:center">' +
|
|
'<div class="thinking-dots"><span></span><span></span><span></span></div>' +
|
|
' Reconnecting... (attempt ' + (attempt + 2) + '/' + maxRetries + ')</div>';
|
|
$('#messages').appendChild(retryNotice);
|
|
$('#messages').scrollTop = $('#messages').scrollHeight;
|
|
|
|
await sleep(delay);
|
|
if (retryNotice.parentElement) retryNotice.remove();
|
|
|
|
var lastAssistant = '';
|
|
for (var mi = conv.messages.length - 1; mi >= 0; mi--) {
|
|
if (conv.messages[mi].role === 'assistant' && !conv.messages[mi]._streaming) {
|
|
lastAssistant = conv.messages[mi].content;
|
|
break;
|
|
}
|
|
}
|
|
|
|
body.messages = body.messages.filter(function(m) { return m.role !== 'assistant'; });
|
|
if (state.streamingContent) {
|
|
body.messages.push({ role: 'assistant', content: state.streamingContent });
|
|
}
|
|
body.stream = true;
|
|
|
|
if (responseDiv && responseDiv.parentElement) {
|
|
var currentText = state.streamingContent || lastAssistant;
|
|
updateStreamingMessage(responseDiv, currentText + '\n\n*--- connection interrupted, resuming ---*\n');
|
|
state.streamingContent = currentText;
|
|
}
|
|
}
|
|
}
|
|
throw lastErr;
|
|
}
|
|
|
|
async function streamResponse(body, responseDiv, conv, isRetry) {
|
|
state.abortController = new AbortController();
|
|
body.stream = true;
|
|
|
|
var url = state.baseUrl.replace(/\/+$/, '') + '/chat/completions';
|
|
var resp = await fetch(url, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Authorization': 'Bearer ' + state.apiKey,
|
|
'Accept-Language': 'en-US,en'
|
|
},
|
|
body: JSON.stringify(body),
|
|
signal: state.abortController.signal
|
|
});
|
|
|
|
if (!resp.ok) {
|
|
var errData = {};
|
|
try { errData = await resp.json(); } catch(e) {}
|
|
throw new Error(errData.error?.message || 'API error ' + resp.status);
|
|
}
|
|
|
|
var reader = resp.body.getReader();
|
|
var decoder = new TextDecoder();
|
|
var fullContent = '';
|
|
var buffer = '';
|
|
_streamAutoSaveCounter = 0;
|
|
|
|
while (true) {
|
|
var chunk = await reader.read();
|
|
if (chunk.done) break;
|
|
|
|
buffer += decoder.decode(chunk.value, { stream: true });
|
|
var lines = buffer.split('\n');
|
|
buffer = lines.pop() || '';
|
|
|
|
for (var i = 0; i < lines.length; i++) {
|
|
var line = lines[i].trim();
|
|
if (!line || !line.startsWith('data:')) continue;
|
|
var data = line.substring(5).trim();
|
|
if (data === '[DONE]') break;
|
|
|
|
try {
|
|
var parsed = JSON.parse(data);
|
|
var delta = parsed.choices && parsed.choices[0] && parsed.choices[0].delta;
|
|
if (delta && delta.content) {
|
|
fullContent += delta.content;
|
|
state.streamingContent = fullContent;
|
|
if (responseDiv && responseDiv.parentElement) {
|
|
updateStreamingMessage(responseDiv, fullContent);
|
|
}
|
|
_streamAutoSaveCounter++;
|
|
if (_streamAutoSaveCounter % 20 === 0) {
|
|
_saveStreamingProgress(conv, fullContent);
|
|
}
|
|
}
|
|
} catch(e) {}
|
|
}
|
|
}
|
|
|
|
conv.messages.push({ role: 'assistant', content: fullContent });
|
|
state.streamingContent = '';
|
|
}
|
|
|
|
function _saveStreamingProgress(conv, content) {
|
|
if (!conv) return;
|
|
var last = conv.messages[conv.messages.length - 1];
|
|
if (last && last.role === 'assistant' && last._streaming) {
|
|
last.content = content;
|
|
} else {
|
|
conv.messages.push({ role: 'assistant', content: content, _streaming: true });
|
|
}
|
|
saveState();
|
|
}
|
|
|
|
function stopGeneration() {
|
|
if (state.abortController) {
|
|
state.abortController.abort();
|
|
}
|
|
flushStreamingToConversation();
|
|
}
|
|
|
|
async function apiRequest(body) {
|
|
var url = state.baseUrl.replace(/\/+$/, '') + '/chat/completions';
|
|
var resp = await fetch(url, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Authorization': 'Bearer ' + state.apiKey,
|
|
'Accept-Language': 'en-US,en'
|
|
},
|
|
body: JSON.stringify(body)
|
|
});
|
|
if (!resp.ok) {
|
|
var errData = {};
|
|
try { errData = await resp.json(); } catch(e) {}
|
|
throw new Error(errData.error?.message || 'API error ' + resp.status);
|
|
}
|
|
return await resp.json();
|
|
}
|
|
|
|
function appendRetryMessage(err, requestBody, conv) {
|
|
var container = $('#messages');
|
|
var div = document.createElement('div');
|
|
div.className = 'message system';
|
|
var isNet = isNetworkError(err);
|
|
div.innerHTML = '<div style="text-align:center">' +
|
|
'<div style="color:var(--danger);margin-bottom:8px">' +
|
|
(isNet ? 'Connection interrupted' : escapeHtml(err.message || 'Request failed')) +
|
|
'</div>' +
|
|
(isNet ? '<button class="retry-btn" id="retry-btn">Retry</button>' : '') +
|
|
'</div>';
|
|
container.appendChild(div);
|
|
container.scrollTop = container.scrollHeight;
|
|
|
|
if (isNet) {
|
|
setTimeout(function() {
|
|
var btn = $('#retry-btn');
|
|
if (btn) btn.addEventListener('click', function() {
|
|
if (div.parentElement) div.remove();
|
|
retryLastRequest(requestBody, conv);
|
|
});
|
|
}, 50);
|
|
}
|
|
}
|
|
|
|
async function retryLastRequest(requestBody, conv) {
|
|
if (!requestBody || !conv) return;
|
|
state.isGenerating = true;
|
|
state.streamingConvId = conv.id;
|
|
updateSendButton();
|
|
|
|
var responseDiv = appendMessage('assistant', '');
|
|
state.streamingResponseDiv = responseDiv;
|
|
|
|
try {
|
|
if (state.streaming) {
|
|
requestBody.stream = true;
|
|
await streamResponseWithRetry(requestBody, responseDiv, conv);
|
|
} else {
|
|
var result = await apiRequestWithRetry(requestBody);
|
|
var content = result.choices[0].message.content;
|
|
updateStreamingMessage(responseDiv, content);
|
|
state.streamingContent = content;
|
|
conv.messages.push({ role: 'assistant', content: content });
|
|
}
|
|
} catch(err) {
|
|
removeThinking();
|
|
if (state.streamingContent) {
|
|
conv.messages.push({ role: 'assistant', content: state.streamingContent, _streaming: false });
|
|
}
|
|
appendRetryMessage(err, requestBody, conv);
|
|
} finally {
|
|
state.isGenerating = false;
|
|
state.abortController = null;
|
|
state.streamingConvId = null;
|
|
state.streamingResponseDiv = null;
|
|
updateSendButton();
|
|
saveState();
|
|
updateTerminalContent();
|
|
}
|
|
}
|
|
|
|
function setupVisibilityHandler() {
|
|
document.addEventListener('visibilitychange', function() {
|
|
if (document.visibilityState === 'hidden') {
|
|
flushStreamingToConversation();
|
|
}
|
|
});
|
|
|
|
window.addEventListener('online', function() {
|
|
var msg = $('#offline-msg');
|
|
if (msg) msg.remove();
|
|
});
|
|
|
|
window.addEventListener('offline', function() {
|
|
var container = $('#messages');
|
|
if (container && !$('#offline-msg')) {
|
|
var div = document.createElement('div');
|
|
div.className = 'message system';
|
|
div.id = 'offline-msg';
|
|
div.innerHTML = '<div style="color:var(--warning)">You are offline. Messages will be saved and sent when connection is restored.</div>';
|
|
container.appendChild(div);
|
|
container.scrollTop = container.scrollHeight;
|
|
}
|
|
});
|
|
}
|
|
|
|
function updateSendButton() {
|
|
var input = $('#message-input');
|
|
var sendBtn = $('#send-btn');
|
|
var stopBtn = $('#stop-btn');
|
|
|
|
if (state.isGenerating) {
|
|
sendBtn.style.display = 'none';
|
|
stopBtn.style.display = 'flex';
|
|
} else {
|
|
sendBtn.style.display = 'flex';
|
|
stopBtn.style.display = 'none';
|
|
sendBtn.disabled = !input.value.trim();
|
|
}
|
|
}
|
|
|
|
function updateModeSelector() {
|
|
$$('.mode-btn').forEach(function(btn) {
|
|
btn.classList.toggle('active', btn.dataset.mode === state.currentMode);
|
|
});
|
|
}
|
|
|
|
function openSidebar() {
|
|
$('#sidebar').classList.add('open');
|
|
$('#sidebar-overlay').classList.add('active');
|
|
}
|
|
|
|
function closeSidebar() {
|
|
$('#sidebar').classList.remove('open');
|
|
$('#sidebar-overlay').classList.remove('active');
|
|
}
|
|
|
|
function applyTheme(theme) {
|
|
state.theme = theme;
|
|
document.documentElement.setAttribute('data-theme', theme);
|
|
var headerBtn = $('#theme-toggle-header');
|
|
if (headerBtn) {
|
|
headerBtn.innerHTML = theme === 'dark' ? '☼' : '☾';
|
|
headerBtn.title = theme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode';
|
|
}
|
|
var settingsToggle = $('#settings-darkmode');
|
|
if (settingsToggle) settingsToggle.checked = (theme === 'dark');
|
|
var metaTheme = document.querySelector('meta[name="theme-color"]');
|
|
if (metaTheme) metaTheme.content = theme === 'dark' ? '#1a1a2e' : '#ffffff';
|
|
saveState();
|
|
}
|
|
|
|
function toggleTheme() {
|
|
applyTheme(state.theme === 'dark' ? 'light' : 'dark');
|
|
}
|
|
|
|
// ---- Terminal Panel ----
|
|
|
|
function parseTerminalEntries(content) {
|
|
if (!content) return [];
|
|
var entries = [];
|
|
|
|
var toolRegex = /\[(CREATE_FILE|EDIT_FILE|DELETE_FILE|RUN_COMMAND|SEARCH|READ_FILE|BUILD|TEST)\]\s*\(([^)]*)\)\s*\n?([\s\S]*?)(?=\n\[|$)/gi;
|
|
var match;
|
|
while ((match = toolRegex.exec(content)) !== null) {
|
|
entries.push({
|
|
type: 'tool',
|
|
action: match[1],
|
|
target: match[2].trim(),
|
|
body: match[3].trim()
|
|
});
|
|
}
|
|
|
|
var codeBlockRegex = /```(\w*)\n([\s\S]*?)```/gi;
|
|
var idx = 0;
|
|
while ((match = codeBlockRegex.exec(content)) !== null) {
|
|
var isTool = false;
|
|
for (var e = 0; e < entries.length; e++) {
|
|
if (entries[e].type === 'tool' && match.index >= content.indexOf(entries[e].body) - 20) {
|
|
isTool = true;
|
|
break;
|
|
}
|
|
}
|
|
if (!isTool) {
|
|
idx++;
|
|
var firstLine = match[2].trim().split('\n')[0];
|
|
var isFilePath = /^(\/|\.\/|\.\.\/|[A-Za-z]:\\|[a-zA-Z0-9_\-]+\.[a-zA-Z]{1,4}$)/.test(firstLine) && firstLine.length < 120 && firstLine.split('\n').length === 1;
|
|
entries.push({
|
|
type: 'code',
|
|
lang: match[1] || 'text',
|
|
code: match[2].trim(),
|
|
index: idx,
|
|
fileName: isFilePath ? firstLine : null
|
|
});
|
|
}
|
|
}
|
|
|
|
entries.sort(function(a, b) {
|
|
return content.indexOf(a.type === 'tool' ? '[' + a.action : '```' + (a.lang || '')) -
|
|
content.indexOf(b.type === 'tool' ? '[' + b.action : '```' + (b.lang || ''));
|
|
});
|
|
|
|
return entries;
|
|
}
|
|
|
|
function renderTerminalEntry(entry) {
|
|
if (entry.type === 'tool') {
|
|
var actionIcon = { CREATE_FILE: '+', EDIT_FILE: '~', DELETE_FILE: '-', RUN_COMMAND: '>', SEARCH: '?', READ_FILE: 'R', BUILD: 'B', TEST: 'T' };
|
|
var actionColor = { CREATE_FILE: 'var(--success)', EDIT_FILE: 'var(--warning)', DELETE_FILE: 'var(--danger)', RUN_COMMAND: 'var(--accent)', SEARCH: 'var(--text-secondary)', READ_FILE: 'var(--text-muted)', BUILD: 'var(--accent)', TEST: 'var(--success)' };
|
|
var icon = actionIcon[entry.action] || '>';
|
|
var color = actionColor[entry.action] || 'var(--accent)';
|
|
var html = '<div class="term-entry term-tool" style="border-left:3px solid ' + color + '">';
|
|
html += '<div class="term-tool-header"><span class="term-action" style="color:' + color + '">[' + icon + '] ' + escapeHtml(entry.action) + '</span>';
|
|
if (entry.target) html += ' <span class="term-target">' + escapeHtml(entry.target) + '</span>';
|
|
html += '</div>';
|
|
if (entry.body) {
|
|
html += '<pre class="term-code">' + escapeHtml(entry.body.substring(0, 2000)) + (entry.body.length > 2000 ? '\n... (truncated)' : '') + '</pre>';
|
|
}
|
|
html += '</div>';
|
|
return html;
|
|
}
|
|
if (entry.type === 'code') {
|
|
var label = entry.fileName ? escapeHtml(entry.fileName) : escapeHtml(entry.lang || 'code');
|
|
var displayCode = entry.fileName ? entry.code.split('\n').slice(1).join('\n') : entry.code;
|
|
if (!displayCode.trim()) displayCode = entry.code;
|
|
var html = '<div class="term-entry term-code-block">';
|
|
html += '<div class="term-file-header"><span class="term-lang">' + label + '</span>';
|
|
html += '<button class="term-copy-btn" data-code="' + escapeHtml(entry.code).replace(/"/g, '"') + '">Copy</button></div>';
|
|
html += '<pre class="term-code">' + escapeHtml(displayCode.substring(0, 3000)) + (displayCode.length > 3000 ? '\n... (truncated)' : '') + '</pre>';
|
|
html += '</div>';
|
|
return html;
|
|
}
|
|
return '';
|
|
}
|
|
|
|
function updateTerminalContent() {
|
|
var termBody = $('#terminal-body');
|
|
if (!termBody) return;
|
|
termBody.innerHTML = '';
|
|
|
|
var conv = getConversation();
|
|
if (!conv) return;
|
|
|
|
var lastAssistant = null;
|
|
for (var i = conv.messages.length - 1; i >= 0; i--) {
|
|
if (conv.messages[i].role === 'assistant') {
|
|
lastAssistant = conv.messages[i];
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (!lastAssistant && !state.streamingContent) {
|
|
termBody.innerHTML = '<div class="term-empty">No code output yet. Use Coding or Agentic mode to generate code.</div>';
|
|
return;
|
|
}
|
|
|
|
var content = state.streamingContent || (lastAssistant ? lastAssistant.content : '');
|
|
var entries = parseTerminalEntries(content);
|
|
|
|
if (entries.length === 0) {
|
|
termBody.innerHTML = '<div class="term-empty">No structured code blocks or tool calls detected in response.</div>';
|
|
return;
|
|
}
|
|
|
|
entries.forEach(function(entry) {
|
|
termBody.innerHTML += renderTerminalEntry(entry);
|
|
});
|
|
|
|
termBody.querySelectorAll('.term-copy-btn').forEach(function(btn) {
|
|
btn.addEventListener('click', function() {
|
|
var code = this.getAttribute('data-code').replace(/"/g, '"').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
navigator.clipboard.writeText(code).then(function() {
|
|
this.textContent = 'Copied!';
|
|
setTimeout(function() { this.textContent = 'Copy'; }.bind(this), 2000);
|
|
}.bind(this));
|
|
});
|
|
});
|
|
|
|
if (state.terminalOpen) {
|
|
termBody.scrollTop = termBody.scrollHeight;
|
|
}
|
|
}
|
|
|
|
function updateTerminalVisibility() {
|
|
var panel = $('#terminal-panel');
|
|
var toggleBtn = $('#terminal-toggle');
|
|
var fileTreeBtn = $('#file-tree-btn');
|
|
if (!panel || !toggleBtn) return;
|
|
|
|
var isDevMode = (state.currentMode === 'coding' || state.currentMode === 'agentic');
|
|
if (isDevMode) {
|
|
panel.style.display = 'flex';
|
|
toggleBtn.style.display = 'flex';
|
|
if (fileTreeBtn) fileTreeBtn.style.display = '';
|
|
} else {
|
|
panel.style.display = 'none';
|
|
toggleBtn.style.display = 'none';
|
|
if (fileTreeBtn) fileTreeBtn.style.display = 'none';
|
|
state.terminalOpen = false;
|
|
closeFileTree();
|
|
}
|
|
|
|
if (state.terminalOpen && isDevMode) {
|
|
panel.classList.add('open');
|
|
} else {
|
|
panel.classList.remove('open');
|
|
}
|
|
|
|
var label = toggleBtn.querySelector('.terminal-label');
|
|
if (label) label.textContent = state.terminalOpen ? 'Hide Terminal' : 'Show Terminal';
|
|
}
|
|
|
|
function toggleTerminal() {
|
|
state.terminalOpen = !state.terminalOpen;
|
|
var panel = $('#terminal-panel');
|
|
if (panel) panel.classList.toggle('open', state.terminalOpen);
|
|
var label = $('#terminal-toggle .terminal-label');
|
|
if (label) label.textContent = state.terminalOpen ? 'Hide Terminal' : 'Show Terminal';
|
|
if (state.terminalOpen) updateTerminalContent();
|
|
saveState();
|
|
}
|
|
|
|
// ---- Terminal & Shell System ----
|
|
|
|
var Shell = null;
|
|
var Installer = null;
|
|
var Wake = null;
|
|
var Bootstrap = null;
|
|
var termState = {
|
|
history: [],
|
|
historyIndex: -1,
|
|
cwd: null,
|
|
homeDir: null,
|
|
toolsDir: null,
|
|
projectsDir: null,
|
|
isRunning: false,
|
|
activePid: null,
|
|
activeStreamId: null,
|
|
devToolsInstalled: false,
|
|
javaToolsInstalled: false,
|
|
hasProot: false,
|
|
prootPath: '',
|
|
nativeLibDir: '',
|
|
hermesPath: '',
|
|
hermesVenv: '',
|
|
autoglmEnabled: false,
|
|
commandQueue: []
|
|
};
|
|
var approvalState = null;
|
|
|
|
function initShellPlugins() {
|
|
try {
|
|
Shell = window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.Shell;
|
|
Installer = window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.Installer;
|
|
FileManager = window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.FileManager;
|
|
Wake = window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.Wake;
|
|
Bootstrap = window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.Bootstrap;
|
|
} catch(e) {}
|
|
if (!Shell) console.warn('Shell plugin not available');
|
|
if (!Installer) console.warn('Installer plugin not available');
|
|
if (!FileManager) console.warn('FileManager plugin not available');
|
|
if (!Wake) console.warn('Wake plugin not available');
|
|
if (!Bootstrap) console.warn('Bootstrap plugin not available');
|
|
}
|
|
|
|
async function setWakeLock(on) {
|
|
if (!Wake) return;
|
|
try {
|
|
if (on) { await Wake.acquire(); } else { await Wake.release(); }
|
|
} catch(e) { console.warn('WakeLock error:', e); }
|
|
}
|
|
|
|
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 };
|
|
}
|
|
}
|
|
|
|
function needsApprovalForCommand(command) {
|
|
var cmd = (command || '').trim();
|
|
return /(^|\s)(ssh|scp|sftp|curl|wget|adb|am\s+start|pm\s+install|install\s+|chmod|chown|rm\s+-rf|mv\s+|cp\s+|python|python3|pip|npm|git\s+push|git\s+pull)/i.test(cmd);
|
|
}
|
|
|
|
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 {
|
|
await Installer.installApk({ path: path });
|
|
termPrint('[APK install triggered: ' + path + ']', 'success');
|
|
} catch(e) {
|
|
termPrint('[Install failed: ' + e.message + ']', 'err');
|
|
}
|
|
}
|
|
|
|
async function openFile(path, mimeType) {
|
|
if (!FileManager) { termPrint('[FileManager plugin not available]', 'err'); return; }
|
|
try {
|
|
await FileManager.openFile({ path: path, mimeType: mimeType || '' });
|
|
termPrint('[Open file triggered: ' + path + ']', 'success');
|
|
} catch(e) {
|
|
termPrint('[Open failed: ' + e.message + ']', 'err');
|
|
}
|
|
}
|
|
|
|
function requestApproval(title, body, onApprove) {
|
|
approvalState = { onApprove: onApprove };
|
|
var modal = $('#approval-modal');
|
|
var titleEl = $('#approval-title');
|
|
var bodyEl = $('#approval-body');
|
|
if (titleEl) titleEl.textContent = title || 'Approval required';
|
|
if (bodyEl) bodyEl.textContent = body || '';
|
|
if (modal) modal.style.display = 'flex';
|
|
}
|
|
|
|
function closeApproval() {
|
|
var modal = $('#approval-modal');
|
|
if (modal) modal.style.display = 'none';
|
|
approvalState = null;
|
|
}
|
|
|
|
async function askApproval(title, body, action) {
|
|
return new Promise(function(resolve) {
|
|
requestApproval(title, body, async function() {
|
|
try {
|
|
var result = await action();
|
|
resolve(result);
|
|
} catch(e) {
|
|
resolve(false);
|
|
} finally {
|
|
closeApproval();
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
async function listFiles(path) {
|
|
if (!FileManager) return null;
|
|
try { return await FileManager.listFiles({ path: path }); } catch(e) { return null; }
|
|
}
|
|
|
|
function isApkPath(path) {
|
|
return !!path && path.toLowerCase().endsWith('.apk');
|
|
}
|
|
|
|
function isOpenablePath(path) {
|
|
return !!path && /\.(apk|apks|zip|json|txt|log|md|html?|xml|js|java|kt|png|jpg|jpeg|webp)$/i.test(path);
|
|
}
|
|
|
|
async function getDeviceInfo() {
|
|
if (!Installer) return {};
|
|
try { return await Installer.getDeviceInfo(); } catch(e) { return {}; }
|
|
}
|
|
|
|
async function getWorkspaceContext() {
|
|
var ctx = '';
|
|
try {
|
|
var info = await getDeviceInfo();
|
|
ctx += 'Device: ' + (info.manufacturer || '') + ' ' + (info.model || '') + ', Android ' + (info.release || '') + ' (SDK ' + (info.sdk || '') + '), ABI: ' + (info.abi || '') + '\n';
|
|
|
|
var envResult = await shellExec('echo "HOME=$HOME CWD=$(pwd) TERMUX=${TERMUX_VERSION:-none}"', termState.cwd || termState.homeDir, false);
|
|
ctx += 'Environment: ' + (envResult.output || '').trim() + '\n';
|
|
|
|
var toolCheck = await shellExec('which aapt2 d8 ecj javac apksigner zipalign 2>/dev/null; echo "---"; which tsu su 2>/dev/null || echo "no-root"', termState.homeDir, false);
|
|
ctx += 'Available tools: ' + (toolCheck.output || '').trim().replace(/\n---\n/, '\nRoot access: ') + '\n';
|
|
|
|
var projectDir = termState.projectsDir || (termState.homeDir + '/projects');
|
|
var lsResult = await shellExec('find ' + projectDir + ' -type f 2>/dev/null | head -30 || echo "no-projects"', termState.homeDir, false);
|
|
if (lsResult.output && lsResult.output.indexOf('no-projects') < 0 && lsResult.output.trim()) {
|
|
ctx += 'Project files:\n' + lsResult.output.trim() + '\n';
|
|
}
|
|
} catch(e) {}
|
|
return ctx || null;
|
|
}
|
|
|
|
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;
|
|
|
|
if (needsApprovalForCommand(command)) {
|
|
await askApproval('Run command?', command, function() {
|
|
return shellExec(command, termState.cwd, false).then(function(result) {
|
|
termPrint('$ ' + command, 'cmd');
|
|
if (result.output) termPrint(result.output.replace(/\n$/, ''), '');
|
|
if (result.exitCode !== 0 && result.exitCode !== undefined) termPrint('[exit code: ' + result.exitCode + ']', 'err');
|
|
return true;
|
|
});
|
|
});
|
|
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 deviceTapRegex = /\[DEVICE_TAP\s+(\d+)\s+(\d+)\]/gi;
|
|
var deviceLongPressRegex = /\[DEVICE_LONG_PRESS\s+(\d+)\s+(\d+)\]/gi;
|
|
var deviceSwipeRegex = /\[DEVICE_SWIPE\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\]/gi;
|
|
var deviceTypeRegex = /\[DEVICE_TYPE\s+([^\]]+)\]/gi;
|
|
var devicePressBackRegex = /\[DEVICE_PRESS_BACK\]/gi;
|
|
var devicePressHomeRegex = /\[DEVICE_PRESS_HOME\]/gi;
|
|
var devicePressRecentsRegex = /\[DEVICE_PRESS_RECENTS\]/gi;
|
|
var deviceScreenshotRegex = /\[DEVICE_SCREENSHOT\]/gi;
|
|
var deviceUiTreeRegex = /\[DEVICE_UI_TREE\]/gi;
|
|
var deviceClickTextRegex = /\[DEVICE_CLICK_TEXT\s+([^\]]+)\]/gi;
|
|
var deviceClickIdRegex = /\[DEVICE_CLICK_ID\s+([^\]]+)\]/gi;
|
|
var deviceLaunchRegex = /\[DEVICE_LAUNCH\s+([^\]]+)\]/gi;
|
|
var deviceCurrentAppRegex = /\[DEVICE_CURRENT_APP\]/gi;
|
|
var sshExecRegex = /\[SSH_EXEC\s+([^\s]+)\s+([^\]]+)\]/gi;
|
|
var sshUploadRegex = /\[SSH_UPLOAD\s+([^\s]+)\s+([^\]]+)\]/gi;
|
|
var sshDownloadRegex = /\[SSH_DOWNLOAD\s+([^\s]+)\s+([^\]]+)\]/gi;
|
|
var remoteExecRegex = /\[REMOTE_EXEC\s+([^\s]+)\s+([^\]]+)\]/gi;
|
|
var curlExecRegex = /\[CURL_EXEC\s+([^\]]+)\]/gi;
|
|
var hermesInstallRegex = /\[HERMES_INSTALL\]/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 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 = deviceTapRegex.exec(content)) !== null) {
|
|
actions.push({ type: 'device_tap', x: parseInt(match[1]), y: parseInt(match[2]) });
|
|
}
|
|
while ((match = deviceLongPressRegex.exec(content)) !== null) {
|
|
actions.push({ type: 'device_long_press', x: parseInt(match[1]), y: parseInt(match[2]) });
|
|
}
|
|
while ((match = deviceSwipeRegex.exec(content)) !== null) {
|
|
actions.push({ type: 'device_swipe', startX: parseInt(match[1]), startY: parseInt(match[2]), endX: parseInt(match[3]), endY: parseInt(match[4]) });
|
|
}
|
|
while ((match = deviceTypeRegex.exec(content)) !== null) {
|
|
actions.push({ type: 'device_type', text: match[1].trim() });
|
|
}
|
|
while ((match = devicePressBackRegex.exec(content)) !== null) {
|
|
actions.push({ type: 'device_press_back' });
|
|
}
|
|
while ((match = devicePressHomeRegex.exec(content)) !== null) {
|
|
actions.push({ type: 'device_press_home' });
|
|
}
|
|
while ((match = devicePressRecentsRegex.exec(content)) !== null) {
|
|
actions.push({ type: 'device_press_recents' });
|
|
}
|
|
while ((match = deviceScreenshotRegex.exec(content)) !== null) {
|
|
actions.push({ type: 'device_screenshot' });
|
|
}
|
|
while ((match = deviceUiTreeRegex.exec(content)) !== null) {
|
|
actions.push({ type: 'device_ui_tree' });
|
|
}
|
|
while ((match = deviceClickTextRegex.exec(content)) !== null) {
|
|
actions.push({ type: 'device_click_text', text: match[1].trim() });
|
|
}
|
|
while ((match = deviceClickIdRegex.exec(content)) !== null) {
|
|
actions.push({ type: 'device_click_id', viewId: match[1].trim() });
|
|
}
|
|
while ((match = deviceLaunchRegex.exec(content)) !== null) {
|
|
actions.push({ type: 'device_launch', pkg: match[1].trim() });
|
|
}
|
|
while ((match = deviceCurrentAppRegex.exec(content)) !== null) {
|
|
actions.push({ type: 'device_current_app' });
|
|
}
|
|
while ((match = hermesInstallRegex.exec(content)) !== null) {
|
|
actions.push({ type: 'hermes_install' });
|
|
}
|
|
while ((match = hermesExecRegex.exec(content)) !== null) {
|
|
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 = sshExecRegex.exec(content)) !== null) {
|
|
actions.push({ type: 'ssh_exec', host: match[1].trim(), command: match[2].trim() });
|
|
}
|
|
while ((match = sshUploadRegex.exec(content)) !== null) {
|
|
actions.push({ type: 'ssh_upload', localPath: match[1].trim(), remotePath: match[2].trim() });
|
|
}
|
|
while ((match = sshDownloadRegex.exec(content)) !== null) {
|
|
actions.push({ type: 'ssh_download', remotePath: match[1].trim(), localPath: match[2].trim() });
|
|
}
|
|
while ((match = remoteExecRegex.exec(content)) !== null) {
|
|
actions.push({ type: 'remote_exec', host: match[1].trim(), command: match[2].trim() });
|
|
}
|
|
while ((match = curlExecRegex.exec(content)) !== null) {
|
|
actions.push({ type: 'curl_exec', url: 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 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 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');
|
|
}
|
|
|
|
function showStatusToast(message, type) {
|
|
var existing = $('#status-toast');
|
|
if (existing) existing.remove();
|
|
var toast = document.createElement('div');
|
|
toast.id = 'status-toast';
|
|
toast.style.cssText = 'position:fixed;bottom:80px;left:50%;transform:translateX(-50%);z-index:1000;' +
|
|
'padding:10px 20px;border-radius:20px;font-size:13px;font-weight:600;max-width:90%;' +
|
|
'text-align:center;pointer-events:none;opacity:0;transition:opacity 0.3s;' +
|
|
'background:' + (type === 'success' ? 'var(--success)' : type === 'err' ? 'var(--danger)' : 'var(--accent)') +
|
|
';color:white;box-shadow:0 4px 12px rgba(0,0,0,0.3)';
|
|
toast.textContent = message;
|
|
document.body.appendChild(toast);
|
|
requestAnimationFrame(function() { toast.style.opacity = '1'; });
|
|
setTimeout(function() {
|
|
toast.style.opacity = '0';
|
|
setTimeout(function() { if (toast.parentElement) toast.remove(); }, 300);
|
|
}, 3000);
|
|
}
|
|
|
|
var _agenticRetryCount = 0;
|
|
|
|
function getMaxRetries() {
|
|
return state.maxRetries || 10;
|
|
}
|
|
|
|
function getMaxAutoContinue() {
|
|
return state.maxAutoContinue || 5;
|
|
}
|
|
|
|
function getFileLanguage(path) {
|
|
var ext = (path || '').split('.').pop().toLowerCase();
|
|
var map = {
|
|
'java': 'java', 'xml': 'xml', 'html': 'html', 'css': 'css',
|
|
'js': 'javascript', 'json': 'json', 'md': 'markdown',
|
|
'py': 'python', 'kt': 'kotlin', 'gradle': 'groovy',
|
|
'properties': 'properties', 'txt': 'text', 'sh': 'shell',
|
|
'yaml': 'yaml', 'yml': 'yaml', 'toml': 'toml',
|
|
'c': 'c', 'cpp': 'cpp', 'h': 'c', 'rs': 'rust',
|
|
'sql': 'sql', 'svg': 'svg', 'png': 'image', 'jpg': 'image'
|
|
};
|
|
return map[ext] || ext;
|
|
}
|
|
|
|
function trackFile(conv, path, content) {
|
|
if (!conv || !path) return;
|
|
if (!conv.files) conv.files = [];
|
|
var lang = getFileLanguage(path);
|
|
var idx = -1;
|
|
for (var i = 0; i < conv.files.length; i++) {
|
|
if (conv.files[i].path === path) { idx = i; break; }
|
|
}
|
|
var entry = { path: path, content: content, language: lang, timestamp: Date.now() };
|
|
if (idx >= 0) {
|
|
conv.files[idx] = entry;
|
|
} else {
|
|
conv.files.push(entry);
|
|
}
|
|
saveState();
|
|
}
|
|
|
|
function trackFilesFromActions(actions, conv) {
|
|
if (!conv) return;
|
|
for (var i = 0; i < actions.length; i++) {
|
|
if (actions[i].type === 'create_file') {
|
|
trackFile(conv, actions[i].path, actions[i].content);
|
|
}
|
|
}
|
|
}
|
|
|
|
function resolveFilePath(path) {
|
|
if (!path) return '';
|
|
if (path.startsWith('/')) return path;
|
|
return (termState.projectsDir || termState.homeDir + '/projects') + '/' + path;
|
|
}
|
|
|
|
function getLastAssistantContent(conv) {
|
|
if (!conv || !conv.messages) return '';
|
|
for (var i = conv.messages.length - 1; i >= 0; i--) {
|
|
if (conv.messages[i].role === 'assistant' && !conv.messages[i]._streaming) {
|
|
return conv.messages[i].content;
|
|
}
|
|
}
|
|
return '';
|
|
}
|
|
|
|
function isTaskComplete(content) {
|
|
if (!content) return true;
|
|
if (content.indexOf('[TASK_COMPLETE]') >= 0) return true;
|
|
var hasAction = content.indexOf('[CREATE_FILE') >= 0 ||
|
|
content.indexOf('[RUN_COMMAND]') >= 0 ||
|
|
content.indexOf('[BUILD_APK') >= 0 ||
|
|
content.indexOf('[INSTALL_APK') >= 0 ||
|
|
content.indexOf('[DEVICE_') >= 0 ||
|
|
content.indexOf('[HERMES_') >= 0 ||
|
|
content.indexOf('[VENV_') >= 0 ||
|
|
content.indexOf('[SSH_') >= 0 ||
|
|
content.indexOf('[REMOTE_EXEC') >= 0 ||
|
|
content.indexOf('[CURL_EXEC') >= 0;
|
|
var hasCodeBlock = content.indexOf('```') >= 0;
|
|
if (!hasCodeBlock && !hasAction && content.length < 300) return true;
|
|
if ((content.match(/```/g) || []).length % 2 !== 0) return false;
|
|
if ((content.match(/\[CREATE_FILE/g) || []).length > (content.match(/\[\/CREATE_FILE\]/g) || []).length) return false;
|
|
if ((content.match(/\[RUN_COMMAND\]/g) || []).length > (content.match(/\[\/RUN_COMMAND\]/g) || []).length) return false;
|
|
if (hasAction) return false;
|
|
return true;
|
|
}
|
|
|
|
async function autoContinueIfNeeded(conv) {
|
|
if (!state.autoContinue) return;
|
|
if (state.currentMode !== 'coding' && state.currentMode !== 'agentic') return;
|
|
if (!conv || !state.apiKey) return;
|
|
|
|
var maxCont = getMaxAutoContinue();
|
|
var count = 0;
|
|
|
|
while (count < maxCont) {
|
|
var last = getLastAssistantContent(conv);
|
|
if (isTaskComplete(last)) break;
|
|
|
|
count++;
|
|
termPrint('\n[>] Auto-continuing (' + count + '/' + maxCont + ')...', 'info');
|
|
showStatusToast('Auto-continuing (' + count + '/' + maxCont + ')...', 'info');
|
|
|
|
var contMsg = 'Your previous response was cut off or the task is not fully complete. ' +
|
|
'Continue from EXACTLY where you left off. Complete ALL remaining files, commands, builds, and installations. ' +
|
|
'When the ENTIRE task is fully done, output [TASK_COMPLETE] on its own line.';
|
|
|
|
conv.messages.push({ role: 'user', content: contMsg });
|
|
saveState();
|
|
appendMessage('user', contMsg);
|
|
|
|
state.isGenerating = true;
|
|
state.streamingConvId = conv.id;
|
|
state.streamingContent = '';
|
|
updateSendButton();
|
|
if (state.keepAwake) setWakeLock(true);
|
|
|
|
var contError = false;
|
|
try {
|
|
var sysPrompt = MODE_PROMPTS[state.currentMode] || MODE_PROMPTS.chat;
|
|
if (Shell && (state.currentMode === 'agentic' || state.currentMode === 'coding')) {
|
|
var wsCtx = await getWorkspaceContext();
|
|
if (wsCtx) sysPrompt += '\n\n## Current Device Context:\n' + wsCtx;
|
|
}
|
|
|
|
var apiMsgs = [{ role: 'system', content: sysPrompt }];
|
|
conv.messages.forEach(function(m) {
|
|
if (m.role === 'user' || (m.role === 'assistant' && !m._streaming)) {
|
|
apiMsgs.push({ role: m.role, content: m.content });
|
|
}
|
|
});
|
|
|
|
var reqBody = {
|
|
model: state.model,
|
|
messages: apiMsgs,
|
|
temperature: state.temperature,
|
|
max_tokens: state.maxTokens,
|
|
stream: state.streaming
|
|
};
|
|
|
|
var respDiv = appendMessage('assistant', '');
|
|
state.streamingResponseDiv = respDiv;
|
|
|
|
if (state.streaming) {
|
|
await streamResponseWithRetry(reqBody, respDiv, conv);
|
|
} else {
|
|
var res = await apiRequestWithRetry(reqBody);
|
|
var cont = res.choices[0].message.content;
|
|
updateStreamingMessage(respDiv, cont);
|
|
state.streamingContent = cont;
|
|
conv.messages.push({ role: 'assistant', content: cont });
|
|
}
|
|
} catch(err) {
|
|
if (state.streamingContent) {
|
|
conv.messages.push({ role: 'assistant', content: state.streamingContent });
|
|
}
|
|
if (err.name !== 'AbortError') {
|
|
termPrint('[!] Auto-continue error: ' + (err.message || err), 'err');
|
|
}
|
|
contError = true;
|
|
}
|
|
|
|
state.isGenerating = false;
|
|
state.abortController = null;
|
|
state.streamingConvId = null;
|
|
state.streamingResponseDiv = null;
|
|
updateSendButton();
|
|
saveState();
|
|
if (state.keepAwake) setWakeLock(false);
|
|
|
|
if (contError) break;
|
|
|
|
var contContent = state.streamingContent || '';
|
|
state.streamingContent = '';
|
|
if (state.autoDeploy && contContent) {
|
|
var contActions = parseAiActions(contContent);
|
|
if (contActions.length > 0) {
|
|
trackFilesFromActions(contActions, conv);
|
|
await autoExecuteActions(contActions, conv);
|
|
}
|
|
}
|
|
renderFileTree();
|
|
}
|
|
|
|
if (count > 0) {
|
|
var finalCheck = getLastAssistantContent(conv);
|
|
if (isTaskComplete(finalCheck)) {
|
|
termPrint('\n[v] Task completed after ' + count + ' auto-continue(s)', 'success');
|
|
showStatusToast('Task completed!', 'success');
|
|
} else if (count >= maxCont) {
|
|
termPrint('\n[!] Max auto-continues reached (' + maxCont + ')', 'warning');
|
|
}
|
|
}
|
|
}
|
|
|
|
function buildFileTreeData(files) {
|
|
var root = { name: '', children: {}, files: [] };
|
|
for (var f = 0; f < files.length; f++) {
|
|
var parts = files[f].path.split('/').filter(Boolean);
|
|
var cur = root;
|
|
for (var i = 0; i < parts.length - 1; i++) {
|
|
if (!cur.children[parts[i]]) {
|
|
cur.children[parts[i]] = { name: parts[i], children: {}, files: [] };
|
|
}
|
|
cur = cur.children[parts[i]];
|
|
}
|
|
cur.files.push({
|
|
name: parts[parts.length - 1],
|
|
path: files[f].path,
|
|
language: files[f].language,
|
|
timestamp: files[f].timestamp
|
|
});
|
|
}
|
|
return root;
|
|
}
|
|
|
|
function renderTreeHtml(node, convId, depth) {
|
|
var html = '';
|
|
var indent = depth * 20;
|
|
var dirs = Object.keys(node.children).sort();
|
|
for (var d = 0; d < dirs.length; d++) {
|
|
var child = node.children[dirs[d]];
|
|
html += '<div class="ftree-folder">';
|
|
html += '<div class="ftree-node ftree-dir" style="padding-left:' + (indent + 8) + 'px">';
|
|
html += '<span class="ftree-arrow">▶</span>';
|
|
html += '<span class="ftree-dirname">' + escapeHtml(dirs[d]) + '</span>';
|
|
html += '</div>';
|
|
html += '<div class="ftree-children">';
|
|
html += renderTreeHtml(child, convId, depth + 1);
|
|
html += '</div></div>';
|
|
}
|
|
for (var fi = 0; fi < node.files.length; fi++) {
|
|
var file = node.files[fi];
|
|
html += '<div class="ftree-node ftree-file" style="padding-left:' + (indent + 20) + 'px" ';
|
|
html += 'data-conv="' + convId + '" data-path="' + escapeAttr(file.path) + '">';
|
|
html += '<span class="ftree-ext">' + escapeHtml(file.language || '?') + '</span>';
|
|
html += '<span class="ftree-fname">' + escapeHtml(file.name) + '</span>';
|
|
if (isApkPath(file.path)) {
|
|
html += '<span class="ftree-badge">APK</span>';
|
|
}
|
|
html += '</div>';
|
|
}
|
|
return html;
|
|
}
|
|
|
|
function renderFileTree() {
|
|
var body = $('#file-tree-body');
|
|
if (!body) return;
|
|
var conv = getConversation();
|
|
if (!conv || !conv.files || conv.files.length === 0) {
|
|
body.innerHTML = '<div class="ftree-empty">No files yet.<br>AI-generated files appear here.</div>';
|
|
var badge = $('#file-tree-count');
|
|
if (badge) badge.textContent = '0 files';
|
|
return;
|
|
}
|
|
var tree = buildFileTreeData(conv.files);
|
|
body.innerHTML = renderTreeHtml(tree, conv.id, 0);
|
|
var badge = $('#file-tree-count');
|
|
if (badge) badge.textContent = conv.files.length + ' file' + (conv.files.length !== 1 ? 's' : '');
|
|
}
|
|
|
|
async function renderDeviceFiles(rootPath) {
|
|
var body = $('#file-tree-body');
|
|
if (!body) return;
|
|
var result = await listFiles(rootPath);
|
|
if (!result) {
|
|
body.innerHTML = '<div class="ftree-empty">File manager unavailable.</div>';
|
|
return;
|
|
}
|
|
var items = result.items || [];
|
|
if (items.length === 0) {
|
|
body.innerHTML = '<div class="ftree-empty">Folder is empty.</div>';
|
|
return;
|
|
}
|
|
var html = '<div class="device-files-root">';
|
|
for (var i = 0; i < items.length; i++) {
|
|
var item = items[i];
|
|
html += '<div class="ftree-node ftree-file device-file-row" data-path="' + escapeAttr(item.path) + '">';
|
|
html += '<span class="ftree-ext">' + escapeHtml(item.directory ? 'dir' : (item.mimeType || '?')) + '</span>';
|
|
html += '<span class="ftree-fname">' + escapeHtml(item.name) + '</span>';
|
|
html += item.directory ? '<span class="ftree-badge">DIR</span>' : (isApkPath(item.path) ? '<span class="ftree-badge">APK</span>' : '');
|
|
html += '</div>';
|
|
}
|
|
html += '</div>';
|
|
body.innerHTML = html;
|
|
}
|
|
|
|
async function renderFileManagerHome() {
|
|
var body = $('#file-tree-body');
|
|
if (!body) return;
|
|
if (!FileManager) {
|
|
body.innerHTML = '<div class="ftree-empty">File manager unavailable.</div>';
|
|
return;
|
|
}
|
|
try {
|
|
var result = await FileManager.getRoots();
|
|
var roots = result.roots || [];
|
|
var html = '<div class="device-files-root">';
|
|
for (var i = 0; i < roots.length; i++) {
|
|
var item = roots[i];
|
|
html += '<div class="ftree-node ftree-file device-file-row" data-path="' + escapeAttr(item.path) + '">';
|
|
html += '<span class="ftree-ext">root</span>';
|
|
html += '<span class="ftree-fname">' + escapeHtml(item.name) + '</span>';
|
|
html += '<span class="ftree-badge">OPEN</span>';
|
|
html += '</div>';
|
|
}
|
|
html += '</div>';
|
|
body.innerHTML = html;
|
|
var title = document.querySelector('#file-tree-panel h3');
|
|
if (title) title.textContent = 'File Manager';
|
|
var count = $('#file-tree-count');
|
|
if (count) count.textContent = roots.length + ' roots';
|
|
} catch(e) {
|
|
body.innerHTML = '<div class="ftree-empty">File manager unavailable.</div>';
|
|
}
|
|
}
|
|
|
|
function toggleFileTree() {
|
|
var panel = $('#file-tree-panel');
|
|
var overlay = $('#file-tree-overlay');
|
|
if (!panel) return;
|
|
var isOpen = panel.classList.contains('open');
|
|
if (isOpen) {
|
|
panel.classList.remove('open');
|
|
if (overlay) overlay.classList.remove('active');
|
|
} else {
|
|
renderFileTree();
|
|
panel.classList.add('open');
|
|
if (overlay) overlay.classList.add('active');
|
|
}
|
|
}
|
|
|
|
function toggleDeviceFileManager() {
|
|
var panel = $('#file-tree-panel');
|
|
var overlay = $('#file-tree-overlay');
|
|
if (!panel) return;
|
|
if (panel.classList.contains('open')) {
|
|
panel.classList.remove('open');
|
|
if (overlay) overlay.classList.remove('active');
|
|
return;
|
|
}
|
|
panel.classList.add('open');
|
|
if (overlay) overlay.classList.add('active');
|
|
renderFileManagerHome();
|
|
}
|
|
|
|
function showProjectFiles() {
|
|
renderFileTree();
|
|
var title = document.querySelector('#file-tree-panel h3');
|
|
if (title) title.textContent = 'Project Files';
|
|
var count = $('#file-tree-count');
|
|
if (count) count.textContent = (getConversation() && getConversation().files ? getConversation().files.length : 0) + ' files';
|
|
}
|
|
|
|
function closeFileTree() {
|
|
var panel = $('#file-tree-panel');
|
|
var overlay = $('#file-tree-overlay');
|
|
if (panel) panel.classList.remove('open');
|
|
if (overlay) overlay.classList.remove('active');
|
|
}
|
|
|
|
function openFileViewer(convId, path) {
|
|
var conv = getConversation(convId);
|
|
if (!conv || !conv.files) return;
|
|
var file = null;
|
|
for (var i = 0; i < conv.files.length; i++) {
|
|
if (conv.files[i].path === path) { file = conv.files[i]; break; }
|
|
}
|
|
if (!file) return;
|
|
|
|
var viewer = $('#file-viewer');
|
|
var nameEl = $('#file-viewer-name');
|
|
var langEl = $('#file-viewer-lang');
|
|
var contentEl = $('#file-viewer-content');
|
|
var textareaEl = $('#file-viewer-textarea');
|
|
var bodyEl = $('#file-viewer-body');
|
|
var editorEl = $('#file-viewer-editor');
|
|
var saveBtn = $('#file-viewer-save');
|
|
var editBtn = $('#file-viewer-edit');
|
|
|
|
if (!viewer || !nameEl || !langEl || !contentEl || !textareaEl || !bodyEl || !editorEl || !saveBtn || !editBtn) return;
|
|
nameEl.textContent = file.path;
|
|
langEl.textContent = file.language;
|
|
contentEl.textContent = file.content;
|
|
textareaEl.value = file.content;
|
|
bodyEl.style.display = '';
|
|
editorEl.style.display = 'none';
|
|
saveBtn.style.display = 'none';
|
|
editBtn.style.display = '';
|
|
editBtn.textContent = 'Edit';
|
|
|
|
viewer.style.display = 'flex';
|
|
viewer.dataset.convId = convId;
|
|
viewer.dataset.path = path;
|
|
}
|
|
|
|
async function openFileFromTree(path) {
|
|
if (isApkPath(path)) {
|
|
await askApproval('Install APK?', path, function() { return installApk(path); });
|
|
return;
|
|
}
|
|
var fileManagerTitle = document.querySelector('#file-tree-panel h3');
|
|
if (fileManagerTitle && fileManagerTitle.textContent === 'File Manager') {
|
|
await renderDeviceFiles(path);
|
|
return;
|
|
}
|
|
await openFile(path, '');
|
|
}
|
|
|
|
function closeFileViewer() {
|
|
var viewer = $('#file-viewer');
|
|
if (viewer) viewer.style.display = 'none';
|
|
}
|
|
|
|
function toggleFileEdit() {
|
|
var bodyEl = $('#file-viewer-body');
|
|
var editorEl = $('#file-viewer-editor');
|
|
var saveBtn = $('#file-viewer-save');
|
|
var editBtn = $('#file-viewer-edit');
|
|
|
|
if (bodyEl.style.display !== 'none') {
|
|
bodyEl.style.display = 'none';
|
|
editorEl.style.display = '';
|
|
saveBtn.style.display = '';
|
|
editBtn.textContent = 'View';
|
|
} else {
|
|
bodyEl.style.display = '';
|
|
editorEl.style.display = 'none';
|
|
saveBtn.style.display = 'none';
|
|
editBtn.textContent = 'Edit';
|
|
}
|
|
}
|
|
|
|
async function saveFileEdit() {
|
|
var viewer = $('#file-viewer');
|
|
var textareaEl = $('#file-viewer-textarea');
|
|
if (!viewer || !textareaEl) return;
|
|
|
|
var convId = viewer.dataset.convId;
|
|
var path = viewer.dataset.path;
|
|
var newContent = textareaEl.value;
|
|
|
|
var conv = getConversation(convId);
|
|
if (!conv) return;
|
|
|
|
var absPath = resolveFilePath(path);
|
|
var dir = absPath.substring(0, absPath.lastIndexOf('/'));
|
|
await shellMkdirs(dir);
|
|
var ok = await shellWriteFile(absPath, newContent);
|
|
|
|
if (ok) {
|
|
trackFile(conv, path, newContent);
|
|
showStatusToast('File saved: ' + path, 'success');
|
|
termPrint('[OK] Saved: ' + path + ' (' + newContent.length + ' bytes)', 'success');
|
|
$('#file-viewer-content').textContent = newContent;
|
|
toggleFileEdit();
|
|
renderFileTree();
|
|
} else {
|
|
showStatusToast('Failed to save file', 'err');
|
|
}
|
|
}
|
|
|
|
async function ensureBuildTools() {
|
|
var check = await shellExec('command -v aapt2 >/dev/null 2>&1 && command -v ecj >/dev/null 2>&1 && (command -v d8 >/dev/null 2>&1 || command -v dx >/dev/null 2>&1)', termState.homeDir, false);
|
|
if (check.exitCode === 0) {
|
|
termState.devToolsInstalled = true;
|
|
return true;
|
|
}
|
|
|
|
var javaCheck = await shellExec('test -f "$TOOLS/jars/ecj.jar" && test -f "$TOOLS/jars/d8.jar" && test -f "$TOOLS/jars/apksigner.jar"', termState.homeDir, false);
|
|
if (javaCheck.exitCode === 0 && termState.nativeLibDir) {
|
|
var aapt2Check = await shellExec('test -f "$TOOLS/bin/aapt2"', termState.homeDir, false);
|
|
if (aapt2Check.exitCode === 0) {
|
|
termState.devToolsInstalled = true;
|
|
termState.javaToolsInstalled = true;
|
|
return true;
|
|
}
|
|
}
|
|
|
|
if (Shell) {
|
|
var env = await Shell.getEnv();
|
|
termState.homeDir = env.HOME;
|
|
termState.toolsDir = env.TOOLS;
|
|
termState.projectsDir = env.PROJECTS;
|
|
termState.cwd = env.CWD || env.HOME;
|
|
termState.hasProot = env.hasProot === true;
|
|
termState.prootPath = env.prootPath || '';
|
|
termState.nativeLibDir = env.nativeLibDir || '';
|
|
}
|
|
|
|
termPrint('[*] Setting up Java build tools (app_process virtual JVM)...', 'info');
|
|
showStatusToast('Installing build tools...', 'info');
|
|
|
|
var javaOk = await setupJavaTools();
|
|
if (javaOk) return true;
|
|
|
|
var prefix = termState.homeDir ? termState.homeDir.replace('/home', '') : '';
|
|
var prefixUsr = prefix + '/usr';
|
|
try { await Bootstrap.fixPermissions(); } catch(e) {}
|
|
|
|
termPrint('[*] Trying Termux pkg install...', 'info');
|
|
var pkgOk = await tryPkgInstall(prefixUsr);
|
|
if (pkgOk) return true;
|
|
|
|
if (termState.prootPath) {
|
|
termPrint('[*] Trying PRoot...', 'info');
|
|
var prootOk = await tryProotExec(termState.prootPath, prefixUsr, prefixUsr + '/bin/pkg', prefixUsr + '/bin/apt');
|
|
if (prootOk) return true;
|
|
}
|
|
|
|
termPrint('[*] Checking for Termux...', 'info');
|
|
var termuxOk = await tryTermuxInstall();
|
|
if (termuxOk) return true;
|
|
|
|
termPrint('[!] All strategies failed. Install Termux from F-Droid:', 'err');
|
|
termPrint(' https://f-droid.org/en/packages/com.termux/', 'warning');
|
|
termPrint(' Then: pkg update && pkg install aapt2 ecj dx apksigner', 'warning');
|
|
termState.devToolsInstalled = false;
|
|
return false;
|
|
}
|
|
|
|
async function setupJavaTools() {
|
|
if (!Shell || !Bootstrap) return false;
|
|
|
|
var toolsDir = termState.toolsDir;
|
|
var jarsDir = toolsDir + '/jars';
|
|
var binDir = toolsDir + '/bin';
|
|
var nativeLibDir = termState.nativeLibDir;
|
|
|
|
await shellExec('mkdir -p "' + jarsDir + '" "' + binDir + '"', termState.homeDir, false);
|
|
|
|
termPrint('[*] Extracting bundled JARs from APK assets...', 'info');
|
|
try {
|
|
var extractResult = await Bootstrap.extractAsset({src: 'jars/ecj.jar', dest: jarsDir + '/ecj.jar'});
|
|
termPrint('[OK] ecj.jar extracted (' + Math.round(extractResult.size/1024) + ' KB)', 'success');
|
|
} catch(e) {
|
|
termPrint('[!] ecj.jar extract failed: ' + e.message, 'err');
|
|
return false;
|
|
}
|
|
|
|
try {
|
|
var extractResult = await Bootstrap.extractAsset({src: 'jars/apksigner.jar', dest: jarsDir + '/apksigner.jar'});
|
|
termPrint('[OK] apksigner.jar extracted (' + Math.round(extractResult.size/1024) + ' KB)', 'success');
|
|
} catch(e) {
|
|
termPrint('[!] apksigner.jar extract failed: ' + e.message, 'err');
|
|
return false;
|
|
}
|
|
|
|
var d8Test = await shellExec('test -f "' + jarsDir + '/d8.jar"', termState.homeDir, false);
|
|
if (d8Test.exitCode !== 0) {
|
|
termPrint('[*] Extracting d8.jar from APK assets...', 'info');
|
|
try {
|
|
var d8Extract = await Bootstrap.extractAsset({src: 'jars/d8.jar', dest: jarsDir + '/d8.jar'});
|
|
termPrint('[OK] d8.jar extracted (' + Math.round(d8Extract.size/1024/1024) + ' MB)', 'success');
|
|
} catch(e) {
|
|
termPrint('[!] d8.jar extract failed: ' + e.message, 'warning');
|
|
termPrint('[*] Downloading d8.jar (~18MB)...', 'info');
|
|
try {
|
|
await Bootstrap.downloadFile({url: 'https://dl.google.com/android/repository/build-tools_r36-linux.zip', dest: toolsDir + '/build-tools.zip'});
|
|
await shellExec('cd "' + toolsDir + '" && unzip -o build-tools.zip "*/lib/d8.jar" 2>&1 && mv */lib/d8.jar jars/d8.jar && rm -rf build-tools.zip android-*', termState.homeDir, false);
|
|
} catch(e2) {
|
|
termPrint('[!] d8.jar download also failed: ' + e2.message, 'err');
|
|
}
|
|
}
|
|
}
|
|
|
|
var d8Check = await shellExec('test -f "' + jarsDir + '/d8.jar" && test -s "' + jarsDir + '/d8.jar"', termState.homeDir, false);
|
|
if (d8Check.exitCode !== 0) {
|
|
termPrint('[!] d8.jar not available', 'err');
|
|
return false;
|
|
}
|
|
termPrint('[OK] d8.jar ready', 'success');
|
|
|
|
var aapt2Check = await shellExec('test -f "' + binDir + '/aapt2"', termState.homeDir, false);
|
|
if (aapt2Check.exitCode !== 0) {
|
|
termPrint('[*] Installing aapt2 from Termux repo...', 'info');
|
|
try {
|
|
var aapt2Result = await Bootstrap.installAapt2({toolsDir: toolsDir});
|
|
termPrint('[OK] aapt2 installed (' + Math.round(aapt2Result.size/1024) + ' KB)', 'success');
|
|
} catch(e) {
|
|
termPrint('[!] aapt2 install failed: ' + e.message, 'warning');
|
|
termPrint('[*] Build will work for resource-less APKs only', 'info');
|
|
}
|
|
}
|
|
|
|
termPrint('[*] Creating app_process wrapper scripts...', 'info');
|
|
var wrappers = {
|
|
ecj: '#!/system/bin/sh\nexec /system/bin/app_process /system/bin --nice-name=zaichat -Djava.class.path=' + jarsDir + '/ecj.jar org.eclipse.jdt.internal.compiler.batch.Main "$@"',
|
|
d8: '#!/system/bin/sh\nexec /system/bin/app_process /system/bin --nice-name=zaichat -Djava.class.path=' + jarsDir + '/d8.jar com.android.tools.r8.D8 "$@"',
|
|
apksigner: '#!/system/bin/sh\nexec /system/bin/app_process /system/bin --nice-name=zaichat -jar ' + jarsDir + '/apksigner.jar "$@"'
|
|
};
|
|
|
|
for (var name in wrappers) {
|
|
try {
|
|
await Shell.writeFile({path: binDir + '/' + name, content: wrappers[name]});
|
|
await shellExec('chmod 755 "' + binDir + '/' + name + '"', termState.homeDir, false);
|
|
} catch(e) {
|
|
termPrint('[!] Failed to create ' + name + ' wrapper: ' + e.message, 'err');
|
|
}
|
|
}
|
|
termPrint('[OK] Wrapper scripts created (ecj, d8, apksigner)', 'success');
|
|
|
|
var verify = await shellExec('test -f "' + jarsDir + '/ecj.jar" && test -f "' + jarsDir + '/d8.jar" && test -f "' + jarsDir + '/apksigner.jar" && test -x "' + binDir + '/ecj" && test -x "' + binDir + '/aapt2"', termState.homeDir, false);
|
|
if (verify.exitCode === 0) {
|
|
termPrint('[OK] Java build environment ready!', 'success');
|
|
showStatusToast('Build tools ready!', 'success');
|
|
termState.devToolsInstalled = true;
|
|
termState.javaToolsInstalled = true;
|
|
return true;
|
|
}
|
|
|
|
termPrint('[OK] Java tools ready (aapt2 optional)', 'success');
|
|
termState.devToolsInstalled = true;
|
|
termState.javaToolsInstalled = true;
|
|
return true;
|
|
}
|
|
|
|
async function tryPkgInstall(prefixUsr) {
|
|
var pkgBin = prefixUsr + '/bin/pkg';
|
|
var pkgTest = await shellExec('test -f "' + pkgBin + '"', termState.homeDir, false);
|
|
if (pkgTest.exitCode !== 0) return false;
|
|
|
|
var cmd = 'export PREFIX="' + prefixUsr + '" LD_LIBRARY_PATH="' + prefixUsr + '/lib" PATH="' + prefixUsr + '/bin:/system/bin:$PATH" && sh "' + pkgBin + '" update -y 2>&1 && sh "' + pkgBin + '" install -y aapt2 ecj dx apksigner 2>&1';
|
|
var result = await shellExec(cmd, termState.homeDir, false);
|
|
if (result.output && result.output.length > 200) {
|
|
termPrint(result.output.substring(result.output.length - 200), '');
|
|
}
|
|
return await toolsReady();
|
|
}
|
|
|
|
async function toolsReady() {
|
|
var termuxCheck = await shellExec('command -v aapt2 >/dev/null 2>&1 && command -v ecj >/dev/null 2>&1', termState.homeDir, false);
|
|
if (termuxCheck.exitCode === 0) {
|
|
showStatusToast('Build tools installed!', 'success');
|
|
termState.devToolsInstalled = true;
|
|
return true;
|
|
}
|
|
if (termState.toolsDir) {
|
|
var javaCheck = await shellExec('test -f "' + termState.toolsDir + '/jars/ecj.jar" && test -f "' + termState.toolsDir + '/jars/d8.jar"', termState.homeDir, false);
|
|
if (javaCheck.exitCode === 0) {
|
|
termState.devToolsInstalled = true;
|
|
termState.javaToolsInstalled = true;
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
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';
|
|
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, "'\\''") + '\'';
|
|
|
|
termPrint('[*] Running pkg via PRoot...', 'info');
|
|
var result = await shellExec(wrappedCmd, termState.homeDir, false);
|
|
if (result.output) {
|
|
var out = result.output;
|
|
if (out.length > 2000) out = out.substring(0, 1000) + '\n... truncated ...\n' + out.substring(out.length - 800);
|
|
termPrint(out.replace(/\n$/, ''), '');
|
|
}
|
|
if (result.exitCode === 0 || result.output.indexOf('Setting up') !== -1) {
|
|
if (await toolsReady()) return true;
|
|
}
|
|
termPrint('[!] PRoot strategy failed (exit ' + result.exitCode + ')', 'err');
|
|
return false;
|
|
}
|
|
|
|
async function tryTermuxInstall() {
|
|
if (!Bootstrap) return false;
|
|
var termuxInfo;
|
|
try { termuxInfo = await Bootstrap.isTermuxInstalled(); } catch(e) { return false; }
|
|
|
|
if (!termuxInfo.installed) {
|
|
termPrint('[!] Termux not installed. Opening F-Droid...', 'warning');
|
|
try { await Bootstrap.openTermuxPage(); } catch(e) {}
|
|
termPrint('[*] After installing Termux:', 'info');
|
|
termPrint(' 1. Open Termux app', 'info');
|
|
termPrint(' 2. Run: pkg update && pkg install aapt2 ecj dx apksigner', 'info');
|
|
termPrint(' 3. Come back to Z.AI Chat', 'info');
|
|
return false;
|
|
}
|
|
|
|
termPrint('[OK] Termux detected! Sending install command...', 'success');
|
|
try {
|
|
await Bootstrap.runInTermux({command: 'pkg update -y && pkg install -y aapt2 ecj dx apksigner'});
|
|
termPrint('[*] Command sent to Termux. Waiting...', 'info');
|
|
await new Promise(function(r) { setTimeout(r, 15000); });
|
|
if (await toolsReady()) return true;
|
|
await new Promise(function(r) { setTimeout(r, 30000); });
|
|
if (await toolsReady()) return true;
|
|
} catch(e) {
|
|
termPrint('[!] Termux RUN_COMMAND failed: ' + e.message, 'err');
|
|
}
|
|
return false;
|
|
}
|
|
|
|
async function checkAutoGLMStatus() {
|
|
var AutoGLM = window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.AutoGLM;
|
|
var statusEl = $('#autoglm-status');
|
|
var btn = $('#autoglm-enable-btn');
|
|
if (!AutoGLM || !statusEl) return;
|
|
try {
|
|
var result = await AutoGLM.isEnabled();
|
|
if (result.enabled) {
|
|
statusEl.innerHTML = '<span style="color:var(--success)">Device control enabled</span>';
|
|
if (btn) { btn.textContent = 'Device Control Active'; btn.disabled = true; }
|
|
} else {
|
|
statusEl.innerHTML = '<span style="color:var(--warning)">Not enabled — tap to open Settings</span>';
|
|
}
|
|
} catch(e) {
|
|
statusEl.innerHTML = '<span style="color:var(--text-secondary)">Checking status...</span>';
|
|
}
|
|
}
|
|
|
|
async function checkDevEnvironment() {
|
|
if (state.currentMode !== 'coding' && state.currentMode !== 'agentic') return;
|
|
|
|
if (!Bootstrap) return;
|
|
try {
|
|
var bsStatus = await Bootstrap.getStatus();
|
|
if (!bsStatus.installed) {
|
|
showDevToolsBanner('Termux not installed. Tap Dev Setup to install Linux environment + build tools.');
|
|
return;
|
|
}
|
|
} catch(e) {}
|
|
|
|
if (termState.devToolsInstalled) return;
|
|
|
|
var termuxCheck = await shellExec('command -v aapt2 >/dev/null 2>&1 && command -v ecj >/dev/null 2>&1', termState.homeDir, false);
|
|
if (termuxCheck.exitCode === 0) {
|
|
termState.devToolsInstalled = true;
|
|
return;
|
|
}
|
|
|
|
if (termState.toolsDir) {
|
|
var javaCheck = await shellExec('test -f "' + termState.toolsDir + '/jars/ecj.jar" && test -f "' + termState.toolsDir + '/jars/d8.jar"', termState.homeDir, false);
|
|
if (javaCheck.exitCode === 0) {
|
|
termState.devToolsInstalled = true;
|
|
termState.javaToolsInstalled = true;
|
|
return;
|
|
}
|
|
}
|
|
|
|
showDevToolsBanner('Build tools not installed. Tap to auto-install via Java virtual environment.');
|
|
}
|
|
|
|
function showDevToolsBanner(msg) {
|
|
var existing = $('#dev-tools-banner');
|
|
if (existing) existing.remove();
|
|
|
|
var container = $('#messages');
|
|
if (!container) return;
|
|
|
|
var banner = document.createElement('div');
|
|
banner.id = 'dev-tools-banner';
|
|
banner.className = 'dev-tools-banner';
|
|
banner.innerHTML = '<span class="dtb-icon">⚠</span> ' +
|
|
'<span class="dtb-msg">' + msg + '</span>' +
|
|
'<button class="dtb-install-btn">Install</button>' +
|
|
'<button class="dtb-dismiss-btn">×</button>';
|
|
|
|
container.insertBefore(banner, container.firstChild);
|
|
|
|
banner.querySelector('.dtb-install-btn').addEventListener('click', async function() {
|
|
var btn = this;
|
|
btn.textContent = 'Installing...';
|
|
btn.disabled = true;
|
|
|
|
var bsStatus;
|
|
try { bsStatus = await Bootstrap.getStatus(); } catch(e) { bsStatus = { installed: false }; }
|
|
|
|
if (!bsStatus.installed) {
|
|
try {
|
|
await Bootstrap.install();
|
|
try { await Bootstrap.fixPermissions(); } catch(e) {}
|
|
if (Shell) {
|
|
var env = await Shell.getEnv();
|
|
termState.homeDir = env.HOME;
|
|
termState.toolsDir = env.TOOLS;
|
|
termState.projectsDir = env.PROJECTS;
|
|
termState.cwd = env.CWD || env.HOME;
|
|
}
|
|
updateCwdDisplay();
|
|
await shellExec('echo shell-ok', termState.homeDir, false);
|
|
} catch(e) {
|
|
btn.textContent = 'Bootstrap failed: ' + e.message;
|
|
return;
|
|
}
|
|
}
|
|
|
|
var ok = await ensureBuildTools();
|
|
if (ok) {
|
|
banner.remove();
|
|
showStatusToast('All tools installed!', 'success');
|
|
} else {
|
|
btn.textContent = 'Retry';
|
|
btn.disabled = false;
|
|
}
|
|
});
|
|
|
|
banner.querySelector('.dtb-dismiss-btn').addEventListener('click', function() {
|
|
banner.remove();
|
|
});
|
|
}
|
|
|
|
async function autoExecuteActions(actions, conv) {
|
|
var hasFiles = actions.some(function(a) { return a.type === 'create_file'; });
|
|
var hasBuild = actions.some(function(a) { return a.type === 'build_apk'; });
|
|
var hasInstall = actions.some(function(a) { return a.type === 'install_apk'; });
|
|
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 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; });
|
|
var hasSsh = actions.some(function(a) { return a.type && a.type.indexOf('ssh_') === 0; });
|
|
var hasRemote = actions.some(function(a) { return a.type === 'remote_exec' || a.type === 'curl_exec'; });
|
|
|
|
if (!hasFiles && !hasBuild && !hasInstall && !hasCommands && !hasDevice && !hasHermes && !hasVenv && !hasSsh && !hasRemote) return;
|
|
_agenticRetryCount = 0;
|
|
var resultLog = [];
|
|
|
|
var fileCount = actions.filter(function(a) { return a.type === 'create_file'; }).length;
|
|
if (fileCount > 0) {
|
|
showStatusToast('Deploying ' + fileCount + ' file' + (fileCount > 1 ? 's' : '') + '...', 'info');
|
|
for (var i = 0; i < actions.length; i++) {
|
|
if (actions[i].type === 'create_file') {
|
|
var deployOk = await autoDeployFile(actions[i]);
|
|
resultLog.push(deployOk);
|
|
}
|
|
}
|
|
showStatusToast(fileCount + ' file' + (fileCount > 1 ? 's' : '') + ' deployed', 'success');
|
|
}
|
|
|
|
if (hasCommands) {
|
|
for (var c = 0; c < actions.length; c++) {
|
|
if (actions[c].type === 'run_command') {
|
|
var cmd = actions[c].command;
|
|
showStatusToast('Running: ' + cmd.substring(0, 40), 'info');
|
|
var cmdApproved = true;
|
|
if (needsApprovalForCommand(cmd)) {
|
|
cmdApproved = await askApproval('Run command?', cmd, function() { return true; });
|
|
}
|
|
if (!cmdApproved) {
|
|
termPrint('[DENIED] ' + cmd, 'warning');
|
|
resultLog.push('DENIED: ' + cmd.substring(0, 60));
|
|
continue;
|
|
}
|
|
var cmdResult = await shellExec(cmd, termState.cwd, false);
|
|
resultLog.push('CMD: ' + cmd.substring(0, 60) + '\nexit: ' + (cmdResult.exitCode !== undefined ? cmdResult.exitCode : '?') + '\n' + (cmdResult.output || '').substring(0, 500));
|
|
if (cmdResult.output) {
|
|
termPrint('$ ' + cmd, 'cmd');
|
|
termPrint(cmdResult.output.replace(/\n$/, ''), cmdResult.exitCode === 0 ? '' : 'err');
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (hasSsh) {
|
|
for (var s = 0; s < actions.length; s++) {
|
|
var sAct = actions[s];
|
|
if (sAct.type.indexOf('ssh_') !== 0) continue;
|
|
var sshCmd = '';
|
|
var sshLabel = '';
|
|
if (sAct.type === 'ssh_exec') {
|
|
sshCmd = 'ssh -o StrictHostKeyChecking=no -o ConnectTimeout=10 ' + sAct.host + ' ' + sAct.command;
|
|
sshLabel = 'SSH: ' + sAct.host + ' → ' + sAct.command.substring(0, 60);
|
|
} else if (sAct.type === 'ssh_upload') {
|
|
sshCmd = 'scp -o StrictHostKeyChecking=no -o ConnectTimeout=10 ' + sAct.localPath + ' ' + sAct.remotePath;
|
|
sshLabel = 'SCP upload: ' + sAct.localPath + ' → ' + sAct.remotePath;
|
|
} else if (sAct.type === 'ssh_download') {
|
|
sshCmd = 'scp -o StrictHostKeyChecking=no -o ConnectTimeout=10 ' + sAct.remotePath + ' ' + sAct.localPath;
|
|
sshLabel = 'SCP download: ' + sAct.remotePath + ' → ' + sAct.localPath;
|
|
}
|
|
var sshOk = await askApproval(sshLabel, sshCmd, function() { return true; });
|
|
if (!sshOk) {
|
|
termPrint('[DENIED] ' + sshLabel, 'warning');
|
|
resultLog.push('DENIED: ' + sshLabel);
|
|
continue;
|
|
}
|
|
termPrint('$ ' + sshCmd, 'cmd');
|
|
var sshResult = await shellExec(sshCmd, termState.cwd, false);
|
|
resultLog.push('SSH: ' + sshLabel + '\nexit: ' + (sshResult.exitCode !== undefined ? sshResult.exitCode : '?') + '\n' + (sshResult.output || '').substring(0, 500));
|
|
termPrint((sshResult.output || '').replace(/\n$/, ''), sshResult.exitCode === 0 ? '' : 'err');
|
|
if (sshResult.exitCode !== 0 && sshResult.exitCode !== undefined) {
|
|
termPrint('[SSH exit: ' + sshResult.exitCode + ']', 'err');
|
|
}
|
|
}
|
|
}
|
|
|
|
if (hasRemote) {
|
|
for (var r = 0; r < actions.length; r++) {
|
|
var rAct = actions[r];
|
|
if (rAct.type === 'remote_exec') {
|
|
var reCmd = 'ssh -o StrictHostKeyChecking=no -o ConnectTimeout=10 ' + rAct.host + ' ' + rAct.command;
|
|
var reOk = await askApproval('Remote exec: ' + rAct.host, reCmd, function() { return true; });
|
|
if (!reOk) { termPrint('[DENIED] ' + rAct.host, 'warning'); continue; }
|
|
termPrint('$ ' + reCmd, 'cmd');
|
|
var reResult = await shellExec(reCmd, termState.cwd, false);
|
|
resultLog.push('REMOTE: ' + rAct.host + '\n' + (reResult.output || '').substring(0, 500));
|
|
termPrint((reResult.output || '').replace(/\n$/, ''), reResult.exitCode === 0 ? '' : 'err');
|
|
} else if (rAct.type === 'curl_exec') {
|
|
var curlCmd = 'curl -sL -o - ' + rAct.url;
|
|
var curlOk = await askApproval('Fetch URL?', rAct.url, function() { return true; });
|
|
if (!curlOk) { termPrint('[DENIED] curl ' + rAct.url, 'warning'); continue; }
|
|
termPrint('$ ' + curlCmd, 'cmd');
|
|
var curlResult = await shellExec(curlCmd, termState.cwd, false);
|
|
resultLog.push('CURL: ' + rAct.url + '\n' + (curlResult.output || '').substring(0, 500));
|
|
termPrint((curlResult.output || '').replace(/\n$/, ''), '');
|
|
}
|
|
}
|
|
}
|
|
|
|
if (hasDevice) {
|
|
for (var d = 0; d < actions.length; d++) {
|
|
var act = actions[d];
|
|
if (act.type.indexOf('device_') !== 0) continue;
|
|
try {
|
|
var devResult = await executeDeviceAction(act);
|
|
resultLog.push(devResult);
|
|
termPrint(devResult, '');
|
|
} catch(e) {
|
|
resultLog.push('DEVICE_ERROR: ' + e.message);
|
|
termPrint('[!] Device: ' + e.message, 'err');
|
|
}
|
|
}
|
|
}
|
|
|
|
if (hasHermes) {
|
|
for (var h = 0; h < actions.length; h++) {
|
|
var hAct = actions[h];
|
|
if (hAct.type.indexOf('hermes_') !== 0) continue;
|
|
try {
|
|
var hermesResult = await executeHermesAction(hAct);
|
|
resultLog.push(hermesResult);
|
|
termPrint(hermesResult, '');
|
|
} catch(e) {
|
|
resultLog.push('HERMES_ERROR: ' + e.message);
|
|
termPrint('[!] Hermes: ' + e.message, 'err');
|
|
}
|
|
}
|
|
}
|
|
|
|
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) {
|
|
showStatusToast('Building APK...', 'info');
|
|
var toolsReady = await ensureBuildTools();
|
|
if (!toolsReady) {
|
|
var buildFail = '[BUILD FAILED] Build tools not available. Install: pkg install aapt2 ecj dx apksigner';
|
|
resultLog.push(buildFail);
|
|
await agenticRetryOnError(buildFail, conv);
|
|
return;
|
|
}
|
|
var buildResult = await autoBuildApk(actions);
|
|
resultLog.push(buildResult);
|
|
if (buildResult.indexOf('[BUILD FAILED]') >= 0) {
|
|
await agenticRetryOnError(buildResult, conv);
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (hasInstall) {
|
|
for (var j = 0; j < actions.length; j++) {
|
|
if (actions[j].type === 'install_apk') {
|
|
showStatusToast('Installing APK...', 'info');
|
|
await installApk(actions[j].path);
|
|
resultLog.push('INSTALL: ' + actions[j].path);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
async function executeDeviceAction(action) {
|
|
var AutoGLM = window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.AutoGLM;
|
|
if (!AutoGLM) throw new Error('AutoGLM plugin not available');
|
|
|
|
switch (action.type) {
|
|
case 'device_tap':
|
|
await AutoGLM.tap({ x: action.x, y: action.y });
|
|
return '[DEVICE] Tap(' + action.x + ', ' + action.y + ')';
|
|
case 'device_long_press':
|
|
await AutoGLM.longPress({ x: action.x, y: action.y });
|
|
return '[DEVICE] LongPress(' + action.x + ', ' + action.y + ')';
|
|
case 'device_swipe':
|
|
await AutoGLM.swipe({ startX: action.startX, startY: action.startY, endX: action.endX, endY: action.endY });
|
|
return '[DEVICE] Swipe(' + action.startX + ',' + action.startY + ' -> ' + action.endX + ',' + action.endY + ')';
|
|
case 'device_type':
|
|
await AutoGLM.typeText({ text: action.text });
|
|
return '[DEVICE] Type: "' + action.text + '"';
|
|
case 'device_press_back':
|
|
await AutoGLM.pressBack();
|
|
return '[DEVICE] Press Back';
|
|
case 'device_press_home':
|
|
await AutoGLM.pressHome();
|
|
return '[DEVICE] Press Home';
|
|
case 'device_press_recents':
|
|
await AutoGLM.pressRecents();
|
|
return '[DEVICE] Press Recents';
|
|
case 'device_screenshot':
|
|
var ssResult = await AutoGLM.takeScreenshot({});
|
|
return '[DEVICE] Screenshot: ' + (ssResult.path || 'saved');
|
|
case 'device_ui_tree':
|
|
var treeResult = await AutoGLM.getUITree({});
|
|
var tree = treeResult.tree || '{}';
|
|
return '[DEVICE] UI Tree: ' + tree.substring(0, 2000);
|
|
case 'device_click_text':
|
|
var clickResult = await AutoGLM.clickByText({ text: action.text });
|
|
return '[DEVICE] Click "' + action.text + '": ' + (clickResult.ok ? 'OK' : 'not found');
|
|
case 'device_click_id':
|
|
var idResult = await AutoGLM.clickNode({ viewId: action.viewId });
|
|
return '[DEVICE] Click ID "' + action.viewId + '": ' + (idResult.ok ? 'OK' : 'not found');
|
|
case 'device_launch':
|
|
await AutoGLM.launchApp({ package: action.pkg });
|
|
return '[DEVICE] Launch: ' + action.pkg;
|
|
case 'device_current_app':
|
|
var appResult = await AutoGLM.getCurrentApp({});
|
|
return '[DEVICE] Current app: ' + (appResult.package || 'unknown');
|
|
default:
|
|
return '[DEVICE] Unknown action: ' + action.type;
|
|
}
|
|
}
|
|
|
|
async function executeHermesAction(action) {
|
|
var Bootstrap = window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.Bootstrap;
|
|
if (!Bootstrap) throw new Error('Bootstrap plugin not available');
|
|
|
|
switch (action.type) {
|
|
case 'hermes_install':
|
|
showStatusToast('Installing Hermes agent...', 'info');
|
|
var installResult = await Bootstrap.installHermes({});
|
|
termState.hermesPath = installResult.path;
|
|
termState.hermesVenv = installResult.venv;
|
|
return '[HERMES] Installed: ' + installResult.path;
|
|
case 'hermes_exec':
|
|
if (!termState.hermesVenv) {
|
|
try {
|
|
var status = await Bootstrap.installHermes({});
|
|
termState.hermesPath = status.path;
|
|
termState.hermesVenv = status.venv;
|
|
} catch(e) {
|
|
throw new Error('Hermes not installed: ' + e.message);
|
|
}
|
|
}
|
|
showStatusToast('Hermes: ' + action.command.substring(0, 30), 'info');
|
|
var execResult = await Bootstrap.hermesExec({ command: action.command, venv: termState.hermesVenv });
|
|
var hermesOutput = execResult.output || '';
|
|
if (hermesOutput.length > 3000) hermesOutput = hermesOutput.substring(0, 1500) + '\n...truncated...\n' + hermesOutput.substring(hermesOutput.length - 1000);
|
|
return '[HERMES] ' + action.command + '\n' + hermesOutput;
|
|
default:
|
|
return '[HERMES] Unknown action: ' + action.type;
|
|
}
|
|
}
|
|
|
|
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) {
|
|
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) {
|
|
return '[OK] ' + path + ' (' + action.content.length + ' bytes)';
|
|
} else {
|
|
return '[FAIL] ' + path;
|
|
}
|
|
}
|
|
|
|
async function autoBuildApk(actions) {
|
|
var projectDir = termState.projectsDir || (termState.homeDir + '/projects');
|
|
|
|
for (var i = 0; i < actions.length; i++) {
|
|
if (actions[i].type === 'create_file') {
|
|
await autoDeployFile(actions[i]);
|
|
}
|
|
}
|
|
|
|
var toolsDir = termState.toolsDir || (termState.homeDir + '/tools');
|
|
var buildScriptPath = toolsDir + '/build.sh';
|
|
await shellMkdirs(toolsDir);
|
|
await shellWriteFile(buildScriptPath, BUILD_SCRIPT);
|
|
await shellExec('chmod +x ' + buildScriptPath, termState.homeDir, false);
|
|
|
|
termPrint('\n--- Building APK ---', 'info');
|
|
var result = await shellExec('cd ' + projectDir + ' && sh ' + buildScriptPath, termState.homeDir, false);
|
|
var output = result.output || '';
|
|
termPrint(output.replace(/\n$/, ''), result.exitCode === 0 ? '' : 'err');
|
|
|
|
if (output.indexOf('[BUILD OK]') >= 0) {
|
|
var apkPath = projectDir + '/build/app-signed.apk';
|
|
var verifyResult = await shellExec('ls -la ' + apkPath + ' 2>&1', termState.homeDir, false);
|
|
if (verifyResult.output && verifyResult.output.indexOf('No such file') < 0) {
|
|
var sizeMatch = verifyResult.output.match(/(\d+)\s+/);
|
|
var sizeInfo = sizeMatch ? ' (' + Math.round(parseInt(sizeMatch[1]) / 1024) + ' KB)' : '';
|
|
var builtName = projectDir.split('/').pop() + '.apk';
|
|
var Installer = window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.Installer;
|
|
var exportedPath = apkPath;
|
|
try {
|
|
if (Installer) {
|
|
var exportResult = await Installer.exportApk({ path: apkPath, name: builtName });
|
|
exportedPath = exportResult.path || apkPath;
|
|
state.lastBuiltApkPath = exportedPath;
|
|
}
|
|
} catch(e) {
|
|
termPrint('[!] Export to real folder failed: ' + e.message, 'warning');
|
|
}
|
|
termPrint('\n[VERIFIED] APK built successfully: ' + exportedPath + sizeInfo, 'success');
|
|
if (state.autoInstallBuiltApk && Installer) {
|
|
try {
|
|
await Installer.installApk({ path: exportedPath });
|
|
termPrint('[OK] Installer opened for built APK', 'success');
|
|
} catch(e) {
|
|
termPrint('[!] Auto-install failed: ' + e.message, 'warning');
|
|
}
|
|
}
|
|
return '[BUILD OK] ' + exportedPath;
|
|
} else {
|
|
termPrint('\n[VERIFY FAILED] Build claimed success but APK not found at ' + apkPath, 'err');
|
|
return '[BUILD FAILED] APK file not found after build. Output:\n' + output.substring(0, 1000);
|
|
}
|
|
} else if (output.indexOf('[BUILD FAILED]') >= 0) {
|
|
return '[BUILD FAILED] ' + output;
|
|
} else if (result.exitCode !== 0) {
|
|
return '[BUILD FAILED] exit=' + result.exitCode + '\n' + output.substring(0, 4000);
|
|
}
|
|
return output.substring(0, 500);
|
|
}
|
|
|
|
async function agenticRetryOnError(errorOutput, conv) {
|
|
_agenticRetryCount++;
|
|
if (_agenticRetryCount > getMaxRetries()) {
|
|
termPrint('\n[!] Max retries reached (' + getMaxRetries() + '). Fix manually or ask again.', 'err');
|
|
showStatusToast('Build failed after ' + getMaxRetries() + ' retries', 'err');
|
|
return;
|
|
}
|
|
|
|
termPrint('\n[!] Build failed. Asking AI to fix (attempt ' + _agenticRetryCount + '/' + getMaxRetries() + ')...', 'warning');
|
|
showStatusToast('Build failed — AI auto-fixing (attempt ' + _agenticRetryCount + ')...', 'err');
|
|
|
|
if (!conv || !state.apiKey) return;
|
|
|
|
var fixMessage = 'The build failed with this error:\n\n```\n' +
|
|
errorOutput.substring(0, 4000) +
|
|
'\n```\n\nPlease fix the code and rebuild. Write ALL corrected files and use [BUILD_APK] again.';
|
|
|
|
if (state.keepAwake) setWakeLock(true);
|
|
|
|
try {
|
|
var conv2 = getConversation(conv.id) || conv;
|
|
conv2.messages.push({ role: 'user', content: fixMessage });
|
|
saveState();
|
|
appendMessage('user', fixMessage);
|
|
|
|
state.isGenerating = true;
|
|
state.streamingConvId = conv2.id;
|
|
state.streamingContent = '';
|
|
updateSendButton();
|
|
showThinking();
|
|
|
|
var systemPrompt = MODE_PROMPTS.agentic;
|
|
var apiMessages = [{ role: 'system', content: systemPrompt }];
|
|
conv2.messages.forEach(function(m) {
|
|
if (m.role === 'user' || (m.role === 'assistant' && !m._streaming)) {
|
|
apiMessages.push({ role: m.role, content: m.content });
|
|
}
|
|
});
|
|
|
|
var requestBody = {
|
|
model: state.model,
|
|
messages: apiMessages,
|
|
temperature: state.temperature,
|
|
max_tokens: state.maxTokens,
|
|
stream: state.streaming
|
|
};
|
|
|
|
removeThinking();
|
|
var responseDiv = appendMessage('assistant', '');
|
|
state.streamingResponseDiv = responseDiv;
|
|
|
|
if (state.streaming) {
|
|
await streamResponseWithRetry(requestBody, responseDiv, conv2);
|
|
} else {
|
|
var res = await apiRequestWithRetry(requestBody);
|
|
updateStreamingMessage(responseDiv, res.choices[0].message.content);
|
|
state.streamingContent = res.choices[0].message.content;
|
|
conv2.messages.push({ role: 'assistant', content: res.choices[0].message.content });
|
|
}
|
|
|
|
var fixContent = state.streamingContent || '';
|
|
if (fixContent) {
|
|
conv2.messages.push({ role: 'assistant', content: fixContent });
|
|
}
|
|
} catch(err) {
|
|
termPrint('[!] Auto-fix failed: ' + err.message, 'err');
|
|
} finally {
|
|
state.isGenerating = false;
|
|
state.abortController = null;
|
|
state.streamingConvId = null;
|
|
state.streamingResponseDiv = null;
|
|
updateSendButton();
|
|
saveState();
|
|
if (state.keepAwake) setWakeLock(false);
|
|
|
|
var fixActions = parseAiActions(state.streamingContent || '');
|
|
if (fixActions.length > 0 && _agenticRetryCount <= getMaxRetries()) {
|
|
await autoExecuteActions(fixActions, conv);
|
|
}
|
|
}
|
|
}
|
|
|
|
async function buildFromActions(actions) {
|
|
showScreen('terminal');
|
|
termPrint('\n--- Building APK ---', 'info');
|
|
|
|
var buildResult = await autoBuildApk(actions);
|
|
termPrint(buildResult, buildResult.indexOf('[BUILD FAILED]') >= 0 ? 'err' : 'success');
|
|
if (state.autoInstallBuiltApk && buildResult.indexOf('[BUILD OK]') >= 0) {
|
|
var Installer = window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.Installer;
|
|
if (Installer && state.lastBuiltApkPath) {
|
|
await Installer.installApk({ path: state.lastBuiltApkPath });
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---- 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 (!Bootstrap) return false;
|
|
try {
|
|
var status = await Bootstrap.getStatus();
|
|
return status.installed === true;
|
|
} catch(e) { return false; }
|
|
}
|
|
|
|
async function setupDevTools() {
|
|
if (!Bootstrap) {
|
|
alert('Bootstrap 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';
|
|
progressText.textContent = 'Starting...';
|
|
|
|
Bootstrap.addListener('bootstrap-progress', function(event) {
|
|
if (progressFill) progressFill.style.width = event.percent + '%';
|
|
if (progressText) progressText.textContent = event.message;
|
|
});
|
|
|
|
try {
|
|
await Bootstrap.install();
|
|
|
|
progressText.textContent = 'Fixing file permissions...';
|
|
try { await Bootstrap.fixPermissions(); } catch(e) {}
|
|
|
|
if (Shell) {
|
|
var env = await Shell.getEnv();
|
|
termState.homeDir = env.HOME;
|
|
termState.toolsDir = env.TOOLS;
|
|
termState.projectsDir = env.PROJECTS;
|
|
termState.cwd = env.CWD || env.HOME;
|
|
}
|
|
updateCwdDisplay();
|
|
|
|
var shellTest = await shellExec('echo OK', termState.homeDir, false);
|
|
if (shellTest.exitCode !== 0) {
|
|
statusEl.innerHTML = '<p style="color:var(--danger)">Shell test failed: ' + (shellTest.output || 'unknown error') + '</p>' +
|
|
'<p>Try restarting the app.</p>';
|
|
btn.disabled = false;
|
|
btn.querySelector('.btn-text').textContent = 'Retry Install';
|
|
btn.querySelector('.btn-loader').style.display = 'none';
|
|
return;
|
|
}
|
|
|
|
statusEl.innerHTML = '<p style="color:var(--success);font-size:16px;font-weight:700">✔ Termux environment installed!</p>' +
|
|
'<p id="devsetup-tools-status">Installing build tools (aapt2, ecj, d8, apksigner)...</p>';
|
|
|
|
var toolsOk = await ensureBuildTools();
|
|
var toolsStatus = $('#devsetup-tools-status');
|
|
if (toolsStatus) {
|
|
toolsStatus.innerHTML = toolsOk
|
|
? '<span style="color:var(--success)">✔ All tools installed — ready to build!</span>'
|
|
: '<span style="color:var(--warning)">Build tools not installed.<br>1. Install Termux from F-Droid<br>2. Run: pkg install aapt2 ecj dx apksigner<br>3. Z.AI Chat will auto-detect Termux tools.</span>';
|
|
}
|
|
|
|
btn.querySelector('.btn-text').textContent = 'Installed';
|
|
btn.querySelector('.btn-loader').style.display = 'none';
|
|
} catch(e) {
|
|
statusEl.innerHTML = '<p style="color:var(--danger)">Install failed: ' + e.message + '</p>' +
|
|
'<p>Check your internet connection and try again.</p>';
|
|
btn.disabled = false;
|
|
btn.querySelector('.btn-text').textContent = 'Retry Install';
|
|
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');
|
|
});
|
|
}
|
|
|
|
var autoglmBtn = $('#autoglm-enable-btn');
|
|
if (autoglmBtn) {
|
|
checkAutoGLMStatus();
|
|
autoglmBtn.addEventListener('click', function() {
|
|
var AutoGLM = window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.AutoGLM;
|
|
if (AutoGLM) {
|
|
AutoGLM.openSettings();
|
|
}
|
|
});
|
|
}
|
|
|
|
var hermesBtn = $('#hermes-install-btn');
|
|
if (hermesBtn) {
|
|
hermesBtn.addEventListener('click', async function() {
|
|
var Bootstrap = window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.Bootstrap;
|
|
if (!Bootstrap) return;
|
|
hermesBtn.disabled = true;
|
|
hermesBtn.textContent = 'Installing Hermes...';
|
|
try {
|
|
var result = await Bootstrap.installHermes({});
|
|
hermesBtn.textContent = 'Hermes Installed!';
|
|
hermesBtn.disabled = true;
|
|
termState.hermesPath = result.path;
|
|
termState.hermesVenv = result.venv;
|
|
termPrint('[OK] Hermes installed: ' + result.path, 'success');
|
|
} catch(e) {
|
|
hermesBtn.textContent = 'Install Failed - Retry';
|
|
hermesBtn.disabled = false;
|
|
termPrint('[!] Hermes install failed: ' + e.message, 'err');
|
|
}
|
|
});
|
|
}
|
|
|
|
$$('.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 v3.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) {
|
|
var url = (baseUrl || state.baseUrl).replace(/\/+$/, '') + '/chat/completions';
|
|
var resp = await fetch(url, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Authorization': 'Bearer ' + apiKey,
|
|
'Accept-Language': 'en-US,en'
|
|
},
|
|
body: JSON.stringify({
|
|
model: state.model,
|
|
messages: [{ role: 'user', content: 'Hi' }],
|
|
max_tokens: 10
|
|
})
|
|
});
|
|
if (!resp.ok) {
|
|
var errData = {};
|
|
try { errData = await resp.json(); } catch(e) {}
|
|
throw new Error(errData.error?.message || 'Connection failed (' + resp.status + ')');
|
|
}
|
|
return await resp.json();
|
|
}
|
|
|
|
function populateSettings() {
|
|
$('#settings-token').value = state.apiKey;
|
|
$('#settings-url').value = state.baseUrl;
|
|
$('#settings-model').value = state.model;
|
|
$('#settings-temp').value = state.temperature;
|
|
$('#temp-value').textContent = state.temperature;
|
|
$('#settings-tokens').value = state.maxTokens;
|
|
$('#tokens-value').textContent = state.maxTokens;
|
|
$('#settings-websearch').checked = state.webSearch;
|
|
$('#settings-streaming').checked = state.streaming;
|
|
$('#settings-autodeploy').checked = state.autoDeploy;
|
|
$('#settings-keepawake').checked = state.keepAwake;
|
|
$('#settings-maxretries').value = state.maxRetries;
|
|
$('#retries-value').textContent = state.maxRetries;
|
|
$('#settings-autocontinue').checked = state.autoContinue;
|
|
$('#settings-maxautocontinue').value = state.maxAutoContinue;
|
|
$('#autocont-value').textContent = state.maxAutoContinue;
|
|
}
|
|
|
|
function saveSettings() {
|
|
state.apiKey = $('#settings-token').value.trim();
|
|
state.baseUrl = $('#settings-url').value.trim();
|
|
state.model = $('#settings-model').value;
|
|
state.temperature = parseFloat($('#settings-temp').value);
|
|
state.maxTokens = parseInt($('#settings-tokens').value);
|
|
state.webSearch = $('#settings-websearch').checked;
|
|
state.streaming = $('#settings-streaming').checked;
|
|
saveState();
|
|
}
|
|
|
|
function exportConversations() {
|
|
var data = JSON.stringify(state.conversations, null, 2);
|
|
var blob = new Blob([data], { type: 'application/json' });
|
|
var url = URL.createObjectURL(blob);
|
|
var a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = 'zai-chat-export-' + new Date().toISOString().slice(0, 10) + '.json';
|
|
a.click();
|
|
URL.revokeObjectURL(url);
|
|
}
|
|
|
|
function init() {
|
|
loadState();
|
|
|
|
initShellPlugins();
|
|
|
|
if (state.apiKey) {
|
|
showScreen('chat');
|
|
if (state.activeConversationId) {
|
|
var conv = getConversation();
|
|
if (conv) {
|
|
state.currentMode = conv.mode || 'chat';
|
|
}
|
|
}
|
|
renderConversationList();
|
|
renderMessages();
|
|
updateHeader();
|
|
updateModeSelector();
|
|
updateTerminalVisibility();
|
|
$('#api-token').value = state.apiKey;
|
|
$('#base-url').value = state.baseUrl;
|
|
}
|
|
|
|
$('#connect-btn').addEventListener('click', async function() {
|
|
var btn = this;
|
|
var apiKey = $('#api-token').value.trim();
|
|
var baseUrl = $('#base-url').value;
|
|
var errorEl = $('#setup-error');
|
|
|
|
if (!apiKey) {
|
|
errorEl.textContent = 'Please enter your API key';
|
|
errorEl.style.display = 'block';
|
|
return;
|
|
}
|
|
|
|
btn.disabled = true;
|
|
btn.querySelector('.btn-text').textContent = 'Connecting...';
|
|
btn.querySelector('.btn-loader').style.display = 'inline-block';
|
|
errorEl.style.display = 'none';
|
|
|
|
try {
|
|
await testConnection(apiKey, baseUrl);
|
|
state.apiKey = apiKey;
|
|
state.baseUrl = baseUrl;
|
|
saveState();
|
|
showScreen('chat');
|
|
newConversation();
|
|
} catch(err) {
|
|
errorEl.textContent = err.message;
|
|
errorEl.style.display = 'block';
|
|
} finally {
|
|
btn.disabled = false;
|
|
btn.querySelector('.btn-text').textContent = 'Connect';
|
|
btn.querySelector('.btn-loader').style.display = 'none';
|
|
}
|
|
});
|
|
|
|
$('#api-token').addEventListener('keydown', function(e) {
|
|
if (e.key === 'Enter') $('#connect-btn').click();
|
|
});
|
|
|
|
$('#message-input').addEventListener('input', function() {
|
|
autoResize(this);
|
|
updateSendButton();
|
|
});
|
|
|
|
$('#message-input').addEventListener('keydown', function(e) {
|
|
if (e.key === 'Enter' && !e.shiftKey) {
|
|
e.preventDefault();
|
|
sendMessage();
|
|
}
|
|
});
|
|
|
|
$('#send-btn').addEventListener('click', sendMessage);
|
|
$('#stop-btn').addEventListener('click', stopGeneration);
|
|
|
|
$$('.mode-btn').forEach(function(btn) {
|
|
btn.addEventListener('click', function() {
|
|
var mode = this.dataset.mode;
|
|
if (mode === 'terminal') {
|
|
showScreen('terminal');
|
|
return;
|
|
}
|
|
state.currentMode = mode;
|
|
updateModeSelector();
|
|
updateHeader();
|
|
updateTerminalVisibility();
|
|
checkDevEnvironment();
|
|
saveState();
|
|
});
|
|
});
|
|
|
|
$('#menu-btn').addEventListener('click', openSidebar);
|
|
$('#sidebar-close').addEventListener('click', closeSidebar);
|
|
$('#sidebar-overlay').addEventListener('click', closeSidebar);
|
|
|
|
$('#new-chat-btn').addEventListener('click', function() { newConversation(); });
|
|
$('#new-chat-sidebar').addEventListener('click', function() { newConversation(); closeSidebar(); });
|
|
|
|
$('#settings-btn').addEventListener('click', function() {
|
|
populateSettings();
|
|
showScreen('settings');
|
|
});
|
|
|
|
$('#settings-back').addEventListener('click', function() {
|
|
saveSettings();
|
|
showScreen('chat');
|
|
});
|
|
|
|
$('#settings-temp').addEventListener('input', function() {
|
|
$('#temp-value').textContent = this.value;
|
|
});
|
|
|
|
$('#settings-tokens').addEventListener('input', function() {
|
|
$('#tokens-value').textContent = this.value;
|
|
});
|
|
|
|
$('#settings-token').addEventListener('change', saveSettings);
|
|
$('#settings-url').addEventListener('change', saveSettings);
|
|
$('#settings-model').addEventListener('change', saveSettings);
|
|
$('#settings-websearch').addEventListener('change', saveSettings);
|
|
$('#settings-streaming').addEventListener('change', saveSettings);
|
|
$('#settings-autodeploy').addEventListener('change', function() {
|
|
state.autoDeploy = this.checked;
|
|
saveState();
|
|
});
|
|
$('#settings-keepawake').addEventListener('change', function() {
|
|
state.keepAwake = this.checked;
|
|
saveState();
|
|
});
|
|
|
|
$('#settings-maxretries').addEventListener('input', function() {
|
|
$('#retries-value').textContent = this.value;
|
|
});
|
|
$('#settings-maxretries').addEventListener('change', function() {
|
|
state.maxRetries = parseInt(this.value) || 10;
|
|
saveState();
|
|
});
|
|
|
|
$('#settings-autocontinue').addEventListener('change', function() {
|
|
state.autoContinue = this.checked;
|
|
saveState();
|
|
});
|
|
$('#settings-maxautocontinue').addEventListener('input', function() {
|
|
$('#autocont-value').textContent = this.value;
|
|
});
|
|
$('#settings-maxautocontinue').addEventListener('change', function() {
|
|
state.maxAutoContinue = parseInt(this.value) || 5;
|
|
saveState();
|
|
});
|
|
|
|
$('#file-tree-btn').addEventListener('click', toggleDeviceFileManager);
|
|
$('#device-files-btn').addEventListener('click', function() {
|
|
var panel = $('#file-tree-panel');
|
|
if (panel && panel.classList.contains('open')) {
|
|
showProjectFiles();
|
|
} else {
|
|
toggleDeviceFileManager();
|
|
}
|
|
});
|
|
$('#file-tree-close').addEventListener('click', closeFileTree);
|
|
$('#file-tree-overlay').addEventListener('click', closeFileTree);
|
|
$('#file-tree-body').addEventListener('click', function(e) {
|
|
var node = e.target.closest('.ftree-file');
|
|
if (node) {
|
|
if (node.dataset.conv) {
|
|
openFileViewer(node.dataset.conv, node.dataset.path);
|
|
} else {
|
|
var title = document.querySelector('#file-tree-panel h3');
|
|
if (title && title.textContent === 'File Manager') {
|
|
renderDeviceFiles(node.dataset.path);
|
|
} else {
|
|
openFileFromTree(node.dataset.path);
|
|
}
|
|
}
|
|
} else {
|
|
node = e.target.closest('.ftree-dir');
|
|
if (node) {
|
|
var folder = node.parentElement;
|
|
folder.classList.toggle('open');
|
|
var arrow = node.querySelector('.ftree-arrow');
|
|
if (arrow) arrow.innerHTML = folder.classList.contains('open') ? '▼' : '▶';
|
|
}
|
|
}
|
|
});
|
|
$('#file-viewer-close').addEventListener('click', closeFileViewer);
|
|
$('#file-viewer-edit').addEventListener('click', toggleFileEdit);
|
|
$('#file-viewer-save').addEventListener('click', saveFileEdit);
|
|
$('#approval-allow').addEventListener('click', function() {
|
|
if (approvalState && approvalState.onApprove) approvalState.onApprove();
|
|
});
|
|
$('#approval-deny').addEventListener('click', closeApproval);
|
|
$('#html-preview-close').addEventListener('click', closeHtmlPreview);
|
|
$('#html-preview-newtab').addEventListener('click', function() {
|
|
var frame = $('#html-preview-frame');
|
|
if (!frame) return;
|
|
var doc = frame.contentDocument || frame.contentWindow.document;
|
|
var src = doc.documentElement.outerHTML;
|
|
openHtmlInNewTab('<!DOCTYPE html>' + src);
|
|
});
|
|
|
|
$('#theme-toggle-header').addEventListener('click', toggleTheme);
|
|
|
|
$('#settings-darkmode').addEventListener('change', function() {
|
|
applyTheme(this.checked ? 'dark' : 'light');
|
|
});
|
|
|
|
$('#terminal-toggle').addEventListener('click', toggleTerminal);
|
|
|
|
$('#export-btn').addEventListener('click', exportConversations);
|
|
|
|
$('#clear-btn').addEventListener('click', function() {
|
|
if (confirm('Clear all conversations? This cannot be undone.')) {
|
|
state.conversations = [];
|
|
state.activeConversationId = null;
|
|
saveState();
|
|
renderConversationList();
|
|
renderMessages();
|
|
updateHeader();
|
|
updateTerminalContent();
|
|
}
|
|
});
|
|
|
|
updateModeSelector();
|
|
updateSendButton();
|
|
applyTheme(state.theme);
|
|
setupVisibilityHandler();
|
|
initTerminal();
|
|
}
|
|
|
|
if (document.readyState === 'loading') {
|
|
document.addEventListener('DOMContentLoaded', init);
|
|
} else {
|
|
init();
|
|
}
|
|
})();
|