diff options
| author | Agando <roduccomm@gmail.com> | 2026-05-25 18:02:02 +0200 |
|---|---|---|
| committer | Agando <roduccomm@gmail.com> | 2026-05-25 18:02:02 +0200 |
| commit | 4baa484b84c5184286cac2743575251454a5ee2b (patch) | |
| tree | cb9002f93644f363d0f35831dc97e6492bbb0ed6 /modules/core | |
auto: 2026-05-25_18:02 changes
Diffstat (limited to 'modules/core')
25 files changed, 7668 insertions, 0 deletions
diff --git a/modules/core/module-aiasm.js b/modules/core/module-aiasm.js new file mode 100644 index 0000000..60e1757 --- /dev/null +++ b/modules/core/module-aiasm.js @@ -0,0 +1,26 @@ +// Extracted from newOS4 index.html — WebXAIAliasModule + class WebXAIAliasModule { + constructor() { this.commands = {}; this.terminal = null; } + getHelpInfo() { + return { + 'webxai': 'webxai <question> — Send a question to the AI assistant', + 'wx': 'wx <question> — Alias for webxai', + 'webx': 'webx <question> — Alias for webxai' + }; + } + registerCommands() { + this.commands['webxai'] = (args) => this.forwardToAI(args); + this.commands['wx'] = (args) => this.forwardToAI(args); + this.commands['webx'] = (args) => this.forwardToAI(args); + } + forwardToAI(args) { + if (!args[0]) { + this.terminal.print("Usage: webxai <your-question>", "warning"); return; + } + if (this.terminal.moduleManager.modules['WebX11AIAssistantModule']) { + this.terminal.moduleManager.modules['WebX11AIAssistantModule'].chat(args); + } else { + this.terminal.ollamaManager.chat(args.join(' ')); + } + } + } diff --git a/modules/core/module-aiassist.js b/modules/core/module-aiassist.js new file mode 100644 index 0000000..0ef6a2f --- /dev/null +++ b/modules/core/module-aiassist.js @@ -0,0 +1,1435 @@ +// Extracted from newOS4 index.html — WebX11AIAssistantModule (Full Agent Loop) + class WebX11AIAssistantModule { + constructor() { + this.commands = {}; this.terminal = null; + this.systemPrompt = this.buildSystemPrompt(); + this.context = []; this.maxContext = 10; + this.maxContextTokens = 2000; + this.MAX_TOOL_TURNS = 8; + this._pendingConfirmation = null; + this._confirmResolve = null; + this._errorLog = []; + this._commandFreq = {}; + this._messageCount = 0; + this._userProfile = {}; + this._sessionMemoryLoaded = false; + this._sessionSaved = false; + this._thinkingTimer = null; + this._thinkingStartTime = 0; + this._toolDefs = null; + try { const s = localStorage.getItem('webx11_ai_patterns'); if (s) this._commandFreq = JSON.parse(s); } catch (e) {} + try { const p = localStorage.getItem('webx11_user_profile'); if (p) this._userProfile = JSON.parse(p); } catch (e) {} + // Restore AI conversation context from localStorage + try { + const saved = localStorage.getItem('webx11_ai_context'); + if (saved) { + const parsed = JSON.parse(saved); + if (parsed && Array.isArray(parsed.context)) { + this.context = parsed.context; + this._messageCount = parsed.messageCount || 0; + } + } + } catch (e) {} + } + buildSystemPrompt() { + return `You are WebX11AI, the AI assistant embedded in the WebX11 browser terminal. You have direct access to read/write files, search the web, install packages, edit modules, and run terminal commands — all via tool calls. Use tools proactively. Keep responses concise. + +== TOOL REFERENCE == +You have these tools available. Use them WITHOUT asking permission unless the tool description says (DESTRUCTIVE): + +FILESYSTEM: +- list_files(path?) — List files in VFS directory (omit for current dir) +- read_file(path) — Read file content (truncated past 8000 chars) +- write_file(path, content) — Write to file (DESTRUCTIVE — confirm with user first) +- make_directory(path) — Create a directory (DESTRUCTIVE — confirm first) +- delete_file(path) — Delete file (DESTRUCTIVE — confirm first) +- get_current_dir — Show current working directory + +SYSTEM: +- get_status — Show filesystem, modules, Ollama, memory status +- get_help — List all available terminal commands +- run_command(command) — Execute any terminal command and capture output +- font-size <8-32> — Change terminal font size (persists in localStorage) + +SEARCH: +- web_search(query) — Search the web via proxy API (SearXNG + DDG fallback, results with title/url/snippet) +- web_fetch(url) — Fetch and extract readable content from a URL via proxy API (returns title + clean text + word count) + +PACKAGES: +- wpm_install(package) — Install WPM package by name (e.g. "widget", "calc") +- wpm_search(keyword) — Search available WPM packages + +SKILLS (PERSISTENT PROCEDURAL MEMORY): +- list_skills(category?) — Browse available skills, optionally filtered by category +- load_skills(query) — Search skills relevant to current task. Call at START of every task. +- create_skill(name, description, content, tags, category) — Save a new stored procedure. Call after completing complex multi-step tasks (3+ tool calls). +- patch_skill(name, content) — Update an existing skill when the approach changes + +MODULE SELF-IMPROVEMENT: +- view_module(name) — View source code of any loaded module +- edit_module(name) — Copy module into editor. Opens editor for modifications. +- test_module(name) — Load edited module from /_mods/ for live testing +- reload_module(name) — Discard edits and reload from original URL +- list_modified_modules — List modules with local edits pending +- get_module_status — Full status of all modules (loaded, modified, URLs) + +== SKILLS WORKFLOW == +1. At the START of every task, call load_skills(query) to check if a stored procedure exists +2. Follow the skill's steps. If they're wrong, call patch_skill to fix them. +3. After a complex multi-step task (3+ tool calls), call create_skill to save the approach +4. Skills persist in /skills/ as markdown files with YAML frontmatter + +== MEMORY SYSTEM == +- I maintain a conversation context (this.context) that persists across messages in this session +- The last ${this.maxContext} exchanges are remembered +- Type "ai-clear" to reset conversation memory +- Type "ai-context" to see what I remember +- Type "ai-model" to see/switch models +- Type "ai-stop" to cancel a running AI request +- Smart context profiles (set via "context <name>") adjust my temperature and system prompt style +- Saved prompts (via "prompt", "prompt-save") let you reuse template with {variable} substitution + +== USER PROFILE == +- Use user_get, user_set to read/write persistent user preferences across sessions +- user_set dot-notation supported: user_set knownFacts.name "Alex" +- Profile auto-stores known facts (name, project, preferences) +- Use session_save after complex tasks to log what was done +- Use session_load to review past sessions +- Use session_recent at start of session to remember what was worked on before + +== SELF-IMPROVEMENT WORKFLOW == +1. User asks for module changes → call get_module_status to list modules +2. Call view_module(name) to read the module source +3. Call edit_module(name) to copy source into editor for modification +4. After editing, call test_module(name) to load and test the edit +5. If it works, the user keeps it. If not, call reload_module(name). + +== AUTO-IMPROVEMENT (SYSTEM-DRIVEN) == +- If I encounter the same tool error 3+ times, a workaround skill is created automatically +- After complex tasks (3+ tool calls), I run health checks and suggest improvements +- I track conversation patterns and surface frequent topics +- Every 20 messages, skills are audited for staleness +- Use check_updates to verify module source URLs are reachable + +== RESPONSE STYLE == +- Be concise. Use bullet points for lists. +- When using tools, explain what you're doing briefly. +- If a tool fails, try an alternative approach. +- For web research tasks: search first, then analyze the results, then present findings. +|- When the user says "update" or "improve" without specifics, use get_status + get_help to understand the current state first. + +== REACT PROTOCOL == +For every task, follow this cycle: +1. **REASON** — Think about what you need next. What information is missing? +2. **ACT** — Call ONE tool at a time (unless multiple independent lookups are needed). +3. **OBSERVE** — Study the tool result carefully. Did it succeed? Is the data useful? +4. **DECIDE** — Do you have enough to answer? If not, go back to step 1. + +RULES: +- Call tools in sequence when the second depends on the first result +- Independent lookups (e.g. reading two unrelated files) can run in parallel +- If a tool returns an error, try a different approach before giving up +- After 6 tool turns, start synthesizing a final answer +- Always provide a complete, helpful final answer — don't leave the user hanging`; + } + getToolDefinitions() { + if (!this._toolDefs) this._toolDefs = this._buildToolDefs(); + return this._toolDefs; + } + _buildToolDefs() { + return [{ + type: 'function', + function: { + name: 'list_files', + description: 'List files and directories in the current or specified virtual filesystem directory', + parameters: { type: 'object', properties: { path: { type: 'string', description: 'Directory path to list, e.g. "" for root, "notes" for notes dir' } }, required: [] } + } + }, { + type: 'function', + function: { + name: 'read_file', + description: 'Read contents of a file from the virtual filesystem', + parameters: { type: 'object', properties: { path: { type: 'string', description: 'File path to read, e.g. "hello.txt" or "notes/todo.md"' } }, required: ['path'] } + } + }, { + type: 'function', + function: { + name: 'write_file', + description: 'Write content to a file in the virtual filesystem (DESTRUCTIVE)', + parameters: { type: 'object', properties: { path: { type: 'string', description: 'File path to write' }, content: { type: 'string', description: 'Content to write' } }, required: ['path', 'content'] } + } + }, { + type: 'function', + function: { + name: 'make_directory', + description: 'Create a new directory in the virtual filesystem', + parameters: { type: 'object', properties: { path: { type: 'string', description: 'Directory name to create' } }, required: ['path'] } + } + }, { + type: 'function', + function: { + name: 'delete_file', + description: 'Delete a file or empty directory from the virtual filesystem (DESTRUCTIVE)', + parameters: { type: 'object', properties: { path: { type: 'string', description: 'Path to delete' } }, required: ['path'] } + } + }, { + type: 'function', + function: { + name: 'get_status', + description: 'Get system status: filesystem, modules, Ollama connection, memory', + parameters: { type: 'object', properties: {}, required: [] } + } + }, { + type: 'function', + function: { + name: 'get_help', + description: 'Get full command reference for WebX11', + parameters: { type: 'object', properties: {}, required: [] } + } + }, { + type: 'function', + function: { + name: 'web_search', + description: 'Search the web using the WebX11 proxy API (SearXNG + DDG fallback). Returns structured results with title, URL, and content snippet.', + parameters: { type: 'object', properties: { query: { type: 'string', description: 'Search query' } }, required: ['query'] } + } + }, { + type: 'function', + function: { + name: 'web_fetch', + description: 'Fetch and extract clean readable content from a URL using the WebX11 proxy API. Returns title, clean text content, and word count. Use for reading articles, documentation, or web pages.', + parameters: { type: 'object', properties: { url: { type: 'string', description: 'Full URL to fetch' } }, required: ['url'] } + } + }, { + type: 'function', + function: { + name: 'wpm_install', + description: 'Install a WebX11 package from the package index', + parameters: { type: 'object', properties: { package: { type: 'string', description: 'Package name to install, e.g. "widget" or "calc"' } }, required: ['package'] } + } + }, { + type: 'function', + function: { + name: 'wpm_search', + description: 'Search available WebX11 packages by keyword', + parameters: { type: 'object', properties: { keyword: { type: 'string', description: 'Search keyword' } }, required: ['keyword'] } + } + }, { + type: 'function', + function: { + name: 'run_command', + description: 'Execute any WebX11 terminal command directly and return its output', + parameters: { type: 'object', properties: { command: { type: 'string', description: 'Full command to execute, e.g. "grep error" or "wc -l"' } }, required: ['command'] } + } + }, { + type: 'function', + function: { + name: 'get_current_dir', + description: 'Get current working directory path in virtual filesystem', + parameters: { type: 'object', properties: {}, required: [] } + } + }, { + type: 'function', + function: { + name: 'view_module', + description: 'View the source code of any loaded WebX11 module', + parameters: { type: 'object', properties: { name: { type: 'string', description: 'Module class name, e.g. ThemeSwitcherModule' } }, required: ['name'] } + } + }, { + type: 'function', + function: { + name: 'edit_module', + description: 'Copy a module source into the editor for modification (DESTRUCTIVE)', + parameters: { type: 'object', properties: { name: { type: 'string', description: 'Module class name to edit' } }, required: ['name'] } + } + }, { + type: 'function', + function: { + name: 'test_module', + description: 'Load an edited module from /_mods/ for live testing', + parameters: { type: 'object', properties: { name: { type: 'string', description: 'Module class name' } }, required: ['name'] } + } + }, { + type: 'function', + function: { + name: 'reload_module', + description: 'Reload a module from its original URL, discarding local edits', + parameters: { type: 'object', properties: { name: { type: 'string', description: 'Module class name' } }, required: ['name'] } + } + }, { + type: 'function', + function: { + name: 'list_modified_modules', + description: 'List which modules have local edits pending test', + parameters: { type: 'object', properties: {}, required: [] } + } + }, { + type: 'function', + function: { + name: 'get_module_status', + description: 'Full status of all modules: loaded, modified, URLs', + parameters: { type: 'object', properties: {}, required: [] } + } + }, { + type: 'function', + function: { + name: 'load_skills', + description: 'Search available skills by relevance to a query. Skills are stored procedures with step-by-step approaches for recurring tasks. Returns matching skill content to help you work efficiently.', + parameters: { type: 'object', properties: { query: { type: 'string', description: 'Search query describing the task — e.g. "search the web" or "organize files"' } }, required: ['query'] } + } + }, { + type: 'function', + function: { + name: 'create_skill', + description: 'Save a new skill (stored procedure) so you can reuse this approach next time. Call after completing a complex multi-step task. Skills persist across sessions.', + parameters: { type: 'object', properties: { + name: { type: 'string', description: 'Short kebab-case name, e.g. "web-scrape-and-summarize"' }, + description: { type: 'string', description: 'One-line description of what this skill does' }, + content: { type: 'string', description: 'Markdown content with YAML frontmatter. Must start with "---\\nname: <name>\\n..."' }, + tags: { type: 'string', description: 'Comma-separated tags, e.g. "search,web,scraping"' }, + category: { type: 'string', description: 'Category, e.g. "research", "system", "dev"' } + }, required: ['name', 'description', 'content'] } + } + }, { + type: 'function', + function: { + name: 'patch_skill', + description: 'Update an existing skill file. Use when a stored procedure is outdated or has better steps. Replaces the entire skill content.', + parameters: { type: 'object', properties: { + name: { type: 'string', description: 'Skill name to patch (without .md)' }, + content: { type: 'string', description: 'Complete new markdown content including YAML frontmatter' } + }, required: ['name', 'content'] } + } + }, { + type: 'function', + function: { + name: 'list_skills', + description: 'List all available skills. Optionally filter by category.', + parameters: { type: 'object', properties: { category: { type: 'string', description: 'Optional category filter, e.g. "research" or "system"' } }, required: [] } + } + }, { + type: 'function', + function: { + name: 'check_updates', + description: 'Check if loaded modules are reachable at their source URLs. Reports any that appear outdated or unreachable.', + parameters: { type: 'object', properties: {}, required: [] } + } + }, { + type: 'function', + function: { + name: 'user_get', + description: 'Read a value from the persistent user profile. Returns null if key not found.', + parameters: { type: 'object', properties: { key: { type: 'string', description: 'Profile key to read, e.g. "preferredModel", "fontSize", "knownFacts.name"' } }, required: ['key'] } + } + }, { + type: 'function', + function: { + name: 'user_set', + description: 'Write a value to the persistent user profile. Saves across sessions.', + parameters: { type: 'object', properties: { key: { type: 'string', description: 'Profile key, e.g. "preferredModel"' }, value: { type: 'string', description: 'Value to store' } }, required: ['key', 'value'] } + } + }, { + type: 'function', + function: { + name: 'user_list', + description: 'List all stored keys in the user profile.', + parameters: { type: 'object', properties: {}, required: [] } + } + }, { + type: 'function', + function: { + name: 'user_forget', + description: 'Delete a key from the user profile.', + parameters: { type: 'object', properties: { key: { type: 'string', description: 'Key to delete' } }, required: ['key'] } + } + }, { + type: 'function', + function: { + name: 'session_save', + description: 'Save a summary of the current session to /memory/. Use after complex tasks. Creates a daily log file.', + parameters: { type: 'object', properties: { summary: { type: 'string', description: 'Brief summary of what was accomplished this session' } }, required: ['summary'] } + } + }, { + type: 'function', + function: { + name: 'session_load', + description: 'Load a past session log by date. Format: YYYY-MM-DD.', + parameters: { type: 'object', properties: { date: { type: 'string', description: 'Date string like "2026-05-23"' } }, required: ['date'] } + } + }, { + type: 'function', + function: { + name: 'session_recent', + description: 'Show summaries from the most recent sessions.', + parameters: { type: 'object', properties: { count: { type: 'number', description: 'Number of recent sessions to show (default 3)' } }, required: [] } + } + }, { + type: 'function', + function: { + name: 'session_stats', + description: 'Aggregate statistics across all sessions: total tasks, most-used tools, common topics.', + parameters: { type: 'object', properties: {}, required: [] } + } + }]; + } + getHelpInfo() { + return { + 'ai': 'ai <question> — Ask the AI assistant anything', + 'ask': 'ask <question> — Alias for ai command', + 'ai-clear': 'ai-clear — Clear conversation context', + 'ai-system': 'ai-system <prompt> — Set system prompt', + 'ai-context': 'ai-context — Show current conversation context', + 'ai-allow': 'ai-allow — Confirm pending AI action', + 'ai-deny': 'ai-deny — Deny pending AI action', + 'ai-stop': 'ai-stop — Cancel current AI operation', + 'ai-model': 'ai-model [model] — Show or set AI model' + }; + } + registerCommands() { + this.commands['ai'] = (args) => this.chat(args); + this.commands['ask'] = (args) => this.chat(args); + this.commands['ai-clear'] = () => this.clearContext(); + this.commands['ai-system'] = (args) => this.setSystemPrompt(args); + this.commands['ai-context'] = () => this.showContext(); + this.commands['ai-allow'] = () => this.confirmPending(); + this.commands['ai-deny'] = () => this.denyPending(); + this.commands['ai-stop'] = () => this.cancelAi(); + this.commands['ai-model'] = (args) => this.showModel(args); + this.commands['ai-compress'] = () => this._manualCompress(); + } + async chat(args) { + const userMessage = args.join(' '); + if (!userMessage) { + this.terminal.print('Usage: ai <your-question>', 'warning'); return; + } + this._cancelled = false; + this.terminal.setAiBusy(true); + while (this._chatLock) await new Promise(r => setTimeout(r, 50)); + this._chatLock = true; + this.terminal.markDirty(); + // Re-read context from localStorage (auto-restore may have written it after init) + this._loadContext(); + // Compress context if it exceeds the token threshold + this._compressContext(); + let loopResult; + try { + const messages = [ + { role: 'system', content: this.systemPrompt } + ]; + this.context.slice().forEach(msg => messages.push(msg)); + messages.push({ role: 'user', content: userMessage }); + this.terminal.print('🤖 WebX11AI thinking...', 'info'); + // Ensure skills directory exists and seed defaults on first run + this.terminal.fs.mkdirRecursive('/skills'); + if (!this.terminal.fs.readFile('/skills/_seeded')) { + try { + await this._seedDefaultSkills(); + } catch (e) { /* non-critical */ } + } + // Auto-load skills relevant to the user's query (awaited, not fire-and-forget) + // Skip skill loading for trivial queries (< 10 chars, no actionable keywords) + const isTrivial = userMessage.length < 10 && !/\b(search|write|install|edit|create|make|find|delete|remove|change|update|improve|add|fix|build|set|load|save|help)\b/i.test(userMessage); + if (!isTrivial) { + try { + const skillRes = await this.runTool('load_skills', { query: userMessage }); + const skillData = JSON.parse(skillRes); + if (skillData && skillData.matches && skillData.matches.length > 0) { + const skillNotes = skillData.matches.map(m => `[Skill: ${m.name}] ${m.content}`).join('\n\n---\n\n'); + messages.push({ role: 'system', content: `Relevant skills for this task:\n\n${skillNotes}\n\nUse the steps above as guidance. If they're wrong or outdated, use patch_skill to fix them after the task.` }); + this.terminal.print(`📚 Loaded ${skillData.count} relevant skill(s)`, 'info'); + } + } catch (e) { /* skills not available yet — first run */ } + } + + // Track conversation patternsfor learning + this._trackPattern(userMessage); + + // Extract facts from user message + this._extractFacts(userMessage, ''); + + // On first message of session, auto-load recent memory + if (!this._sessionMemoryLoaded) { + this._sessionMemoryLoaded = true; + this.terminal.fs.mkdirRecursive('/memory'); + try { + const recentRes = await this.runTool('session_recent', { count: 3 }); + const rd = JSON.parse(recentRes); + if (rd.sessions && rd.sessions.length > 0) { + const memNotes = rd.sessions.map(s => '[' + s.date + '] ' + (s.tasks || 1) + ' tasks: ' + (s.summary || 'No summary')).join('\n'); + messages.push({ role: 'system', content: 'Recent sessions:\n' + memNotes + '\nThis provides continuity. Refer to past work when relevant.' }); + this.terminal.print('📖 Loaded ' + rd.sessions.length + ' recent session(s)', 'info'); + } + } catch (e) {} + } + + loopResult = await this.agenticLoop(messages); + this.context.push({ role: 'user', content: userMessage }); + if (loopResult.finalAnswer) { + this.context.push({ role: 'assistant', content: loopResult.finalAnswer }); + } + if (this.context.length > this.maxContext * 2) { + this.context = this.context.slice(-this.maxContext * 2); + } + // Reflection: after complex tasks, run health scan and auto-skill creation + if (loopResult.toolTurns >= 2 && !this._cancelled) { + await this.afterActionReflection(userMessage, loopResult.toolTurns); + } + // Auto-create skills from repeated errors + if (this._errorLog.length >= 3) { + await this._autoCreateErrorSkill(); + } + // Skill quality audit every 20 messages + this._messageCount++; + if (this._messageCount % 20 === 0) { + await this._auditSkills(); + } + // Sound notification on AI response + if (this.terminal.playNotificationSound) { + setTimeout(() => this.terminal.playNotificationSound(), 100); + } + } finally { + this._chatLock = false; + this.terminal.setAiBusy(false); + // Persist conversation context after each exchange + this._saveContext(); + // Log the AI interaction + this.terminal.log('INFO', 'AI', 'Query: ' + userMessage.substring(0, 100)); + if (loopResult && loopResult.toolTurns) { + this.terminal.log('INFO', 'AI', 'Used ' + loopResult.toolTurns + ' tool(s)'); + } + } + // Register session save on page unload (runs once per session) + if (!this._sessionSaved) { + this._sessionSaved = true; + window.addEventListener('beforeunload', () => { + this.terminal.fs.mkdirRecursive('/memory'); + const today = new Date().toISOString().split('T')[0]; + const index = this.terminal.fs.fsReadJSON('/memory/index.json') || {}; + index[today] = index[today] || { summary: 'Session in progress', tasks: 0 }; + index[today].tasks = (index[today].tasks || 0) + 1; + index[today].updated = new Date().toISOString(); + this.terminal.fs.fsWriteJSON('/memory/index.json', index); + // Also persist AI context on page close + this._saveContext(); + }); + } + } + _summarizeObservation(result, maxLen) { + if (!result || typeof result !== 'string' || result.length <= maxLen) return result || ''; + const lines = result.split('\n'); + if (lines.length > 5) { + return lines.slice(0, 2).join('\n') + '\n...(' + (lines.length - 4) + ' more lines)...\n' + lines.slice(-2).join('\n'); + } + return result.substring(0, maxLen) + '...(truncated from ' + result.length + ' chars)'; + } + _isToolError(result) { + if (!result || typeof result !== 'string') return null; + const trimmed = result.trim(); + if (trimmed.startsWith('{"error":') || trimmed.startsWith("{'error':")) { + try { const parsed = JSON.parse(trimmed); return parsed.error || null; } + catch (e) { return null; } + } + return null; + } + async agenticLoop(messages) { + let toolTurns = 0; + while (toolTurns < this.MAX_TOOL_TURNS) { + if (this._cancelled) { this.terminal.print('AI loop cancelled', 'warning'); return { finalAnswer: null, toolTurns }; } + toolTurns++; + // On last turn, inject wrap-up prompt + if (toolTurns === this.MAX_TOOL_TURNS) { + this.terminal.print(`⏱ Final turn (${toolTurns}/${this.MAX_TOOL_TURNS}) — AI must provide final answer`, 'warning'); + messages.push({ role: 'system', content: 'This is your last tool turn. You MUST provide a final answer after this tool call. Synthesize what you know.' }); + } + const thinkStart = Date.now(); + const res = await this.terminal.ollamaManager.chatWithTools(messages, this.getToolDefinitions()); + const thinkTime = Math.round((Date.now() - thinkStart) / 1000); + if (this._cancelled) return { finalAnswer: null, toolTurns }; + if (res.error) { this.terminal.print(`⚠️ AI model error (${thinkTime}s): ${res.error}`, 'error'); return { finalAnswer: null, toolTurns }; } + if (res.message && !res.tool_calls) { + const text = res.message.content || ''; + this.terminal.printAIResponse(text || '(no response)'); + if (text) this.terminal.ollamaManager.fireResponseHooks(text); + this.terminal.print(`💬 AI answered (turn ${toolTurns}/${this.MAX_TOOL_TURNS}, ${thinkTime}s)`, 'info'); + return { finalAnswer: text, toolTurns }; + } + if (res.tool_calls && res.tool_calls.length > 0) { + this.terminal.print(`🔧 AI wants ${res.tool_calls.length} tool(s) — turn ${toolTurns}/${this.MAX_TOOL_TURNS} (${thinkTime}s)`, 'info'); + messages.push(res.message); + let pendingText = res.message?.content || null; + const destructiveTools = res.tool_calls.filter(tc => this.isDestructive(tc.function.name)); + const safeTools = res.tool_calls.filter(tc => !this.isDestructive(tc.function.name)); + // Execute non-destructive tools in parallel (independent read-only operations) + const safeResults = await Promise.all(safeTools.map(async (tc) => { + const fn = tc.function; + const t0 = Date.now(); + const toolResult = await this.runTool(fn.name, fn.arguments); + const t1 = Math.round((Date.now() - t0) / 1000); + return { fn, toolResult, t1 }; + })); + for (const { fn, toolResult, t1 } of safeResults) { + this.terminal.print(`🔧 Tool: ${fn.name}(${typeof fn.arguments === 'string' ? fn.arguments : JSON.stringify(fn.arguments)})`, 'info'); + const obs = this._summarizeObservation(toolResult, 150); + const err = this._isToolError(toolResult); + if (err) { + this.terminal.print(`📊 Result (${t1}s): ⚠️ Error: ${err}`, 'warning'); + } else { + this.terminal.print(`📊 Result (${t1}s): ${obs}`, 'info'); + } + messages.push({ role: 'tool', content: toolResult }); + } + // Only block on destructive tools + if (destructiveTools.length > 0) { + const approved = await this.confirmActionMulti(destructiveTools); + if (approved) { + for (const tc of destructiveTools) { + const fn = tc.function; + const t0 = Date.now(); + this.terminal.print(`🔧 Tool: ${fn.name}(${typeof fn.arguments === 'string' ? fn.arguments : JSON.stringify(fn.arguments)})`, 'info'); + const toolResult = await this.runTool(fn.name, fn.arguments); + const t1 = Math.round((Date.now() - t0) / 1000); + const dObs = this._summarizeObservation(toolResult, 150); + const dErr = this._isToolError(toolResult); + if (dErr) { + this.terminal.print(`📊 Result (${t1}s): ⚠️ Error: ${dErr}`, 'warning'); + } else { + this.terminal.print(`📊 Result (${t1}s): ${dObs}`, 'info'); + } + messages.push({ role: 'tool', content: toolResult }); + } + } else { + messages.push({ + role: 'tool', + content: `Destructive actions (${destructiveTools.map(tc => tc.function.name).join(', ')}) denied by user. Non-destructive actions already executed.` + }); + } + } + if (pendingText) { + messages.push({ role: 'assistant', content: pendingText }); + } + continue; + } + return { finalAnswer: null, toolTurns }; + } + // Loop exited without final answer — auto-summary + const toolNames = Object.keys(this._commandFreq).slice(-3).join(', '); + this.terminal.print(`🤖 AI reached the ${toolTurns}/${this.MAX_TOOL_TURNS} tool limit.`, 'warning'); + this.terminal.print('Tools were executed but no final answer was produced — try a more specific query.', 'info'); + return { finalAnswer: null, toolTurns }; + } + async afterActionReflection(query, toolTurns) { + // Debounce: only suggest skill creation once per 30 minutes + const now = Date.now(); + if (this._lastSkillSuggestion && (now - this._lastSkillSuggestion) < 1800000) { + return; + } + const suggestions = []; + const fs = this.terminal.fs; + try { + // Batch: read all status info in one VFS pass + const reg = this.terminal.moduleManager.moduleRegistry || []; + const total = reg.length; + const loaded = Object.keys(this.terminal.moduleManager.modules).length; + if (loaded < total) { + suggestions.push(loaded + '/' + total + ' modules loaded — use loadmod to restore'); + } + } catch (e) {} + try { + const skillIdx = fs.fsReadJSON('/skills/index.json'); + const skillCount = skillIdx ? Object.keys(skillIdx).length : 0; + if (skillCount === 0 && toolTurns >= 4) { + suggestions.push('Save this approach as a skill with create_skill'); + this._lastSkillSuggestion = now; + } + } catch (e) {} + try { + const modsDir = fs.getCurrentDir(); + const mc = modsDir.content['_mods']; + const modCount = mc && mc.content ? Object.keys(mc.content).filter(k => k.endsWith('.js') && !k.endsWith('.orig.js')).length : 0; + if (modCount > 0) { + suggestions.push(modCount + ' pending module edit(s) in /_mods/ — use test_module to verify'); + } + } catch (e) {} + try { + if (performance.memory && performance.memory.usedJSHeapSize > 100 * 1048576) { + suggestions.push('High memory (' + (performance.memory.usedJSHeapSize / 1048576).toFixed(0) + 'MB) — consider clearing terminal'); + } + } catch (e) {} + if (toolTurns >= 4 && suggestions.length === 0) { + suggestions.push('Used ' + toolTurns + ' tools — create a skill to save this workflow'); + this._lastSkillSuggestion = now; + } + if (suggestions.length > 0) { + this.terminal.print('💡 ' + suggestions.join(' | '), 'info'); + } + } + async confirmActionMulti(toolCalls) { + const names = toolCalls.map(tc => tc.function.name).join(', '); + const args = toolCalls.map(tc => typeof tc.function.arguments === 'string' ? tc.function.arguments : JSON.stringify(tc.function.arguments)).join(' | '); + const truncatedArgs = args.length > 60 ? args.substring(0, 60) + '...' : args; + this.terminal.printHTML(`⚠️ AI wants to: ${names}(${truncatedArgs}). <span class="cmd-link" onclick="webx11.executeCommand('ai-allow')" style="color:var(--success-color);cursor:pointer;text-decoration:underline;">ai-allow</span> to confirm, <span class="cmd-link" onclick="webx11.executeCommand('ai-deny')" style="color:var(--error-color);cursor:pointer;text-decoration:underline;">ai-deny</span> to refuse. (Auto-denies in 120s)`); + // Show confirm bar OUTSIDE scrollable output (always visible) + const area = document.getElementById('ai-confirm-area'); + if (!area) { + // Fallback to promise resolve + return true; + } + area.innerHTML = ` + <span class="confirm-desc">⚠️ ${names}(${truncatedArgs})</span> + <button class="confirm-btn" id="ai-confirm-allow" onclick="(function(){var a=document.getElementById('ai-confirm-allow');if(a.__allow) a.__allow();}).call(this)">✅ Allow</button> + <button class="confirm-btn deny" id="ai-confirm-deny" onclick="(function(){var d=document.getElementById('ai-confirm-deny');if(d.__deny) d.__deny();}).call(this)">❌ Deny</button> + <span class="confirm-timer" id="ai-confirm-timer"></span> + <span style="font-size:0.75em;color:var(--text-muted);width:100%;text-align:center">Type: ai-allow or ai-deny</span> + `; + area.classList.add('visible'); + this._confirmArea = area; + this._confirmBar = area; + + // Attach button handlers via both addEventListener + DOM property (inline onclick fallback) + const allowBtn = document.getElementById('ai-confirm-allow'); + const denyBtn = document.getElementById('ai-confirm-deny'); + const onAllow = () => { + this._resolveConfirm(true); + }; + const onDeny = () => { + this._resolveConfirm(false); + }; + allowBtn.addEventListener('click', onAllow); + denyBtn.addEventListener('click', onDeny); + allowBtn.__allow = onAllow; + denyBtn.__deny = onDeny; + + const startTime = Date.now(); + return new Promise((resolve) => { + this._pendingConfirmation = { names, args }; + this._confirmResolve = resolve; + this._confirmAllowBtn = allowBtn; + this._confirmDenyBtn = denyBtn; + this._confirmOnAllow = onAllow; + this._confirmOnDeny = onDeny; + // Countdown timer updates with escalating visibility + const timerInterval = setInterval(() => { + const elapsed = Math.round((Date.now() - startTime) / 1000); + const remaining = Math.max(0, 120 - elapsed); + const timerEl = document.getElementById('ai-confirm-timer'); + if (timerEl) { + timerEl.textContent = `⏱ ${remaining}s`; + timerEl.className = 'confirm-timer'; + if (remaining > 30) { + timerEl.style.color = 'var(--warning-color)'; + } else if (remaining > 10) { + timerEl.classList.add('urgent'); + } else { + timerEl.classList.add('critical'); + } + } + if (remaining === 60) { + this.terminal.print(`⏱ 60s remaining to respond to AI confirmation for: ${names}`, 'warning'); + } else if (remaining === 30) { + this.terminal.print(`⏱ 30s — ${names} will be auto-denied soon!`, 'warning'); + } else if (remaining === 10) { + this.terminal.print(`⏱ 10s remaining! Type ai-allow or click Allow now!`, 'warning'); + } + }, 500); + this._confirmTimerInterval = timerInterval; + setTimeout(() => { + clearInterval(timerInterval); + if (this._confirmResolve) { + this._confirmResolve(false); + this._cleanupConfirmBar(); + this.terminal.print('⏱ Confirmation timed out — tool not executed', 'warning'); + } + }, 120000); + }); + } + _resolveConfirm(approved) { + if (this._confirmResolve) { + this._confirmResolve(approved); + this._cleanupConfirmBar(); + if (approved) { + this.terminal.print('Action allowed', 'success'); + } else { + this.terminal.print('Action denied', 'warning'); + } + } + } + _cleanupConfirmBar() { + clearInterval(this._confirmTimerInterval); + this._confirmTimerInterval = null; + this._confirmResolve = null; + this._pendingConfirmation = null; + this._confirmAllowBtn = null; + this._confirmDenyBtn = null; + this._confirmOnAllow = null; + this._confirmOnDeny = null; + const area = document.getElementById('ai-confirm-area'); + if (area) { + area.classList.remove('visible'); + area.innerHTML = ''; + } + this._confirmArea = null; + this._confirmBar = null; + const timerEl = document.getElementById('ai-confirm-timer'); + if (timerEl) timerEl.style.animation = ''; + } + confirmPending() { + this._resolveConfirm(true); + } + denyPending() { + this._resolveConfirm(false); + } + cancelAi() { + this._cancelled = true; + this.terminal.print('⏹ Cancelling AI...', 'warning'); + // Abort any in-flight HTTP request + if (this.terminal.ollamaManager) { + this.terminal.ollamaManager.abort(); + } + // Clear any pending confirmation + if (this._confirmResolve) { + this._resolveConfirm(false); + } + // Immediately reset UI so user knows it stopped + this.terminal.setAiBusy(false); + } + isDestructive(name) { + return name === 'write_file' || name === 'delete_file' || name === 'run_command' || name === 'make_directory' || name === 'edit_module'; + } + async runTool(name, argsStr) { + try { + const a = typeof argsStr === 'object' ? argsStr : (argsStr ? JSON.parse(argsStr) : {}); + switch (name) { + case 'list_files': { + const originalPath = a.path || ''; + const p = originalPath.replace(/^\//, ''); + const saved = [...this.terminal.fs.currentPath]; + if (originalPath.startsWith('/')) { + this.terminal.fs.currentPath = []; + } + if (p) { + const parts = p.split('/'); + const dir = this.terminal.fs.root; + let cur = dir; + for (const part of parts) { + if (part === '..') { saved.pop(); continue; } + if (part && cur.content[part] && cur.content[part].type === 'dir') cur = cur.content[part]; + else return JSON.stringify({ error: `Directory not found: ${p}` }); + } + this.terminal.fs.currentPath = saved.concat(parts.filter(x => x !== '..')); + } + const files = this.terminal.fs.ls(); + this.terminal.fs.currentPath = saved; + return JSON.stringify(files.map(f => ({ name: f.name, type: f.type, size: f.size }))); + } + case 'read_file': { + const rp = a.path || ''; + const savedR = [...this.terminal.fs.currentPath]; + if (rp.startsWith('/')) this.terminal.fs.currentPath = []; + const path = rp.replace(/^\//, ''); + const content = this.terminal.fs.readFile(path); + this.terminal.fs.currentPath = savedR; + if (content === null) return JSON.stringify({ error: `File not found: ${path}` }); + return content.length > 8000 ? content.substring(0, 8000) + '\n...(truncated)' : content; + } + case 'write_file': { + const wp = a.path || ''; + const savedW = [...this.terminal.fs.currentPath]; + if (wp.startsWith('/')) this.terminal.fs.currentPath = []; + const path = wp.replace(/^\//, ''); + const content = a.content || ''; + this.terminal.fs.writeFile(path, content); + this.terminal.fs.currentPath = savedW; + return `File written: ${path} (${content.length} chars)`; + } + case 'make_directory': { + const mp = a.path || ''; + const savedM = [...this.terminal.fs.currentPath]; + if (mp.startsWith('/')) this.terminal.fs.currentPath = []; + const path = mp.replace(/^\//, ''); + const ok = this.terminal.fs.mkdir(path); + this.terminal.fs.currentPath = savedM; + if (ok) return `Directory created: ${path}`; + return JSON.stringify({ error: `Cannot create: ${path}` }); + } + case 'delete_file': { + const dp = a.path || ''; + const savedD = [...this.terminal.fs.currentPath]; + if (dp.startsWith('/')) this.terminal.fs.currentPath = []; + const path = dp.replace(/^\//, ''); + const delOk = this.terminal.fs.deleteFile(path); + this.terminal.fs.currentPath = savedD; + if (delOk) return `Deleted: ${path}`; + return JSON.stringify({ error: `Cannot delete: ${path}` }); + } + case 'get_status': { + const fs = this.terminal.fs; + const mm = this.terminal.moduleManager; + const om = this.terminal.ollamaManager; + return JSON.stringify({ + files: fs.ls().length, + dirs: fs.currentPath.length ? '/' + fs.currentPath.join('/') : '/', + modules: Object.keys(mm.modules).length, + ollama: om.connected ? `Connected (${om.currentModel})` : 'Disconnected', + memory: performance.memory ? (performance.memory.usedJSHeapSize / 1048576).toFixed(1) + 'MB' : 'N/A' + }); + } + case 'get_help': { + const cmds = this.terminal.moduleManager.getAllCommands(); + return JSON.stringify(Object.keys(cmds).sort()); + } + case 'get_current_dir': { + return '/' + this.terminal.fs.currentPath.join('/'); + } + case 'web_search': { + const query = a.query || ''; + if (!query) return JSON.stringify({ error: 'No query provided' }); + try { + const resp = await fetch('/api/search?q=' + encodeURIComponent(query)); + if (!resp.ok) { return JSON.stringify({ error: 'Search API returned HTTP ' + resp.status }); } + const data = await resp.json(); + const results = data.results || []; + if (results.length === 0) return JSON.stringify({ message: 'No results found' }); + return JSON.stringify({ count: results.length, source: data.source, results: results.map(r => ({ title: r.title, url: r.url, content: (r.content || '').substring(0, 300) })) }); + } catch (e) { return JSON.stringify({ error: 'Search failed: ' + e.message }); } + } + case 'web_fetch': { + const url = a.url || ''; + if (!url) return JSON.stringify({ error: 'No URL provided' }); + try { + const resp = await fetch('/api/extract?url=' + encodeURIComponent(url)); + if (resp.status === 403) return JSON.stringify({ error: 'URL blocked — internal network' }); + if (!resp.ok) { return JSON.stringify({ error: 'Fetch API returned HTTP ' + resp.status }); } + const data = await resp.json(); + return JSON.stringify({ title: data.title || '', content: (data.content || '').substring(0, 4000), word_count: data.word_count || 0, method: data.method || 'extract' }); + } catch (e) { return JSON.stringify({ error: 'Fetch failed: ' + e.message }); } + } + case 'wpm_install': { + const pkg = a.package || ''; + if (!pkg) return JSON.stringify({ error: 'No package name' }); + const mod = this.terminal.moduleManager.modules['WpmModule']; + if (mod) { await mod.install(pkg); return `Package "${pkg}" installation attempted`; } + return JSON.stringify({ error: 'WPM module not available' }); + } + case 'wpm_search': { + const kw = a.keyword || ''; + if (!kw) return JSON.stringify({ error: 'No keyword' }); + const mod = this.terminal.moduleManager.modules['WpmModule']; + if (mod) { + const originalPrint = this.terminal.print.bind(this.terminal); + let output = ''; + this.terminal.print = (text, type) => { output += text + '\n'; }; + try { await mod.search(kw); } finally { this.terminal.print = originalPrint; } + return output.trim() || 'Search completed'; + } + return JSON.stringify({ error: 'WPM module not available' }); + } + case 'run_command': { + const cmd = a.command || ''; + if (!cmd) return JSON.stringify({ error: 'No command provided' }); + const originalPrint = this.terminal.print.bind(this.terminal); + let output = ''; + this.terminal.print = (text, type) => { output += text + '\n'; }; + try { await this.terminal.dispatchCommand(cmd.split(' ')[0].toLowerCase(), cmd.split(' ').slice(1)); } finally { this.terminal.print = originalPrint; } + return output.trim() || '(no output)'; + } + case 'check_updates': { + const reg = this.terminal.moduleManager.moduleRegistry; + const results = []; + for (const mod of reg) { + if (mod.url && mod.url.startsWith('http')) { + try { + const resp = await fetch(mod.url, { method: 'HEAD' }); + results.push({ name: mod.name, url: mod.url, status: resp.ok ? 'reachable' : 'error (' + resp.status + ')' }); + } catch (e) { + results.push({ name: mod.name, url: mod.url, status: 'unreachable' }); + } + } + } + return JSON.stringify({ checked: results.length, modules: results }); + } + case 'user_get': { + const key = a.key || ''; + if (!key) return JSON.stringify({ error: 'Key is required' }); + const val = key.split('.').reduce((o, k) => (o && typeof o === 'object') ? o[k] : undefined, this._userProfile); + return JSON.stringify({ key, value: val !== undefined ? val : null }); + } + case 'user_set': { + const ukey = a.key || ''; + if (!ukey) return JSON.stringify({ error: 'Key and value are required' }); + const uval = a.value || ''; + const keys = ukey.split('.'); + let obj = this._userProfile; + for (let i = 0; i < keys.length - 1; i++) { + if (!obj[keys[i]] || typeof obj[keys[i]] !== 'object') obj[keys[i]] = {}; + obj = obj[keys[i]]; + } + obj[keys[keys.length - 1]] = uval; + try { localStorage.setItem('webx11_user_profile', JSON.stringify(this._userProfile)); } catch (e) {} + return JSON.stringify({ success: true, key: ukey, value: uval }); + } + case 'user_list': { + return JSON.stringify({ profile: this._userProfile, keys: Object.keys(this._userProfile) }); + } + case 'user_forget': { + const fkey = a.key || ''; + if (!fkey) return JSON.stringify({ error: 'Key is required' }); + if (fkey.includes('.')) { + const parts = fkey.split('.'); + let obj = this._userProfile; + for (let i = 0; i < parts.length - 1; i++) { + if (!obj[parts[i]]) return JSON.stringify({ error: 'Key not found' }); + obj = obj[parts[i]]; + } + delete obj[parts[parts.length - 1]]; + } else { + delete this._userProfile[fkey]; + } + try { localStorage.setItem('webx11_user_profile', JSON.stringify(this._userProfile)); } catch (e) {} + return JSON.stringify({ success: true, key: fkey }); + } + case 'session_save': { + const summary = a.summary || 'No summary provided'; + const fs = this.terminal.fs; + fs.mkdirRecursive('/memory'); + const today = new Date().toISOString().split('T')[0]; + const log = { date: today, summary, toolCallCount: 0, skillsCreated: [], modulesEdited: [], timestamp: new Date().toISOString() }; + fs.fsWriteJSON('/memory/' + today + '.json', log); + const index = fs.fsReadJSON('/memory/index.json') || {}; + index[today] = { summary, tasks: (index[today]?.tasks || 0) + 1, updated: new Date().toISOString() }; + fs.fsWriteJSON('/memory/index.json', index); + return JSON.stringify({ success: true, date: today }); + } + case 'session_load': { + const sdate = a.date || ''; + if (!sdate) return JSON.stringify({ error: 'Date required (YYYY-MM-DD)' }); + const sfs = this.terminal.fs; + const slog = sfs.fsReadJSON('/memory/' + sdate + '.json'); + if (!slog) return JSON.stringify({ error: 'No session log for ' + sdate }); + return JSON.stringify(slog); + } + case 'session_recent': { + const count = a.count || 3; + const rfs = this.terminal.fs; + const rindex = rfs.fsReadJSON('/memory/index.json') || {}; + const dates = Object.keys(rindex).sort().reverse().slice(0, count); + const sessions = dates.map(d => ({ date: d, ...rindex[d] })); + return JSON.stringify({ sessions, count: sessions.length }); + } + case 'session_stats': { + const sfs = this.terminal.fs; + const sindex = sfs.fsReadJSON('/memory/index.json') || {}; + const entries = Object.entries(sindex); + let totalTasks = 0; + for (const [, meta] of entries) totalTasks += meta.tasks || 0; + return JSON.stringify({ totalSessions: entries.length, totalTasks, dates: entries.map(([d]) => d) }); + } + case 'load_skills': { + const query = (a && a.query || '').toLowerCase(); + const fs = this.terminal.fs; + const index = fs.fsReadJSON('/skills/index.json') || {}; + if (!query) { + const names = Object.keys(index); + return JSON.stringify({ skills: names, count: names.length, message: 'No query provided — showing all skill names. Call again with a query to load content.' }); + } + const matches = []; + for (const [name, meta] of Object.entries(index)) { + const score = (name.includes(query) ? 3 : 0) + (meta.description.toLowerCase().includes(query) ? 2 : 0) + ((meta.tags || []).some(t => t.toLowerCase().includes(query)) ? 2 : 0); + if (score > 0) { + const content = fs.fsRead(`/skills/${name}.md`) || ''; + matches.push({ name, description: meta.description, category: meta.category, score, content: content.substring(0, 2000) }); + } + } + matches.sort((a, b) => b.score - a.score); + const top = matches.slice(0, 3); + return JSON.stringify(top.length > 0 ? { matches: top.map(m => ({ name: m.name, description: m.description, content: m.content })), count: top.length } : { matches: [], count: 0, message: 'No matching skills found. You can create one after this task.' }); + } + case 'create_skill': { + const name = (a.name || '').trim().replace(/\s+/g, '-').toLowerCase(); + if (!name) return JSON.stringify({ error: 'Skill name is required' }); + const description = a.description || ''; + let content = a.content || ''; + const tags = (a.tags || '').split(',').map(t => t.trim()).filter(Boolean); + const category = a.category || 'general'; + if (!content.startsWith('---')) { + content = `---\nname: ${name}\ndescription: ${description}\ntags: [${tags.join(', ')}]\ncategory: ${category}\n---\n## Steps\n${content}`; + } + const fs = this.terminal.fs; + fs.mkdirRecursive('/skills'); + fs.fsWrite(`/skills/${name}.md`, content); + const index = fs.fsReadJSON('/skills/index.json') || {}; + index[name] = { name, description, tags, category, created: index[name]?.created || new Date().toISOString(), updated: new Date().toISOString() }; + fs.fsWriteJSON('/skills/index.json', index); + this.terminal.print(`💡 Skill saved: ${name}`, 'success'); + return JSON.stringify({ success: true, name, message: `Skill "${name}" created (${content.length} chars)` }); + } + case 'patch_skill': { + const pname = (a.name || '').trim(); + if (!pname) return JSON.stringify({ error: 'Skill name is required' }); + const pcontent = a.content || ''; + if (!pcontent) return JSON.stringify({ error: 'Skill content is required' }); + const fs = this.terminal.fs; + const index = fs.fsReadJSON('/skills/index.json') || {}; + if (!index[pname]) return JSON.stringify({ error: `Skill "${pname}" not found` }); + fs.fsWrite(`/skills/${pname}.md`, pcontent); + index[pname].updated = new Date().toISOString(); + // Re-parse frontmatter for metadata updates + const metaMatch = pcontent.match(/^---\n([\s\S]*?)\n---/); + if (metaMatch) { + const metaLines = metaMatch[1].split('\n'); + metaLines.forEach(line => { + const [k, ...v] = line.split(':'); + if (k === 'description') index[pname].description = v.join(':').trim(); + if (k === 'tags') index[pname].tags = v.join(':').trim().replace(/[\[\]]/g, '').split(',').map(t => t.trim()); + if (k === 'category') index[pname].category = v.join(':').trim(); + }); + } + fs.fsWriteJSON('/skills/index.json', index); + this.terminal.print(`💡 Skill patched: ${pname}`, 'success'); + return JSON.stringify({ success: true, name: pname, message: `Skill "${pname}" updated` }); + } + case 'list_skills': { + const filterCat = (a.category || '').toLowerCase(); + const fs = this.terminal.fs; + const index = fs.fsReadJSON('/skills/index.json') || {}; + const list = Object.entries(index).map(([name, meta]) => ({ name, description: meta.description, category: meta.category || 'general', tags: meta.tags || [], updated: meta.updated })) + .filter(s => !filterCat || (s.category || '').toLowerCase() === filterCat); + return JSON.stringify({ skills: list, count: list.length }); + } + case 'view_module': + case 'edit_module': + case 'test_module': + case 'reload_module': + case 'list_modified_modules': + case 'get_module_status': + return await this.runModuleTool(name, argsStr); + default: + return JSON.stringify({ error: `Unknown tool: ${name}` }); + } + } catch (e) { + this._errorLog.push({ tool: name, args: a, error: e.message, timestamp: Date.now() }); + return JSON.stringify({ error: e.message, stack: e.stack?.substring(0, 200) }); + } + } + async runModuleTool(name, argsStr) { + const a = typeof argsStr === 'object' ? argsStr : (argsStr ? JSON.parse(argsStr) : {}); + switch (name) { + case 'view_module': { + const modName = a.name || ''; + const entry = this.terminal.moduleManager.moduleRegistry.find(m => m.name === modName); + const code = this.terminal.fs.readFile(`_mods/${modName}.js`); + if (code) return code; + const url = entry ? entry.url : null; + if (!url) return JSON.stringify({ error: `No source URL for ${modName}` }); + try { + const resp = await fetch(url); + if (resp.ok) { + const src = await resp.text(); + this.terminal.fs.writeFile(`_mods/${modName}.orig.js`, src); + return src.substring(0, 8000); + } + return JSON.stringify({ error: `HTTP ${resp.status}` }); + } catch (e) { return JSON.stringify({ error: e.message }); } + } + case 'edit_module': { + const modName = a.name || ''; + const entry = this.terminal.moduleManager.moduleRegistry.find(m => m.name === modName); + const url = entry ? entry.url : null; + let code = this.terminal.fs.readFile(`_mods/${modName}.js`); + if (!code && url) { + try { + const resp = await fetch(url); + if (resp.ok) { + code = await resp.text(); + this.terminal.fs.writeFile(`_mods/${modName}.orig.js`, code); + this.terminal.fs.writeFile(`_mods/${modName}.js`, code); + this.terminal.fs.writeFile(`_mods/${modName}.meta.json`, JSON.stringify({ originalUrl: url, modifiedAt: new Date().toISOString(), editCount: 0 })); + } + } catch (e) {} + } + if (code) this.terminal.openCodeEditor(modName, url || '', code); + return code ? `Editor opened for ${modName} (${code.length} chars)` : JSON.stringify({ error: `Could not load source for ${modName}` }); + } + case 'test_module': { + const modName = a.name || ''; + const code = this.terminal.fs.readFile(`_mods/${modName}.js`); + if (!code) return JSON.stringify({ error: `No local edit for ${modName}` }); + this.terminal.moduleManager.loadModuleFromCode(code, `blob://_mods/${modName}.js`); + return `Module "${modName}" loaded from local edit for testing`; + } + case 'reload_module': { + const modName = a.name || ''; + const entry = this.terminal.moduleManager.moduleRegistry.find(m => m.name === modName); + this.terminal.fs.deleteFile(`_mods/${modName}.js`); + this.terminal.fs.deleteFile(`_mods/${modName}.orig.js`); + this.terminal.fs.deleteFile(`_mods/${modName}.meta.json`); + if (entry && entry.url) { + this.terminal.moduleManager.unloadModule(modName); + await this.terminal.moduleManager.loadModuleFromURL(entry.url); + return `Module "${modName}" reloaded from ${entry.url}`; + } + return JSON.stringify({ error: `No original URL for ${modName}` }); + } + case 'list_modified_modules': { + const modsDir = this.terminal.fs.getCurrentDir(); + const mc = modsDir.content['_mods']; + if (!mc || Object.keys(mc.content).length === 0) return JSON.stringify({ modified: [], message: 'No local edits' }); + const files = Object.keys(mc.content).filter(k => k.endsWith('.js') && !k.endsWith('.orig.js')); + return JSON.stringify({ modified: files.map(f => f.replace('.js', '')), count: files.length }); + } + case 'get_module_status': { + const mm = this.terminal.moduleManager; + const modsDir = this.terminal.fs.getCurrentDir(); + const hasMods = modsDir.content['_mods'] ? !!Object.keys(modsDir.content['_mods'].content).length : false; + const modules = mm.moduleRegistry.map(m => ({ + name: m.name, url: m.url, enabled: m.enabled, + loaded: !!mm.modules[m.name], + modified: !!this.terminal.fs.readFile(`_mods/${m.name}.js`) + })); + return JSON.stringify({ modules, total: modules.length, hasLocalEdits: hasMods }); + } + default: return JSON.stringify({ error: `Unknown module tool: ${name}` }); + } + } + clearContext() { + this.context = []; + localStorage.removeItem('webx11_ai_context'); + this.terminal.print('AI conversation context cleared', 'success'); + } + _saveContext() { + try { + const data = JSON.stringify({ + context: this.context.slice(-20), + messageCount: this._messageCount, + savedAt: Date.now() + }); + localStorage.setItem('webx11_ai_context', data); + // Also persist to VFS for crash survival + try { + const fs = this.terminal.fs; + if (fs) { + fs.mkdirRecursive('_sessions'); + fs.writeFile('_sessions/_auto-context.json', data); + } + } catch (e) {} + } catch (e) { + // localStorage quota exceeded — silently ignore + } + } + _loadContext() { + try { + const saved = localStorage.getItem('webx11_ai_context'); + if (saved) { + const data = JSON.parse(saved); + if (data && data.context) { + this.context = data.context.slice(-20); + this._messageCount = data.messageCount || 0; + } + } + } catch (e) {} + } + setSystemPrompt(args) { + if (!args[0]) { + this.terminal.print(`Current system prompt: ${this.systemPrompt.substring(0, 50)}...`, 'info'); return; + } + this.systemPrompt = args.join(' '); + this.terminal.print('System prompt updated', 'success'); + } + showModel(args) { + const om = this.terminal.ollamaManager; + if (args.length > 0) { + const name = args.join(' '); + if (om.models && om.models.some(m => m.name === name)) { + om.currentModel = name; + localStorage.setItem('webx11_ollama_model', name); + this.terminal.print(`AI model set to: ${name}`, 'success'); + } else { + this.terminal.print(`Model "${name}" not found in available models. Use ai-model to list.`, 'warning'); + } + return; + } + this.terminal.print(`Current AI model: ${om.currentModel || 'none'}`, 'info'); + if (om.models && om.models.length > 0) { + const maxShow = 20; + const names = om.models.slice(0, maxShow).map(m => m.name).join(', '); + this.terminal.print(`Available (${om.models.length}): ${names}${om.models.length > maxShow ? `... +${om.models.length - maxShow} more` : ''}`, 'info'); + } else { + this.terminal.print('No models available. Use ollamaconnect first.', 'warning'); + } + } + showContext() { + if (this.context.length === 0) { + this.terminal.print('No conversation context', 'info'); return; + } + this.terminal.print(`=== Context (${this.context.length} messages) ===`, 'info'); + this.context.forEach(msg => { + const icon = msg.role === 'user' ? '👤' : msg.role === 'assistant' ? '🤖' : '🔧'; + const content = typeof msg.content === 'string' ? msg.content.substring(0, 60) : JSON.stringify(msg).substring(0, 60); + this.terminal.print(`${icon} ${content}...`); + }); + } + _extractFacts(userMsg, aiResp) { + const patterns = [ + { re: /my name is (\w+)/i, key: 'name' }, + { re: /call me (\w+)/i, key: 'name' }, + { re: /i(?:'m| am) working on (.+)/i, key: 'project' }, + { re: /(?:my|the) project is (.+)/i, key: 'project' }, + { re: /i have a server at (.+)/i, key: 'server' }, + { re: /i use (\w+).*?(?:editor|ide|text)/i, key: 'preferredEditor' }, + { re: /i (?:like|prefer|love) (.+)/i, key: 'preference' }, + ]; + let changed = false; + for (const { re, key } of patterns) { + const m = (userMsg + ' ' + (aiResp || '')).match(re); + if (m && m[1]) { + if (!this._userProfile.knownFacts) this._userProfile.knownFacts = {}; + if (this._userProfile.knownFacts[key] !== m[1].trim()) { + this._userProfile.knownFacts[key] = m[1].trim(); + changed = true; + } + } + } + if (changed) { + try { localStorage.setItem('webx11_user_profile', JSON.stringify(this._userProfile)); } catch (e) {} + } + } + async _seedDefaultSkills() { + const fs = this.terminal.fs; + const mindmapSkill = `--- +name: mindmap-user-guide +description: How to create, view, and manage brainstorming mindmaps using the mindmap terminal module +tags: [mindmap, brainstorming, organization, planning] +category: productivity +--- +## Steps + +1. **Create a new mindmap:** + - \`mindmap new "<topic>" -t tag1,tag2\` — creates a root node + - Tags are optional (e.g. planning, research, dev) + - Root gets auto-assigned a color + +2. **Add child nodes:** + - \`mindmap add <parent-id> "<idea>" -t tag\` — add a child + - Get the parent-id from \`mindmap show\` + - IDs are 4-char random strings (e.g. a3f8, x7k2) + +3. **View the mindmap:** + - \`mindmap show\` — full tree with Unicode box-drawing + - \`mindmap show <id>\` — subtree starting at that node + +4. **Edit nodes:** + - \`mindmap rename <id> "<new text>"\` — change text + - \`mindmap color <id> <color>\` — set color (red/green/yellow/blue/cyan/magenta/white) + - \`mindmap tag <id> <tag>\` — add a tag + +5. **Persistence:** + - \`mindmap save <name>\` — save to localStorage + - \`mindmap load <name>\` — restore + - \`mindmap list\` — show saved mindmaps + - \`mindmap delete <name>\` — remove saved + +6. **Export:** + - \`mindmap export [id]\` — Markdown bullet list output + +## When to use +- User wants to brainstorm, organize ideas, plan features +- User says "map out", "diagram", "brainstorm", "organize thoughts" +- Visualizing project structure or task decomposition`; + + fs.fsWrite('/skills/mindmap-user-guide.md', mindmapSkill); + const existingIndex = fs.fsReadJSON('/skills/index.json') || {}; + existingIndex['mindmap-user-guide'] = { name: 'mindmap-user-guide', description: 'How to create, view, and manage brainstorming mindmaps using the mindmap terminal module', tags: ['mindmap', 'brainstorming', 'organization', 'planning'], category: 'productivity', created: existingIndex['mindmap-user-guide']?.created || new Date().toISOString(), updated: new Date().toISOString() }; + fs.fsWriteJSON('/skills/index.json', existingIndex); + fs.fsWrite('/skills/_seeded', '1'); + } + _trackPattern(userMessage) { + const lower = userMessage.toLowerCase(); + const patterns = ['font size','bigger text','smaller text','export','backup','save','install','load module','search','find','color','theme','skill','learn','remember','mindmap','brainstorm']; + for (const p of patterns) { + if (lower.includes(p)) this._commandFreq[p] = (this._commandFreq[p] || 0) + 1; + } + try { localStorage.setItem('webx11_ai_patterns', JSON.stringify(this._commandFreq)); } catch (e) {} + } + async _autoCreateErrorSkill() { + const grouped = {}; + for (const e of this._errorLog) { + if (!grouped[e.tool]) grouped[e.tool] = []; + grouped[e.tool].push(e); + } + for (const [tool, errors] of Object.entries(grouped)) { + if (errors.length >= 3) { + const skillName = 'tool-' + tool + '-workaround'; + const existing = this.terminal.fs.fsRead('/skills/' + skillName + '.md'); + if (existing) continue; + const steps = errors.map((e, i) => (i + 1) + '. When calling ' + tool + ', avoid: "' + JSON.stringify(e.args) + '" which caused: ' + e.error).join('\n'); + const content = '---\nname: ' + skillName + '\ndescription: Workarounds for ' + tool + ' failures\ntags: [' + tool + ', error, workaround]\ncategory: system\n---\n## Steps\n' + steps; + await this.runTool('create_skill', { name: skillName, description: 'Workarounds for ' + tool + ' failures', content: content, tags: tool + ',error,workaround', category: 'system' }); + this.terminal.print('🤖 Auto-created skill from repeated errors: ' + skillName, 'info'); + } + } + this._errorLog = []; + } + async _auditSkills() { + const fs = this.terminal.fs; + const index = fs.fsReadJSON('/skills/index.json') || {}; + const now = Date.now(); + const issues = []; + for (const [name, meta] of Object.entries(index)) { + const content = fs.fsRead('/skills/' + name + '.md'); + if (!content || content.length < 50) { + issues.push({ name, issue: 'empty or too short' }); + continue; + } + const updated = new Date(meta.updated || 0).getTime(); + const ageDays = (now - updated) / 86400000; + if (ageDays > 30) issues.push({ name, issue: 'not updated in ' + Math.round(ageDays) + ' days' }); + } + if (issues.length > 0) { + this.terminal.print('📋 Skill audit: ' + issues.length + ' issue(s) found', 'info'); + issues.slice(0, 3).forEach(i => this.terminal.print(' - ' + i.name + ': ' + i.issue, 'warning')); + } + } + _estimateTokens(text) { + if (!text || typeof text !== 'string') return 0; + return Math.ceil(text.length / 4); + } + _estimateContextTokens() { + let total = 0; + for (const msg of this.context) { + total += this._estimateTokens(msg.content || ''); + } + return total; + } + async _compressContext() { + if (this.context.length <= 6) return; + const totalTokens = this._estimateContextTokens(); + if (totalTokens < this.maxContextTokens) return; + const keepCount = 3; + const oldMsgs = this.context.slice(0, -keepCount * 2); + if (oldMsgs.length < 2) return; + const recentMsgs = this.context.slice(-keepCount * 2); + const summaryText = oldMsgs.map(m => + (m.role === 'user' ? '👤' : m.role === 'assistant' ? '🤖' : '⚙️') + ': ' + + (m.content || '').substring(0, 150) + ).join(' | '); + let compressed = `[Earlier context (${oldMsgs.length} messages): ${summaryText}]`; + // Try AI summarization, fallback to truncation + try { + if (this.terminal.ollamaManager && this.terminal.ollamaManager.connected) { + const sumPrompt = 'Summarize these conversation exchanges into a single compact sentence. Focus on what the user asked and what actions were taken. Keep it under 200 characters.\n\n' + JSON.stringify(oldMsgs.map(m => ({ role: m.role, content: (m.content || '').substring(0, 200) }))); + const sumRes = await this.terminal.ollamaManager.chat(sumPrompt); + if (sumRes && sumRes.length < 300) { + compressed = '[Earlier context: ' + sumRes.trim() + ']'; + } + } + } catch (e) { /* fallback to truncation */ } + this.context = [{ role: 'system', content: compressed }, ...recentMsgs]; + this.terminal.print('📦 Context compressed (' + oldMsgs.length + ' messages → 1 summary, ~' + totalTokens + ' tokens)', 'info'); + } + _manualCompress() { + this._compressContext().then(() => { + this.terminal.print('Context: ' + this.context.length + ' message(s), ~' + this._estimateContextTokens() + ' estimated tokens', 'info'); + }).catch(e => { + this.terminal.print('Compression error: ' + e.message, 'error'); + }); + } + } diff --git a/modules/core/module-chatstudio.js b/modules/core/module-chatstudio.js new file mode 100644 index 0000000..5777acf --- /dev/null +++ b/modules/core/module-chatstudio.js @@ -0,0 +1,1060 @@ +/* + * ChatStudio Overlay Module for newOS4 v4.2 + * Integrates ChatStudio Lite's full chat UI as an overlay launched via `chat` command. + * All Lite engine functions are inlined from lite/cslite-core.js, adapted to use + * the terminal's OllamaManager for endpoint configuration. + */ +class ChatStudioModule { + constructor() { + this.commands = {}; + this.terminal = null; + this._overlay = null; + this._active = false; + this._cssLoaded = false; + this._escapeHandler = null; + this._initialized = false; + } + + getHelpInfo() { + return { + 'chat': 'Toggle the ChatStudio overlay', + 'chat-setapi': 'Set the API base URL for ChatStudio', + 'chat-setmodel': 'Set the default model for ChatStudio', + 'chat-ask': 'Ask a question directly via ChatStudio' + }; + } + + registerCommands() { + this.commands['chat'] = () => this.toggleChatOverlay(); + this.commands['chat-setapi'] = (args) => { + if (!args[0]) { + const base = window.API_BASE || '/api'; + this.terminal.print('Current API base: ' + base, 'info'); + this.terminal.print('Usage: chat-setapi <base-url>', 'info'); + this.terminal.print('Example: chat-setapi /api', 'info'); + return; + } + const url = args[0].replace(/\/+$/, ''); + window.API_BASE = url; + try { + var settings = JSON.parse(localStorage.getItem('webx11_chat_settings') || '{}'); + settings.apiBase = url; + localStorage.setItem('webx11_chat_settings', JSON.stringify(settings)); + } catch (e) {} + this.terminal.print('API base set to: ' + url + ' (reopen chat to apply)', 'success'); + }; + } + + toggleChatOverlay() { + if (this._active) { + this._closeOverlay(); + } else { + this._openOverlay(); + } + } + + _loadOverlayAssets() { + return new Promise((resolve, reject) => { + // Load CSS + if (!this._cssLoaded) { + const link = document.createElement('link'); + link.rel = 'stylesheet'; + link.href = 'chat/chat-ui.css?v=4'; + link.onload = () => { this._cssLoaded = true; }; + link.onerror = () => reject(new Error('Failed to load chat/chat-ui.css')); + document.head.appendChild(link); + } + + // Expose ALL Lite globals BEFORE scripts load so chat-modules.js & chat-commands.js can use them + if (!window._csGlobalsInited) { + window.OLLAMA_URL = (this.terminal && this.terminal.ollamaManager && this.terminal.ollamaManager.endpoint) || '/ollama'; + window.OLLAMA_API_KEY = ''; + window.DEFAULT_MODEL = 'llama3.2'; + window.USE_STREAMING = true; + window.API_BASE = '/api'; + window.latestAiContent = ''; + window.logs = []; + window.messages = []; + window.isStreaming = false; + window.CHATSTUDIO_VERSION = '1.0.0-lite-inline'; + window.modules = []; + window.currentChatModule = 'default'; + + // Utility functions + window.utf8_to_b64 = function(str) { return btoa(unescape(encodeURIComponent(str))); }; + window.b64_to_utf8 = function(str) { return decodeURIComponent(escape(atob(str))); }; + window.escapeHtml = function(text) { + if (typeof text !== 'string') return ''; + return text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/\"/g, '"'); + }; + window.generateId = function() { return Date.now().toString(36) + '_' + Math.random().toString(36).substr(2, 9); }; + window.showToast = function(msg) { + var t = document.getElementById('toast'); + if (!t) return; + t.textContent = msg; + t.style.display = 'block'; + setTimeout(function() { t.style.display = 'none'; }, 2500); + }; + window.addLog = function(action, detail) { + // Placeholder — replaced by full impl in _initLiteEngine + }; + // Placeholder functions — replaced by full implementations in _initLiteEngine + window.addMessage = function(role, content) {}; + window.showTyping = function() {}; + window.hideTyping = function() {}; + window.loadSettings = function() {}; + window.saveSettings = function() {}; + window.loadModels = function() {}; + window.switchModule = function(id) {}; + window.getCurrentModule = function() {}; + window.sendMessage = function() {}; + window.clearChat = function() {}; + window.closeSettingsModal = function() {}; + window.openSettingsModal = function() {}; + window._csGlobalsInited = true; + } + + // Fetch HTML template + fetch('chat/chat-ui.html?v=4') + .then(r => r.text()) + .then(html => { + // Insert overlay into body if not already there + if (!document.getElementById('chat-overlay')) { + const temp = document.createElement('div'); + temp.innerHTML = html; + document.body.appendChild(temp.firstElementChild); + + // Load commands.js and modules.js dynamically + const loadScript = (src) => { + return new Promise((res, rej) => { + const s = document.createElement('script'); + s.src = src; + s.onload = () => res(); + s.onerror = () => rej(new Error('Failed to load ' + src)); + document.body.appendChild(s); + }); + }; + + Promise.all([ + loadScript('chat/chat-commands.js?v=4'), + loadScript('chat/chat-modules.js?v=4') + ]).then(() => { + resolve(); + }); + } else { + resolve(); + } + }) + .catch(() => reject(new Error('Failed to load chat/chat-ui.html'))); + }); + } + + async _openOverlay() { + if (this._active) return; + this._active = true; + + try { + // Load overlay assets first (CSS, HTML, scripts) + await this._loadOverlayAssets(); + + // Initialize the Lite engine + this._initLiteEngine(); + + // Show overlay + const overlay = document.getElementById('chat-overlay'); + if (overlay) { + overlay.style.display = 'flex'; + overlay.style.flexDirection = 'column'; + } + + // Add Escape listener + this._escapeHandler = (e) => { + if (e.key === 'Escape') { + this._closeOverlay(); + } + }; + document.addEventListener('keydown', this._escapeHandler); + + // Set window-level endpoint for Lite to use + if (this.terminal && this.terminal.ollamaManager) { + window.ollamaEndpoint = this.terminal.ollamaManager.endpoint; + } + } catch (err) { + // Print error to terminal + if (this.terminal && this.terminal.print) { + this.terminal.print('Chat overlay failed: ' + err.message, 'error'); + } + + // Show a toast on the main page if possible + if (typeof window.showToast === 'function') { + window.showToast('Chat overlay failed: ' + err.message); + } + + // Clean up any partial overlay DOM + const partialOverlay = document.getElementById('chat-overlay'); + if (partialOverlay) { + partialOverlay.remove(); + } + + // Reset state so user can retry + this._active = false; + } + } + + _closeOverlay() { + if (!this._active) return; + this._active = false; + + const overlay = document.getElementById('chat-overlay'); + if (overlay) { + overlay.style.display = 'none'; + } + + // Remove Escape listener + if (this._escapeHandler) { + document.removeEventListener('keydown', this._escapeHandler); + this._escapeHandler = null; + } + + // Focus terminal input + if (this.terminal && this.terminal.inputField) { + setTimeout(() => this.terminal.inputField.focus(), 50); + } + } + + _initLiteEngine() { + // Already initialized — no-op + if (this._initialized) return; + + // Set the overlay reference + this._overlay = document.getElementById('chat-overlay'); + if (!this._overlay) { + if (this.terminal && this.terminal.print) { + this.terminal.print('Chat overlay: #chat-overlay element not found', 'error'); + } + return; + } + + this._initialized = true; + + try { + // Close button already exists in HTML as "✕ Close" button calling closeChatOverlay() + window.closeChatOverlay = () => this._closeOverlay(); + + // ── Constants ── + const CHATSTUDIO_VERSION = '1.0.0-lite-inline'; + const API_KEY = 'webx11_chat_api'; + const MESSAGES_KEY = 'cslite_messages'; + const SETTINGS_KEY = 'webx11_chat_settings'; + const LOGS_KEY = 'webx11_chat_logs'; + + // ── State ── + let OLLAMA_URL = '/ollama'; + let OLLAMA_API_KEY = ''; + let DEFAULT_MODEL = 'llama3.2'; + let USE_STREAMING = true; + let API_BASE = '/api'; + let latestAiContent = ''; + let logs = []; + let messages = []; + let isStreaming = false; + + // Sync OLLAMA_URL from terminal + if (this.terminal && this.terminal.ollamaManager) { + OLLAMA_URL = this.terminal.ollamaManager.endpoint || '/ollama'; + } + + // ── Utility Functions ── + function escapeHtml(text) { + if (typeof text !== 'string') return ''; + return text + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"'); + } + + function utf8_to_b64(str) { + return btoa(unescape(encodeURIComponent(str))); + } + + function b64_to_utf8(str) { + return decodeURIComponent(escape(atob(str))); + } + + function generateId() { + return Date.now().toString(36) + '_' + Math.random().toString(36).substr(2, 9); + } + + function showToast(msg) { + var t = document.getElementById('toast'); + if (!t) return; + t.textContent = msg; + t.style.display = 'block'; + setTimeout(function () { + t.style.display = 'none'; + }, 2500); + } + + function autoExpand(el) { + el.style.height = 'auto'; + el.style.height = Math.min(el.scrollHeight, 150) + 'px'; + } + + function toggleSidebar() { + var s = document.getElementById('sidebar'); + if (s) s.classList.toggle('open'); + var b = document.getElementById('sidebarBackdrop'); + if (b) b.classList.toggle('open'); + } + function closeSidebar() { + var s = document.getElementById('sidebar'); + if (s) s.classList.remove('open'); + var b = document.getElementById('sidebarBackdrop'); + if (b) b.classList.remove('open'); + } + + // ── Log System ── + function addLog(action, detail) { + var time = new Date().toLocaleTimeString(); + logs.unshift({ time: time, action: action, detail: detail }); + if (logs.length > 50) logs.pop(); + renderLogs(); + try { + localStorage.setItem(LOGS_KEY, utf8_to_b64(JSON.stringify(logs))); + } catch (e) { /* ignore */ } + } + + function renderLogs() { + var container = document.getElementById('logsContent'); + if (!container) return; + container.innerHTML = logs.map(function (l) { + return '<div class="log-entry">' + + '<span class="log-time">' + escapeHtml(l.time) + '</span>' + + '<span class="log-action">' + escapeHtml(l.action) + '</span>' + + '<span class="log-detail">' + escapeHtml(l.detail) + '</span>' + + '</div>'; + }).join(''); + } + + function copyLogs() { + var text = logs.map(function (l) { + return l.time + ' | ' + l.action + ' | ' + l.detail; + }).join('\n'); + navigator.clipboard.writeText(text).then(function () { + showToast('Logs copied!'); + }); + } + + function clearLogs() { + if (!confirm('Clear all activity logs?')) return; + logs = []; + try { + localStorage.removeItem(LOGS_KEY); + } catch (e) { /* ignore */ } + renderLogs(); + showToast('Logs cleared'); + } + + function loadLogs() { + try { + var saved = localStorage.getItem(LOGS_KEY); + if (saved) logs = JSON.parse(b64_to_utf8(saved)); + } catch (e) { + logs = []; + } + renderLogs(); + } + + // ── Settings ── + function loadSettings() { + try { + var saved = localStorage.getItem(SETTINGS_KEY); + if (saved) { + var settings = JSON.parse(b64_to_utf8(saved)); + OLLAMA_URL = settings.ollamaUrl || '/ollama'; + OLLAMA_API_KEY = settings.ollamaApiKey || ''; + DEFAULT_MODEL = settings.defaultModel || 'llama3.2'; + USE_STREAMING = settings.useStreaming !== false; + API_BASE = settings.apiBase || '/api'; + } + } catch (e) { + console.warn('Failed to load settings:', e); + } + // Sync from terminal if available + var term = window.webx11; + if (term && term.ollamaManager) { + OLLAMA_URL = term.ollamaManager.endpoint || OLLAMA_URL; + } + var urlEl = document.getElementById('ollamaUrl'); + var keyEl = document.getElementById('ollamaApiKey'); + var modelEl = document.getElementById('defaultModel'); + var streamEl = document.getElementById('streamingToggle'); + var apiEl = document.getElementById('apiBase'); + if (urlEl) urlEl.value = OLLAMA_URL; + if (keyEl) keyEl.value = OLLAMA_API_KEY; + if (modelEl) modelEl.value = DEFAULT_MODEL; + if (streamEl) streamEl.checked = USE_STREAMING; + if (apiEl) apiEl.value = API_BASE; + } + + function saveSettings() { + OLLAMA_URL = document.getElementById('ollamaUrl').value.trim() || '/ollama'; + OLLAMA_API_KEY = document.getElementById('ollamaApiKey').value.trim(); + DEFAULT_MODEL = document.getElementById('defaultModel').value.trim() || 'llama3.2'; + USE_STREAMING = document.getElementById('streamingToggle').checked; + API_BASE = document.getElementById('apiBase').value.trim() || '/api'; + try { + var settings = { + ollamaUrl: OLLAMA_URL, + ollamaApiKey: OLLAMA_API_KEY, + defaultModel: DEFAULT_MODEL, + useStreaming: USE_STREAMING, + apiBase: API_BASE + }; + localStorage.setItem(SETTINGS_KEY, utf8_to_b64(JSON.stringify(settings))); + } catch (e) { + console.warn('Failed to save settings:', e); + } + closeSettingsModal(); + showToast('Settings saved!'); + } + + function openSettingsModal() { + loadSettings(); + var m = document.getElementById('settingsModal'); + if (m) m.classList.add('open'); + } + + function closeSettingsModal() { + var m = document.getElementById('settingsModal'); + if (m) m.classList.remove('open'); + } + + async function testConnection() { + var status = document.getElementById('connectionStatus'); + if (!status) return; + status.textContent = 'Testing...'; + status.style.color = 'var(--text2)'; + try { + var headers = { 'Content-Type': 'application/json' }; + if (OLLAMA_API_KEY) headers['Authorization'] = 'Bearer ' + OLLAMA_API_KEY; + var response = await fetch(OLLAMA_URL + '/api/tags', { headers: headers }); + if (response.ok) { + var data = await response.json(); + var modelCount = data.models ? data.models.length : 0; + status.textContent = 'Connected! ' + modelCount + ' models available.'; + status.style.color = 'var(--accent)'; + } else { + status.textContent = 'Connection failed: HTTP ' + response.status; + status.style.color = 'var(--danger)'; + } + } catch (e) { + status.textContent = 'Error: ' + e.message; + status.style.color = 'var(--danger)'; + } + } + + // ── Models ── + async function loadModels() { + try { + var response = await fetch(OLLAMA_URL + '/api/tags'); + if (!response.ok) throw new Error('Failed to fetch models'); + var data = await response.json(); + var select = document.getElementById('modelSelect'); + if (!select) return; + var shortNames = { + 'llama3.2': 'Llama3.2', + 'llama3': 'Llama3', + 'gemma3:4b': 'Gemma3-4B', + 'gemma3:1b': 'Gemma3-1B', + 'gemma2:2b': 'Gemma2', + 'qwen2.5:7b-instruct': 'Qwen2.5', + 'qwen3:8b': 'Qwen3', + 'mistral': 'Mistral', + 'tinyllama': 'Tiny' + }; + select.innerHTML = data.models.map(function (m) { + var name = m.name.split(':')[0]; + var display = shortNames[m.name] || name.substring(0, 14); + return '<option value="' + escapeHtml(m.name) + '">' + escapeHtml(display) + '</option>'; + }).join(''); + addLog('Init', 'Loaded ' + data.models.length + ' models'); + } catch (e) { + console.error('Failed to load models:', e); + var select = document.getElementById('modelSelect'); + if (select) select.innerHTML = '<option value="llama3.2">llama3.2 (default)</option>'; + } + } + + var modelSelect = document.getElementById('modelSelect'); + if (modelSelect) { + modelSelect.addEventListener('change', function () { + addLog('Model', 'Switched to ' + this.value); + }); + } + + // ── Message History ── + function saveMessages(msgs) { + msgs = msgs || messages; + try { + localStorage.setItem(MESSAGES_KEY, JSON.stringify(msgs)); + } catch (e) { + console.warn('Failed to save messages:', e); + } + } + + function loadMessages() { + try { + var raw = localStorage.getItem(MESSAGES_KEY); + if (raw) { + messages = JSON.parse(raw); + } + } catch (e) { + console.warn('Failed to load messages:', e); + messages = []; + } + } + + function renderMessagesFromData() { + var container = document.getElementById('chatContainer'); + if (!container) return; + if (!messages || messages.length === 0) { + return; + } + container.innerHTML = ''; + for (var i = 0; i < messages.length; i++) { + var msg = messages[i]; + appendMessageToDOM(msg.role, msg.content, msg.id, false); + } + container.scrollTop = container.scrollHeight; + } + + function appendMessageToDOM(role, content, msgId, save) { + if (save === undefined) save = true; + var container = document.getElementById('chatContainer'); + if (!container) return; + var div = document.createElement('div'); + div.className = 'message ' + role; + if (msgId) div.dataset.msgId = msgId; + var previewBtn = role === 'ai' + ? '<button class="msg-copy" onclick="showHtmlPreview()" title="Preview HTML">👁️</button>' + : ''; + if (role === 'ai') latestAiContent = content; + var renderedContent = role === 'user' ? escapeHtml(content) : formatContent(content); + div.innerHTML = '<div class="role">' + + '<span>' + (role === 'user' ? 'You' : 'AI') + '</span>' + + '<div style="display:flex;gap:4px;">' + + '<button class="msg-copy" onclick="copyMessage(this, \'' + role + '\')" title="Copy">📋</button>' + + previewBtn + + '</div></div>' + + '<div class="msg-content">' + renderedContent + '</div>'; + div.dataset.content = content; + container.appendChild(div); + container.scrollTop = container.scrollHeight; + if (save) { + saveMessages(); + } + } + + function addMessage(role, content) { + var msgId = generateId(); + var msg = { + id: msgId, + role: role, + content: content, + timestamp: Date.now() + }; + messages.push(msg); + appendMessageToDOM(role, content, msgId, true); + return msgId; + } + + // ── Content Formatting ── + function formatContent(text) { + // Step 1: Extract code blocks into placeholders + var blocks = []; + var processed = text + .replace(/```html\n?([\s\S]*?)```/g, function(m, code) { + var idx = blocks.length; + blocks.push({lang: 'html', code: code, isBlock: true}); + return '%%CODEBLOCK_' + idx + '%%'; + }) + .replace(/```(\w*)\n?([\s\S]*?)```/g, function(m, lang, code) { + var idx = blocks.length; + blocks.push({lang: lang, code: code, isBlock: true}); + return '%%CODEBLOCK_' + idx + '%%'; + }) + .replace(/`([^`]+)`/g, function(m, content) { + var idx = blocks.length; + blocks.push({lang: '', code: content, isBlock: false, isInline: true}); + return '%%CODEBLOCK_' + idx + '%%'; + }); + + // Step 2: Escape everything outside code blocks + processed = escapeHtml(processed); + + // Step 3: Apply markdown formatting on the escaped text + processed = processed + .replace(/\*\*([^*]+)\*\*/g, function(m, content) { return '<strong>' + content + '</strong>'; }) + .replace(/(https?:\/\/[^\s<]+)/g, '<a href="$1" target="_blank" rel="noopener">$1</a>') + .replace(/\n/g, '<br>'); + + // Step 4: Restore code blocks with proper HTML + processed = processed.replace(/%%CODEBLOCK_(\d+)%%/g, function(m, idx) { + idx = parseInt(idx); + var block = blocks[idx]; + if (!block) return m; + if (block.isInline) { + return '<code>' + escapeHtml(block.code) + '</code>'; + } + var id = 'code_' + Math.random().toString(36).substr(2, 9); + if (block.lang === 'html') { + return '<div class="code-block" data-code="' + encodeURIComponent(block.code) + '">' + + '<pre>' + + '<button class="msg-copy" style="opacity:1;float:right;margin-bottom:8px;" onclick="previewCodeBlock(this)">👁️</button>' + + '<button class="msg-copy" style="opacity:1;float:right;margin-bottom:8px;margin-right:8px;" onclick="copyMessage(this, \'code\')">📋</button>' + + '<code id="' + id + '">' + escapeHtml(block.code) + '</code></pre></div>'; + } + return '<pre>' + + '<button class="msg-copy" style="opacity:1;float:right;margin-bottom:8px;" onclick="copyMessage(this, \'code\')">📋</button>' + + '<code>' + escapeHtml(block.code) + '</code></pre>'; + }); + + return processed; + } + + // ── Copy & Preview ── + function copyMessage(btn, role) { + var content; + if (role === 'code') { + content = btn.nextElementSibling.textContent; + } else { + var msg = btn.closest('.message'); + content = msg.dataset.content; + } + navigator.clipboard.writeText(content).then(function () { + showToast('Copied!'); + }); + } + + function showHtmlPreview() { + var frame = document.getElementById('previewFrame'); + if (!frame) return; + var doc = frame.contentWindow.document; + doc.open(); + doc.write(latestAiContent); + doc.close(); + var m = document.getElementById('previewModal'); + if (m) m.classList.add('open'); + } + + function previewCodeBlock(btn) { + var block = btn.closest('.code-block'); + if (!block) return; + var code = decodeURIComponent(block.dataset.code); + var frame = document.getElementById('previewFrame'); + if (!frame) return; + var doc = frame.contentWindow.document; + doc.open(); + doc.write(code); + doc.close(); + var m = document.getElementById('previewModal'); + if (m) m.classList.add('open'); + } + + function closePreviewModal() { + var m = document.getElementById('previewModal'); + if (m) m.classList.remove('open'); + } + + // ── Typing Indicator ── + function showTyping() { + var container = document.getElementById('chatContainer'); + if (!container) return; + var div = document.createElement('div'); + div.className = 'typing'; + div.id = 'typingIndicator'; + div.innerHTML = 'AI is thinking' + + '<span class="typing-dots">' + + '<span></span><span></span><span></span>' + + '</span>'; + container.appendChild(div); + container.scrollTop = container.scrollHeight; + } + + function hideTyping() { + var t = document.getElementById('typingIndicator'); + if (t) t.remove(); + } + + // ── Send Message ── + async function sendMessage() { + if (isStreaming) return; + + var input = document.getElementById('chatInput'); + if (!input) return; + var msg = input.value.trim(); + if (!msg) return; + + input.value = ''; + input.style.height = 'auto'; + + addMessage('user', msg); + addLog('User', msg.substring(0, 50) + (msg.length > 50 ? '...' : '')); + + if (msg.startsWith('/')) { + await handleCommand(msg); + return; + } + + showTyping(); + + // Sync OLLAMA_URL from terminal before sending + var term = window.webx11; + if (term && term.ollamaManager) { + OLLAMA_URL = term.ollamaManager.endpoint || OLLAMA_URL; + } + // Re-set window-level endpoint + window.ollamaEndpoint = OLLAMA_URL; + + var modelSelect = document.getElementById('modelSelect'); + var model = modelSelect ? modelSelect.value : DEFAULT_MODEL; + var mod = typeof getCurrentModule === 'function' ? getCurrentModule() : null; + var systemPrompt = mod && mod.prompt ? mod.prompt : ''; + var chatHistory = buildChatContext(); + + try { + var headers = { 'Content-Type': 'application/json' }; + if (OLLAMA_API_KEY) headers['Authorization'] = 'Bearer ' + OLLAMA_API_KEY; + + var fullPrompt = systemPrompt + ? systemPrompt + '\n\n' + chatHistory + '\n\nUser: ' + msg + '\n\nAI:' + : chatHistory + '\n\nUser: ' + msg + '\n\nAI:'; + + if (USE_STREAMING) { + await sendStreamingRequest(headers, model, fullPrompt); + } else { + await sendNonStreamingRequest(headers, model, fullPrompt); + } + } catch (e) { + hideTyping(); + console.error('Chat error:', e); + addMessage('ai', 'Sorry, I could not connect to the AI service. Make sure Ollama is running on your server.\n\nError: ' + e.message); + } + } + + async function sendStreamingRequest(headers, model, prompt) { + isStreaming = true; + hideTyping(); + + var msgId = generateId(); + var container = document.getElementById('chatContainer'); + if (!container) { isStreaming = false; return; } + var div = document.createElement('div'); + div.className = 'message ai'; + div.dataset.msgId = msgId; + div.innerHTML = '<div class="role"><span>AI</span><div style="display:flex;gap:4px;">' + + '<button class="msg-copy" onclick="copyMessage(this, \'ai\')" title="Copy">📋</button>' + + '<button class="msg-copy" onclick="showHtmlPreview()" title="Preview HTML">👁️</button>' + + '</div></div><div class="msg-content"><span class="streaming-cursor"></span></div>'; + div.dataset.content = ''; + container.appendChild(div); + container.scrollTop = container.scrollHeight; + + var contentEl = div.querySelector('.msg-content'); + var fullContent = ''; + + try { + var response = await fetch(OLLAMA_URL + '/api/generate', { + method: 'POST', + headers: headers, + body: JSON.stringify({ + model: model, + prompt: prompt, + stream: true + }) + }); + + if (!response.ok) { + contentEl.textContent = 'AI request failed: HTTP ' + response.status; + throw new Error('AI request failed: HTTP ' + response.status); + } + + var reader = response.body.getReader(); + var decoder = new TextDecoder(); + var buffer = ''; + + var lastFormat = 0; + var formatInterval = 400; + + while (true) { + var result = await reader.read(); + if (result.done) break; + + buffer += decoder.decode(result.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) continue; + try { + var parsed = JSON.parse(line); + if (parsed.response) { + fullContent += parsed.response; + var now = Date.now(); + if (now - lastFormat >= formatInterval) { + contentEl.innerHTML = formatContent(fullContent) + '<span class="streaming-cursor"></span>'; + container.scrollTop = container.scrollHeight; + lastFormat = now; + } + } + if (parsed.done) { + break; + } + } catch (e) { + } + } + } + + // Process remaining buffer + if (buffer.trim()) { + try { + var parsed = JSON.parse(buffer); + if (parsed.response) { + fullContent += parsed.response; + } + } catch (e) { /* ignore */ } + } + + // Final render without cursor + contentEl.innerHTML = formatContent(fullContent); + div.dataset.content = fullContent; + latestAiContent = fullContent; + container.scrollTop = container.scrollHeight; + + messages.push({ + id: msgId, + role: 'ai', + content: fullContent, + timestamp: Date.now() + }); + saveMessages(); + addLog('AI', fullContent.substring(0, 50) + '...'); + + } catch (e) { + if (contentEl) contentEl.textContent = 'Error: ' + e.message; + console.error('Stream error:', e); + } + + isStreaming = false; + } + + async function sendNonStreamingRequest(headers, model, prompt) { + try { + var response = await fetch(OLLAMA_URL + '/api/generate', { + method: 'POST', + headers: headers, + body: JSON.stringify({ + model: model, + prompt: prompt, + stream: false + }) + }); + + hideTyping(); + + if (!response.ok) throw new Error('AI request failed'); + + var data = await response.json(); + addMessage('ai', data.response || 'No response'); + addLog('AI', (data.response || '').substring(0, 50) + '...'); + } catch (e) { + hideTyping(); + throw e; + } + } + + function buildChatContext() { + var context = ''; + for (var i = 0; i < messages.length; i++) { + var m = messages[i]; + var role = m.role === 'user' ? 'User' : 'AI'; + var content = m.content.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim(); + context += role + ': ' + content + '\n'; + } + return context; + } + + function getChatHistory() { + return buildChatContext(); + } + + // ── Clear & Share ── + function clearChat() { + if (!confirm('Clear chat history?')) return; + messages = []; + localStorage.removeItem(MESSAGES_KEY); + var container = document.getElementById('chatContainer'); + if (container) { + container.innerHTML = + '<div class="message ai">' + + '<div class="role">AI</div>' + + 'Chat cleared! How can I help you?' + + '</div>'; + } + addLog('Clear', 'Chat history cleared'); + } + + function shareChat() { + var msgs = []; + for (var i = 0; i < messages.length; i++) { + var m = messages[i]; + var role = m.role === 'user' ? 'User' : 'AI'; + msgs.push('**' + role + ':** ' + m.content); + } + var text = '# WebX11 AI Chat\n\n' + msgs.join('\n\n'); + + if (navigator.clipboard) { + navigator.clipboard.writeText(text).then(function () { + showToast('Chat copied to clipboard!'); + addLog('Share', 'Chat copied to clipboard'); + }); + } else { + var blob = new Blob([text], { type: 'text/plain' }); + var url = URL.createObjectURL(blob); + var a = document.createElement('a'); + a.href = url; + a.download = 'chat-export.txt'; + a.click(); + URL.revokeObjectURL(url); + addLog('Share', 'Chat downloaded as file'); + } + } + + // ── Expose Lite globals for commands.js and modules.js ── + // All functions exposed under CSLite namespace AND as individual window.* + // for backward compatibility with chat-commands.js and chat-modules.js + window.CSLite = { + CHATSTUDIO_VERSION: CHATSTUDIO_VERSION, + OLLAMA_URL: OLLAMA_URL, + OLLAMA_API_KEY: OLLAMA_API_KEY, + DEFAULT_MODEL: DEFAULT_MODEL, + USE_STREAMING: USE_STREAMING, + API_BASE: API_BASE, + getApiUrl: function(path) { return (window.CSLite.API_BASE || '/api') + path; }, + latestAiContent: latestAiContent, + logs: logs, + messages: messages, + isStreaming: isStreaming, + modules: [], + currentChatModule: 'default', + escapeHtml: escapeHtml, + utf8_to_b64: utf8_to_b64, + b64_to_utf8: b64_to_utf8, + generateId: generateId, + showToast: showToast, + autoExpand: autoExpand, + toggleSidebar: toggleSidebar, + closeSidebar: closeSidebar, + addLog: addLog, + renderLogs: renderLogs, + loadSettings: loadSettings, + saveSettings: saveSettings, + loadModels: loadModels, + loadMessages: loadMessages, + saveMessages: saveMessages, + appendMessageToDOM: appendMessageToDOM, + addMessage: addMessage, + formatContent: formatContent, + copyMessage: copyMessage, + showHtmlPreview: showHtmlPreview, + previewCodeBlock: previewCodeBlock, + closePreviewModal: closePreviewModal, + showTyping: showTyping, + hideTyping: hideTyping, + sendMessage: sendMessage, + sendStreamingRequest: sendStreamingRequest, + sendNonStreamingRequest: sendNonStreamingRequest, + buildChatContext: buildChatContext, + getChatHistory: getChatHistory, + clearChat: clearChat, + shareChat: shareChat, + copyLogs: copyLogs, + clearLogs: clearLogs, + loadLogs: loadLogs, + testConnection: testConnection, + openSettingsModal: openSettingsModal, + closeSettingsModal: closeSettingsModal + }; + // Individual window.* assignments for backward compat + window.CHATSTUDIO_VERSION = CHATSTUDIO_VERSION; + window.OLLAMA_URL = OLLAMA_URL; + window.OLLAMA_API_KEY = OLLAMA_API_KEY; + window.DEFAULT_MODEL = DEFAULT_MODEL; + window.USE_STREAMING = USE_STREAMING; + window.API_BASE = API_BASE; + window.getApiUrl = function(path) { return (window.API_BASE || '/api') + path; }; + window.latestAiContent = latestAiContent; + window.logs = logs; + window.messages = messages; + window.isStreaming = isStreaming; + window.escapeHtml = escapeHtml; + window.utf8_to_b64 = utf8_to_b64; + window.b64_to_utf8 = b64_to_utf8; + window.generateId = generateId; + window.showToast = showToast; + window.autoExpand = autoExpand; + window.toggleSidebar = toggleSidebar; + window.closeSidebar = closeSidebar; + window.addLog = addLog; + window.renderLogs = renderLogs; + window.loadSettings = loadSettings; + window.saveSettings = saveSettings; + window.loadModels = loadModels; + window.loadMessages = loadMessages; + window.saveMessages = saveMessages; + window.appendMessageToDOM = appendMessageToDOM; + window.addMessage = addMessage; + window.formatContent = formatContent; + window.copyMessage = copyMessage; + window.showHtmlPreview = showHtmlPreview; + window.previewCodeBlock = previewCodeBlock; + window.closePreviewModal = closePreviewModal; + window.showTyping = showTyping; + window.hideTyping = hideTyping; + window.sendMessage = sendMessage; + window.sendStreamingRequest = sendStreamingRequest; + window.sendNonStreamingRequest = sendNonStreamingRequest; + window.buildChatContext = buildChatContext; + window.getChatHistory = getChatHistory; + window.clearChat = clearChat; + window.shareChat = shareChat; + window.copyLogs = copyLogs; + window.clearLogs = clearLogs; + window.loadLogs = loadLogs; + window.testConnection = testConnection; + window.openSettingsModal = openSettingsModal; + window.closeSettingsModal = closeSettingsModal; + + // ── Init ── + loadSettings(); + loadMessages(); + loadLogs(); + loadModels(); + renderMessagesFromData(); + addLog('Init', 'Chat overlay initialized'); + } catch (err) { + // Reset initialization flag so user can retry + this._initialized = false; + + // Print error to terminal + if (this.terminal && this.terminal.print) { + this.terminal.print('Chat overlay init error: ' + err.message, 'error'); + } + + console.error('ChatStudioModule._initLiteEngine error:', err); + } + } +} diff --git a/modules/core/module-config.js b/modules/core/module-config.js new file mode 100644 index 0000000..fb5199e --- /dev/null +++ b/modules/core/module-config.js @@ -0,0 +1,92 @@ +// Extracted from newOS4 index.html — ConfigManagerModule + class ConfigManagerModule { + constructor() { + this.commands = {}; this.terminal = null; + this.configPrefix = 'webx11_config_'; + } + getHelpInfo() { + return { + 'config': 'config [key] — Show all config or a specific key', + 'config-set': 'config-set <key> <value> — Set a configuration value', + 'config-list': 'config-list — List all configuration keys', + 'config-delete': 'config-delete <key> — Delete a configuration key', + 'config-export': 'config-export — Export all config as JSON', + 'config-reset': 'config-reset — Reset all configuration to defaults', + }; + } + registerCommands() { + this.commands['config'] = (args) => this.getConfig(args); + this.commands['config-set'] = (args) => this.setConfig(args); + this.commands['config-list'] = () => this.listConfig(); + this.commands['config-delete'] = (args) => this.deleteConfig(args); + this.commands['config-export'] = () => this.exportConfig(); + this.commands['config-reset'] = () => this.resetConfig(); + } + getConfig(args) { + if (!args[0]) { + this.terminal.print("Usage: config <key>", "warning"); return; + } + const value = localStorage.getItem(this.configPrefix + args[0]); + if (value) { + this.terminal.print(`${args[0]}: ${value}`, "info"); + } else { + this.terminal.print(`Key '${args[0]}' not found`, "error"); + } + } + setConfig(args) { + if (args.length < 2) { + this.terminal.print("Usage: config-set <key> <value>", "warning"); return; + } + const key = args[0], value = args.slice(1).join(' '); + localStorage.setItem(this.configPrefix + key, value); + this.terminal.print(`Config saved: ${key} = ${value}`, "success"); + } + listConfig() { + const configs = {}; + for (let i = 0; i < localStorage.length; i++) { + const key = localStorage.key(i); + if (key.startsWith(this.configPrefix)) { + configs[key.replace(this.configPrefix, '')] = localStorage.getItem(key); + } + } + if (Object.keys(configs).length === 0) { + this.terminal.print("No configuration saved", "info"); return; + } + this.terminal.print("=== Configuration ===", "info"); + Object.entries(configs).forEach(([k, v]) => { this.terminal.print(`${k}: ${v}`); }); + } + deleteConfig(args) { + if (!args[0]) { + this.terminal.print("Usage: config-delete <key>", "warning"); return; + } + localStorage.removeItem(this.configPrefix + args[0]); + this.terminal.print(`Config '${args[0]}' deleted`, "success"); + } + exportConfig() { + const configs = {}; + for (let i = 0; i < localStorage.length; i++) { + const key = localStorage.key(i); + if (key.startsWith(this.configPrefix)) { + configs[key] = localStorage.getItem(key); + } + } + const json = JSON.stringify(configs, null, 2); + const blob = new Blob([json], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; a.download = 'webx11-config.json'; a.click(); + URL.revokeObjectURL(url); + this.terminal.print("Configuration exported", "success"); + } + resetConfig() { + if (confirm("Reset all configuration to defaults?")) { + const keysToRemove = []; + for (let i = 0; i < localStorage.length; i++) { + const key = localStorage.key(i); + if (key.startsWith(this.configPrefix)) { keysToRemove.push(key); } + } + keysToRemove.forEach(k => localStorage.removeItem(k)); + this.terminal.print("Configuration reset to defaults", "success"); + } + } + } diff --git a/modules/core/module-duckduckgo.js b/modules/core/module-duckduckgo.js new file mode 100644 index 0000000..c443b72 --- /dev/null +++ b/modules/core/module-duckduckgo.js @@ -0,0 +1,38 @@ +// Extracted from newOS4 index.html — DuckDuckGoModule + class DuckDuckGoModule { + constructor() { + this.commands = {}; + this.terminal = null; + } + getHelpInfo() { + return { + 'ddg': 'ddg <query> — Search DuckDuckGo (opens query in new tab)', + 'duck': 'duck <query> — Alias for ddg', + }; + } + registerCommands() { + this.commands['ddg'] = (args) => this.search(args); + this.commands['duck'] = (args) => this.search(args); + } + async search(args) { + if (!args[0]) { + this.terminal.print("Usage: ddg <search-query>", "warning"); + this.terminal.print("Example: ddg javascript tutorials", "info"); + this.terminal.print("Note: Due to CORS, results may be limited. Try 'search' command for better results.", "warning"); + return; + } + const query = args.join(' '); + this.terminal.print(`🦆 DuckDuckGo Search: ${query}`, "info"); + + // DuckDuckGo doesn't have a public API, so we provide alternatives + this.terminal.print("⚠️ DuckDuckGo doesn't provide a public API for direct access.", "warning"); + this.terminal.print("Opening search in new tab...", "info"); + + // Open in new tab as fallback + const ddgUrl = `https://duckduckgo.com/?q=${encodeURIComponent(query)}`; + window.open(ddgUrl, '_blank'); + + this.terminal.print(`\n💡 Alternative: Use 'search <query>' for SearXNG (privacy-focused)`, "info"); + this.terminal.print(`🔗 Direct link: ${ddgUrl}`, "info"); + } + } diff --git a/modules/core/module-dynamicprompts.js b/modules/core/module-dynamicprompts.js new file mode 100644 index 0000000..ff8c5ea --- /dev/null +++ b/modules/core/module-dynamicprompts.js @@ -0,0 +1,102 @@ +// Extracted from newOS4 index.html — DynamicPromptsModule + class DynamicPromptsModule { + constructor() { + this.commands = {}; this.terminal = null; + this.prompts = this.loadPrompts(); this.variables = {}; + } + getHelpInfo() { + return { + 'prompt': 'prompt <name> [additional-text] — Use a saved prompt template', + 'prompt-save': 'prompt-save <name> <template> — Save a new prompt template', + 'prompt-list': 'prompt-list — List all saved prompt templates', + 'prompt-delete': 'prompt-delete <name> — Delete a saved prompt', + 'prompt-var': 'prompt-var <name> <value> — Set a template variable', + 'prompt-vars': 'prompt-vars — List all template variables' + }; + } + loadPrompts() { + try { + const saved = localStorage.getItem('webx11_prompts'); + return saved ? JSON.parse(saved) : {}; + } catch (e) { return {}; } + } + savePrompts() { + localStorage.setItem('webx11_prompts', JSON.stringify(this.prompts)); + } + registerCommands() { + this.commands['prompt'] = (args) => this.usePrompt(args); + this.commands['prompt-save'] = (args) => this.savePrompt(args); + this.commands['prompt-list'] = () => this.listPrompts(); + this.commands['prompt-delete'] = (args) => this.deletePrompt(args); + this.commands['prompt-var'] = (args) => this.setVariable(args); + this.commands['prompt-vars'] = () => this.listVariables(); + } + usePrompt(args) { + if (!args[0]) { + this.terminal.print("Usage: prompt <name> [additional-text]", "warning"); return; + } + const name = args[0], additional = args.slice(1).join(' '); + if (!this.prompts[name]) { + this.terminal.print(`Prompt '${name}' not found`, "error"); return; + } + let template = this.prompts[name].template; + Object.entries(this.variables).forEach(([k, v]) => { + template = template.replace(new RegExp(`\\{${k}\\}`, 'g'), v); + }); + const finalPrompt = additional ? `${template} ${additional}` : template; + this.terminal.print("=== Generated Prompt ===", "info"); + this.terminal.printHTML(`<div class="output-line ollama" style="margin-top: 5px;"> + <button class="copy-btn" onclick="navigator.clipboard.writeText(this.parentElement.innerText.replace('Copy','').trim())">Copy</button> + ${finalPrompt} + </div>`); + this.terminal.print("\nUse with: ai <generated-prompt>", "info"); + } + savePrompt(args) { + if (args.length < 2) { + this.terminal.print("Usage: prompt-save <name> <template>", "warning"); return; + } + const name = args[0], template = args.slice(1).join(' '); + this.prompts[name] = { template, created: new Date().toISOString() }; + this.savePrompts(); + this.terminal.print(`Prompt '${name}' saved`, "success"); + } + listPrompts() { + const names = Object.keys(this.prompts); + if (names.length === 0) { + this.terminal.print("No saved prompts", "info"); return; + } + this.terminal.print("=== Saved Prompts ===", "info"); + names.forEach(name => { + const p = this.prompts[name]; + this.terminal.print(`📝 ${name}: ${p.template.substring(0, 50)}...`); + }); + } + deletePrompt(args) { + if (!args[0]) { + this.terminal.print("Usage: prompt-delete <name>", "warning"); return; + } + if (this.prompts[args[0]]) { + delete this.prompts[args[0]]; + this.savePrompts(); + this.terminal.print(`Prompt '${args[0]}' deleted`, "success"); + } else { + this.terminal.print(`Prompt '${args[0]}' not found`, "error"); + } + } + setVariable(args) { + if (args.length < 2) { + this.terminal.print("Usage: prompt-var <name> <value>", "warning"); return; + } + const name = args[0], value = args.slice(1).join(' '); + this.variables[name] = value; + this.terminal.print(`Variable set: ${name} = ${value}`, "success"); + } + listVariables() { + const names = Object.keys(this.variables); + if (names.length === 0) { + this.terminal.print("No variables set", "info"); return; + } + this.terminal.print("=== Variables ===", "info"); + names.forEach(name => { this.terminal.print(`${name}: ${this.variables[name]}`); }); + } + } diff --git a/modules/core/module-fetcher.js b/modules/core/module-fetcher.js new file mode 100644 index 0000000..fea84d2 --- /dev/null +++ b/modules/core/module-fetcher.js @@ -0,0 +1,65 @@ +class WebFetcherModule { + constructor() { + this.commands = {}; + this.terminal = null; + } + getHelpInfo() { + return { + 'fetch': 'fetch <url> — Fetch and display raw HTML/text content from a URL', + 'web': 'web <url> — Alias for fetch', + 'extract': 'extract <url> — Extract readable article content (title + body text) from a URL', + }; + } + registerCommands() { + this.commands['fetch'] = (args) => this.fetchUrl(args); + this.commands['web'] = (args) => this.fetchUrl(args); + this.commands['extract'] = (args) => this.extractUrl(args); + } + async fetchUrl(args) { + if (!args[0]) { + this.terminal.print("Usage: fetch <url>", "warning"); + this.terminal.print("Example: fetch https://example.com", "info"); + return; + } + let url = args[0]; + if (!url.match(/^https?:\/\//i)) url = 'https://' + url; + this.terminal.print(`🌐 Fetching: ${url}`, "info"); + try { + const resp = await fetch('/api/fetch?url=' + encodeURIComponent(url)); + if (resp.status === 403) { this.terminal.print('Blocked: internal network addresses not allowed', 'error'); return; } + if (!resp.ok) { this.terminal.print('Fetch failed: HTTP ' + resp.status, 'error'); return; } + const text = await resp.text(); + const cleanText = text.replace(/\s+/g, ' ').trim(); + this.terminal.print(`\n=== Content from ${url} ===\n`, 'success'); + this.terminal.print(cleanText.substring(0, 3000) + (cleanText.length > 3000 ? '...' : '')); + this.terminal.print(`\n📏 Content Length: ${cleanText.length} chars`, 'info'); + } catch (e) { + this.terminal.print('Fetch failed: ' + e.message, 'error'); + } + } + async extractUrl(args) { + if (!args[0]) { + this.terminal.print("Usage: extract <url>", "warning"); + this.terminal.print("Example: extract https://example.com", "info"); + return; + } + let url = args[0]; + if (!url.match(/^https?:\/\//i)) url = 'https://' + url; + this.terminal.print(`📄 Extracting: ${url}`, "info"); + try { + const resp = await fetch('/api/extract?url=' + encodeURIComponent(url)); + if (resp.status === 403) { this.terminal.print('Blocked: internal network addresses not allowed', 'error'); return; } + if (!resp.ok) { + const err = await resp.json().catch(() => ({})); + this.terminal.print('Extract failed: ' + (err.error || 'HTTP ' + resp.status), 'error'); + return; + } + const data = await resp.json(); + if (data.title) this.terminal.print(`\n📰 ${data.title}\n`, 'success'); + this.terminal.print(data.content || '(no content extracted)'); + this.terminal.print(`\n📏 ${data.word_count || 0} words (via ${data.method || 'unknown'})`, 'info'); + } catch (e) { + this.terminal.print('Extract failed: ' + e.message, 'error'); + } + } +} diff --git a/modules/core/module-fs.js b/modules/core/module-fs.js new file mode 100644 index 0000000..c4bc786 --- /dev/null +++ b/modules/core/module-fs.js @@ -0,0 +1,445 @@ +class FileSystemModule { + constructor() { this.commands = {}; this.terminal = null; } + getHelpInfo() { + return { + 'ls': 'ls [path] [-la] — List files and directories', + 'cat': 'cat <file> — Display file contents', + 'put': 'put <file> <content> — Create or overwrite a file', + 'rm': 'rm <file> — Remove a file; use -r/-rf for directories', + 'mkdir': 'mkdir <dir> [-p] — Create a directory; -p creates parent paths', + 'rmdir': 'rmdir <dir> — Remove a directory', + 'mv': 'mv <source> <dest> — Move file or directory', + 'cp': 'cp <source> <dest> — Copy file or directory', + 'cd': 'cd <path> — Change current directory; cd / to go to root', + 'pwd': 'pwd — Print current working directory', + 'find': 'find <pattern> — Search files by name pattern', + 'grep': 'grep <pattern> [file] — Search file content for pattern', + 'head': 'head <file> [lines] — Show first N lines (default: 10)', + 'tail': 'tail <file> [lines] — Show last N lines (default: 10)', + 'sort': 'sort <file> — Sort lines of a file alphabetically', + 'wc': 'wc <file> — Count lines, words, and bytes', + 'touch': 'touch <file> — Create empty file or update timestamp', + 'tree': 'tree — Display directory structure as a tree', + 'du': 'du [path] — Estimate file/directory size', + 'upload': 'upload — Upload a file from your device', + 'download': 'download <file> — Download a file to your device', + 'vfs-export': 'vfs-export — Export the entire virtual filesystem as JSON', + 'vfs-import': 'vfs-import — Import a previously exported VFS JSON file', + }; + } + registerCommands() { + const self = this; + this.commands['ls'] = (args) => { + const target = args && args.length > 0 && !args[0].startsWith('-') ? args[0] : undefined; + const flags = (args || []).filter(a => a.startsWith('-')); + const showLong = flags.some(f => f.includes('l')); + const showHidden = flags.some(f => f.includes('a')); + const files = this.terminal.fs.ls(target); + if (!files || files.length === 0) { this.terminal.print('(empty)', 'info'); return; } + const filtered = showHidden ? files : files.filter(f => !f.name.startsWith('.')); + if (filtered.length === 0) { this.terminal.print('(no visible files, use ls -a)', 'info'); return; } + const maxName = Math.max(...filtered.map(f => f.name.length), 8); + if (showLong) { + filtered.forEach(f => { + const type = f.type === 'dir' ? 'd' : '-'; + const perms = f.type === 'dir' ? 'rwxr-xr-x' : 'rw-r--r--'; + const size = String(f.size || 0).padStart(6); + this.terminal.print(type + perms + ' darkbot ' + size + ' ' + f.name); + }); + const total = filtered.reduce((s, f) => s + (f.size || 0), 0); + this.terminal.print('total ' + filtered.length + ' (' + total + ' bytes)', 'text-muted'); + } else { + this.terminal.printClickable(filtered); + } + }; + this.commands['cat'] = (args) => { + if(!args[0]) return this.terminal.print('Usage: cat <filename>', 'warning'); + const content = this.terminal.fs.readFile(args[0]); + if(content !== null && content !== undefined) { + if (content.length === 0) this.terminal.print('(empty file)', 'info'); + else this.terminal.print(content); + } else this.terminal.print('cat: ' + args[0] + ': No such file', 'error'); + }; + this.commands['echo'] = (args) => { + if (!args || args.length === 0) { this.terminal.print(''); return; } + const ri = args.indexOf('>'); + if (ri >= 0) { + const c = args.slice(0, ri).join(' '); const fn = args.slice(ri + 1).join(' '); + if (fn) { this.terminal.fs.writeFile(fn, c); this.terminal.print('Wrote ' + c.length + ' bytes to ' + fn, 'success'); } + return; + } + this.terminal.print(args.join(' ')); + }; + this.commands['put'] = (args) => { + if(args.length < 2) return this.terminal.print('Usage: put <filename> <content>', 'warning'); + const name = args[0]; const content = args.slice(1).join(' '); + this.terminal.fs.writeFile(name, content); + this.terminal.print('Created/updated: ' + name + ' (' + content.length + ' bytes)', 'success'); + }; + this.commands['rm'] = (args) => { + if(!args[0]) return this.terminal.print('Usage: rm <filename>', 'warning'); + if(args[0] === '-r' || args[0] === '-rf') { + const t = args[1]; if (!t) return this.terminal.print('Usage: rm -r <dirname>', 'warning'); + if (this.terminal.fs.deleteDirRecursive(t)) this.terminal.print('Removed: ' + t, 'success'); + else this.terminal.print('rm: ' + t + ': No such file or directory', 'error'); + return; + } + if(this.terminal.fs.deleteFile(args[0])) this.terminal.print('Removed: ' + args[0], 'success'); + else this.terminal.print('rm: ' + args[0] + ': No such file', 'error'); + }; + this.commands['mkdir'] = (args) => { + if(!args[0]) return this.terminal.print('Usage: mkdir <dirname>', 'warning'); + if (args[0] === '-p') { + if(!args[1]) return this.terminal.print('Usage: mkdir -p <path>', 'warning'); + if(this.terminal.fs.mkdirRecursive(args[1])) this.terminal.print('Created directories: ' + args[1], 'success'); + else this.terminal.print('mkdir: cannot create ' + args[1], 'error'); + return; + } + if(this.terminal.fs.mkdir(args[0])) this.terminal.print('Created directory: ' + args[0], 'success'); + else this.terminal.print('mkdir: cannot create ' + args[0] + ': File exists', 'error'); + }; + this.commands['rmdir'] = (args) => { + if(!args[0]) return this.terminal.print('Usage: rmdir <dirname>', 'warning'); + if (this.terminal.fs.deleteDirRecursive(args[0])) this.terminal.print('Removed directory: ' + args[0], 'success'); + else this.terminal.print('rmdir: ' + args[0] + ': No such directory', 'error'); + }; + this.commands['mv'] = (args) => { + if(!args[0] || !args[1]) return this.terminal.print('Usage: mv <source> <dest>', 'warning'); + const srcDir = this.terminal.fs.readDir(args[0]); + if (srcDir !== null) { + if (this.terminal.fs.moveDir(args[0], args[1])) { + this.terminal.print('Moved directory: ' + args[0] + ' to ' + args[1], 'success'); + } else { + this.terminal.print('mv: ' + args[0] + ': failed to move directory', 'error'); + } + return; + } + if (this.terminal.fs.moveFile(args[0], args[1])) { + this.terminal.print('Moved: ' + args[0] + ' to ' + args[1], 'success'); + } else { + this.terminal.print('mv: ' + args[0] + ': No such file or directory', 'error'); + } + }; + this.commands['cp'] = (args) => { + if(!args[0] || !args[1]) return this.terminal.print('Usage: cp <source> <dest>', 'warning'); + const srcDir = this.terminal.fs.readDir(args[0]); + if (srcDir !== null) { + if (this.terminal.fs.copyDir(args[0], args[1])) { + this.terminal.print('Copied directory: ' + args[0] + ' to ' + args[1], 'success'); + } else { + this.terminal.print('cp: ' + args[0] + ': failed to copy directory', 'error'); + } + return; + } + if (this.terminal.fs.copyFile(args[0], args[1])) { + this.terminal.print('Copied: ' + args[0] + ' to ' + args[1], 'success'); + } else { + this.terminal.print('cp: ' + args[0] + ': No such file or directory', 'error'); + } + }; + this.commands['cd'] = (args) => { + if (!args[0] || args[0] === '/') { + this.terminal.fs.currentPath = []; + this.terminal.fs.saveToStorage(); + this.terminal.updatePrompt(); + return; + } + const segs = this.terminal.fs._toAbsoluteSegments(args[0]); + if (segs === null) { + this.terminal.print('cd: ' + args[0] + ': No such file or directory', 'error'); + return; + } + const dir = this.terminal.fs._resolveDir(args[0]); + if (dir) { + this.terminal.fs.currentPath = segs; + this.terminal.fs.saveToStorage(); + this.terminal.updatePrompt(); + } else { + this.terminal.print('cd: ' + args[0] + ': Not a directory', 'error'); + } + }; + this.commands['pwd'] = () => { this.terminal.print('/' + this.terminal.fs.currentPath.join('/')); }; + this.commands['find'] = (args) => { + if (!args[0]) return this.terminal.print('Usage: find <pattern>', 'warning'); + const results = this.terminal.fs.findFiles ? this.terminal.fs.findFiles(args[0].toLowerCase()) : []; + if (results.length === 0) return this.terminal.print('No matches for ' + args[0], 'info'); + results.forEach(r => this.terminal.print('./' + r)); + this.terminal.print('Found ' + results.length + ' match(es)', 'info'); + }; + this.commands['grep'] = (args) => { + if(!args[0]) return this.terminal.print('Usage: grep <pattern> [filename]', 'warning'); + const pattern = args[0]; const target = args[1]; + if (target) { + const c = this.terminal.fs.readFile(target); + if(c === null || c === undefined) return this.terminal.print('grep: ' + target + ': No such file', 'error'); + const lines = c.split('\n'); const re = new RegExp(pattern, 'i'); + let count = 0; lines.forEach((line, i) => { if (re.test(line)) { this.terminal.print((i+1) + ': ' + line); count++; } }); + if (count === 0) this.terminal.print('(no matches)', 'info'); + else this.terminal.print(count + ' match(es)', 'text-muted'); + } else { + const dir = this.terminal.fs.getCurrentDir(); + if (!dir) return this.terminal.print('grep: cannot search', 'error'); + let total = 0; const re = new RegExp(pattern, 'i'); + Object.keys(dir.content).forEach(name => { + const node = dir.content[name]; + if (node.type === 'file') { + (node.content || '').split('\n').forEach((line, i) => { + if (re.test(line)) { this.terminal.print(name + ':' + (i+1) + ': ' + line); total++; } + }); + } + }); + if (total === 0) this.terminal.print('(no matches)', 'info'); + else this.terminal.print(total + ' total match(es)', 'text-muted'); + } + }; + this.commands['head'] = (args) => { + if(!args[0]) return this.terminal.print('Usage: head <filename> [lines]', 'warning'); + const c = this.terminal.fs.readFile(args[0]); + if(c === null || c === undefined) return this.terminal.print('head: ' + args[0] + ': No such file', 'error'); + const n = args[1] ? parseInt(args[1]) : 10; + this.terminal.print(c.split('\n').slice(0, n).join('\n')); + }; + this.commands['tail'] = (args) => { + if(!args[0]) return this.terminal.print('Usage: tail <filename> [lines]', 'warning'); + const c = this.terminal.fs.readFile(args[0]); + if(c === null || c === undefined) return this.terminal.print('tail: ' + args[0] + ': No such file', 'error'); + const n = args[1] ? parseInt(args[1]) : 10; + this.terminal.print(c.split('\n').slice(-n).join('\n')); + }; + this.commands['sort'] = (args) => { + if(!args[0]) return this.terminal.print('Usage: sort <filename>', 'warning'); + const c = this.terminal.fs.readFile(args[0]); + if(c === null || c === undefined) return this.terminal.print('sort: ' + args[0] + ': No such file', 'error'); + c.split('\n').sort().forEach(l => this.terminal.print(l)); + }; + this.commands['wc'] = (args) => { + if(!args[0]) return this.terminal.print('Usage: wc <filename>', 'warning'); + const content = this.terminal.fs.readFile(args[0]); + if(content === null || content === undefined) return this.terminal.print('wc: ' + args[0] + ': No such file', 'error'); + const lines = content.split('\n').length; + const words = content.split(/\s+/).filter(w => w).length; + this.terminal.print(lines + ' ' + words + ' ' + content.length + ' ' + args[0]); + }; + this.commands['touch'] = (args) => { + if(!args[0]) return this.terminal.print('Usage: touch <filename>', 'warning'); + const e = this.terminal.fs.readFile(args[0]); + if (e === null || e === undefined) { + const segs = this.terminal.fs._toAbsoluteSegments(args[0]); + if (segs) { + const dirPath = segs.slice(0, -1).join('/') || '.'; + const name = segs[segs.length - 1]; + this.terminal.fs.mkdirRecursive(dirPath); + } + this.terminal.fs.writeFile(args[0], ''); + this.terminal.print('Created: ' + args[0], 'success'); + } + else { this.terminal.print('Touched: ' + args[0], 'success'); } + }; + this.commands['tree'] = () => { + const dir = this.terminal.fs.getCurrentDir(); + if (!dir) return this.terminal.print('(no directory)', 'warning'); + const lines = []; this._printTree(dir, '', lines); + lines.forEach(l => this.terminal.print(l)); + }; + this.commands['du'] = (args) => { + const t = args && args[0] ? args[0] : '.'; + if (t === '.') { + const dir = this.terminal.fs.getCurrentDir(); if (!dir) return; + let total = 0; Object.keys(dir.content).forEach(k => { total += dir.content[k].content ? dir.content[k].content.length : 0; }); + this.terminal.print(total + '\t' + t); + } else { + const c = this.terminal.fs.readFile(t); + if (c === null || c === undefined) return; + this.terminal.print(c.length + '\t' + t); + } + }; + this.commands['upload'] = () => { document.getElementById('file-upload-input').click(); }; + this.commands['download'] = (args) => { + if(!args[0]) return this.terminal.print('Usage: download <filename>', 'warning'); + const content = this.terminal.fs.readFile(args[0]); + if(content !== null && content !== undefined) { + const blob = new Blob([content], {type: 'application/octet-stream'}); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; a.download = args[0]; + a.style.display = 'none'; + document.body.appendChild(a); + a.click(); + setTimeout(() => { document.body.removeChild(a); URL.revokeObjectURL(url); }, 2000); + this.terminal.print('Downloaded: ' + args[0], 'success'); + } else { this.terminal.print('download: ' + args[0] + ': No such file', 'error'); } + }; + this.commands['vfs-export'] = () => { + const data = this.terminal.fs.root; + const json = JSON.stringify(data, null, 2); + const blob = new Blob([json], {type: 'application/json'}); + const sizeMB = blob.size / (1024 * 1024); + if (sizeMB > 5) { + this.terminal.print('Warning: VFS size is ' + sizeMB.toFixed(1) + ' MB — large download may take time', 'warning'); + } + const now = new Date(); + const dateStr = now.getFullYear() + '-' + String(now.getMonth() + 1).padStart(2, '0') + '-' + String(now.getDate()).padStart(2, '0'); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; a.download = 'webx11-filesystem-' + dateStr + '.json'; + a.style.display = 'none'; + document.body.appendChild(a); + a.click(); + setTimeout(() => { document.body.removeChild(a); URL.revokeObjectURL(url); }, 2000); + this.terminal.print('Exported VFS to webx11-filesystem-' + dateStr + '.json', 'success'); + }; + + // Session commands (save/load/export/import) + this.commands['session-save'] = (args) => { + if (!args[0]) return this.terminal.print('Usage: session-save <name>', 'warning'); + const name = args[0].replace(/[^a-zA-Z0-9_-]/g, '_'); + const snapshot = { + type: 'session', + savedAt: new Date().toISOString(), + fsRoot: this.terminal.fs.root, + currentPath: this.terminal.fs.currentPath, + version: 1 + }; + const filePath = '_sessions/' + name + '.json'; + this.terminal.fs.mkdirRecursive('_sessions'); + this.terminal.fs.writeFile(filePath, JSON.stringify(snapshot, null, 2)); + this.terminal.print('Session saved: ' + name, 'success'); + }; + this.commands['session-load'] = (args) => { + if (!args[0]) return this.terminal.print('Usage: session-load <name>', 'warning'); + const filePath = '_sessions/' + args[0] + '.json'; + const content = this.terminal.fs.readFile(filePath); + if (content === null) return this.terminal.print('Session not found: ' + args[0], 'error'); + try { + const session = JSON.parse(content); + if (!session || session.type !== 'session' || !session.fsRoot) { + return this.terminal.print('Invalid session file: ' + args[0], 'error'); + } + this.terminal.fs.root = session.fsRoot; + this.terminal.fs.currentPath = session.currentPath || []; + this.terminal.fs.saveToStorage(); + this.terminal.updatePrompt(); + this.terminal.print('Session loaded: ' + args[0] + ' (' + new Date(session.savedAt).toLocaleString() + ')', 'success'); + this.terminal.executeCommand('ls'); + } catch (e) { + this.terminal.print('Failed to load session: ' + e.message, 'error'); + } + }; + this.commands['session-list'] = () => { + this.terminal.fs.mkdirRecursive('_sessions'); + const files = this.terminal.fs.ls('_sessions'); + if (!files || files.length === 0) return this.terminal.print('No saved sessions', 'info'); + this.terminal.print('Saved sessions:', 'text-muted'); + files.forEach(f => { + if (f.type === 'file' && f.name.endsWith('.json')) { + try { + const content = JSON.parse(this.terminal.fs.readFile('_sessions/' + f.name)); + const label = f.name.replace('.json', ''); + const date = content.savedAt ? new Date(content.savedAt).toLocaleString() : 'unknown'; + const fileCount = content.fsRoot ? Object.keys(content.fsRoot.content || {}).length : 0; + this.terminal.print(' ' + label + ' (' + fileCount + ' items, ' + date + ')'); + } catch (e) { + this.terminal.print(' ' + f.name.replace('.json', '') + ' (corrupt)'); + } + } + }); + }; + this.commands['session-delete'] = (args) => { + if (!args[0]) return this.terminal.print('Usage: session-delete <name>', 'warning'); + const filePath = '_sessions/' + args[0] + '.json'; + if (this.terminal.fs.deleteFile(filePath)) { + this.terminal.print('Session deleted: ' + args[0], 'success'); + } else { + this.terminal.print('Session not found: ' + args[0], 'error'); + } + }; + this.commands['session-export'] = (args) => { + if (!args[0]) return this.terminal.print('Usage: session-export <name>', 'warning'); + const filePath = '_sessions/' + args[0] + '.json'; + const content = this.terminal.fs.readFile(filePath); + if (content === null) return this.terminal.print('Session not found: ' + args[0], 'error'); + const blob = new Blob([content], {type: 'application/json'}); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = 'webx11-session-' + args[0] + '.json'; + a.style.display = 'none'; + document.body.appendChild(a); + a.click(); + setTimeout(() => { document.body.removeChild(a); URL.revokeObjectURL(url); }, 2000); + this.terminal.print('Exported session: ' + args[0], 'success'); + }; + this.commands['session-import'] = () => { + const input = document.createElement('input'); + input.type = 'file'; + input.accept = '.json'; + input.onchange = (e) => { + const file = e.target.files[0]; + if (!file) return; + const reader = new FileReader(); + reader.onload = (evt) => { + try { + const session = JSON.parse(evt.target.result); + if (!session || session.type !== 'session' || !session.fsRoot) { + this.terminal.print('Invalid session file (not a valid session export)', 'error'); + return; + } + this.terminal.fs.root = session.fsRoot; + this.terminal.fs.currentPath = session.currentPath || []; + this.terminal.fs.saveToStorage(); + this.terminal.updatePrompt(); + this.terminal.print('Session imported: ' + file.name + ' (' + (session.savedAt ? new Date(session.savedAt).toLocaleString() : 'unknown date') + ')', 'success'); + this.terminal.executeCommand('ls'); + } catch (err) { + this.terminal.print('Failed to import session: ' + err.message, 'error'); + } + }; + reader.readAsText(file); + }; + input.click(); + }; + this.commands['vfs-import'] = () => { + const input = document.createElement('input'); + input.type = 'file'; + input.accept = '.json'; + input.onchange = (e) => { + const file = e.target.files[0]; + if (!file) return; + const reader = new FileReader(); + reader.onload = (evt) => { + try { + const data = JSON.parse(evt.target.result); + if (!data || !data.type) { + this.terminal.print('Invalid VFS file', 'error'); + return; + } + if (this.terminal.fs.restoreFromExport(data)) { + this.terminal.updatePrompt(); + this.terminal.print('VFS restored from: ' + file.name, 'success'); + this.terminal.executeCommand('ls'); + } else { + this.terminal.print('Failed to restore VFS', 'error'); + } + } catch (err) { + this.terminal.print('Failed to import VFS: ' + err.message, 'error'); + } + }; + reader.readAsText(file); + }; + input.click(); + }; + } + _printTree(dir, prefix, lines) { + const names = Object.keys(dir.content || {}).sort(); + names.forEach((name, i) => { + const isLast = i === names.length - 1; + const node = dir.content[name]; + const connector = isLast ? '\u2514\u2500\u2500 ' : '\u251c\u2500\u2500 '; + const label = node.type === 'dir' ? name + '/' : name; + lines.push(prefix + connector + label); + if (node.type === 'dir') { + this._printTree(node, prefix + (isLast ? ' ' : '\u2502 '), lines); + } + }); + } +} diff --git a/modules/core/module-io.js b/modules/core/module-io.js new file mode 100644 index 0000000..d7ff996 --- /dev/null +++ b/modules/core/module-io.js @@ -0,0 +1,80 @@ +// Extracted from newOS4 index.html — ModuleIOModule + class ModuleIOModule { + constructor() { this.commands = {}; this.terminal = null; } + getHelpInfo() { + return { + 'export-mods': 'export-mods — Export all loaded modules to a JSON file', + 'import-mods': 'import-mods <filename> — Import modules from a JSON file', + 'backup': 'backup — Create a full system backup', + 'restore': 'restore <filename> — Restore system from a backup file', + 'share-mod': 'share-mod <module-name> — Share a module as JSON to clipboard' + }; + } + registerCommands() { + this.commands['export-mods'] = () => this.exportModules(); + this.commands['import-mods'] = (args) => this.importModules(args); + this.commands['backup'] = () => this.fullBackup(); + this.commands['restore'] = (args) => this.restoreBackup(args); + this.commands['share-mod'] = (args) => this.shareModule(args); + } + exportModules() { + const registry = this.terminal.moduleManager.moduleRegistry; + if (registry.length === 0) { + this.terminal.print("No modules to export", "warning"); return; + } + const exportData = { version: '4.1', exported: new Date().toISOString(), modules: registry }; + const json = JSON.stringify(exportData, null, 2); + const blob = new Blob([json], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; a.download = `webx11-modules-${Date.now()}.json`; a.click(); + URL.revokeObjectURL(url); + this.terminal.print(`Exported ${registry.length} module(s)`, "success"); + } + importModules(args) { + this.terminal.print("⚠️ To import modules:", "warning"); + this.terminal.print("1. Upload the JSON file using 'upload'", "info"); + this.terminal.print("2. Use: import-mods <filename>", "info"); + } + async fullBackup() { + const backup = { + version: '4.1', timestamp: new Date().toISOString(), + filesystem: localStorage.getItem('webx11_filesystem'), + modules: localStorage.getItem('webx11_modules'), + ollama: { + endpoint: localStorage.getItem('webx11_ollama_endpoint'), + model: localStorage.getItem('webx11_ollama_model') + }, config: {} + }; + for (let i = 0; i < localStorage.length; i++) { + const key = localStorage.key(i); + if (key.startsWith('webx11_')) { backup.config[key] = localStorage.getItem(key); } + } + const json = JSON.stringify(backup, null, 2); + const blob = new Blob([json], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; a.download = `webx11-backup-${Date.now()}.json`; a.click(); + URL.revokeObjectURL(url); + this.terminal.print("Full backup created", "success"); + } + restoreBackup(args) { + this.terminal.print("⚠️ To restore backup:", "warning"); + this.terminal.print("1. Upload the backup JSON file", "info"); + this.terminal.print("2. Use: restore <filename>", "info"); + } + shareModule(args) { + if (!args[0]) { + this.terminal.print("Usage: share-mod <module-name>", "warning"); return; + } + const mod = this.terminal.moduleManager.moduleRegistry.find(m => m.name === args[0]); + if (!mod) { + this.terminal.print(`Module '${args[0]}' not found`, "error"); return; + } + const shareData = { name: mod.name, url: mod.url, shared: new Date().toISOString() }; + const json = JSON.stringify(shareData, null, 2); + navigator.clipboard.writeText(json).then(() => { + this.terminal.print("Module info copied to clipboard", "success"); + }).catch(() => { this.terminal.print(json, "info"); }); + } + } diff --git a/modules/core/module-loader.js b/modules/core/module-loader.js new file mode 100644 index 0000000..850d627 --- /dev/null +++ b/modules/core/module-loader.js @@ -0,0 +1,178 @@ +(function() { + 'use strict'; + + const BASE = 'modules/core/'; + + const FOUNDATION = [ + 'virtual-fs.js', + 'module-manager.js', + 'ollama-manager.js', + 'terminal.js' + ]; + + const BUILTIN = [ + 'module-system.js', + 'module-fs.js', + 'module-modmgr.js', + 'module-ollama.js', + 'module-my.js', + 'module-searxng.js', + 'module-servermon.js', + 'module-ollamamodels.js', + 'module-config.js', + 'module-simplegui.js', + 'module-io.js', + 'module-dynamicprompts.js', + 'module-aiassist.js', + 'module-aiasm.js', + 'module-smartctx.js', + 'module-duckduckgo.js', + 'module-fetcher.js', + 'module-skills.js', + 'secret-vault.js', + 'module-chatstudio.js' + ]; + + function getSelectedModules() { + try { + const saved = localStorage.getItem('webx11_startup_modules'); + if (!saved) return null; + const parsed = JSON.parse(saved); + if (Array.isArray(parsed)) return parsed; + } catch (e) {} + return null; + } + + function isSelected(name, selected) { + if (!selected) return true; + return selected.includes(name); + } + + function loadScript(url, filename) { + return new Promise((resolve) => { + const existing = document.querySelector(`script[src="${url}"]`); + if (existing) { resolve(); return; } + const el = document.createElement('script'); + el.src = url + (url.includes('?') ? '&t=' : '?t=') + Date.now(); + el.onload = () => resolve(); + el.onerror = () => { + console.warn(`[ModuleLoader] Failed to load: ${filename || url} — continuing`); + // Log to terminal if available + if (typeof window !== 'undefined' && window.webx11 && window.webx11.log) { + window.webx11.log('WARN', 'Loader', 'Failed to load: ' + (filename || url)); + } + resolve(); + }; + document.body.appendChild(el); + }); + } + + function setSplash(phase, current, total, filename) { + const fill = document.getElementById('splash-progress-fill'); + const phaseEl = document.getElementById('splash-phase'); + const fileEl = document.getElementById('splash-filename'); + if (fill && total > 0) { + fill.style.width = Math.round((current / total) * 100) + '%'; + } + if (phaseEl) phaseEl.textContent = phase; + if (fileEl) fileEl.textContent = filename || ''; + } + + function hideSplash() { + const overlay = document.getElementById('splash-overlay'); + if (overlay) { + overlay.classList.add('hidden'); + setTimeout(() => { overlay.style.display = 'none'; }, 350); + } + } + + function showSplashError(msg) { + const el = document.getElementById('splash-error'); + const fill = document.getElementById('splash-progress-fill'); + if (el) { el.textContent = msg; el.classList.add('show'); } + if (fill) fill.style.background = 'var(--error-color)'; + } + + function reloadWithSelection() { + location.reload(); + } + + window.__moduleLoaderReload = reloadWithSelection; + + const CACHE = '?v=18'; + + async function init() { + try { + const selected = getSelectedModules(); + const total = FOUNDATION.length + BUILTIN.length; + let count = 0; + + // Phase 1: Foundation (always load) + setSplash('Loading foundation layer (1/3)', 0, total, ''); + for (const mod of FOUNDATION) { + count++; + setSplash('Loading foundation layer (1/3)', count, total, mod); + await loadScript(BASE + mod + CACHE, mod); + } + + // Phase 2: Built-in modules (filtered) + setSplash('Loading built-in modules (2/3)', count, total, ''); + for (const mod of BUILTIN) { + count++; + if (!isSelected(mod, selected)) { + console.log(`[ModuleLoader] Skipped: ${mod} (not in startup selection)`); + setSplash('Loading built-in modules (2/3)', count, total, '⏭ ' + mod); + await new Promise(r => setTimeout(r, 20)); + continue; + } + setSplash('Loading built-in modules (2/3)', count, total, mod); + await loadScript(BASE + mod + CACHE, mod); + } + + // Phase 3: Initialize terminal + setSplash('Initializing terminal (3/3)', total, total, ''); + const webx11 = new WebX11Terminal(); + window.webx11 = webx11; + + // Auto-connect to Ollama after init + setTimeout(async () => { + try { + if (webx11.ollamaManager && !webx11.ollamaManager.connected) { + webx11.log('INFO', 'System', 'WebX11 v4.2 started'); + webx11.log('INFO', 'System', 'Default model: ' + webx11.ollamaManager.currentModel); + await webx11.ollamaManager.connect(); + if (typeof webx11.updateOllamaStatus === 'function') { + webx11.updateOllamaStatus(); + } + webx11.print('🤖 Default model: ' + webx11.ollamaManager.currentModel, 'info'); + } + } catch (e) { + // Silently ignore — user can manually connect + } + }, 500); + + // Phase 4: Ext modules (filtered) + if (typeof webx11._loadExtModules === 'function') { + const extKeys = ['calc', 'voice', 'cron', 'theme', 'wpm', 'mindmap', 'webdav', 'skill-pack-core', 'editor', 'diff', 'alias', 'finder', 'filemgr']; + const extSelected = selected + ? extKeys.filter(k => isSelected(k + '.js', selected)) + : extKeys; + if (extSelected.length > 0) { + webx11._loadExtModules(extSelected); + } + } + + hideSplash(); + console.log('[ModuleLoader] All modules loaded successfully'); + } catch (err) { + console.error('[ModuleLoader] Fatal error:', err); + showSplashError('Loading failed — ' + err.message); + } + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', init); + } else { + init(); + } +})(); diff --git a/modules/core/module-manager.js b/modules/core/module-manager.js new file mode 100644 index 0000000..8e9e911 --- /dev/null +++ b/modules/core/module-manager.js @@ -0,0 +1,138 @@ +// Extracted from newOS4 index.html — ModuleManager + class ModuleManager { + constructor(terminal) { + this.terminal = terminal; + this.modules = {}; + this.moduleRegistry = []; + this.loadFromStorage(); + } + loadFromStorage() { + try { + const saved = localStorage.getItem('webx11_modules'); + if (saved) { + this.moduleRegistry = JSON.parse(saved); + } + } catch (e) { console.error('Module load error:', e); } + } + saveToStorage() { + try { + localStorage.setItem('webx11_modules', JSON.stringify(this.moduleRegistry)); + } catch (e) { console.error('Module save error:', e); } + } + async loadModuleFromURL(url) { + try { + this.terminal.print(`Loading module from ${url}...`, 'info'); + const response = await fetch(url); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const code = await response.text(); + const ModuleClass = this.createModuleClass(code, url); + if (ModuleClass) { + const instance = new ModuleClass(); + this.registerModule(instance, url); + if (instance.init) { + await instance.init(); + } + this.terminal.print(`Module loaded successfully from ${url}`, 'success'); + return true; + } + } catch (error) { + this.terminal.print(`Failed to load module: ${error.message}`, 'error'); + return false; + } + } + createModuleClass(code, url) { + try { + const classMatch = code.match(/class\s+(\w+)\s+(extends\s+\S+)?\s*\{/); + if (classMatch) { + const className = classMatch[1]; + const mod = new Function('return ' + code + '\nreturn ' + className)(); + return mod; + } + throw new Error('No valid module class found'); + } catch (e) { + this.terminal.print(`Module parse error: ${e.message}`, 'error'); + return null; + } + } + registerModule(instance, url = null) { + const name = instance.constructor.name; + this.modules[name] = instance; + instance.terminal = this.terminal; + if (instance.registerCommands) { instance.registerCommands(); } + // Only persist modules loaded from absolute URLs (not boot-time ext modules) + if (url && url.startsWith('http') && !this.moduleRegistry.find(m => m.name === name)) { + this.moduleRegistry.push({ + name: name, url: url, + loaded: new Date().toISOString(), enabled: true + }); + this.saveToStorage(); + } + console.log(`Module registered: ${name}`); + } + unregisterModule(name) { + if (this.modules[name]) { + delete this.modules[name]; + this.moduleRegistry = this.moduleRegistry.filter(m => m.name !== name); + this.saveToStorage(); + return true; + } + return false; + } + getModuleList() { + const allNames = new Set([...this.moduleRegistry.map(m => m.name), ...Object.keys(this.modules)]); + return Array.from(allNames).map(name => ({ + name, + url: (this.moduleRegistry.find(m => m.name === name) || {}).url || null, + loaded: this.modules[name] !== undefined + })); + } + clearAll() { + this.modules = {}; + this.moduleRegistry = []; + this.saveToStorage(); + } + getModuleRequirements(name) { + const mod = this.modules[name]; + if (mod && typeof mod.requirements === 'function') { + return mod.requirements(); + } + return null; + } + checkAllRequirements() { + const results = {}; + for (const modName in this.modules) { + const mod = this.modules[modName]; + if (typeof mod.requirements === 'function') { + results[modName] = mod.requirements(); + } + } + return results; + } + getAllCommands() { + const commands = {}; + for (const modName in this.modules) { + const mod = this.modules[modName]; + if (mod.commands) { + for (const cmd in mod.commands) { + commands[cmd] = modName; + } + } + } + return commands; + } + getAllHelp() { + const help = {}; + for (const modName in this.modules) { + const mod = this.modules[modName]; + if (typeof mod.getHelpInfo === 'function') { + const modHelp = mod.getHelpInfo(); + if (modHelp) Object.assign(help, modHelp); + } else if (mod.commands) { + for (const cmd in mod.commands) { + if (!help[cmd]) help[cmd] = cmd + ' — (no description)'; + } + } + } + return help; + } + } diff --git a/modules/core/module-modmgr.js b/modules/core/module-modmgr.js new file mode 100644 index 0000000..1104883 --- /dev/null +++ b/modules/core/module-modmgr.js @@ -0,0 +1,40 @@ +// Extracted from newOS4 index.html — ModuleManagerModule + class ModuleManagerModule { + constructor() { this.commands = {}; this.terminal = null; } + getHelpInfo() { + return { + 'modules': 'modules — Open the module manager GUI', + 'listmods': 'listmods — List all loaded modules with status', + 'loadmod': 'loadmod <url> — Load a module from a URL', + 'removemod': 'removemod <name> — Remove a loaded module by name', + 'startup-modules': 'startup-modules — Open the startup modules selector', + 'load-local': 'load-local <path> — Load a module from VFS or URL', + 'loadlocal': 'loadlocal <path> — Alias for load-local', + }; + } + registerCommands() { + this.commands['modules'] = () => { this.terminal.openModuleManager(); }; + this.commands['loadmod'] = async (args) => { + if(!args[0]) return this.terminal.print("Usage: loadmod <url>", 'warning'); + await this.terminal.moduleManager.loadModuleFromURL(args[0]); + this.terminal.renderModuleList(); + }; + this.commands['removemod'] = (args) => { + if(!args[0]) return this.terminal.print("Usage: removemod <name>", 'warning'); + if(this.terminal.moduleManager.unregisterModule(args[0])) { + this.terminal.print(`Module '${args[0]}' removed`, 'success'); + } else { + this.terminal.print(`Module not found: ${args[0]}`, 'error'); + } + }; + this.commands['listmods'] = () => { + const mods = this.terminal.moduleManager.getModuleList(); + if(mods.length === 0) return this.terminal.print("No modules loaded"); + mods.forEach(m => { + const status = m.loaded ? '✓' : '✗'; + const badge = m.url ? '' : ' [Built-in]'; + this.terminal.print(`${status} ${m.name}${badge}`); + }); + }; + } + } diff --git a/modules/core/module-my.js b/modules/core/module-my.js new file mode 100644 index 0000000..5fc13ca --- /dev/null +++ b/modules/core/module-my.js @@ -0,0 +1,82 @@ +class MyModule { + constructor() { + this.commands = {}; this.terminal = null; this.userData = {}; + try { const d = localStorage.getItem('webx11_my_data'); if (d) this.userData = JSON.parse(d); } catch (e) {} + } + getHelpInfo() { + return { + 'my': 'my — Show module info and stored key count', + 'my-set': 'my-set <key> <value> — Store a key-value pair', + 'my-get': 'my-get <key> — Retrieve a stored value by key', + 'my-list': 'my-list — List all stored keys', + 'my-export': 'my-export — Export all stored data as JSON', + 'my-import': 'my-import <json> — Import data from JSON string', + 'my-clear': 'my-clear — Clear all stored data' + }; + } + _save() { + try { localStorage.setItem('webx11_my_data', JSON.stringify(this.userData)); } catch (e) {} + } + registerCommands() { + this.commands['my'] = () => this.showInfo(); + this.commands['my-set'] = (args) => this.setData(args); + this.commands['my-get'] = (args) => this.getData(args); + this.commands['my-list'] = () => this.listData(); + this.commands['my-export'] = () => this.exportData(); + this.commands['my-import'] = (args) => this.importData(args); + this.commands['my-clear'] = () => this.clearData(); + } + showInfo() { + this.terminal.print("=== My Module v2.0 ===", "info"); + this.terminal.print("Personal workspace for custom commands"); + this.terminal.print(`Stored keys: ${Object.keys(this.userData).length}`, "success"); + } + setData(args) { + if (args.length < 2) { + this.terminal.print("Usage: my-set <key> <value>", "warning"); return; + } + const key = args[0], value = args.slice(1).join(' '); + this.userData[key] = { value, timestamp: new Date().toISOString() }; + this._save(); + this.terminal.print(`Saved: ${key} = ${value}`, "success"); + } + getData(args) { + if (!args[0]) { + this.terminal.print("Usage: my-get <key>", "warning"); return; + } + if (this.userData[args[0]]) { + this.terminal.print(`${args[0]}: ${this.userData[args[0]].value}`, "info"); + } else { + this.terminal.print(`Key '${args[0]}' not found`, "error"); + } + } + listData() { + const keys = Object.keys(this.userData); + if (!keys.length) return this.terminal.print('No data stored.', 'info'); + this.terminal.print('=== My Data ===', 'info'); + keys.forEach(k => { + const v = this.userData[k]; + const type = typeof v.value; + this.terminal.print(` ${k} (${type}): ${String(v.value).substring(0, 40)}`); + }); + } + exportData() { + this.terminal.print(JSON.stringify(this.userData, null, 2)); + } + importData(args) { + if (!args[0]) return this.terminal.print('Usage: my-import <json>', 'warning'); + try { + const data = JSON.parse(args.join(' ')); + Object.assign(this.userData, data); + this._save(); + this.terminal.print(`Imported ${Object.keys(data).length} keys`, 'success'); + } catch (e) { + this.terminal.print('Invalid JSON: ' + e.message, 'error'); + } + } + clearData() { + this.userData = {}; + localStorage.removeItem('webx11_my_data'); + this.terminal.print("Personal data cleared", "success"); + } +} diff --git a/modules/core/module-ollama.js b/modules/core/module-ollama.js new file mode 100644 index 0000000..3eabe19 --- /dev/null +++ b/modules/core/module-ollama.js @@ -0,0 +1,52 @@ +// Extracted from newOS4 index.html — OllamaModule + class OllamaModule { + constructor() { this.commands = {}; this.terminal = null; } + getHelpInfo() { + return { + 'ollama': 'ollama — Open the Ollama model manager UI', + 'ollamaconnect': 'ollamaconnect — Connect to the Ollama server', + 'ollamadisconnect': 'ollamadisconnect — Disconnect from the Ollama server', + 'ollamamodels': 'ollamamodels — List all available models from Ollama', + 'ollamasetmodel': 'ollamasetmodel <name> — Set the active Ollama model', + 'ollamasetproxy': 'ollamasetproxy [endpoint] — View or change the Ollama endpoint URL', + }; + } + registerCommands() { + this.commands['ollama'] = () => { this.terminal.openOllamaManager(); }; + this.commands['ollamaconnect'] = async () => { + await this.terminal.ollamaManager.connect(); + this.terminal.updateOllamaStatus(); + }; + this.commands['ollamadisconnect'] = () => { + this.terminal.ollamaManager.connected = false; + this.terminal.ollamaManager.models = []; + this.terminal.print('Ollama disconnected', 'info'); + this.terminal.updateOllamaStatus(); + }; + this.commands['ollamamodels'] = () => { + const models = this.terminal.ollamaManager.getModels(); + if(models.length === 0) return this.terminal.print("No models found. Connect first."); + models.forEach(m => this.terminal.print(`${m.name} (${(m.size/1e9).toFixed(1)}GB)`)); + }; + this.commands['ollamasetmodel'] = (args) => { + if(!args[0]) return this.terminal.print("Usage: ollamasetmodel <name>", 'warning'); + this.terminal.ollamaManager.setModel(args[0]); + }; + this.commands['ollamasetproxy'] = (args) => { + if(!args[0]) { + const ep = this.terminal.ollamaManager.endpoint; + return this.terminal.print(`Current Ollama endpoint: ${ep}`, 'info'); + } + this.terminal.ollamaManager.setEndpoint(args[0]); + this.terminal.print(`Ollama endpoint set to: ${args[0]}`, 'success'); + }; + this.commands['ai'] = async (args) => { + if(!args[0]) return this.terminal.print("Usage: ai <prompt>", 'warning'); + await this.terminal.ollamaManager.chat(args.join(' ')); + }; + this.commands['ask'] = async (args) => { + if(!args[0]) return this.terminal.print("Usage: ask <prompt>", 'warning'); + await this.terminal.ollamaManager.chat(args.join(' ')); + }; + } + } diff --git a/modules/core/module-ollamamodels.js b/modules/core/module-ollamamodels.js new file mode 100644 index 0000000..9a7763a --- /dev/null +++ b/modules/core/module-ollamamodels.js @@ -0,0 +1,87 @@ +// Extracted from newOS4 index.html — OllamaModelsModule + class OllamaModelsModule { + constructor() { this.commands = {}; this.terminal = null; } + getHelpInfo() { + return { + 'models': 'models — List all available Ollama models', + 'model-info': 'model-info <model-name> — Show details about a specific model', + 'model-pull': 'model-pull <model-name> — Pull a model from Ollama registry', + 'model-delete': 'model-delete <model-name> — Delete a model from local storage', + 'model-run': 'model-run <model-name> [prompt] — Run inference with a model' + }; + } + registerCommands() { + this.commands['models'] = () => this.listModels(); + this.commands['model-info'] = (args) => this.modelInfo(args); + this.commands['model-pull'] = (args) => this.pullModel(args); + this.commands['model-delete'] = (args) => this.deleteModel(args); + this.commands['model-run'] = (args) => this.runModel(args); + } + async listModels() { + const endpoint = localStorage.getItem('webx11_ollama_endpoint') || 'http://localhost:11434'; + try { + const response = await fetch(`${endpoint}/api/tags`); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const data = await response.json(); + const models = data.models || []; + if (models.length === 0) { + this.terminal.print("No models found. Run 'ollama pull <model>' first.", "warning"); return; + } + this.terminal.print("=== Available Ollama Models ===", "info"); + this.terminal.printHTML(`<table style="width: 100%; border-collapse: collapse; margin: 10px 0;"> + <tr style="border-bottom: 1px solid #005500;"> + <th style="text-align: left; padding: 5px;">Name</th> + <th style="text-align: left; padding: 5px;">Size</th> + <th style="text-align: left; padding: 5px;">Family</th> + </tr>`); + models.forEach(m => { + const sizeGB = (m.size / 1e9).toFixed(2); + this.terminal.printHTML(`<tr style="border-bottom: 1px solid #111;"> + <td style="padding: 5px; color: #00ff00;">${m.name}</td> + <td style="padding: 5px; color: #888888;">${sizeGB} GB</td> + <td style="padding: 5px; color: #00aaff;">${m.details?.family || 'N/A'}</td> + </tr>`); + }); + this.terminal.printHTML(`</table>`); + } catch (e) { + this.terminal.print(`Failed to fetch models: ${e.message}`, "error"); + } + } + async modelInfo(args) { + if (!args[0]) { + this.terminal.print("Usage: model-info <model-name>", "warning"); return; + } + const endpoint = localStorage.getItem('webx11_ollama_endpoint') || 'http://localhost:11434'; + try { + const response = await fetch(`${endpoint}/api/show`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: args[0] }) + }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const data = await response.json(); + this.terminal.print(`=== ${args[0]} ===`, "info"); + this.terminal.print(`License: ${data.license || 'N/A'}`); + this.terminal.print(`Parameters: ${data.details?.parameter_size || 'N/A'}`); + this.terminal.print(`Family: ${data.details?.family || 'N/A'}`); + } catch (e) { + this.terminal.print(`Failed to get model info: ${e.message}`, "error"); + } + } + pullModel(args) { + this.terminal.print("⚠️ Model pulling must be done via Ollama CLI", "warning"); + this.terminal.print(`Run: ollama pull ${args[0] || '<model-name>'}`, "info"); + } + deleteModel(args) { + this.terminal.print("⚠️ Model deletion must be done via Ollama CLI", "warning"); + this.terminal.print(`Run: ollama rm ${args[0] || '<model-name>'}`, "info"); + } + async runModel(args) { + if (!args[0]) { + this.terminal.print("Usage: model-run <model-name> [prompt]", "warning"); return; + } + const model = args[0]; + const prompt = args.slice(1).join(' ') || 'Hello!'; + this.terminal.print(`Running ${model}...`, "info"); + await this.terminal.ollamaManager.chat(prompt, model); + } + } diff --git a/modules/core/module-searxng.js b/modules/core/module-searxng.js new file mode 100644 index 0000000..f2c2e72 --- /dev/null +++ b/modules/core/module-searxng.js @@ -0,0 +1,73 @@ +class SearXNGModule { + constructor() { + this.commands = {}; + this.terminal = null; + } + getHelpInfo() { + return { + 'search': 'search <query> [--summarize] [--fetch] — Search using SearXNG; --summarize/-s adds AI overview, --fetch/-f fetches page content', + 's': 's <query> [--summarize] [--fetch] — Alias for search', + }; + } + registerCommands() { + this.commands['search'] = (args) => this.search(args); + this.commands['s'] = (args) => this.search(args); + } + async search(args) { + if (!args[0]) { + this.terminal.print("Usage: search <query> [--summarize] [--fetch]", "warning"); + this.terminal.print("Example: search quantum computing --summarize", "info"); + return; + } + const argsList = args.slice(); + const summarize = argsList.includes('--summarize') || argsList.includes('-s'); + const fetchContent = argsList.includes('--fetch') || argsList.includes('-f'); + const query = argsList.filter(a => !a.startsWith('-')).join(' '); + if (!query) { this.terminal.print('Search query is required', 'warning'); return; } + + this.terminal.print(`🔍 Searching: ${query}`, 'info'); + + let params = 'q=' + encodeURIComponent(query); + if (summarize) params += '&summarize=true'; + if (fetchContent) params += '&fetch_content=true'; + + try { + const resp = await fetch('/api/search?' + params); + if (!resp.ok) { + const err = await resp.json().catch(() => ({})); + this.terminal.print('Search failed: ' + (err.error || 'HTTP ' + resp.status), 'error'); + return; + } + const data = await resp.json(); + const results = data.results || []; + + if (results.length === 0) { + this.terminal.print('No results found.', 'info'); + return; + } + + this.terminal.print(`\n=== ${results.length} results (via ${data.source || 'unknown'}) ===\n`, 'success'); + + if (data.ai_overview) { + this.terminal.print('🤖 AI Overview:', 'info'); + this.terminal.print(data.ai_overview, 'ollama'); + this.terminal.print(''); + } + + results.forEach((r, i) => { + const snippet = r.content || ''; + const contentSnippet = r.content_snippet || ''; + this.terminal.printHTML(` + <div class="search-result"> + <strong>${i + 1}. <a href="${r.url}" target="_blank" style="color: #00aaff;">${r.title || 'No Title'}</a></strong><br/> + <span style="color: #888888; font-size: 0.85em;">${r.url || 'No URL'}</span><br/> + <span style="color: #00ff00; font-size: 0.85em;">${(snippet || '').substring(0, 200)}...</span> + ${contentSnippet ? `<br/><span style="color: #aaaaaa; font-size: 0.8em;">📄 ${contentSnippet.substring(0, 150)}...</span>` : ''} + </div> + `); + }); + } catch (e) { + this.terminal.print('Search failed: ' + e.message, 'error'); + } + } +} diff --git a/modules/core/module-servermon.js b/modules/core/module-servermon.js new file mode 100644 index 0000000..23407db --- /dev/null +++ b/modules/core/module-servermon.js @@ -0,0 +1,107 @@ +// Extracted from newOS4 index.html — ServerMonitorModule + class ServerMonitorModule { + constructor() { + this.commands = {}; this.terminal = null; + this.servers = this.loadServers(); this.monitorInterval = null; + } + getHelpInfo() { + return { + 'server': 'Add a server to monitor: server <name> <url>', + 'servers': 'List all monitored servers', + 'server-check': 'Check a server status: server-check <name-or-index>', + 'server-remove': 'Remove a server: server-remove <name-or-index>', + 'monitor': 'Toggle auto-monitoring (checks every 60s)' + }; + } + loadServers() { + try { + const saved = localStorage.getItem('webx11_servers'); + return saved ? JSON.parse(saved) : []; + } catch (e) { return []; } + } + saveServers() { + localStorage.setItem('webx11_servers', JSON.stringify(this.servers)); + } + registerCommands() { + this.commands['server'] = (args) => this.addServer(args); + this.commands['servers'] = () => this.listServers(); + this.commands['server-check'] = (args) => this.checkServer(args); + this.commands['server-remove'] = (args) => this.removeServer(args); + this.commands['monitor'] = (args) => this.toggleMonitor(args); + } + addServer(args) { + if (args.length < 2) { + this.terminal.print("Usage: server <name> <url>", "warning"); return; + } + const name = args[0], url = args[1]; + this.servers.push({ name, url, status: 'unknown', lastCheck: null }); + this.saveServers(); + this.terminal.print(`Server '${name}' added`, "success"); + } + listServers() { + if (this.servers.length === 0) { + this.terminal.print("No servers configured", "info"); return; + } + this.terminal.print("=== Monitored Servers ===", "info"); + this.servers.forEach((s, i) => { + const statusIcon = s.status === 'online' ? '🟢' : s.status === 'offline' ? '🔴' : '⚪'; + this.terminal.print(`${i + 1}. ${statusIcon} ${s.name} - ${s.url}`); + if (s.lastCheck) { this.terminal.print(` Last check: ${s.lastCheck}`, "info"); } + }); + } + async checkServer(args) { + if (!args[0]) { + this.terminal.print("Usage: server-check <name-or-index>", "warning"); return; + } + const server = this.findServer(args[0]); + if (!server) { + this.terminal.print("Server not found", "error"); return; + } + this.terminal.print(`Checking ${server.name}...`, "info"); + try { + const start = Date.now(); + const response = await fetch(server.url, { method: 'HEAD', mode: 'no-cors' }); + const latency = Date.now() - start; + server.status = 'online'; server.lastCheck = new Date().toLocaleString(); server.latency = latency; + this.saveServers(); + this.terminal.print(`✅ ${server.name} is online (${latency}ms)`, "success"); + } catch (e) { + server.status = 'offline'; server.lastCheck = new Date().toLocaleString(); + this.saveServers(); + this.terminal.print(`❌ ${server.name} is offline`, "error"); + } + } + removeServer(args) { + if (!args[0]) { + this.terminal.print("Usage: server-remove <name-or-index>", "warning"); return; + } + const index = this.findServerIndex(args[0]); + if (index === -1) { + this.terminal.print("Server not found", "error"); return; + } + this.servers.splice(index, 1); + this.saveServers(); + this.terminal.print("Server removed", "success"); + } + toggleMonitor(args) { + if (this.monitorInterval) { + clearInterval(this.monitorInterval); + this.monitorInterval = null; + this.terminal.print("Auto-monitor stopped", "warning"); + } else { + this.terminal.print("Auto-monitor started (every 60s)", "success"); + this.monitorInterval = setInterval(() => { + this.servers.forEach((s, i) => { this.checkServer([String(i + 1)]); }); + }, 60000); + } + } + findServer(identifier) { + const index = this.findServerIndex(identifier); + return index !== -1 ? this.servers[index] : null; + } + findServerIndex(identifier) { + const idx = parseInt(identifier) - 1; + if (!isNaN(idx) && idx >= 0 && idx < this.servers.length) { return idx; } + return this.servers.findIndex(s => s.name.toLowerCase() === identifier.toLowerCase()); + } + } diff --git a/modules/core/module-simplegui.js b/modules/core/module-simplegui.js new file mode 100644 index 0000000..78fa9b5 --- /dev/null +++ b/modules/core/module-simplegui.js @@ -0,0 +1,67 @@ +// Extracted from newOS4 index.html — SimpleGUIModule + class SimpleGUIModule { + constructor() { this.commands = {}; this.terminal = null; this.guiElements = {}; } + getHelpInfo() { + return { + 'gui': 'gui — Show the GUI builder overview', + 'gui-button': 'gui-button <label> <command> — Add a clickable button', + 'gui-input': 'gui-input <placeholder> <varName> — Add a text input field', + 'gui-text': 'gui-text <content> — Add a text element', + 'gui-clear': 'gui-clear — Clear all GUI elements', + 'gui-run': 'gui-run <command> — Run a terminal command from the GUI' + }; + } + registerCommands() { + this.commands['gui'] = () => this.showGUI(); + this.commands['gui-button'] = (args) => this.createButton(args); + this.commands['gui-input'] = (args) => this.createInput(args); + this.commands['gui-text'] = (args) => this.createText(args); + this.commands['gui-clear'] = () => this.clearGUI(); + this.commands['gui-run'] = (args) => this.runCommand(args); + } + showGUI() { + this.terminal.print("=== Simple GUI Builder ===", "info"); + this.terminal.print("Commands: gui-button, gui-input, gui-text, gui-clear", "info"); + } + createButton(args) { + if (args.length < 2) { + this.terminal.print("Usage: gui-button <label> <command>", "warning"); return; + } + const label = args[0], command = args.slice(1).join(' '); + const id = 'gui-btn-' + Date.now(); + this.terminal.printHTML(`<div style="margin: 5px 0;"> + <button class="qa-btn" onclick="${command}" id="${id}">${label}</button> + </div>`); + this.guiElements[id] = { type: 'button', label, command }; + this.terminal.print(`Button '${label}' created`, "success"); + } + createInput(args) { + if (args.length < 2) { + this.terminal.print("Usage: gui-input <placeholder> <varName>", "warning"); return; + } + const placeholder = args[0], varName = args[1], id = 'gui-input-' + Date.now(); + this.terminal.printHTML(`<div style="margin: 5px 0;"> + <input type="text" id="${id}" placeholder="${placeholder}" + style="background: #0a0a0a; border: 1px solid #005500; + color: #00ff00; padding: 8px; width: 100%;" /> + </div>`); + this.guiElements[id] = { type: 'input', placeholder, varName }; + this.terminal.print(`Input '${placeholder}' created (var: ${varName})`, "success"); + } + createText(args) { + if (!args[0]) { + this.terminal.print("Usage: gui-text <content>", "warning"); return; + } + const content = args.join(' '); + this.terminal.printHTML(`<div style="margin: 5px 0; padding: 8px; + background: #111111; border-left: 3px solid #00aaff;">${content}</div>`); + this.terminal.print("Text element created", "success"); + } + clearGUI() { this.guiElements = {}; this.terminal.print("GUI elements cleared", "success"); } + runCommand(args) { + if (!args[0]) { + this.terminal.print("Usage: gui-run <command>", "warning"); return; + } + this.terminal.executeCommand(args.join(' ')); + } + } diff --git a/modules/core/module-skills.js b/modules/core/module-skills.js new file mode 100644 index 0000000..ed6e14f --- /dev/null +++ b/modules/core/module-skills.js @@ -0,0 +1,271 @@ +// Extracted from newOS4 index.html — SkillsModule (v4.2 — Self-improving AI) + class SkillsModule { + constructor() { this.commands = {}; this.terminal = null; } + getHelpInfo() { + return { + 'skill-list': 'skill-list — List all installed skills', + 'skill-view': 'skill-view <name> — View the content of a skill', + 'skill-create': 'skill-create <name> [desc] [category] [tags] — Create a new skill', + 'skill-patch': 'skill-patch <name> [content...] — Update skill content (no args to view current)', + 'skill-delete': 'skill-delete <name> — Delete a skill', + 'skill-export': 'skill-export <name> — Export a skill to WebDAV', + 'skill-import': 'skill-import <name> — Import a skill from WebDAV', + 'skill-sync': 'skill-sync — Bidirectional sync all skills with WebDAV', + }; + } + ensureSkillsDir() { + if (this.terminal && this.terminal.fs) { + const fs = this.terminal.fs; + // Ensure /skills/ directory exists + if (!fs.root.content['skills']) { + fs.root.content['skills'] = { type: 'dir', content: {} }; + fs.saveToStorage(); + } + } + } + registerCommands() { + this.commands['skill-list'] = () => this.listSkills(); + this.commands['skill-view'] = (args) => this.viewSkill(args); + this.commands['skill-create'] = (args) => this.createSkill(args); + this.commands['skill-patch'] = (args) => this.patchSkill(args); + this.commands['skill-delete'] = (args) => this.deleteSkill(args); + this.commands['skill-export'] = (args) => this.exportSkill(args); + this.commands['skill-import'] = (args) => this.importSkill(args); + this.commands['skill-sync'] = () => this.syncSkills(); + } + listSkills() { + this.ensureSkillsDir(); + const index = this.terminal.fs.fsReadJSON('/skills/index.json') || {}; + const names = Object.keys(index); + if (names.length === 0) { + this.terminal.print('No skills found.', 'info'); + return; + } + this.terminal.print(`=== Skills (${names.length}) ===`, 'info'); + names.forEach(name => { + const s = index[name]; + const tags = (s.tags || []).join(', '); + this.terminal.print(`📘 ${name} — ${s.description} [${s.category}] ${tags ? '(' + tags + ')' : ''}`); + }); + } + viewSkill(args) { + const name = (args[0] || '').replace(/\.md$/, ''); + if (!name) { + this.terminal.print('Usage: skill-view <name>', 'warning'); + return; + } + const content = this.terminal.fs.fsRead(`/skills/${name}.md`); + if (!content) { + this.terminal.print(`Skill '${name}' not found`, 'error'); + return; + } + this.terminal.print(`=== ${name} ===`, 'info'); + this.terminal.print(content, 'info'); + } + createSkill(args) { + this.ensureSkillsDir(); + const name = args[0]; + if (!name) { + this.terminal.print('Usage: skill-create <name> [description] [category] [tag1,tag2,...]', 'warning'); + return; + } + const description = args[1] || 'No description'; + const category = args[2] || 'general'; + const tags = (args[3] || '').split(',').filter(Boolean); + const fs = this.terminal.fs; + if (fs.fsRead(`/skills/${name}.md`)) { + this.terminal.print(`Skill '${name}' already exists. Use skill-patch to modify it.`, 'warning'); + return; + } + const content = '---\nname: ' + name + '\ndescription: ' + description + '\ntags: [' + tags.join(', ') + ']\ncategory: ' + category + '\n---\n## Steps\n1. (to be filled)\n'; + fs.fsWrite(`/skills/${name}.md`, content); + const index = fs.fsReadJSON('/skills/index.json') || {}; + index[name] = { name, description, category, tags }; + fs.fsWriteJSON('/skills/index.json', index); + this.terminal.print(`Skill '${name}' created. Edit its content with skill-patch ${name}`, 'success'); + } + patchSkill(args) { + const name = args[0]; + if (!name) { + this.terminal.print('Usage: skill-patch <name> [content...]', 'warning'); + return; + } + const fs = this.terminal.fs; + const existing = fs.fsRead(`/skills/${name}.md`); + if (!existing) { + this.terminal.print(`Skill '${name}' not found. Use skill-create first.`, 'error'); + return; + } + if (args.length < 2) { + // No new content — show the skill so user can copy-paste + this.terminal.print(`=== ${name} (current content) ===`, 'info'); + this.terminal.print(existing, 'info'); + this.terminal.print('---', 'info'); + this.terminal.print('Pass new content as additional arguments: skill-patch <name> <full content>', 'warning'); + return; + } + const newContent = args.slice(1).join(' '); + fs.fsWrite(`/skills/${name}.md`, newContent); + this.terminal.print(`Skill '${name}' patched`, 'success'); + } + deleteSkill(args) { + const name = (args[0] || '').replace(/\.md$/, ''); + if (!name) { + this.terminal.print('Usage: skill-delete <name>', 'warning'); + return; + } + const fs = this.terminal.fs; + if (!fs.fsRead(`/skills/${name}.md`)) { + this.terminal.print(`Skill '${name}' not found`, 'error'); + return; + } + fs.fsWrite(`/skills/${name}.md`, ''); + const index = fs.fsReadJSON('/skills/index.json') || {}; + delete index[name]; + fs.fsWriteJSON('/skills/index.json', index); + this.terminal.print(`Skill '${name}' deleted`, 'success'); + } + getWebDAVModule() { + return this.terminal && this.terminal.moduleManager.modules['WebDAVModule']; + } + async exportSkill(args) { + const name = (args[0] || '').replace(/\.md$/, ''); + if (!name) { + this.terminal.print('Usage: skill-export <name>', 'warning'); + return; + } + const wd = this.getWebDAVModule(); + if (!wd || !wd.config || !wd.config.url) { + this.terminal.print('WebDAV not configured. Use webdav-config first.', 'warning'); + return; + } + this.ensureSkillsDir(); + const fs = this.terminal.fs; + const content = fs.fsRead(`/skills/${name}.md`); + if (!content) { + this.terminal.print(`Skill '${name}' not found in VFS`, 'error'); + return; + } + const index = fs.fsReadJSON('/skills/index.json') || {}; + const meta = index[name]; + try { + // Ensure remote skills dir + if (typeof wd._ensureDir === 'function') await wd._ensureDir('skills'); + // Push the skill file + const resp1 = await wd._webdavRequest('PUT', 'skills/' + name + '.md', { + body: content, + headers: { 'Content-Type': 'text/markdown' } + }); + if (!resp1.ok && resp1.status !== 201 && resp1.status !== 204) { + throw new Error('Upload failed: HTTP ' + resp1.status); + } + // Push updated index + const indexResp = await wd._webdavRequest('PUT', 'skills/index.json', { + body: JSON.stringify(index, null, 2), + headers: { 'Content-Type': 'application/json' } + }); + if (indexResp.ok || indexResp.status === 201 || indexResp.status === 204) { + this.terminal.print(`Skill '${name}' exported to WebDAV skills/`, 'success'); + } + } catch (e) { + this.terminal.print('Export failed: ' + e.message, 'error'); + } + } + async importSkill(args) { + const name = (args[0] || '').replace(/\.md$/, ''); + if (!name) { + this.terminal.print('Usage: skill-import <name>', 'warning'); + return; + } + const wd = this.getWebDAVModule(); + if (!wd || !wd.config || !wd.config.url) { + this.terminal.print('WebDAV not configured. Use webdav-config first.', 'warning'); + return; + } + try { + const resp = await wd._webdavRequest('GET', 'skills/' + name + '.md'); + if (!resp.ok) throw new Error('HTTP ' + resp.status); + const content = await resp.text(); + this.ensureSkillsDir(); + const fs = this.terminal.fs; + fs.fsWrite(`/skills/${name}.md`, content); + // Try to pull the index to get metadata; if fails, create minimal entry + let meta = { name, description: 'Imported from WebDAV', category: 'general', tags: [] }; + try { + const indexResp = await wd._webdavRequest('GET', 'skills/index.json'); + if (indexResp.ok) { + const remoteIndex = JSON.parse(await indexResp.text()); + if (remoteIndex && remoteIndex[name]) { + meta = remoteIndex[name]; + } + } + } catch (e) { /* use default meta */ } + const index = fs.fsReadJSON('/skills/index.json') || {}; + index[name] = meta; + fs.fsWriteJSON('/skills/index.json', index); + this.terminal.print(`Skill '${name}' imported from WebDAV (${content.length} bytes)`, 'success'); + } catch (e) { + this.terminal.print('Import failed: ' + e.message, 'error'); + } + } + async syncSkills() { + const wd = this.getWebDAVModule(); + if (!wd || !wd.config || !wd.config.url) { + this.terminal.print('WebDAV not configured. Use webdav-config first.', 'warning'); + return; + } + this.ensureSkillsDir(); + const fs = this.terminal.fs; + const localIndex = fs.fsReadJSON('/skills/index.json') || {}; + const localNames = new Set(Object.keys(localIndex)); + this.terminal.print('Syncing skills with WebDAV...', 'info'); + let imported = 0, exported = 0; + try { + // Try to get remote index + let remoteIndex = {}; + try { + const resp = await wd._webdavRequest('GET', 'skills/index.json'); + if (resp.ok) { + remoteIndex = JSON.parse(await resp.text()) || {}; + } + } catch (e) { /* no remote index yet */ } + const remoteNames = new Set(Object.keys(remoteIndex)); + // Export local-only skills to remote + for (const name of localNames) { + if (!remoteNames.has(name)) { + const content = fs.fsRead(`/skills/${name}.md`); + if (content) { + await wd._ensureDir('skills'); + await wd._webdavRequest('PUT', 'skills/' + name + '.md', { + body: content, + headers: { 'Content-Type': 'text/markdown' } + }); + exported++; + } + } + } + // Import remote-only skills to local + for (const name of remoteNames) { + if (!localNames.has(name)) { + const resp = await wd._webdavRequest('GET', 'skills/' + name + '.md'); + if (resp.ok) { + const content = await resp.text(); + fs.fsWrite(`/skills/${name}.md`, content); + localIndex[name] = remoteIndex[name]; + imported++; + } + } + } + // Push updated local index back to remote + fs.fsWriteJSON('/skills/index.json', localIndex); + await wd._ensureDir('skills'); + await wd._webdavRequest('PUT', 'skills/index.json', { + body: JSON.stringify(localIndex, null, 2), + headers: { 'Content-Type': 'application/json' } + }); + this.terminal.print(`Sync complete: ${exported} exported, ${imported} imported`, 'success'); + } catch (e) { + this.terminal.print('Sync failed: ' + e.message, 'error'); + } + } + } diff --git a/modules/core/module-smartctx.js b/modules/core/module-smartctx.js new file mode 100644 index 0000000..2b4712d --- /dev/null +++ b/modules/core/module-smartctx.js @@ -0,0 +1,93 @@ +// Extracted from newOS4 index.html — SmartContextModule + class SmartContextModule { + constructor() { + this.commands = {}; this.terminal = null; + this.contextProfiles = this.loadProfiles(); this.activeProfile = null; + } + getHelpInfo() { + return { + 'context': 'context [name] — Set or view current context profile', + 'contexts': 'contexts — List all available context profiles', + 'context-new': 'context-new <name> <displayName> <systemPrompt> — Create a new context profile', + 'context-delete': 'context-delete <name> — Delete a context profile', + 'smart-ask': 'smart-ask <question> — Ask a question using the active context' + }; + } + loadProfiles() { + try { + const saved = localStorage.getItem('webx11_context_profiles'); + return saved ? JSON.parse(saved) : this.getDefaultProfiles(); + } catch (e) { return this.getDefaultProfiles(); } + } + saveProfiles() { + localStorage.setItem('webx11_context_profiles', JSON.stringify(this.contextProfiles)); + } + getDefaultProfiles() { + return { + 'coding': { name: 'Coding Assistant', systemPrompt: "You are an expert programmer.", temperature: 0.7 }, + 'debug': { name: 'Debug Helper', systemPrompt: "You are a debugging expert.", temperature: 0.5 }, + 'creative': { name: 'Creative Writer', systemPrompt: "You are a creative writer.", temperature: 0.9 }, + 'technical': { name: 'Technical Docs', systemPrompt: "You are a technical writer.", temperature: 0.3 }, + 'chat': { name: 'Casual Chat', systemPrompt: "You are a friendly assistant.", temperature: 0.8 } + }; + } + registerCommands() { + this.commands['context'] = (args) => this.setContext(args); + this.commands['contexts'] = () => this.listContexts(); + this.commands['context-new'] = (args) => this.createContext(args); + this.commands['context-delete'] = (args) => this.deleteContext(args); + this.commands['smart-ask'] = (args) => this.smartAsk(args); + } + setContext(args) { + if (!args[0]) { + this.terminal.print(`Current context: ${this.activeProfile || 'none'}`, "info"); return; + } + if (this.contextProfiles[args[0]]) { + this.activeProfile = args[0]; + this.terminal.print(`Context set to: ${this.contextProfiles[args[0]].name}`, "success"); + } else { + this.terminal.print(`Profile '${args[0]}' not found`, "error"); + } + } + listContexts() { + this.terminal.print("=== Context Profiles ===", "info"); + Object.entries(this.contextProfiles).forEach(([key, profile]) => { + const active = key === this.activeProfile ? ' ✅' : ''; + this.terminal.print(`${key}: ${profile.name}${active}`); + }); + } + createContext(args) { + if (args.length < 3) { + this.terminal.print("Usage: context-new <name> <displayName> <systemPrompt>", "warning"); return; + } + const name = args[0], displayName = args[1], systemPrompt = args.slice(2).join(' '); + this.contextProfiles[name] = { name: displayName, systemPrompt, temperature: 0.7 }; + this.saveProfiles(); + this.terminal.print(`Context profile '${name}' created`, "success"); + } + deleteContext(args) { + if (!args[0]) { + this.terminal.print("Usage: context-delete <name>", "warning"); return; + } + if (this.contextProfiles[args[0]]) { + delete this.contextProfiles[args[0]]; + this.saveProfiles(); + if (this.activeProfile === args[0]) { this.activeProfile = null; } + this.terminal.print(`Context profile '${args[0]}' deleted`, "success"); + } else { + this.terminal.print(`Profile '${args[0]}' not found`, "error"); + } + } + async smartAsk(args) { + if (!args[0]) { + this.terminal.print("Usage: smart-ask <your-question>", "warning"); return; + } + let systemPrompt = "You are a helpful assistant."; + if (this.activeProfile && this.contextProfiles[this.activeProfile]) { + systemPrompt = this.contextProfiles[this.activeProfile].systemPrompt; + this.terminal.print(`Using context: ${this.contextProfiles[this.activeProfile].name}`, "info"); + } + const fullPrompt = `${systemPrompt}\n\nUser: ${args.join(' ')}`; + await this.terminal.ollamaManager.chat(fullPrompt); + } + } diff --git a/modules/core/module-system.js b/modules/core/module-system.js new file mode 100644 index 0000000..f21946a --- /dev/null +++ b/modules/core/module-system.js @@ -0,0 +1,154 @@ +// Extracted from newOS4 index.html — SystemModule + class SystemModule { + constructor() { + this.commands = {}; + this.terminal = null; + } + getHelpInfo() { + return { + 'help': 'help [command] — Show all commands or usage for a specific command', + 'echo': 'echo <text...> — Print text to the terminal', + 'mem': 'mem — Show current memory usage', + 'status': 'status — Display system status (network, files, modules, Ollama)', + 'preview': 'preview <file.html> — Render an HTML file from the VFS in a preview pane', + 'factory-reset': 'factory-reset [--confirm] — Wipe all webx11_ data (VFS, config, AI profile, mindmaps, themes, all localStorage)', + 'reset-vfs': 'reset-vfs [--confirm] — Clear only the virtual filesystem (keeps settings, modules, AI profile)', + 'logs': 'logs [n] [LEVEL] — Show last n log entries (optionally filtered by level: INFO/WARN/ERROR)', + 'font-size': 'font-size <8-32> — Set terminal font size (persists across sessions)', + 'setfont': 'setfont <8-32> — Alias for font-size', + 'smart-prompt': 'smart-prompt [on|off] — Toggle context-aware prompt (shows name, skills, sessions, memory, edits)', + 'checkpoint': 'checkpoint [name] — Create a VFS snapshot (name defaults to ISO timestamp)', + 'checkpoint-list': 'checkpoint-list — List all VFS checkpoints', + 'rollback': 'rollback <name> — Restore VFS from a checkpoint', + 'checkpoint-del': 'checkpoint-del <name> — Delete a VFS checkpoint', + 'commands': 'commands — List all commands with module source and requirements status', + 'check': 'check — Show overall system health including module requirements', + }; + } + registerCommands() { + this.commands['help'] = (args) => this.handleHelp(args); + this.commands['echo'] = (args) => this.terminal.print(args.join(' ')); + this.commands['mem'] = () => { + const mem = performance.memory ? (performance.memory.usedJSHeapSize / 1048576).toFixed(2) : 'N/A'; + this.terminal.print(`Memory Usage: ${mem} MB`, 'info'); + document.getElementById('header-status').innerText = `Mem: ${mem}MB`; + }; + this.commands['status'] = () => { + this.terminal.print(`System: WebX11 v4.2`); + this.terminal.print(`Network: ${navigator.onLine ? 'Online' : 'Offline'}`); + this.terminal.print(`Files: ${this.terminal.fs.ls().length} items`); + this.terminal.print(`Modules: ${Object.keys(this.terminal.moduleManager.modules).length} loaded`); + this.terminal.print(`Ollama: ${this.terminal.ollamaManager.connected ? 'Connected' : 'Disconnected'}`); + }; + this.commands['preview'] = (args) => this.terminal.openPreview(args); + this.commands['factory-reset'] = (args) => { + if (!args[0] || args[0] !== '--confirm') { + this.terminal.print('⚠️ FACTORY RESET will clear all data:', 'error'); + this.terminal.print(' • Filesystem (all files + directories)', 'warning'); + this.terminal.print(' • AI memory, patterns, user profile', 'warning'); + this.terminal.print(' • Ollama endpoint, model selection', 'warning'); + this.terminal.print(' • Terminal history, font size', 'warning'); + this.terminal.print(' • All config, modules, packages', 'warning'); + this.terminal.print(' • Mindmaps, notes, bookmarks', 'warning'); + this.terminal.print(' • Themes, context profiles, servers', 'warning'); + this.terminal.print(' • Chat settings, my data', 'warning'); + this.terminal.print('Type: factory-reset --confirm to proceed', 'error'); + return; + } + for (let i = localStorage.length - 1; i >= 0; i--) { + const key = localStorage.key(i); + if (key && key.startsWith('webx11_')) localStorage.removeItem(key); + } + this.terminal.fs.root = { type: 'dir', content: {} }; + this.terminal.fs.currentPath = []; + this.terminal.outputContainer.innerHTML = ''; + this.terminal.print('✅ Factory reset complete. Reloading...', 'success'); + setTimeout(() => location.reload(), 1000); + }; + this.commands['reset-vfs'] = (args) => { + if (!args[0] || args[0] !== '--confirm') { + this.terminal.print('⚠️ RESET VFS will clear all files and directories:', 'warning'); + this.terminal.print(' • Virtual filesystem (all files + directories)', 'warning'); + this.terminal.print(' • VFS will be reset to empty root', 'warning'); + this.terminal.print(' • All settings, modules, AI profile preserved', 'info'); + this.terminal.print('Type: reset-vfs --confirm to proceed', 'error'); + return; + } + this.terminal.fs.root = { type: 'dir', content: {} }; + this.terminal.fs.currentPath = []; + this.terminal.fs.saveToStorage(); + this.terminal.print('✅ VFS reset complete. Reloading...', 'success'); + setTimeout(() => location.reload(), 1000); + }; + this.commands['logs'] = (args) => { + const n = parseInt(args[0]) || 20; + const level = args[1] ? args[1].toUpperCase() : null; + const content = this.terminal.fs.readFile('/logs/current.log'); + if (!content) { this.terminal.print('No logs yet', 'info'); return; } + let lines = content.split('\n').filter(l => l); + if (level) lines = lines.filter(l => l.includes('[' + level + ']')); + const tail = lines.slice(-n); + if (tail.length === 0) { this.terminal.print('No matching log entries', 'info'); return; } + this.terminal.print('--- Logs (last ' + tail.length + ') ---', 'info'); + tail.forEach(l => this.terminal.print(l, l.includes('[ERROR]') ? 'error' : 'info')); + }; + } + handleHelp(args) { + const helpMap = this.terminal.moduleManager.getAllHelp(); + // Single command lookup + if (args && args.length > 0) { + const cmd = args[0].toLowerCase(); + const allCommands = this.terminal.moduleManager.getAllCommands(); + if (allCommands[cmd]) { + const helpEntry = helpMap[cmd]; + if (helpEntry) { + this.terminal.print(helpEntry, 'info'); + } else { + this.terminal.print(`No help available for ${cmd}`, 'warning'); + } + } else { + this.terminal.print(`No help available for ${cmd}`, 'warning'); + } + return; + } + // Categorize all commands + const cats = { + files: ['ls','ll','cat','put','rm','mv','cp','mkdir','rmdir','cd','pwd','find','grep','head','tail','sort','wc','touch','tree','du','upload','download','vfs-export','vfs-import','edit','diff'], + ai: ['ai','ask','ai-clear','ai-system','ai-context','ai-allow','ai-deny','ai-stop','ai-model'], + search: ['search','s','ddg','duck','fetch','web','extract'], + tools: ['calc','cron-every','cron-list','cron-stop','cron-clear','wpm','mindmap','theme','voice','alias','unalias'], + system: ['help','clear','cls','echo','mem','status','font-size','setfont','smart-prompt','logs','preview','factory-reset','reset-vfs'], + net: ['server','servers','server-check','server-remove','monitor'], + mods: ['modules','listmods','loadmod','removemod','load-local','loadlocal','startup-modules'], + ollama: ['ollama','ollamaconnect','ollamadisconnect','ollamamodels','ollamasetmodel','ollamasetproxy','models','model-info','model-pull','model-delete','model-run'], + skills: ['skill-list','skill-view','skill-create','skill-patch','skill-delete','skill-export','skill-import','skill-sync','skill-pack-core'], + config: ['config','config-set','config-list','config-delete','config-export','config-reset'], + webx: ['webxai','wx','webx','context','contexts','context-new','context-delete','smart-ask','prompt','prompt-save','prompt-list','prompt-delete','prompt-var','prompt-vars'], + data: ['my','my-set','my-get','my-list','my-export','my-import','my-clear','export-mods','import-mods','backup','restore','share-mod'], + ext: ['chat','chat-setapi','chat-setmodel','chat-ask','webdav-config','webdav-status','webdav-ls','webdav-push','webdav-pull','webdav-backup','webdav-restore','webdav-backup-list'], + gui: ['gui','gui-button','gui-input','gui-text','gui-clear','gui-run'], + }; + const labels = { + files:'📁 Filesystem', ai:'🤖 AI', search:'🔍 Search & Web', tools:'🔧 Tools', + system:'⚙️ System', net:'📡 Network', mods:'🧩 Modules', ollama:'🦙 Ollama', + skills:'📚 Skills', config:'⚙️ Config', webx:'🌐 WebX', data:'💾 Data', + ext:'🧰 Extras', gui:'🖥️ GUI' + }; + let result = '╔══════════════════════════════════════╗\n║ 🔷 WebX11 v4.2 — All Commands ║\n╚══════════════════════════════════════╝\n\n'; + const total = Object.keys(helpMap).length; + result += `📊 ${total} commands loaded\n\n`; + for (const [key, cmds] of Object.entries(cats)) { + const present = cmds.filter(c => helpMap[c]); + if (present.length === 0) continue; + result += labels[key] + '\n'; + present.forEach(c => { + const desc = (helpMap[c] || c).split(' — ')[1] || ''; + const short = desc.substring(0, 55); + result += ` ${c.padEnd(16)}${short}\n`; + }); + result += '\n'; + } + result += '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n help <command> → detailed usage\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'; + this.terminal.print(result.trim(), 'info'); + } + } diff --git a/modules/core/ollama-manager.js b/modules/core/ollama-manager.js new file mode 100644 index 0000000..c790e6f --- /dev/null +++ b/modules/core/ollama-manager.js @@ -0,0 +1,842 @@ +// Extracted from newOS4 index.html — OllamaManager / LLMRouter +// Refactored to support multiple LLM providers with auto-fallback +// Class name kept as OllamaManager for backward compatibility +class OllamaManager { + constructor(terminal) { + this.terminal = terminal; + // --- Backward-compat properties (reflected from active provider) --- + this.endpoint = localStorage.getItem('webx11_ollama_endpoint') || 'http://localhost:11434'; + this.connected = false; + this.models = []; + this.currentModel = localStorage.getItem('webx11_ollama_model') || 'gemma4:31b-cloud'; + this.responseHooks = []; + this._abortController = null; + // Build fallback endpoints + this.fallbackEndpoints = []; + const origin = typeof window !== 'undefined' ? window.location.origin : ''; + if (origin) { + this.fallbackEndpoints.push(origin + '/ollama/'); + } + + // --- Multi-provider architecture --- + this.providers = []; // array of { name, type, endpoint, apiKey, model, priority, enabled } + this.currentProvider = null; // currently active provider object + this.fallbackCount = 0; + this._loadProviders(); + + // If no active provider set, pick the highest-priority enabled one + if (!this.currentProvider) { + const sorted = this._getSortedEnabledProviders(); + if (sorted.length > 0) { + this.currentProvider = sorted[0]; + this._syncCompatProperties(this.currentProvider); + } + } + } + + // ─── Provider management ───────────────────────────────────────────────── + + _loadProviders() { + const stored = this._getSecret('webx11_llm_providers'); + if (stored) { + try { + this.providers = JSON.parse(stored); + // Ensure all entries have the 'enabled' field + this.providers.forEach(p => { if (p.enabled === undefined) p.enabled = true; }); + } catch (e) { + this.providers = []; + } + } + if (this.providers.length === 0) { + // Default single provider: Ollama + this.providers = [{ + name: 'Ollama', + type: 'ollama', + endpoint: localStorage.getItem('webx11_ollama_endpoint') || 'http://localhost:11434', + apiKey: '', + model: localStorage.getItem('webx11_ollama_model') || 'gemma4:31b-cloud', + priority: 0, + enabled: true + }]; + } + } + + _saveProviders() { + this._setSecret('webx11_llm_providers', JSON.stringify(this.providers)); + } + + _getSecret(key) { + if (typeof window !== 'undefined' && window.webx11_secretVault && typeof window.webx11_secretVault.decrypt === 'function') { + return window.webx11_secretVault.decrypt(key); + } + return localStorage.getItem(key); + } + + _setSecret(key, value) { + if (typeof window !== 'undefined' && window.webx11_secretVault && typeof window.webx11_secretVault.encrypt === 'function') { + window.webx11_secretVault.encrypt(key, value); + } else { + localStorage.setItem(key, value); + } + } + + _getSortedEnabledProviders() { + return this.providers + .filter(p => p.enabled) + .sort((a, b) => (a.priority || 0) - (b.priority || 0)); + } + + _syncCompatProperties(provider) { + if (!provider) return; + this.endpoint = provider.endpoint; + this.currentModel = provider.model || this.currentModel; + // Don't override backward-compat localStorage unless explicitly set + } + + _getProviderByType(type) { + return this.providers.find(p => p.type === type && p.enabled); + } + + addProvider(name, type, endpoint, apiKey, model, priority) { + const existing = this.providers.findIndex(p => p.name === name); + const entry = { + name: name, + type: type || 'ollama', + endpoint: endpoint || 'http://localhost:11434', + apiKey: apiKey || '', + model: model || localStorage.getItem('webx11_ollama_model') || 'gemma4:31b-cloud', + priority: priority !== undefined ? priority : this.providers.length, + enabled: true + }; + if (existing >= 0) { + this.providers[existing] = entry; + } else { + this.providers.push(entry); + } + this._saveProviders(); + // If this is the new highest-priority enabled provider, activate it + const sorted = this._getSortedEnabledProviders(); + if (sorted.length > 0 && sorted[0].name === entry.name) { + this.currentProvider = entry; + this._syncCompatProperties(entry); + } + return true; + } + + removeProvider(name) { + const idx = this.providers.findIndex(p => p.name === name); + if (idx < 0) return false; + this.providers.splice(idx, 1); + if (this.currentProvider && this.currentProvider.name === name) { + this.currentProvider = null; + const sorted = this._getSortedEnabledProviders(); + if (sorted.length > 0) { + this.currentProvider = sorted[0]; + this._syncCompatProperties(this.currentProvider); + } + } + this._saveProviders(); + return true; + } + + listProviders() { + return this.providers; + } + + setActiveProvider(name) { + const provider = this.providers.find(p => p.name === name); + if (!provider) { + this.terminal.print(`Provider "${name}" not found`, 'error'); + return false; + } + this.currentProvider = provider; + this._syncCompatProperties(provider); + this.terminal.print(`Active provider set to: ${name} (${provider.type} at ${provider.endpoint})`, 'success'); + return true; + } + + getActiveProvider() { + return this.currentProvider; + } + + getProviderStats() { + const sorted = this._getSortedEnabledProviders(); + const connectedProvider = this.connected + ? (this.currentProvider ? this.currentProvider.name : 'unknown') + : null; + return { + currentProvider: this.currentProvider ? this.currentProvider.name : null, + totalProviders: this.providers.length, + connectedProvider: connectedProvider, + fallbackCount: this.fallbackCount + }; + } + + // ─── Connection ────────────────────────────────────────────────────────── + + async connect() { + const sortedProviders = this._getSortedEnabledProviders(); + if (sortedProviders.length === 0) { + this.terminal.print('No enabled providers configured', 'error'); + this.connected = false; + return false; + } + + // Also build endpoint-based fallback list for backward compat + const allEndpoints = []; + // First try the original endpoint + fallbackEndpoints for plain Ollama connect + if (this._getProviderByType('ollama')) { + allEndpoints.push(this.endpoint); + for (const fb of this.fallbackEndpoints) { + if (!allEndpoints.includes(fb)) allEndpoints.push(fb); + } + } + + // Try each provider in priority order + for (const provider of sortedProviders) { + let success = false; + if (provider.type === 'ollama') { + // Try provider endpoint first, then fallback endpoints + const eps = [provider.endpoint]; + for (const fb of this.fallbackEndpoints) { + if (!eps.includes(fb)) eps.push(fb); + } + for (const ep of eps) { + try { + this.terminal.print(`Connecting to ${provider.name} at ${ep}...`, 'info'); + const response = await fetch(`${ep}/api/tags`, { + method: 'GET', headers: { 'Content-Type': 'application/json' } + }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const data = await response.json(); + this.models = data.models || []; + this.connected = true; + this.currentProvider = provider; + provider.endpoint = ep; // may have been updated via fallback + this._syncCompatProperties(provider); + this.saveSettings(); + this.terminal.print(`Connected to ${provider.name}! Found ${this.models.length} model(s)`, 'success'); + this.terminal.log('INFO', 'Ollama', 'Connected to ' + provider.name + ' at ' + ep + ' with ' + this.models.length + ' model(s)'); + if (!this.currentModel || !this.models.some(m => m.name === this.currentModel)) { + if (this.models.length > 0) { + this.currentModel = this.models[0].name; + provider.model = this.currentModel; + localStorage.setItem('webx11_ollama_model', this.currentModel); + this.terminal.print(`Auto-selected model: ${this.currentModel}`, 'info'); + } + } + this._saveProviders(); + success = true; + break; + } catch (error) { + this.terminal.log('WARN', 'Ollama', provider.name + ' endpoint ' + ep + ' failed: ' + error.message); + this.terminal.print(`${provider.name} endpoint ${ep} failed: ${error.message}`, 'warning'); + } + } + } else { + // OpenAI-compatible provider (OpenRouter, etc.) + try { + this.terminal.print(`Connecting to ${provider.name} at ${provider.endpoint}...`, 'info'); + const headers = { 'Content-Type': 'application/json' }; + if (provider.apiKey) { + headers['Authorization'] = 'Bearer ' + provider.apiKey; + } + const response = await fetch(`${provider.endpoint}/v1/models`, { + method: 'GET', headers + }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const data = await response.json(); + this.models = (data.data || data.models || []).map(m => ({ + name: m.id || m.name, + size: m.size || 0 + })); + this.connected = true; + this.currentProvider = provider; + this._syncCompatProperties(provider); + this.saveSettings(); + this.terminal.print(`Connected to ${provider.name}! Found ${this.models.length} model(s)`, 'success'); + this.terminal.log('INFO', 'Ollama', 'Connected to ' + provider.name + ' at ' + provider.endpoint + ' with ' + this.models.length + ' model(s)'); + if (!this.currentModel || !this.models.some(m => m.name === this.currentModel)) { + if (this.models.length > 0) { + this.currentModel = this.models[0].name; + provider.model = this.currentModel; + localStorage.setItem('webx11_ollama_model', this.currentModel); + this.terminal.print(`Auto-selected model: ${this.currentModel}`, 'info'); + } + } + this._saveProviders(); + success = true; + } catch (error) { + this.terminal.log('WARN', 'Ollama', provider.name + ' failed: ' + error.message); + this.terminal.print(`${provider.name} failed: ${error.message}`, 'warning'); + } + } + + if (success) return true; + } + + // If provider-based connect failed, try bare endpoint fallback (legacy) + for (const ep of allEndpoints) { + try { + this.terminal.print(`Connecting (legacy fallback) to ${ep}...`, 'info'); + const response = await fetch(`${ep}/api/tags`, { + method: 'GET', headers: { 'Content-Type': 'application/json' } + }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const data = await response.json(); + this.models = data.models || []; + this.connected = true; + if (ep !== this.endpoint) { + this.endpoint = ep; + this.saveSettings(); + this.terminal.print(`Endpoint updated to: ${ep}`, 'info'); + } + this.terminal.print(`Connected! Found ${this.models.length} model(s)`, 'success'); + this.terminal.log('INFO', 'Ollama', 'Connected to ' + ep + ' with ' + this.models.length + ' model(s)'); + if (!this.currentModel || !this.models.some(m => m.name === this.currentModel)) { + if (this.models.length > 0) { + this.currentModel = this.models[0].name; + localStorage.setItem('webx11_ollama_model', this.currentModel); + this.terminal.print(`Auto-selected model: ${this.currentModel}`, 'info'); + } + } + this.saveSettings(); + return true; + } catch (error) { + this.terminal.log('WARN', 'Ollama', 'Endpoint ' + ep + ' failed: ' + error.message); + this.terminal.print(`Endpoint ${ep} failed: ${error.message}`, 'warning'); + } + } + + this.terminal.log('ERROR', 'Ollama', 'Connection failed on all providers'); + this.connected = false; + return false; + } + + // ─── Chat (non-streaming) ──────────────────────────────────────────────── + + async chat(prompt, model = null) { + if (!this.connected) { + this.terminal.print('Ollama not connected. Use ollamaconnect first.', 'error'); + return null; + } + const useModel = model || this.currentModel; + if (!useModel) { + this.terminal.print('No model selected. Use ollamamodels to select.', 'error'); + return null; + } + + const providersToTry = this._getFallbackChain(); + let lastError = null; + + for (const provider of providersToTry) { + const result = await this._chatWithProvider(provider, prompt, useModel); + if (result !== null && result !== undefined) { + // Success — if we fell back, increment fallbackCount + if (provider !== this.currentProvider) { + this.fallbackCount++; + this.terminal.print(`Fell back to provider: ${provider.name}`, 'warning'); + this.currentProvider = provider; + this._syncCompatProperties(provider); + } + return result; + } + lastError = result; + } + + if (lastError === null) { + this.terminal.print(`Ollama error: No provider responded`, 'error'); + } + return null; + } + + async _chatWithProvider(provider, prompt, model) { + const useModel = model || provider.model || this.currentModel; + let fullText = ''; + try { + if (provider.type === 'ollama') { + this._abortController = new AbortController(); + const signal = this._abortController.signal; + const response = await fetch(`${provider.endpoint}/api/generate`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ model: useModel, prompt: prompt, stream: true }), + signal + }); + this._abortController = null; + if (!response.ok) throw new Error(`HTTP ${response.status}`); + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let isFirstToken = true; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + const chunk = decoder.decode(value, { stream: true }); + const lines = chunk.split('\n').filter(l => l.trim()); + + for (const line of lines) { + try { + const parsed = JSON.parse(line); + if (parsed.response) { + fullText += parsed.response; + this.terminal.printStreamingResponse(parsed.response, !isFirstToken); + isFirstToken = false; + } + } catch (e) { + // Skip malformed partial lines + } + } + } + return fullText || null; + } else { + // OpenAI-compatible provider + this._abortController = new AbortController(); + const signal = this._abortController.signal; + const headers = { 'Content-Type': 'application/json' }; + if (provider.apiKey) { + headers['Authorization'] = 'Bearer ' + provider.apiKey; + } + const response = await fetch(`${provider.endpoint}/v1/chat/completions`, { + method: 'POST', + headers, + body: JSON.stringify({ + model: useModel, + messages: [{ role: 'user', content: prompt }], + stream: true + }), + signal + }); + this._abortController = null; + if (!response.ok) throw new Error(`HTTP ${response.status}`); + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let isFirstToken = true; + let buffer = ''; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split('\n'); + buffer = lines.pop() || ''; + + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed || trimmed === 'data: [DONE]') continue; + if (trimmed.startsWith('data: ')) { + try { + const parsed = JSON.parse(trimmed.substring(6)); + const content = parsed.choices?.[0]?.delta?.content || ''; + if (content) { + fullText += content; + this.terminal.printStreamingResponse(content, !isFirstToken); + isFirstToken = false; + } + } catch (e) { + // Skip malformed SSE lines + } + } + } + } + return fullText || null; + } + } catch (error) { + if (fullText && fullText.length > 0) { + this.terminal.printStreamingResponse(`\n\n[Stream error: ${error.message}]`, true); + return fullText; + } + return null; // signal failure so caller can try next provider + } + } + + // ─── Chat Stream ───────────────────────────────────────────────────────── + + async chatStream(prompt, onToken, onComplete, model = null) { + if (!this.connected) { this.terminal.print('Ollama not connected.', 'error'); onComplete && onComplete(null); return; } + const useModel = model || this.currentModel; + if (!useModel) { this.terminal.print('No model selected.', 'error'); onComplete && onComplete(null); return; } + + const providersToTry = this._getFallbackChain(); + let triedAny = false; + + for (const provider of providersToTry) { + triedAny = true; + const result = await this._chatStreamWithProvider(provider, prompt, onToken, onComplete, useModel); + if (result !== null) { + if (provider !== this.currentProvider) { + this.fallbackCount++; + this.terminal.print(`Fell back to provider: ${provider.name}`, 'warning'); + this.currentProvider = provider; + this._syncCompatProperties(provider); + } + return result; + } + } + + if (!triedAny) { + this.terminal.print('No model selected.', 'error'); + onComplete && onComplete(null); + } else { + this.terminal.print(`Stream error: No provider responded`, 'error'); + onComplete && onComplete(null); + } + return null; + } + + async _chatStreamWithProvider(provider, prompt, onToken, onComplete, model) { + const useModel = model || provider.model || this.currentModel; + try { + if (provider.type === 'ollama') { + this._abortController = new AbortController(); + const signal = this._abortController.signal; + const response = await fetch(`${provider.endpoint}/api/generate`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ model: useModel, prompt: prompt, stream: true }), + signal + }); + this._abortController = null; + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let fullResponse = ''; + let buffer = ''; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split('\n'); + buffer = lines.pop() || ''; + for (const line of lines) { + if (!line.trim()) continue; + try { + const obj = JSON.parse(line); + if (obj.response) { + fullResponse += obj.response; + onToken && onToken(obj.response, fullResponse); + } + } catch (e) { continue; } + } + } + if (buffer.trim()) { + try { + const obj = JSON.parse(buffer); + if (obj.response) { fullResponse += obj.response; onToken && onToken(obj.response, fullResponse); } + } catch (e) {} + } + this.fireResponseHooks(fullResponse); + onComplete && onComplete(fullResponse); + return fullResponse; + } else { + // OpenAI-compatible provider + this._abortController = new AbortController(); + const signal = this._abortController.signal; + const headers = { 'Content-Type': 'application/json' }; + if (provider.apiKey) { + headers['Authorization'] = 'Bearer ' + provider.apiKey; + } + const response = await fetch(`${provider.endpoint}/v1/chat/completions`, { + method: 'POST', + headers, + body: JSON.stringify({ + model: useModel, + messages: [{ role: 'user', content: prompt }], + stream: true + }), + signal + }); + this._abortController = null; + if (!response.ok) throw new Error(`HTTP ${response.status}`); + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let fullResponse = ''; + let buffer = ''; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split('\n'); + buffer = lines.pop() || ''; + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed || trimmed === 'data: [DONE]') continue; + if (trimmed.startsWith('data: ')) { + try { + const parsed = JSON.parse(trimmed.substring(6)); + const content = parsed.choices?.[0]?.delta?.content || ''; + if (content) { + fullResponse += content; + onToken && onToken(content, fullResponse); + } + } catch (e) { continue; } + } + } + } + this.fireResponseHooks(fullResponse); + onComplete && onComplete(fullResponse); + return fullResponse; + } + } catch (error) { + this.terminal.print(`Stream error with ${provider.name}: ${error.message}`, 'error'); + onComplete && onComplete(null); + return null; + } + } + + // ─── Chat With Tools ───────────────────────────────────────────────────── + + async chatWithTools(messages, tools, model = null) { + if (!this.connected) { + this.terminal.print('Ollama not connected. Use ollamaconnect first.', 'error'); + return { message: null, tool_calls: null, error: 'Not connected' }; + } + const useModel = model || this.currentModel; + if (!useModel) { + this.terminal.print('No model selected.', 'error'); + return { message: null, tool_calls: null, error: 'No model' }; + } + + const providersToTry = this._getFallbackChain(); + let lastError = null; + + for (const provider of providersToTry) { + const result = await this._chatWithToolsWithProvider(provider, messages, tools, useModel); + if (result && !result.error) { + if (provider !== this.currentProvider) { + this.fallbackCount++; + this.terminal.print(`Fell back to provider: ${provider.name}`, 'warning'); + this.currentProvider = provider; + this._syncCompatProperties(provider); + } + return result; + } + // Only keep non-cancellation errors for fallback + if (result && result.error && result.error !== 'Cancelled') { + lastError = result; + } + } + + return lastError || { message: null, tool_calls: null, error: 'All providers failed' }; + } + + async _chatWithToolsWithProvider(provider, messages, tools, model) { + const useModel = model || provider.model || this.currentModel; + this._abortController = new AbortController(); + const signal = this._abortController.signal; + + try { + if (provider.type === 'ollama') { + const body = { model: useModel, messages, stream: false }; + if (tools && tools.length > 0) body.tools = tools; + const t0 = Date.now(); + const response = await fetch(`${provider.endpoint}/api/chat`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + signal + }); + this._abortController = null; + const networkTime = Math.round((Date.now() - t0) / 1000); + if (!response.ok) { + if (response.status === 400) { + return await this.chatWithToolsFallback(messages, tools, useModel, signal); + } + throw new Error(`HTTP ${response.status}`); + } + const data = await response.json(); + this.terminal.log('INFO', 'Ollama', `chatWithTools round-trip: ${networkTime}s (model: ${useModel}, provider: ${provider.name})`); + return { + message: data.message || null, + tool_calls: data.message?.tool_calls || null, + error: null + }; + } else { + // OpenAI-compatible provider + const headers = { 'Content-Type': 'application/json' }; + if (provider.apiKey) { + headers['Authorization'] = 'Bearer ' + provider.apiKey; + } + const body = { + model: useModel, + messages: messages, + stream: false + }; + if (tools && tools.length > 0) { + body.tools = tools; + } + const t0 = Date.now(); + const response = await fetch(`${provider.endpoint}/v1/chat/completions`, { + method: 'POST', + headers, + body: JSON.stringify(body), + signal + }); + this._abortController = null; + const networkTime = Math.round((Date.now() - t0) / 1000); + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + const data = await response.json(); + this.terminal.log('INFO', 'Ollama', `chatWithTools round-trip: ${networkTime}s (model: ${useModel}, provider: ${provider.name})`); + const choice = data.choices?.[0]; + const message = choice?.message || null; + const toolCalls = message?.tool_calls || null; + return { + message: message ? { role: message.role || 'assistant', content: message.content || '' } : null, + tool_calls: toolCalls ? toolCalls.map(tc => ({ + id: tc.id, + type: tc.type || 'function', + function: { + name: tc.function?.name || '', + arguments: tc.function?.arguments || '{}' + } + })) : null, + error: null + }; + } + } catch (error) { + if (error.name === 'AbortError') { + this.terminal.print('⏹ AI request cancelled by user', 'warning'); + return { message: null, tool_calls: null, error: 'Cancelled' }; + } + if (error.message === 'Failed to fetch' && provider.type === 'ollama') { + return await this.chatWithToolsFallback(messages, tools, useModel); + } + this.terminal.print(`${provider.name} /api/chat error: ${error.message}`, 'error'); + return { message: null, tool_calls: null, error: error.message }; + } finally { + this._abortController = null; + } + } + + async chatWithToolsFallback(messages, tools, model, signal) { + const provider = this.currentProvider; + const useModel = model || (provider ? provider.model : this.currentModel); + if (provider && provider.type !== 'ollama') { + // For OpenAI-compatible providers, try without tools + this.terminal.print(`Model doesn't support tool calling. Retrying without tools...`, 'warning'); + try { + const headers = { 'Content-Type': 'application/json' }; + if (provider.apiKey) { + headers['Authorization'] = 'Bearer ' + provider.apiKey; + } + const body = { + model: useModel, + messages: messages, + stream: false + }; + const response = await fetch(`${provider.endpoint}/v1/chat/completions`, { + method: 'POST', headers, + body: JSON.stringify(body), + signal + }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const data = await response.json(); + const text = data.choices?.[0]?.message?.content || ''; + return { message: { role: 'assistant', content: text }, tool_calls: null, error: null }; + } catch (e) { + if (e.name === 'AbortError') { + return { message: null, tool_calls: null, error: 'Cancelled' }; + } + return { message: null, tool_calls: null, error: e.message }; + } + } + + // Original Ollama fallback + this.terminal.print(`Model doesn't support tool calling. Retrying /api/chat without tools...`, 'warning'); + try { + const body = { model: useModel, messages, stream: false }; + const response = await fetch(`${this.endpoint}/api/chat`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + signal + }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const data = await response.json(); + const text = data.message?.content || data.response || ''; + return { message: { role: 'assistant', content: text }, tool_calls: null, error: null }; + } catch (e) { + if (e.name === 'AbortError') { + return { message: null, tool_calls: null, error: 'Cancelled' }; + } + return { message: null, tool_calls: null, error: e.message }; + } + } + + // ─── Internal helpers ──────────────────────────────────────────────────── + + _getFallbackChain() { + const sorted = this._getSortedEnabledProviders(); + if (sorted.length === 0) return []; + + // Start with the current provider (if any), then all others sorted by priority + const chain = []; + const seen = new Set(); + + if (this.currentProvider && this.currentProvider.enabled) { + chain.push(this.currentProvider); + seen.add(this.currentProvider.name); + } + + for (const p of sorted) { + if (!seen.has(p.name)) { + chain.push(p); + seen.add(p.name); + } + } + + return chain; + } + + // ─── Backward-compat methods ───────────────────────────────────────────── + + setEndpoint(url) { + this.endpoint = url; + // Also update the current provider's endpoint if it exists + if (this.currentProvider) { + this.currentProvider.endpoint = url; + this._saveProviders(); + } + this.saveSettings(); + } + + setModel(name) { + this.currentModel = name; + // Also update the current provider's model + if (this.currentProvider) { + this.currentProvider.model = name; + this._saveProviders(); + } + this.saveSettings(); + this.terminal.print(`Model set to: ${name}`, 'success'); + } + + saveSettings() { + localStorage.setItem('webx11_ollama_endpoint', this.endpoint); + localStorage.setItem('webx11_ollama_model', this.currentModel); + } + + getModels() { return this.models; } + + addResponseHook(fn) { this.responseHooks.push(fn); } + + removeResponseHook(fn) { this.responseHooks = this.responseHooks.filter(h => h !== fn); } + + fireResponseHooks(text) { if (text) this.responseHooks.forEach(fn => { try { fn(text); } catch (e) {} }); } + + abort() { + if (this._abortController) { + this._abortController.abort(); + this._abortController = null; + return true; + } + return false; + } +} diff --git a/modules/core/secret-vault.js b/modules/core/secret-vault.js new file mode 100644 index 0000000..e62192a --- /dev/null +++ b/modules/core/secret-vault.js @@ -0,0 +1,459 @@ +// Extracted from newOS4 index.html — SecretVault +class SecretVault { + constructor() { + this.commands = {}; + this.terminal = null; + this._key = null; + this._prefix = 'webx11_secret_'; + this._saltKey = 'webx11_secret_salt'; + this._verifyName = 'secret_vault_verified'; + this._openModeKey = 'webx11_secret_open_key'; + } + + getHelpInfo() { + return { + 'secret-set': 'secret-set <name> <value> — Encrypt and store a secret', + 'secret-get': 'secret-get <name> — Decrypt and display a secret value', + 'secret-list': 'secret-list — List all stored secret names', + 'secret-del': 'secret-del <name> — Remove a stored secret', + 'secret-lock': 'secret-lock — Lock the vault (clear derived key from memory)', + 'secret-unlock': 'secret-unlock <pin> — Unlock the vault with a PIN code', + 'secret-setpin': 'secret-setpin <pin> — Set or change the vault PIN code', + 'secret-status': 'secret-status — Show vault status (locked/unlocked/pin set/open mode)', + }; + } + + registerCommands() { + this.commands['secret-set'] = (args) => this._cmdSet(args); + this.commands['secret-get'] = (args) => this._cmdGet(args); + this.commands['secret-list'] = () => this._cmdList(); + this.commands['secret-del'] = (args) => this._cmdDel(args); + this.commands['secret-lock'] = () => this._cmdLock(); + this.commands['secret-unlock'] = (args) => this._cmdUnlock(args); + this.commands['secret-setpin'] = (args) => this._cmdSetPin(args); + this.commands['secret-status'] = () => this._cmdStatus(); + } + + // ---------- Core API ---------- + + isPinSet() { + return localStorage.getItem(this._saltKey) !== null; + } + + isLocked() { + // Locked only if PIN is set but key is not derived in memory + return this.isPinSet() && this._key === null; + } + + async setPin(pin) { + if (!pin || typeof pin !== 'string' || pin.length === 0) { + return { error: 'PIN must be a non-empty string' }; + } + + // Generate new salt + const salt = this._generateSalt(); + localStorage.setItem(this._saltKey, this._arrayToBase64(salt)); + + // Derive new key from the new PIN + const newKey = await this._deriveKeyFromPin(pin, salt); + + if (this.isPinSet() && this._key !== null) { + // Re-encrypt all existing secrets with new key + await this._reEncryptAll(newKey); + } + + // Store the new key in memory + this._key = newKey; + + // Store a verification token so we know the PIN works + await this._encryptWithKey(this._verifyName, 'verified', this._key); + + return { success: true }; + } + + lock() { + this._key = null; + return { success: true }; + } + + async unlock(pin) { + if (!pin || typeof pin !== 'string' || pin.length === 0) { + return { error: 'PIN must be a non-empty string' }; + } + + if (!this.isPinSet()) { + return { error: 'No PIN has been set. Use secret-setpin first.' }; + } + + const saltBase64 = localStorage.getItem(this._saltKey); + if (!saltBase64) { + return { error: 'Salt not found. Vault may be corrupted.' }; + } + + const salt = this._base64ToArray(saltBase64); + const key = await this._deriveKeyFromPin(pin, salt); + + // Verify PIN by checking the verification token + const verifyData = await this._decryptWithKey(this._verifyName, key); + if (verifyData === null) { + return { error: 'Invalid PIN' }; + } + + this._key = key; + return { success: true }; + } + + async encrypt(name, value) { + if (this.isLocked()) { + return { error: 'Vault is locked' }; + } + + // Ensure key is available + let key = this._key; + if (!key) { + // Open mode: use or generate a session-like key + key = await this._getOpenModeKey(); + } + + const stringValue = typeof value === 'string' ? value : JSON.stringify(value); + return this._encryptWithKey(name, stringValue, key); + } + + async decrypt(name) { + if (this.isLocked()) { + return null; + } + + let key = this._key; + if (!key) { + key = await this._getOpenModeKey(); + if (!key) return null; + } + + return this._decryptWithKey(name, key); + } + + remove(name) { + localStorage.removeItem(this._prefix + name); + return { success: true }; + } + + list() { + const names = []; + for (let i = 0; i < localStorage.length; i++) { + const key = localStorage.key(i); + if (key.startsWith(this._prefix)) { + names.push(key.substring(this._prefix.length)); + } + } + return names; + } + + has(name) { + return localStorage.getItem(this._prefix + name) !== null; + } + + /** + * Scans text for any stored secret values and replaces them with '[REDACTED]'. + * Used by terminal and AI context to prevent leaking secrets in output. + */ + async redactSecret(text) { + if (!text || typeof text !== 'string') return text; + + const names = this.list(); + if (names.length === 0) return text; + + let redacted = text; + + for (const name of names) { + try { + const value = await this.decrypt(name); + if (value && typeof value === 'string' && value.length > 0) { + // Escape special regex characters in the secret value + const escaped = value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + try { + const regex = new RegExp(escaped, 'g'); + redacted = redacted.replace(regex, '[REDACTED]'); + } catch (e) { + // If regex fails (e.g., complex Unicode), try manual replacement + while (redacted.includes(value)) { + redacted = redacted.replace(value, '[REDACTED]'); + } + } + } + } catch (e) { + // Skip secrets that can't be decrypted + } + } + + return redacted; + } + + // ---------- Internal crypto methods ---------- + + _generateSalt() { + const salt = new Uint8Array(16); + crypto.getRandomValues(salt); + return salt; + } + + _generateIV() { + const iv = new Uint8Array(12); + crypto.getRandomValues(iv); + return iv; + } + + _arrayToBase64(array) { + return btoa(String.fromCharCode.apply(null, new Uint8Array(array))); + } + + _base64ToArray(base64) { + const binary = atob(base64); + const array = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + array[i] = binary.charCodeAt(i); + } + return array; + } + + async _deriveKeyFromPin(pin, salt) { + const encoder = new TextEncoder(); + const keyMaterial = await crypto.subtle.importKey( + 'raw', + encoder.encode(pin), + 'PBKDF2', + false, + ['deriveKey'] + ); + + return crypto.subtle.deriveKey( + { + name: 'PBKDF2', + salt: salt, + iterations: 100000, + hash: 'SHA-256', + }, + keyMaterial, + { + name: 'AES-GCM', + length: 256, + }, + false, + ['encrypt', 'decrypt'] + ); + } + + async _getOpenModeKey() { + // In open mode (no PIN set), we use a persistent 32-char hex key in localStorage + // This is less secure than PIN mode but better than plaintext + let keyHex = localStorage.getItem(this._openModeKey); + if (!keyHex) { + keyHex = this._generateOpenKey(); + localStorage.setItem(this._openModeKey, keyHex); + } + return this._hexKeyToCryptoKey(keyHex); + } + + _generateOpenKey() { + const bytes = new Uint8Array(32); + crypto.getRandomValues(bytes); + return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join(''); + } + + async _hexKeyToCryptoKey(hex) { + const bytes = new Uint8Array(32); + for (let i = 0; i < 32; i++) { + bytes[i] = parseInt(hex.substr(i * 2, 2), 16); + } + return crypto.subtle.importKey( + 'raw', + bytes, + { + name: 'AES-GCM', + length: 256, + }, + false, + ['encrypt', 'decrypt'] + ); + } + + async _encryptWithKey(name, value, key) { + try { + const encoder = new TextEncoder(); + const data = encoder.encode(value); + const iv = this._generateIV(); + + const encrypted = await crypto.subtle.encrypt( + { + name: 'AES-GCM', + iv: iv, + }, + key, + data + ); + + const encryptedArray = new Uint8Array(encrypted); + const storageData = JSON.stringify({ + iv: this._arrayToBase64(iv), + data: this._arrayToBase64(encryptedArray), + }); + + localStorage.setItem(this._prefix + name, storageData); + return { success: true }; + } catch (e) { + return { error: 'Encryption failed: ' + e.message }; + } + } + + async _decryptWithKey(name, key) { + try { + const storageData = localStorage.getItem(this._prefix + name); + if (!storageData) return null; + + const parsed = JSON.parse(storageData); + if (!parsed.iv || !parsed.data) return null; + + const iv = this._base64ToArray(parsed.iv); + const encryptedData = this._base64ToArray(parsed.data); + + const decrypted = await crypto.subtle.decrypt( + { + name: 'AES-GCM', + iv: iv, + }, + key, + encryptedData + ); + + const decoder = new TextDecoder(); + return decoder.decode(decrypted); + } catch (e) { + return null; + } + } + + async _reEncryptAll(newKey) { + const names = this.list(); + const oldKey = this._key; + + for (const name of names) { + // Skip verification token — it's regenerated in setPin + if (name === this._verifyName) continue; + + const plaintext = await this._decryptWithKey(name, oldKey); + if (plaintext !== null) { + await this._encryptWithKey(name, plaintext, newKey); + } + } + + // Update verification token with new key + await this._encryptWithKey(this._verifyName, 'verified', newKey); + } + + // ---------- Command handlers ---------- + + async _cmdSet(args) { + if (args.length < 2) { + this.terminal.print('Usage: secret-set <name> <value>', 'warning'); + return; + } + const name = args[0]; + const value = args.slice(1).join(' '); + const result = await this.encrypt(name, value); + if (result.error) { + this.terminal.print(result.error, 'error'); + } else { + this.terminal.print(`Secret '${name}' saved`, 'success'); + } + } + + async _cmdGet(args) { + if (!args[0]) { + this.terminal.print('Usage: secret-get <name>', 'warning'); + return; + } + const value = await this.decrypt(args[0]); + if (value === null) { + if (this.isLocked()) { + this.terminal.print('Vault is locked. Use secret-unlock <pin>', 'warning'); + } else { + this.terminal.print(`Secret '${args[0]}' not found`, 'error'); + } + } else { + this.terminal.print(`Secret '${args[0]}': ${value}`, 'info'); + } + } + + _cmdList() { + const names = this.list(); + if (names.length === 0) { + this.terminal.print('No secrets stored', 'info'); + return; + } + this.terminal.print('=== Stored Secrets ===', 'info'); + names.forEach(n => { + // Skip internal verification token + if (n === this._verifyName) return; + this.terminal.print(` - ${n}`); + }); + } + + _cmdDel(args) { + if (!args[0]) { + this.terminal.print('Usage: secret-del <name>', 'warning'); + return; + } + const result = this.remove(args[0]); + if (result.success) { + this.terminal.print(`Secret '${args[0]}' deleted`, 'success'); + } + } + + _cmdLock() { + const result = this.lock(); + if (result.success) { + this.terminal.print('Vault locked', 'success'); + } + } + + async _cmdUnlock(args) { + if (!args[0]) { + this.terminal.print('Usage: secret-unlock <pin>', 'warning'); + return; + } + const result = await this.unlock(args[0]); + if (result.error) { + this.terminal.print(result.error, 'error'); + } else { + this.terminal.print('Vault unlocked', 'success'); + } + } + + async _cmdSetPin(args) { + if (!args[0]) { + this.terminal.print('Usage: secret-setpin <pin>', 'warning'); + return; + } + const result = await this.setPin(args[0]); + if (result.error) { + this.terminal.print(result.error, 'error'); + } else { + this.terminal.print('PIN set successfully. Vault is now in PIN-protected mode.', 'success'); + } + } + + _cmdStatus() { + const pinSet = this.isPinSet(); + const locked = this.isLocked(); + const hasKey = this._key !== null; + + this.terminal.print('=== Secret Vault Status ===', 'info'); + this.terminal.print(`PIN set: ${pinSet ? 'Yes' : 'No'}`, pinSet ? 'success' : 'info'); + if (pinSet) { + this.terminal.print(`Vault: ${locked ? '🔒 Locked' : '🔓 Unlocked'}`, locked ? 'warning' : 'success'); + } else { + this.terminal.print(`Mode: Open (no PIN — using persistent key)`, 'info'); + } + this.terminal.print(`Secrets stored: ${this.list().filter(n => n !== this._verifyName).length}`, 'info'); + } +} diff --git a/modules/core/terminal.js b/modules/core/terminal.js new file mode 100644 index 0000000..0a9538f --- /dev/null +++ b/modules/core/terminal.js @@ -0,0 +1,1362 @@ +// Extracted from newOS4 index.html — WebX11Terminal + class WebX11Terminal { + constructor() { + this.outputContainer = document.getElementById('terminal-container'); + this.inputField = document.getElementById('command-input'); + this.dropdown = document.getElementById('autocomplete-dropdown'); + this.statusBar = document.getElementById('status-text'); + this.networkStatus = document.getElementById('network-status'); + this.fs = new VirtualFileSystem(); + this.secretVault = null; + try { + if (typeof SecretVault !== 'undefined') { + this.secretVault = new SecretVault(this); + window.webx11_secretVault = this.secretVault; + } + } catch (e) { console.warn('SecretVault init:', e.message); } + this.moduleManager = new ModuleManager(this); + this.ollamaManager = new OllamaManager(this); + this._pipeInput = null; + this._pipeBufferSize = 5000; + this._historyBuffer = []; + this._historyMaxLines = 50; + this.MAX_OUTPUT_LINES = 200; + this._aiBusy = false; + this._dirty = false; + this.history = JSON.parse(localStorage.getItem('webx11_command_history') || '[]'); + this.historyIndex = this.history.length; + this.selectedSuggestion = -1; + this.suggestions = []; + this._isCdAutocomplete = false; + this._autocompletePrefix = ''; + this.initEventListeners(); + this.updateNetworkStatus(); + this.loadBuiltInModules(); + this._loadHistory(); + // Auto-restore session + this._autoRestoreSession(); + this._restoreFontSize(); + // Init auto-save timer — 60s after boot, then every 60s + setTimeout(() => this._initAutoSave(), 30000); + // Init sound toggle button text + const soundBtn = document.getElementById('sound-toggle-btn'); + if (soundBtn) { + soundBtn.textContent = localStorage.getItem('webx11_config_ai_sound') === 'off' ? '🔇 Muted' : '🔊 Sound'; + } + setTimeout(() => this.inputField.focus(), 100); + } + initEventListeners() { + this.inputField.addEventListener('keydown', (e) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + if (this.selectedSuggestion >= 0 && this.suggestions[this.selectedSuggestion]) { + if (this._autocompletePrefix) { + this.inputField.value = this._autocompletePrefix + this.suggestions[this.selectedSuggestion]; + } else if (!this._isCdAutocomplete) { + this.inputField.value = this.suggestions[this.selectedSuggestion]; + } + } + const cmd = this.inputField.value; + this.history.push(cmd); + this.historyIndex = this.history.length; + this._saveCommandHistory(); + this.executeCommand(cmd); + this.inputField.value = ''; + this.inputField.style.height = 'auto'; + this.hideAutocomplete(); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + if (this.historyIndex > 0) { + this.historyIndex--; + this.inputField.value = this.history[this.historyIndex]; + } + this.hideAutocomplete(); + } else if (e.key === 'ArrowDown') { + e.preventDefault(); + if (this.historyIndex < this.history.length - 1) { + this.historyIndex++; + this.inputField.value = this.history[this.historyIndex]; + } else { + this.historyIndex = this.history.length; + this.inputField.value = ''; + } + this.hideAutocomplete(); + } else if (e.key === 'Tab') { + e.preventDefault(); + if (this.suggestions.length > 0) { + this.selectedSuggestion = (this.selectedSuggestion + 1) % this.suggestions.length; + this.updateAutocompleteDisplay(); + } + } else if (e.key === 'Escape') { + if (this._aiBusy) { + this.executeCommand('ai-stop'); + } else { + this.hideAutocomplete(); + } + } + }); + this.inputField.addEventListener('input', () => { + this.inputField.style.height = 'auto'; + this.inputField.style.height = Math.min(this.inputField.scrollHeight, 120) + 'px'; + this.showAutocomplete(); + }); + this.inputField.addEventListener('focus', () => { + this.showAutocomplete(); + }); + document.addEventListener('click', (e) => { + if (!e.target.closest('#input-area')) { + this.hideAutocomplete(); + } + }); + window.addEventListener('online', () => this.updateNetworkStatus()); + window.addEventListener('offline', () => this.updateNetworkStatus()); + document.getElementById('file-upload-input').addEventListener('change', (e) => { + const file = e.target.files[0]; + if (file) { + const reader = new FileReader(); + reader.onload = (evt) => { + this.fs.writeFile(file.name, evt.target.result); + this.markDirty(); + this.print(`File '${file.name}' uploaded successfully.`, 'success'); + }; + reader.readAsText(file); + } + e.target.value = ''; + }); + // Voice mic button + // Sound toggle button + const soundBtn = document.getElementById('sound-toggle-btn'); + if (soundBtn) { + soundBtn.addEventListener('click', () => { + const current = localStorage.getItem('webx11_config_ai_sound'); + if (current === 'off') { + localStorage.setItem('webx11_config_ai_sound', 'on'); + soundBtn.textContent = '🔊 Sound'; + } else { + localStorage.setItem('webx11_config_ai_sound', 'off'); + soundBtn.textContent = '🔇 Muted'; + } + }); + } + // Mic toggle button + const micToggleBtn = document.getElementById('voice-toggle-btn'); + if (micToggleBtn) { + micToggleBtn.addEventListener('click', () => this.toggleMicVisibility()); + } + } + updateNetworkStatus() { + const isOnline = navigator.onLine; + this.networkStatus.textContent = isOnline ? '📶 Online' : '❌ Offline'; + this.networkStatus.style.color = isOnline ? 'var(--text-color)' : 'var(--error-color)'; + } + updatePrompt() { + const path = '/' + this.fs.currentPath.join('/'); + const label = document.querySelector('.prompt-label'); + if (!label) return; + if (localStorage.getItem('webx11_smart_prompt') === 'off') { + label.textContent = path === '/' ? '$' : path + ' $'; + return; + } + const indicators = []; + try { + const p = localStorage.getItem('webx11_user_profile'); + if (p) { const pf = JSON.parse(p); if (pf.knownFacts && pf.knownFacts.name) indicators.push('👤' + pf.knownFacts.name); } + } catch (e) {} + try { + const idx = this.fs.fsReadJSON('/skills/index.json'); + if (idx) { const c = Object.keys(idx).length; if (c > 0) indicators.push('📚' + c); } + } catch (e) {} + try { + const memIdx = this.fs.fsReadJSON('/memory/index.json'); + if (memIdx) { const c = Object.keys(memIdx).length; if (c > 0) indicators.push('🧠' + c); } + } catch (e) {} + try { + if (performance.memory && performance.memory.usedJSHeapSize > 80 * 1048576) { + indicators.push('💾' + (performance.memory.usedJSHeapSize / 1048576).toFixed(0) + 'MB'); + } + } catch (e) {} + try { + const md = this.fs.readDir('_mods'); + if (md && md.content) { const e = Object.keys(md.content).filter(k => k.endsWith('.meta.json')); if (e.length > 0) indicators.push('⚡' + e.length); } + } catch (e) {} + const prefix = indicators.length ? indicators.join(' ') + ' ' : ''; + label.textContent = prefix + (path === '/' ? '$' : path + ' $'); + } + print(text, type = '') { + const line = document.createElement('div'); + line.className = `output-line ${type}`; + line.textContent = text; + this.outputContainer.appendChild(line); + this._historyBuffer.push({ l: text, t: type }); + if (this._historyBuffer.length > this._historyMaxLines) { + this._historyBuffer.shift(); + } + this._saveHistory(); + this.scrollToBottom(); + this._trimOutput(); + } + printAIResponse(text) { + const wrapper = document.createElement('div'); + wrapper.className = 'output-line ollama'; + const safeText = text.replace(/</g, "<").replace(/>/g, ">"); + wrapper.innerHTML = ` + <button class="copy-btn" onclick="webx11.copyToClipboard(this)">Copy</button> + <div class="ai-content">${safeText}</div> + `; + this.outputContainer.appendChild(wrapper); + this.scrollToBottom(); + this._trimOutput(); + } + printStreamingResponse(text, append) { + if (!append) { + const safeText = text.replace(/</g, "<").replace(/>/g, ">"); + const wrapper = document.createElement('div'); + wrapper.className = 'output-line ollama'; + wrapper.innerHTML = ` + <button class="copy-btn" onclick="webx11.copyToClipboard(this)">Copy</button> + <div class="ai-content">${safeText}</div> + `; + this.outputContainer.appendChild(wrapper); + } else { + const contentDiv = this.outputContainer.querySelector('.output-line.ollama:last-child .ai-content'); + if (contentDiv) { + const safeText = text.replace(/</g, "<").replace(/>/g, ">"); + contentDiv.textContent += safeText; + } + } + this.scrollToBottom(); + this._trimOutput(); + } + copyToClipboard(btn) { + const contentDiv = btn.nextElementSibling; + const text = contentDiv.innerText; + navigator.clipboard.writeText(text).then(() => { + const originalText = btn.textContent; + btn.textContent = 'Copied!'; + setTimeout(() => { btn.textContent = originalText; }, 2000); + }).catch(err => { console.error('Failed to copy: ', err); }); + } + printClickable(items) { + const line = document.createElement('div'); + line.className = 'output-line'; + items.forEach(f => { + const span = document.createElement('span'); + const safeName = f.name.replace(/</g, '<').replace(/>/g, '>'); + if (f.type === 'dir') { + span.className = 'clickable-dir'; + span.textContent = '[dir]' + f.name + '/'; + span.onclick = () => { + this.executeCommand('cd ' + f.name); + setTimeout(() => this.executeCommand('ls'), 50); + }; + } else { + span.className = 'clickable-file'; + span.textContent = ' ' + f.name; + span.onclick = () => this.executeCommand('cat ' + f.name); + } + line.appendChild(span); + }); + this.outputContainer.appendChild(line); + this.scrollToBottom(); + } + printHTML(html) { + const line = document.createElement('div'); + line.className = 'output-line'; + line.innerHTML = html; + this.outputContainer.appendChild(line); + this.scrollToBottom(); + } + _loadHistory() { + try { + const saved = localStorage.getItem('webx11_terminal_output'); + if (!saved) return; + const entries = JSON.parse(saved); + if (!Array.isArray(entries)) return; + this._historyBuffer = entries.slice(-this._historyMaxLines); + // Check localStorage quota warning + let quotaWarned = false; + try { + const testKey = '_quota_test_' + Date.now(); + localStorage.setItem(testKey, 'x'.repeat(1024)); + localStorage.removeItem(testKey); + } catch (e) { + quotaWarned = true; + } + // Lazy-load history: render first 20 immediately, batch the rest + const batchSize = 20; + const toRender = entries.slice(-this._historyMaxLines); + const initial = toRender.slice(0, batchSize); + for (const entry of initial) { + const line = document.createElement('div'); + line.className = `output-line ${entry.t || ''}`; + line.textContent = entry.l || ''; + this.outputContainer.appendChild(line); + } + if (toRender.length > batchSize) { + const remaining = toRender.slice(batchSize); + const doBatch = (index) => { + if (index >= remaining.length) { + this.scrollToBottom(); + return; + } + requestAnimationFrame(() => { + const end = Math.min(index + batchSize, remaining.length); + for (let i = index; i < end; i++) { + const line = document.createElement('div'); + line.className = `output-line ${remaining[i].t || ''}`; + line.textContent = remaining[i].l || ''; + this.outputContainer.appendChild(line); + } + doBatch(end); + }); + }; + doBatch(0); + } else { + this.scrollToBottom(); + } + if (quotaWarned) { + const warnLine = document.createElement('div'); + warnLine.className = 'output-line warning'; + warnLine.textContent = '⚠ localStorage is near quota — history may be incomplete'; + this.outputContainer.appendChild(warnLine); + } + } catch (e) { + // Silently ignore corrupt history data + } + } + _saveHistory() { + try { + const slice = this._historyBuffer.slice(-30); + localStorage.setItem('webx11_terminal_output', JSON.stringify(slice)); + } catch (e) { + // Silently ignore storage errors (e.g. quota exceeded) + } + } + _saveCommandHistory() { + try { + localStorage.setItem('webx11_command_history', JSON.stringify(this.history)); + } catch (e) { + // Silently ignore storage errors + } + } + _trimOutput() { + const container = this.outputContainer; + while (container.children.length > this.MAX_OUTPUT_LINES) { + container.removeChild(container.firstChild); + } + } + markDirty() { + this._dirty = true; + } + _initAutoSave() { + if (this._autoSaveTimer) return; + this._autoSaveTimer = setInterval(() => { + // Auto-trim on memory pressure + try { + if (performance.memory && performance.memory.usedJSHeapSize > 80 * 1048576) { + const oldMax = this.MAX_OUTPUT_LINES; + this.MAX_OUTPUT_LINES = performance.memory.usedJSHeapSize > 120 * 1048576 ? 50 : 100; + this._trimOutput(); + this.MAX_OUTPUT_LINES = oldMax; + this.print('🧹 Terminal trimmed (memory: ' + (performance.memory.usedJSHeapSize / 1048576).toFixed(0) + ' MB)', 'text-muted'); + } + } catch (e) {} + if (!this._dirty) return; + this._autoSaveSession(); + this._dirty = false; + }, 180000); + } + _autoSaveSession() { + try { + this.fs.mkdirRecursive('_sessions'); + const snapshot = { + type: 'session', + savedAt: new Date().toISOString(), + fsRoot: this.fs.root, + currentPath: this.fs.currentPath, + version: 1, + terminalLines: this._historyBuffer.slice(-50) + }; + this.fs.writeFile('_sessions/_auto.json', JSON.stringify(snapshot, null, 2)); + // Also persist AI context concurrently + try { + const ctx = localStorage.getItem('webx11_ai_context'); + if (ctx) this.fs.writeFile('_sessions/_auto-context.json', ctx); + } catch (e) {} + } catch (e) { + // Silently handle auto-save failures + } + } + _autoRestoreSession() { + try { + this.fs.mkdirRecursive('_sessions'); + const autoSession = this.fs.readFile('_sessions/_auto.json'); + if (autoSession) { + const data = JSON.parse(autoSession); + if (data && data.fsRoot) { + this.fs.root = data.fsRoot; + this.fs.currentPath = data.currentPath || []; + this.fs.saveToStorage(); + this.print('↻ Auto-restored session from ' + (data.savedAt ? new Date(data.savedAt).toLocaleTimeString() : 'recent'), 'success'); + // Defer AI context restoration (AI module loads after terminal init) + try { + const aiCtx = this.fs.readFile('_sessions/_auto-context.json'); + if (aiCtx) { + localStorage.setItem('webx11_ai_context', aiCtx); + } + } catch (e) {} + } + } + } catch (e) { + // Silent on first boot — no auto-session yet + } + } + scrollToBottom() { + this.outputContainer.scrollTop = this.outputContainer.scrollHeight; + } + showAutocomplete() { + const input = this.inputField.value; + const trimmed = input.trim(); + const commands = this.moduleManager.getAllCommands(); + this.suggestions = []; + this.selectedSuggestion = -1; + this._isCdAutocomplete = false; + + if (trimmed.length === 0) { + this.hideAutocomplete(); + return; + } + + const parts = input.split(' '); + const cmdPart = parts[0].toLowerCase(); + const lowerInput = input.toLowerCase(); + + // Commands that take file/dir arguments for autocomplete + const fileArgCommands = ['cat', 'preview', 'rm', 'rmdir', 'edit', 'view']; + const dirArgCommands = ['ls', 'cd']; + // Session commands that autocomplete from _sessions/*.json + const sessionArgCommands = ['session-load', 'session-delete', 'session-export', 'session-save']; + + // If we have more than one word, we're past the command name — autocomplete file/dir/session names + if (parts.length > 1 && parts[1].length > 0) { + const partial = parts.slice(1).join(' '); + const partialTrimmed = partial.trim(); + + if (partialTrimmed === '..') { + this.hideAutocomplete(); + return; + } + + // Determine if we should show session names, dirs only, or both + if (sessionArgCommands.includes(cmdPart)) { + this.fs.mkdirRecursive('_sessions'); + const all = this.fs.ls('_sessions'); + this.suggestions = all + .filter(e => e.type === 'file' && e.name.endsWith('.json')) + .map(e => e.name.replace('.json', '')) + .filter(n => n.startsWith(partialTrimmed)); + } else if (cmdPart === 'cd' || cmdPart === 'rmdir' || dirArgCommands.includes(cmdPart)) { + // Dir-only commands + const entries = this.fs.ls().filter(f => f.type === 'dir'); + this.suggestions = entries + .filter(e => e.name.startsWith(partialTrimmed)) + .map(e => e.name + '/'); + } else if (fileArgCommands.includes(cmdPart)) { + const all = this.fs.ls(); + this.suggestions = all + .filter(e => e.name.startsWith(partialTrimmed)) + .map(e => e.type === 'dir' ? e.name + '/' : e.name); + } else { + this.hideAutocomplete(); + return; + } + + this._autocompletePrefix = cmdPart + ' '; + if (this.suggestions.length > 0) { + this._isCdAutocomplete = (cmdPart === 'cd' || cmdPart === 'rmdir'); + this.renderAutocomplete(); + } else { + this.hideAutocomplete(); + } + return; + } + + // Just typed a space after command — show all matching entries + if (parts.length > 1) { + if (sessionArgCommands.includes(cmdPart)) { + this.fs.mkdirRecursive('_sessions'); + const all = this.fs.ls('_sessions'); + this.suggestions = all + .filter(e => e.type === 'file' && e.name.endsWith('.json')) + .map(e => e.name.replace('.json', '')); + } else if (cmdPart === 'cd' || cmdPart === 'rmdir' || dirArgCommands.includes(cmdPart)) { + const dirs = this.fs.ls().filter(f => f.type === 'dir'); + this.suggestions = dirs.map(d => d.name + '/'); + } else if (fileArgCommands.includes(cmdPart)) { + const all = this.fs.ls(); + this.suggestions = all.map(e => e.type === 'dir' ? e.name + '/' : e.name); + } else { + this.hideAutocomplete(); + return; + } + this._autocompletePrefix = cmdPart + ' '; + if (this.suggestions.length > 0) { + this._isCdAutocomplete = (cmdPart === 'cd' || cmdPart === 'rmdir'); + this.renderAutocomplete(); + } else { + this.hideAutocomplete(); + } + return; + } + + // Directory autocomplete for bare 'cd ' (no partial yet) — should be unreachable due to above block + if (lowerInput.startsWith('cd ')) { + const dirs = this.fs.ls().filter(f => f.type === 'dir'); + this.suggestions = dirs.map(d => d.name + '/'); + if (this.suggestions.length > 0) { + this._isCdAutocomplete = true; + this.renderAutocomplete(); + } else { + this.hideAutocomplete(); + } + return; + } + + // Command name autocomplete (first word only) — also clears any stale prefix + this._autocompletePrefix = ''; + for (const cmd in commands) { + if (cmd.startsWith(cmdPart) && cmd !== cmdPart) { + this.suggestions.push(cmd); + } + } + + if (this.suggestions.length > 0) { + this.renderAutocomplete(); + } else { + this.hideAutocomplete(); + } + } + renderAutocomplete() { + this.dropdown.innerHTML = ''; + this.suggestions.forEach((item, index) => { + const div = document.createElement('div'); + div.className = `autocomplete-item ${index === this.selectedSuggestion ? 'selected' : ''}`; + div.innerHTML = this._isCdAutocomplete + ? `<span class="cmd-name" style="color:var(--accent-color)">${item}</span>` + : `<span class="cmd-name">${item}</span>`; + div.onclick = () => { + if (this._autocompletePrefix) { + this.inputField.value = this._autocompletePrefix + item; + } else if (this._isCdAutocomplete) { + this.inputField.value = 'cd ' + item; + } else { + this.inputField.value = item + ' '; + } + this.hideAutocomplete(); + this.inputField.focus(); + }; + this.dropdown.appendChild(div); + }); + this.dropdown.style.display = 'block'; + } + updateAutocompleteDisplay() { + const items = this.dropdown.querySelectorAll('.autocomplete-item'); + items.forEach((item, index) => { + item.classList.toggle('selected', index === this.selectedSuggestion); + }); + if (this.selectedSuggestion >= 0 && this.suggestions[this.selectedSuggestion]) { + if (this._autocompletePrefix) { + this.inputField.value = this._autocompletePrefix + this.suggestions[this.selectedSuggestion]; + } else if (this._isCdAutocomplete) { + this.inputField.value = 'cd ' + this.suggestions[this.selectedSuggestion]; + } else { + this.inputField.value = this.suggestions[this.selectedSuggestion] + ' '; + } + } + } + hideAutocomplete() { + this.dropdown.style.display = 'none'; + this.suggestions = []; + this.selectedSuggestion = -1; + this._isCdAutocomplete = false; + this._autocompletePrefix = ''; + } + loadBuiltInModules() { + this._safeRegister('SystemModule', 'system'); + this._safeRegister('FileSystemModule', 'filesystem'); + this._safeRegister('ModuleManagerModule', 'module manager'); + this._safeRegister('OllamaModule', 'ollama'); + this._safeRegister('SearXNGModule', 'searxng search'); + this._safeRegister('ConfigManagerModule', 'config'); + this._safeRegister('WebX11AIAssistantModule', 'AI assistant'); + this._safeRegister('WebFetcherModule', 'web fetcher'); + this._safeRegister('WebXAIAliasModule', 'AI alias'); + this._safeRegister('SkillsModule', 'skills'); + this._safeRegister('SecretVault', 'secret vault'); + this.reloadPersistedModules(); + this._safeRegister('ChatStudioModule', 'chat studio'); + } + _safeRegister(className, label) { + let cls; + try { + cls = eval(className); + } catch (e) { + console.log(`[SafeRegister] Skipped ${label}: class not loaded (skipped in boot selection)`); + return; + } + if (typeof cls === 'undefined') { + console.log(`[SafeRegister] Skipped ${label}: class not defined`); + return; + } + try { this.moduleManager.registerModule(new cls()); } + catch (e) { console.warn(`[SafeRegister] Skipped ${label}: ${e.message}`); } + } + async _loadExtModules(filterKeys) { + const autoLoadKeys = filterKeys || ['calc', 'voice', 'cron', 'theme', 'wpm', 'mindmap', 'webdav', 'skill-pack-core', 'editor', 'diff', 'alias', 'finder']; + try { + const res = await fetch('packages.json'); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const index = await res.json(); + for (const key of autoLoadKeys) { + const pkg = index.packages[key]; + if (pkg) { + await this.moduleManager.loadModuleFromURL(pkg.url); + } + } + } catch (err) { + this.print(`Failed to auto-load ext modules: ${err.message}`, 'error'); + } + } + async reloadPersistedModules() { + const registry = this.moduleManager.moduleRegistry; + const skipList = ['calc', 'voice', 'cron', 'theme', 'wpm', 'mindmap', 'webdav', 'skill-pack-core', 'editor', 'diff', 'alias', 'finder']; + for (const mod of registry) { + if (mod.enabled && mod.url) { + // Skip ext modules — they load via _loadExtModules + const name = (mod.url.match(/(\w+)\.js/) || [])[1]; + if (name && skipList.includes(name)) continue; + await this.moduleManager.loadModuleFromURL(mod.url); + } + } + } + async executeCommand(rawInput) { + if (!rawInput.trim()) return; + this.markDirty(); + this.print(`webx11@module:~$ ${rawInput}`, 'command-prompt'); + const pipeParts = this.splitPipe(rawInput.trim()); + if (pipeParts.length > 1) { + await this.executePipeChain(pipeParts); + return; + } + const parts = rawInput.trim().split(/\s+/); + const cmd = parts[0].toLowerCase(); + const args = parts.slice(1); + if (cmd === 'startup-modules') { + this.openStartupModules(); + return; + } + if (cmd === 'font-size' || cmd === 'setfont') { + this.setFontSize(args[0]); + return; + } + if (cmd === 'load-local' || cmd === 'loadlocal') { + this.loadLocalModule(args[0]); + return; + } + if (cmd === 'smart-prompt') { + const val = args[0] && args[0] === 'off' ? 'off' : 'on'; + localStorage.setItem('webx11_smart_prompt', val); + this.print('Smart prompt ' + val, 'success'); + this.updatePrompt(); + return; + } + if (cmd === 'mem') { + this.printMemoryStats(); + return; + } + if (cmd === 'clear') { + this.outputContainer.innerHTML = ''; + this._historyBuffer = []; + localStorage.removeItem('webx11_terminal_output'); + return; + } + let executed = false; + for (const modName in this.moduleManager.modules) { + const mod = this.moduleManager.modules[modName]; + if (mod.commands && mod.commands[cmd]) { + try { + await mod.commands[cmd](args); + executed = true; + } catch (err) { + this.print(`Error in ${modName}.${cmd}: ${err.message}`, 'error'); + } + break; + } + } + if (!executed) { + // Route unknown commands to the AI agent + try { + const aiMod = this.moduleManager.modules['WebX11AIAssistantModule']; + if (aiMod) { + this.print('→', 'info'); + await aiMod.chat([rawInput.trim()]); + return; + } + } catch (e) { + this.print(`Agent error: ${e.message}`, 'error'); + return; + } + } + } + splitPipe(input) { + const parts = []; + let current = ''; + let inQuote = false; + for (const ch of input) { + if (ch === '"' || ch === "'") { inQuote = !inQuote; current += ch; } + else if (ch === '|' && !inQuote) { parts.push(current.trim()); current = ''; } + else { current += ch; } + } + parts.push(current.trim()); + return parts; + } + async executePipeChain(pipeParts) { + let pipeInput = null; + for (let i = 0; i < pipeParts.length; i++) { + const cmdText = pipeParts[i]; + const parts = cmdText.split(/\s+/); + const cmd = parts[0].toLowerCase(); + const args = parts.slice(1); + this._pipeInput = pipeInput; + if (i < pipeParts.length - 1) { + pipeInput = await this.capturePipeOutput(cmd, args); + } else { + await this.dispatchCommand(cmd, args); + } + } + this._pipeInput = null; + } + async capturePipeOutput(cmd, args) { + const originalPrint = this.print.bind(this); + let output = ''; + this.print = (text, type) => { output += text + '\n'; }; + await this.dispatchCommand(cmd, args); + this.print = originalPrint; + const trimmed = output.trim(); + if (trimmed.length > this._pipeBufferSize) { + return trimmed.slice(0, this._pipeBufferSize); + } + return trimmed; + } + async dispatchCommand(cmd, args) { + if (cmd === 'clear') { this.outputContainer.innerHTML = ''; this._historyBuffer = []; localStorage.removeItem('webx11_terminal_output'); return; } + for (const modName in this.moduleManager.modules) { + const mod = this.moduleManager.modules[modName]; + if (mod.commands && mod.commands[cmd]) { + try { await mod.commands[cmd](args); return; } + catch (err) { this.print(`Error in ${modName}.${cmd}: ${err.message}`, 'error'); return; } + } + } + // Route unknown commands to the AI agent + try { + const aiMod = this.moduleManager.modules['WebX11AIAssistantModule']; + if (aiMod) { + this.print('→ Routing to AI assistant...', 'info'); + await aiMod.chat([cmd + ' ' + args.join(' ')]); + return; + } + } catch (e) { + this.print(`Agent error: ${e.message}`, 'error'); + } + this.print(`Command not found: ${cmd}`, 'error'); + } + openFileBrowser() { + const modal = document.getElementById('file-browser'); + const content = document.getElementById('fb-content'); + content.innerHTML = ''; + const files = this.fs.ls(); + if (files.length === 0) { + content.innerHTML = '<div class="text-center" style="padding:20px; color:#666">Directory Empty</div>'; + } else { + files.forEach(f => { + const div = document.createElement('div'); + div.className = `file-item ${f.type === 'dir' ? 'dir' : ''}`; + div.innerHTML = `<span>${f.type === 'dir' ? '📁' : '📄'} ${f.name}</span> <span style="font-size:0.8em">${f.size}b</span>`; + div.onclick = () => { + if (f.type === 'file') { + const content = this.fs.readFile(f.name); + this.print(`--- ${f.name} ---`); + this.print(content); + this.print('--- End ---'); + this.closeFileBrowser(); + } + }; + content.appendChild(div); + }); + } + modal.style.display = 'flex'; + } + closeFileBrowser() { + document.getElementById('file-browser').style.display = 'none'; + this.inputField.focus(); + } + openModuleManager() { + const modal = document.getElementById('module-manager'); + this.renderModuleList(); + modal.style.display = 'flex'; + } + closeModuleManager() { + document.getElementById('module-manager').style.display = 'none'; + this.inputField.focus(); + } + renderModuleList() { + const list = document.getElementById('module-list'); + list.innerHTML = ''; + const modules = this.moduleManager.getModuleList(); + if (modules.length === 0) { + list.innerHTML = '<div class="text-center" style="padding:20px; color:#666">No modules loaded</div>'; + return; + } + modules.forEach(mod => { + const div = document.createElement('div'); + div.className = `module-item ${mod.loaded ? '' : 'disabled'}`; + const badge = mod.url ? '' : '<span class="module-badge builtin">Built-in</span>'; + div.innerHTML = ` + <div> + <strong>${mod.name}</strong>${badge} + <div style="font-size:0.8em; color:#666">${mod.url || 'Built-in'}</div> + </div> + ${mod.url ? `<button class="btn btn-danger" onclick="webx11.removeModule('${mod.name}')" style="padding:4px 8px;">✕</button>` : ''} + `; + list.appendChild(div); + }); + } + async loadModuleFromURL() { + const url = document.getElementById('module-url-input').value.trim(); + if (!url) { alert('Please enter a URL'); return; } + const success = await this.moduleManager.loadModuleFromURL(url); + if (success) { + document.getElementById('module-url-input').value = ''; + this.renderModuleList(); + } + } + removeModule(name) { + if (this.moduleManager.unregisterModule(name)) { + this.print(`Module '${name}' removed`, 'success'); + this.renderModuleList(); + } + } + clearAllModules() { + if (confirm('Remove all custom modules? Built-in modules will remain.')) { + this.moduleManager.clearAll(); + this.renderModuleList(); + this.print('All custom modules cleared', 'success'); + } + } + openOllamaManager() { + const modal = document.getElementById('ollama-manager'); + document.getElementById('ollama-endpoint').value = this.ollamaManager.endpoint; + this.updateOllamaStatus(); + this.renderOllamaModels(); + modal.style.display = 'flex'; + } + closeOllamaManager() { + document.getElementById('ollama-manager').style.display = 'none'; + this.inputField.focus(); + } + openPreview(args) { + let path = ''; + if (typeof args === 'string') path = args.trim(); + else if (Array.isArray(args)) path = args.join(' ').trim(); + if (!path) { + this.print('Usage: preview <file.html>', 'warning'); + return; + } + // Remove leading slash for VFS paths + const vfsPath = path.replace(/^\/*/, ''); + let content = this.fs.readFile(vfsPath); + if (content === null) content = this.fs.fsRead('/' + vfsPath); + if (content === null) { + this.print(`File not found: ${path}`, 'error'); + return; + } + // Use srcdoc instead of blob URL — works reliably on mobile browsers + const iframe = document.getElementById('preview-iframe'); + iframe.srcdoc = content; + document.getElementById('preview-title').textContent = `📄 ${path}`; + document.getElementById('preview-modal').style.display = 'flex'; + } + closePreview() { + document.getElementById('preview-modal').style.display = 'none'; + const iframe = document.getElementById('preview-iframe'); + iframe.srcdoc = ''; + iframe.src = 'about:blank'; + this.inputField.focus(); + } + openSkillsManager() { + const modal = document.getElementById('skills-manager'); + // Ensure /skills/ directory exists + const skillsMod = this.moduleManager.modules['SkillsModule']; + if (skillsMod && skillsMod.ensureSkillsDir) skillsMod.ensureSkillsDir(); + this.refreshSkillsList(); + modal.style.display = 'flex'; + } + closeSkillsManager() { + document.getElementById('skills-manager').style.display = 'none'; + this.inputField.focus(); + } + openConfigManager() { + document.getElementById('config-manager').style.display = 'flex'; + this.renderConfigList(); + } + closeConfigManager() { + document.getElementById('config-manager').style.display = 'none'; + this.inputField.focus(); + } + renderConfigList() { + const list = document.getElementById('config-list'); + if (!list) return; + const prefix = 'webx11_config_'; + const entries = []; + for (let i = 0; i < localStorage.length; i++) { + const key = localStorage.key(i); + if (key && key.startsWith(prefix)) { + entries.push({ key: key.replace(prefix, ''), value: localStorage.getItem(key) }); + } + } + if (entries.length === 0) { + list.innerHTML = '<div style="padding:20px; text-align:center; color:#666">No configuration saved.</div>'; + return; + } + list.innerHTML = entries.map(e => { + const displayVal = e.key.toLowerCase().includes('password') || e.key.toLowerCase().includes('token') || e.key.toLowerCase().includes('secret') + ? '****' : e.value; + return `<div class="module-item"> + <div><strong>${e.key}</strong> <span style="color:var(--text-muted)">${displayVal}</span></div> + <button class="btn btn-danger" onclick="webx11.deleteConfigFromUI('${e.key.replace(/'/g, "\\'")}')" style="padding:4px 8px; font-size:0.8em">✕</button> + </div>`; + }).join(''); + } + addConfigFromUI() { + const key = document.getElementById('config-key-input').value.trim(); + const value = document.getElementById('config-value-input').value.trim(); + if (!key || !value) { + this.print('Enter both key and value', 'warning'); + return; + } + localStorage.setItem('webx11_config_' + key, value); + document.getElementById('config-key-input').value = ''; + document.getElementById('config-value-input').value = ''; + this.print(`Config saved: ${key} = ${value}`, 'success'); + this.renderConfigList(); + } + deleteConfigFromUI(key) { + localStorage.removeItem('webx11_config_' + key); + this.print(`Config '${key}' deleted`, 'success'); + this.renderConfigList(); + } + exportConfigFromUI() { + const mod = this.moduleManager.modules['ConfigManagerModule']; + if (mod && mod.commands && mod.commands['config-export']) { + mod.commands['config-export'](); + } + } + resetConfigFromUI() { + const mod = this.moduleManager.modules['ConfigManagerModule']; + if (mod && mod.commands && mod.commands['config-reset']) { + mod.commands['config-reset'](); + this.renderConfigList(); + } + } + refreshSkillsList() { + const list = document.getElementById('skills-list'); + list.innerHTML = ''; + const fs = this.fs; + fs.mkdirRecursive('/skills'); + const index = fs.fsReadJSON('/skills/index.json') || {}; + const names = Object.keys(index); + if (names.length === 0) { + list.innerHTML = '<div style="padding:20px; text-align:center; color:#666">No skills yet. Create one below!</div>'; + return; + } + names.forEach(name => { + const s = index[name]; + const tags = (s.tags || []).join(', '); + const div = document.createElement('div'); + div.className = 'module-item'; + div.innerHTML = ` + <div> + <strong style="color:var(--success-color)">${name}</strong> + <div style="font-size:0.85em; color:#aaa">${s.description || ''}</div> + <div style="font-size:0.75em; color:#666">${s.category || 'general'}${tags ? ' · ' + tags : ''}</div> + </div> + <div style="display:flex;gap:4px"> + <button class="btn" onclick="webx11.viewSkillUI('${name}')" style="padding:4px 8px; font-size:0.8em">👁</button> + <button class="btn btn-danger" onclick="webx11.deleteSkillUI('${name}')" style="padding:4px 8px; font-size:0.8em">✕</button> + </div> + `; + list.appendChild(div); + }); + } + viewSkillUI(name) { + const fs = this.fs; + const content = fs.fsRead(`/skills/${name}.md`); + if (!content) { this.print(`Skill '${name}' not found`, 'error'); return; } + document.getElementById('skill-preview-title').textContent = name; + document.getElementById('skill-preview-content').textContent = content; + document.getElementById('skill-preview').style.display = 'block'; + } + closeSkillPreview() { + document.getElementById('skill-preview').style.display = 'none'; + } + deleteSkillUI(name) { + if (!confirm(`Delete skill "${name}"?`)) return; + const fs = this.fs; + fs.mkdirRecursive('/skills'); + fs.fsWrite(`/skills/${name}.md`, ''); + const index = fs.fsReadJSON('/skills/index.json') || {}; + delete index[name]; + fs.fsWriteJSON('/skills/index.json', index); + this.print(`Skill '${name}' deleted`, 'success'); + this.refreshSkillsList(); + } + createSkillFromUI() { + const name = document.getElementById('skill-name-input').value.trim().replace(/\s+/g, '-').toLowerCase(); + const description = document.getElementById('skill-desc-input').value.trim() || 'No description'; + const tags = document.getElementById('skill-tags-input').value.trim().split(',').map(t => t.trim()).filter(Boolean); + const contentInput = document.getElementById('skill-content-input').value.trim(); + if (!name) { alert('Skill name is required'); return; } + const fs = this.fs; + fs.mkdirRecursive('/skills'); + if (fs.fsRead(`/skills/${name}.md`)) { + alert(`Skill '${name}' already exists. Delete it first or use skill-patch command.`); + return; + } + let content = contentInput || `---\nname: ${name}\ndescription: ${description}\ntags: [${tags.join(', ')}]\ncategory: general\n---\n## Steps\n1. (to be filled)\n`; + if (!content.startsWith('---')) { + content = `---\nname: ${name}\ndescription: ${description}\ntags: [${tags.join(', ')}]\ncategory: general\n---\n${content}`; + } + fs.fsWrite(`/skills/${name}.md`, content); + const index = fs.fsReadJSON('/skills/index.json') || {}; + index[name] = { name, description, category: 'general', tags }; + fs.fsWriteJSON('/skills/index.json', index); + this.print(`Skill '${name}' created`, 'success'); + document.getElementById('skill-name-input').value = ''; + document.getElementById('skill-desc-input').value = ''; + document.getElementById('skill-tags-input').value = ''; + document.getElementById('skill-content-input').value = ''; + this.refreshSkillsList(); + } + updateOllamaStatus() { + const statusDiv = document.getElementById('ollama-status'); + const statusText = document.getElementById('ollama-status-text'); + if (this.ollamaManager.connected) { + statusDiv.className = 'ollama-status connected'; + statusText.textContent = `Connected (${this.ollamaManager.models.length} models)`; + } else { + statusDiv.className = 'ollama-status disconnected'; + statusText.textContent = 'Disconnected'; + } + } + renderOllamaModels() { + const container = document.getElementById('ollama-models'); + const select = document.getElementById('ollama-current-model'); + container.innerHTML = ''; + select.innerHTML = '<option value="">Select a model</option>'; + const models = this.ollamaManager.getModels(); + if (models.length === 0) { + container.innerHTML = '<div class="text-center" style="padding:20px; color:#666">Connect to see models</div>'; + return; + } + models.forEach(model => { + const div = document.createElement('div'); + div.className = 'model-item'; + div.innerHTML = `<span>${model.name}</span><span style="font-size:0.8em; color:#666">${model.size ? (model.size / 1e9).toFixed(1) + 'GB' : ''}</span>`; + div.onclick = () => { + this.ollamaManager.setModel(model.name); + select.value = model.name; + }; + container.appendChild(div); + const option = document.createElement('option'); + option.value = model.name; + option.textContent = model.name; + if (model.name === this.ollamaManager.currentModel) { option.selected = true; } + select.appendChild(option); + }); + } + async connectOllama() { + const endpoint = document.getElementById('ollama-endpoint').value.trim(); + this.ollamaManager.setEndpoint(endpoint); + const success = await this.ollamaManager.connect(); + this.updateOllamaStatus(); + if (success) { this.renderOllamaModels(); } + } + setOllamaModel() { + const select = document.getElementById('ollama-current-model'); + this.ollamaManager.setModel(select.value); + } + openStartupModules() { + document.getElementById('startup-modules').style.display = 'flex'; + this.renderStartupModules(); + } + closeStartupModules() { + document.getElementById('startup-modules').style.display = 'none'; + } + renderStartupModules() { + const body = document.getElementById('startup-modules-body'); + const selected = (() => { + try { + const s = localStorage.getItem('webx11_startup_modules'); + return s ? JSON.parse(s) : null; + } catch (e) { return null; } + })(); + const isSel = (name) => !selected || selected.includes(name); + body.innerHTML = ''; + + const foundation = ['virtual-fs.js','module-manager.js','ollama-manager.js','terminal.js']; + const builtin = [ + 'module-system.js','module-fs.js','module-modmgr.js','module-ollama.js', + 'module-my.js','module-searxng.js','module-servermon.js','module-ollamamodels.js', + 'module-config.js','module-simplegui.js','module-io.js','module-dynamicprompts.js', + 'module-aiassist.js','module-aiasm.js','module-smartctx.js','module-duckduckgo.js', + 'module-fetcher.js','module-skills.js','module-chatstudio.js' + ]; + const extFiles = [ + 'calc.js','voice.js','cron.js','theme.js','wpm.js','mindmap.js','skill-pack-core.js' + ]; + + const addSection = (title) => { + const h = document.createElement('div'); + h.className = 'mod-section-header'; + h.textContent = title; + body.appendChild(h); + }; + const addCard = (name, badge, isDisabled) => { + const card = document.createElement('div'); + card.className = 'mod-card' + (isDisabled ? ' disabled' : ''); + const label = document.createElement('label'); + const cb = document.createElement('input'); + cb.type = 'checkbox'; + cb.checked = isDisabled || isSel(name); + cb.disabled = isDisabled; + cb.dataset.name = name; + const nameSpan = document.createElement('span'); + nameSpan.className = 'mod-name'; + nameSpan.textContent = name; + const badgeSpan = document.createElement('span'); + badgeSpan.className = 'mod-badge' + (badge === 'foundation' ? ' foundation' : badge === 'ext' ? ' ext' : ''); + badgeSpan.textContent = badge; + label.appendChild(cb); + label.appendChild(nameSpan); + card.appendChild(label); + card.appendChild(badgeSpan); + body.appendChild(card); + }; + + addSection('Foundation (always loaded)'); + foundation.forEach(n => addCard(n, 'foundation', true)); + + addSection('Built-in modules'); + builtin.forEach(n => addCard(n, 'built-in', false)); + + addSection('Extensions'); + extFiles.forEach(n => addCard(n, 'ext', false)); + } + saveStartupModules() { + const cbs = document.querySelectorAll('#startup-modules-body input[type="checkbox"]:not(:disabled)'); + const selected = []; + cbs.forEach(cb => { + if (cb.checked) selected.push(cb.dataset.name); + }); + try { + localStorage.setItem('webx11_startup_modules', JSON.stringify(selected)); + } catch (e) { + this.print('Failed to save startup modules', 'error'); + return; + } + this.print('Startup modules saved, reloading...', 'success'); + setTimeout(() => location.reload(), 500); + } + openCodeEditor(name, url, code) { + const modal = document.getElementById('code-editor'); + const title = document.getElementById('code-editor-title'); + const info = document.getElementById('code-editor-info'); + const ta = document.getElementById('code-editor-textarea'); + if (!modal || !ta) return; + this._editingModule = { name: name, url: url }; + title.textContent = '📝 Edit: ' + name; + info.textContent = 'Module: ' + name + (url ? ' | Source: ' + url : ''); + ta.value = code; + modal.style.display = 'flex'; + } + closeCodeEditor() { + document.getElementById('code-editor').style.display = 'none'; + this._editingModule = null; + } + async loadLocalModule(path) { + if (!path) { + this.print('Usage: load-local <path>', 'warning'); + this.print('Loads a module from VFS (e.g. "load-local modules/ext/mindmap.js") or from URL', 'info'); + return; + } + let code = this.fs.readFile(path); + if (code === null) { + try { + const resp = await fetch(path); + if (!resp.ok) throw new Error('HTTP ' + resp.status); + code = await resp.text(); + } catch (e) { + this.print('load-local: file not found in VFS or at URL: ' + path, 'error'); + return; + } + } + try { + const el = document.createElement('script'); + el.textContent = code; + el.onerror = () => this.print('load-local: failed to execute ' + path, 'error'); + document.body.appendChild(el); + this.print('Module loaded from ' + path, 'success'); + } catch (e) { + this.print('load-local: error: ' + e.message, 'error'); + } + } + async saveCodeEditor() { + const ta = document.getElementById('code-editor-textarea'); + const mod = this._editingModule; + if (!mod || !ta) return; + const code = ta.value; + this.fs.writeFile('_mods/' + mod.name + '.js', code); + if (!this.fs.readFile('_mods/' + mod.name + '.orig.js')) { + this.fs.writeFile('_mods/' + mod.name + '.orig.js', 'placeholder'); + } + this.fs.writeFile('_mods/' + mod.name + '.meta.json', JSON.stringify({ + originalUrl: mod.url || '', + modifiedAt: new Date().toISOString(), editCount: 1 + })); + try { + await this.moduleManager.loadModuleFromCode(code, 'blob://_mods/' + mod.name + '.js'); + this.print('✅ Module loaded live from _mods/' + mod.name + '.js', 'success'); + } catch (e) { + this.print('⚠️ Auto-test failed: ' + e.message + ' — edits saved but module not loaded', 'error'); + } + this.closeCodeEditor(); + } + _restoreFontSize() { + try { + const saved = localStorage.getItem('webx11_font_size'); + if (saved) { + const s = parseInt(saved); + if (s >= 8 && s <= 32) document.documentElement.style.fontSize = s + 'px'; + } + } catch (e) {} + } + log(level, source, message) { + const d = new Date(); + const ts = d.toISOString().split('T')[1].split('.')[0]; + const entry = `[${ts}][${level}][${source}] ${message}`; + console.log(entry); + // Write to VFS log + try { + this.fs.mkdirRecursive('/logs'); + let current = this.fs.readFile('/logs/current.log') || ''; + current += entry + '\n'; + const lines = current.split('\n').filter(l => l); + if (lines.length > 200) { + this.fs.writeFile('/logs/old.log', lines.slice(0, -100).join('\n')); + this.fs.writeFile('/logs/current.log', lines.slice(-100).join('\n')); + } else { + this.fs.writeFile('/logs/current.log', current); + } + } catch (e) {} + } + setAiBusy(busy) { + this._aiBusy = !!busy; + if (this._aiBusyInterval) { + clearInterval(this._aiBusyInterval); + this._aiBusyInterval = null; + } + const cancelBtn = document.getElementById('ai-cancel-btn'); + if (busy) { + if (cancelBtn) cancelBtn.style.display = 'inline-block'; + this._aiBusyStart = Date.now(); + const updateTimer = () => { + let elapsed = Math.round((Date.now() - this._aiBusyStart) / 1000); + let sec = elapsed % 60; + let min = Math.floor(elapsed / 60); + this.statusBar.textContent = '🤖 AI Thinking... ⏱ ' + (min > 0 ? min + 'm ' : '') + sec + 's'; + this.statusBar.className = 'loading'; + }; + updateTimer(); + this._aiBusyInterval = setInterval(updateTimer, 1000); + } else { + if (cancelBtn) cancelBtn.style.display = 'none'; + this.statusBar.textContent = 'Ready'; + this.statusBar.className = ''; + this._aiBusyStart = 0; + } + } + printMemoryStats() { + const pm = typeof performance !== 'undefined' && performance.memory; + let heapInfo = ''; + if (pm) { + const used = (pm.usedJSHeapSize / 1048576).toFixed(1); + const total = (pm.totalJSHeapSize / 1048576).toFixed(1); + const limit = (pm.jsHeapSizeLimit / 1048576).toFixed(1); + heapInfo = `JS heap: ${used}MB / ${total}MB (limit ${limit}MB)`; + } else { + try { + const lsSize = (new Blob([JSON.stringify(localStorage)]).size / 1024).toFixed(1); + heapInfo = `localStorage: ${lsSize}KB`; + } catch (e) { + heapInfo = 'Memory API not available on this browser'; + } + } + const output = [ + `📊 Memory Stats`, + ` ${heapInfo}`, + ` Terminal lines in DOM: ${this.outputContainer.children.length}`, + ` History buffer: ${this._historyBuffer.length} entries`, + ` MAX_OUTPUT_LINES: ${this.MAX_OUTPUT_LINES}`, + ` Commands in history: ${this.history.length}` + ].join('\n'); + this.print(output, 'info'); + } + playNotificationSound() { + if (localStorage.getItem('webx11_config_ai_sound') === 'off') return; + try { + const ctx = new (window.AudioContext || window.webkitAudioContext)(); + const osc = ctx.createOscillator(); + const gain = ctx.createGain(); + osc.connect(gain); + gain.connect(ctx.destination); + osc.frequency.value = 880; + osc.type = 'sine'; + gain.gain.setValueAtTime(0.15, ctx.currentTime); + gain.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.3); + osc.start(ctx.currentTime); + osc.stop(ctx.currentTime + 0.3); + } catch (e) {} + } + toggleMicVisibility() { + const voiceMod = this.moduleManager.modules['VoiceModule']; + if (!voiceMod) { + this.print('Voice module not loaded', 'warning'); + return; + } + if (voiceMod.listening) { + voiceMod.stopListening(); + } else { + voiceMod.handle([]); + } + } + setFontSize(size) { + const s = parseInt(size); + if (isNaN(s) || s < 8 || s > 32) { + this.print('Usage: font-size <8-32>', 'warning'); + return; + } + document.documentElement.style.fontSize = s + 'px'; + try { localStorage.setItem('webx11_font_size', String(s)); } catch (e) {} + this.print('Font size set to ' + s + 'px', 'success'); + } + } diff --git a/modules/core/virtual-fs.js b/modules/core/virtual-fs.js new file mode 100644 index 0000000..a505699 --- /dev/null +++ b/modules/core/virtual-fs.js @@ -0,0 +1,320 @@ +class VirtualFileSystem { + constructor() { + this.root = { type: 'dir', content: {} }; + this.currentPath = []; + this.loadFromStorage(); + } + loadFromStorage() { + try { + const saved = localStorage.getItem('webx11_filesystem'); + if (saved) { + const data = JSON.parse(saved); + this.root = data.root || this.root; + this.currentPath = data.currentPath || []; + } + } catch (e) { console.error('FS load error:', e); } + } + saveToStorage() { + try { + localStorage.setItem('webx11_filesystem', JSON.stringify({ + root: this.root, currentPath: this.currentPath + })); + } catch (e) { console.error('FS save error:', e); } + } + getCurrentDir() { + let dir = this.root; + for (let part of this.currentPath) { + if (dir.content[part] && dir.content[part].type === 'dir') { + dir = dir.content[part]; + } else { return null; } + } + return dir; + } + _toAbsoluteSegments(path) { + if (!path || path === '.' || path === '') return [...this.currentPath]; + if (path === '/') return []; + const parts = path.split('/').filter(p => p && p !== '.'); + let base = path.startsWith('/') ? [] : [...this.currentPath]; + const result = [...base]; + for (const p of parts) { + if (p === '..') { + if (result.length > 0) result.pop(); + else return null; + } else { + result.push(p); + } + } + return result; + } + _resolvePath(path) { + const segs = this._toAbsoluteSegments(path); + if (segs === null) return null; + if (segs.length === 0) return { dir: this.root, name: null }; + let dir = this.root; + for (let i = 0; i < segs.length - 1; i++) { + if (!dir.content[segs[i]] || dir.content[segs[i]].type !== 'dir') return null; + dir = dir.content[segs[i]]; + } + return { dir, name: segs[segs.length - 1] }; + } + _resolveDir(path) { + const segs = this._toAbsoluteSegments(path); + if (segs === null) return null; + if (segs.length === 0) return this.root; + let dir = this.root; + for (const seg of segs) { + if (!dir.content[seg] || dir.content[seg].type !== 'dir') return null; + dir = dir.content[seg]; + } + return dir; + } + ls(path) { + let dir; + if (path) { + const resolved = this._resolvePath(path); + if (!resolved) return []; + if (resolved.name) { + const node = resolved.dir.content[resolved.name]; + if (node && node.type === 'dir') dir = node; + else return []; + } else { + dir = resolved.dir; + } + } else { + dir = this.getCurrentDir(); + } + if (!dir) return []; + return Object.keys(dir.content).map(name => ({ + name, type: dir.content[name].type, + size: dir.content[name].content ? dir.content[name].content.length : 0 + })); + } + writeFile(path, content) { + const resolved = this._resolvePath(path); + if (!resolved || !resolved.name) return false; + const name = resolved.name; + if (name === '.' || name === '..') return false; + if (resolved.dir.content[name] && resolved.dir.content[name].type === 'dir') return false; + resolved.dir.content[name] = { type: 'file', content: content, modified: new Date() }; + this.saveToStorage(); + return true; + } + readFile(path) { + const resolved = this._resolvePath(path); + if (!resolved || !resolved.name) return null; + const node = resolved.dir.content[resolved.name]; + if (node && node.type === 'file') return node.content; + return null; + } + deleteFile(path) { + const resolved = this._resolvePath(path); + if (!resolved || !resolved.name) return false; + const node = resolved.dir.content[resolved.name]; + if (node && node.type === 'file') { + delete resolved.dir.content[resolved.name]; + this.saveToStorage(); + return true; + } + return false; + } + mkdir(path) { + const resolved = this._resolvePath(path); + if (!resolved || !resolved.name) return false; + const name = resolved.name; + if (name === '.' || name === '..') return false; + if (resolved.dir.content[name]) return false; + resolved.dir.content[name] = { type: 'dir', content: {} }; + this.saveToStorage(); + return true; + } + mkdirRecursive(path) { + const segs = this._toAbsoluteSegments(path); + if (!segs || segs.length === 0) return true; + let dir = this.root; + for (const seg of segs) { + if (!dir.content[seg]) { + dir.content[seg] = { type: 'dir', content: {} }; + } else if (dir.content[seg].type !== 'dir') { + return false; + } + dir = dir.content[seg]; + } + this.saveToStorage(); + return true; + } + readDir(path) { + const resolved = this._resolvePath(path); + if (!resolved) return null; + if (resolved.name) { + const node = resolved.dir.content[resolved.name]; + return (node && node.type === 'dir') ? node : null; + } + return resolved.dir; + } + copyFile(srcPath, dstPath) { + const src = this._resolvePath(srcPath); + if (!src || !src.name) return false; + const srcNode = src.dir.content[src.name]; + if (!srcNode || srcNode.type !== 'file') return false; + const dst = this._resolvePath(dstPath); + if (!dst || !dst.name) return false; + const copy = JSON.parse(JSON.stringify(srcNode)); + dst.dir.content[dst.name] = copy; + this.saveToStorage(); + return true; + } + copyDir(srcPath, dstPath) { + const src = this._resolvePath(srcPath); + if (!src || !src.name) return false; + const srcNode = src.dir.content[src.name]; + if (!srcNode || srcNode.type !== 'dir') return false; + const dst = this._resolvePath(dstPath); + if (!dst || !dst.name) return false; + if (dst.dir.content[dst.name]) return false; + const copy = JSON.parse(JSON.stringify(srcNode)); + dst.dir.content[dst.name] = copy; + this.saveToStorage(); + return true; + } + moveFile(srcPath, dstPath) { + if (this.copyFile(srcPath, dstPath)) { + const src = this._resolvePath(srcPath); + if (src && src.name) { + delete src.dir.content[src.name]; + this.saveToStorage(); + return true; + } + } + return false; + } + moveDir(srcPath, dstPath) { + if (this.copyDir(srcPath, dstPath)) { + const src = this._resolvePath(srcPath); + if (src && src.name) { + delete src.dir.content[src.name]; + this.saveToStorage(); + return true; + } + } + return false; + } + // Old-compat aliases for agent tools and SkillsModule + fsRead(path) { + const content = this.readFile(path); + return content !== null ? content : null; + } + fsWrite(path, content) { + return this.writeFile(path, content); + } + fsReadJSON(path) { + const c = this.fsRead(path); + if (c === null) return null; + try { return JSON.parse(c); } catch (e) { return null; } + } + fsWriteJSON(path, obj) { + return this.fsWrite(path, JSON.stringify(obj)); + } + findFiles(pattern) { + const results = []; + const search = (node, path) => { + if (node.type === 'dir' && node.content) { + for (const k of Object.keys(node.content)) { + const sub = node.content[k]; + const subPath = path ? path + '/' + k : k; + if (sub.type === 'dir') search(sub, subPath); + else if (sub.type === 'file' && k.toLowerCase().includes(pattern)) results.push(subPath); + } + } + }; + search(this.root, ''); + return results.sort(); + } + deleteDirRecursive(path) { + const resolved = this._resolvePath(path); + if (!resolved || !resolved.name) return false; + const node = resolved.dir.content[resolved.name]; + if (!node || node.type !== 'dir') return false; + delete resolved.dir.content[resolved.name]; + this.saveToStorage(); + return true; + } + // Full state serialization for session save/restore + serializeState() { + return JSON.stringify({ + root: this.root, + currentPath: this.currentPath, + version: 1 + }); + } + restoreState(json) { + try { + const data = JSON.parse(json); + if (!data || !data.root || !data.root.type) return false; + this.root = data.root; + this.currentPath = data.currentPath || []; + this.saveToStorage(); + return true; + } catch (e) { + console.error('FS restore error:', e); + return false; + } + } + // Restore VFS from an exported dump file (replaces entire root) + restoreFromExport(content) { + if (typeof content === 'string') { + try { content = JSON.parse(content); } catch (e) { return false; } + } + if (!content || !content.type) return false; + this.root = content; + this.currentPath = []; + this.saveToStorage(); + return true; + } + _snapshotDirExists() { + return !!this.readDir('/_snapshots'); + } + checkpoint(name) { + if (!name) name = new Date().toISOString(); + const createdAt = new Date().toISOString(); + const data = { + version: 1, + createdAt, + root: JSON.parse(JSON.stringify(this.root)), + currentPath: [...this.currentPath] + }; + if (!this.readDir('/_snapshots')) { + this.mkdirRecursive('/_snapshots'); + } + this.fsWriteJSON('/_snapshots/checkpoint-' + name + '.json', data); + this.saveToStorage(); + // Auto-manage: keep max 10 checkpoints, delete oldest + const all = this.listCheckpoints(); + if (all.length > 10) { + const sorted = all.slice().sort(); + const toDelete = sorted.slice(0, all.length - 10); + for (const old of toDelete) this.deleteCheckpoint(old); + } + return true; + } + rollback(name) { + const raw = this.fsReadJSON('/_snapshots/checkpoint-' + name + '.json'); + if (!raw || !raw.root) return false; + this.root = raw.root; + this.currentPath = raw.currentPath || []; + this.saveToStorage(); + return true; + } + listCheckpoints() { + const dir = this.readDir('/_snapshots'); + if (!dir || !dir.content) return []; + const prefix = 'checkpoint-'; + const suffix = '.json'; + return Object.keys(dir.content) + .filter(k => k.startsWith(prefix) && k.endsWith(suffix)) + .map(k => k.slice(prefix.length, k.length - suffix.length)) + .sort(); + } + deleteCheckpoint(name) { + return this.deleteFile('/_snapshots/checkpoint-' + name + '.json'); + } +} |