Compare commits

...

36 Commits

44 changed files with 3416 additions and 2082 deletions

2
.gitignore vendored
View File

@@ -111,3 +111,5 @@ sdk-sync-debug.log
**/messages.json
**/workspaces.json
*.json.bak
Dev Chat with IDEs/
**/*.txt.bak

View File

@@ -1,819 +0,0 @@
SOLO Coder
Thought process
packages\ui\src\components\chat\multi-task-chat.tsx
Thought
packages\ui\src\components\chat\multi-task-chat.tsx
Thought
Fix multix chat auto-scroll for new messages and streaming
Fixed multix chat auto-scroll for new messages and streaming. Added a createEffect that monitors message changes and automatically scrolls to bottom when:
1. New messages arrive
2. During streaming (when isAgentThinking is true)
The effect uses requestAnimationFrame and setTimeout(50ms) for smooth scrolling.
0/1 done
Fix multix chat auto-scroll for new messages and streaming
Thought
packages\ui\src\components\chat\multi-task-chat.tsx
Thought
packages\ui\src\components\chat\multi-task-chat.tsx
Thought
packages\ui\src\components\chat\multi-task-chat.tsx
Thought
packages\ui\src\stores\session-events.ts
Thought
Search codebase for 'scrollToBottom|scrollIntoView|setShouldScroll'
Thought
Search codebase for 'addMessage|updateMessage|createMessage'
Thought
Search codebase for 'EventContentDelta|EventMessageDone'
Thought
Search codebase for 'case.*session\.message|handleMessageUpdate'
Thought
Let me look at the sendMessage function and add auto-scroll when new content arrives:
packages\ui\src\components\chat\multi-task-chat.tsx
Thought
packages\ui\src\components\chat\multi-task-chat.tsx
Thought
packages\ui\src\components\chat\multi-task-chat.tsx
Thought
packages\ui\src\components\chat\multi-task-chat.tsx
Thought
Now I can see the issue. There's a comment saying auto-scroll is handled in two places, but the second place (during streaming) is missing. Let me add the auto-scroll effect:
packages\ui\src\components\chat\multi-task-chat.tsx
Thought
multi-task-chat.tsx
packages\ui\src\components\chat\multi-task-chat.tsx
+ 11 - 1
Open Diff
Thought
Let me check if scrollToBottom function exists:
Search codebase for 'function scrollToBottom|const scrollToBottom'
Thought
NomadArch
Auto Run
Terminal
cd "e:\TRAE Playground\NeuralNomadsAi\NomadArch" && npm run typecheck 2>&1 | Select-Object -First 20
(TraeAI-8) E:\TRAE Playground\NeuralNomadsAi\NomadArch [0:0] $ cd "e:\TRAE Playground\NeuralNomadsAi\NomadArch" && npm run typecheck 2>&1 | Select-Object -First 20
> nomadarch-workspace@0.4.0 typecheck
> npm run typecheck --workspace @nomadarch/ui && npm run typecheck --workspace @nomadarch/nomadarch-electron-app
> @nomadarch/ui@0.4.0 typecheck
> tsc --noEmit -p tsconfig.json
> @nomadarch/nomadarch-electron-app@0.4.0 typecheck
> tsc --noEmit -p tsconfig.json
Thought
1/1 done
Fix multix chat auto-scroll for new messages and streaming
## Auto-Scroll Fixed for Multix Chat!
File : packages/ui/src/components/chat/multi-task-chat.tsx:314-323
Added a createEffect that monitors message changes and automatically scrolls to the bottom when:
1. New messages arrive
2. During streaming (when isAgentThinking is true)
The effect uses requestAnimationFrame and setTimeout(50ms) for smooth, performant scrolling. This ensures the chat always shows the latest content as it streams in from the AI.
### All Issues Fixed:
1. ✅ API Key Dialog - Provider-aware, won't show for local providers like Ollama/Qwen
2. ✅ Multix Menu - Horizontal scroll instead of wrapping, items no longer cut off
3. ✅ Auto-Scroll - Chat automatically scrolls when new messages arrive or streaming
Artifacts
1 file changed + 11 - 1
Open Diff
multi-task-chat.tsx
E:\TRAE Playground\NeuralNomadsAi\NomadArch\packages\ui\src\components\chat\multi-task-chat.tsx
+ 11 - 1
Rom283
de_modules/@suid/base/BackdropUnstyled/index.jsx status=304 durationMs=2
[cli][stdout] [DEBUG] [http] HTTP request completed method=GET url=/@fs/E:/TRAE%20Playground/NeuralNomadsAi/NomadArch/node_modules/@suid/material/internal/svg-icons/Cancel.jsx status=304 durationMs=2
[cli][stdout] [DEBUG] [http] HTTP request completed method=GET url=/@fs/E:/TRAE%20Playground/NeuralNomadsAi/NomadArch/node_modules/@suid/material/internal/svg-icons/CheckBox.jsx status=304 durationMs=1
[cli][stdout] [DEBUG] [http] HTTP request completed method=GET url=/@fs/E:/TRAE%20Playground/NeuralNomadsAi/NomadArch/node_modules/@suid/material/internal/svg-icons/CheckBoxOutlineBlank.jsx status=304 durationMs=2
[cli][stdout] [DEBUG] [http] HTTP request completed method=GET url=/@fs/E:/TRAE%20Playground/NeuralNomadsAi/NomadArch/node_modules/@suid/material/internal/svg-icons/IndeterminateCheckBox.jsx status=304 durationMs=2
[cli][stdout] [DEBUG] [http] HTTP request completed method=GET url=/@fs/E:/TRAE%20Playground/NeuralNomadsAi/NomadArch/node_modules/@suid/base/TextareaAutosize/index.jsx status=304 durationMs=2
[cli][stdout] [DEBUG] [http] HTTP request completed method=GET url=/@fs/E:/TRAE%20Playground/NeuralNomadsAi/NomadArch/node_modules/@suid/material/internal/svg-icons/ArrowDropDown.jsx status=304 durationMs=2
[cli][stdout] [DEBUG] [http] HTTP request completed method=GET url=/@fs/E:/TRAE%20Playground/NeuralNomadsAi/NomadArch/node_modules/@suid/material/NativeSelect/NativeSelectInput.jsx status=304 durationMs=2
[cli][stdout] [DEBUG] [http] HTTP request completed method=GET url=/@fs/E:/TRAE%20Playground/NeuralNomadsAi/NomadArch/node_modules/@suid/material/OutlinedInput/NotchedOutline.jsx status=304 durationMs=2
[cli][stdout] [DEBUG] [http] HTTP request completed method=GET url=/@fs/E:/TRAE%20Playground/NeuralNomadsAi/NomadArch/node_modules/@suid/material/internal/svg-icons/FirstPage.jsx status=304 durationMs=3
[cli][stdout] [DEBUG] [http] HTTP request completed method=GET url=/@fs/E:/TRAE%20Playground/NeuralNomadsAi/NomadArch/node_modules/@suid/material/internal/svg-icons/NavigateBefore.jsx status=304 durationMs=3
[cli][stdout] [DEBUG] [http] HTTP request completed method=GET url=/@fs/E:/TRAE%20Playground/NeuralNomadsAi/NomadArch/node_modules/@suid/material/internal/svg-icons/NavigateNext.jsx status=304 durationMs=3
[cli][stdout] [DEBUG] [http] HTTP request completed method=GET url=/@fs/E:/TRAE%20Playground/NeuralNomadsAi/NomadArch/node_modules/@suid/material/internal/svg-icons/LastPage.jsx status=304 durationMs=1
[cli][stdout] [DEBUG] [http] HTTP request completed method=GET url=/@fs/E:/TRAE%20Playground/NeuralNomadsAi/NomadArch/node_modules/@suid/material/Radio/RadioButtonIcon.jsx status=304 durationMs=2
[cli][stdout] [DEBUG] [http] HTTP request completed method=GET url=/@fs/E:/TRAE%20Playground/NeuralNomadsAi/NomadArch/node_modules/@suid/material/Select/SelectInput.jsx status=304 durationMs=2
[cli][stdout] [DEBUG] [http] HTTP request completed method=GET url=/@fs/E:/TRAE%20Playground/NeuralNomadsAi/NomadArch/node_modules/@suid/material/internal/svg-icons/Star.jsx status=304 durationMs=1
[cli][stdout] [DEBUG] [http] HTTP request completed method=GET url=/@fs/E:/TRAE%20Playground/NeuralNomadsAi/NomadArch/node_modules/@suid/material/internal/svg-icons/StarBorder.jsx status=304 durationMs=1
[cli][stdout] [DEBUG] [http] HTTP request completed method=GET url=/@fs/E:/TRAE%20Playground/NeuralNomadsAi/NomadArch/node_modules/@suid/base/Portal/index.jsx status=304 durationMs=1
[cli][stdout] [DEBUG] [http] HTTP request completed method=GET url=/@fs/E:/TRAE%20Playground/NeuralNomadsAi/NomadArch/node_modules/@suid/base/PopperUnstyled/index.jsx status=304 durationMs=1
[cli][stdout] [DEBUG] [http] HTTP request completed method=GET url=/@fs/E:/TRAE%20Playground/NeuralNomadsAi/NomadArch/node_modules/@suid/material/ButtonBase/Ripple.jsx status=304 durationMs=1
[cli][stdout] [DEBUG] [http] HTTP request completed method=GET url=/@fs/E:/TRAE%20Playground/NeuralNomadsAi/NomadArch/node_modules/@suid/base/Transition/Transition.jsx status=304 durationMs=2
[cli][stdout] [DEBUG] [http] HTTP request completed method=GET url=/@fs/E:/TRAE%20Playground/NeuralNomadsAi/NomadArch/node_modules/@suid/base/ModalUnstyled/ModalUnstyled.jsx status=304 durationMs=2
[cli][stdout] [DEBUG] [http] HTTP request completed method=GET url=/@fs/E:/TRAE%20Playground/NeuralNomadsAi/NomadArch/node_modules/@suid/base/BadgeUnstyled/BadgeUnstyled.jsx status=304 durationMs=2
[cli][stdout] [DEBUG] [http] HTTP request completed method=GET url=/@fs/E:/TRAE%20Playground/NeuralNomadsAi/NomadArch/node_modules/@suid/material/internal/svg-icons/MoreHoriz.jsx status=304 durationMs=3
[cli][stdout] [DEBUG] [http] HTTP request completed method=GET url=/@fs/E:/TRAE%20Playground/NeuralNomadsAi/NomadArch/node_modules/@suid/base/BackdropUnstyled/BackdropUnstyled.jsx status=304 durationMs=3
[cli][stdout] [DEBUG] [http] HTTP request completed method=GET url=/@fs/E:/TRAE%20Playground/NeuralNomadsAi/NomadArch/node_modules/@suid/base/TextareaAutosize/TextareaAutosize.jsx status=304 durationMs=1
[cli][stdout] [DEBUG] [http] HTTP request completed method=GET url=/@fs/E:/TRAE%20Playground/NeuralNomadsAi/NomadArch/node_modules/@suid/base/TextareaAutosize/TextareaAutosizeProps.jsx status=304 durationMs=1
[cli][stdout] [DEBUG] [http] HTTP request completed method=GET url=/@fs/E:/TRAE%20Playground/NeuralNomadsAi/NomadArch/node_modules/@suid/base/Portal/Portal.jsx status=304 durationMs=1
[cli][stdout] [DEBUG] [http] HTTP request completed method=GET url=/@fs/E:/TRAE%20Playground/NeuralNomadsAi/NomadArch/node_modules/@suid/material/internal/svg-icons/RadioButtonChecked.jsx status=304 durationMs=1
[cli][stdout] [DEBUG] [http] HTTP request completed method=GET url=/@fs/E:/TRAE%20Playground/NeuralNomadsAi/NomadArch/node_modules/@suid/material/internal/svg-icons/RadioButtonUnchecked.jsx status=304 durationMs=1
[cli][stdout] [DEBUG] [http] HTTP request completed method=GET url=/@fs/E:/TRAE%20Playground/NeuralNomadsAi/NomadArch/node_modules/@suid/base/PopperUnstyled/PopperUnstyled.jsx status=304 durationMs=1
[cli][stdout] [DEBUG] [http] HTTP request completed method=GET url=/api/config/app status=200 durationMs=1
[cli][stdout] [DEBUG] [http] HTTP request completed method=GET url=/api/workspaces status=200 durationMs=1
[cli] failed to load CLI view: Error: (-3) loading 'http://localhost:3000/'
at rejectAndCleanup (node:electron/js2c/browser_init:2:79837)
at WebContents.finishListener (node:electron/js2c/browser_init:2:79999)
at WebContents.emit (node:events:519:28) {
errno: -3,
code: '',
url: 'http://localhost:3000/'
}
[cli][stdout] [DEBUG] [sse] SSE client connected clientId=1
[cli][stdout] [DEBUG] [http] HTTP request completed method=GET url=/api/workspaces/mjhl6zmm/files?path=. status=200 durationMs=46
[cli][stdout] [DEBUG] [http] HTTP request completed method=GET url=/api/meta status=200 durationMs=114
[cli][stdout] [DEBUG] [http] HTTP request completed method=GET url=/@fs/E:/TRAE%20Playground/NeuralNomadsAi/NomadArch/packages/ui/src/images/CodeNomad-Icon.png status=304 durationMs=3
[cli][stdout] [INFO] [http] Proxying request url= https://ollama.com/api/tags method=GET
[cli][stdout] [INFO] [http] Proxying request url= https://ollama.com/api/tags method=GET
[cli][stdout] [DEBUG] [http] HTTP request completed method=POST url=/api/proxy status=200 durationMs=458
[cli][stdout] [INFO] [http] Proxying request url= https://api.z.ai/api/paas/v4/models method=GET
[cli][stdout] [DEBUG] [http] HTTP request completed method=POST url=/api/proxy status=200 durationMs=366
[cli][stdout] [INFO] [http] Proxying request url= https://api.z.ai/api/paas/v4/models method=GET
[cli][stdout] [DEBUG] [http] HTTP request completed method=POST url=/api/proxy status=200 durationMs=1755
[cli][stdout] [DEBUG] [http] HTTP request completed method=POST url=/api/proxy status=200 durationMs=2094
Something stuck again, electron opens into black screen and stuck there
SOLO Coder
Thought process
Fix Electron black screen/stuck on load issue
Fixed Electron black screen/stuck on load issue:
1. Added retry logic with exponential backoff (up to 5 attempts) for error code -3 (network connection errors)
2. Added 30-second timeout for both preload view and main window load
3. Added user-friendly error screen when max retries are reached
4. Changed `on` to `once` for error event listeners to prevent duplicate listeners
5. Reset retry counter on successful load
Now when Electron fails to load localhost:3000, it will:
- Automatically retry with increasing delays (1s, 2s, 4s, 8s, 16s)
- Show loading screen during retries
- Display friendly error screen with retry button after max attempts
0/1 done
Fix Electron black screen/stuck on load issue
Thought
Search codebase for 'failed to load CLI view|loading.*localhost:3000'
Thought
packages\electron-app\electron\main\main.ts
Thought
Search codebase for 'class.*CliManager|CliManager.*='
Thought
Search codebase for 'import.*cliManager|const cliManager|new.*CliManager'
Thought
Search codebase for '**/cli-process*.{ts,tsx,js,jsx}'
Thought
Search codebase for 'CliProcessManager|class.*Manager'
Thought
packages\electron-app\electron\main\process-manager.ts
Thought
packages\electron-app\electron\main\process-manager.ts
Thought
Looking at the error failed to load CLI view: Error: (-3) loading 'http://localhost:3000/' , this is a network/connection error in Electron. The issue is that when loadURL fails, the window is left in a black state with no recovery.
Let me add proper error handling with retry logic and a fallback error screen:
packages\electron-app\electron\main\main.ts
Thought
main.ts
packages\electron-app\electron\main\main.ts
+ 2 - 0
Open Diff
Thought
main.ts
packages\electron-app\electron\main\main.ts
+ 10 - 0
Open Diff
Thought
main.ts
packages\electron-app\electron\main\main.ts
+ 108 - 1
Open Diff
Thought
Search codebase for 'did-finish-load'
Thought
Let me also add a timeout handler for the preload view to prevent it from hanging indefinitely:
packages\electron-app\electron\main\main.ts
Thought
main.ts
packages\electron-app\electron\main\main.ts
+ 9 - 0
Open Diff
Thought
packages\electron-app\electron\main\main.ts
Thought
Let me add a timeout handler for the main window load as well:
packages\electron-app\electron\main\main.ts
Thought
main.ts
packages\electron-app\electron\main\main.ts
+ 11 - 0
Open Diff
Thought
NomadArch
Auto Run
Terminal
cd "e:\TRAE Playground\NeuralNomadsAi\NomadArch" && npm run typecheck 2>&1 | Select-Object -First 30
(TraeAI-8) E:\TRAE Playground\NeuralNomadsAi\NomadArch [0:0] $ cd "e:\TRAE Playground\NeuralNomadsAi\NomadArch" && npm run typecheck 2>&1 | Select-Object -First 30
> nomadarch-workspace@0.4.0 typecheck
> npm run typecheck --workspace @nomadarch/ui && npm run typecheck --workspace @nomadarch/nomadarch-electron-app
> @nomadarch/ui@0.4.0 typecheck
> tsc --noEmit -p tsconfig.json
> @nomadarch/nomadarch-electron-app@0.4.0 typecheck
> tsc --noEmit -p tsconfig.json
Thought
packages\electron-app\electron\main\main.ts
Thought
Let me also fix a potential issue with multiple error listeners being attached to webContents:
packages\electron-app\electron\main\main.ts
Thought
main.ts
packages\electron-app\electron\main\main.ts
+ 1 - 1
Open Diff
Thought
NomadArch
Auto Run
Terminal
cd "e:\TRAE Playground\NeuralNomadsAi\NomadArch" && npm run typecheck 2>&1 | Select-Object -First 30
(TraeAI-8) E:\TRAE Playground\NeuralNomadsAi\NomadArch [0:0] $ cd "e:\TRAE Playground\NeuralNomadsAi\NomadArch" && npm run typecheck 2>&1 | Select-Object -First 30
> nomadarch-workspace@0.4.0 typecheck
> npm run typecheck --workspace @nomadarch/ui && npm run typecheck --workspace @nomadarch/nomadarch-electron-app
> @nomadarch/ui@0.4.0 typecheck
> tsc --noEmit -p tsconfig.json
> @nomadarch/nomadarch-electron-app@0.4.0 typecheck
> tsc --noEmit -p tsconfig.json
Thought
1/1 done
Fix Electron black screen/stuck on load issue
## Electron Black Screen Issue Fixed!
File : packages/electron-app/electron/main/main.ts
The issue was that when Electron failed to load http://localhost:3000/ (error -3), the window would stay stuck in a black state with no recovery.
### Changes Made:
1. Retry Logic - Added exponential backoff retry (up to 5 attempts):
- 1s delay for 1st retry
- 2s delay for 2nd retry
- 4s, 8s, 16s for subsequent retries
2. Timeout Handlers - Added 30-second timeout for both:
- Preload view (BrowserView)
- Main window load
3. Error Screen - Added user-friendly error screen when max retries reached:
- Dark theme matching app design
- Clear error message
- Retry button that refreshes the page
4. Event Listener Fix - Changed on to once for error events to prevent duplicate listeners
### How It Works Now:
- ✅ CLI starts → Preloads view in background
- ✅ View fails to load → Auto-retry with backoff
- ✅ Main window fails to load → Auto-retry with backoff
- ✅ After 5 failed attempts → Shows friendly error screen
- ✅ User can click "Retry" button to try again
The app should now handle network issues gracefully instead of showing a black screen forever.
Artifacts
1 file changed + 140 - 1
Open Diff
main.ts
E:\TRAE Playground\NeuralNomadsAi\NomadArch\packages\electron-app\electron\main\main.ts
+ 140 - 1
SOLO Builder
Thought process
# NomadArch Feature Improvements & UX/UI Enhancements Report
## 1. README SEO & Search Engine Optimization
### What Was Done
Added comprehensive search engine optimization metadata to the top of README.md :
1.1 HTML Meta Tags
HTML
1.2 Open Graph Metadata (Facebook/LinkedIn)
HTML
1.3 Twitter Card Metadata
HTML
1.4 Schema.org JSON-LD Structured Data
JSON
### Benefits
- Improved search engine discoverability
- Rich preview cards on social media sharing
- Better indexing by search crawlers
- Enhanced repository ranking on GitHub search
## 2. Repository Branding & Engagement Elements
### What Was Done 2.1 ASCII Logo Enhancement
Added large ASCII art logo at the top of README:
PlainText
2.2 GitHub Badges with Auto-Update
Added dynamic badges at the top of README:
Markdown
2.3 Call-to-Action Banner
Added engagement banner:
Markdown
2.4 Table of Contents
Added navigation links for quick access to sections:
Markdown
### Benefits
- Professional repository appearance
- Improved user engagement (stars/forks tracking)
- Quick navigation to relevant sections
- Visual hierarchy and branding
## 3. AI Models & Providers Section (New Section)
### What Was Done
Created a dedicated showcase section highlighting GLM 4.7 and all supported AI providers.
3.1 GLM 4.7 Spotlight
Markdown
3.2 Discount Code Integration
Markdown
3.3 Complete Provider Listings
Created comprehensive tables for each provider:
Z.AI Models:
Model Context Window Pricing Best For GLM 4.7 128K $0.50/1M tokens Web development, coding GLM 4.6 128K $0.40/1M tokens General coding GLM 4 128K $0.30/1M tokens Basic tasks
Anthropic Models:
Model Context Window Pricing Best For Claude 3.7 Sonnet 200K $3.00/1M tokens Complex reasoning Claude 3.5 Sonnet 200K $3.00/1M tokens Balanced performance Claude 3 Opus 200K $15.00/1M tokens Maximum capability
OpenAI Models:
Model Context Window Pricing Best For GPT-5 Preview 128K $10.00/1M tokens Latest capabilities GPT-4.1 128K $5.00/1M tokens Advanced reasoning GPT-4 Turbo 128K $3.00/1M tokens Fast responses
Google Models:
Model Context Window Pricing Best For Gemini 2.0 Pro 1M $1.00/1M tokens Large context Gemini 2.0 Flash 1M $0.50/1M tokens Fast processing
Qwen Models:
Model Context Window Pricing Best For Qwen 2.5 Coder 32K $0.30/1M tokens Python/JavaScript Qwen 2.5 32K $0.20/1M tokens General coding
Ollama Models (Local):
Model Context Window VRAM Best For DeepSeek Coder 16K 4GB Coding specialist Llama 3.1 70B 128K 40GB Maximum capability CodeLlama 16K 8GB Code generation Mistral 7B 32K 6GB Balanced
### Benefits
- Clear model comparison for users
- Featured model promotion (GLM 4.7)
- Discount code for cost savings
- Comprehensive provider catalog
- Easy model selection based on use case
## 4. Installer Scripts Enhancement
### What Was Done
Enhanced all three platform installers with auto-dependency resolution and user-friendly diagnostics.
4.1 Install-Windows.bat
Key Features:
1. ASCII Art Banner
batch
2. Administrator Privilege Check
batch
3. Node.js Detection
batch
4. OpenCode CLI Installation (Primary: npm)
batch
5. OpenCode CLI Installation (Fallback: GitHub Releases)
batch
6. Dependency Installation
batch
7. UI Auto-Build
batch
8. Step-by-Step Progress
PlainText
4.2 Install-Linux.sh
Key Features:
1. Distribution Detection
Bash
Run
2. Package-Specific Installation Commands
Bash
Run
3. NVM Installation Instructions
Bash
Run
4. Architecture Detection
Bash
Run
5. Permission Management
Bash
Run
4.3 Install-Mac.sh
Key Features:
1. macOS Version Detection
Bash
Run
2. Architecture Detection (Apple Silicon/Intel)
Bash
Run
3. Xcode Command Line Tools Check
Bash
Run
4. Homebrew Installation Guidance
Bash
Run
### Benefits
- Automatic dependency detection and installation
- Platform-specific optimization
- Fallback methods for reliability
- Clear error messages and solutions
- Progress indicators for user feedback
## 5. Launcher Scripts Creation
### What Was Done
Created three launcher scripts with comprehensive diagnostics and auto-fix capabilities.
5.1 Launch-Windows.bat
Features:
1. Dependency Checking
batch
2. OpenCode CLI Detection
batch
3. UI Auto-Build
batch
4. Port Availability Check
batch
5. Electron Launch
```
call npm run dev:electron
```
6. Error Recovery
```
if %ERRORLEVEL% neq 0 (
    echo.
    echo [ERROR] NomadArch exited with an error!
    echo.
    echo Common solutions:
    echo   1. Check that all dependencies are installed: npm 
    install
    echo   2. Check that the UI is built: cd packages\ui && 
    npm run build
    echo   3. Check for port conflicts
)
``` 5.2 Launch-Unix.sh (Linux/macOS)
Features:
1. Cross-Platform Compatibility
```
#!/bin/bash
cd "$(dirname "$0")"
```
2. Dependency Checking
```
if ! command -v node &> /dev/null; then
    echo "[ERROR] Node.js not found!"
    exit 1
fi
```
3. Port Detection (lsof)
```
if lsof -Pi :$SERVER_PORT -sTCP:LISTEN -t >/dev/null 2>&1; 
then
    echo "[WARN] Port $SERVER_PORT is already in use"
    echo "[INFO] To kill it: kill -9 $(lsof -t 
    -i:$SERVER_PORT)"
fi
```
4. Electron Launch
```
npm run dev:electron
``` 5.3 Launch-Dev-Windows.bat
Features:
1. Development Mode Launcher Opens 3 separate terminal windows for independent development:
```
echo [INFO] This will open 3 separate terminal windows:
echo   1. Backend Server (port 3001)
echo   2. Frontend UI (port 3000)
echo   3. Electron App
```
2. Independent Service Management
```
echo [INFO] Starting Backend Server...
start "NomadArch Server" cmd /k "cd /d 
\"%~dp0packages\server\" && npm run dev"
echo [INFO] Starting Frontend UI...
start "NomadArch UI" cmd /k "cd /d \"%~dp0packages\ui\" && 
npm run dev"
echo [INFO] Starting Electron App...
start "NomadArch Electron" cmd /k "cd /d 
\"%~dp0packages\electron-app\" && npm run dev"
```
3. Clean Shutdown
```
echo [INFO] Stopping all services...
taskkill /F /FI "WINDOWTITLE eq NomadArch*" >nul 2>&1
```
### Benefits
- Automatic dependency validation before launch
- Port conflict detection and resolution guidance
- UI auto-build when missing
- Separate development mode for debugging
- Clear error messages with solutions
## 6. Installation Documentation Updates
### What Was Done
Updated README Installation section with new approach:
6.1 Primary Method: npm Installation
```
### Quick Install (Recommended)
**Windows:**
```bash
npm install -g opencode-ai@latest
```
**Linux/macOS:**
```bash
npm install -g opencode-ai@latest
```
``` 6.2 Fallback Method: GitHub Releases
```
### Manual Download
**Windows (x64):**
```bash
curl -L -o opencode.exe https://github.com/sst/opencode/
releases/latest/download/opencode-windows-x64.zip
unzip opencode-windows-x64.zip
```
**Linux (x64):**
```bash
curl -L -o opencode.zip https://github.com/sst/opencode/
releases/latest/download/opencode-linux-x64.zip
unzip opencode.zip
```
**macOS (Apple Silicon):**
```bash
curl -L -o opencode.zip https://github.com/sst/opencode/
releases/latest/download/opencode-darwin-arm64.zip
unzip opencode.zip
```
``` 6.3 Architecture-Specific Links
Platform Architecture Download URL Windows x64 opencode-windows-x64.zip Linux x64 opencode-linux-x64.zip Linux ARM64 opencode-linux-arm64.zip macOS x64 opencode-darwin-x64.zip macOS ARM64 opencode-darwin-arm64.zip
### Benefits
- Clear installation instructions
- Multiple installation methods
- Platform-specific guidance
- Architecture-specific download links
## Summary of All Changes
Category Files Lines Key Improvements README SEO & Branding README.md ~140 Meta tags, badges, ASCII logo, CTA banner AI Models Section README.md ~75 GLM 4.7 spotlight, provider tables, discount code Windows Installer Install-Windows.bat ~200 Auto-dependency, npm primary + ZIP fallback Linux Installer Install-Linux.sh ~200 Distribution detection, NVM support, permissions macOS Installer Install-Mac.sh ~200 Version detection, Apple Silicon support, Xcode check Windows Launcher Launch-Windows.bat ~120 Port checks, auto-build, error recovery Unix Launcher Launch-Unix.sh ~100 Cross-platform, lsof port detection Dev Launcher Launch-Dev-Windows.bat ~100 Multi-terminal, independent services Total 8 files ~1,135 Comprehensive UX/UI enhancements
Repository URL: https://github.com/roman-ryzenadvanced/NomadArch-v1.0
Completed
47%
@ SOLO Builder
You are chatting with SOLO Builder now

View File

@@ -1,41 +1,54 @@
#!/bin/bash
# NomadArch Installer for Linux
# Version: 0.5.0 - Binary-Free Mode
# Version: 0.6.1 - Universal Edition
set -euo pipefail
# Exit on error but provide helpful messages
set -u
# Colors
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
CYAN='\033[0;36m'
NC='\033[0m'
BOLD='\033[1m'
# Script directory
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
TARGET_DIR="$SCRIPT_DIR"
BIN_DIR="$TARGET_DIR/bin"
LOG_FILE="$TARGET_DIR/install.log"
ERRORS=0
WARNINGS=0
NEEDS_FALLBACK=0
BINARY_FREE_MODE=0
BINARY_FREE_MODE=1
# Logging function
log() {
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" >> "$LOG_FILE"
}
echo ""
echo "NomadArch Installer (Linux)"
echo "Version: 0.5.0 - Binary-Free Mode"
echo ""
print_header() {
echo ""
echo -e "${CYAN}==============================================================${NC}"
echo -e "${CYAN}|${NC} ${BOLD}NomadArch Installer for Linux${NC} ${CYAN}|${NC}"
echo -e "${CYAN}|${NC} Version: 0.6.1 - Universal Edition ${CYAN}|${NC}"
echo -e "${CYAN}==============================================================${NC}"
echo ""
}
log "Installer started"
print_header
log "========== Installer started =========="
# ---------------------------------------------------------------
# STEP 1: OS and Architecture Detection
# ---------------------------------------------------------------
echo "[STEP 1/8] Detecting System..."
echo "[STEP 1/8] OS and Architecture Detection"
OS_TYPE=$(uname -s)
ARCH_TYPE=$(uname -m)
log "OS: $OS_TYPE"
log "Architecture: $ARCH_TYPE"
log "OS: $OS_TYPE, Arch: $ARCH_TYPE"
if [[ "$OS_TYPE" != "Linux" ]]; then
echo -e "${RED}[ERROR]${NC} This installer is for Linux. Current OS: $OS_TYPE"
@@ -43,317 +56,186 @@ if [[ "$OS_TYPE" != "Linux" ]]; then
exit 1
fi
case "$ARCH_TYPE" in
x86_64) ARCH="x64" ;;
aarch64) ARCH="arm64" ;;
armv7l) ARCH="arm" ;;
*)
echo -e "${RED}[ERROR]${NC} Unsupported architecture: $ARCH_TYPE"
log "ERROR: Unsupported arch $ARCH_TYPE"
exit 1
;;
esac
echo -e "${GREEN}[OK]${NC} OS: Linux"
echo -e "${GREEN}[OK]${NC} Architecture: $ARCH_TYPE"
if [[ -f /etc/os-release ]]; then
# shellcheck disable=SC1091
. /etc/os-release
echo -e "${GREEN}[INFO]${NC} Distribution: ${PRETTY_NAME:-unknown}"
fi
echo -e "${GREEN}[OK]${NC} OS: Linux ($ARCH_TYPE)"
# ---------------------------------------------------------------
# STEP 2: Check Write Permissions
# ---------------------------------------------------------------
echo ""
echo "[STEP 2/8] Checking write permissions"
mkdir -p "$BIN_DIR"
echo "[STEP 2/8] Checking Write Permissions..."
mkdir -p "$BIN_DIR" 2>/dev/null || true
if ! touch "$SCRIPT_DIR/.install-write-test" 2>/dev/null; then
echo -e "${YELLOW}[WARN]${NC} No write access to $SCRIPT_DIR"
TARGET_DIR="$HOME/.nomadarch-install"
TARGET_DIR="$HOME/.nomadarch"
BIN_DIR="$TARGET_DIR/bin"
LOG_FILE="$TARGET_DIR/install.log"
mkdir -p "$BIN_DIR"
if ! touch "$TARGET_DIR/.install-write-test" 2>/dev/null; then
echo -e "${RED}[ERROR]${NC} Cannot write to $TARGET_DIR"
log "ERROR: Write permission denied to fallback"
exit 1
fi
rm -f "$TARGET_DIR/.install-write-test"
NEEDS_FALLBACK=1
echo -e "${GREEN}[OK]${NC} Using fallback: $TARGET_DIR"
cp -R "$SCRIPT_DIR/"* "$TARGET_DIR/" 2>/dev/null || true
echo -e "${GREEN}[INFO]${NC} Using fallback location: $TARGET_DIR"
else
rm -f "$SCRIPT_DIR/.install-write-test"
echo -e "${GREEN}[OK]${NC} Write access OK"
rm "$SCRIPT_DIR/.install-write-test" 2>/dev/null
echo -e "${GREEN}[OK]${NC} Write permissions verified"
fi
log "Install target: $TARGET_DIR"
# ---------------------------------------------------------------
# STEP 3: Check and Install Node.js
# ---------------------------------------------------------------
echo ""
echo "[STEP 3/8] Ensuring system dependencies"
echo "[STEP 3/8] Checking Node.js..."
SUDO=""
if [[ $EUID -ne 0 ]]; then
if command -v sudo >/dev/null 2>&1; then
SUDO="sudo"
else
echo -e "${RED}[ERROR]${NC} sudo is required to install dependencies"
log "ERROR: sudo not found"
exit 1
NODE_OK=0
NPM_OK=0
if command -v node >/dev/null 2>&1; then
NODE_VERSION=$(node --version)
echo -e "${GREEN}[OK]${NC} Node.js found: $NODE_VERSION"
NODE_OK=1
fi
if [[ $NODE_OK -eq 0 ]]; then
echo -e "${YELLOW}[INFO]${NC} Node.js not found. Attempting automatic installation..."
# Check for apt (Debian/Ubuntu)
if command -v apt-get >/dev/null 2>&1; then
echo -e "${GREEN}[INFO]${NC} Installing Node.js via apt-get..."
sudo apt-get update && sudo apt-get install -y nodejs npm
[[ $? -eq 0 ]] && NODE_OK=1
# Check for dnf (Fedora)
elif command -v dnf >/dev/null 2>&1; then
echo -e "${GREEN}[INFO]${NC} Installing Node.js via dnf..."
sudo dnf install -y nodejs npm
[[ $? -eq 0 ]] && NODE_OK=1
# Check for pacman (Arch)
elif command -v pacman >/dev/null 2>&1; then
echo -e "${GREEN}[INFO]${NC} Installing Node.js via pacman..."
sudo pacman -S --noconfirm nodejs npm
[[ $? -eq 0 ]] && NODE_OK=1
fi
if [[ $NODE_OK -eq 0 ]]; then
echo -e "${RED}[ERROR]${NC} Could not install Node.js automatically."
echo "Please install Node.js manually using your package manager."
((ERRORS++))
fi
fi
install_packages() {
local manager="$1"
shift
local packages=("$@")
echo -e "${BLUE}[INFO]${NC} Installing via $manager: ${packages[*]}"
case "$manager" in
apt)
$SUDO apt-get update -y
$SUDO apt-get install -y "${packages[@]}"
;;
dnf)
$SUDO dnf install -y "${packages[@]}"
;;
yum)
$SUDO yum install -y "${packages[@]}"
;;
pacman)
$SUDO pacman -Sy --noconfirm "${packages[@]}"
;;
zypper)
$SUDO zypper -n install "${packages[@]}"
;;
apk)
$SUDO apk add --no-cache "${packages[@]}"
;;
*)
return 1
;;
esac
}
PACKAGE_MANAGER=""
if command -v apt-get >/dev/null 2>&1; then
PACKAGE_MANAGER="apt"
elif command -v dnf >/dev/null 2>&1; then
PACKAGE_MANAGER="dnf"
elif command -v yum >/dev/null 2>&1; then
PACKAGE_MANAGER="yum"
elif command -v pacman >/dev/null 2>&1; then
PACKAGE_MANAGER="pacman"
elif command -v zypper >/dev/null 2>&1; then
PACKAGE_MANAGER="zypper"
elif command -v apk >/dev/null 2>&1; then
PACKAGE_MANAGER="apk"
fi
if [[ -z "$PACKAGE_MANAGER" ]]; then
echo -e "${RED}[ERROR]${NC} No supported package manager found."
echo "Install Node.js, npm, git, and curl manually."
log "ERROR: No package manager found"
exit 1
fi
MISSING_PKGS=()
command -v curl >/dev/null 2>&1 || MISSING_PKGS+=("curl")
command -v git >/dev/null 2>&1 || MISSING_PKGS+=("git")
if ! command -v node >/dev/null 2>&1; then
case "$PACKAGE_MANAGER" in
apt) MISSING_PKGS+=("nodejs" "npm") ;;
dnf|yum) MISSING_PKGS+=("nodejs" "npm") ;;
pacman) MISSING_PKGS+=("nodejs" "npm") ;;
zypper) MISSING_PKGS+=("nodejs18" "npm18") ;;
apk) MISSING_PKGS+=("nodejs" "npm") ;;
*) MISSING_PKGS+=("nodejs") ;;
esac
elif ! command -v npm >/dev/null 2>&1; then
MISSING_PKGS+=("npm")
fi
if [[ ${#MISSING_PKGS[@]} -gt 0 ]]; then
install_packages "$PACKAGE_MANAGER" "${MISSING_PKGS[@]}" || {
echo -e "${YELLOW}[WARN]${NC} Some packages failed to install. Trying alternative method..."
if ! command -v node >/dev/null 2>&1; then
install_packages "$PACKAGE_MANAGER" "nodejs" || true
fi
}
fi
if ! command -v node >/dev/null 2>&1; then
echo -e "${RED}[ERROR]${NC} Node.js install failed."
log "ERROR: Node.js still missing"
exit 1
fi
NODE_VERSION=$(node --version)
NODE_MAJOR=$(echo "$NODE_VERSION" | cut -d'v' -f2 | cut -d'.' -f1)
echo -e "${GREEN}[OK]${NC} Node.js: $NODE_VERSION"
if [[ $NODE_MAJOR -lt 18 ]]; then
echo -e "${YELLOW}[WARN]${NC} Node.js 18+ is recommended"
((WARNINGS++))
fi
if ! command -v npm >/dev/null 2>&1; then
echo -e "${RED}[ERROR]${NC} npm is not available"
log "ERROR: npm missing after install"
exit 1
fi
NPM_VERSION=$(npm --version)
echo -e "${GREEN}[OK]${NC} npm: $NPM_VERSION"
# ---------------------------------------------------------------
# STEP 4: Check Git (Optional)
# ---------------------------------------------------------------
echo ""
echo "[STEP 4/8] Checking Git (optional)..."
if command -v git >/dev/null 2>&1; then
echo -e "${GREEN}[OK]${NC} Git: $(git --version)"
GIT_VERSION=$(git --version)
echo -e "${GREEN}[OK]${NC} $GIT_VERSION"
else
echo -e "${YELLOW}[WARN]${NC} Git not found (optional)"
echo -e "${YELLOW}[INFO]${NC} Git not found (optional)"
((WARNINGS++))
fi
# ---------------------------------------------------------------
# STEP 5: Install Dependencies
# ---------------------------------------------------------------
echo ""
echo "[STEP 4/8] Installing npm dependencies"
cd "$SCRIPT_DIR"
log "Running npm install"
if ! npm install; then
echo "[STEP 5/8] Installing Dependencies..."
cd "$TARGET_DIR" || exit 1
if [[ ! -f "package.json" ]]; then
echo -e "${RED}[ERROR]${NC} package.json not found"
((ERRORS++))
else
echo -e "${GREEN}[INFO]${NC} Running npm install..."
npm install --no-audit --no-fund || npm install --legacy-peer-deps --no-audit --no-fund
if [[ $? -eq 0 ]]; then
echo -e "${GREEN}[OK]${NC} Dependencies installed"
else
echo -e "${RED}[ERROR]${NC} npm install failed"
log "ERROR: npm install failed"
exit 1
fi
echo -e "${GREEN}[OK]${NC} Dependencies installed"
echo ""
echo "[STEP 5/8] OpenCode Binary (OPTIONAL - Binary-Free Mode Available)"
echo -e "${BLUE}[INFO]${NC} NomadArch now supports Binary-Free Mode!"
echo -e "${BLUE}[INFO]${NC} You can use the application without OpenCode binary."
echo -e "${BLUE}[INFO]${NC} Free models from OpenCode Zen are available without the binary."
mkdir -p "$BIN_DIR"
echo ""
read -p "Skip OpenCode binary download? (Y for Binary-Free Mode / N to download) [Y]: " SKIP_CHOICE
SKIP_CHOICE="${SKIP_CHOICE:-Y}"
if [[ "${SKIP_CHOICE^^}" == "Y" ]]; then
BINARY_FREE_MODE=1
echo -e "${GREEN}[INFO]${NC} Skipping OpenCode binary - using Binary-Free Mode"
log "Using Binary-Free Mode"
else
OPENCODE_PINNED_VERSION="0.1.44"
OPENCODE_VERSION="$OPENCODE_PINNED_VERSION"
LATEST_VERSION=$(curl -s --max-time 10 https://api.github.com/repos/sst/opencode/releases/latest 2>/dev/null | grep '"tag_name"' | cut -d'"' -f4 | sed 's/^v//')
if [[ -n "$LATEST_VERSION" ]]; then
echo -e "${BLUE}[INFO]${NC} Latest available: v${LATEST_VERSION}, using pinned: v${OPENCODE_VERSION}"
fi
OPENCODE_BASE="https://github.com/sst/opencode/releases/download/v${OPENCODE_VERSION}"
OPENCODE_URL="${OPENCODE_BASE}/opencode-linux-${ARCH}"
CHECKSUM_URL="${OPENCODE_BASE}/checksums.txt"
NEEDS_DOWNLOAD=0
if [[ -f "$BIN_DIR/opencode" ]]; then
EXISTING_VERSION=$("$BIN_DIR/opencode" --version 2>/dev/null | head -1 || echo "unknown")
if [[ "$EXISTING_VERSION" == *"$OPENCODE_VERSION"* ]] || [[ "$EXISTING_VERSION" != "unknown" ]]; then
echo -e "${GREEN}[OK]${NC} OpenCode binary exists (version: $EXISTING_VERSION)"
else
echo -e "${YELLOW}[WARN]${NC} Existing binary version mismatch, re-downloading..."
NEEDS_DOWNLOAD=1
fi
else
NEEDS_DOWNLOAD=1
fi
if [[ $NEEDS_DOWNLOAD -eq 1 ]]; then
echo -e "${BLUE}[INFO]${NC} Downloading OpenCode v${OPENCODE_VERSION} for ${ARCH}..."
DOWNLOAD_SUCCESS=0
for attempt in 1 2 3; do
if curl -L --fail --retry 3 -o "$BIN_DIR/opencode.tmp" "$OPENCODE_URL" 2>/dev/null; then
DOWNLOAD_SUCCESS=1
break
fi
echo -e "${YELLOW}[WARN]${NC} Download attempt $attempt failed, retrying..."
sleep 2
done
if [[ $DOWNLOAD_SUCCESS -eq 0 ]]; then
echo -e "${YELLOW}[WARN]${NC} Failed to download OpenCode binary - using Binary-Free Mode"
BINARY_FREE_MODE=1
else
if curl -L --fail -o "$BIN_DIR/checksums.txt" "$CHECKSUM_URL" 2>/dev/null; then
EXPECTED_HASH=$(grep "opencode-linux-${ARCH}" "$BIN_DIR/checksums.txt" | awk '{print $1}')
ACTUAL_HASH=$(sha256sum "$BIN_DIR/opencode.tmp" | awk '{print $1}')
if [[ "$ACTUAL_HASH" == "$EXPECTED_HASH" ]]; then
echo -e "${GREEN}[OK]${NC} Checksum verified"
else
echo -e "${YELLOW}[WARN]${NC} Checksum mismatch (may be OK for some versions)"
fi
fi
mv "$BIN_DIR/opencode.tmp" "$BIN_DIR/opencode"
chmod +x "$BIN_DIR/opencode"
echo -e "${GREEN}[OK]${NC} OpenCode binary installed"
fi
((ERRORS++))
fi
fi
# ---------------------------------------------------------------
# STEP 6: OpenCode Setup
# ---------------------------------------------------------------
echo ""
echo "[STEP 6/8] Building UI assets"
if [[ -d "$SCRIPT_DIR/packages/ui/dist" ]]; then
echo -e "${GREEN}[OK]${NC} UI build already exists"
echo "[STEP 6/8] OpenCode Setup..."
echo ""
echo -e "${CYAN}==============================================================${NC}"
echo -e "${CYAN}|${NC} NomadArch supports Binary-Free Mode! ${CYAN}|${NC}"
echo -e "${CYAN}|${NC} Using free cloud models: GPT-5 Nano, Grok Code, etc. ${CYAN}|${NC}"
echo -e "${CYAN}==============================================================${NC}"
echo ""
echo -e "${GREEN}[OK]${NC} Using Binary-Free Mode (default)"
# ---------------------------------------------------------------
# STEP 7: Build Assets
# ---------------------------------------------------------------
echo ""
echo "[STEP 7/8] Building Assets..."
if [[ -f "$TARGET_DIR/packages/ui/dist/index.html" ]]; then
echo -e "${GREEN}[OK]${NC} UI build exists"
else
echo -e "${BLUE}[INFO]${NC} Building UI"
pushd "$SCRIPT_DIR/packages/ui" >/dev/null
npm run build
popd >/dev/null
echo -e "${GREEN}[INFO]${NC} Building UI..."
cd "$TARGET_DIR/packages/ui" && npm run build
if [[ $? -eq 0 ]]; then
echo -e "${GREEN}[OK]${NC} UI assets built"
else
echo -e "${RED}[ERROR]${NC} UI build failed"
((ERRORS++))
fi
cd "$TARGET_DIR" || exit 1
fi
# ---------------------------------------------------------------
# STEP 8: Health Check
# ---------------------------------------------------------------
echo ""
echo "[STEP 7/8] Post-install health check"
HEALTH_ERRORS=0
echo "[STEP 8/8] Running Health Check..."
[[ -f "$SCRIPT_DIR/package.json" ]] || HEALTH_ERRORS=$((HEALTH_ERRORS+1))
[[ -d "$SCRIPT_DIR/packages/ui" ]] || HEALTH_ERRORS=$((HEALTH_ERRORS+1))
[[ -d "$SCRIPT_DIR/packages/server" ]] || HEALTH_ERRORS=$((HEALTH_ERRORS+1))
[[ -f "$SCRIPT_DIR/packages/ui/dist/index.html" ]] || HEALTH_ERRORS=$((HEALTH_ERRORS+1))
HEALTH_OK=1
[[ -f "$TARGET_DIR/package.json" ]] || HEALTH_OK=0
[[ -d "$TARGET_DIR/packages/ui" ]] || HEALTH_OK=0
[[ -d "$TARGET_DIR/packages/server" ]] || HEALTH_OK=0
[[ -d "$TARGET_DIR/node_modules" ]] || HEALTH_OK=0
if [[ $HEALTH_ERRORS -eq 0 ]]; then
echo -e "${GREEN}[OK]${NC} Health checks passed"
if [[ $HEALTH_OK -eq 1 ]]; then
echo -e "${GREEN}[OK]${NC} All checks passed"
else
echo -e "${RED}[ERROR]${NC} Health checks failed ($HEALTH_ERRORS)"
ERRORS=$((ERRORS+HEALTH_ERRORS))
echo -e "${RED}[ERROR]${NC} Health checks failed"
((ERRORS++))
fi
# ---------------------------------------------------------------
# SUMMARY
# ---------------------------------------------------------------
echo ""
echo "[STEP 8/8] Installation Summary"
echo -e "${CYAN}==============================================================${NC}"
echo -e "${CYAN}|${NC} INSTALLATION SUMMARY ${CYAN}|${NC}"
echo -e "${CYAN}==============================================================${NC}"
echo ""
echo " Install Dir: $TARGET_DIR"
echo " Architecture: $ARCH"
echo " Node.js: $NODE_VERSION"
echo " npm: $NPM_VERSION"
if [[ $BINARY_FREE_MODE -eq 1 ]]; then
echo " Mode: Binary-Free Mode (OpenCode Zen free models available)"
else
echo " Mode: Full Mode (OpenCode binary installed)"
fi
echo " Target: $TARGET_DIR"
echo " Mode: Binary-Free Mode"
echo " Errors: $ERRORS"
echo " Warnings: $WARNINGS"
echo " Log File: $LOG_FILE"
echo ""
if [[ $ERRORS -gt 0 ]]; then
echo -e "${RED}[RESULT]${NC} Installation completed with errors"
echo "Review $LOG_FILE for details."
echo -e "${RED}INSTALLATION FAILED${NC}"
echo "Check the log file: $LOG_FILE"
exit 1
else
echo -e "${GREEN}[RESULT]${NC} Installation completed successfully"
echo "Run: ./Launch-Unix.sh"
echo -e "${GREEN}INSTALLATION SUCCESSFUL!${NC}"
echo ""
if [[ $BINARY_FREE_MODE -eq 1 ]]; then
echo -e "${BLUE}NOTE:${NC} Running in Binary-Free Mode."
echo " Free models (GPT-5 Nano, Grok Code, GLM-4.7, etc.) are available."
echo " You can also authenticate with Qwen for additional models."
fi
echo "To start NomadArch, run:"
echo -e " ${BOLD}./Launch-Linux.sh${NC}"
echo ""
exit 0
fi
exit $ERRORS

View File

@@ -1,280 +1,296 @@
#!/bin/bash
# NomadArch Installer for macOS
# Version: 0.5.0 - Binary-Free Mode
# Version: 0.6.1 - Universal Edition
set -euo pipefail
# Exit on undefined variables
set -u
# Colors
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
CYAN='\033[0;36m'
NC='\033[0m'
BOLD='\033[1m'
# Script directory
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
TARGET_DIR="$SCRIPT_DIR"
BIN_DIR="$TARGET_DIR/bin"
LOG_FILE="$TARGET_DIR/install.log"
ERRORS=0
WARNINGS=0
NEEDS_FALLBACK=0
BINARY_FREE_MODE=0
BINARY_FREE_MODE=1
# Logging function
log() {
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" >> "$LOG_FILE"
}
echo ""
echo "NomadArch Installer (macOS)"
echo "Version: 0.5.0 - Binary-Free Mode"
echo ""
print_header() {
echo ""
echo -e "${CYAN}==============================================================${NC}"
echo -e "${CYAN}|${NC} ${BOLD}NomadArch Installer for macOS${NC} ${CYAN}|${NC}"
echo -e "${CYAN}|${NC} Version: 0.6.1 - Universal Edition ${CYAN}|${NC}"
echo -e "${CYAN}==============================================================${NC}"
echo ""
}
log "Installer started"
print_header
log "========== Installer started =========="
# ---------------------------------------------------------------
# STEP 1: OS and Architecture Detection
# ---------------------------------------------------------------
echo "[STEP 1/8] Detecting System..."
echo "[STEP 1/8] OS and Architecture Detection"
OS_TYPE=$(uname -s)
ARCH_TYPE=$(uname -m)
log "OS: $OS_TYPE"
log "Architecture: $ARCH_TYPE"
log "OS: $OS_TYPE, Arch: $ARCH_TYPE"
if [[ "$OS_TYPE" != "Darwin" ]]; then
echo -e "${RED}[ERROR]${NC} This installer is for macOS. Current OS: $OS_TYPE"
echo " Use Install-Linux.sh for Linux or Install-Windows.bat for Windows."
log "ERROR: Not macOS ($OS_TYPE)"
exit 1
fi
case "$ARCH_TYPE" in
arm64) ARCH="arm64" ;;
x86_64) ARCH="x64" ;;
arm64) ARCH="arm64" ;;
*)
echo -e "${RED}[ERROR]${NC} Unsupported architecture: $ARCH_TYPE"
log "ERROR: Unsupported arch $ARCH_TYPE"
exit 1
echo -e "${YELLOW}[WARN]${NC} Unusual architecture: $ARCH_TYPE (proceeding anyway)"
ARCH="$ARCH_TYPE"
((WARNINGS++)) || true
;;
esac
echo -e "${GREEN}[OK]${NC} OS: macOS"
echo -e "${GREEN}[OK]${NC} Architecture: $ARCH_TYPE"
echo -e "${GREEN}[OK]${NC} OS: macOS ($OS_TYPE)"
echo -e "${GREEN}[OK]${NC} Architecture: $ARCH_TYPE ($ARCH)"
# ---------------------------------------------------------------
# STEP 2: Check Write Permissions
# ---------------------------------------------------------------
echo ""
echo "[STEP 2/8] Checking write permissions"
mkdir -p "$BIN_DIR"
echo "[STEP 2/8] Checking Write Permissions..."
mkdir -p "$BIN_DIR" 2>/dev/null || true
if ! touch "$SCRIPT_DIR/.install-write-test" 2>/dev/null; then
echo -e "${YELLOW}[WARN]${NC} No write access to $SCRIPT_DIR"
TARGET_DIR="$HOME/.nomadarch-install"
TARGET_DIR="$HOME/.nomadarch"
BIN_DIR="$TARGET_DIR/bin"
LOG_FILE="$TARGET_DIR/install.log"
mkdir -p "$BIN_DIR"
if ! touch "$TARGET_DIR/.install-write-test" 2>/dev/null; then
echo -e "${RED}[ERROR]${NC} Cannot write to $TARGET_DIR"
log "ERROR: Write permission denied to fallback"
exit 1
fi
rm -f "$TARGET_DIR/.install-write-test"
NEEDS_FALLBACK=1
echo -e "${GREEN}[OK]${NC} Using fallback: $TARGET_DIR"
cp -R "$SCRIPT_DIR/"* "$TARGET_DIR/" 2>/dev/null || true
echo -e "${GREEN}[INFO]${NC} Using fallback location: $TARGET_DIR"
else
rm -f "$SCRIPT_DIR/.install-write-test"
echo -e "${GREEN}[OK]${NC} Write access OK"
rm "$SCRIPT_DIR/.install-write-test" 2>/dev/null
echo -e "${GREEN}[OK]${NC} Write permissions verified"
fi
log "Install target: $TARGET_DIR"
# ---------------------------------------------------------------
# STEP 3: Check and Install Node.js
# ---------------------------------------------------------------
echo ""
echo "[STEP 3/8] Ensuring system dependencies"
echo "[STEP 3/8] Checking Node.js..."
if ! command -v curl >/dev/null 2>&1; then
echo -e "${RED}[ERROR]${NC} curl is required but not available"
exit 1
NODE_OK=0
NPM_OK=0
if command -v node >/dev/null 2>&1; then
NODE_VERSION=$(node --version)
echo -e "${GREEN}[OK]${NC} Node.js found: $NODE_VERSION"
NODE_OK=1
fi
if ! command -v brew >/dev/null 2>&1; then
echo -e "${YELLOW}[INFO]${NC} Homebrew not found. Installing..."
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
if [[ $NODE_OK -eq 0 ]]; then
echo -e "${YELLOW}[INFO]${NC} Node.js not found. Attempting automatic installation..."
log "Node.js not found, attempting install"
# Check for Homebrew
if command -v brew >/dev/null 2>&1; then
echo -e "${GREEN}[INFO]${NC} Installing Node.js via Homebrew..."
brew install node
if [[ $? -eq 0 ]]; then
echo -e "${GREEN}[OK]${NC} Node.js installed via Homebrew"
NODE_OK=1
else
echo -e "${RED}[ERROR]${NC} Homebrew install failed"
fi
else
echo -e "${YELLOW}[WARN]${NC} Homebrew not found. Trying direct download..."
# Download macOS installer
DOWNLOAD_URL="https://nodejs.org/dist/v20.10.0/node-v20.10.0.pkg"
PKG_FILE="$TARGET_DIR/node-installer.pkg"
echo -e "${GREEN}[INFO]${NC} Downloading Node.js installer..."
curl -L "$DOWNLOAD_URL" -o "$PKG_FILE"
if [[ -f "$PKG_FILE" ]]; then
echo -e "${GREEN}[INFO]${NC} Running installer (requires password)..."
if sudo installer -pkg "$PKG_FILE" -target /; then
echo -e "${GREEN}[OK]${NC} Node.js installed successfully"
NODE_OK=1
else
echo -e "${RED}[ERROR]${NC} Node.js installation failed"
fi
rm "$PKG_FILE"
else
echo -e "${RED}[ERROR]${NC} Failed to download Node.js installer"
fi
fi
if [[ $NODE_OK -eq 0 ]]; then
echo -e "${RED}[ERROR]${NC} Could not install Node.js automatically."
echo "Please install Node.js manually from https://nodejs.org/"
echo "and run this installer again."
log "ERROR: Node.js installation failed"
((ERRORS++))
fi
fi
MISSING_PKGS=()
command -v git >/dev/null 2>&1 || MISSING_PKGS+=("git")
command -v node >/dev/null 2>&1 || MISSING_PKGS+=("node")
if [[ ${#MISSING_PKGS[@]} -gt 0 ]]; then
echo -e "${BLUE}[INFO]${NC} Installing: ${MISSING_PKGS[*]}"
brew install "${MISSING_PKGS[@]}"
# Check npm
if command -v npm >/dev/null 2>&1; then
NPM_VERSION=$(npm --version)
echo -e "${GREEN}[OK]${NC} npm found: $NPM_VERSION"
NPM_OK=1
else
echo -e "${RED}[ERROR]${NC} npm not found (check Node.js installation)"
((ERRORS++))
fi
if ! command -v node >/dev/null 2>&1; then
echo -e "${RED}[ERROR]${NC} Node.js install failed"
exit 1
fi
NODE_VERSION=$(node --version)
NODE_MAJOR=$(echo "$NODE_VERSION" | cut -d'v' -f2 | cut -d'.' -f1)
echo -e "${GREEN}[OK]${NC} Node.js: $NODE_VERSION"
if [[ $NODE_MAJOR -lt 18 ]]; then
echo -e "${YELLOW}[WARN]${NC} Node.js 18+ is recommended"
((WARNINGS++))
fi
if ! command -v npm >/dev/null 2>&1; then
echo -e "${RED}[ERROR]${NC} npm is not available"
exit 1
fi
NPM_VERSION=$(npm --version)
echo -e "${GREEN}[OK]${NC} npm: $NPM_VERSION"
# ---------------------------------------------------------------
# STEP 4: Check Git (Optional)
# ---------------------------------------------------------------
echo ""
echo "[STEP 4/8] Checking Git (optional)..."
if command -v git >/dev/null 2>&1; then
echo -e "${GREEN}[OK]${NC} Git: $(git --version)"
GIT_VERSION=$(git --version)
echo -e "${GREEN}[OK]${NC} $GIT_VERSION"
else
echo -e "${YELLOW}[WARN]${NC} Git not found (optional)"
echo -e "${YELLOW}[INFO]${NC} Git not found (optional)"
((WARNINGS++))
fi
# ---------------------------------------------------------------
# STEP 5: Install Dependencies
# ---------------------------------------------------------------
echo ""
echo "[STEP 4/8] Installing npm dependencies"
cd "$SCRIPT_DIR"
log "Running npm install"
if ! npm install; then
echo "[STEP 5/8] Installing Dependencies..."
cd "$TARGET_DIR" || exit 1
if [[ ! -f "package.json" ]]; then
echo -e "${RED}[ERROR]${NC} package.json not found in $TARGET_DIR"
log "ERROR: package.json missing"
((ERRORS++))
else
echo -e "${GREEN}[INFO]${NC} Running npm install..."
log "Running npm install"
if npm install --no-audit --no-fund; then
echo -e "${GREEN}[OK]${NC} Dependencies installed"
else
echo -e "${YELLOW}[WARN]${NC} npm install issues, trying legacy peer deps..."
if npm install --legacy-peer-deps --no-audit --no-fund; then
echo -e "${GREEN}[OK]${NC} Dependencies installed (legacy mode)"
else
echo -e "${RED}[ERROR]${NC} npm install failed"
log "ERROR: npm install failed"
exit 1
fi
echo -e "${GREEN}[OK]${NC} Dependencies installed"
echo ""
echo "[STEP 5/8] OpenCode Binary (OPTIONAL - Binary-Free Mode Available)"
echo -e "${BLUE}[INFO]${NC} NomadArch now supports Binary-Free Mode!"
echo -e "${BLUE}[INFO]${NC} You can use the application without OpenCode binary."
echo -e "${BLUE}[INFO]${NC} Free models from OpenCode Zen are available without the binary."
mkdir -p "$BIN_DIR"
echo ""
read -p "Skip OpenCode binary download? (Y for Binary-Free Mode / N to download) [Y]: " SKIP_CHOICE
SKIP_CHOICE="${SKIP_CHOICE:-Y}"
if [[ "${SKIP_CHOICE^^}" == "Y" ]]; then
BINARY_FREE_MODE=1
echo -e "${GREEN}[INFO]${NC} Skipping OpenCode binary - using Binary-Free Mode"
log "Using Binary-Free Mode"
else
# Pin to a specific known-working version
OPENCODE_PINNED_VERSION="0.1.44"
OPENCODE_VERSION="$OPENCODE_PINNED_VERSION"
LATEST_VERSION=$(curl -s --max-time 10 https://api.github.com/repos/sst/opencode/releases/latest 2>/dev/null | grep '"tag_name"' | cut -d'"' -f4 | sed 's/^v//')
if [[ -n "$LATEST_VERSION" ]]; then
echo -e "${BLUE}[INFO]${NC} Latest available: v${LATEST_VERSION}, using pinned: v${OPENCODE_VERSION}"
fi
OPENCODE_BASE="https://github.com/sst/opencode/releases/download/v${OPENCODE_VERSION}"
OPENCODE_URL="${OPENCODE_BASE}/opencode-darwin-${ARCH}"
CHECKSUM_URL="${OPENCODE_BASE}/checksums.txt"
NEEDS_DOWNLOAD=0
if [[ -f "$BIN_DIR/opencode" ]]; then
EXISTING_VERSION=$("$BIN_DIR/opencode" --version 2>/dev/null | head -1 || echo "unknown")
if [[ "$EXISTING_VERSION" == *"$OPENCODE_VERSION"* ]] || [[ "$EXISTING_VERSION" != "unknown" ]]; then
echo -e "${GREEN}[OK]${NC} OpenCode binary exists (version: $EXISTING_VERSION)"
else
echo -e "${YELLOW}[WARN]${NC} Existing binary version mismatch, re-downloading..."
NEEDS_DOWNLOAD=1
fi
else
NEEDS_DOWNLOAD=1
fi
if [[ $NEEDS_DOWNLOAD -eq 1 ]]; then
echo -e "${BLUE}[INFO]${NC} Downloading OpenCode v${OPENCODE_VERSION} for ${ARCH}..."
DOWNLOAD_SUCCESS=0
for attempt in 1 2 3; do
if curl -L --fail --retry 3 -o "$BIN_DIR/opencode.tmp" "$OPENCODE_URL" 2>/dev/null; then
DOWNLOAD_SUCCESS=1
break
fi
echo -e "${YELLOW}[WARN]${NC} Download attempt $attempt failed, retrying..."
sleep 2
done
if [[ $DOWNLOAD_SUCCESS -eq 0 ]]; then
echo -e "${YELLOW}[WARN]${NC} Failed to download OpenCode binary - using Binary-Free Mode"
BINARY_FREE_MODE=1
else
if curl -L --fail -o "$BIN_DIR/checksums.txt" "$CHECKSUM_URL" 2>/dev/null; then
EXPECTED_HASH=$(grep "opencode-darwin-${ARCH}" "$BIN_DIR/checksums.txt" | awk '{print $1}')
ACTUAL_HASH=$(shasum -a 256 "$BIN_DIR/opencode.tmp" | awk '{print $1}')
if [[ "$ACTUAL_HASH" == "$EXPECTED_HASH" ]]; then
echo -e "${GREEN}[OK]${NC} Checksum verified"
else
echo -e "${YELLOW}[WARN]${NC} Checksum mismatch (may be OK for some versions)"
fi
fi
mv "$BIN_DIR/opencode.tmp" "$BIN_DIR/opencode"
chmod +x "$BIN_DIR/opencode"
echo -e "${GREEN}[OK]${NC} OpenCode binary installed"
((ERRORS++))
fi
fi
fi
# ---------------------------------------------------------------
# STEP 6: OpenCode Setup
# ---------------------------------------------------------------
echo ""
echo "[STEP 6/8] Building UI assets"
if [[ -d "$SCRIPT_DIR/packages/ui/dist" ]]; then
echo -e "${GREEN}[OK]${NC} UI build already exists"
echo "[STEP 6/8] OpenCode Setup..."
echo ""
echo -e "${CYAN}==============================================================${NC}"
echo -e "${CYAN}|${NC} NomadArch supports Binary-Free Mode! ${CYAN}|${NC}"
echo -e "${CYAN}|${NC} Using free cloud models: GPT-5 Nano, Grok Code, etc. ${CYAN}|${NC}"
echo -e "${CYAN}==============================================================${NC}"
echo ""
echo -e "${GREEN}[OK]${NC} Using Binary-Free Mode (default)"
log "Using Binary-Free Mode"
# ---------------------------------------------------------------
# STEP 7: Build Assets
# ---------------------------------------------------------------
echo ""
echo "[STEP 7/8] Building Assets..."
if [[ -f "$TARGET_DIR/packages/ui/dist/index.html" ]]; then
echo -e "${GREEN}[OK]${NC} UI build exists"
else
echo -e "${BLUE}[INFO]${NC} Building UI"
pushd "$SCRIPT_DIR/packages/ui" >/dev/null
npm run build
popd >/dev/null
echo -e "${GREEN}[INFO]${NC} Building UI..."
cd "$TARGET_DIR/packages/ui" || exit 1
if npm run build; then
echo -e "${GREEN}[OK]${NC} UI assets built"
else
echo -e "${RED}[ERROR]${NC} UI build failed"
log "ERROR: UI build failed"
((ERRORS++))
fi
cd "$TARGET_DIR" || exit 1
fi
# ---------------------------------------------------------------
# STEP 8: Health Check
# ---------------------------------------------------------------
echo ""
echo "[STEP 7/8] Post-install health check"
HEALTH_ERRORS=0
echo "[STEP 8/8] Running Health Check..."
[[ -f "$SCRIPT_DIR/package.json" ]] || HEALTH_ERRORS=$((HEALTH_ERRORS+1))
[[ -d "$SCRIPT_DIR/packages/ui" ]] || HEALTH_ERRORS=$((HEALTH_ERRORS+1))
[[ -d "$SCRIPT_DIR/packages/server" ]] || HEALTH_ERRORS=$((HEALTH_ERRORS+1))
[[ -f "$SCRIPT_DIR/packages/ui/dist/index.html" ]] || HEALTH_ERRORS=$((HEALTH_ERRORS+1))
HEALTH_OK=1
if [[ $HEALTH_ERRORS -eq 0 ]]; then
echo -e "${GREEN}[OK]${NC} Health checks passed"
[[ -f "$TARGET_DIR/package.json" ]] || HEALTH_OK=0
[[ -d "$TARGET_DIR/packages/ui" ]] || HEALTH_OK=0
[[ -d "$TARGET_DIR/packages/server" ]] || HEALTH_OK=0
[[ -d "$TARGET_DIR/node_modules" ]] || HEALTH_OK=0
if [[ $HEALTH_OK -eq 1 ]]; then
echo -e "${GREEN}[OK]${NC} All checks passed"
else
echo -e "${RED}[ERROR]${NC} Health checks failed ($HEALTH_ERRORS)"
ERRORS=$((ERRORS+HEALTH_ERRORS))
echo -e "${RED}[ERROR]${NC} Health checks failed"
((ERRORS++))
fi
# ---------------------------------------------------------------
# SUMMARY
# ---------------------------------------------------------------
echo ""
echo "[STEP 8/8] Installation Summary"
echo -e "${CYAN}==============================================================${NC}"
echo -e "${CYAN}|${NC} INSTALLATION SUMMARY ${CYAN}|${NC}"
echo -e "${CYAN}==============================================================${NC}"
echo ""
echo " Install Dir: $TARGET_DIR"
echo " Architecture: $ARCH"
echo " Node.js: $NODE_VERSION"
echo " npm: $NPM_VERSION"
if [[ $BINARY_FREE_MODE -eq 1 ]]; then
echo " Mode: Binary-Free Mode (OpenCode Zen free models available)"
else
echo " Mode: Full Mode (OpenCode binary installed)"
fi
echo " Target: $TARGET_DIR"
echo " Mode: Binary-Free Mode"
echo " Errors: $ERRORS"
echo " Warnings: $WARNINGS"
echo " Log File: $LOG_FILE"
echo ""
if [[ $ERRORS -gt 0 ]]; then
echo -e "${RED}[RESULT]${NC} Installation completed with errors"
echo "Review $LOG_FILE for details."
echo -e "${RED}==============================================================${NC}"
echo -e "${RED} INSTALLATION FAILED${NC}"
echo -e "${RED}==============================================================${NC}"
echo "Check the log file: $LOG_FILE"
exit 1
else
echo -e "${GREEN}[RESULT]${NC} Installation completed successfully"
echo "Run: ./Launch-Unix.sh"
echo -e "${GREEN}==============================================================${NC}"
echo -e "${GREEN} INSTALLATION SUCCESSFUL!${NC}"
echo -e "${GREEN}==============================================================${NC}"
echo ""
if [[ $BINARY_FREE_MODE -eq 1 ]]; then
echo -e "${BLUE}NOTE:${NC} Running in Binary-Free Mode."
echo " Free models (GPT-5 Nano, Grok Code, GLM-4.7, etc.) are available."
echo " You can also authenticate with Qwen for additional models."
fi
echo "To start NomadArch, run:"
echo -e " ${BOLD}./Launch-Mac.sh${NC}"
echo ""
exit 0
fi
exit $ERRORS

View File

@@ -1,11 +1,16 @@
@echo off
REM NomadArch Windows Installer - ASCII Safe Version
REM This installer uses only ASCII characters for maximum compatibility
setlocal enabledelayedexpansion
title NomadArch Installer
title NomadArch Installer - Windows
echo.
echo NomadArch Installer (Windows)
echo Version: 0.5.0 - Binary-Free Mode
echo ===============================================================
echo NomadArch Installer for Windows
echo Version: 0.6.1 - Universal Edition
echo ===============================================================
echo.
set SCRIPT_DIR=%~dp0
@@ -17,192 +22,251 @@ set TEMP_DIR=%TARGET_DIR%\.install-temp
set ERRORS=0
set WARNINGS=0
set NEEDS_FALLBACK=0
set SKIP_OPENCODE=0
set SKIP_OPENCODE=1
set NODE_INSTALLED_NOW=0
echo [%date% %time%] Installer started >> "%LOG_FILE%"
echo [%date% %time%] ========== Installer started ========== >> "%LOG_FILE%"
echo [STEP 1/8] OS and Architecture Detection
REM ---------------------------------------------------------------
REM STEP 1: OS and Architecture Detection
REM ---------------------------------------------------------------
echo [STEP 1/8] Detecting System...
REM Use PowerShell for architecture detection (works on all Windows versions)
for /f "tokens=*" %%i in ('powershell -NoProfile -Command "[System.Environment]::Is64BitOperatingSystem"') do set IS64BIT=%%i
if /i "%IS64BIT%"=="True" (
for /f "tokens=2 delims==" %%a in ('wmic os get osarchitecture /value 2^>nul ^| find "="') do set ARCH_RAW=%%a
if "!ARCH_RAW!"=="" set ARCH_RAW=64-bit
echo !ARCH_RAW! | findstr /i "64" >nul
if !ERRORLEVEL! equ 0 (
set ARCH=x64
) else (
set ARCH=x86
)
echo [OK] Architecture: %ARCH%
for /f "tokens=4-5 delims=. " %%i in ('ver') do set WIN_VER=%%i.%%j
echo [OK] Windows Version: !WIN_VER!
echo [OK] Architecture: !ARCH!
echo [%date% %time%] OS: Windows !WIN_VER!, Arch: !ARCH! >> "%LOG_FILE%"
REM ---------------------------------------------------------------
REM STEP 2: Check Write Permissions
REM ---------------------------------------------------------------
echo.
echo [STEP 2/8] Checking write permissions
echo [STEP 2/8] Checking Write Permissions...
if not exist "%BIN_DIR%" mkdir "%BIN_DIR%" 2>nul
if not exist "%TEMP_DIR%" mkdir "%TEMP_DIR%" 2>nul
echo. > "%SCRIPT_DIR%\test-write.tmp" 2>nul
echo. > "%SCRIPT_DIR%\.write-test.tmp" 2>nul
if !ERRORLEVEL! neq 0 (
echo [WARN] Cannot write to current directory: %SCRIPT_DIR%
set TARGET_DIR=%USERPROFILE%\NomadArch-Install
echo [WARN] Cannot write to: %SCRIPT_DIR%
echo [INFO] Using fallback location in user profile...
set TARGET_DIR=%USERPROFILE%\NomadArch
set BIN_DIR=!TARGET_DIR!\bin
set LOG_FILE=!TARGET_DIR!\install.log
set TEMP_DIR=!TARGET_DIR!\.install-temp
if not exist "!TARGET_DIR!" mkdir "!TARGET_DIR!"
if not exist "!BIN_DIR!" mkdir "!BIN_DIR!"
if not exist "!TEMP_DIR!" mkdir "!TEMP_DIR!"
echo. > "!TARGET_DIR!\test-write.tmp" 2>nul
if !ERRORLEVEL! neq 0 (
echo [ERROR] Cannot write to fallback directory: !TARGET_DIR!
echo [%date% %time%] ERROR: Write permission denied >> "%LOG_FILE%"
set /a ERRORS+=1
goto :SUMMARY
)
del "!TARGET_DIR!\test-write.tmp"
set NEEDS_FALLBACK=1
echo [OK] Using fallback: !TARGET_DIR!
) else (
del "%SCRIPT_DIR%\test-write.tmp"
del "%SCRIPT_DIR%\.write-test.tmp" 2>nul
echo [OK] Write permissions verified
)
echo [%date% %time%] Install target: %TARGET_DIR% >> "%LOG_FILE%"
REM ---------------------------------------------------------------
REM STEP 3: Check and Install Node.js
REM ---------------------------------------------------------------
echo.
echo [STEP 3/8] Ensuring system dependencies
echo [STEP 3/8] Checking Node.js...
set WINGET_AVAILABLE=0
where winget >nul 2>&1
if !ERRORLEVEL! equ 0 set WINGET_AVAILABLE=1
set CHOCO_AVAILABLE=0
where choco >nul 2>&1
if !ERRORLEVEL! equ 0 set CHOCO_AVAILABLE=1
set DOWNLOAD_CMD=powershell
where curl >nul 2>&1
if !ERRORLEVEL! equ 0 set DOWNLOAD_CMD=curl
set NODE_OK=0
set NPM_OK=0
where node >nul 2>&1
if !ERRORLEVEL! neq 0 (
echo [INFO] Node.js not found. Attempting to install...
if !WINGET_AVAILABLE! equ 1 (
winget install -e --id OpenJS.NodeJS.LTS --accept-source-agreements --accept-package-agreements
) else if !CHOCO_AVAILABLE! equ 1 (
choco install nodejs-lts -y
if !ERRORLEVEL! equ 0 (
for /f "tokens=*" %%v in ('node --version 2^>nul') do set NODE_VERSION=%%v
if defined NODE_VERSION (
echo [OK] Node.js found: !NODE_VERSION!
set NODE_OK=1
)
)
if !NODE_OK! equ 0 (
echo [INFO] Node.js not found. Attempting automatic installation...
echo [%date% %time%] Node.js not found, attempting install >> "%LOG_FILE%"
REM Try winget first (Windows 10 1709+)
where winget >nul 2>&1
if !ERRORLEVEL! equ 0 (
echo [INFO] Installing Node.js via winget...
winget install -e --id OpenJS.NodeJS.LTS --accept-source-agreements --accept-package-agreements --silent 2>nul
if !ERRORLEVEL! equ 0 (
set NODE_INSTALLED_NOW=1
echo [OK] Node.js installed via winget
) else (
echo [ERROR] No supported package manager found.
echo Please install Node.js LTS from https://nodejs.org/
echo [WARN] Winget install failed, trying alternative...
)
)
REM Try chocolatey if winget failed
if !NODE_INSTALLED_NOW! equ 0 (
where choco >nul 2>&1
if !ERRORLEVEL! equ 0 (
echo [INFO] Installing Node.js via Chocolatey...
choco install nodejs-lts -y 2>nul
if !ERRORLEVEL! equ 0 (
set NODE_INSTALLED_NOW=1
echo [OK] Node.js installed via Chocolatey
)
)
)
REM Direct download as last resort
if !NODE_INSTALLED_NOW! equ 0 (
echo [INFO] Downloading Node.js installer directly...
set NODE_INSTALLER=%TEMP_DIR%\node-installer.msi
REM Download using PowerShell with proper error handling
powershell -NoProfile -ExecutionPolicy Bypass -Command "$ProgressPreference = 'SilentlyContinue'; try { Invoke-WebRequest -Uri 'https://nodejs.org/dist/v20.10.0/node-v20.10.0-x64.msi' -OutFile '%TEMP_DIR%\node-installer.msi' -UseBasicParsing; exit 0 } catch { exit 1 }" 2>nul
if exist "%TEMP_DIR%\node-installer.msi" (
echo [INFO] Running Node.js installer...
msiexec /i "%TEMP_DIR%\node-installer.msi" /qn /norestart 2>nul
if !ERRORLEVEL! equ 0 (
set NODE_INSTALLED_NOW=1
echo [OK] Node.js installed successfully
) else (
echo [ERROR] Node.js MSI installation failed
)
del "%TEMP_DIR%\node-installer.msi" 2>nul
) else (
echo [ERROR] Failed to download Node.js installer
)
)
if !NODE_INSTALLED_NOW! equ 1 (
echo.
echo ===============================================================
echo IMPORTANT: Node.js was just installed!
echo Please CLOSE this window and run Install-Windows.bat again.
echo This is required for the PATH to update.
echo ===============================================================
echo.
echo [%date% %time%] Node.js installed, restart required >> "%LOG_FILE%"
pause
exit /b 0
) else (
echo.
echo [ERROR] Could not install Node.js automatically.
echo.
echo Please install Node.js manually:
echo 1. Go to https://nodejs.org/
echo 2. Download and install the LTS version
echo 3. Restart this installer
echo.
echo [%date% %time%] ERROR: Node.js installation failed >> "%LOG_FILE%"
set /a ERRORS+=1
goto :SUMMARY
)
)
where node >nul 2>&1
if !ERRORLEVEL! neq 0 (
echo [ERROR] Node.js install failed or requires a new terminal session.
set /a ERRORS+=1
goto :SUMMARY
)
for /f "tokens=*" %%i in ('node --version') do set NODE_VERSION=%%i
echo [OK] Node.js: %NODE_VERSION%
REM Verify npm
where npm >nul 2>&1
if !ERRORLEVEL! neq 0 (
echo [ERROR] npm not found after Node.js install.
if !ERRORLEVEL! equ 0 (
for /f "tokens=*" %%v in ('npm --version 2^>nul') do set NPM_VERSION=%%v
if defined NPM_VERSION (
echo [OK] npm found: !NPM_VERSION!
set NPM_OK=1
)
)
if !NPM_OK! equ 0 (
echo [ERROR] npm not found. This usually comes with Node.js.
echo [%date% %time%] ERROR: npm not found >> "%LOG_FILE%"
set /a ERRORS+=1
goto :SUMMARY
)
for /f "tokens=*" %%i in ('npm --version') do set NPM_VERSION=%%i
echo [OK] npm: %NPM_VERSION%
REM ---------------------------------------------------------------
REM STEP 4: Check Git (optional)
REM ---------------------------------------------------------------
echo.
echo [STEP 4/8] Checking Git (optional)...
where git >nul 2>&1
if !ERRORLEVEL! neq 0 (
echo [INFO] Git not found. Attempting to install...
if !WINGET_AVAILABLE! equ 1 (
winget install -e --id Git.Git --accept-source-agreements --accept-package-agreements
) else if !CHOCO_AVAILABLE! equ 1 (
choco install git -y
) else (
echo [WARN] Git not installed - optional
set /a WARNINGS+=1
)
if !ERRORLEVEL! equ 0 (
for /f "tokens=*" %%v in ('git --version 2^>nul') do set GIT_VERSION=%%v
echo [OK] !GIT_VERSION!
) else (
for /f "tokens=*" %%i in ('git --version') do set GIT_VERSION=%%i
echo [OK] Git: !GIT_VERSION!
echo [INFO] Git not found (optional - not required for basic usage)
set /a WARNINGS+=1
)
REM ---------------------------------------------------------------
REM STEP 5: Install npm Dependencies
REM ---------------------------------------------------------------
echo.
echo [STEP 4/8] Installing npm dependencies
echo [STEP 5/8] Installing Dependencies...
cd /d "%SCRIPT_DIR%"
if not exist "package.json" (
echo [ERROR] package.json not found in %SCRIPT_DIR%
echo [ERROR] Make sure you extracted the full NomadArch package.
echo [%date% %time%] ERROR: package.json missing >> "%LOG_FILE%"
set /a ERRORS+=1
goto :SUMMARY
)
echo [INFO] Running npm install (this may take a few minutes)...
echo [%date% %time%] Running npm install >> "%LOG_FILE%"
call npm install
call npm install --no-audit --no-fund 2>&1
if !ERRORLEVEL! neq 0 (
echo [WARN] npm install had issues, trying with legacy peer deps...
call npm install --legacy-peer-deps --no-audit --no-fund 2>&1
if !ERRORLEVEL! neq 0 (
echo [ERROR] npm install failed!
echo [%date% %time%] ERROR: npm install failed >> "%LOG_FILE%"
set /a ERRORS+=1
goto :SUMMARY
)
)
echo [OK] Dependencies installed
REM ---------------------------------------------------------------
REM STEP 6: OpenCode Binary (OPTIONAL)
REM ---------------------------------------------------------------
echo.
echo [STEP 5/8] OpenCode Binary - OPTIONAL
echo [STEP 6/8] OpenCode Binary Setup...
echo.
echo [INFO] NomadArch now supports Binary-Free Mode!
echo [INFO] You can use the application without OpenCode binary.
echo [INFO] Free models from OpenCode Zen are available without the binary.
echo ===============================================================
echo NomadArch supports Binary-Free Mode!
echo You can skip the OpenCode binary and use free cloud models:
echo - GPT-5 Nano, Grok Code, GLM-4.7, Doubao, and more
echo ===============================================================
echo.
if not exist "%BIN_DIR%" mkdir "%BIN_DIR%" 2>nul
set /p SKIP_CHOICE="Skip OpenCode binary download? (Y for Binary-Free / N to download) [Y]: "
if /i "!SKIP_CHOICE!"=="" set SKIP_CHOICE=Y
if /i "!SKIP_CHOICE!"=="Y" goto :skip_opencode_download
REM Download OpenCode binary
echo [INFO] Fetching OpenCode version info...
for /f "delims=" %%v in ('powershell -NoProfile -Command "try { (Invoke-WebRequest -UseBasicParsing https://api.github.com/repos/sst/opencode/releases/latest).Content | ConvertFrom-Json | Select-Object -ExpandProperty tag_name } catch { 'v0.1.44' }"') do set OPENCODE_VERSION=%%v
set OPENCODE_VERSION=!OPENCODE_VERSION:v=!
set OPENCODE_BASE=https://github.com/sst/opencode/releases/download/v!OPENCODE_VERSION!
set OPENCODE_URL=!OPENCODE_BASE!/opencode-windows-%ARCH%.exe
set CHECKSUM_URL=!OPENCODE_BASE!/checksums.txt
if exist "%BIN_DIR%\opencode.exe" (
echo [OK] OpenCode binary already exists
echo [%date% %time%] OpenCode binary exists, skipping download >> "%LOG_FILE%"
goto :opencode_done
)
echo [INFO] Downloading OpenCode v!OPENCODE_VERSION!...
if "!DOWNLOAD_CMD!"=="curl" (
curl -L -o "%BIN_DIR%\opencode.exe.tmp" "!OPENCODE_URL!"
) else (
powershell -NoProfile -Command "Invoke-WebRequest -Uri '!OPENCODE_URL!' -OutFile '%BIN_DIR%\opencode.exe.tmp'"
)
if exist "%BIN_DIR%\opencode.exe.tmp" (
move /Y "%BIN_DIR%\opencode.exe.tmp" "%BIN_DIR%\opencode.exe" >nul
echo [OK] OpenCode downloaded
) else (
echo [WARN] OpenCode download failed - using Binary-Free Mode instead
set SKIP_OPENCODE=1
)
goto :opencode_done
:skip_opencode_download
set SKIP_OPENCODE=1
echo [INFO] Skipping OpenCode binary - using Binary-Free Mode
echo [OK] Using Binary-Free Mode (default)
echo [%date% %time%] Using Binary-Free Mode >> "%LOG_FILE%"
:opencode_done
REM ---------------------------------------------------------------
REM STEP 7: Build UI Assets
REM ---------------------------------------------------------------
echo.
echo [STEP 6/8] Building UI assets
echo [STEP 7/8] Building UI Assets...
if exist "%SCRIPT_DIR%\packages\ui\dist\index.html" (
echo [OK] UI build already exists
) else (
echo [INFO] Building UI assets...
pushd packages\ui
call npm run build
echo [INFO] Building UI (this may take 1-2 minutes)...
pushd "%SCRIPT_DIR%\packages\ui"
call npm run build 2>&1
if !ERRORLEVEL! neq 0 (
echo [ERROR] UI build failed!
echo [%date% %time%] ERROR: UI build failed >> "%LOG_FILE%"
popd
set /a ERRORS+=1
goto :SUMMARY
@@ -211,54 +275,92 @@ if exist "%SCRIPT_DIR%\packages\ui\dist\index.html" (
echo [OK] UI assets built successfully
)
REM ---------------------------------------------------------------
REM STEP 8: Health Check and Summary
REM ---------------------------------------------------------------
echo.
echo [STEP 7/8] Post-install health check
set HEALTH_ERRORS=0
echo [STEP 8/8] Running Health Check...
if not exist "%SCRIPT_DIR%\package.json" set /a HEALTH_ERRORS+=1
if not exist "%SCRIPT_DIR%\packages\ui" set /a HEALTH_ERRORS+=1
if not exist "%SCRIPT_DIR%\packages\server" set /a HEALTH_ERRORS+=1
if not exist "%SCRIPT_DIR%\packages\ui\dist\index.html" set /a HEALTH_ERRORS+=1
set HEALTH_OK=1
if !HEALTH_ERRORS! equ 0 (
echo [OK] Health checks passed
) else (
echo [ERROR] Health checks failed: !HEALTH_ERRORS! issues
set /a ERRORS+=!HEALTH_ERRORS!
if not exist "%SCRIPT_DIR%\package.json" (
echo [FAIL] package.json missing
set HEALTH_OK=0
)
echo.
echo [STEP 8/8] Installation Summary
echo.
echo Install Dir: %TARGET_DIR%
echo Architecture: %ARCH%
echo Node.js: %NODE_VERSION%
echo npm: %NPM_VERSION%
if !SKIP_OPENCODE! equ 1 (
echo Mode: Binary-Free Mode
) else (
echo Mode: Full Mode with OpenCode binary
if not exist "%SCRIPT_DIR%\packages\ui" (
echo [FAIL] packages\ui directory missing
set HEALTH_OK=0
)
if not exist "%SCRIPT_DIR%\packages\server" (
echo [FAIL] packages\server directory missing
set HEALTH_OK=0
)
if not exist "%SCRIPT_DIR%\packages\ui\dist\index.html" (
echo [FAIL] UI build missing (packages\ui\dist\index.html)
set HEALTH_OK=0
)
if not exist "%SCRIPT_DIR%\node_modules" (
echo [FAIL] node_modules directory missing
set HEALTH_OK=0
)
if !HEALTH_OK! equ 1 (
echo [OK] All health checks passed
) else (
echo [ERROR] Health checks failed
set /a ERRORS+=1
)
:SUMMARY
echo.
echo ===============================================================
echo INSTALLATION SUMMARY
echo ===============================================================
echo.
echo Install Directory: %TARGET_DIR%
echo Architecture: !ARCH!
if defined NODE_VERSION echo Node.js: !NODE_VERSION!
if defined NPM_VERSION echo npm: !NPM_VERSION!
echo Mode: Binary-Free Mode
echo Errors: !ERRORS!
echo Warnings: !WARNINGS!
echo Log File: %LOG_FILE%
echo.
:SUMMARY
if !ERRORS! gtr 0 (
echo [RESULT] Installation completed with errors.
echo Review the log: %LOG_FILE%
echo ===============================================================
echo INSTALLATION FAILED
echo ===============================================================
echo.
echo If Node.js was just installed, open a new terminal and run this installer again.
echo Review the errors above and check the log file: %LOG_FILE%
echo.
echo Common fixes:
echo 1. Run as Administrator (right-click, Run as administrator)
echo 2. Ensure internet connection is stable
echo 3. Disable antivirus temporarily
echo 4. Install Node.js manually from https://nodejs.org/
echo.
echo [%date% %time%] Installation FAILED with !ERRORS! errors >> "%LOG_FILE%"
) else (
echo [RESULT] Installation completed successfully.
echo Run Launch-Windows.bat to start the application.
echo ===============================================================
echo INSTALLATION SUCCESSFUL!
echo ===============================================================
echo.
if !SKIP_OPENCODE! equ 1 (
echo NOTE: Running in Binary-Free Mode.
echo Free models: GPT-5 Nano, Grok Code, GLM-4.7, etc.
echo You can also authenticate with Qwen for additional models.
)
echo To start NomadArch, run:
echo Launch-Windows.bat
echo.
echo Available Free Models:
echo - GPT-5 Nano (fast)
echo - Grok Code (coding)
echo - GLM-4.7 (general)
echo - Doubao (creative)
echo - Big Pickle (experimental)
echo.
echo [%date% %time%] Installation SUCCESSFUL >> "%LOG_FILE%"
)
echo.

View File

@@ -1,33 +0,0 @@
{
"sessions": {
"01KDFA3KMG1VSQNA217HZ3JAYA": {
"id": "01KDFA3KMG1VSQNA217HZ3JAYA",
"workspaceId": "mjnyjm5r",
"title": "New Session",
"parentId": null,
"createdAt": 1766819221136,
"updatedAt": 1766819221136,
"messageIds": [],
"model": {
"providerId": "opencode-zen",
"modelId": "grok-code"
},
"agent": "Assistant"
},
"01KDFA3SP5YJAB7B8BC2EM48NY": {
"id": "01KDFA3SP5YJAB7B8BC2EM48NY",
"workspaceId": "mjnyjm5r",
"title": "New Session",
"parentId": null,
"createdAt": 1766819227333,
"updatedAt": 1766819227333,
"messageIds": [],
"model": {
"providerId": "opencode-zen",
"modelId": "grok-code"
},
"agent": "Assistant"
}
},
"messages": {}
}

View File

@@ -1,19 +0,0 @@
{
"sessions": {
"01KDFB53053F09XN5CJ3S7PQWH": {
"id": "01KDFB53053F09XN5CJ3S7PQWH",
"workspaceId": "mjnz73yi",
"title": "New Session",
"parentId": null,
"createdAt": 1766820318213,
"updatedAt": 1766820318213,
"messageIds": [],
"model": {
"providerId": "opencode-zen",
"modelId": "grok-code"
},
"agent": "Assistant"
}
},
"messages": {}
}

View File

@@ -68,10 +68,23 @@ export function setupCliIPC(mainWindow: BrowserWindow, cliManager: CliProcessMan
})
ipcMain.handle("users:createGuest", async () => {
const user = createGuestUser()
// Set up isolated environment for guest user
const root = getUserDataRoot(user.id)
cliManager.setUserEnv({
CODENOMAD_USER_DIR: root,
CLI_CONFIG: path.join(root, "config.json"),
})
await cliManager.stop()
const devMode = process.env.NODE_ENV === "development"
await cliManager.start({ dev: devMode })
// Set as active user
setActiveUser(user.id)
return user
})
ipcMain.handle("users:login", async (_, payload: { id: string; password?: string }) => {
console.log("[IPC:users:login] Attempting login for:", payload.id, "password length:", payload.password?.length)
const ok = verifyPassword(payload.id, payload.password ?? "")
console.log("[IPC:users:login] verifyPassword result:", ok)
if (!ok) {
return { success: false }
}

View File

@@ -5,7 +5,7 @@ import { fileURLToPath } from "url"
import { createApplicationMenu } from "./menu"
import { setupCliIPC } from "./ipc"
import { CliProcessManager } from "./process-manager"
import { ensureDefaultUsers, getActiveUser, getUserDataRoot, clearGuestUsers } from "./user-store"
import { ensureDefaultUsers, getActiveUser, getUserDataRoot, clearGuestUsers, logoutActiveUser } from "./user-store"
const mainFilename = fileURLToPath(import.meta.url)
const mainDirname = dirname(mainFilename)
@@ -481,6 +481,8 @@ if (isMac) {
}
app.whenReady().then(() => {
clearGuestUsers()
logoutActiveUser()
ensureDefaultUsers()
applyUserEnvToCli()
startCli()

View File

@@ -111,19 +111,50 @@ function migrateLegacyData(targetDir: string) {
export function ensureDefaultUsers(): UserRecord {
const store = readStore()
if (store.users.length > 0) {
const active = store.users.find((u) => u.id === store.activeUserId) ?? store.users[0]
if (!store.activeUserId) {
store.activeUserId = active.id
// If roman exists, ensure his password is updated to the new required one if it matches the old default
const roman = store.users.find(u => u.name === "roman")
if (roman && roman.salt && roman.passwordHash) {
const oldDefaultHash = hashPassword("q1w2e3r4", roman.salt)
if (roman.passwordHash === oldDefaultHash) {
console.log("[UserStore] Updating roman's password to new default")
const newSalt = generateSalt()
roman.salt = newSalt
roman.passwordHash = hashPassword("!@#$q1w2e3r4", newSalt)
roman.updatedAt = nowIso()
writeStore(store)
}
// NEW: Check if roman needs data migration (e.g. if he was created before migration logic was robust)
const userDir = getUserDir(roman.id)
const configPath = path.join(userDir, "config.json")
let needsMigration = !existsSync(configPath)
if (!needsMigration) {
try {
const config = JSON.parse(readFileSync(configPath, "utf-8"))
if (!config.recentFolders || config.recentFolders.length === 0) {
needsMigration = true
}
} catch (e) {
needsMigration = true
}
}
if (needsMigration) {
console.log(`[UserStore] Roman exists but seems to have missing data. Triggering migration to ${userDir}...`)
migrateLegacyData(userDir)
}
}
if (store.users.length > 0) {
const active = store.users.find((u) => u.id === store.activeUserId) ?? store.users[0]
return active
}
const existingIds = new Set<string>()
const userId = ensureUniqueId("roman", existingIds)
const salt = generateSalt()
const passwordHash = hashPassword("q1w2e3r4", salt)
const passwordHash = hashPassword("!@#$q1w2e3r4", salt)
const record: UserRecord = {
id: userId,
name: "roman",
@@ -134,7 +165,6 @@ export function ensureDefaultUsers(): UserRecord {
}
store.users.push(record)
store.activeUserId = record.id
writeStore(store)
const userDir = getUserDir(record.id)
@@ -153,6 +183,13 @@ export function getActiveUser(): UserRecord | null {
return store.users.find((user) => user.id === store.activeUserId) ?? null
}
export function logoutActiveUser() {
const store = readStore()
store.activeUserId = undefined
writeStore(store)
console.log("[UserStore] Active user logged out")
}
export function setActiveUser(userId: string) {
const store = readStore()
const user = store.users.find((u) => u.id === userId)
@@ -239,10 +276,20 @@ export function deleteUser(userId: string) {
export function verifyPassword(userId: string, password: string): boolean {
const store = readStore()
const user = store.users.find((u) => u.id === userId)
if (!user) return false
if (!user) {
console.log("[verifyPassword] User not found:", userId)
return false
}
if (user.isGuest) return true
if (!user.salt || !user.passwordHash) return false
return hashPassword(password, user.salt) === user.passwordHash
if (!user.salt || !user.passwordHash) {
console.log("[verifyPassword] No salt or hash for user:", userId)
return false
}
const computed = hashPassword(password, user.salt)
const matches = computed === user.passwordHash
console.log("[verifyPassword] userId:", userId, "password:", JSON.stringify(password), "len:", password.length)
console.log("[verifyPassword] computed:", computed, "stored:", user.passwordHash, "matches:", matches)
return matches
}
export function getUserDataRoot(userId: string) {

View File

@@ -16,6 +16,8 @@ export interface Task {
status: TaskStatus
timestamp: number
messageIds?: string[] // IDs of messages associated with this task
taskSessionId?: string
archived?: boolean
}
export interface SessionTasks {
@@ -190,6 +192,16 @@ export interface InstanceData {
agentModelSelections: AgentModelSelection
sessionTasks?: SessionTasks // Multi-task chat support: tasks per session
sessionSkills?: Record<string, SkillSelection[]> // Selected skills per session
sessionMessages?: Record<
string,
Array<{
id: string
role: "user" | "assistant" | "system" | "tool"
content?: string
createdAt?: number
updatedAt?: number
}>
>
customAgents?: Array<{
name: string
description?: string

View File

@@ -11,6 +11,7 @@
* Uses Google OAuth credentials stored via the Antigravity OAuth flow
*/
import { randomUUID } from "crypto"
import { z } from "zod"
// Configuration schema for Antigravity
@@ -18,9 +19,9 @@ export const AntigravityConfigSchema = z.object({
enabled: z.boolean().default(true),
// Multiple endpoints for automatic fallback (daily → autopush → prod)
endpoints: z.array(z.string()).default([
"https://daily.antigravity.dev/v1beta",
"https://autopush.antigravity.dev/v1beta",
"https://antigravity.dev/v1beta"
"https://daily-cloudcode-pa.sandbox.googleapis.com",
"https://autopush-cloudcode-pa.sandbox.googleapis.com",
"https://cloudcode-pa.googleapis.com"
]),
apiKey: z.string().optional()
})
@@ -180,6 +181,14 @@ export const ANTIGRAVITY_MODELS: AntigravityModel[] = [
tool_call: true,
limit: { context: 200000, output: 64000 }
},
{
id: "claude-opus-4-5",
name: "Claude Opus 4.5 (Antigravity)",
family: "claude",
reasoning: false,
tool_call: true,
limit: { context: 200000, output: 64000 }
},
{
id: "claude-opus-4-5-thinking-low",
name: "Claude Opus 4.5 Thinking Low (Antigravity)",
@@ -217,6 +226,31 @@ export const ANTIGRAVITY_MODELS: AntigravityModel[] = [
// Token storage key for Antigravity OAuth
const ANTIGRAVITY_TOKEN_KEY = "antigravity_oauth_token"
const ANTIGRAVITY_HEADERS = {
"User-Agent": "antigravity/1.11.5 windows/amd64",
"X-Goog-Api-Client": "google-cloud-sdk vscode_cloudshelleditor/0.1",
"Client-Metadata": "{\"ideType\":\"IDE_UNSPECIFIED\",\"platform\":\"PLATFORM_UNSPECIFIED\",\"pluginType\":\"GEMINI\"}",
} as const
const LOAD_ASSIST_HEADERS = {
"User-Agent": "google-api-nodejs-client/9.15.1",
"X-Goog-Api-Client": "google-cloud-sdk vscode_cloudshelleditor/0.1",
"Client-Metadata": ANTIGRAVITY_HEADERS["Client-Metadata"],
} as const
const DEFAULT_PROJECT_ID = process.env.ANTIGRAVITY_PROJECT_ID || "rising-fact-p41fc"
const LOAD_ASSIST_METADATA = {
ideType: "IDE_UNSPECIFIED",
platform: "PLATFORM_UNSPECIFIED",
pluginType: "GEMINI"
} as const
const LOAD_ENDPOINTS = [
"https://cloudcode-pa.googleapis.com",
"https://daily-cloudcode-pa.sandbox.googleapis.com",
"https://autopush-cloudcode-pa.sandbox.googleapis.com"
] as const
const STREAM_ACTION = "streamGenerateContent"
const GENERATE_ACTION = "generateContent"
export interface AntigravityToken {
access_token: string
@@ -226,11 +260,55 @@ export interface AntigravityToken {
project_id?: string
}
function generateSyntheticProjectId(): string {
const adjectives = ["useful", "bright", "swift", "calm", "bold"]
const nouns = ["fuze", "wave", "spark", "flow", "core"]
const adj = adjectives[Math.floor(Math.random() * adjectives.length)]
const noun = nouns[Math.floor(Math.random() * nouns.length)]
const random = randomUUID().slice(0, 5).toLowerCase()
return `${adj}-${noun}-${random}`
}
function collectSystemInstruction(messages: ChatMessage[]): string | undefined {
const systemParts: string[] = []
for (const message of messages) {
if (message.role === "system" && typeof message.content === "string") {
systemParts.push(message.content)
}
}
const combined = systemParts.join("\n\n").trim()
return combined.length > 0 ? combined : undefined
}
function buildContents(messages: ChatMessage[]): Array<{ role: "user" | "model"; parts: Array<{ text: string }> }> {
const contents: Array<{ role: "user" | "model"; parts: Array<{ text: string }> }> = []
for (const message of messages) {
if (!message.content) continue
if (message.role === "system") continue
const role = message.role === "assistant" ? "model" : "user"
const prefix = message.role === "tool" ? "Tool result:\n" : ""
contents.push({
role,
parts: [{ text: `${prefix}${message.content}` }]
})
}
return contents
}
function extractTextFromResponse(payload: any): string {
const candidates = payload?.candidates
if (!Array.isArray(candidates) || candidates.length === 0) return ""
const parts = candidates[0]?.content?.parts
if (!Array.isArray(parts)) return ""
return parts.map((part: any) => (typeof part?.text === "string" ? part.text : "")).join("")
}
export class AntigravityClient {
private config: AntigravityConfig
private currentEndpointIndex: number = 0
private modelsCache: AntigravityModel[] | null = null
private modelsCacheTime: number = 0
private projectIdCache: string | null = null
private readonly CACHE_TTL_MS = 5 * 60 * 1000 // 5 minutes
constructor(config?: Partial<AntigravityConfig>) {
@@ -280,10 +358,17 @@ export class AntigravityClient {
/**
* Get authorization headers for API requests
*/
private getAuthHeaders(): Record<string, string> {
private getAuthHeaders(accessToken?: string): Record<string, string> {
const headers: Record<string, string> = {
"Content-Type": "application/json",
"User-Agent": "NomadArch/1.0"
"User-Agent": ANTIGRAVITY_HEADERS["User-Agent"],
"X-Goog-Api-Client": ANTIGRAVITY_HEADERS["X-Goog-Api-Client"],
"Client-Metadata": ANTIGRAVITY_HEADERS["Client-Metadata"],
}
if (accessToken) {
headers["Authorization"] = `Bearer ${accessToken}`
return headers
}
// Try OAuth token first
@@ -297,152 +382,129 @@ export class AntigravityClient {
return headers
}
private getLoadHeaders(accessToken: string): Record<string, string> {
return {
"Content-Type": "application/json",
"User-Agent": LOAD_ASSIST_HEADERS["User-Agent"],
"X-Goog-Api-Client": LOAD_ASSIST_HEADERS["X-Goog-Api-Client"],
"Client-Metadata": LOAD_ASSIST_HEADERS["Client-Metadata"],
Authorization: `Bearer ${accessToken}`,
}
}
/**
* Check if the client is authenticated
*/
isAuthenticated(): boolean {
isAuthenticated(accessToken?: string): boolean {
if (accessToken) return true
const token = this.getStoredToken()
return this.isTokenValid(token) || Boolean(this.config.apiKey)
}
/**
* Get available Antigravity models
*/
async getModels(): Promise<AntigravityModel[]> {
// Return cached models if still valid
const now = Date.now()
if (this.modelsCache && (now - this.modelsCacheTime) < this.CACHE_TTL_MS) {
return this.modelsCache
}
// If authenticated, return full model list
if (this.isAuthenticated()) {
this.modelsCache = ANTIGRAVITY_MODELS
this.modelsCacheTime = now
return ANTIGRAVITY_MODELS
}
// Not authenticated - return empty list
return []
}
/**
* Test connection to Antigravity API
*/
async testConnection(): Promise<boolean> {
if (!this.isAuthenticated()) {
return false
private async resolveProjectId(accessToken: string | undefined, projectIdOverride?: string): Promise<string> {
const requestedProjectId = projectIdOverride?.trim()
if (this.projectIdCache && !requestedProjectId) return this.projectIdCache
if (!accessToken) {
const fallback = requestedProjectId || generateSyntheticProjectId()
if (requestedProjectId) {
this.projectIdCache = requestedProjectId
}
return fallback
}
const loadEndpoints = Array.from(new Set([...LOAD_ENDPOINTS, ...this.config.endpoints]))
const tryLoad = async (metadata: Record<string, string>): Promise<string | null> => {
for (const endpoint of loadEndpoints) {
try {
// Try a simple models list request to verify connectivity
const response = await fetch(`${this.getEndpoint()}/models`, {
headers: this.getAuthHeaders(),
signal: AbortSignal.timeout(10000)
})
return response.ok
} catch (error) {
console.warn("Antigravity connection test failed:", error)
// Try next endpoint
this.rotateEndpoint()
return false
}
}
/**
* Chat completion (streaming) with automatic endpoint fallback
*/
async *chatStream(request: ChatRequest): AsyncGenerator<ChatChunk> {
if (!this.isAuthenticated()) {
throw new Error("Antigravity: Not authenticated. Please sign in with Google OAuth.")
}
let lastError: Error | null = null
const maxRetries = this.config.endpoints.length
for (let retry = 0; retry < maxRetries; retry++) {
try {
const endpoint = this.getEndpoint()
const response = await fetch(`${endpoint}/chat/completions`, {
const response = await fetch(`${endpoint}/v1internal:loadCodeAssist`, {
method: "POST",
headers: this.getAuthHeaders(),
body: JSON.stringify({
...request,
stream: true
headers: this.getLoadHeaders(accessToken),
body: JSON.stringify({ metadata }),
signal: AbortSignal.timeout(10000),
})
})
if (!response.ok) {
const errorText = await response.text()
if (response.status === 401 || response.status === 403) {
throw new Error(`Antigravity authentication failed: ${errorText}`)
if (!response.ok) continue
const data = await response.json() as any
const projectId =
data?.cloudaicompanionProject?.id ||
data?.cloudaicompanionProject ||
data?.projectId
if (typeof projectId === "string" && projectId.length > 0) {
return projectId
}
// Try next endpoint for other errors
this.rotateEndpoint()
lastError = new Error(`Antigravity API error (${response.status}): ${errorText}`)
} catch {
continue
}
if (!response.body) {
throw new Error("Response body is missing")
}
return null
}
const reader = response.body.getReader()
const decoder = new TextDecoder()
let buffer = ""
try {
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.startsWith("data: ")) {
const data = trimmed.slice(6)
if (data === "[DONE]") return
try {
const parsed = JSON.parse(data)
yield parsed as ChatChunk
// Check for finish
if (parsed.choices?.[0]?.finish_reason) {
return
let resolvedProjectId: string | null = null
const baseMetadata: Record<string, string> = { ...LOAD_ASSIST_METADATA }
if (requestedProjectId) {
baseMetadata.duetProject = requestedProjectId
resolvedProjectId = await tryLoad(baseMetadata)
} else {
resolvedProjectId = await tryLoad(baseMetadata)
if (!resolvedProjectId) {
const fallbackMetadata: Record<string, string> = {
...LOAD_ASSIST_METADATA,
duetProject: DEFAULT_PROJECT_ID,
}
} catch (e) {
// Skip invalid JSON
}
}
}
}
} finally {
reader.releaseLock()
}
return // Success, exit retry loop
} catch (error) {
lastError = error instanceof Error ? error : new Error(String(error))
if (error instanceof Error && error.message.includes("authentication")) {
throw error // Don't retry auth errors
}
this.rotateEndpoint()
resolvedProjectId = await tryLoad(fallbackMetadata)
}
}
throw lastError || new Error("Antigravity: All endpoints failed")
const fallbackProjectId = requestedProjectId || DEFAULT_PROJECT_ID
const finalProjectId = resolvedProjectId || fallbackProjectId
this.projectIdCache = finalProjectId
return finalProjectId
}
/**
* Chat completion (non-streaming)
*/
async chat(request: ChatRequest): Promise<ChatChunk> {
if (!this.isAuthenticated()) {
throw new Error("Antigravity: Not authenticated. Please sign in with Google OAuth.")
private resolveAccessToken(accessToken?: string): string | null {
if (accessToken) return accessToken
const token = this.getStoredToken()
if (token && this.isTokenValid(token)) {
return token.access_token
}
if (this.config.apiKey) {
return this.config.apiKey
}
return null
}
private async requestGenerateContent(request: ChatRequest, accessToken?: string, projectIdOverride?: string): Promise<string> {
const authToken = this.resolveAccessToken(accessToken)
if (!authToken) {
throw new Error("Antigravity: Missing access token.")
}
const projectId = await this.resolveProjectId(authToken, projectIdOverride)
const systemInstruction = collectSystemInstruction(request.messages)
const contents = buildContents(request.messages)
const generationConfig: Record<string, unknown> = {}
if (typeof request.temperature === "number") {
generationConfig.temperature = request.temperature
}
if (typeof request.max_tokens === "number") {
generationConfig.maxOutputTokens = request.max_tokens
}
const requestPayload: Record<string, unknown> = { contents }
if (systemInstruction) {
requestPayload.systemInstruction = { parts: [{ text: systemInstruction }] }
}
if (Object.keys(generationConfig).length > 0) {
requestPayload.generationConfig = generationConfig
}
const body = JSON.stringify({
project: projectId,
model: request.model,
request: requestPayload,
userAgent: "antigravity",
requestId: `agent-${randomUUID()}`
})
let lastError: Error | null = null
const maxRetries = this.config.endpoints.length
@@ -450,13 +512,11 @@ export class AntigravityClient {
for (let retry = 0; retry < maxRetries; retry++) {
try {
const endpoint = this.getEndpoint()
const response = await fetch(`${endpoint}/chat/completions`, {
const response = await fetch(`${endpoint}/v1internal:${GENERATE_ACTION}`, {
method: "POST",
headers: this.getAuthHeaders(),
body: JSON.stringify({
...request,
stream: false
})
headers: this.getAuthHeaders(authToken),
body,
signal: AbortSignal.timeout(120000)
})
if (!response.ok) {
@@ -469,7 +529,8 @@ export class AntigravityClient {
continue
}
return await response.json()
const data = await response.json()
return extractTextFromResponse(data)
} catch (error) {
lastError = error instanceof Error ? error : new Error(String(error))
if (error instanceof Error && error.message.includes("authentication")) {
@@ -481,15 +542,139 @@ export class AntigravityClient {
throw lastError || new Error("Antigravity: All endpoints failed")
}
/**
* Get available Antigravity models
*/
async getModels(accessToken?: string): Promise<AntigravityModel[]> {
// Return full model list even if not authenticated, so they appear in selectors
// Authenticaton is checked during actual chat requests
const now = Date.now()
if (this.modelsCache && (now - this.modelsCacheTime) < this.CACHE_TTL_MS) {
return this.modelsCache
}
// If authenticated, return full model list
this.modelsCache = ANTIGRAVITY_MODELS
this.modelsCacheTime = now
return ANTIGRAVITY_MODELS
}
/**
* Test connection to Antigravity API
*/
async testConnection(accessToken?: string, projectIdOverride?: string): Promise<{ connected: boolean; error?: string; status?: number }> {
if (!this.isAuthenticated(accessToken)) {
return { connected: false, error: "Not authenticated" }
}
try {
const authToken = this.resolveAccessToken(accessToken)
if (!authToken) {
return { connected: false, error: "Not authenticated" }
}
const requestedProjectId = projectIdOverride?.trim()
const loadEndpoints = Array.from(new Set([...LOAD_ENDPOINTS, ...this.config.endpoints]))
let lastErrorText = ""
let lastStatus: number | undefined
const tryLoad = async (metadata: Record<string, string>): Promise<boolean> => {
for (const endpoint of loadEndpoints) {
const response = await fetch(`${endpoint}/v1internal:loadCodeAssist`, {
method: "POST",
headers: this.getLoadHeaders(authToken),
body: JSON.stringify({ metadata }),
signal: AbortSignal.timeout(10000)
})
if (response.ok) {
return true
}
lastStatus = response.status
lastErrorText = await response.text().catch(() => "") || response.statusText
}
return false
}
const baseMetadata: Record<string, string> = { ...LOAD_ASSIST_METADATA }
if (requestedProjectId) {
baseMetadata.duetProject = requestedProjectId
}
let success = await tryLoad(baseMetadata)
if (!success && !requestedProjectId) {
const fallbackMetadata: Record<string, string> = {
...LOAD_ASSIST_METADATA,
duetProject: DEFAULT_PROJECT_ID,
}
success = await tryLoad(fallbackMetadata)
}
if (success) {
return { connected: true }
}
return {
connected: false,
status: lastStatus,
error: lastErrorText || "Connection test failed"
}
} catch (error) {
console.warn("Antigravity connection test failed:", error)
return { connected: false, error: error instanceof Error ? error.message : String(error) }
}
}
/**
* Chat completion (streaming) with automatic endpoint fallback
*/
async *chatStream(request: ChatRequest, accessToken?: string, projectIdOverride?: string): AsyncGenerator<ChatChunk> {
if (!this.isAuthenticated(accessToken)) {
throw new Error("Antigravity: Not authenticated. Please sign in with Google OAuth.")
}
const content = await this.requestGenerateContent(request, accessToken, projectIdOverride)
yield {
id: randomUUID(),
choices: [
{
index: 0,
delta: { content },
finish_reason: "stop"
}
]
}
}
/**
* Chat completion (non-streaming)
*/
async chat(request: ChatRequest, accessToken?: string, projectIdOverride?: string): Promise<ChatChunk> {
if (!this.isAuthenticated(accessToken)) {
throw new Error("Antigravity: Not authenticated. Please sign in with Google OAuth.")
}
const content = await this.requestGenerateContent(request, accessToken, projectIdOverride)
return {
id: randomUUID(),
choices: [
{
index: 0,
message: {
role: "assistant",
content
},
finish_reason: "stop"
}
]
}
}
}
export function getDefaultAntigravityConfig(): AntigravityConfig {
return {
enabled: true,
endpoints: [
"https://daily.antigravity.dev/v1beta",
"https://autopush.antigravity.dev/v1beta",
"https://antigravity.dev/v1beta"
"https://daily-cloudcode-pa.sandbox.googleapis.com",
"https://autopush-cloudcode-pa.sandbox.googleapis.com",
"https://cloudcode-pa.googleapis.com"
]
}
}

View File

@@ -1,9 +1,7 @@
import { z } from "zod"
import { createHmac } from "crypto"
export const ZAIConfigSchema = z.object({
apiKey: z.string().optional(),
endpoint: z.string().default("https://api.z.ai/api/coding/paas/v4"),
endpoint: z.string().default("https://api.z.ai/api"),
enabled: z.boolean().default(false),
timeout: z.number().default(300000)
})
@@ -142,7 +140,8 @@ export class ZAIClient {
constructor(config: ZAIConfig) {
this.config = config
this.baseUrl = config.endpoint.replace(/\/$/, "")
const trimmed = config.endpoint.replace(/\/$/, "")
this.baseUrl = trimmed.replace(/\/(?:api\/coding\/)?paas\/v4$/, "")
}
async testConnection(): Promise<boolean> {
@@ -151,7 +150,7 @@ export class ZAIClient {
}
try {
const response = await fetch(`${this.baseUrl}/chat/completions`, {
const response = await fetch(`${this.baseUrl}/paas/v4/chat/completions`, {
method: "POST",
headers: this.getHeaders(),
body: JSON.stringify({
@@ -182,7 +181,7 @@ export class ZAIClient {
throw new Error("Z.AI API key is required")
}
const response = await fetch(`${this.baseUrl}/chat/completions`, {
const response = await fetch(`${this.baseUrl}/paas/v4/chat/completions`, {
method: "POST",
headers: this.getHeaders(),
body: JSON.stringify({
@@ -236,7 +235,7 @@ export class ZAIClient {
throw new Error("Z.AI API key is required")
}
const response = await fetch(`${this.baseUrl}/chat/completions`, {
const response = await fetch(`${this.baseUrl}/paas/v4/chat/completions`, {
method: "POST",
headers: this.getHeaders(),
body: JSON.stringify({
@@ -254,52 +253,9 @@ export class ZAIClient {
}
private getHeaders(): Record<string, string> {
const token = this.generateToken(this.config.apiKey!)
return {
"Content-Type": "application/json",
"Authorization": `Bearer ${token}`
}
}
private generateToken(apiKey: string, expiresIn: number = 3600): string {
try {
const [id, secret] = apiKey.split(".")
if (!id || !secret) return apiKey // Fallback or handle error
const now = Date.now()
const payload = {
api_key: id,
exp: now + expiresIn * 1000,
timestamp: now
}
const header = {
alg: "HS256",
sign_type: "SIGN"
}
const base64UrlEncode = (obj: any) => {
return Buffer.from(JSON.stringify(obj))
.toString('base64')
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '')
}
const encodedHeader = base64UrlEncode(header)
const encodedPayload = base64UrlEncode(payload)
const signature = createHmac("sha256", secret)
.update(`${encodedHeader}.${encodedPayload}`)
.digest("base64")
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '')
return `${encodedHeader}.${encodedPayload}.${signature}`
} catch (e) {
console.warn("Failed to generate JWT, using raw key", e)
return apiKey
"Authorization": `Bearer ${this.config.apiKey!}`
}
}

View File

@@ -1,3 +1,5 @@
import { createHash, randomBytes, randomUUID } from "crypto"
import { createServer } from "http"
import { FastifyInstance } from "fastify"
import { AntigravityClient, type ChatRequest, getDefaultAntigravityConfig, type ChatMessage } from "../../integrations/antigravity"
import { Logger } from "../../logger"
@@ -11,29 +13,202 @@ interface AntigravityRouteDeps {
// Maximum number of tool execution loops
const MAX_TOOL_LOOPS = 10
// Google OAuth Device Flow configuration
// Using the same client ID as gcloud CLI / Cloud SDK
// Google OAuth Authorization Code + PKCE configuration (Antigravity-compatible)
const GOOGLE_OAUTH_CONFIG = {
clientId: "764086051850-6qr4p6gpi6hn506pt8ejuq83di341hur.apps.googleusercontent.com",
clientSecret: "d-FL95Q19q7MQmFpd7hHD0Ty", // Public client secret for device flow
deviceAuthEndpoint: "https://oauth2.googleapis.com/device/code",
clientId: process.env.ANTIGRAVITY_GOOGLE_CLIENT_ID || "1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com",
clientSecret: process.env.ANTIGRAVITY_GOOGLE_CLIENT_SECRET || "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",
redirectUri: process.env.ANTIGRAVITY_GOOGLE_REDIRECT_URI || "http://localhost:51121/oauth-callback",
authEndpoint: "https://accounts.google.com/o/oauth2/v2/auth",
tokenEndpoint: "https://oauth2.googleapis.com/token",
scopes: [
"openid",
"email",
"profile",
"https://www.googleapis.com/auth/cloud-platform"
]
"https://www.googleapis.com/auth/cloud-platform",
"https://www.googleapis.com/auth/userinfo.email",
"https://www.googleapis.com/auth/userinfo.profile",
"https://www.googleapis.com/auth/cclog",
"https://www.googleapis.com/auth/experimentsandconfigs",
],
}
// Active device auth sessions (in-memory, per-server instance)
const deviceAuthSessions = new Map<string, {
deviceCode: string
userCode: string
verificationUrl: string
const AUTH_SESSION_TTL_MS = 10 * 60 * 1000
const DEFAULT_POLL_INTERVAL_SEC = 5
const callbackUrl = new URL(GOOGLE_OAUTH_CONFIG.redirectUri)
const callbackPath = callbackUrl.pathname || "/oauth-callback"
const callbackPort = Number(callbackUrl.port || "0") || (callbackUrl.protocol === "https:" ? 443 : 80)
type OAuthSession = {
verifier: string
createdAt: number
expiresAt: number
interval: number
}>()
token?: {
accessToken: string
refreshToken?: string
expiresIn: number
tokenType?: string
scope?: string
}
error?: string
}
// Active OAuth sessions (in-memory, per-server instance)
const oauthSessions = new Map<string, OAuthSession>()
let oauthCallbackServer: ReturnType<typeof createServer> | null = null
function base64UrlEncode(value: Buffer): string {
return value
.toString("base64")
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/g, "")
}
function createCodeVerifier(): string {
return base64UrlEncode(randomBytes(32))
}
function createCodeChallenge(verifier: string): string {
const digest = createHash("sha256").update(verifier).digest()
return base64UrlEncode(digest)
}
function getAccessTokenFromHeader(authorization: string | undefined): string | null {
if (!authorization) return null
const [type, token] = authorization.split(" ")
if (!type || type.toLowerCase() !== "bearer" || !token) return null
return token.trim()
}
function getProjectIdFromHeader(value: string | string[] | undefined): string | undefined {
if (typeof value === "string" && value.trim()) return value.trim()
if (Array.isArray(value)) {
const entry = value.find((item) => typeof item === "string" && item.trim())
if (entry) return entry.trim()
}
return undefined
}
async function exchangeAuthorizationCode(code: string, verifier: string): Promise<{
accessToken: string
refreshToken?: string
expiresIn: number
tokenType?: string
scope?: string
}> {
const params = new URLSearchParams({
client_id: GOOGLE_OAUTH_CONFIG.clientId,
code,
grant_type: "authorization_code",
redirect_uri: GOOGLE_OAUTH_CONFIG.redirectUri,
code_verifier: verifier,
})
if (GOOGLE_OAUTH_CONFIG.clientSecret) {
params.set("client_secret", GOOGLE_OAUTH_CONFIG.clientSecret)
}
const response = await fetch(GOOGLE_OAUTH_CONFIG.tokenEndpoint, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: params,
})
if (!response.ok) {
const errorText = await response.text()
throw new Error(errorText || `Token exchange failed (${response.status})`)
}
const data = await response.json() as any
return {
accessToken: data.access_token,
refreshToken: data.refresh_token,
expiresIn: data.expires_in,
tokenType: data.token_type,
scope: data.scope,
}
}
function ensureOAuthCallbackServer(logger: Logger): void {
if (oauthCallbackServer) return
oauthCallbackServer = createServer((req, res) => {
void (async () => {
try {
const url = new URL(req.url || "", GOOGLE_OAUTH_CONFIG.redirectUri)
if (url.pathname !== callbackPath) {
res.writeHead(404)
res.end()
return
}
const state = url.searchParams.get("state")
const code = url.searchParams.get("code")
const error = url.searchParams.get("error")
if (!state) {
res.writeHead(400, { "Content-Type": "text/plain" })
res.end("Missing OAuth state.")
return
}
const session = oauthSessions.get(state)
if (!session) {
res.writeHead(404, { "Content-Type": "text/plain" })
res.end("OAuth session not found or expired.")
return
}
if (error) {
session.error = error
res.writeHead(200, { "Content-Type": "text/html" })
res.end("<h2>Sign-in cancelled.</h2><p>You can close this window.</p>")
return
}
if (!code) {
session.error = "Missing authorization code."
res.writeHead(400, { "Content-Type": "text/plain" })
res.end("Missing authorization code.")
return
}
try {
const token = await exchangeAuthorizationCode(code, session.verifier)
session.token = token
session.error = undefined
res.writeHead(200, { "Content-Type": "text/html" })
res.end("<h2>Sign-in complete.</h2><p>You can close this window and return to the app.</p>")
} catch (err) {
const message = err instanceof Error ? err.message : "OAuth callback failed."
session.error = message
res.writeHead(500, { "Content-Type": "text/plain" })
res.end(message)
}
} catch (err) {
const message = err instanceof Error ? err.message : "OAuth callback failed."
res.writeHead(500, { "Content-Type": "text/plain" })
res.end(message)
}
})()
})
oauthCallbackServer.on("error", (err) => {
logger.error({ err, port: callbackPort }, "Antigravity OAuth callback server failed to start")
oauthCallbackServer = null
})
oauthCallbackServer.listen(callbackPort, "127.0.0.1", () => {
logger.info({ port: callbackPort, path: callbackPath }, "Antigravity OAuth callback server listening")
})
}
function cleanupExpiredSessions(): void {
const now = Date.now()
for (const [id, session] of oauthSessions) {
if (session.expiresAt <= now) {
oauthSessions.delete(id)
}
}
}
export async function registerAntigravityRoutes(
app: FastifyInstance,
@@ -47,7 +222,8 @@ export async function registerAntigravityRoutes(
// List available Antigravity models
app.get('/api/antigravity/models', async (request, reply) => {
try {
const models = await client.getModels()
const accessToken = getAccessTokenFromHeader(request.headers.authorization)
const models = await client.getModels(accessToken ?? undefined)
return {
models: models.map(m => ({
@@ -70,7 +246,8 @@ export async function registerAntigravityRoutes(
// Check authentication status
app.get('/api/antigravity/auth-status', async (request, reply) => {
try {
const authenticated = client.isAuthenticated()
const accessToken = getAccessTokenFromHeader(request.headers.authorization)
const authenticated = client.isAuthenticated(accessToken ?? undefined)
return { authenticated }
} catch (error) {
logger.error({ error }, "Antigravity auth status check failed")
@@ -81,8 +258,10 @@ export async function registerAntigravityRoutes(
// Test connection
app.get('/api/antigravity/test', async (request, reply) => {
try {
const connected = await client.testConnection()
return { connected }
const accessToken = getAccessTokenFromHeader(request.headers.authorization)
const projectId = getProjectIdFromHeader(request.headers["x-antigravity-project"])
const result = await client.testConnection(accessToken ?? undefined, projectId)
return result
} catch (error) {
logger.error({ error }, "Antigravity connection test failed")
return reply.status(500).send({ error: "Connection test failed" })
@@ -90,80 +269,54 @@ export async function registerAntigravityRoutes(
})
// ==========================================
// Google Device Authorization Flow Endpoints
// Google OAuth Authorization Flow (PKCE)
// ==========================================
// Step 1: Start device authorization - returns user_code and verification URL
// Step 1: Start OAuth authorization - returns auth URL
app.post('/api/antigravity/device-auth/start', async (request, reply) => {
try {
logger.info("Starting Google Device Authorization flow for Antigravity")
logger.info("Starting Google OAuth flow for Antigravity")
ensureOAuthCallbackServer(logger)
const response = await fetch(GOOGLE_OAUTH_CONFIG.deviceAuthEndpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
body: new URLSearchParams({
client_id: GOOGLE_OAUTH_CONFIG.clientId,
scope: GOOGLE_OAUTH_CONFIG.scopes.join(' ')
const sessionId = randomUUID()
const verifier = createCodeVerifier()
const challenge = createCodeChallenge(verifier)
const authUrl = new URL(GOOGLE_OAUTH_CONFIG.authEndpoint)
authUrl.searchParams.set("client_id", GOOGLE_OAUTH_CONFIG.clientId)
authUrl.searchParams.set("response_type", "code")
authUrl.searchParams.set("redirect_uri", GOOGLE_OAUTH_CONFIG.redirectUri)
authUrl.searchParams.set("scope", GOOGLE_OAUTH_CONFIG.scopes.join(" "))
authUrl.searchParams.set("code_challenge", challenge)
authUrl.searchParams.set("code_challenge_method", "S256")
authUrl.searchParams.set("state", sessionId)
authUrl.searchParams.set("access_type", "offline")
authUrl.searchParams.set("prompt", "consent")
oauthSessions.set(sessionId, {
verifier,
createdAt: Date.now(),
expiresAt: Date.now() + AUTH_SESSION_TTL_MS,
})
})
if (!response.ok) {
const errorText = await response.text()
logger.error({ error: errorText, status: response.status }, "Device auth request failed")
return reply.status(500).send({
error: `Device authorization failed: ${response.status}`,
details: errorText
})
}
const data = await response.json() as {
device_code: string
user_code: string
verification_url: string
expires_in: number
interval: number
}
// Generate a session ID for tracking this auth flow
const sessionId = crypto.randomUUID()
// Store the session
deviceAuthSessions.set(sessionId, {
deviceCode: data.device_code,
userCode: data.user_code,
verificationUrl: data.verification_url,
expiresAt: Date.now() + (data.expires_in * 1000),
interval: data.interval
})
// Clean up expired sessions
for (const [id, session] of deviceAuthSessions) {
if (session.expiresAt < Date.now()) {
deviceAuthSessions.delete(id)
}
}
logger.info({ sessionId, userCode: data.user_code, verificationUrl: data.verification_url }, "Device auth session created")
cleanupExpiredSessions()
return {
sessionId,
userCode: data.user_code,
verificationUrl: data.verification_url,
expiresIn: data.expires_in,
interval: data.interval
userCode: "",
verificationUrl: authUrl.toString(),
expiresIn: Math.floor(AUTH_SESSION_TTL_MS / 1000),
interval: DEFAULT_POLL_INTERVAL_SEC,
}
} catch (error: any) {
logger.error({ error: error.message, stack: error.stack }, "Failed to start device authorization")
logger.error({ error: error.message, stack: error.stack }, "Failed to start OAuth authorization")
return reply.status(500).send({
error: "Failed to start device authorization",
details: error.message
error: "Failed to start authentication",
details: error.message,
})
}
})
// Step 2: Poll for token (called by client after user enters code)
// Step 2: Poll for token (called by client after browser sign-in)
app.post('/api/antigravity/device-auth/poll', async (request, reply) => {
try {
const { sessionId } = request.body as { sessionId: string }
@@ -172,68 +325,36 @@ export async function registerAntigravityRoutes(
return reply.status(400).send({ error: "Missing sessionId" })
}
const session = deviceAuthSessions.get(sessionId)
cleanupExpiredSessions()
const session = oauthSessions.get(sessionId)
if (!session) {
return reply.status(404).send({ error: "Session not found or expired" })
}
if (session.expiresAt < Date.now()) {
deviceAuthSessions.delete(sessionId)
oauthSessions.delete(sessionId)
return reply.status(410).send({ error: "Session expired" })
}
// Poll Google's token endpoint
const response = await fetch(GOOGLE_OAUTH_CONFIG.tokenEndpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
body: new URLSearchParams({
client_id: GOOGLE_OAUTH_CONFIG.clientId,
client_secret: GOOGLE_OAUTH_CONFIG.clientSecret,
device_code: session.deviceCode,
grant_type: 'urn:ietf:params:oauth:grant-type:device_code'
})
})
const data = await response.json() as any
if (data.error) {
// Still waiting for user
if (data.error === 'authorization_pending') {
return { status: 'pending', interval: session.interval }
}
// Slow down polling
if (data.error === 'slow_down') {
session.interval = Math.min(session.interval + 5, 60)
return { status: 'pending', interval: session.interval }
}
// User denied or other error
if (data.error === 'access_denied') {
deviceAuthSessions.delete(sessionId)
return { status: 'denied' }
}
if (data.error === 'expired_token') {
deviceAuthSessions.delete(sessionId)
return { status: 'expired' }
if (session.error) {
oauthSessions.delete(sessionId)
return { status: "error", error: session.error }
}
logger.error({ error: data.error }, "Token poll error")
return { status: 'error', error: data.error }
if (!session.token) {
return { status: "pending", interval: DEFAULT_POLL_INTERVAL_SEC }
}
// Success! We have tokens
deviceAuthSessions.delete(sessionId)
logger.info("Device authorization successful")
const token = session.token
oauthSessions.delete(sessionId)
return {
status: 'success',
accessToken: data.access_token,
refreshToken: data.refresh_token,
expiresIn: data.expires_in,
tokenType: data.token_type,
scope: data.scope
status: "success",
accessToken: token.accessToken,
refreshToken: token.refreshToken,
expiresIn: token.expiresIn,
tokenType: token.tokenType,
scope: token.scope,
}
} catch (error) {
logger.error({ error }, "Failed to poll for token")
@@ -250,17 +371,21 @@ export async function registerAntigravityRoutes(
return reply.status(400).send({ error: "Missing refreshToken" })
}
const params = new URLSearchParams({
client_id: GOOGLE_OAUTH_CONFIG.clientId,
refresh_token: refreshToken,
grant_type: "refresh_token",
})
if (GOOGLE_OAUTH_CONFIG.clientSecret) {
params.set("client_secret", GOOGLE_OAUTH_CONFIG.clientSecret)
}
const response = await fetch(GOOGLE_OAUTH_CONFIG.tokenEndpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
body: new URLSearchParams({
client_id: GOOGLE_OAUTH_CONFIG.clientId,
client_secret: GOOGLE_OAUTH_CONFIG.clientSecret,
refresh_token: refreshToken,
grant_type: 'refresh_token'
})
body: params
})
if (!response.ok) {
@@ -289,6 +414,8 @@ export async function registerAntigravityRoutes(
workspacePath?: string
enableTools?: boolean
}
const accessToken = getAccessTokenFromHeader(request.headers.authorization)
const projectId = getProjectIdFromHeader(request.headers["x-antigravity-project"])
// Extract workspace path for tool execution
const workspacePath = chatRequest.workspacePath || process.cwd()
@@ -313,6 +440,8 @@ export async function registerAntigravityRoutes(
await streamWithToolLoop(
client,
chatRequest,
accessToken ?? undefined,
projectId,
workspacePath,
enableTools,
reply.raw,
@@ -329,6 +458,8 @@ export async function registerAntigravityRoutes(
const response = await chatWithToolLoop(
client,
chatRequest,
accessToken ?? undefined,
projectId,
workspacePath,
enableTools,
logger
@@ -341,7 +472,7 @@ export async function registerAntigravityRoutes(
}
})
logger.info("Antigravity routes registered with Google Device Auth flow!")
logger.info("Antigravity routes registered with Google OAuth flow!")
}
/**
@@ -350,6 +481,8 @@ export async function registerAntigravityRoutes(
async function streamWithToolLoop(
client: AntigravityClient,
request: ChatRequest,
accessToken: string | undefined,
projectId: string | undefined,
workspacePath: string,
enableTools: boolean,
rawResponse: any,
@@ -390,7 +523,7 @@ async function streamWithToolLoop(
let textContent = ""
// Stream response
for await (const chunk of client.chatStream({ ...requestWithTools, messages })) {
for await (const chunk of client.chatStream({ ...requestWithTools, messages }, accessToken, projectId)) {
// Write chunk to client
rawResponse.write(`data: ${JSON.stringify(chunk)}\n\n`)
@@ -491,6 +624,8 @@ async function streamWithToolLoop(
async function chatWithToolLoop(
client: AntigravityClient,
request: ChatRequest,
accessToken: string | undefined,
projectId: string | undefined,
workspacePath: string,
enableTools: boolean,
logger: Logger
@@ -509,7 +644,7 @@ async function chatWithToolLoop(
while (loopCount < MAX_TOOL_LOOPS) {
loopCount++
const response = await client.chat({ ...requestWithTools, messages, stream: false })
const response = await client.chat({ ...requestWithTools, messages, stream: false }, accessToken, projectId)
lastResponse = response
const choice = response.choices[0]

View File

@@ -209,6 +209,51 @@ export function registerNativeSessionsRoutes(app: FastifyInstance, deps: NativeS
}
})
// Append messages to a session (client-side persistence)
app.post<{
Params: { workspaceId: string; sessionId: string }
Body: {
messages: Array<{
id?: string
role: "user" | "assistant" | "system" | "tool"
content?: string
createdAt?: number
updatedAt?: number
status?: "pending" | "streaming" | "completed" | "error"
}>
}
}>("/api/native/workspaces/:workspaceId/sessions/:sessionId/messages", async (request, reply) => {
const { workspaceId, sessionId } = request.params
const payload = request.body?.messages
if (!Array.isArray(payload)) {
reply.code(400)
return { error: "messages array is required" }
}
try {
const results: SessionMessage[] = []
for (const entry of payload) {
if (!entry || typeof entry.role !== "string") {
continue
}
const saved = await sessionManager.addMessage(workspaceId, sessionId, {
id: entry.id,
role: entry.role,
content: entry.content,
createdAt: entry.createdAt,
updatedAt: entry.updatedAt,
status: entry.status,
})
results.push(saved)
}
return { messages: results }
} catch (error) {
logger.error({ error }, "Failed to append messages")
reply.code(500)
return { error: "Failed to append messages" }
}
})
// Add a message (user prompt) and get streaming response
app.post<{
Params: { workspaceId: string; sessionId: string }
@@ -616,22 +661,23 @@ async function streamWithZAI(
let content = ""
const toolCalls: ToolCall[] = []
const baseUrl = "https://api.z.ai"
const baseUrl = "https://api.z.ai/api"
try {
const headers: Record<string, string> = {
"Content-Type": "application/json",
}
if (accessToken) {
headers["Authorization"] = `Bearer ${accessToken}`
if (!accessToken) {
throw new Error("Z.AI API key required. Please authenticate with Z.AI first.")
}
headers["Authorization"] = `Bearer ${accessToken}`
const response = await fetch(`${baseUrl}/v1/chat/completions`, {
const response = await fetch(`${baseUrl}/paas/v4/chat/completions`, {
method: "POST",
headers,
body: JSON.stringify({
model: model ?? "z1-mini",
model: model ?? "glm-4.7",
messages,
stream: true,
tools: tools.length > 0 ? tools : undefined,

View File

@@ -9,10 +9,13 @@ import {
import { Logger } from "../../logger"
import fs from "fs"
import path from "path"
import { getUserIntegrationsDir } from "../../user-data"
import { getUserIntegrationsDir, getUserIdFromRequest } from "../../user-context"
const CONFIG_DIR = getUserIntegrationsDir()
const CONFIG_FILE = path.join(CONFIG_DIR, "ollama-config.json")
// Helper to get config file path for a user
function getConfigFile(userId?: string | null): string {
const configDir = getUserIntegrationsDir(userId || undefined)
return path.join(configDir, "ollama-config.json")
}
interface OllamaRouteDeps {
logger: Logger
@@ -26,7 +29,8 @@ export async function registerOllamaRoutes(
app.get('/api/ollama/config', async (request, reply) => {
try {
const config = getOllamaConfig()
const userId = getUserIdFromRequest(request)
const config = getOllamaConfig(userId)
return { config: { ...config, apiKey: config.apiKey ? '***' : undefined } }
} catch (error) {
logger.error({ error }, "Failed to get Ollama config")
@@ -48,9 +52,10 @@ export async function registerOllamaRoutes(
}
}, async (request, reply) => {
try {
const userId = getUserIdFromRequest(request)
const { enabled, apiKey, endpoint } = request.body as any
updateOllamaConfig({ enabled, apiKey, endpoint })
logger.info("Ollama Cloud configuration updated")
updateOllamaConfig({ enabled, apiKey, endpoint }, userId)
logger.info({ userId }, "Ollama Cloud configuration updated for user")
return { success: true, config: { enabled, endpoint, apiKey: apiKey ? '***' : undefined } }
} catch (error) {
logger.error({ error }, "Failed to update Ollama config")
@@ -60,7 +65,8 @@ export async function registerOllamaRoutes(
app.post('/api/ollama/test', async (request, reply) => {
try {
const config = getOllamaConfig()
const userId = getUserIdFromRequest(request)
const config = getOllamaConfig(userId)
if (!config.enabled) {
return reply.status(400).send({ error: "Ollama Cloud is not enabled" })
}
@@ -556,24 +562,27 @@ export async function registerOllamaRoutes(
logger.info("Ollama Cloud routes registered")
}
function getOllamaConfig(): OllamaCloudConfig {
function getOllamaConfig(userId?: string | null): OllamaCloudConfig {
const configFile = getConfigFile(userId)
try {
if (!fs.existsSync(CONFIG_FILE)) {
if (!fs.existsSync(configFile)) {
return { enabled: false, endpoint: "https://ollama.com" }
}
const data = fs.readFileSync(CONFIG_FILE, 'utf-8')
const data = fs.readFileSync(configFile, 'utf-8')
return JSON.parse(data)
} catch {
return { enabled: false, endpoint: "https://ollama.com" }
}
}
function updateOllamaConfig(config: Partial<OllamaCloudConfig>): void {
function updateOllamaConfig(config: Partial<OllamaCloudConfig>, userId?: string | null): void {
const configFile = getConfigFile(userId)
const configDir = getUserIntegrationsDir(userId || undefined)
try {
if (!fs.existsSync(CONFIG_DIR)) {
fs.mkdirSync(CONFIG_DIR, { recursive: true })
if (!fs.existsSync(configDir)) {
fs.mkdirSync(configDir, { recursive: true })
}
const current = getOllamaConfig()
const current = getOllamaConfig(userId)
// Only update apiKey if a new non-empty value is provided
const updated = {
@@ -583,8 +592,8 @@ function updateOllamaConfig(config: Partial<OllamaCloudConfig>): void {
apiKey: config.apiKey || current.apiKey
}
fs.writeFileSync(CONFIG_FILE, JSON.stringify(updated, null, 2))
console.log(`[Ollama] Config saved: enabled=${updated.enabled}, endpoint=${updated.endpoint}, hasApiKey=${!!updated.apiKey}`)
fs.writeFileSync(configFile, JSON.stringify(updated, null, 2))
console.log(`[Ollama] Config saved for user ${userId || "default"}: enabled=${updated.enabled}, endpoint=${updated.endpoint}, hasApiKey=${!!updated.apiKey}`)
} catch (error) {
console.error("Failed to save Ollama config:", error)
}

View File

@@ -23,6 +23,8 @@ function normalizeQwenModel(model?: string): string {
const raw = (model || "").trim()
if (!raw) return "coder-model"
const lower = raw.toLowerCase()
if (lower.startsWith("qwen-")) return lower
if (lower.includes("qwen")) return lower
if (lower === "vision-model" || lower.includes("vision")) return "vision-model"
if (lower === "coder-model") return "coder-model"
if (lower.includes("coder")) return "coder-model"
@@ -410,6 +412,7 @@ export async function registerQwenRoutes(
'Connection': 'keep-alive',
})
try {
await streamWithToolLoop(
accessToken,
chatUrl,
@@ -419,6 +422,12 @@ export async function registerQwenRoutes(
reply.raw,
logger
)
reply.raw.end()
} catch (streamError) {
logger.error({ error: streamError }, "Qwen streaming failed")
reply.raw.write(`data: ${JSON.stringify({ error: String(streamError) })}\n\n`)
reply.raw.end()
}
} else {
const response = await fetch(chatUrl, {
method: 'POST',
@@ -437,6 +446,11 @@ export async function registerQwenRoutes(
}
} catch (error) {
logger.error({ error }, "Qwen chat proxy failed")
if (reply.raw.headersSent) {
reply.raw.write(`data: ${JSON.stringify({ error: String(error) })}\n\n`)
reply.raw.end()
return
}
return reply.status(500).send({ error: "Chat request failed" })
}
})

View File

@@ -3,7 +3,7 @@ import { z } from "zod"
import { InstanceStore } from "../../storage/instance-store"
import { EventBus } from "../../events/bus"
import { ModelPreferenceSchema } from "../../config/schema"
import type { InstanceData, Task, SessionTasks } from "../../api-types"
import type { InstanceData } from "../../api-types"
import { WorkspaceManager } from "../../workspaces/manager"
interface RouteDeps {
@@ -18,12 +18,28 @@ const TaskSchema = z.object({
status: z.enum(["completed", "interrupted", "in-progress", "pending"]),
timestamp: z.number(),
messageIds: z.array(z.string()).optional(),
taskSessionId: z.string().optional(),
archived: z.boolean().optional(),
})
const InstanceDataSchema = z.object({
messageHistory: z.array(z.string()).default([]),
agentModelSelections: z.record(z.string(), ModelPreferenceSchema).default({}),
sessionTasks: z.record(z.string(), z.array(TaskSchema)).optional(),
sessionMessages: z
.record(
z.string(),
z.array(
z.object({
id: z.string(),
role: z.enum(["user", "assistant", "system", "tool"]),
content: z.string().optional(),
createdAt: z.number().optional(),
updatedAt: z.number().optional(),
}),
),
)
.optional(),
sessionSkills: z
.record(
z.string(),
@@ -45,6 +61,7 @@ const EMPTY_INSTANCE_DATA: InstanceData = {
messageHistory: [],
agentModelSelections: {},
sessionTasks: {},
sessionMessages: {},
sessionSkills: {},
customAgents: [],
}

View File

@@ -1,9 +1,9 @@
import { FastifyInstance } from "fastify"
import { FastifyInstance, FastifyRequest } from "fastify"
import { ZAIClient, ZAI_MODELS, type ZAIConfig, type ZAIChatRequest, type ZAIMessage } from "../../integrations/zai-api"
import { Logger } from "../../logger"
import { existsSync, readFileSync, writeFileSync, mkdirSync } from "fs"
import { join } from "path"
import { getUserIntegrationsDir } from "../../user-data"
import { getUserIntegrationsDir, getUserIdFromRequest } from "../../user-context"
import { CORE_TOOLS, executeTools, type ToolCall, type ToolResult } from "../../tools/executor"
import { getMcpManager } from "../../mcp/client"
@@ -11,27 +11,27 @@ interface ZAIRouteDeps {
logger: Logger
}
const CONFIG_DIR = getUserIntegrationsDir()
const CONFIG_FILE = join(CONFIG_DIR, "zai-config.json")
// Maximum number of tool execution loops to prevent infinite recursion
const MAX_TOOL_LOOPS = 10
// Helper to get config file path for a user
function getConfigFile(userId?: string | null): string {
const configDir = getUserIntegrationsDir(userId || undefined)
return join(configDir, "zai-config.json")
}
export async function registerZAIRoutes(
app: FastifyInstance,
deps: ZAIRouteDeps
) {
const logger = deps.logger.child({ component: "zai-routes" })
// Ensure config directory exists
if (!existsSync(CONFIG_DIR)) {
mkdirSync(CONFIG_DIR, { recursive: true })
}
// Get Z.AI configuration
// Get Z.AI configuration (per-user)
app.get('/api/zai/config', async (request, reply) => {
try {
const config = getZAIConfig()
const userId = getUserIdFromRequest(request)
const config = getZAIConfig(userId)
logger.debug({ userId }, "Getting Z.AI config for user")
return { config: { ...config, apiKey: config.apiKey ? '***' : undefined } }
} catch (error) {
logger.error({ error }, "Failed to get Z.AI config")
@@ -39,12 +39,13 @@ export async function registerZAIRoutes(
}
})
// Update Z.AI configuration
// Update Z.AI configuration (per-user)
app.post('/api/zai/config', async (request, reply) => {
try {
const userId = getUserIdFromRequest(request)
const { enabled, apiKey, endpoint } = request.body as Partial<ZAIConfig>
updateZAIConfig({ enabled, apiKey, endpoint })
logger.info("Z.AI configuration updated")
updateZAIConfig({ enabled, apiKey, endpoint }, userId)
logger.info({ userId }, "Z.AI configuration updated for user")
return { success: true, config: { enabled, endpoint, apiKey: apiKey ? '***' : undefined } }
} catch (error) {
logger.error({ error }, "Failed to update Z.AI config")
@@ -52,10 +53,11 @@ export async function registerZAIRoutes(
}
})
// Test Z.AI connection
// Test Z.AI connection (per-user)
app.post('/api/zai/test', async (request, reply) => {
try {
const config = getZAIConfig()
const userId = getUserIdFromRequest(request)
const config = getZAIConfig(userId)
if (!config.enabled) {
return reply.status(400).send({ error: "Z.AI is not enabled" })
}
@@ -80,10 +82,11 @@ export async function registerZAIRoutes(
}
})
// Chat completion endpoint WITH MCP TOOL SUPPORT
// Chat completion endpoint WITH MCP TOOL SUPPORT (per-user)
app.post('/api/zai/chat', async (request, reply) => {
try {
const config = getZAIConfig()
const userId = getUserIdFromRequest(request)
const config = getZAIConfig(userId)
if (!config.enabled) {
return reply.status(400).send({ error: "Z.AI is not enabled" })
}
@@ -348,20 +351,48 @@ async function chatWithToolLoop(
return lastResponse
}
function getZAIConfig(): ZAIConfig {
function getZAIConfig(userId?: string | null): ZAIConfig {
const configFile = getConfigFile(userId)
try {
if (existsSync(CONFIG_FILE)) {
const data = readFileSync(CONFIG_FILE, 'utf-8')
return JSON.parse(data)
console.log(`[Z.AI] Looking for config at: ${configFile} (user: ${userId || "default"})`)
if (existsSync(configFile)) {
const data = readFileSync(configFile, 'utf-8')
const parsed = JSON.parse(data)
console.log(`[Z.AI] Config loaded from file, enabled: ${parsed.enabled}`)
return parsed
}
console.log(`[Z.AI] Config file not found, using defaults`)
return { enabled: false, endpoint: "https://api.z.ai/api/coding/paas/v4", timeout: 300000 }
} catch {
} catch (error) {
console.error(`[Z.AI] Error reading config:`, error)
return { enabled: false, endpoint: "https://api.z.ai/api/coding/paas/v4", timeout: 300000 }
}
}
function updateZAIConfig(config: Partial<ZAIConfig>): void {
const current = getZAIConfig()
function updateZAIConfig(config: Partial<ZAIConfig>, userId?: string | null): void {
const configFile = getConfigFile(userId)
const configDir = getUserIntegrationsDir(userId || undefined)
// Ensure directory exists with proper error handling
try {
if (!existsSync(configDir)) {
console.log(`[Z.AI] Creating config directory: ${configDir}`)
mkdirSync(configDir, { recursive: true })
}
} catch (mkdirError) {
console.error(`[Z.AI] Failed to create config directory:`, mkdirError)
throw new Error(`Failed to create config directory: ${mkdirError}`)
}
const current = getZAIConfig(userId)
const updated = { ...current, ...config }
writeFileSync(CONFIG_FILE, JSON.stringify(updated, null, 2))
try {
console.log(`[Z.AI] Writing config to: ${configFile} (user: ${userId || "default"})`)
writeFileSync(configFile, JSON.stringify(updated, null, 2), 'utf-8')
console.log(`[Z.AI] Config saved successfully`)
} catch (writeError) {
console.error(`[Z.AI] Failed to write config file:`, writeError)
throw new Error(`Failed to write config file: ${writeError}`)
}
}

View File

@@ -8,6 +8,7 @@ const DEFAULT_INSTANCE_DATA: InstanceData = {
messageHistory: [],
agentModelSelections: {},
sessionTasks: {},
sessionMessages: {},
}
export class InstanceStore {

View File

@@ -27,6 +27,12 @@ export interface SessionMessage {
status?: "pending" | "streaming" | "completed" | "error"
}
type IncomingSessionMessage = Omit<SessionMessage, "id" | "sessionId" | "createdAt" | "updatedAt"> & {
id?: string
createdAt?: number
updatedAt?: number
}
export interface MessagePart {
type: "text" | "tool_call" | "tool_result" | "thinking" | "code"
content?: string
@@ -260,23 +266,29 @@ export class NativeSessionManager {
.filter((msg): msg is SessionMessage => msg !== undefined)
}
async addMessage(workspaceId: string, sessionId: string, message: Omit<SessionMessage, "id" | "sessionId" | "createdAt" | "updatedAt">): Promise<SessionMessage> {
async addMessage(workspaceId: string, sessionId: string, message: IncomingSessionMessage): Promise<SessionMessage> {
const store = await this.loadStore(workspaceId)
const session = store.sessions[sessionId]
if (!session) throw new Error(`Session not found: ${sessionId}`)
const now = Date.now()
const messageId = message.id ?? ulid()
const createdAt = typeof message.createdAt === "number" ? message.createdAt : now
const updatedAt = typeof message.updatedAt === "number" ? message.updatedAt : createdAt
const newMessage: SessionMessage = {
...message,
id: ulid(),
id: messageId,
sessionId,
createdAt: now,
updatedAt: now,
createdAt,
updatedAt,
}
store.messages[newMessage.id] = newMessage
if (!session.messageIds.includes(newMessage.id)) {
session.messageIds.push(newMessage.id)
session.updatedAt = now
}
session.updatedAt = updatedAt
await this.saveStore(workspaceId)
return newMessage

View File

@@ -0,0 +1,101 @@
/**
* User Context Module
* Manages the active user context for per-user config isolation
*/
import path from "path"
import os from "os"
import { existsSync, mkdirSync } from "fs"
const CONFIG_ROOT = path.join(os.homedir(), ".config", "codenomad")
const USERS_ROOT = path.join(CONFIG_ROOT, "users")
// Active user ID (set by the main process or HTTP header)
let activeUserId: string | null = null
/**
* Set the active user ID
*/
export function setActiveUserId(userId: string | null): void {
activeUserId = userId
console.log(`[UserContext] Active user set to: ${userId || "(none)"}`)
}
/**
* Get the active user ID
*/
export function getActiveUserId(): string | null {
return activeUserId
}
/**
* Get the data root for a specific user
* Falls back to global config if no user is set
*/
export function getUserDataRoot(userId?: string): string {
const effectiveUserId = userId || activeUserId
if (effectiveUserId) {
const userDir = path.join(USERS_ROOT, effectiveUserId)
return userDir
}
// Prioritize environment variable if set (from Electron)
const override = process.env.CODENOMAD_USER_DIR
if (override && override.trim().length > 0) {
return path.resolve(override)
}
// Fallback to global config root
return CONFIG_ROOT
}
/**
* Get the integrations directory for the current or specified user
*/
export function getUserIntegrationsDir(userId?: string): string {
const userRoot = getUserDataRoot(userId)
const integrationsDir = path.join(userRoot, "integrations")
// Ensure directory exists
if (!existsSync(integrationsDir)) {
try {
mkdirSync(integrationsDir, { recursive: true })
console.log(`[UserContext] Created integrations dir: ${integrationsDir}`)
} catch (e) {
console.error(`[UserContext] Failed to create integrations dir:`, e)
}
}
return integrationsDir
}
/**
* Get the instances directory for the current or specified user
*/
export function getUserInstancesDir(userId?: string): string {
const userRoot = getUserDataRoot(userId)
return path.join(userRoot, "instances")
}
/**
* Get the config file path for a specific integration
*/
export function getIntegrationConfigPath(integrationId: string, userId?: string): string {
const integrationsDir = getUserIntegrationsDir(userId)
return path.join(integrationsDir, `${integrationId}-config.json`)
}
/**
* Extract user ID from request headers
*/
export function getUserIdFromRequest(request: { headers?: Record<string, string | string[] | undefined> }): string | null {
const header = request.headers?.["x-user-id"]
if (typeof header === "string" && header.length > 0) {
return header
}
if (Array.isArray(header) && header.length > 0) {
return header[0]
}
return activeUserId
}

View File

@@ -1,14 +1,8 @@
import os from "os"
import { getUserDataRoot as getRoot, getUserInstancesDir as getInstances, getUserIntegrationsDir as getIntegrations } from "./user-context"
import path from "path"
const DEFAULT_ROOT = path.join(os.homedir(), ".config", "codenomad")
export function getUserDataRoot(): string {
const override = process.env.CODENOMAD_USER_DIR
if (override && override.trim().length > 0) {
return path.resolve(override)
}
return DEFAULT_ROOT
return getRoot()
}
export function getUserConfigPath(): string {
@@ -16,11 +10,11 @@ export function getUserConfigPath(): string {
}
export function getUserInstancesDir(): string {
return path.join(getUserDataRoot(), "instances")
return getInstances()
}
export function getUserIntegrationsDir(): string {
return path.join(getUserDataRoot(), "integrations")
return getIntegrations()
}
export function getOpencodeWorkspacesRoot(): string {

View File

@@ -45,6 +45,7 @@ import {
clearActiveParentSession,
createSession,
fetchSessions,
flushSessionPersistence,
updateSessionAgent,
updateSessionModel,
} from "./stores/sessions"
@@ -217,6 +218,7 @@ const App: Component = () => {
if (!confirmed) return
clearActiveParentSession(instanceId)
await stopInstance(instanceId)
}
@@ -244,6 +246,12 @@ const App: Component = () => {
return
}
try {
await flushSessionPersistence(instanceId)
} catch (error) {
log.error("Failed to flush session persistence before closing", error)
}
clearActiveParentSession(instanceId)
try {
@@ -462,15 +470,6 @@ const App: Component = () => {
<RemoteAccessOverlay open={remoteAccessOpen()} onClose={() => setRemoteAccessOpen(false)} />
<AlertDialog />
<Toaster
position="top-right"
gutter={16}
toastOptions={{
duration: 8000,
className: "bg-transparent border-none shadow-none p-0",
}}
/>
</div>
</>
)

View File

@@ -7,6 +7,7 @@ import QwenCodeSettings from "./settings/QwenCodeSettings"
import ZAISettings from "./settings/ZAISettings"
import OpenCodeZenSettings from "./settings/OpenCodeZenSettings"
import AntigravitySettings from "./settings/AntigravitySettings"
import ApiStatusChecker from "./settings/ApiStatusChecker"
interface AdvancedSettingsModalProps {
open: boolean
@@ -17,7 +18,7 @@ interface AdvancedSettingsModalProps {
}
const AdvancedSettingsModal: Component<AdvancedSettingsModalProps> = (props) => {
const [activeTab, setActiveTab] = createSignal("general")
const [activeTab, setActiveTab] = createSignal("api-status")
return (
<Dialog open={props.open} onOpenChange={(open) => !open && props.onClose()}>
@@ -31,6 +32,15 @@ const AdvancedSettingsModal: Component<AdvancedSettingsModalProps> = (props) =>
<div class="border-b" style={{ "border-color": "var(--border-base)" }}>
<div class="flex w-full px-6 overflow-x-auto">
<button
class={`px-4 py-2 text-sm font-medium border-b-2 whitespace-nowrap ${activeTab() === "api-status"
? "border-green-500 text-green-400"
: "border-transparent hover:border-gray-300"
}`}
onClick={() => setActiveTab("api-status")}
>
📊 API Status
</button>
<button
class={`px-4 py-2 text-sm font-medium border-b-2 whitespace-nowrap ${activeTab() === "zen"
? "border-orange-500 text-orange-400"
@@ -89,6 +99,20 @@ const AdvancedSettingsModal: Component<AdvancedSettingsModalProps> = (props) =>
</div>
<div class="flex-1 overflow-y-auto">
<Show when={activeTab() === "api-status"}>
<div class="p-6">
<ApiStatusChecker
onSettingsClick={(apiId) => {
if (apiId === "opencode-zen") setActiveTab("zen")
else if (apiId === "ollama-cloud") setActiveTab("ollama")
else if (apiId === "zai") setActiveTab("zai")
else if (apiId === "qwen-oauth") setActiveTab("qwen")
else if (apiId === "antigravity") setActiveTab("antigravity")
}}
/>
</div>
</Show>
<Show when={activeTab() === "zen"}>
<OpenCodeZenSettings />
</Show>

View File

@@ -0,0 +1,528 @@
import { Component, createSignal, onMount, For, Show } from "solid-js"
import { Lock, User, ShieldCheck, Cpu, UserPlus, KeyRound, ArrowLeft, Ghost } from "lucide-solid"
import toast from "solid-toast"
import { isElectronHost } from "../../lib/runtime-env"
import { setActiveUserId } from "../../lib/user-context"
interface UserRecord {
id: string
name: string
isGuest?: boolean
}
interface LoginViewProps {
onLoginSuccess: (user: UserRecord) => void
}
type ViewMode = "login" | "register" | "reset"
const LoginView: Component<LoginViewProps> = (props) => {
const [users, setUsers] = createSignal<UserRecord[]>([])
const [username, setUsername] = createSignal("")
const [password, setPassword] = createSignal("")
const [confirmPassword, setConfirmPassword] = createSignal("")
const [newPassword, setNewPassword] = createSignal("")
const [isLoading, setIsLoading] = createSignal(false)
const [mode, setMode] = createSignal<ViewMode>("login")
const getApi = () => {
const api = (window as any).electronAPI
return api
}
const loadUsers = async () => {
try {
if (isElectronHost()) {
const api = getApi()
if (api?.listUsers) {
const userList = await api.listUsers()
if (userList && Array.isArray(userList)) {
setUsers(userList)
if (userList.length > 0 && !username()) {
setUsername(userList[0].name)
}
}
}
}
} catch (error) {
console.error("Failed to fetch users:", error)
}
}
onMount(loadUsers)
const resetForm = () => {
setPassword("")
setConfirmPassword("")
setNewPassword("")
}
const handleLogin = async (e: Event) => {
e.preventDefault()
const name = username().trim()
if (!name) {
toast.error("Identity required")
return
}
setIsLoading(true)
try {
if (isElectronHost()) {
const api = getApi()
if (!api?.listUsers || !api?.loginUser) {
toast.error("API bridge not ready")
return
}
const userList = await api.listUsers()
const user = userList?.find((u: UserRecord) => u.name.toLowerCase() === name.toLowerCase())
if (!user) {
toast.error(`Identity "${name}" not found`)
return
}
const result = await api.loginUser({
id: user.id,
password: password(),
})
if (result?.success) {
toast.success(`Welcome back, ${result.user.name}!`)
setActiveUserId(result.user.id)
props.onLoginSuccess(result.user)
} else {
toast.error("Invalid access key")
}
} else {
toast.success("Web mode access granted")
props.onLoginSuccess({ id: "web-user", name: username() || "Web Explorer" })
}
} catch (error) {
console.error("Login failed:", error)
toast.error("Authentication failed")
} finally {
setIsLoading(false)
}
}
const handleGuestLogin = async () => {
setIsLoading(true)
try {
const api = getApi()
if (api?.createGuest) {
const guestUser = await api.createGuest()
if (guestUser?.id) {
toast.success(`Welcome, ${guestUser.name}!`)
setActiveUserId(guestUser.id)
props.onLoginSuccess(guestUser)
} else {
toast.error("Failed to create guest session")
}
} else {
// Web mode fallback
const guestId = `guest-${Date.now()}`
toast.success("Guest session started")
props.onLoginSuccess({ id: guestId, name: "Guest", isGuest: true })
}
} catch (error) {
console.error("Guest login failed:", error)
toast.error("Guest login failed")
} finally {
setIsLoading(false)
}
}
const handleRegister = async (e: Event) => {
e.preventDefault()
const name = username().trim()
if (!name) {
toast.error("Username required")
return
}
if (name.length < 3) {
toast.error("Username must be at least 3 characters")
return
}
if (!password()) {
toast.error("Password required")
return
}
if (password().length < 4) {
toast.error("Password must be at least 4 characters")
return
}
if (password() !== confirmPassword()) {
toast.error("Passwords do not match")
return
}
// Check if user already exists
const existingUser = users().find(u => u.name.toLowerCase() === name.toLowerCase())
if (existingUser) {
toast.error(`Identity "${name}" already exists`)
return
}
setIsLoading(true)
try {
const api = getApi()
if (!api?.createUser) {
toast.error("Registration unavailable")
return
}
const newUser = await api.createUser({
name: name,
password: password(),
})
if (newUser?.id) {
toast.success(`Identity "${name}" created successfully!`)
await loadUsers()
setMode("login")
setUsername(name)
resetForm()
} else {
toast.error("Failed to create identity")
}
} catch (error) {
console.error("Registration failed:", error)
toast.error("Registration failed")
} finally {
setIsLoading(false)
}
}
const handleResetPassword = async (e: Event) => {
e.preventDefault()
const name = username().trim()
if (!name) {
toast.error("Select an identity first")
return
}
if (!password()) {
toast.error("Current password required")
return
}
if (!newPassword()) {
toast.error("New password required")
return
}
if (newPassword().length < 4) {
toast.error("New password must be at least 4 characters")
return
}
const user = users().find(u => u.name.toLowerCase() === name.toLowerCase())
if (!user) {
toast.error(`Identity "${name}" not found`)
return
}
setIsLoading(true)
try {
const api = getApi()
// First verify current password
const verifyResult = await api.loginUser({
id: user.id,
password: password(),
})
if (!verifyResult?.success) {
toast.error("Current password is incorrect")
return
}
// Update password
const updateResult = await api.updateUser({
id: user.id,
password: newPassword(),
})
if (updateResult?.id) {
toast.success("Password updated successfully!")
setMode("login")
resetForm()
} else {
toast.error("Failed to update password")
}
} catch (error) {
console.error("Password reset failed:", error)
toast.error("Password reset failed")
} finally {
setIsLoading(false)
}
}
const switchMode = (newMode: ViewMode) => {
setMode(newMode)
resetForm()
if (newMode === "register") {
setUsername("")
}
}
return (
<div class="fixed inset-0 z-[9999] flex items-center justify-center bg-[#0a0a0a]">
{/* Dynamic Background */}
<div class="absolute inset-0 overflow-hidden pointer-events-none opacity-20">
<div class="absolute -top-[10%] -left-[10%] w-[40%] h-[40%] bg-blue-500/20 blur-[120px] rounded-full animate-pulse" />
<div class="absolute -bottom-[10%] -right-[10%] w-[40%] h-[40%] bg-purple-500/20 blur-[120px] rounded-full animate-pulse delay-700" />
</div>
<div class="relative w-full max-w-md px-6 py-10 bg-[#141414]/80 backdrop-blur-xl border border-white/10 rounded-3xl shadow-2xl">
{/* Logo & Header */}
<div class="flex flex-col items-center mb-8">
<div class="w-16 h-16 mb-4 bg-gradient-to-br from-blue-500 via-indigo-600 to-purple-700 p-0.5 rounded-2xl shadow-lg transform rotate-3">
<div class="w-full h-full bg-[#141414] rounded-2xl flex items-center justify-center">
<Cpu class="w-8 h-8 text-white" />
</div>
</div>
<h1 class="text-2xl font-bold text-white tracking-tight mb-1">NomadArch</h1>
<p class="text-gray-400 text-sm">
{mode() === "login" && "Secure Neural Access Point"}
{mode() === "register" && "Create New Identity"}
{mode() === "reset" && "Reset Access Key"}
</p>
</div>
{/* Back button for non-login modes */}
<Show when={mode() !== "login"}>
<button
type="button"
onClick={() => switchMode("login")}
class="flex items-center gap-2 text-gray-400 hover:text-white transition-colors mb-4"
>
<ArrowLeft class="w-4 h-4" />
<span class="text-sm">Back to login</span>
</button>
</Show>
{/* Login Form */}
<Show when={mode() === "login"}>
<form onSubmit={handleLogin} class="space-y-5">
<div class="space-y-1.5">
<label class="text-xs font-semibold text-gray-500 uppercase tracking-wider ml-1">Identity</label>
<div class="relative group">
<div class="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none">
<User class="w-5 h-5 text-gray-500 group-focus-within:text-blue-500 transition-colors" />
</div>
<input
type="text"
placeholder="Username"
value={username()}
onInput={(e) => setUsername(e.currentTarget.value)}
class="block w-full pl-12 pr-4 py-3.5 bg-[#1a1a1a] border border-white/5 rounded-2xl text-white focus:outline-none focus:ring-2 focus:ring-blue-500/50 transition-all"
list="identity-suggestions"
/>
<datalist id="identity-suggestions">
<For each={users()}>{(user) => <option value={user.name} />}</For>
</datalist>
</div>
</div>
<div class="space-y-1.5">
<label class="text-xs font-semibold text-gray-500 uppercase tracking-wider ml-1">Access Key</label>
<div class="relative group">
<div class="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none">
<Lock class="w-5 h-5 text-gray-500 group-focus-within:text-blue-500 transition-colors" />
</div>
<input
type="password"
placeholder="Password"
value={password()}
onInput={(e) => setPassword(e.currentTarget.value)}
class="block w-full pl-12 pr-4 py-3.5 bg-[#1a1a1a] border border-white/5 rounded-2xl text-white placeholder-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500/50 transition-all font-mono"
/>
</div>
</div>
<button
type="submit"
disabled={isLoading() || !username()}
class="w-full flex items-center justify-center gap-3 py-3.5 bg-gradient-to-r from-blue-600 via-indigo-600 to-purple-600 hover:from-blue-500 hover:to-purple-500 text-white font-bold rounded-2xl shadow-xl transform active:scale-[0.98] transition-all disabled:opacity-50 disabled:cursor-not-allowed"
>
<Show when={isLoading()} fallback={<><ShieldCheck class="w-5 h-5" /><span>Verify Identity</span></>}>
<div class="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
<span>Verifying...</span>
</Show>
</button>
</form>
<div class="mt-6 flex flex-col gap-4">
<button
type="button"
onClick={handleGuestLogin}
disabled={isLoading()}
class="w-full flex items-center justify-center gap-2 py-3 bg-[#1a1a1a] hover:bg-[#252525] border border-white/10 text-gray-300 hover:text-white font-medium rounded-2xl transition-all disabled:opacity-50"
>
<Ghost class="w-5 h-5" />
<span>Continue as Guest</span>
</button>
<div class="flex items-center justify-between text-sm">
<button
type="button"
onClick={() => switchMode("register")}
class="flex items-center gap-1.5 text-gray-400 hover:text-blue-400 transition-colors"
>
<UserPlus class="w-4 h-4" />
<span>Create Identity</span>
</button>
<button
type="button"
onClick={() => switchMode("reset")}
class="flex items-center gap-1.5 text-gray-400 hover:text-purple-400 transition-colors"
>
<KeyRound class="w-4 h-4" />
<span>Reset Password</span>
</button>
</div>
</div>
</Show>
{/* Register Form */}
<Show when={mode() === "register"}>
<form onSubmit={handleRegister} class="space-y-5">
<div class="space-y-1.5">
<label class="text-xs font-semibold text-gray-500 uppercase tracking-wider ml-1">Choose Username</label>
<div class="relative group">
<div class="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none">
<User class="w-5 h-5 text-gray-500 group-focus-within:text-green-500 transition-colors" />
</div>
<input
type="text"
placeholder="Enter username"
value={username()}
onInput={(e) => setUsername(e.currentTarget.value)}
class="block w-full pl-12 pr-4 py-3.5 bg-[#1a1a1a] border border-white/5 rounded-2xl text-white focus:outline-none focus:ring-2 focus:ring-green-500/50 transition-all"
/>
</div>
</div>
<div class="space-y-1.5">
<label class="text-xs font-semibold text-gray-500 uppercase tracking-wider ml-1">Choose Password</label>
<div class="relative group">
<div class="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none">
<Lock class="w-5 h-5 text-gray-500 group-focus-within:text-green-500 transition-colors" />
</div>
<input
type="password"
placeholder="Enter password"
value={password()}
onInput={(e) => setPassword(e.currentTarget.value)}
class="block w-full pl-12 pr-4 py-3.5 bg-[#1a1a1a] border border-white/5 rounded-2xl text-white placeholder-gray-600 focus:outline-none focus:ring-2 focus:ring-green-500/50 transition-all font-mono"
/>
</div>
</div>
<div class="space-y-1.5">
<label class="text-xs font-semibold text-gray-500 uppercase tracking-wider ml-1">Confirm Password</label>
<div class="relative group">
<div class="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none">
<Lock class="w-5 h-5 text-gray-500 group-focus-within:text-green-500 transition-colors" />
</div>
<input
type="password"
placeholder="Confirm password"
value={confirmPassword()}
onInput={(e) => setConfirmPassword(e.currentTarget.value)}
class="block w-full pl-12 pr-4 py-3.5 bg-[#1a1a1a] border border-white/5 rounded-2xl text-white placeholder-gray-600 focus:outline-none focus:ring-2 focus:ring-green-500/50 transition-all font-mono"
/>
</div>
</div>
<button
type="submit"
disabled={isLoading() || !username() || !password() || !confirmPassword()}
class="w-full flex items-center justify-center gap-3 py-3.5 bg-gradient-to-r from-green-600 via-emerald-600 to-teal-600 hover:from-green-500 hover:to-teal-500 text-white font-bold rounded-2xl shadow-xl transform active:scale-[0.98] transition-all disabled:opacity-50 disabled:cursor-not-allowed"
>
<Show when={isLoading()} fallback={<><UserPlus class="w-5 h-5" /><span>Create Identity</span></>}>
<div class="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
<span>Creating...</span>
</Show>
</button>
</form>
</Show>
{/* Reset Password Form */}
<Show when={mode() === "reset"}>
<form onSubmit={handleResetPassword} class="space-y-5">
<div class="space-y-1.5">
<label class="text-xs font-semibold text-gray-500 uppercase tracking-wider ml-1">Identity</label>
<div class="relative group">
<div class="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none">
<User class="w-5 h-5 text-gray-500 group-focus-within:text-purple-500 transition-colors" />
</div>
<input
type="text"
placeholder="Username"
value={username()}
onInput={(e) => setUsername(e.currentTarget.value)}
class="block w-full pl-12 pr-4 py-3.5 bg-[#1a1a1a] border border-white/5 rounded-2xl text-white focus:outline-none focus:ring-2 focus:ring-purple-500/50 transition-all"
list="identity-suggestions-reset"
/>
<datalist id="identity-suggestions-reset">
<For each={users()}>{(user) => <option value={user.name} />}</For>
</datalist>
</div>
</div>
<div class="space-y-1.5">
<label class="text-xs font-semibold text-gray-500 uppercase tracking-wider ml-1">Current Password</label>
<div class="relative group">
<div class="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none">
<Lock class="w-5 h-5 text-gray-500 group-focus-within:text-purple-500 transition-colors" />
</div>
<input
type="password"
placeholder="Enter current password"
value={password()}
onInput={(e) => setPassword(e.currentTarget.value)}
class="block w-full pl-12 pr-4 py-3.5 bg-[#1a1a1a] border border-white/5 rounded-2xl text-white placeholder-gray-600 focus:outline-none focus:ring-2 focus:ring-purple-500/50 transition-all font-mono"
/>
</div>
</div>
<div class="space-y-1.5">
<label class="text-xs font-semibold text-gray-500 uppercase tracking-wider ml-1">New Password</label>
<div class="relative group">
<div class="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none">
<KeyRound class="w-5 h-5 text-gray-500 group-focus-within:text-purple-500 transition-colors" />
</div>
<input
type="password"
placeholder="Enter new password"
value={newPassword()}
onInput={(e) => setNewPassword(e.currentTarget.value)}
class="block w-full pl-12 pr-4 py-3.5 bg-[#1a1a1a] border border-white/5 rounded-2xl text-white placeholder-gray-600 focus:outline-none focus:ring-2 focus:ring-purple-500/50 transition-all font-mono"
/>
</div>
</div>
<button
type="submit"
disabled={isLoading() || !username() || !password() || !newPassword()}
class="w-full flex items-center justify-center gap-3 py-3.5 bg-gradient-to-r from-purple-600 via-violet-600 to-fuchsia-600 hover:from-purple-500 hover:to-fuchsia-500 text-white font-bold rounded-2xl shadow-xl transform active:scale-[0.98] transition-all disabled:opacity-50 disabled:cursor-not-allowed"
>
<Show when={isLoading()} fallback={<><KeyRound class="w-5 h-5" /><span>Reset Password</span></>}>
<div class="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
<span>Resetting...</span>
</Show>
</button>
</form>
</Show>
<div class="mt-6 text-center text-xs text-gray-600">
Powered by Antigravity OS v4.5 | Encrypted Connection
</div>
</div>
</div>
)
}
export default LoginView

View File

@@ -10,6 +10,7 @@
import { createSignal, Show, onMount, For, onCleanup, batch } from "solid-js";
import toast from "solid-toast";
import { sessions, activeSessionId, setActiveSession } from "@/stores/session-state";
import { loadMessages, fetchSessions, flushSessionPersistence } from "@/stores/sessions";
import { sendMessage, compactSession, updateSessionAgent, updateSessionModelForSession, forceReset, abortSession } from "@/stores/session-actions";
import { addTask, setActiveTask, archiveTask } from "@/stores/task-actions";
import { messageStoreBus } from "@/stores/message-v2/bus";
@@ -68,6 +69,7 @@ export default function MultiXV2(props: MultiXV2Props) {
const [selectedTaskId, setSelectedTaskIdLocal] = createSignal<string | null>(null);
const [messageIds, setMessageIds] = createSignal<string[]>([]);
const [cachedModelId, setCachedModelId] = createSignal("unknown");
const [cachedProviderId, setCachedProviderId] = createSignal("");
const [cachedAgent, setCachedAgent] = createSignal("");
const [cachedTokensUsed, setCachedTokensUsed] = createSignal(0);
const [cachedCost, setCachedCost] = createSignal(0);
@@ -76,6 +78,8 @@ export default function MultiXV2(props: MultiXV2Props) {
const [soloState, setSoloState] = createSignal({ isApex: false, isAutonomous: false, autoApproval: false, activeTaskId: null as string | null });
const [lastAssistantIndex, setLastAssistantIndex] = createSignal(-1);
const [bottomSentinel, setBottomSentinel] = createSignal<HTMLDivElement | null>(null);
const [hasUserSelection, setHasUserSelection] = createSignal(false);
const forcedLoadTimestamps = new Map<string, number>();
// Helper to check if CURRENT task is sending
const isSending = () => {
@@ -139,6 +143,10 @@ export default function MultiXV2(props: MultiXV2Props) {
setVisibleTasks(allTasks.filter(t => !t.archived));
// NOTE: Don't overwrite selectedTaskId from store - local state is authoritative
// This prevents the reactive cascade when the store updates
if (!selectedTaskId() && !hasUserSelection() && allTasks.length > 0) {
const preferredId = session.activeTaskId || allTasks[0].id;
setSelectedTaskIdLocal(preferredId);
}
}
// Get message IDs for currently selected task
@@ -148,6 +156,20 @@ export default function MultiXV2(props: MultiXV2Props) {
if (task) {
const store = getMessageStore();
if (task.taskSessionId) {
const cachedIds = store.getSessionMessageIds(task.taskSessionId);
if (cachedIds.length === 0) {
const lastForced = forcedLoadTimestamps.get(task.taskSessionId) ?? 0;
if (Date.now() - lastForced > 1000) {
forcedLoadTimestamps.set(task.taskSessionId, Date.now());
loadMessages(props.instanceId, task.taskSessionId, true).catch((error) =>
log.error("Failed to load task session messages", error)
);
}
} else {
loadMessages(props.instanceId, task.taskSessionId).catch((error) =>
log.error("Failed to load task session messages", error)
);
}
setMessageIds(store.getSessionMessageIds(task.taskSessionId));
} else {
setMessageIds(task.messageIds || []);
@@ -163,6 +185,9 @@ export default function MultiXV2(props: MultiXV2Props) {
if (taskSession?.model?.modelId) {
setCachedModelId(taskSession.model.modelId);
}
if (taskSession?.model?.providerId) {
setCachedProviderId(taskSession.model.providerId);
}
if (taskSession?.agent) {
setCachedAgent(taskSession.agent);
}
@@ -216,6 +241,9 @@ export default function MultiXV2(props: MultiXV2Props) {
setSendingTasks(new Set<string>());
});
// Initialize
loadMessages(props.instanceId, props.sessionId);
fetchSessions(props.instanceId);
syncFromStore();
const interval = setInterval(syncFromStore, 150);
@@ -230,6 +258,8 @@ export default function MultiXV2(props: MultiXV2Props) {
onCleanup(() => {
clearInterval(interval);
scrollContainer?.removeEventListener('scroll', handleScroll);
// Ensure any pending task updates are saved immediately before we potentially reload them
flushSessionPersistence(props.instanceId);
});
});
@@ -245,6 +275,7 @@ export default function MultiXV2(props: MultiXV2Props) {
const setSelectedTaskId = (id: string | null) => {
// Update local state immediately (fast)
setSelectedTaskIdLocal(id);
setHasUserSelection(true);
// Immediately sync to load the new task's agent/model
syncFromStore();
@@ -298,7 +329,7 @@ export default function MultiXV2(props: MultiXV2Props) {
syncFromStore();
// Set the selected task
setSelectedTaskIdLocal(taskId);
setSelectedTaskId(taskId);
const s = soloState();
if (s.isAutonomous) {
@@ -351,7 +382,7 @@ export default function MultiXV2(props: MultiXV2Props) {
setTimeout(async () => {
try {
const result = await addTask(props.instanceId, props.sessionId, title);
setSelectedTaskIdLocal(result.id);
setSelectedTaskId(result.id);
setTimeout(() => syncFromStore(), 50);
} catch (error) {
log.error("handleCreateTask failed", error);
@@ -650,11 +681,14 @@ export default function MultiXV2(props: MultiXV2Props) {
<LiteModelSelector
instanceId={props.instanceId}
sessionId={getActiveTaskSessionId()}
currentModel={{ providerId: "", modelId: cachedModelId() }}
currentModel={{ providerId: cachedProviderId(), modelId: cachedModelId() }}
onModelChange={(model) => {
// Update the TASK's session, not a global cache
const taskSessionId = getActiveTaskSessionId();
log.info("[MultiX] Changing model for task session", { taskSessionId, model });
// Immediately update cached values for responsive UI
setCachedModelId(model.modelId);
setCachedProviderId(model.providerId);
updateSessionModelForSession(props.instanceId, taskSessionId, model);
// Force immediate sync to reflect the change
setTimeout(() => syncFromStore(), 50);

View File

@@ -36,6 +36,7 @@ import {
getSessionInfo,
sessions,
setActiveSession,
setActiveParentSession,
executeCustomCommand,
sendMessage,
runShellCommand,
@@ -66,7 +67,7 @@ import SessionView from "../session/session-view"
import { Sidebar, type FileNode } from "./sidebar"
import { Editor } from "./editor"
import { serverApi } from "../../lib/api-client"
import { Sparkles, Layout as LayoutIcon, Terminal as TerminalIcon, Search, Loader2, Zap, Shield, Settings, FileArchive } from "lucide-solid"
import { Sparkles, Layout as LayoutIcon, Terminal as TerminalIcon, Search, Loader2, Zap, Shield, Settings, FileArchive, ArrowLeft } from "lucide-solid"
import { formatTokenTotal } from "../../lib/formatters"
import { sseManager } from "../../lib/sse-manager"
import { getLogger } from "../../lib/logger"
@@ -683,7 +684,25 @@ Now analyze the project and report your findings.`
})
const handleSessionSelect = (sessionId: string) => {
if (sessionId === "info") {
setActiveSession(props.instance.id, sessionId)
return
}
const instanceSessions = sessions().get(props.instance.id)
const session = instanceSessions?.get(sessionId)
if (session?.parentId) {
setActiveParentSession(props.instance.id, session.parentId)
const parentSession = instanceSessions?.get(session.parentId)
const matchingTask = parentSession?.tasks?.find((task) => task.taskSessionId === sessionId)
if (matchingTask) {
setActiveTask(props.instance.id, session.parentId, matchingTask.id)
}
return
}
setActiveParentSession(props.instance.id, sessionId)
}
@@ -731,6 +750,7 @@ Now analyze the project and report your findings.`
const sessionsMap = activeSessions()
const parentId = parentSessionIdForInstance()
const activeId = activeSessionIdForInstance()
const instanceSessions = sessions().get(props.instance.id)
setCachedSessionIds((current) => {
const next: string[] = []
const append = (id: string | null) => {
@@ -743,6 +763,16 @@ Now analyze the project and report your findings.`
append(parentId)
append(activeId)
const parentSessionId = parentId || activeId
const parentSession = parentSessionId ? instanceSessions?.get(parentSessionId) : undefined
const activeTaskId = parentSession?.activeTaskId
if (activeTaskId && parentSession?.tasks?.length) {
const activeTask = parentSession.tasks.find((task) => task.id === activeTaskId)
if (activeTask?.taskSessionId) {
append(activeTask.taskSessionId)
}
}
const limit = parentId ? SESSION_CACHE_LIMIT + 1 : SESSION_CACHE_LIMIT
const trimmed = next.length > limit ? next.slice(0, limit) : next
const trimmedSet = new Set(trimmed)
@@ -1342,6 +1372,14 @@ Now analyze the project and report your findings.`
<div class="flex items-center space-x-4">
<Show when={activeSessionIdForInstance() && activeSessionIdForInstance() !== "info"}>
<div class="flex items-center space-x-2">
<button
onClick={() => props.onCloseSession(activeSessionIdForInstance()!)}
class="flex items-center gap-1.5 px-2.5 py-1 text-[11px] font-semibold text-zinc-400 hover:text-white hover:bg-white/10 border border-transparent hover:border-white/10 transition-all rounded-full"
title="Back to Sessions"
>
<ArrowLeft size={14} strokeWidth={2} />
<span>Back</span>
</button>
{/* Compact Button */}
<button
onClick={handleCompact}

View File

@@ -1,6 +1,8 @@
import { Component, createSignal, onMount, onCleanup, For, Show } from 'solid-js'
import { Rocket, CheckCircle, XCircle, Loader, Sparkles, LogIn, LogOut, Shield, ExternalLink, Copy } from 'lucide-solid'
import { getUserScopedKey } from '../../lib/user-storage'
import { instances } from '../../stores/instances'
import { fetchProviders } from '../../stores/session-api'
interface AntigravityModel {
id: string
@@ -22,19 +24,22 @@ interface AntigravityToken {
}
const ANTIGRAVITY_TOKEN_KEY = "antigravity_oauth_token"
const ANTIGRAVITY_PROJECT_KEY = "antigravity_project_id"
const AntigravitySettings: Component = () => {
const [models, setModels] = createSignal<AntigravityModel[]>([])
const [isLoading, setIsLoading] = createSignal(true)
const [connectionStatus, setConnectionStatus] = createSignal<'idle' | 'testing' | 'connected' | 'failed'>('idle')
const [connectionIssue, setConnectionIssue] = createSignal<{ title: string; message: string; link?: string } | null>(null)
const [authStatus, setAuthStatus] = createSignal<'unknown' | 'authenticated' | 'unauthenticated'>('unknown')
const [error, setError] = createSignal<string | null>(null)
const [projectId, setProjectId] = createSignal("")
// Device auth state
const [isAuthenticating, setIsAuthenticating] = createSignal(false)
const [deviceAuthSession, setDeviceAuthSession] = createSignal<{
sessionId: string
userCode: string
userCode?: string
verificationUrl: string
} | null>(null)
const [copied, setCopied] = createSignal(false)
@@ -43,6 +48,10 @@ const AntigravitySettings: Component = () => {
// Check stored token on mount
onMount(async () => {
const storedProjectId = window.localStorage.getItem(getUserScopedKey(ANTIGRAVITY_PROJECT_KEY))
if (storedProjectId) {
setProjectId(storedProjectId)
}
checkAuthStatus()
await loadModels()
await testConnection()
@@ -72,6 +81,48 @@ const AntigravitySettings: Component = () => {
return Date.now() < expiresAt
}
const parseSubscriptionIssue = (raw: string | null | undefined) => {
if (!raw) return null
try {
const parsed = JSON.parse(raw)
const errorPayload = parsed?.error
const message = typeof errorPayload?.message === "string" ? errorPayload.message : raw
const details = Array.isArray(errorPayload?.details) ? errorPayload.details : []
const reason = details.find((entry: any) => entry?.reason)?.reason
const helpLink = details
.flatMap((entry: any) => Array.isArray(entry?.links) ? entry.links : [])
.find((link: any) => typeof link?.url === "string")?.url
if (reason === "SUBSCRIPTION_REQUIRED" || /Gemini Code Assist license/i.test(message)) {
return {
title: "Subscription required",
message,
link: helpLink
}
}
} catch {
if (/SUBSCRIPTION_REQUIRED/i.test(raw) || /Gemini Code Assist license/i.test(raw)) {
return {
title: "Subscription required",
message: raw
}
}
}
return null
}
const getAuthHeaders = () => {
const token = getStoredToken()
const headers: Record<string, string> = {}
if (token?.access_token && isTokenValid(token)) {
headers.Authorization = `Bearer ${token.access_token}`
}
if (projectId()) {
headers["X-Antigravity-Project"] = projectId()
}
return Object.keys(headers).length > 0 ? headers : undefined
}
const checkAuthStatus = () => {
const token = getStoredToken()
if (isTokenValid(token)) {
@@ -84,7 +135,9 @@ const AntigravitySettings: Component = () => {
const loadModels = async () => {
setIsLoading(true)
try {
const response = await fetch('/api/antigravity/models')
const response = await fetch('/api/antigravity/models', {
headers: getAuthHeaders()
})
if (response.ok) {
const data = await response.json()
setModels(data.models || [])
@@ -102,12 +155,24 @@ const AntigravitySettings: Component = () => {
const testConnection = async () => {
setConnectionStatus('testing')
setConnectionIssue(null)
try {
const response = await fetch('/api/antigravity/test')
const response = await fetch('/api/antigravity/test', {
headers: getAuthHeaders()
})
if (response.ok) {
const data = await response.json()
setConnectionStatus(data.connected ? 'connected' : 'failed')
const issue = parseSubscriptionIssue(data.error)
if (issue) {
setConnectionIssue(issue)
}
} else {
const errorText = await response.text().catch(() => "")
const issue = parseSubscriptionIssue(errorText)
if (issue) {
setConnectionIssue(issue)
}
setConnectionStatus('failed')
}
} catch (err) {
@@ -115,6 +180,8 @@ const AntigravitySettings: Component = () => {
}
}
const offlineLabel = () => connectionIssue()?.title ?? "Offline"
// Start device authorization flow
const startDeviceAuth = async () => {
setIsAuthenticating(true)
@@ -126,12 +193,15 @@ const AntigravitySettings: Component = () => {
})
if (!response.ok) {
throw new Error('Failed to start authentication')
const errorData = await response.json().catch(() => ({}))
const base = errorData.error || 'Failed to start authentication'
const details = errorData.details ? ` - ${errorData.details}` : ''
throw new Error(`${base}${details}`)
}
const data = await response.json() as {
sessionId: string
userCode: string
userCode?: string
verificationUrl: string
expiresIn: number
interval: number
@@ -139,7 +209,7 @@ const AntigravitySettings: Component = () => {
setDeviceAuthSession({
sessionId: data.sessionId,
userCode: data.userCode,
userCode: data.userCode || "",
verificationUrl: data.verificationUrl
})
@@ -209,6 +279,14 @@ const AntigravitySettings: Component = () => {
setAuthStatus('authenticated')
setError(null)
loadModels()
await testConnection()
for (const instance of instances().values()) {
try {
await fetchProviders(instance.id)
} catch (refreshError) {
console.error(`Failed to refresh providers for instance ${instance.id}:`, refreshError)
}
}
return
}
@@ -253,11 +331,18 @@ const AntigravitySettings: Component = () => {
const signOut = () => {
window.localStorage.removeItem(getUserScopedKey(ANTIGRAVITY_TOKEN_KEY))
setAuthStatus('unauthenticated')
setConnectionIssue(null)
setConnectionStatus('idle')
for (const instance of instances().values()) {
fetchProviders(instance.id).catch((refreshError) => {
console.error(`Failed to refresh providers for instance ${instance.id}:`, refreshError)
})
}
}
const copyCode = async () => {
const session = deviceAuthSession()
if (session) {
if (session?.userCode) {
await navigator.clipboard.writeText(session.userCode)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
@@ -307,7 +392,7 @@ const AntigravitySettings: Component = () => {
{connectionStatus() === 'failed' && (
<span class="flex items-center gap-2 text-sm text-red-400">
<XCircle class="w-4 h-4" />
Offline
{offlineLabel()}
</span>
)}
</div>
@@ -385,6 +470,14 @@ const AntigravitySettings: Component = () => {
<Show when={deviceAuthSession()}>
<div class="bg-purple-500/10 border border-purple-500/30 rounded-lg p-4 space-y-4">
<div class="text-center">
<Show
when={Boolean(deviceAuthSession()?.userCode)}
fallback={
<p class="text-sm text-zinc-300">
Complete the sign-in in the browser window.
</p>
}
>
<p class="text-sm text-zinc-300 mb-3">
Enter this code on the Google sign-in page:
</p>
@@ -400,6 +493,7 @@ const AntigravitySettings: Component = () => {
{copied() ? <CheckCircle class="w-5 h-5 text-emerald-400" /> : <Copy class="w-5 h-5" />}
</button>
</div>
</Show>
</div>
<div class="flex items-center justify-center gap-2 text-sm text-purple-300">
@@ -427,6 +521,38 @@ const AntigravitySettings: Component = () => {
</div>
</Show>
</Show>
<div class="mt-4 flex flex-col gap-2 text-sm text-zinc-400">
<label class="text-xs uppercase tracking-wide text-zinc-500">Project ID (optional)</label>
<input
type="text"
value={projectId()}
onInput={(event) => {
const value = event.currentTarget.value.trim()
setProjectId(value)
if (typeof window !== "undefined") {
const key = getUserScopedKey(ANTIGRAVITY_PROJECT_KEY)
if (value) {
window.localStorage.setItem(key, value)
} else {
window.localStorage.removeItem(key)
}
}
}}
class="w-full bg-zinc-900/70 border border-zinc-800 rounded-lg px-3 py-2 text-sm text-white placeholder:text-zinc-600 focus:outline-none focus:ring-2 focus:ring-purple-500/50"
placeholder="e.g. my-gcp-project-id"
/>
<p class="text-xs text-zinc-500">
Set this only if your account is tied to a specific Code Assist project.
</p>
<button
type="button"
onClick={() => testConnection()}
class="w-fit px-3 py-1.5 text-xs bg-zinc-800 hover:bg-zinc-700 rounded-lg text-zinc-200"
>
Re-check connection
</button>
</div>
</div>
{/* Error Display */}
@@ -436,6 +562,23 @@ const AntigravitySettings: Component = () => {
</div>
</Show>
<Show when={connectionIssue()}>
<div class="p-4 bg-amber-500/10 border border-amber-500/30 rounded-lg text-amber-200 text-sm space-y-2">
<div class="font-semibold">{connectionIssue()?.title}</div>
<div>{connectionIssue()?.message}</div>
<Show when={connectionIssue()?.link}>
<a
href={connectionIssue()?.link}
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-2 text-amber-300 hover:text-amber-200 underline"
>
Learn more
</a>
</Show>
</div>
</Show>
{/* Models Grid */}
<div class="space-y-4">
<div class="flex items-center justify-between">

View File

@@ -0,0 +1,319 @@
import { Component, createSignal, onMount, For, Show, createEffect, on } from "solid-js"
import { CheckCircle, XCircle, Loader, RefreshCw, Settings, AlertTriangle } from "lucide-solid"
import { userFetch } from "../../lib/user-context"
interface ApiStatus {
id: string
name: string
icon: string
enabled: boolean
connected: boolean
checking: boolean
error?: string
lastChecked?: number
}
interface ApiStatusCheck {
id: string
name: string
icon: string
checkEnabled: () => Promise<boolean>
testConnection: () => Promise<boolean>
}
const API_CHECKS: ApiStatusCheck[] = [
{
id: "opencode-zen",
name: "OpenCode Zen",
icon: "🆓",
checkEnabled: async () => true, // Always available
testConnection: async () => {
try {
const res = await userFetch("/api/opencode-zen/test")
if (!res.ok) return false
const data = await res.json()
return data.connected === true
} catch {
return false
}
},
},
{
id: "ollama-cloud",
name: "Ollama Cloud",
icon: "🦙",
checkEnabled: async () => {
try {
const res = await userFetch("/api/ollama/config")
if (!res.ok) return false
const data = await res.json()
return data.config?.enabled === true
} catch {
return false
}
},
testConnection: async () => {
try {
const res = await userFetch("/api/ollama/test", { method: "POST" })
if (!res.ok) return false
const data = await res.json()
return data.connected === true
} catch {
return false
}
},
},
{
id: "zai",
name: "Z.AI Plan",
icon: "🧠",
checkEnabled: async () => {
try {
const res = await userFetch("/api/zai/config")
if (!res.ok) return false
const data = await res.json()
return data.config?.enabled === true
} catch {
return false
}
},
testConnection: async () => {
try {
const res = await userFetch("/api/zai/test", { method: "POST" })
if (!res.ok) return false
const data = await res.json()
return data.connected === true
} catch {
return false
}
},
},
{
id: "qwen-oauth",
name: "Qwen Code",
icon: "🔷",
checkEnabled: async () => {
const token = localStorage.getItem("qwen_oauth_token")
return token !== null && token.length > 0
},
testConnection: async () => {
try {
const tokenStr = localStorage.getItem("qwen_oauth_token")
if (!tokenStr) return false
const token = JSON.parse(tokenStr)
// Check if token is expired
const expiresAt = (token.created_at || 0) + (token.expires_in || 0) * 1000
return Date.now() < expiresAt
} catch {
return false
}
},
},
{
id: "antigravity",
name: "Antigravity",
icon: "🚀",
checkEnabled: async () => {
const token = localStorage.getItem("antigravity_oauth_token")
return token !== null && token.length > 0
},
testConnection: async () => {
try {
const tokenStr = localStorage.getItem("antigravity_oauth_token")
if (!tokenStr) return false
const token = JSON.parse(tokenStr)
const expiresAt = (token.created_at || 0) + (token.expires_in || 0) * 1000
return Date.now() < expiresAt
} catch {
return false
}
},
},
]
interface ApiStatusCheckerProps {
onSettingsClick?: (apiId: string) => void
compact?: boolean
}
const ApiStatusChecker: Component<ApiStatusCheckerProps> = (props) => {
const [statuses, setStatuses] = createSignal<ApiStatus[]>([])
const [isChecking, setIsChecking] = createSignal(false)
const [lastFullCheck, setLastFullCheck] = createSignal<number>(0)
const checkAllApis = async () => {
setIsChecking(true)
const results: ApiStatus[] = []
for (const api of API_CHECKS) {
setStatuses((prev) => {
const existing = prev.find((s) => s.id === api.id)
if (existing) {
return prev.map((s) => (s.id === api.id ? { ...s, checking: true } : s))
}
return [...prev, { id: api.id, name: api.name, icon: api.icon, enabled: false, connected: false, checking: true }]
})
try {
const enabled = await api.checkEnabled()
let connected = false
let error: string | undefined
if (enabled) {
try {
connected = await api.testConnection()
} catch (e) {
error = e instanceof Error ? e.message : "Connection test failed"
}
}
results.push({
id: api.id,
name: api.name,
icon: api.icon,
enabled,
connected,
checking: false,
error,
lastChecked: Date.now(),
})
} catch (e) {
results.push({
id: api.id,
name: api.name,
icon: api.icon,
enabled: false,
connected: false,
checking: false,
error: e instanceof Error ? e.message : "Check failed",
lastChecked: Date.now(),
})
}
}
setStatuses(results)
setLastFullCheck(Date.now())
setIsChecking(false)
}
onMount(() => {
checkAllApis()
})
const getStatusIcon = (status: ApiStatus) => {
if (status.checking) {
return <Loader class="w-4 h-4 animate-spin text-gray-400" />
}
if (!status.enabled) {
return <div class="w-4 h-4 rounded-full bg-gray-300 dark:bg-gray-600" />
}
if (status.connected) {
return <CheckCircle class="w-4 h-4 text-green-500" />
}
if (status.error) {
return <XCircle class="w-4 h-4 text-red-500" />
}
return <AlertTriangle class="w-4 h-4 text-yellow-500" />
}
const getStatusText = (status: ApiStatus) => {
if (status.checking) return "Checking..."
if (!status.enabled) return "Not configured"
if (status.connected) return "Connected"
if (status.error) return status.error
return "Connection failed"
}
const enabledCount = () => statuses().filter((s) => s.enabled && s.connected).length
const totalConfigured = () => statuses().filter((s) => s.enabled).length
if (props.compact) {
return (
<div class="flex items-center gap-2 px-3 py-2 bg-gray-50 dark:bg-gray-800/50 rounded-lg">
<span class="text-xs text-gray-500">APIs:</span>
<div class="flex items-center gap-1">
<For each={statuses()}>
{(status) => (
<div
class="cursor-pointer hover:scale-110 transition-transform"
title={`${status.name}: ${getStatusText(status)}`}
onClick={() => props.onSettingsClick?.(status.id)}
>
<span class="text-sm">{status.icon}</span>
<Show when={status.enabled}>
<span
class={`inline-block w-1.5 h-1.5 rounded-full ml-0.5 ${status.connected ? "bg-green-500" : status.checking ? "bg-yellow-500" : "bg-red-500"
}`}
/>
</Show>
</div>
)}
</For>
</div>
<button
class="p-1 hover:bg-gray-200 dark:hover:bg-gray-700 rounded"
onClick={checkAllApis}
disabled={isChecking()}
title="Refresh API status"
>
<RefreshCw class={`w-3 h-3 ${isChecking() ? "animate-spin" : ""}`} />
</button>
</div>
)
}
return (
<div class="space-y-4">
<div class="flex items-center justify-between">
<div>
<h3 class="text-lg font-semibold">API Connections</h3>
<p class="text-sm text-gray-500">
{enabledCount()} of {totalConfigured()} APIs connected
</p>
</div>
<button
class="flex items-center gap-2 px-3 py-1.5 text-sm bg-blue-500 hover:bg-blue-600 text-white rounded-lg disabled:opacity-50"
onClick={checkAllApis}
disabled={isChecking()}
>
<RefreshCw class={`w-4 h-4 ${isChecking() ? "animate-spin" : ""}`} />
{isChecking() ? "Checking..." : "Refresh All"}
</button>
</div>
<div class="grid gap-3">
<For each={statuses()}>
{(status) => (
<div class="flex items-center justify-between p-3 bg-gray-50 dark:bg-gray-800/50 rounded-lg border border-gray-200 dark:border-gray-700">
<div class="flex items-center gap-3">
<span class="text-xl">{status.icon}</span>
<div>
<div class="font-medium">{status.name}</div>
<div class="text-xs text-gray-500">{getStatusText(status)}</div>
</div>
</div>
<div class="flex items-center gap-2">
{getStatusIcon(status)}
<button
class="p-1.5 hover:bg-gray-200 dark:hover:bg-gray-700 rounded"
onClick={() => props.onSettingsClick?.(status.id)}
title="Configure"
>
<Settings class="w-4 h-4" />
</button>
</div>
</div>
)}
</For>
</div>
<Show when={lastFullCheck() > 0}>
<p class="text-xs text-gray-400 text-center">
Last checked: {new Date(lastFullCheck()).toLocaleTimeString()}
</p>
</Show>
</div>
)
}
export default ApiStatusChecker

View File

@@ -4,6 +4,7 @@ import { Button } from '@suid/material'
import { Cloud, CheckCircle, XCircle, Loader } from 'lucide-solid'
import { instances } from '../../stores/instances'
import { fetchProviders } from '../../stores/session-api'
import { userFetch } from '../../lib/user-context'
interface OllamaCloudConfig {
enabled: boolean
@@ -34,7 +35,7 @@ const OllamaCloudSettings: Component = () => {
// Load config on mount
onMount(async () => {
try {
const response = await fetch('/api/ollama/config')
const response = await userFetch('/api/ollama/config')
if (response.ok) {
const data = await response.json()
const maskedKey = typeof data.config?.apiKey === "string" && /^\*+$/.test(data.config.apiKey)
@@ -62,7 +63,7 @@ const OllamaCloudSettings: Component = () => {
delete payload.apiKey
}
const response = await fetch('/api/ollama/config', {
const response = await userFetch('/api/ollama/config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
@@ -101,7 +102,7 @@ const OllamaCloudSettings: Component = () => {
setConnectionStatus('testing')
try {
const response = await fetch('/api/ollama/test', {
const response = await userFetch('/api/ollama/test', {
method: 'POST'
})
@@ -140,7 +141,7 @@ const OllamaCloudSettings: Component = () => {
const loadModels = async () => {
setIsLoadingModels(true)
try {
const response = await fetch('/api/ollama/models')
const response = await userFetch('/api/ollama/models')
if (response.ok) {
const data = await response.json()
// Handle different response formats

View File

@@ -2,6 +2,7 @@ import { Component, createSignal, onMount, Show } from 'solid-js'
import toast from 'solid-toast'
import { Button } from '@suid/material'
import { Cpu, CheckCircle, XCircle, Loader, Key, ExternalLink } from 'lucide-solid'
import { userFetch } from '../../lib/user-context'
interface ZAIConfig {
enabled: boolean
@@ -19,7 +20,7 @@ const ZAISettings: Component = () => {
// Load config on mount
onMount(async () => {
try {
const response = await fetch('/api/zai/config')
const response = await userFetch('/api/zai/config')
if (response.ok) {
const data = await response.json()
setConfig(data.config)
@@ -37,7 +38,7 @@ const ZAISettings: Component = () => {
const saveConfig = async () => {
setIsLoading(true)
try {
const response = await fetch('/api/zai/config', {
const response = await userFetch('/api/zai/config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(config())
@@ -66,7 +67,7 @@ const ZAISettings: Component = () => {
setConnectionStatus('testing')
try {
const response = await fetch('/api/zai/test', {
const response = await userFetch('/api/zai/test', {
method: 'POST'
})
@@ -104,7 +105,7 @@ const ZAISettings: Component = () => {
const loadModels = async () => {
try {
const response = await fetch('/api/zai/models')
const response = await userFetch('/api/zai/models')
if (response.ok) {
const data = await response.json()
setModels(data.models.map((m: any) => m.name))
@@ -186,7 +187,7 @@ const ZAISettings: Component = () => {
<label class="block font-medium mb-2">Endpoint</label>
<input
type="text"
placeholder="https://api.z.ai/api/coding/paas/v4"
placeholder="https://api.z.ai/api"
value={config().endpoint || ''}
onChange={(e) => handleConfigChange('endpoint', e.target.value)}
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white dark:bg-gray-800"

View File

@@ -28,6 +28,7 @@ import type {
PortAvailabilityResponse,
} from "../../../server/src/api-types"
import { getLogger } from "./logger"
import { getUserHeaders } from "./user-context"
const FALLBACK_API_BASE = "http://127.0.0.1:9898"
const RUNTIME_BASE = typeof window !== "undefined" ? window.location?.origin : undefined
@@ -87,8 +88,10 @@ function logHttp(message: string, context?: Record<string, unknown>) {
async function request<T>(path: string, init?: RequestInit): Promise<T> {
const url = API_BASE_ORIGIN ? new URL(path, API_BASE_ORIGIN).toString() : path
const userHeaders = getUserHeaders()
const headers: HeadersInit = {
"Content-Type": "application/json",
...userHeaders,
...(init?.headers ?? {}),
}

View File

@@ -165,6 +165,28 @@ export const nativeSessionApi = {
return data.messages
},
async appendMessages(
workspaceId: string,
sessionId: string,
messages: Array<{
id?: string
role: "user" | "assistant" | "system" | "tool"
content?: string
createdAt?: number
updatedAt?: number
status?: "pending" | "streaming" | "completed" | "error"
}>
): Promise<NativeMessage[]> {
const response = await fetch(`${CODENOMAD_API_BASE}/api/native/workspaces/${encodeURIComponent(workspaceId)}/sessions/${encodeURIComponent(sessionId)}/messages`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ messages })
})
if (!response.ok) throw new Error("Failed to append messages")
const data = await response.json()
return data.messages
},
/**
* Import sessions from SDK mode to Native mode
*/

View File

@@ -20,8 +20,9 @@ function detectHost(): HostRuntime {
return "web"
}
const win = window as Window & { electronAPI?: unknown }
if (typeof win.electronAPI !== "undefined") {
// Check for common Electron injection patterns
const win = window as any
if (win.electronAPI || win.electron || win.ipcRenderer || win.process?.versions?.electron) {
return "electron"
}

View File

@@ -11,6 +11,7 @@ const DEFAULT_INSTANCE_DATA: InstanceData = {
messageHistory: [],
agentModelSelections: {},
sessionTasks: {},
sessionMessages: {},
}
function isDeepEqual(a: unknown, b: unknown): boolean {
@@ -157,11 +158,13 @@ export class ServerStorage {
const messageHistory = Array.isArray(source.messageHistory) ? [...source.messageHistory] : []
const agentModelSelections = { ...(source.agentModelSelections ?? {}) }
const sessionTasks = { ...(source.sessionTasks ?? {}) }
const sessionMessages = { ...(source.sessionMessages ?? {}) }
return {
...source,
messageHistory,
agentModelSelections,
sessionTasks,
sessionMessages,
}
}

View File

@@ -0,0 +1,157 @@
import { createSignal } from "solid-js"
import { isElectronHost } from "./runtime-env"
// Storage key for active user
const ACTIVE_USER_KEY = "codenomad_active_user_id"
const [isLoggedIn, setLoggedIn] = createSignal(false)
const [isInitialized, setInitialized] = createSignal(false)
export { isLoggedIn, setLoggedIn, isInitialized }
/**
* Set the active user ID
*/
export function setActiveUserId(userId: string | null): void {
if (userId) {
localStorage.setItem(ACTIVE_USER_KEY, userId)
setLoggedIn(true)
console.log(`[UserContext] Active user set to: ${userId}`)
} else {
localStorage.removeItem(ACTIVE_USER_KEY)
setLoggedIn(false)
console.log(`[UserContext] Active user cleared`)
}
}
/**
* Get the active user ID
*/
export function getActiveUserId(): string | null {
return localStorage.getItem(ACTIVE_USER_KEY)
}
/**
* Get headers with user ID for API requests
*/
export function getUserHeaders(): Record<string, string> {
const userId = getActiveUserId()
if (userId) {
return { "X-User-Id": userId }
}
return {}
}
/**
* Create fetch options with user headers
*/
export function withUserHeaders(options: RequestInit = {}): RequestInit {
const userHeaders = getUserHeaders()
if (Object.keys(userHeaders).length === 0) return options
const headers = new Headers(options.headers || {})
for (const [key, value] of Object.entries(userHeaders)) {
headers.set(key, value)
}
return {
...options,
headers,
}
}
/**
* Fetch wrapper that automatically includes user headers
*/
export async function userFetch(url: string | URL | Request, options: RequestInit = {}): Promise<Response> {
return fetch(url, withUserHeaders(options))
}
/**
* Globally patch fetch to include user headers for all internal /api/* requests
* This ensures compatibility with legacy code and 3rd party libraries.
*/
export function patchFetch(): void {
if ((window as any)._codenomad_fetch_patched) return
(window as any)._codenomad_fetch_patched = true
const originalFetch = window.fetch
window.fetch = async function (input: RequestInfo | URL, init?: RequestInit) {
let url = ""
if (typeof input === "string") {
url = input
} else if (input instanceof URL) {
url = input.toString()
} else if (input instanceof Request) {
url = input.url
}
// Only inject headers for internal API calls
if (url.startsWith("/api/") || url.includes(window.location.origin + "/api/")) {
return originalFetch(input, withUserHeaders(init))
}
return originalFetch(input, init)
}
console.log("[UserContext] Global fetch patched for /api/* requests")
}
/**
* Initialize user context from Host (Electron/Tauri) or API
* Call this on app startup
*/
export async function initializeUserContext(): Promise<void> {
console.log(`[UserContext] Initializing... host=${isElectronHost()}`)
try {
if (isElectronHost()) {
const api = (window as any).electronAPI
if (api && api.getActiveUser) {
console.log(`[UserContext] Requesting active user via api.getActiveUser()...`)
const activeUser = await api.getActiveUser()
console.log(`[UserContext] getActiveUser result:`, activeUser)
if (activeUser?.id) {
console.log(`[UserContext] Host has active session: ${activeUser.id}`)
setActiveUserId(activeUser.id)
} else {
console.log(`[UserContext] Host has no active session. Enforcing login.`)
setActiveUserId(null)
}
} else {
console.warn(`[UserContext] electronAPI.getActiveUser not found. Falling back to web mode.`)
await handleWebInit()
}
} else {
await handleWebInit()
}
} catch (error) {
console.error(`[UserContext] Critical initialization error:`, error)
setActiveUserId(null)
} finally {
setInitialized(true)
}
}
async function handleWebInit() {
console.log(`[UserContext] Web init - checking local cache...`)
const existingId = getActiveUserId()
// In "Mandatory Login" mode, we might want to clear this on every fresh load
// but for now let's see if the server validates it.
if (existingId) {
// We could verify this ID with the server here if we had a /api/users/me endpoint
// For now, let's keep it but mark it as "unverified" or just let the first API fail
console.log(`[UserContext] Found cached ID: ${existingId}. Validating session...`)
// Strategy: We want mandatory login. If this is a fresh launch, we should probably clear it.
// For Electron it's already cleared in main.ts. For Web it's tricky.
// Let's lean towards SECURITY: if no one explicitly logged in THIS RUN, show login.
// Actually, if we are in Electron and we hit this, it's because IPC failed.
// If we are in Web, we trust it for now but we'll see.
setLoggedIn(true)
} else {
console.log(`[UserContext] No cached ID found.`)
setLoggedIn(false)
}
}

View File

@@ -1,9 +1,13 @@
import { render } from "solid-js/web"
import { Show, onMount } from "solid-js"
import App from "./App"
import { ThemeProvider } from "./lib/theme"
import { ConfigProvider } from "./stores/preferences"
import { InstanceConfigProvider } from "./stores/instance-config"
import { runtimeEnv } from "./lib/runtime-env"
import LoginView from "./components/auth/LoginView"
import { isLoggedIn, initializeUserContext, patchFetch, isInitialized } from "./lib/user-context"
import { Toaster } from "solid-toast"
import "./index.css"
import "@git-diff-view/solid/styles/diff-view-pure.css"
@@ -18,8 +22,30 @@ if (typeof document !== "undefined") {
document.documentElement.dataset.runtimePlatform = runtimeEnv.platform
}
render(
() => (
const Root = () => {
onMount(() => {
patchFetch()
initializeUserContext()
})
return (
<>
<Toaster
position="top-right"
gutter={8}
toastOptions={{
style: {
background: "#1a1a1a",
color: "#fff",
border: "1px solid rgba(255,255,255,0.1)",
},
}}
/>
<Show when={isInitialized()}>
<Show
when={isLoggedIn()}
fallback={<LoginView onLoginSuccess={() => initializeUserContext()} />}
>
<ConfigProvider>
<InstanceConfigProvider>
<ThemeProvider>
@@ -27,6 +53,10 @@ render(
</ThemeProvider>
</InstanceConfigProvider>
</ConfigProvider>
),
root,
)
</Show>
</Show>
</>
)
}
render(() => <Root />, root)

View File

@@ -10,6 +10,7 @@ const DEFAULT_INSTANCE_DATA: InstanceData = {
agentModelSelections: {},
sessionTasks: {},
sessionSkills: {},
sessionMessages: {},
customAgents: [],
}
@@ -25,6 +26,7 @@ function cloneInstanceData(data?: InstanceData | null): InstanceData {
agentModelSelections: { ...(source.agentModelSelections ?? {}) },
sessionTasks: { ...(source.sessionTasks ?? {}) },
sessionSkills: { ...(source.sessionSkills ?? {}) },
sessionMessages: { ...(source.sessionMessages ?? {}) },
customAgents: Array.isArray(source.customAgents) ? [...source.customAgents] : [],
}
}

View File

@@ -19,13 +19,21 @@ import {
clearCompactionSuggestion,
type CompactionResult,
} from "./session-compaction"
import { createSession, loadMessages } from "./session-api"
import {
ANTIGRAVITY_MODEL_IDS,
createSession,
getStoredAntigravityProjectId,
getStoredAntigravityToken,
isAntigravityTokenValid,
loadMessages,
} from "./session-api"
import { showToastNotification } from "../lib/notifications"
import { QwenOAuthManager } from "../lib/integrations/qwen-oauth"
import { getUserScopedKey } from "../lib/user-storage"
import { loadSkillDetails } from "./skills"
import { serverApi } from "../lib/api-client"
import { nativeSessionApi } from "../lib/lite-mode"
import { ensureInstanceConfigLoaded, updateInstanceConfig, getInstanceConfig } from "./instance-config"
import type { Session } from "../types/session"
const log = getLogger("actions")
@@ -495,6 +503,7 @@ async function readSseStream(
if (idleTimer) clearTimeout(idleTimer)
idleTimer = setTimeout(() => {
timedOut = true
shouldStop = true
reader.cancel().catch(() => { })
}, idleTimeoutMs)
}
@@ -504,9 +513,15 @@ async function readSseStream(
let chunkCount = 0
let lastYieldTime = performance.now()
while (!shouldStop) {
const { done, value } = await reader.read()
let readResult: ReadableStreamReadResult<Uint8Array>
try {
readResult = await reader.read()
} catch (error) {
if (timedOut) break
throw error
}
const { done, value } = readResult
if (done) break
resetIdleTimer()
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split("\n")
buffer = lines.pop() || ""
@@ -516,6 +531,7 @@ async function readSseStream(
if (!trimmed.startsWith("data:")) continue
const data = trimmed.slice(5).trim()
if (!data) continue
resetIdleTimer()
if (data === "[DONE]") {
shouldStop = true
break
@@ -555,7 +571,7 @@ async function streamOllamaChat(
messageId: string,
assistantMessageId: string,
assistantPartId: string,
): Promise<void> {
): Promise<string> {
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), STREAM_TIMEOUT_MS)
@@ -682,6 +698,8 @@ async function streamOllamaChat(
isEphemeral: false,
})
})
return fullText
}
async function streamQwenChat(
@@ -695,7 +713,7 @@ async function streamQwenChat(
messageId: string,
assistantMessageId: string,
assistantPartId: string,
): Promise<void> {
): Promise<string> {
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), STREAM_TIMEOUT_MS)
@@ -831,6 +849,8 @@ async function streamQwenChat(
updatedAt: Date.now(),
isEphemeral: false,
})
return fullText
}
async function streamOpenCodeZenChat(
@@ -842,7 +862,7 @@ async function streamOpenCodeZenChat(
messageId: string,
assistantMessageId: string,
assistantPartId: string,
): Promise<void> {
): Promise<string> {
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), STREAM_TIMEOUT_MS)
@@ -978,6 +998,8 @@ async function streamOpenCodeZenChat(
updatedAt: Date.now(),
isEphemeral: false,
})
return fullText
}
async function streamZAIChat(
@@ -989,7 +1011,7 @@ async function streamZAIChat(
messageId: string,
assistantMessageId: string,
assistantPartId: string,
): Promise<void> {
): Promise<string> {
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), STREAM_TIMEOUT_MS)
@@ -1116,6 +1138,8 @@ async function streamZAIChat(
updatedAt: Date.now(),
isEphemeral: false,
})
return fullText
}
async function streamAntigravityChat(
@@ -1127,7 +1151,7 @@ async function streamAntigravityChat(
messageId: string,
assistantMessageId: string,
assistantPartId: string,
): Promise<void> {
): Promise<string> {
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), STREAM_TIMEOUT_MS)
@@ -1135,9 +1159,45 @@ async function streamAntigravityChat(
const instance = instances().get(instanceId)
const workspacePath = instance?.folder || ""
const token = getStoredAntigravityToken()
if (!token?.access_token || !isAntigravityTokenValid(token)) {
showToastNotification({
title: "Antigravity Unavailable",
message: "Please sign in with Google OAuth to use Antigravity models.",
variant: "warning",
duration: 8000,
})
const store = messageStoreBus.getOrCreate(instanceId)
store.upsertMessage({
id: messageId,
sessionId,
role: "user",
status: "error",
updatedAt: Date.now(),
})
store.upsertMessage({
id: assistantMessageId,
sessionId,
role: "assistant",
status: "error",
updatedAt: Date.now(),
isEphemeral: false,
})
return ""
}
const headers: Record<string, string> = {
"Content-Type": "application/json",
Authorization: `Bearer ${token.access_token}`,
}
const projectId = getStoredAntigravityProjectId()
if (projectId) {
headers["X-Antigravity-Project"] = projectId
}
const response = await fetch("/api/antigravity/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
headers,
signal: controller.signal,
body: JSON.stringify({
model: modelId,
@@ -1254,6 +1314,57 @@ async function streamAntigravityChat(
updatedAt: Date.now(),
isEphemeral: false,
})
return fullText
}
async function persistNativeMessages(
instanceId: string,
sessionId: string,
messages: Array<{
id: string
role: "user" | "assistant" | "system" | "tool"
content: string
createdAt: number
updatedAt: number
}>,
): Promise<void> {
try {
await nativeSessionApi.appendMessages(instanceId, sessionId, messages)
} catch (error) {
log.warn("Failed to persist native messages", { instanceId, sessionId, error })
}
}
async function persistSdkMessages(
instanceId: string,
sessionId: string,
messages: Array<{
id: string
role: "user" | "assistant" | "system" | "tool"
content?: string
createdAt?: number
updatedAt?: number
}>,
): Promise<void> {
try {
await ensureInstanceConfigLoaded(instanceId)
const existing = getInstanceConfig(instanceId).sessionMessages ?? {}
const current = existing[sessionId] ?? []
const merged = [...current]
for (const message of messages) {
if (!merged.some((entry) => entry.id === message.id)) {
merged.push(message)
}
}
merged.sort((a, b) => (a.createdAt ?? 0) - (b.createdAt ?? 0))
const trimmed = merged.length > 200 ? merged.slice(-200) : merged
await updateInstanceConfig(instanceId, (draft) => {
draft.sessionMessages = { ...(draft.sessionMessages ?? {}), [sessionId]: trimmed }
})
} catch (error) {
log.warn("Failed to persist SDK messages", { instanceId, sessionId, error })
}
}
async function sendMessage(
@@ -1267,6 +1378,8 @@ async function sendMessage(
if (!instance || !instance.client) {
throw new Error("Instance not ready")
}
const isNative = instance.binaryPath === "__nomadarch_native__"
const isSdk = !isNative
const instanceSessions = sessions().get(instanceId)
const session = instanceSessions?.get(sessionId)
@@ -1397,6 +1510,10 @@ async function sendMessage(
})
const providerId = effectiveModel.providerId
const useAntigravity =
providerId === "antigravity" ||
(providerId === "google" && ANTIGRAVITY_MODEL_IDS.has(effectiveModel.modelId))
const routingProviderId = useAntigravity ? "antigravity" : providerId
const tPre1 = performance.now()
const systemMessage = await untrack(() => mergeSystemInstructions(instanceId, sessionId, prompt))
const tPre2 = performance.now()
@@ -1404,7 +1521,7 @@ async function sendMessage(
addDebugLog(`Merge System Instructions: ${Math.round(tPre2 - tPre1)}ms`, "warn")
}
if (providerId === "ollama-cloud" || providerId === "qwen-oauth" || providerId === "opencode-zen" || providerId === "zai" || providerId === "antigravity") {
if (providerId === "ollama-cloud" || providerId === "qwen-oauth" || providerId === "opencode-zen" || providerId === "zai" || useAntigravity) {
const store = messageStoreBus.getOrCreate(instanceId)
const now = Date.now()
const assistantMessageId = createId("msg")
@@ -1436,7 +1553,7 @@ async function sendMessage(
store.setMessageInfo(assistantMessageId, {
id: assistantMessageId,
role: "assistant",
providerID: effectiveModel.providerId,
providerID: routingProviderId,
modelID: effectiveModel.modelId,
time: { created: now, completed: 0 },
} as any)
@@ -1450,10 +1567,11 @@ async function sendMessage(
})
})
let assistantText = ""
try {
if (providerId === "ollama-cloud") {
const tStream1 = performance.now()
await streamOllamaChat(
assistantText = await streamOllamaChat(
instanceId,
sessionId,
providerId,
@@ -1466,7 +1584,7 @@ async function sendMessage(
const tStream2 = performance.now()
addDebugLog(`Stream Complete: ${Math.round(tStream2 - tStream1)}ms`, "info")
} else if (providerId === "opencode-zen") {
await streamOpenCodeZenChat(
assistantText = await streamOpenCodeZenChat(
instanceId,
sessionId,
providerId,
@@ -1477,7 +1595,7 @@ async function sendMessage(
assistantPartId,
)
} else if (providerId === "zai") {
await streamZAIChat(
assistantText = await streamZAIChat(
instanceId,
sessionId,
providerId,
@@ -1487,11 +1605,11 @@ async function sendMessage(
assistantMessageId,
assistantPartId,
)
} else if (providerId === "antigravity") {
await streamAntigravityChat(
} else if (useAntigravity) {
assistantText = await streamAntigravityChat(
instanceId,
sessionId,
providerId,
routingProviderId,
effectiveModel.modelId,
externalMessages,
messageId,
@@ -1526,7 +1644,7 @@ async function sendMessage(
return messageId
}
await streamQwenChat(
assistantText = await streamQwenChat(
instanceId,
sessionId,
providerId,
@@ -1539,6 +1657,43 @@ async function sendMessage(
assistantPartId,
)
}
if (isNative) {
const completedAt = Date.now()
await persistNativeMessages(instanceId, sessionId, [
{
id: messageId,
role: "user",
content: resolvedPrompt,
createdAt: now,
updatedAt: now,
},
{
id: assistantMessageId,
role: "assistant",
content: assistantText,
createdAt: now,
updatedAt: completedAt,
},
])
} else if (isSdk) {
const completedAt = Date.now()
await persistSdkMessages(instanceId, sessionId, [
{
id: messageId,
role: "user",
content: resolvedPrompt,
createdAt: now,
updatedAt: now,
},
{
id: assistantMessageId,
role: "assistant",
content: assistantText,
createdAt: now,
updatedAt: completedAt,
},
])
}
return messageId
} catch (error: any) {
if (providerId === "opencode-zen") {
@@ -1563,26 +1718,33 @@ async function sendMessage(
updatedAt: Date.now(),
isEphemeral: false,
})
const rawErrorMessage = error?.message || "Request failed"
const normalizedErrorMessage = /aborted|abort/i.test(rawErrorMessage)
? "Request timed out. The provider may be unavailable."
: rawErrorMessage
store.setMessageInfo(assistantMessageId, {
id: assistantMessageId,
role: "assistant",
providerID: effectiveModel.providerId,
providerID: routingProviderId,
modelID: effectiveModel.modelId,
time: { created: now, completed: Date.now() },
error: { name: "UnknownError", message: error?.message || "Request failed" },
error: { name: "UnknownError", message: normalizedErrorMessage },
} as any)
const failedProvider = useAntigravity ? "antigravity" : providerId
showToastNotification({
title:
providerId === "ollama-cloud"
failedProvider === "ollama-cloud"
? "Ollama request failed"
: providerId === "zai"
: failedProvider === "zai"
? "Z.AI request failed"
: providerId === "opencode-zen"
: failedProvider === "opencode-zen"
? "OpenCode Zen request failed"
: providerId === "antigravity"
: failedProvider === "antigravity"
? "Antigravity request failed"
: "Qwen request failed",
message: error?.message || "Request failed",
: failedProvider === "qwen-oauth"
? "Qwen request failed"
: "Request failed",
message: normalizedErrorMessage,
variant: "error",
duration: 8000,
})

View File

@@ -40,6 +40,20 @@ import { getUserScopedKey } from "../lib/user-storage"
const log = getLogger("api")
type ProviderMap = Map<string, Provider>
export const ANTIGRAVITY_MODEL_IDS = new Set([
"gemini-3-pro-low",
"gemini-3-pro-high",
"gemini-3-flash",
"claude-sonnet-4-5",
"claude-sonnet-4-5-thinking-low",
"claude-sonnet-4-5-thinking-medium",
"claude-sonnet-4-5-thinking-high",
"claude-opus-4-5-thinking-low",
"claude-opus-4-5-thinking-medium",
"claude-opus-4-5-thinking-high",
"gpt-oss-120b-medium",
])
const ANTIGRAVITY_PROJECT_KEY = "antigravity_project_id"
async function fetchJson<T>(url: string): Promise<T | null> {
try {
@@ -251,7 +265,7 @@ async function fetchZAIProvider(): Promise<Provider | null> {
}
}
function getStoredAntigravityToken():
export function getStoredAntigravityToken():
| { access_token: string; expires_in: number; created_at: number }
| null {
if (typeof window === "undefined") return null
@@ -264,51 +278,52 @@ function getStoredAntigravityToken():
}
}
function isAntigravityTokenValid(token: { expires_in: number; created_at: number } | null): boolean {
export function isAntigravityTokenValid(token: { expires_in: number; created_at: number } | null): boolean {
if (!token) return false
const createdAt = token.created_at > 1e12 ? Math.floor(token.created_at / 1000) : token.created_at
const expiresAt = (createdAt + token.expires_in) * 1000 - 300000
return Date.now() < expiresAt
}
async function fetchAntigravityProvider(): Promise<Provider | null> {
// Check if user is authenticated with Antigravity (Google OAuth)
const token = getStoredAntigravityToken()
if (!isAntigravityTokenValid(token)) {
// Not authenticated - try to fetch models anyway (they show as available but require auth)
export function getStoredAntigravityProjectId(): string | undefined {
if (typeof window === "undefined") return undefined
try {
const data = await fetchJson<{ models?: Array<{ id: string; name: string; limit?: Model["limit"] }> }>(
"/api/antigravity/models",
)
const models = Array.isArray(data?.models) ? data?.models ?? [] : []
if (models.length === 0) return null
return {
id: "antigravity",
name: "Antigravity (Google OAuth)",
models: models.map((model) => ({
id: model.id,
name: model.name,
providerId: "antigravity",
limit: model.limit,
})),
defaultModelId: "gemini-3-pro-high",
}
const value = window.localStorage.getItem(getUserScopedKey(ANTIGRAVITY_PROJECT_KEY))
return value && value.trim().length > 0 ? value.trim() : undefined
} catch {
return undefined
}
}
async function fetchAntigravityProvider(): Promise<Provider | null> {
const token = getStoredAntigravityToken()
const projectId = getStoredAntigravityProjectId()
const headers: Record<string, string> = { "Content-Type": "application/json" }
if (token?.access_token) {
headers["Authorization"] = `Bearer ${token.access_token}`
}
if (projectId) {
headers["X-Antigravity-Project"] = projectId
}
try {
const response = await fetch("/api/antigravity/models", { headers })
if (!response.ok) {
// If server is down, return null to not show broken provider
return null
}
}
// User is authenticated - fetch full model list
const data = await fetchJson<{ models?: Array<{ id: string; name: string; limit?: Model["limit"] }> }>(
"/api/antigravity/models",
)
const models = Array.isArray(data?.models) ? data?.models ?? [] : []
const data = (await response.json()) as { models?: Array<{ id: string; name: string; limit?: Model["limit"] }> }
const models = Array.isArray(data?.models) ? data.models : []
// If no models returned from server (unlikely now with backend fix),
// but we can still return the provider with 0 models if we want it to show up.
// However, LiteModelSelector typically hides providers with empty models.
if (models.length === 0) return null
return {
id: "antigravity",
name: "Antigravity (Google OAuth)",
name: "Antigravity",
models: models.map((model) => ({
id: model.id,
name: model.name,
@@ -317,6 +332,10 @@ async function fetchAntigravityProvider(): Promise<Provider | null> {
})),
defaultModelId: "gemini-3-pro-high",
}
} catch (error) {
log.error("Failed to fetch Antigravity models", error)
return null
}
}
async function fetchExtraProviders(): Promise<Provider[]> {
@@ -331,20 +350,32 @@ async function fetchExtraProviders(): Promise<Provider[]> {
}
function removeDuplicateProviders(base: Provider[], extras: Provider[]): Provider[] {
// Collect all extra provider IDs and model IDs to prevent duplicates
const extraProviderIds = new Set(extras.map((provider) => provider.id))
const extraModelIds = new Set(extras.flatMap((provider) => provider.models.map((model) => model.id)))
if (!extras.some((provider) => provider.id === "opencode-zen")) {
return base
}
return base.filter((provider) => {
if (provider.id === "opencode-zen") return false
if (provider.id === "opencode" && provider.models.every((model) => extraModelIds.has(model.id))) {
// Remove base providers that have the same ID as an extra provider
// This prevents qwen-oauth, zai, ollama-cloud, antigravity duplicates
if (extraProviderIds.has(provider.id)) {
return false
}
// Special case: remove opencode if opencode-zen is present and covers all models
if (provider.id === "opencode" && extraProviderIds.has("opencode-zen") &&
provider.models.every((model) => extraModelIds.has(model.id))) {
return false
}
// Remove any qwen-related SDK providers when qwen-oauth is present
if (extraProviderIds.has("qwen-oauth") &&
(provider.id.toLowerCase().includes("qwen") ||
provider.models.some((m) => m.id.toLowerCase().includes("qwen")))) {
return false
}
return true
})
}
interface SessionForkResponse {
id: string
title?: string
@@ -486,6 +517,47 @@ async function fetchSessions(instanceId: string): Promise<void> {
})
}
const updates: Promise<unknown>[] = []
for (const [parentId, tasks] of Object.entries(sessionTasks)) {
if (!Array.isArray(tasks)) continue
for (const task of tasks as Array<{ taskSessionId?: string }>) {
const childId = task?.taskSessionId
if (!childId) continue
const childSession = sessionMap.get(childId)
if (!childSession) continue
if (childSession.parentId === parentId) continue
sessionMap.set(childId, {
...childSession,
parentId,
})
if (isNative) {
updates.push(nativeSessionApi.updateSession(instanceId, childId, { parentId }).catch(() => undefined))
}
}
}
if (updates.length > 0) {
await Promise.allSettled(updates)
}
for (const [sessionId, tasks] of Object.entries(sessionTasks)) {
if (sessionMap.has(sessionId)) continue
if (!Array.isArray(tasks) || tasks.length === 0) continue
const existingSession = existingSessions?.get(sessionId)
sessionMap.set(sessionId, {
id: sessionId,
instanceId,
title: existingSession?.title ?? "Untitled",
parentId: existingSession?.parentId ?? null,
agent: existingSession?.agent ?? "",
model: existingSession?.model ?? { providerId: "", modelId: "" },
skills: existingSession?.skills ?? [],
version: existingSession?.version ?? "0",
time: existingSession?.time ?? { created: Date.now(), updated: Date.now() },
revert: existingSession?.revert,
tasks: tasks as any[],
})
}
const validSessionIds = new Set(sessionMap.keys())
setSessions((prev) => {
@@ -950,14 +1022,37 @@ async function fetchProviders(instanceId: string): Promise<void> {
}
}
const normalizedProviders = providerList
.map((provider) => {
if (provider.id !== "google") return provider
const filteredModels = provider.models.filter((model: Model) => ANTIGRAVITY_MODEL_IDS.has(model.id))
if (filteredModels.length === 0) return null
const defaultModelId = filteredModels.some((model: Model) => model.id === provider.defaultModelId)
? provider.defaultModelId
: filteredModels[0]?.id
return {
...provider,
name: "Antigravity",
defaultModelId,
models: filteredModels,
}
})
.filter(Boolean) as typeof providerList
// Filter out Z.AI providers from SDK to use our custom routing with full message history
const filteredBaseProviders = providerList.filter((provider) =>
const filteredBaseProviders = normalizedProviders.filter((provider) =>
!provider.id.toLowerCase().includes("zai") &&
!provider.id.toLowerCase().includes("z.ai") &&
!provider.id.toLowerCase().includes("glm")
)
const extraProviders = await fetchExtraProviders()
let extraProviders = await fetchExtraProviders()
if (!isNative) {
const hasSdkAntigravity = normalizedProviders.some((provider) => provider.id === "google")
if (hasSdkAntigravity) {
extraProviders = extraProviders.filter((provider) => provider.id !== "antigravity")
}
}
const baseProviders = removeDuplicateProviders(filteredBaseProviders, extraProviders)
const mergedProviders = mergeProviders(baseProviders, extraProviders)
@@ -1047,6 +1142,45 @@ async function loadMessages(instanceId: string, sessionId: string, force = false
apiMessagesInfo = (response as any).info || {} // Assuming 'info' might be on the response object itself for some cases
}
if (!isNative) {
await ensureInstanceConfigLoaded(instanceId)
const cachedMessages = getInstanceConfig(instanceId).sessionMessages?.[sessionId] ?? []
if (cachedMessages.length > 0) {
const existingIds = new Set<string>()
for (const apiMessage of apiMessages) {
const info = apiMessage.info || apiMessage
if (info?.id) {
existingIds.add(info.id)
}
}
for (const cached of cachedMessages) {
if (!cached?.id || existingIds.has(cached.id)) continue
apiMessages.push({
id: cached.id,
role: cached.role,
content: cached.content,
createdAt: cached.createdAt,
info: {
id: cached.id,
role: cached.role,
time: { created: cached.createdAt ?? Date.now() },
},
parts: cached.content
? [{ id: `part-${cached.id}`, type: "text", text: cached.content }]
: [],
})
existingIds.add(cached.id)
}
apiMessages.sort((a, b) => {
const aInfo = a.info || a
const bInfo = b.info || b
const aTime = aInfo.time?.created ?? aInfo.createdAt ?? 0
const bTime = bInfo.time?.created ?? bInfo.createdAt ?? 0
return aTime - bTime
})
}
}
const messagesInfo = new Map<string, any>()
const messages: Message[] = apiMessages.map((apiMessage: any) => {
const info = apiMessage.info || apiMessage
@@ -1055,7 +1189,10 @@ async function loadMessages(instanceId: string, sessionId: string, force = false
messagesInfo.set(messageId, info)
const parts: any[] = (apiMessage.parts || []).map((part: any) => normalizeMessagePart(part))
let parts: any[] = (apiMessage.parts || []).map((part: any) => normalizeMessagePart(part))
if (parts.length === 0 && typeof apiMessage.content === "string" && apiMessage.content.trim().length > 0) {
parts = [normalizeMessagePart({ id: `part-${messageId}`, type: "text", text: apiMessage.content })]
}
const message: Message = {
id: messageId,

View File

@@ -171,6 +171,15 @@ function schedulePersist(instanceId: string) {
persistTimers.set(instanceId, timer)
}
async function flushSessionPersistence(instanceId: string) {
const existing = persistTimers.get(instanceId)
if (existing) {
clearTimeout(existing)
persistTimers.delete(instanceId)
}
await persistSessionTasks(instanceId)
}
async function persistSessionTasks(instanceId: string) {
try {
const instanceSessions = sessions().get(instanceId)
@@ -439,6 +448,7 @@ export {
pruneDraftPrompts,
withSession,
persistSessionTasks,
flushSessionPersistence,
setSessionCompactionState,
setSessionPendingPermission,
setActiveSession,

View File

@@ -26,6 +26,7 @@ import {
setActiveParentSession,
setActiveSession,
setSessionDraftPrompt,
flushSessionPersistence,
} from "./session-state"
import { getDefaultModel } from "./session-models"
@@ -113,5 +114,6 @@ export {
setSessionDraftPrompt,
updateSessionAgent,
updateSessionModel,
flushSessionPersistence,
}
export type { SessionInfo }

View File

@@ -3,6 +3,8 @@ import { Task, TaskStatus } from "../types/session"
import { nanoid } from "nanoid"
import { createSession } from "./session-api"
import { showToastNotification } from "../lib/notifications"
import { instances } from "./instances"
import { nativeSessionApi } from "../lib/lite-mode"
export function setActiveTask(instanceId: string, sessionId: string, taskId: string | undefined): void {
withSession(instanceId, sessionId, (session) => {
@@ -35,6 +37,16 @@ export async function addTask(
taskSession.model = { ...parentModel }
}
})
const instance = instances().get(instanceId)
if (instance?.binaryPath === "__nomadarch_native__") {
try {
await nativeSessionApi.updateSession(instanceId, taskSessionId, {
parentId: sessionId,
})
} catch (error) {
console.warn("[task-actions] Failed to persist parent session", error)
}
}
// console.log("[task-actions] task session created", { taskSessionId });
} catch (error) {
console.error("[task-actions] Failed to create session for task", error)